@danieltmn/tamnora 0.16.1 → 0.18.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 +45 -0
- package/README.md +58 -4
- package/SECURITY.md +8 -0
- package/harness/packages/cli/src/commands/mcp.js +31 -4
- package/harness/packages/cli/src/commands/repl.js +7 -2
- package/harness/packages/cli/src/commands/run.js +5 -2
- package/harness/packages/cli/src/commands/tui.js +9 -2
- package/harness/packages/cli/src/lib/runtime.js +23 -1
- package/harness/packages/cli/test/tunnel.test.js +13 -1
- package/harness/packages/core/src/agent/modes.js +3 -0
- package/harness/packages/core/src/config/index.js +3 -1
- package/harness/packages/core/src/index.js +17 -3
- package/harness/packages/core/src/mcp/catalog.js +79 -0
- package/harness/packages/core/src/mcp/index.js +14 -2
- package/harness/packages/core/src/mcp/registry.js +29 -1
- package/harness/packages/core/src/procs/detect.js +59 -8
- package/harness/packages/core/src/procs/kill.js +26 -5
- package/harness/packages/core/src/redact.js +25 -0
- package/harness/packages/core/src/skills/catalog.js +78 -0
- package/harness/packages/core/src/skills/remote.js +51 -0
- package/harness/packages/core/src/tools/executors.js +93 -2
- package/harness/packages/core/src/tools/index.js +27 -6
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/mcp.js +91 -0
- package/harness/packages/core/src/tools/permissions.js +53 -8
- package/harness/packages/core/src/tools/registry.js +14 -7
- package/harness/packages/core/src/tools/schema.js +86 -0
- package/harness/packages/core/src/tts/edge.js +28 -7
- package/harness/packages/core/src/tunnel.js +51 -5
- package/harness/packages/core/test/compact.test.js +2 -0
- package/harness/packages/core/test/labels.test.js +1 -0
- package/harness/packages/core/test/mcp-registry.test.js +37 -1
- package/harness/packages/core/test/mcp-routing.test.js +115 -0
- package/harness/packages/core/test/procs.test.js +70 -2
- package/harness/packages/core/test/redact.test.js +22 -0
- package/harness/packages/core/test/skills.test.js +11 -0
- package/harness/packages/core/test/tools.test.js +97 -3
- package/harness/packages/server/public/app.js +629 -28
- package/harness/packages/server/public/index.html +44 -2
- package/harness/packages/server/public/styles.css +106 -7
- package/harness/packages/server/src/lib/agentTools.js +45 -3
- package/harness/packages/server/src/lib/channels/telegram.js +2 -0
- package/harness/packages/server/src/lib/procs.js +9 -3
- package/harness/packages/server/src/lib/prompt.js +4 -1
- package/harness/packages/server/src/lib/questions.js +72 -0
- package/harness/packages/server/src/lib/runManager.js +29 -2
- package/harness/packages/server/src/lib/scheduler.js +12 -1
- package/harness/packages/server/src/lib/tasks.js +3 -1
- package/harness/packages/server/src/routes/chat.js +40 -3
- package/harness/packages/server/src/routes/mcp.js +54 -2
- package/harness/packages/server/src/routes/preview.js +49 -28
- package/harness/packages/server/src/routes/procs.js +20 -3
- package/harness/packages/server/src/routes/providers.js +12 -0
- package/harness/packages/server/src/routes/sessions.js +3 -1
- package/harness/packages/server/src/routes/skills.js +48 -1
- package/harness/packages/server/src/routes/state.js +8 -1
- package/harness/packages/server/src/server.js +37 -1
- package/harness/packages/server/test/preview.test.js +111 -0
- package/harness/packages/server/test/server.test.js +285 -34
- package/harness/packages/server/test/telegram-features.test.js +2 -0
- package/harness/packages/server/test/telegram-voz.test.js +2 -0
- package/harness/packages/server/test/telegram.test.js +2 -0
- package/harness/tamnora.example.json +3 -1
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@ const state = {
|
|
|
13
13
|
active: { provider: '', model: '' },
|
|
14
14
|
pendingInput: null,
|
|
15
15
|
permissionCards: new Map(),
|
|
16
|
+
questionCards: new Map(),
|
|
16
17
|
attachments: [],
|
|
17
18
|
home: null,
|
|
18
19
|
cwd: '',
|
|
@@ -32,6 +33,8 @@ const state = {
|
|
|
32
33
|
// del stream a proposito: un cliente sin stream abierto igual tiene que poder
|
|
33
34
|
// ver que la IA esta esperando una autorizacion suya.
|
|
34
35
|
pendingAsks: new Map(),
|
|
36
|
+
// Formularios interactivos (`ask_user`) esperando respuesta: askId -> { sessionId }.
|
|
37
|
+
pendingQuestions: new Map(),
|
|
35
38
|
// Ponerlo en cola para reconciliar apenas termine la que esta en curso.
|
|
36
39
|
reconcileAgain: false,
|
|
37
40
|
};
|
|
@@ -196,7 +199,7 @@ function activateTab(name) {
|
|
|
196
199
|
hideMoreMenu();
|
|
197
200
|
if (name === 'runs') refreshRuns();
|
|
198
201
|
if (name === 'providers') refreshProviders();
|
|
199
|
-
if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshMemory(); refreshSchedules(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
|
|
202
|
+
if (name === 'ajustes') { refreshMcp(); refreshMcpCatalog(); refreshSkills(); refreshSkillCatalog(); refreshMemory(); refreshSchedules(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
|
|
200
203
|
}
|
|
201
204
|
|
|
202
205
|
document.querySelectorAll('.tab[data-tab]').forEach((t) =>
|
|
@@ -824,7 +827,13 @@ const chatForm = $('#chat-form');
|
|
|
824
827
|
const chatInput = $('#chat-input');
|
|
825
828
|
const submitBtn = chatForm.querySelector('button[type="submit"]');
|
|
826
829
|
const stopBtn = $('#stop-btn');
|
|
830
|
+
const interruptBtn = $('#interrupt-btn');
|
|
827
831
|
const controllers = new Map();
|
|
832
|
+
// `whenIdle` espera a que el turno de una sesion termine (lo usa "interrumpir").
|
|
833
|
+
const idleWaiters = new Map();
|
|
834
|
+
// Sesiones donde el turno actual se esta cortando a proposito: `drainQueue` no
|
|
835
|
+
// debe arrancar un mensaje al final de ese turno (lo arranca el interruptor).
|
|
836
|
+
const interrupting = new Set();
|
|
828
837
|
const sessionsEl = $('#sessions');
|
|
829
838
|
|
|
830
839
|
let activePane = blankPane;
|
|
@@ -1015,6 +1024,7 @@ async function renderPane(id) {
|
|
|
1015
1024
|
// borra. Hay que repintarlos aca: sin esto una tarjeta de permiso se ve y
|
|
1016
1025
|
// al primer re-render (run_start, sweep, reconexion) desaparece para siempre.
|
|
1017
1026
|
for (const ask of detail.permissions ?? []) showStandalonePermission(record, ask);
|
|
1027
|
+
for (const ask of detail.questions ?? []) showStandaloneQuestion(record, ask);
|
|
1018
1028
|
return detail;
|
|
1019
1029
|
}
|
|
1020
1030
|
|
|
@@ -1515,6 +1525,152 @@ function resolvePermissionCard(entry, allow) {
|
|
|
1515
1525
|
entry.verdict.textContent = allow ? 'aprobado desde otra ventana' : 'denegado desde otra ventana';
|
|
1516
1526
|
}
|
|
1517
1527
|
|
|
1528
|
+
/* --- formularios interactivos (ask_user) ---------------------------------- */
|
|
1529
|
+
|
|
1530
|
+
function liveQuestion(askId) {
|
|
1531
|
+
const entry = state.questionCards.get(askId);
|
|
1532
|
+
return entry && entry.card.isConnected ? entry : null;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Formulario que el modelo pide con `ask_user`: en vez de preguntas numeradas en
|
|
1537
|
+
* el texto, la consola renderiza los campos y el usuario los contesta de una.
|
|
1538
|
+
*/
|
|
1539
|
+
function addQuestionCard(container, data) {
|
|
1540
|
+
const previous = state.questionCards.get(data.askId);
|
|
1541
|
+
if (previous && previous.card.isConnected) return previous.card;
|
|
1542
|
+
const card = el('div', 'question-card');
|
|
1543
|
+
if (data.title) card.appendChild(el('div', 'question-title', data.title));
|
|
1544
|
+
const form = el('form', 'question-form');
|
|
1545
|
+
const fields = [];
|
|
1546
|
+
for (const q of data.questions ?? []) {
|
|
1547
|
+
const field = el('label', 'question-field');
|
|
1548
|
+
field.appendChild(el('span', 'question-label', q.label));
|
|
1549
|
+
let input;
|
|
1550
|
+
if (q.type === 'textarea') {
|
|
1551
|
+
input = document.createElement('textarea');
|
|
1552
|
+
input.rows = 3;
|
|
1553
|
+
} else if (q.type === 'select' || q.type === 'confirm') {
|
|
1554
|
+
input = document.createElement('select');
|
|
1555
|
+
const options = q.type === 'confirm' ? ['sí', 'no'] : q.options ?? [];
|
|
1556
|
+
for (const opt of options) {
|
|
1557
|
+
const option = document.createElement('option');
|
|
1558
|
+
option.value = opt;
|
|
1559
|
+
option.textContent = opt;
|
|
1560
|
+
input.appendChild(option);
|
|
1561
|
+
}
|
|
1562
|
+
} else {
|
|
1563
|
+
input = document.createElement('input');
|
|
1564
|
+
input.type = 'text';
|
|
1565
|
+
}
|
|
1566
|
+
if (q.placeholder && 'placeholder' in input) input.placeholder = q.placeholder;
|
|
1567
|
+
input.name = q.id;
|
|
1568
|
+
input.className = 'question-input';
|
|
1569
|
+
field.appendChild(input);
|
|
1570
|
+
form.appendChild(field);
|
|
1571
|
+
fields.push({ q, input });
|
|
1572
|
+
}
|
|
1573
|
+
const actions = el('div', 'question-actions');
|
|
1574
|
+
const status = el('span', 'muted', '');
|
|
1575
|
+
const submit = button('enviar', () => {});
|
|
1576
|
+
submit.type = 'submit';
|
|
1577
|
+
submit.classList.add('btn', 'primary');
|
|
1578
|
+
actions.append(status, el('span', 'spacer'), submit);
|
|
1579
|
+
form.appendChild(actions);
|
|
1580
|
+
card.appendChild(form);
|
|
1581
|
+
(container.tools ?? container).appendChild(card);
|
|
1582
|
+
scrollMessages(paneOf(container));
|
|
1583
|
+
state.questionCards.set(data.askId, { card, fields, status });
|
|
1584
|
+
|
|
1585
|
+
const settle = (text, klass) => {
|
|
1586
|
+
card.classList.add(klass);
|
|
1587
|
+
status.textContent = text;
|
|
1588
|
+
submit.disabled = true;
|
|
1589
|
+
for (const { input } of fields) input.disabled = true;
|
|
1590
|
+
state.pendingQuestions.delete(data.askId);
|
|
1591
|
+
renderAlertSignals();
|
|
1592
|
+
};
|
|
1593
|
+
|
|
1594
|
+
form.addEventListener('submit', async (event) => {
|
|
1595
|
+
event.preventDefault();
|
|
1596
|
+
const answers = {};
|
|
1597
|
+
for (const { q, input } of fields) {
|
|
1598
|
+
answers[q.id] = q.type === 'confirm' ? input.value === 'sí' : input.value;
|
|
1599
|
+
}
|
|
1600
|
+
submit.disabled = true;
|
|
1601
|
+
try {
|
|
1602
|
+
await api(`/api/questions/${encodeURIComponent(data.askId)}`, { method: 'POST', body: { answers } });
|
|
1603
|
+
settle('enviado', 'answered');
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
if (error?.status === 404) {
|
|
1606
|
+
settle('ya no aplica', 'answered');
|
|
1607
|
+
reconcile({ immediate: true });
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
submit.disabled = false;
|
|
1611
|
+
status.textContent = `no se pudo enviar: ${error.message}`;
|
|
1612
|
+
}
|
|
1613
|
+
});
|
|
1614
|
+
return card;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/** Formulario pendiente recreado tras un reload (no se reconstruye de mensajes). */
|
|
1618
|
+
function showStandaloneQuestion(record, ask) {
|
|
1619
|
+
if (liveQuestion(ask.askId)) return;
|
|
1620
|
+
record.el.querySelector('.empty')?.remove();
|
|
1621
|
+
const holder = addMessage('assistant', '', record.el);
|
|
1622
|
+
holder.textEl.remove();
|
|
1623
|
+
addQuestionCard(holder, { askId: ask.askId, title: ask.title, questions: ask.questions });
|
|
1624
|
+
state.pendingQuestions.set(ask.askId, { sessionId: record.id });
|
|
1625
|
+
renderAlertSignals();
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/* --- botones de opcion (offer_choices) ------------------------------------ */
|
|
1629
|
+
|
|
1630
|
+
function sendChoice(label) {
|
|
1631
|
+
chatInput.value = label;
|
|
1632
|
+
sendMessage();
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** Botones tappables: al tocar uno se manda como mensaje del usuario. */
|
|
1636
|
+
function addChoiceCard(container, { question, options } = {}) {
|
|
1637
|
+
const items = Array.isArray(options) ? options.map((o) => String(o).trim()).filter(Boolean).slice(0, 6) : [];
|
|
1638
|
+
if (!items.length || !container) return null;
|
|
1639
|
+
const card = el('div', 'choice-card');
|
|
1640
|
+
if (question) card.appendChild(el('div', 'choice-question', question));
|
|
1641
|
+
const row = el('div', 'choice-options');
|
|
1642
|
+
for (const label of items) row.appendChild(button(label, () => sendChoice(label), 'btn choice-btn'));
|
|
1643
|
+
card.appendChild(row);
|
|
1644
|
+
(container.tools ?? container).appendChild(card);
|
|
1645
|
+
scrollMessages(paneOf(container));
|
|
1646
|
+
return card;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/* --- plan/checklist (show_plan) ------------------------------------------- */
|
|
1650
|
+
|
|
1651
|
+
const PLAN_MARKS = { done: '✓', active: '◐', pending: '○' };
|
|
1652
|
+
|
|
1653
|
+
/** Checklist interactivo: el modelo lo reemite para actualizar el estado. */
|
|
1654
|
+
function addPlanCard(container, { title, steps } = {}) {
|
|
1655
|
+
const items = Array.isArray(steps)
|
|
1656
|
+
? steps.filter((s) => s && String(s.text ?? '').trim()).slice(0, 20)
|
|
1657
|
+
: [];
|
|
1658
|
+
if (!items.length || !container) return null;
|
|
1659
|
+
const card = el('div', 'plan-card');
|
|
1660
|
+
if (title) card.appendChild(el('div', 'plan-title', String(title)));
|
|
1661
|
+
const list = el('ul', 'plan-steps');
|
|
1662
|
+
for (const step of items) {
|
|
1663
|
+
const status = ['done', 'active', 'pending'].includes(step.status) ? step.status : 'pending';
|
|
1664
|
+
const li = el('li', `plan-step ${status}`);
|
|
1665
|
+
li.append(el('span', 'plan-mark', PLAN_MARKS[status]), el('span', 'plan-text', String(step.text)));
|
|
1666
|
+
list.appendChild(li);
|
|
1667
|
+
}
|
|
1668
|
+
card.appendChild(list);
|
|
1669
|
+
(container.tools ?? container).appendChild(card);
|
|
1670
|
+
scrollMessages(paneOf(container));
|
|
1671
|
+
return card;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1518
1674
|
function userText(content) {
|
|
1519
1675
|
if (typeof content === 'string') return content;
|
|
1520
1676
|
if (Array.isArray(content)) {
|
|
@@ -1534,11 +1690,15 @@ function userImages(content) {
|
|
|
1534
1690
|
.filter(Boolean);
|
|
1535
1691
|
}
|
|
1536
1692
|
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1693
|
+
const ADVISOR_KINDS = {
|
|
1694
|
+
code: { label: 'Cuidado!', glyph: '⚠' },
|
|
1695
|
+
explain: { label: 'Importante!', glyph: 'ℹ' },
|
|
1696
|
+
advice: { label: 'Consejo', glyph: '✦' },
|
|
1697
|
+
summary: { label: 'Resumen', glyph: '✎' },
|
|
1698
|
+
};
|
|
1699
|
+
|
|
1700
|
+
function advisorMeta(kind) {
|
|
1701
|
+
return ADVISOR_KINDS[kind] || { label: 'Asesor', glyph: '✦' };
|
|
1542
1702
|
}
|
|
1543
1703
|
|
|
1544
1704
|
/** Tarjeta del asesor: una capa de comentario, no un mensaje del agente. */
|
|
@@ -1547,9 +1707,15 @@ function addAdvisorCard(container, { text, kind } = {}) {
|
|
|
1547
1707
|
if (!clean || !container) return null;
|
|
1548
1708
|
const exists = [...container.querySelectorAll('.advisor-text')].some((node) => node.textContent === clean);
|
|
1549
1709
|
if (exists) return null;
|
|
1550
|
-
const
|
|
1710
|
+
const meta = advisorMeta(kind);
|
|
1711
|
+
const klass = ADVISOR_KINDS[kind] ? ` advisor-${kind}` : '';
|
|
1712
|
+
const card = el('div', `advisor-card${klass}`);
|
|
1551
1713
|
const head = el('div', 'advisor-head');
|
|
1552
|
-
head.append(
|
|
1714
|
+
head.append(
|
|
1715
|
+
el('span', 'advisor-glyph', meta.glyph),
|
|
1716
|
+
el('span', 'advisor-title', 'asesor'),
|
|
1717
|
+
el('span', `advisor-badge advisor-badge-${kind || 'advice'}`, meta.label)
|
|
1718
|
+
);
|
|
1553
1719
|
card.append(head, el('div', 'advisor-text', clean));
|
|
1554
1720
|
container.appendChild(card);
|
|
1555
1721
|
return card;
|
|
@@ -1580,11 +1746,14 @@ function renderStored(message, pane) {
|
|
|
1580
1746
|
}
|
|
1581
1747
|
for (const call of message.tool_calls ?? []) {
|
|
1582
1748
|
const name = call.function?.name ?? call.name;
|
|
1583
|
-
const
|
|
1749
|
+
const args = parseToolArgs(call);
|
|
1750
|
+
const tool = addToolCard(bubble, name, args, { callId: call.id, open: false });
|
|
1584
1751
|
tool.card.classList.remove('running');
|
|
1585
1752
|
tool.card.classList.add('done');
|
|
1586
1753
|
tool.glyph.textContent = '✓';
|
|
1587
1754
|
if (call.id) toolCardIndex.set(call.id, tool);
|
|
1755
|
+
if (name === 'offer_choices') addChoiceCard(bubble, { question: args.question, options: args.options });
|
|
1756
|
+
if (name === 'show_plan') addPlanCard(bubble, { title: args.title, steps: args.steps });
|
|
1588
1757
|
}
|
|
1589
1758
|
} else if (message.role === 'tool') {
|
|
1590
1759
|
const tool = toolCardIndex.get(message.tool_call_id);
|
|
@@ -1774,7 +1943,7 @@ async function removeProject(cwd, count) {
|
|
|
1774
1943
|
|
|
1775
1944
|
/* --- session tabs --------------------------------------------------------- */
|
|
1776
1945
|
|
|
1777
|
-
function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, running = false, permissions = []) {
|
|
1946
|
+
function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, running = false, permissions = [], questions = []) {
|
|
1778
1947
|
const id = session.id;
|
|
1779
1948
|
if (state.panes.has(id)) return state.panes.get(id);
|
|
1780
1949
|
const pane = document.createElement('div');
|
|
@@ -1801,6 +1970,7 @@ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, run
|
|
|
1801
1970
|
// sesion. Sin esto, recargar con un permiso en el aire dejaba el run
|
|
1802
1971
|
// bloqueado esperando una decision que no se podia tomar.
|
|
1803
1972
|
for (const ask of permissions) showStandalonePermission(record, ask);
|
|
1973
|
+
for (const ask of questions) showStandaloneQuestion(record, ask);
|
|
1804
1974
|
return record;
|
|
1805
1975
|
}
|
|
1806
1976
|
|
|
@@ -1862,7 +2032,7 @@ async function openTab(id) {
|
|
|
1862
2032
|
return null;
|
|
1863
2033
|
});
|
|
1864
2034
|
if (!data) return;
|
|
1865
|
-
registerSession(data.session, data.messages, data.tokens, data.running, data.permissions);
|
|
2035
|
+
registerSession(data.session, data.messages, data.tokens, data.running, data.permissions, data.questions);
|
|
1866
2036
|
activateSession(id);
|
|
1867
2037
|
}
|
|
1868
2038
|
|
|
@@ -1874,6 +2044,7 @@ function activateSession(id) {
|
|
|
1874
2044
|
showPane(id);
|
|
1875
2045
|
if (record.cwd) setCwd(record.cwd);
|
|
1876
2046
|
if (stopBtn) stopBtn.hidden = !record.working;
|
|
2047
|
+
syncInterruptBtn();
|
|
1877
2048
|
auditStatus(record);
|
|
1878
2049
|
updateStatus();
|
|
1879
2050
|
renderTokens();
|
|
@@ -1891,6 +2062,39 @@ function updateSubmitLabel(working) {
|
|
|
1891
2062
|
submitBtn.title = working ? 'El agente está trabajando: se envía al terminar' : 'enviar';
|
|
1892
2063
|
}
|
|
1893
2064
|
|
|
2065
|
+
/** El boton de interrumpir solo tiene sentido con un turno en curso y texto que mandar. */
|
|
2066
|
+
function syncInterruptBtn() {
|
|
2067
|
+
if (!interruptBtn) return;
|
|
2068
|
+
const record = state.panes.get(state.sessionId);
|
|
2069
|
+
const working = Boolean(record?.working);
|
|
2070
|
+
const hasInput = Boolean(chatInput.value.trim()) || state.attachments.length > 0;
|
|
2071
|
+
interruptBtn.hidden = !working;
|
|
2072
|
+
interruptBtn.disabled = !working || !hasInput;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
/** Promesa que resuelve cuando el turno de la sesion deja de estar activo. */
|
|
2076
|
+
function whenIdle(sessionKey, timeoutMs = 8000) {
|
|
2077
|
+
const record = state.panes.get(sessionKey);
|
|
2078
|
+
if (!record?.working) return Promise.resolve();
|
|
2079
|
+
return new Promise((resolve) => {
|
|
2080
|
+
let timer = null;
|
|
2081
|
+
const settle = () => {
|
|
2082
|
+
if (timer) clearTimeout(timer);
|
|
2083
|
+
const list = idleWaiters.get(sessionKey);
|
|
2084
|
+
if (list) {
|
|
2085
|
+
const index = list.indexOf(settle);
|
|
2086
|
+
if (index >= 0) list.splice(index, 1);
|
|
2087
|
+
if (!list.length) idleWaiters.delete(sessionKey);
|
|
2088
|
+
}
|
|
2089
|
+
resolve();
|
|
2090
|
+
};
|
|
2091
|
+
timer = setTimeout(settle, timeoutMs);
|
|
2092
|
+
const list = idleWaiters.get(sessionKey) ?? [];
|
|
2093
|
+
list.push(settle);
|
|
2094
|
+
idleWaiters.set(sessionKey, list);
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
|
|
1894
2098
|
function auditStatus(record) {
|
|
1895
2099
|
submitBtn.disabled = false;
|
|
1896
2100
|
updateSubmitLabel(record.working);
|
|
@@ -1941,10 +2145,18 @@ function setWorking(id, working) {
|
|
|
1941
2145
|
// solo se recupera recargando.
|
|
1942
2146
|
if (record) record.working = record.attached = working;
|
|
1943
2147
|
renderTabs();
|
|
2148
|
+
if (!working) {
|
|
2149
|
+
const waiters = idleWaiters.get(id);
|
|
2150
|
+
if (waiters) {
|
|
2151
|
+
idleWaiters.delete(id);
|
|
2152
|
+
for (const settle of [...waiters]) settle();
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
1944
2155
|
if (id === state.sessionId) {
|
|
1945
2156
|
submitBtn.disabled = false;
|
|
1946
2157
|
updateSubmitLabel(working);
|
|
1947
2158
|
if (stopBtn) stopBtn.hidden = !working;
|
|
2159
|
+
syncInterruptBtn();
|
|
1948
2160
|
$('#sb-rounds').textContent = working ? '' : 'listo';
|
|
1949
2161
|
$('#sb-rounds').classList.toggle('running', working);
|
|
1950
2162
|
renderFooterActivity(working ? 'thinking' : null);
|
|
@@ -1957,7 +2169,7 @@ async function restoreTabs() {
|
|
|
1957
2169
|
for (const id of ids) {
|
|
1958
2170
|
if (state.panes.has(id)) continue;
|
|
1959
2171
|
const data = await api(`/api/sessions/${id}`).catch(() => null);
|
|
1960
|
-
if (data) registerSession(data.session, data.messages, data.tokens, data.running, data.permissions);
|
|
2172
|
+
if (data) registerSession(data.session, data.messages, data.tokens, data.running, data.permissions, data.questions);
|
|
1961
2173
|
}
|
|
1962
2174
|
const active = saved?.active && state.panes.has(saved.active) ? saved.active : state.tabOrder[state.tabOrder.length - 1];
|
|
1963
2175
|
if (active) activateSession(active);
|
|
@@ -2158,11 +2370,35 @@ function handleChatEvent(event, data, turn) {
|
|
|
2158
2370
|
}
|
|
2159
2371
|
if (turn.live && data.name === 'preview_url' && data.args?.url) {
|
|
2160
2372
|
setExplorerMode('preview');
|
|
2161
|
-
|
|
2373
|
+
// Desde otro dispositivo, 127.0.0.1 apunta al dispositivo: si el agente
|
|
2374
|
+
// pidio una URL local, la publicamos por tailscale/tunel.
|
|
2375
|
+
const port = isLocalHost() ? 0 : localPortOf(data.args.url);
|
|
2376
|
+
if (port) openRemoteTunnel(port);
|
|
2377
|
+
else loadPreview(data.args.url);
|
|
2378
|
+
}
|
|
2379
|
+
if (turn.live && data.name === 'offer_choices') {
|
|
2380
|
+
addChoiceCard(target, { question: data.args?.question, options: data.args?.options });
|
|
2381
|
+
}
|
|
2382
|
+
if (turn.live && data.name === 'show_plan') {
|
|
2383
|
+
addPlanCard(target, { title: data.args?.title, steps: data.args?.steps });
|
|
2162
2384
|
}
|
|
2163
2385
|
} else if (event === 'tool_result') {
|
|
2164
2386
|
setActivity(turn.assistant(), 'thinking');
|
|
2165
2387
|
finishToolCard(turn.lastTool, data);
|
|
2388
|
+
} else if (event === 'question') {
|
|
2389
|
+
if (!liveQuestion(data.askId)) addQuestionCard(turn.assistant(), data);
|
|
2390
|
+
state.pendingQuestions.set(data.askId, { sessionId: data.sessionId ?? turn.sessionKey });
|
|
2391
|
+
renderAlertSignals();
|
|
2392
|
+
if (paneOf(turn.active) !== activePane) toast('Formulario pendiente en otra pestaña.');
|
|
2393
|
+
} else if (event === 'question_answered') {
|
|
2394
|
+
state.pendingQuestions.delete(data?.askId);
|
|
2395
|
+
renderAlertSignals();
|
|
2396
|
+
const entry = state.questionCards.get(data.askId);
|
|
2397
|
+
if (entry && !entry.card.classList.contains('answered')) {
|
|
2398
|
+
entry.card.classList.add('answered');
|
|
2399
|
+
entry.status.textContent = data?.cancelled ? 'cancelado' : 'respondido en otra ventana';
|
|
2400
|
+
for (const { input } of entry.fields) input.disabled = true;
|
|
2401
|
+
}
|
|
2166
2402
|
} else if (event === 'permission') {
|
|
2167
2403
|
// Si la tarjeta ya esta en el DOM (la creo `renderPane` antes del replay, o
|
|
2168
2404
|
// llego por el canal global) no hay que crear OTRO mensaje del asistente
|
|
@@ -2361,7 +2597,7 @@ function contextTokensForRequest() {
|
|
|
2361
2597
|
return context ? Math.floor(context * 0.8) : undefined;
|
|
2362
2598
|
}
|
|
2363
2599
|
|
|
2364
|
-
async function startTurn(sessionKey, pane, input, images) {
|
|
2600
|
+
async function startTurn(sessionKey, pane, input, images, opts = {}) {
|
|
2365
2601
|
const user = addMessage('user', input || `(${images.length} imagen${images.length > 1 ? 'es' : ''})`, pane);
|
|
2366
2602
|
appendImages(user, images);
|
|
2367
2603
|
const turn = createChatTurn(pane, sessionKey);
|
|
@@ -2386,6 +2622,7 @@ async function startTurn(sessionKey, pane, input, images) {
|
|
|
2386
2622
|
mode: modeSelect.value || undefined,
|
|
2387
2623
|
yes: yesInput.checked ? true : undefined,
|
|
2388
2624
|
contextTokens: contextTokensForRequest(),
|
|
2625
|
+
interrupt: opts.interrupt ? true : undefined,
|
|
2389
2626
|
},
|
|
2390
2627
|
(event, data, id) => {
|
|
2391
2628
|
if (id !== null) turn.cursor = Number(id) || turn.cursor;
|
|
@@ -2463,6 +2700,9 @@ function renderQueue(sessionKey = state.sessionId) {
|
|
|
2463
2700
|
function drainQueue(sessionKey) {
|
|
2464
2701
|
const record = state.panes.get(sessionKey);
|
|
2465
2702
|
if (!record || record.working) return;
|
|
2703
|
+
// Mientras se interrumpe, el mensaje que manda el usuario lo arranca
|
|
2704
|
+
// `interruptAndSend`, no la cola.
|
|
2705
|
+
if (interrupting.has(sessionKey)) return;
|
|
2466
2706
|
const list = queueFor(sessionKey);
|
|
2467
2707
|
if (!list.length) return;
|
|
2468
2708
|
const next = list.shift();
|
|
@@ -2530,11 +2770,13 @@ async function sendMessage() {
|
|
|
2530
2770
|
}
|
|
2531
2771
|
|
|
2532
2772
|
// Si el agente está trabajando, el mensaje queda en cola y se envía solo.
|
|
2773
|
+
// Para cortarlo y mandar el mensaje ya, está el botón/atajo de interrumpir.
|
|
2533
2774
|
if (current?.working) {
|
|
2534
2775
|
enqueueMessage(state.sessionId, input, images);
|
|
2535
2776
|
chatInput.value = '';
|
|
2536
2777
|
state.attachments = [];
|
|
2537
2778
|
renderAttachments();
|
|
2779
|
+
syncInterruptBtn();
|
|
2538
2780
|
return;
|
|
2539
2781
|
}
|
|
2540
2782
|
|
|
@@ -2572,12 +2814,55 @@ if (stopBtn) {
|
|
|
2572
2814
|
});
|
|
2573
2815
|
}
|
|
2574
2816
|
|
|
2817
|
+
/**
|
|
2818
|
+
* Interrumpe el turno en curso y manda `input`/`images` ya mismo.
|
|
2819
|
+
*
|
|
2820
|
+
* Marcamos la sesion en `interrupting` para que el `finally` del turno viejo no
|
|
2821
|
+
* dispare la cola (lo arrancamos nosotros). Esperamos a que el turno anterior se
|
|
2822
|
+
* desenrolle antes de empezar el nuevo; el server, por su lado, cancela el run y
|
|
2823
|
+
* espera a que termine antes de aceptar el POST con `interrupt: true`.
|
|
2824
|
+
*/
|
|
2825
|
+
async function interruptAndSend(sessionKey, input, images) {
|
|
2826
|
+
const record = state.panes.get(sessionKey);
|
|
2827
|
+
if (!record) return;
|
|
2828
|
+
interrupting.add(sessionKey);
|
|
2829
|
+
controllers.get(sessionKey)?.abort();
|
|
2830
|
+
await whenIdle(sessionKey);
|
|
2831
|
+
const promise = startTurn(sessionKey, record.el, input, images, { interrupt: true });
|
|
2832
|
+
// startTurn ya arranco (working=true y controller nuevo); soltamos el guardia
|
|
2833
|
+
// para que SU finally pueda drenar la cola.
|
|
2834
|
+
interrupting.delete(sessionKey);
|
|
2835
|
+
await promise;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
function sendInterrupt() {
|
|
2839
|
+
const record = state.panes.get(state.sessionId);
|
|
2840
|
+
if (!record?.working) return;
|
|
2841
|
+
const input = chatInput.value.trim();
|
|
2842
|
+
// Los comandos (`/…`, `!…`) se resuelven por su camino normal, no interrumpen.
|
|
2843
|
+
if (input.startsWith('/') || input.startsWith('!')) return sendMessage();
|
|
2844
|
+
const images = state.attachments.slice();
|
|
2845
|
+
if (!input && !images.length) return;
|
|
2846
|
+
chatInput.value = '';
|
|
2847
|
+
state.attachments = [];
|
|
2848
|
+
renderAttachments();
|
|
2849
|
+
syncInterruptBtn();
|
|
2850
|
+
interruptAndSend(state.sessionId, input, images);
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
if (interruptBtn) interruptBtn.addEventListener('click', sendInterrupt);
|
|
2854
|
+
|
|
2575
2855
|
chatForm.addEventListener('submit', (event) => {
|
|
2576
2856
|
event.preventDefault();
|
|
2577
2857
|
sendMessage();
|
|
2578
2858
|
});
|
|
2579
2859
|
chatInput.addEventListener('keydown', (event) => {
|
|
2580
2860
|
if (handleSlashKey(event)) return;
|
|
2861
|
+
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
|
2862
|
+
event.preventDefault();
|
|
2863
|
+
sendInterrupt();
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2581
2866
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
2582
2867
|
event.preventDefault();
|
|
2583
2868
|
sendMessage();
|
|
@@ -3041,7 +3326,8 @@ const COMMANDS = [
|
|
|
3041
3326
|
['/compact', 'resumir la conversación y liberar contexto'],
|
|
3042
3327
|
['/clear', 'borrar los mensajes de esta sesión'],
|
|
3043
3328
|
['/cwd', 'mostrar la carpeta de trabajo'],
|
|
3044
|
-
['/
|
|
3329
|
+
['/port <puerto>', 'ver si un puerto está activo (y qué PID lo usa)'],
|
|
3330
|
+
['/portkill <puerto>', 'matar el proceso que escucha en un puerto'],
|
|
3045
3331
|
['/help', 'mostrar esta ayuda'],
|
|
3046
3332
|
];
|
|
3047
3333
|
|
|
@@ -3104,13 +3390,14 @@ async function runCommand(name, arg) {
|
|
|
3104
3390
|
notify(`Carpeta de trabajo: \`${cwd}\``);
|
|
3105
3391
|
return { handled: true };
|
|
3106
3392
|
}
|
|
3393
|
+
case 'portkill':
|
|
3107
3394
|
case 'kill': {
|
|
3108
3395
|
const port = Number(arg);
|
|
3109
3396
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
3110
|
-
notify('Uso: `/
|
|
3397
|
+
notify('Uso: `/portkill <puerto>`, por ejemplo `/portkill 3000`.', 'notice err');
|
|
3111
3398
|
return { handled: true };
|
|
3112
3399
|
}
|
|
3113
|
-
const out = await api('/api/
|
|
3400
|
+
const out = await api('/api/ports/kill', { method: 'POST', body: { port } }).catch((error) => ({
|
|
3114
3401
|
error: error.message,
|
|
3115
3402
|
}));
|
|
3116
3403
|
if (out.error) notify(`No se pudo matar el puerto ${port}: ${out.error}`, 'notice err');
|
|
@@ -3119,6 +3406,18 @@ async function runCommand(name, arg) {
|
|
|
3119
3406
|
else notify(`Puerto ${port}: no se pudo matar (PID ${out.errors.join(', ')}).`, 'notice err');
|
|
3120
3407
|
return { handled: true };
|
|
3121
3408
|
}
|
|
3409
|
+
case 'port': {
|
|
3410
|
+
const port = Number(arg);
|
|
3411
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
3412
|
+
notify('Uso: `/port <puerto>`, por ejemplo `/port 3000`.', 'notice err');
|
|
3413
|
+
return { handled: true };
|
|
3414
|
+
}
|
|
3415
|
+
const out = await api(`/api/ports/${port}`).catch((error) => ({ error: error.message }));
|
|
3416
|
+
if (out.error) notify(`No se pudo consultar el puerto ${port}: ${out.error}`, 'notice err');
|
|
3417
|
+
else if (out.listening) notify(`Puerto ${port}: **activo** (PID ${out.pids.join(', ')}).`);
|
|
3418
|
+
else notify(`Puerto ${port}: nada escuchando.`);
|
|
3419
|
+
return { handled: true };
|
|
3420
|
+
}
|
|
3122
3421
|
case 'clear':
|
|
3123
3422
|
case 'limpiar': {
|
|
3124
3423
|
if (!state.sessionId) return needSession();
|
|
@@ -3274,6 +3573,7 @@ function handleSlashKey(event) {
|
|
|
3274
3573
|
function onChatInputChanged() {
|
|
3275
3574
|
slashIndex = 0;
|
|
3276
3575
|
updateSlashMenu();
|
|
3576
|
+
syncInterruptBtn();
|
|
3277
3577
|
}
|
|
3278
3578
|
|
|
3279
3579
|
chatInput.addEventListener('blur', () => setTimeout(hideSlashMenu, 120));
|
|
@@ -3718,9 +4018,16 @@ providerModal.addEventListener('click', (event) => {
|
|
|
3718
4018
|
/* --- ajustes: MCP + Skills ----------------------------------------------- */
|
|
3719
4019
|
|
|
3720
4020
|
const mcpListEl = $('#mcp-list');
|
|
4021
|
+
const mcpSearchResultsEl = $('#mcp-search-results');
|
|
4022
|
+
const mcpCatalogEl = $('#mcp-catalog');
|
|
3721
4023
|
const mcpModal = $('#mcp-modal');
|
|
3722
4024
|
const mcpForm = $('#mcp-form');
|
|
4025
|
+
const mcpModeEl = $('#mcp-mode');
|
|
4026
|
+
const mcpCountEl = $('#mcp-count');
|
|
4027
|
+
const mcpPinnedEl = $('#mcp-pinned');
|
|
3723
4028
|
const skillListEl = $('#skill-list');
|
|
4029
|
+
const skillSearchResultsEl = $('#skill-search-results');
|
|
4030
|
+
const skillCatalogEl = $('#skill-catalog');
|
|
3724
4031
|
const skillModal = $('#skill-modal');
|
|
3725
4032
|
const skillForm = $('#skill-form');
|
|
3726
4033
|
const memoryListEl = $('#memory-list');
|
|
@@ -3815,6 +4122,16 @@ function setStatus(node, text, cls = '') {
|
|
|
3815
4122
|
async function refreshMcp({ connect = false } = {}) {
|
|
3816
4123
|
const data = await api(`/api/mcp${connect ? '?connect=1' : ''}`).catch(() => null);
|
|
3817
4124
|
if (!data) return;
|
|
4125
|
+
if (mcpModeEl && data.mode) mcpModeEl.value = data.mode;
|
|
4126
|
+
if (mcpPinnedEl && Array.isArray(data.pinned) && document.activeElement !== mcpPinnedEl) {
|
|
4127
|
+
mcpPinnedEl.value = data.pinned.join(', ');
|
|
4128
|
+
}
|
|
4129
|
+
if (mcpCountEl) {
|
|
4130
|
+
mcpCountEl.textContent =
|
|
4131
|
+
data.toolCount == null
|
|
4132
|
+
? ''
|
|
4133
|
+
: `${data.toolCount} herramientas · ${data.lazy ? 'diferidas (mcp_call)' : 'completas en contexto'}`;
|
|
4134
|
+
}
|
|
3818
4135
|
clear(mcpListEl);
|
|
3819
4136
|
if (!data.servers.length) {
|
|
3820
4137
|
mcpListEl.appendChild(el('li', 'muted', 'sin servidores MCP'));
|
|
@@ -4908,6 +5225,137 @@ async function importSkill() {
|
|
|
4908
5225
|
});
|
|
4909
5226
|
}
|
|
4910
5227
|
|
|
5228
|
+
/* --- ajustes: biblioteca y buscador de MCP y skills ----------------------- */
|
|
5229
|
+
|
|
5230
|
+
async function refreshMcpCatalog() {
|
|
5231
|
+
if (!mcpCatalogEl) return;
|
|
5232
|
+
const data = await api('/api/mcp/catalog').catch(() => null);
|
|
5233
|
+
if (!data) return;
|
|
5234
|
+
clear(mcpCatalogEl);
|
|
5235
|
+
for (const entry of data.catalog) {
|
|
5236
|
+
const li = el('li', entry.installed ? 'active' : '');
|
|
5237
|
+
const main = el('div', 'grow');
|
|
5238
|
+
main.append(el('div', '', entry.name), el('div', 'pmeta', entry.description || ''));
|
|
5239
|
+
li.appendChild(main);
|
|
5240
|
+
const actions = el('div', 'pactions');
|
|
5241
|
+
if (entry.requiredEnv?.length) actions.appendChild(el('span', 'pmeta', `env: ${entry.requiredEnv.join(', ')}`));
|
|
5242
|
+
if (entry.installed) actions.appendChild(el('span', 'pmeta', 'instalado'));
|
|
5243
|
+
else actions.appendChild(button('agregar', (event) => { event.stopPropagation(); installCatalogMcp(entry.id); }));
|
|
5244
|
+
li.appendChild(actions);
|
|
5245
|
+
mcpCatalogEl.appendChild(li);
|
|
5246
|
+
}
|
|
5247
|
+
}
|
|
5248
|
+
|
|
5249
|
+
async function installCatalogMcp(id) {
|
|
5250
|
+
setStatus('#mcp-import-status', `agregando ${id}…`);
|
|
5251
|
+
const data = await api('/api/mcp/import', { method: 'POST', body: { catalog: id } }).catch((e) => ({ error: e.message }));
|
|
5252
|
+
if (data?.error) return setStatus('#mcp-import-status', data.error, 'err');
|
|
5253
|
+
setStatus('#mcp-import-status', `agregado ${id} (deshabilitado)`, 'ok');
|
|
5254
|
+
await refreshMcp();
|
|
5255
|
+
await refreshMcpCatalog();
|
|
5256
|
+
}
|
|
5257
|
+
|
|
5258
|
+
async function searchMcp() {
|
|
5259
|
+
const input = $('#mcp-search');
|
|
5260
|
+
const q = input?.value.trim();
|
|
5261
|
+
if (!q) return;
|
|
5262
|
+
setStatus('#mcp-search-status', 'buscando…');
|
|
5263
|
+
const data = await api(`/api/mcp/search?q=${encodeURIComponent(q)}`).catch((e) => ({ error: e.message }));
|
|
5264
|
+
if (data?.error) return setStatus('#mcp-search-status', data.error, 'err');
|
|
5265
|
+
clear(mcpSearchResultsEl);
|
|
5266
|
+
if (!data.results.length) {
|
|
5267
|
+
setStatus('#mcp-search-status', 'sin resultados');
|
|
5268
|
+
return;
|
|
5269
|
+
}
|
|
5270
|
+
setStatus('#mcp-search-status', `${data.results.length} resultado(s)`);
|
|
5271
|
+
for (const r of data.results) {
|
|
5272
|
+
const li = el('li', r.installed ? 'active' : '');
|
|
5273
|
+
const main = el('div', 'grow');
|
|
5274
|
+
main.append(el('div', '', r.name || r.id), el('div', 'pmeta', r.description || r.id));
|
|
5275
|
+
li.appendChild(main);
|
|
5276
|
+
const actions = el('div', 'pactions');
|
|
5277
|
+
if (r.requiredEnv?.length) actions.appendChild(el('span', 'pmeta', `env: ${r.requiredEnv.join(', ')}`));
|
|
5278
|
+
if (r.installed) actions.appendChild(el('span', 'pmeta', 'instalado'));
|
|
5279
|
+
else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installMcpFromRegistry(r.registry || r.name); }));
|
|
5280
|
+
li.appendChild(actions);
|
|
5281
|
+
mcpSearchResultsEl.appendChild(li);
|
|
5282
|
+
}
|
|
5283
|
+
}
|
|
5284
|
+
|
|
5285
|
+
async function installMcpFromRegistry(name) {
|
|
5286
|
+
setStatus('#mcp-search-status', `instalando ${name}…`);
|
|
5287
|
+
const data = await api('/api/mcp/import', { method: 'POST', body: { name } }).catch((e) => ({ error: e.message }));
|
|
5288
|
+
if (data?.error) return setStatus('#mcp-search-status', data.error, 'err');
|
|
5289
|
+
const required = (data.requiredEnv ?? []).length ? ` · requiere env: ${data.requiredEnv.join(', ')}` : '';
|
|
5290
|
+
setStatus('#mcp-search-status', `instalado ${data.imported}${required}`, 'ok');
|
|
5291
|
+
await refreshMcp();
|
|
5292
|
+
await refreshMcpCatalog();
|
|
5293
|
+
await searchMcp();
|
|
5294
|
+
}
|
|
5295
|
+
|
|
5296
|
+
async function refreshSkillCatalog() {
|
|
5297
|
+
if (!skillCatalogEl) return;
|
|
5298
|
+
const data = await api('/api/skills/catalog').catch(() => null);
|
|
5299
|
+
if (!data) return;
|
|
5300
|
+
clear(skillCatalogEl);
|
|
5301
|
+
for (const entry of data.catalog) {
|
|
5302
|
+
const li = el('li', entry.installed ? 'active' : '');
|
|
5303
|
+
const main = el('div', 'grow');
|
|
5304
|
+
main.append(el('div', '', entry.name), el('div', 'pmeta', entry.description || ''));
|
|
5305
|
+
li.appendChild(main);
|
|
5306
|
+
const actions = el('div', 'pactions');
|
|
5307
|
+
if (entry.installed) actions.appendChild(el('span', 'pmeta', 'instalada'));
|
|
5308
|
+
else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installCatalogSkill(entry.id); }));
|
|
5309
|
+
li.appendChild(actions);
|
|
5310
|
+
skillCatalogEl.appendChild(li);
|
|
5311
|
+
}
|
|
5312
|
+
}
|
|
5313
|
+
|
|
5314
|
+
async function installCatalogSkill(id) {
|
|
5315
|
+
setStatus('#skill-import-status', `instalando ${id}…`);
|
|
5316
|
+
const data = await api('/api/skills', { method: 'POST', body: { id } }).catch((e) => ({ error: e.message }));
|
|
5317
|
+
if (data?.error) return setStatus('#skill-import-status', data.error, 'err');
|
|
5318
|
+
setStatus('#skill-import-status', `instalada ${id} (deshabilitada)`, 'ok');
|
|
5319
|
+
await refreshSkills();
|
|
5320
|
+
await refreshSkillCatalog();
|
|
5321
|
+
}
|
|
5322
|
+
|
|
5323
|
+
async function searchSkills() {
|
|
5324
|
+
const input = $('#skill-search');
|
|
5325
|
+
const q = input?.value.trim();
|
|
5326
|
+
if (!q) return;
|
|
5327
|
+
setStatus('#skill-search-status', 'buscando…');
|
|
5328
|
+
const data = await api(`/api/skills/search?q=${encodeURIComponent(q)}`).catch((e) => ({ error: e.message }));
|
|
5329
|
+
if (data?.error) return setStatus('#skill-search-status', data.error, 'err');
|
|
5330
|
+
clear(skillSearchResultsEl);
|
|
5331
|
+
if (!data.results.length) {
|
|
5332
|
+
setStatus('#skill-search-status', 'sin resultados');
|
|
5333
|
+
return;
|
|
5334
|
+
}
|
|
5335
|
+
setStatus('#skill-search-status', `${data.results.length} resultado(s)`);
|
|
5336
|
+
for (const r of data.results) {
|
|
5337
|
+
const li = el('li', r.installed ? 'active' : '');
|
|
5338
|
+
const main = el('div', 'grow');
|
|
5339
|
+
main.append(el('div', '', r.source), el('div', 'pmeta', r.description || r.ref));
|
|
5340
|
+
li.appendChild(main);
|
|
5341
|
+
const actions = el('div', 'pactions');
|
|
5342
|
+
if (r.installed) actions.appendChild(el('span', 'pmeta', 'instalada'));
|
|
5343
|
+
else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installSkillRef(r.ref); }));
|
|
5344
|
+
li.appendChild(actions);
|
|
5345
|
+
skillSearchResultsEl.appendChild(li);
|
|
5346
|
+
}
|
|
5347
|
+
}
|
|
5348
|
+
|
|
5349
|
+
async function installSkillRef(ref) {
|
|
5350
|
+
setStatus('#skill-search-status', `instalando ${ref}…`);
|
|
5351
|
+
const data = await api('/api/skills/import', { method: 'POST', body: { ref } }).catch((e) => ({ error: e.message }));
|
|
5352
|
+
if (data?.error) return setStatus('#skill-search-status', data.error, 'err');
|
|
5353
|
+
setStatus('#skill-search-status', `instalada ${data.installed}`, 'ok');
|
|
5354
|
+
await refreshSkills();
|
|
5355
|
+
await refreshSkillCatalog();
|
|
5356
|
+
await searchSkills();
|
|
5357
|
+
}
|
|
5358
|
+
|
|
4911
5359
|
if (mcpListEl) {
|
|
4912
5360
|
$('#mcp-new').addEventListener('click', () => openMcpForm());
|
|
4913
5361
|
$('#mcp-import-btn')?.addEventListener('click', importMcp);
|
|
@@ -4924,7 +5372,46 @@ if (mcpListEl) {
|
|
|
4924
5372
|
importSkill();
|
|
4925
5373
|
}
|
|
4926
5374
|
});
|
|
5375
|
+
$('#mcp-search-btn')?.addEventListener('click', searchMcp);
|
|
5376
|
+
$('#mcp-search')?.addEventListener('keydown', (event) => {
|
|
5377
|
+
if (event.key === 'Enter') {
|
|
5378
|
+
event.preventDefault();
|
|
5379
|
+
searchMcp();
|
|
5380
|
+
}
|
|
5381
|
+
});
|
|
5382
|
+
$('#skill-search-btn')?.addEventListener('click', searchSkills);
|
|
5383
|
+
$('#skill-search')?.addEventListener('keydown', (event) => {
|
|
5384
|
+
if (event.key === 'Enter') {
|
|
5385
|
+
event.preventDefault();
|
|
5386
|
+
searchSkills();
|
|
5387
|
+
}
|
|
5388
|
+
});
|
|
5389
|
+
refreshMcpCatalog();
|
|
5390
|
+
refreshSkillCatalog();
|
|
4927
5391
|
$('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
|
|
5392
|
+
mcpModeEl?.addEventListener('change', async () => {
|
|
5393
|
+
try {
|
|
5394
|
+
await api('/api/settings', { method: 'PUT', body: { mcpMode: mcpModeEl.value } });
|
|
5395
|
+
await refreshMcp({ connect: true });
|
|
5396
|
+
} catch (error) {
|
|
5397
|
+
toast(error.message);
|
|
5398
|
+
}
|
|
5399
|
+
});
|
|
5400
|
+
const savePinnedMcp = async () => {
|
|
5401
|
+
try {
|
|
5402
|
+
await api('/api/settings', { method: 'PUT', body: { mcpPinned: String(mcpPinnedEl.value || '') } });
|
|
5403
|
+
await refreshMcp({ connect: true });
|
|
5404
|
+
} catch (error) {
|
|
5405
|
+
toast(error.message);
|
|
5406
|
+
}
|
|
5407
|
+
};
|
|
5408
|
+
$('#mcp-pinned-save')?.addEventListener('click', savePinnedMcp);
|
|
5409
|
+
mcpPinnedEl?.addEventListener('keydown', (event) => {
|
|
5410
|
+
if (event.key === 'Enter') {
|
|
5411
|
+
event.preventDefault();
|
|
5412
|
+
savePinnedMcp();
|
|
5413
|
+
}
|
|
5414
|
+
});
|
|
4928
5415
|
$('#tg-save')?.addEventListener('click', submitChannel);
|
|
4929
5416
|
$('#tg-test')?.addEventListener('click', testChannel);
|
|
4930
5417
|
$('#voz-save')?.addEventListener('click', submitVoz);
|
|
@@ -5411,6 +5898,7 @@ function refreshExplorerMode() {
|
|
|
5411
5898
|
if (explorerMode === 'files') refreshExplorer();
|
|
5412
5899
|
else if (explorerMode === 'procs') detectProcs();
|
|
5413
5900
|
else if (explorerMode === 'git') refreshGit();
|
|
5901
|
+
else if (explorerMode === 'preview') syncPreviewTunnel();
|
|
5414
5902
|
}
|
|
5415
5903
|
|
|
5416
5904
|
document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
|
|
@@ -5463,16 +5951,75 @@ const previewTools = setupPreviewTools({
|
|
|
5463
5951
|
teleport: true,
|
|
5464
5952
|
});
|
|
5465
5953
|
restorePreviewMax = () => previewTools.setMax(false);
|
|
5466
|
-
|
|
5954
|
+
|
|
5955
|
+
const PREVIEW_URL_KEY = 'tamnora-preview-url';
|
|
5956
|
+
const PREVIEW_DEVICE_KEY = 'tamnora-preview-device';
|
|
5957
|
+
|
|
5958
|
+
/** True si la consola corre en la misma maquina que el server (loopback). */
|
|
5959
|
+
function isLocalHost() {
|
|
5960
|
+
const host = String(location.hostname || '').toLowerCase();
|
|
5961
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]' || host.endsWith('.localhost');
|
|
5962
|
+
}
|
|
5963
|
+
|
|
5964
|
+
/** Puerto si la URL apunta a loopback (127.0.0.1/localhost/::1). */
|
|
5965
|
+
function localPortOf(url) {
|
|
5966
|
+
const match = String(url ?? '').match(/^https?:\/\/(?:localhost|127(?:\.\d+){3}|\[::1\]):(\d+)/i);
|
|
5967
|
+
return match ? Number(match[1]) : 0;
|
|
5968
|
+
}
|
|
5969
|
+
|
|
5970
|
+
function setTunnelUi(tunnel) {
|
|
5971
|
+
const closeBtn = $('#preview-close');
|
|
5972
|
+
if (closeBtn) closeBtn.hidden = !tunnel;
|
|
5973
|
+
if (tunnel) {
|
|
5974
|
+
const why = tunnel.provider === 'tailscale' ? 'privado (tailscale)' : tunnel.provider ? tunnel.provider : '';
|
|
5975
|
+
setPreviewStatus(`acceso remoto activo: ${tunnel.url}${why ? ` · ${why}` : ''}`);
|
|
5976
|
+
}
|
|
5977
|
+
}
|
|
5978
|
+
|
|
5979
|
+
async function syncPreviewTunnel({ load = false } = {}) {
|
|
5980
|
+
const data = await api('/api/preview').catch(() => null);
|
|
5981
|
+
const tunnel = data?.tunnel || null;
|
|
5982
|
+
setTunnelUi(tunnel);
|
|
5983
|
+
if (tunnel?.url && load) loadPreview(tunnel.url);
|
|
5984
|
+
return tunnel;
|
|
5985
|
+
}
|
|
5986
|
+
|
|
5987
|
+
/** Espera a que el server confirme el tunel si la respuesta del POST se corto. */
|
|
5988
|
+
async function pollPreviewTunnel(tries = 5, delayMs = 1200) {
|
|
5989
|
+
for (let i = 0; i < tries; i++) {
|
|
5990
|
+
const data = await api('/api/preview').catch(() => null);
|
|
5991
|
+
if (data?.tunnel?.url) return data.tunnel;
|
|
5992
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
5993
|
+
}
|
|
5994
|
+
return null;
|
|
5995
|
+
}
|
|
5467
5996
|
|
|
5468
5997
|
function loadPreview(url) {
|
|
5469
5998
|
const value = String(url ?? '').trim();
|
|
5470
5999
|
if (!value) return setPreviewStatus('Poné una URL (p. ej. http://127.0.0.1:5173).', true);
|
|
5471
6000
|
previewUrlInput.value = value;
|
|
5472
6001
|
previewFrame.src = value;
|
|
6002
|
+
storage.set(PREVIEW_URL_KEY, value);
|
|
5473
6003
|
setPreviewStatus(`cargando ${value} …`);
|
|
5474
6004
|
}
|
|
5475
6005
|
|
|
6006
|
+
/** Abre acceso remoto al puerto (tailscale privado o tunel publico). */
|
|
6007
|
+
async function openRemoteTunnel(port) {
|
|
6008
|
+
const provider = $('#preview-provider')?.value || 'tailscale';
|
|
6009
|
+
setPreviewStatus(`abriendo acceso remoto (${provider})…`);
|
|
6010
|
+
const data = await api('/api/preview/tunnel', { method: 'POST', body: { port, provider } }).catch((e) => ({ error: e.message }));
|
|
6011
|
+
if (data?.error) {
|
|
6012
|
+
// La respuesta pudo cortarse (p. ej. la tablet en background): el server pudo
|
|
6013
|
+
// haber abierto igual el tunel, asi que lo confirmamos antes de fallar.
|
|
6014
|
+
const tunnel = await pollPreviewTunnel();
|
|
6015
|
+
if (!tunnel) return setPreviewStatus(`error: ${data.error}`, true);
|
|
6016
|
+
setTunnelUi(tunnel);
|
|
6017
|
+
return loadPreview(tunnel.url);
|
|
6018
|
+
}
|
|
6019
|
+
setTunnelUi(data.tunnel);
|
|
6020
|
+
loadPreview(data.tunnel.url);
|
|
6021
|
+
}
|
|
6022
|
+
|
|
5476
6023
|
$('#preview-open')?.addEventListener('click', () => loadPreview(previewUrlInput.value));
|
|
5477
6024
|
previewUrlInput?.addEventListener('keydown', (event) => {
|
|
5478
6025
|
if (event.key === 'Enter') {
|
|
@@ -5483,16 +6030,29 @@ previewUrlInput?.addEventListener('keydown', (event) => {
|
|
|
5483
6030
|
previewFrame?.addEventListener('load', () => {
|
|
5484
6031
|
if (previewFrame.src) setPreviewStatus(previewFrame.src);
|
|
5485
6032
|
});
|
|
5486
|
-
$('#preview-tunnel')?.addEventListener('click',
|
|
6033
|
+
$('#preview-tunnel')?.addEventListener('click', () => {
|
|
5487
6034
|
const match = String(previewUrlInput.value).match(/:(\d+)/);
|
|
5488
6035
|
const port = match ? Number(match[1]) : 0;
|
|
5489
6036
|
if (!port) return setPreviewStatus('Poné una URL con puerto (p. ej. http://127.0.0.1:5173).', true);
|
|
5490
|
-
|
|
5491
|
-
const data = await api('/api/preview/tunnel', { method: 'POST', body: { port } }).catch((e) => ({ error: e.message }));
|
|
5492
|
-
if (data?.error) return setPreviewStatus(`error: ${data.error}`, true);
|
|
5493
|
-
loadPreview(data.tunnel.url);
|
|
5494
|
-
setPreviewStatus(`túnel público: ${data.tunnel.url}`);
|
|
6037
|
+
openRemoteTunnel(port);
|
|
5495
6038
|
});
|
|
6039
|
+
$('#preview-close')?.addEventListener('click', async () => {
|
|
6040
|
+
await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null);
|
|
6041
|
+
setTunnelUi(null);
|
|
6042
|
+
setPreviewStatus('acceso remoto cerrado.');
|
|
6043
|
+
});
|
|
6044
|
+
$('#preview-device')?.addEventListener('change', (event) => storage.set(PREVIEW_DEVICE_KEY, event.target.value));
|
|
6045
|
+
|
|
6046
|
+
// Restaurar lo tipeado y el tamano elegido: en la tablet la pagina se recarga
|
|
6047
|
+
// sola (iOS la descarta en background) y antes se perdia todo el estado.
|
|
6048
|
+
(function restorePreview() {
|
|
6049
|
+
const savedDevice = storage.get(PREVIEW_DEVICE_KEY, '');
|
|
6050
|
+
if (savedDevice && $('#preview-device')) $('#preview-device').value = savedDevice;
|
|
6051
|
+
previewTools.applyDevice();
|
|
6052
|
+
const savedUrl = storage.get(PREVIEW_URL_KEY, '');
|
|
6053
|
+
if (savedUrl) previewUrlInput.value = savedUrl;
|
|
6054
|
+
syncPreviewTunnel({ load: true });
|
|
6055
|
+
})();
|
|
5496
6056
|
|
|
5497
6057
|
/* procs: deteccion y dev servers */
|
|
5498
6058
|
|
|
@@ -5508,8 +6068,19 @@ async function detectProcs() {
|
|
|
5508
6068
|
procsCwdEl.textContent = cwd ? `cwd: ${cwd}` : 'sin carpeta';
|
|
5509
6069
|
const data = await api(`/api/procs/detect?cwd=${encodeURIComponent(cwd)}`).catch(() => null);
|
|
5510
6070
|
if (!data) return;
|
|
5511
|
-
for (const chip of data.chips)
|
|
6071
|
+
for (const chip of data.chips) {
|
|
6072
|
+
const node = button(chip.label, () => startProc(chip), 'xchip');
|
|
6073
|
+
if (chip.source === 'project') node.title = 'definido en .tamnora/procs.json';
|
|
6074
|
+
if (chip.allowed === false) {
|
|
6075
|
+
node.disabled = true;
|
|
6076
|
+
node.title = `"${chip.command}" no está en processes.allow`;
|
|
6077
|
+
}
|
|
6078
|
+
procsChipsEl.appendChild(node);
|
|
6079
|
+
}
|
|
5512
6080
|
if (!data.chips.length) procsChipsEl.appendChild(el('span', 'muted', 'no detecté comandos de dev'));
|
|
6081
|
+
else if (data.chips.some((c) => c.source === 'project')) {
|
|
6082
|
+
procsChipsEl.appendChild(el('span', 'muted', '· desde .tamnora/procs.json'));
|
|
6083
|
+
}
|
|
5513
6084
|
await refreshProcs();
|
|
5514
6085
|
}
|
|
5515
6086
|
|
|
@@ -5523,7 +6094,6 @@ async function refreshProcs() {
|
|
|
5523
6094
|
}
|
|
5524
6095
|
if (!data.procs.length) {
|
|
5525
6096
|
procsListEl.appendChild(el('li', 'muted', 'sin procesos corriendo'));
|
|
5526
|
-
return;
|
|
5527
6097
|
}
|
|
5528
6098
|
for (const proc of data.procs) {
|
|
5529
6099
|
const li = el('li', proc.running ? 'active' : '');
|
|
@@ -5535,11 +6105,42 @@ async function refreshProcs() {
|
|
|
5535
6105
|
li.appendChild(main);
|
|
5536
6106
|
const actions = el('div', 'pactions');
|
|
5537
6107
|
actions.appendChild(button('logs', () => { selectedProc = proc.id; refreshProcLog(); }));
|
|
5538
|
-
if (proc.port) actions.appendChild(button('preview', () =>
|
|
6108
|
+
if (proc.port) actions.appendChild(button('preview', () => openProcPreview(proc.port)));
|
|
5539
6109
|
if (proc.running) actions.appendChild(button('detener', () => stopProc(proc.id), 'btn danger'));
|
|
5540
6110
|
li.appendChild(actions);
|
|
5541
6111
|
procsListEl.appendChild(li);
|
|
5542
6112
|
}
|
|
6113
|
+
await renderTunnelRow();
|
|
6114
|
+
}
|
|
6115
|
+
|
|
6116
|
+
/** Fila del tunel activo: no es un proc (lo maneja la ruta preview), pero
|
|
6117
|
+
* conviene verlo y poder cerrarlo aca. */
|
|
6118
|
+
async function renderTunnelRow() {
|
|
6119
|
+
const data = await api('/api/preview').catch(() => null);
|
|
6120
|
+
const tunnel = data?.tunnel;
|
|
6121
|
+
if (!tunnel?.url) return;
|
|
6122
|
+
const li = el('li', 'active');
|
|
6123
|
+
const main = el('div', 'grow');
|
|
6124
|
+
main.append(
|
|
6125
|
+
el('div', '', `túnel → puerto ${tunnel.port}`),
|
|
6126
|
+
el('div', 'pmeta', `${tunnel.provider || ''} · ${tunnel.url}`)
|
|
6127
|
+
);
|
|
6128
|
+
li.appendChild(main);
|
|
6129
|
+
const actions = el('div', 'pactions');
|
|
6130
|
+
actions.appendChild(button('preview', () => { setExplorerMode('preview'); loadPreview(tunnel.url); }));
|
|
6131
|
+
actions.appendChild(
|
|
6132
|
+
button('detener', async () => { await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null); refreshProcs(); }, 'btn danger')
|
|
6133
|
+
);
|
|
6134
|
+
li.appendChild(actions);
|
|
6135
|
+
procsListEl.appendChild(li);
|
|
6136
|
+
}
|
|
6137
|
+
|
|
6138
|
+
/** Preview de un proc: local si la consola corre en la misma maquina, remoto
|
|
6139
|
+
* (tailscale/tunel) si se accede desde otro dispositivo. */
|
|
6140
|
+
async function openProcPreview(port) {
|
|
6141
|
+
setExplorerMode('preview');
|
|
6142
|
+
if (isLocalHost()) return loadPreview(`http://127.0.0.1:${port}`);
|
|
6143
|
+
return openRemoteTunnel(port);
|
|
5543
6144
|
}
|
|
5544
6145
|
|
|
5545
6146
|
async function startProc(chip) {
|
|
@@ -5870,7 +6471,7 @@ let baseTitle = '';
|
|
|
5870
6471
|
* tienen que marcar que hay un permiso esperando.
|
|
5871
6472
|
*/
|
|
5872
6473
|
function renderAlertSignals() {
|
|
5873
|
-
const pending = state.pendingAsks.size;
|
|
6474
|
+
const pending = state.pendingAsks.size + state.pendingQuestions.size;
|
|
5874
6475
|
if (!baseTitle) baseTitle = document.title;
|
|
5875
6476
|
const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
|
|
5876
6477
|
document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
|