@danieltmn/tamnora 0.17.0 → 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 +38 -0
- package/README.md +37 -3
- 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 +15 -1
- 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/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/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 +501 -23
- package/harness/packages/server/public/index.html +41 -2
- package/harness/packages/server/public/styles.css +99 -3
- 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/prompt.js +4 -1
- package/harness/packages/server/src/lib/questions.js +72 -0
- 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 +31 -2
- 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/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 +186 -19
- 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) =>
|
|
@@ -1021,6 +1024,7 @@ async function renderPane(id) {
|
|
|
1021
1024
|
// borra. Hay que repintarlos aca: sin esto una tarjeta de permiso se ve y
|
|
1022
1025
|
// al primer re-render (run_start, sweep, reconexion) desaparece para siempre.
|
|
1023
1026
|
for (const ask of detail.permissions ?? []) showStandalonePermission(record, ask);
|
|
1027
|
+
for (const ask of detail.questions ?? []) showStandaloneQuestion(record, ask);
|
|
1024
1028
|
return detail;
|
|
1025
1029
|
}
|
|
1026
1030
|
|
|
@@ -1521,6 +1525,152 @@ function resolvePermissionCard(entry, allow) {
|
|
|
1521
1525
|
entry.verdict.textContent = allow ? 'aprobado desde otra ventana' : 'denegado desde otra ventana';
|
|
1522
1526
|
}
|
|
1523
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
|
+
|
|
1524
1674
|
function userText(content) {
|
|
1525
1675
|
if (typeof content === 'string') return content;
|
|
1526
1676
|
if (Array.isArray(content)) {
|
|
@@ -1540,11 +1690,15 @@ function userImages(content) {
|
|
|
1540
1690
|
.filter(Boolean);
|
|
1541
1691
|
}
|
|
1542
1692
|
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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: '✦' };
|
|
1548
1702
|
}
|
|
1549
1703
|
|
|
1550
1704
|
/** Tarjeta del asesor: una capa de comentario, no un mensaje del agente. */
|
|
@@ -1553,9 +1707,15 @@ function addAdvisorCard(container, { text, kind } = {}) {
|
|
|
1553
1707
|
if (!clean || !container) return null;
|
|
1554
1708
|
const exists = [...container.querySelectorAll('.advisor-text')].some((node) => node.textContent === clean);
|
|
1555
1709
|
if (exists) return null;
|
|
1556
|
-
const
|
|
1710
|
+
const meta = advisorMeta(kind);
|
|
1711
|
+
const klass = ADVISOR_KINDS[kind] ? ` advisor-${kind}` : '';
|
|
1712
|
+
const card = el('div', `advisor-card${klass}`);
|
|
1557
1713
|
const head = el('div', 'advisor-head');
|
|
1558
|
-
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
|
+
);
|
|
1559
1719
|
card.append(head, el('div', 'advisor-text', clean));
|
|
1560
1720
|
container.appendChild(card);
|
|
1561
1721
|
return card;
|
|
@@ -1586,11 +1746,14 @@ function renderStored(message, pane) {
|
|
|
1586
1746
|
}
|
|
1587
1747
|
for (const call of message.tool_calls ?? []) {
|
|
1588
1748
|
const name = call.function?.name ?? call.name;
|
|
1589
|
-
const
|
|
1749
|
+
const args = parseToolArgs(call);
|
|
1750
|
+
const tool = addToolCard(bubble, name, args, { callId: call.id, open: false });
|
|
1590
1751
|
tool.card.classList.remove('running');
|
|
1591
1752
|
tool.card.classList.add('done');
|
|
1592
1753
|
tool.glyph.textContent = '✓';
|
|
1593
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 });
|
|
1594
1757
|
}
|
|
1595
1758
|
} else if (message.role === 'tool') {
|
|
1596
1759
|
const tool = toolCardIndex.get(message.tool_call_id);
|
|
@@ -1780,7 +1943,7 @@ async function removeProject(cwd, count) {
|
|
|
1780
1943
|
|
|
1781
1944
|
/* --- session tabs --------------------------------------------------------- */
|
|
1782
1945
|
|
|
1783
|
-
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 = []) {
|
|
1784
1947
|
const id = session.id;
|
|
1785
1948
|
if (state.panes.has(id)) return state.panes.get(id);
|
|
1786
1949
|
const pane = document.createElement('div');
|
|
@@ -1807,6 +1970,7 @@ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, run
|
|
|
1807
1970
|
// sesion. Sin esto, recargar con un permiso en el aire dejaba el run
|
|
1808
1971
|
// bloqueado esperando una decision que no se podia tomar.
|
|
1809
1972
|
for (const ask of permissions) showStandalonePermission(record, ask);
|
|
1973
|
+
for (const ask of questions) showStandaloneQuestion(record, ask);
|
|
1810
1974
|
return record;
|
|
1811
1975
|
}
|
|
1812
1976
|
|
|
@@ -1868,7 +2032,7 @@ async function openTab(id) {
|
|
|
1868
2032
|
return null;
|
|
1869
2033
|
});
|
|
1870
2034
|
if (!data) return;
|
|
1871
|
-
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);
|
|
1872
2036
|
activateSession(id);
|
|
1873
2037
|
}
|
|
1874
2038
|
|
|
@@ -2005,7 +2169,7 @@ async function restoreTabs() {
|
|
|
2005
2169
|
for (const id of ids) {
|
|
2006
2170
|
if (state.panes.has(id)) continue;
|
|
2007
2171
|
const data = await api(`/api/sessions/${id}`).catch(() => null);
|
|
2008
|
-
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);
|
|
2009
2173
|
}
|
|
2010
2174
|
const active = saved?.active && state.panes.has(saved.active) ? saved.active : state.tabOrder[state.tabOrder.length - 1];
|
|
2011
2175
|
if (active) activateSession(active);
|
|
@@ -2206,11 +2370,35 @@ function handleChatEvent(event, data, turn) {
|
|
|
2206
2370
|
}
|
|
2207
2371
|
if (turn.live && data.name === 'preview_url' && data.args?.url) {
|
|
2208
2372
|
setExplorerMode('preview');
|
|
2209
|
-
|
|
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 });
|
|
2210
2384
|
}
|
|
2211
2385
|
} else if (event === 'tool_result') {
|
|
2212
2386
|
setActivity(turn.assistant(), 'thinking');
|
|
2213
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
|
+
}
|
|
2214
2402
|
} else if (event === 'permission') {
|
|
2215
2403
|
// Si la tarjeta ya esta en el DOM (la creo `renderPane` antes del replay, o
|
|
2216
2404
|
// llego por el canal global) no hay que crear OTRO mensaje del asistente
|
|
@@ -3830,9 +4018,16 @@ providerModal.addEventListener('click', (event) => {
|
|
|
3830
4018
|
/* --- ajustes: MCP + Skills ----------------------------------------------- */
|
|
3831
4019
|
|
|
3832
4020
|
const mcpListEl = $('#mcp-list');
|
|
4021
|
+
const mcpSearchResultsEl = $('#mcp-search-results');
|
|
4022
|
+
const mcpCatalogEl = $('#mcp-catalog');
|
|
3833
4023
|
const mcpModal = $('#mcp-modal');
|
|
3834
4024
|
const mcpForm = $('#mcp-form');
|
|
4025
|
+
const mcpModeEl = $('#mcp-mode');
|
|
4026
|
+
const mcpCountEl = $('#mcp-count');
|
|
4027
|
+
const mcpPinnedEl = $('#mcp-pinned');
|
|
3835
4028
|
const skillListEl = $('#skill-list');
|
|
4029
|
+
const skillSearchResultsEl = $('#skill-search-results');
|
|
4030
|
+
const skillCatalogEl = $('#skill-catalog');
|
|
3836
4031
|
const skillModal = $('#skill-modal');
|
|
3837
4032
|
const skillForm = $('#skill-form');
|
|
3838
4033
|
const memoryListEl = $('#memory-list');
|
|
@@ -3927,6 +4122,16 @@ function setStatus(node, text, cls = '') {
|
|
|
3927
4122
|
async function refreshMcp({ connect = false } = {}) {
|
|
3928
4123
|
const data = await api(`/api/mcp${connect ? '?connect=1' : ''}`).catch(() => null);
|
|
3929
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
|
+
}
|
|
3930
4135
|
clear(mcpListEl);
|
|
3931
4136
|
if (!data.servers.length) {
|
|
3932
4137
|
mcpListEl.appendChild(el('li', 'muted', 'sin servidores MCP'));
|
|
@@ -5020,6 +5225,137 @@ async function importSkill() {
|
|
|
5020
5225
|
});
|
|
5021
5226
|
}
|
|
5022
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
|
+
|
|
5023
5359
|
if (mcpListEl) {
|
|
5024
5360
|
$('#mcp-new').addEventListener('click', () => openMcpForm());
|
|
5025
5361
|
$('#mcp-import-btn')?.addEventListener('click', importMcp);
|
|
@@ -5036,7 +5372,46 @@ if (mcpListEl) {
|
|
|
5036
5372
|
importSkill();
|
|
5037
5373
|
}
|
|
5038
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();
|
|
5039
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
|
+
});
|
|
5040
5415
|
$('#tg-save')?.addEventListener('click', submitChannel);
|
|
5041
5416
|
$('#tg-test')?.addEventListener('click', testChannel);
|
|
5042
5417
|
$('#voz-save')?.addEventListener('click', submitVoz);
|
|
@@ -5523,6 +5898,7 @@ function refreshExplorerMode() {
|
|
|
5523
5898
|
if (explorerMode === 'files') refreshExplorer();
|
|
5524
5899
|
else if (explorerMode === 'procs') detectProcs();
|
|
5525
5900
|
else if (explorerMode === 'git') refreshGit();
|
|
5901
|
+
else if (explorerMode === 'preview') syncPreviewTunnel();
|
|
5526
5902
|
}
|
|
5527
5903
|
|
|
5528
5904
|
document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
|
|
@@ -5575,16 +5951,75 @@ const previewTools = setupPreviewTools({
|
|
|
5575
5951
|
teleport: true,
|
|
5576
5952
|
});
|
|
5577
5953
|
restorePreviewMax = () => previewTools.setMax(false);
|
|
5578
|
-
|
|
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
|
+
}
|
|
5579
5996
|
|
|
5580
5997
|
function loadPreview(url) {
|
|
5581
5998
|
const value = String(url ?? '').trim();
|
|
5582
5999
|
if (!value) return setPreviewStatus('Poné una URL (p. ej. http://127.0.0.1:5173).', true);
|
|
5583
6000
|
previewUrlInput.value = value;
|
|
5584
6001
|
previewFrame.src = value;
|
|
6002
|
+
storage.set(PREVIEW_URL_KEY, value);
|
|
5585
6003
|
setPreviewStatus(`cargando ${value} …`);
|
|
5586
6004
|
}
|
|
5587
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
|
+
|
|
5588
6023
|
$('#preview-open')?.addEventListener('click', () => loadPreview(previewUrlInput.value));
|
|
5589
6024
|
previewUrlInput?.addEventListener('keydown', (event) => {
|
|
5590
6025
|
if (event.key === 'Enter') {
|
|
@@ -5595,16 +6030,29 @@ previewUrlInput?.addEventListener('keydown', (event) => {
|
|
|
5595
6030
|
previewFrame?.addEventListener('load', () => {
|
|
5596
6031
|
if (previewFrame.src) setPreviewStatus(previewFrame.src);
|
|
5597
6032
|
});
|
|
5598
|
-
$('#preview-tunnel')?.addEventListener('click',
|
|
6033
|
+
$('#preview-tunnel')?.addEventListener('click', () => {
|
|
5599
6034
|
const match = String(previewUrlInput.value).match(/:(\d+)/);
|
|
5600
6035
|
const port = match ? Number(match[1]) : 0;
|
|
5601
6036
|
if (!port) return setPreviewStatus('Poné una URL con puerto (p. ej. http://127.0.0.1:5173).', true);
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
6037
|
+
openRemoteTunnel(port);
|
|
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.');
|
|
5607
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
|
+
})();
|
|
5608
6056
|
|
|
5609
6057
|
/* procs: deteccion y dev servers */
|
|
5610
6058
|
|
|
@@ -5646,7 +6094,6 @@ async function refreshProcs() {
|
|
|
5646
6094
|
}
|
|
5647
6095
|
if (!data.procs.length) {
|
|
5648
6096
|
procsListEl.appendChild(el('li', 'muted', 'sin procesos corriendo'));
|
|
5649
|
-
return;
|
|
5650
6097
|
}
|
|
5651
6098
|
for (const proc of data.procs) {
|
|
5652
6099
|
const li = el('li', proc.running ? 'active' : '');
|
|
@@ -5658,11 +6105,42 @@ async function refreshProcs() {
|
|
|
5658
6105
|
li.appendChild(main);
|
|
5659
6106
|
const actions = el('div', 'pactions');
|
|
5660
6107
|
actions.appendChild(button('logs', () => { selectedProc = proc.id; refreshProcLog(); }));
|
|
5661
|
-
if (proc.port) actions.appendChild(button('preview', () =>
|
|
6108
|
+
if (proc.port) actions.appendChild(button('preview', () => openProcPreview(proc.port)));
|
|
5662
6109
|
if (proc.running) actions.appendChild(button('detener', () => stopProc(proc.id), 'btn danger'));
|
|
5663
6110
|
li.appendChild(actions);
|
|
5664
6111
|
procsListEl.appendChild(li);
|
|
5665
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);
|
|
5666
6144
|
}
|
|
5667
6145
|
|
|
5668
6146
|
async function startProc(chip) {
|
|
@@ -5993,7 +6471,7 @@ let baseTitle = '';
|
|
|
5993
6471
|
* tienen que marcar que hay un permiso esperando.
|
|
5994
6472
|
*/
|
|
5995
6473
|
function renderAlertSignals() {
|
|
5996
|
-
const pending = state.pendingAsks.size;
|
|
6474
|
+
const pending = state.pendingAsks.size + state.pendingQuestions.size;
|
|
5997
6475
|
if (!baseTitle) baseTitle = document.title;
|
|
5998
6476
|
const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
|
|
5999
6477
|
document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
|