@danieltmn/tamnora 0.17.0 → 0.19.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 +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 +95 -4
- 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 +87 -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 +567 -33
- package/harness/packages/server/public/index.html +51 -7
- package/harness/packages/server/public/styles.css +124 -4
- package/harness/packages/server/src/lib/agentTools.js +45 -3
- package/harness/packages/server/src/lib/channels/telegram.js +10 -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/scheduler.js +12 -1
- package/harness/packages/server/src/lib/tasks.js +3 -1
- package/harness/packages/server/src/lib/ttsSettings.js +11 -0
- 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/routes/tts.js +4 -2
- 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/packages/server/test/tts.test.js +5 -1
- 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
|
|
|
@@ -1363,7 +1367,7 @@ async function loadFullToolResult(entry, message) {
|
|
|
1363
1367
|
function speakControl(args) {
|
|
1364
1368
|
const texto = String(args?.texto ?? '').trim();
|
|
1365
1369
|
if (!texto) return null;
|
|
1366
|
-
const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
|
|
1370
|
+
const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, pitch: args?.pitch, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
|
|
1367
1371
|
btn.append(icon('play', 13), el('span', '', 'escuchar'));
|
|
1368
1372
|
return btn;
|
|
1369
1373
|
}
|
|
@@ -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);
|
|
@@ -2202,15 +2366,39 @@ function handleChatEvent(event, data, turn) {
|
|
|
2202
2366
|
turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
|
|
2203
2367
|
if (turn.live && data.name === 'speak') {
|
|
2204
2368
|
const texto = String(data.args?.texto ?? '').trim();
|
|
2205
|
-
if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
|
|
2369
|
+
if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, pitch: data.args?.pitch, label: 'voz' }).catch(() => {});
|
|
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');
|
|
@@ -3855,6 +4050,9 @@ const tgStatusEl = $('#tg-status');
|
|
|
3855
4050
|
const vozEnabledEl = $('#voz-enabled');
|
|
3856
4051
|
const vozVoiceEl = $('#voz-voice');
|
|
3857
4052
|
const vozRateEl = $('#voz-rate');
|
|
4053
|
+
const vozRateValEl = $('#voz-rate-val');
|
|
4054
|
+
const vozPitchEl = $('#voz-pitch');
|
|
4055
|
+
const vozPitchValEl = $('#voz-pitch-val');
|
|
3858
4056
|
const vozAnnStartEl = $('#voz-ann-start');
|
|
3859
4057
|
const vozAnnToolsEl = $('#voz-ann-tools');
|
|
3860
4058
|
const vozAnnEndEl = $('#voz-ann-end');
|
|
@@ -3927,6 +4125,16 @@ function setStatus(node, text, cls = '') {
|
|
|
3927
4125
|
async function refreshMcp({ connect = false } = {}) {
|
|
3928
4126
|
const data = await api(`/api/mcp${connect ? '?connect=1' : ''}`).catch(() => null);
|
|
3929
4127
|
if (!data) return;
|
|
4128
|
+
if (mcpModeEl && data.mode) mcpModeEl.value = data.mode;
|
|
4129
|
+
if (mcpPinnedEl && Array.isArray(data.pinned) && document.activeElement !== mcpPinnedEl) {
|
|
4130
|
+
mcpPinnedEl.value = data.pinned.join(', ');
|
|
4131
|
+
}
|
|
4132
|
+
if (mcpCountEl) {
|
|
4133
|
+
mcpCountEl.textContent =
|
|
4134
|
+
data.toolCount == null
|
|
4135
|
+
? ''
|
|
4136
|
+
: `${data.toolCount} herramientas · ${data.lazy ? 'diferidas (mcp_call)' : 'completas en contexto'}`;
|
|
4137
|
+
}
|
|
3930
4138
|
clear(mcpListEl);
|
|
3931
4139
|
if (!data.servers.length) {
|
|
3932
4140
|
mcpListEl.appendChild(el('li', 'muted', 'sin servidores MCP'));
|
|
@@ -4440,6 +4648,7 @@ const voz = {
|
|
|
4440
4648
|
enabled: false,
|
|
4441
4649
|
voice: 'es-AR-ElenaNeural',
|
|
4442
4650
|
rate: '+0%',
|
|
4651
|
+
pitch: '+0Hz',
|
|
4443
4652
|
announce: { start: true, tools: true, end: true, advice: true, answer: false, summary: false },
|
|
4444
4653
|
},
|
|
4445
4654
|
};
|
|
@@ -4484,13 +4693,53 @@ function ensurePlayer() {
|
|
|
4484
4693
|
});
|
|
4485
4694
|
}
|
|
4486
4695
|
|
|
4696
|
+
/** Convierte la velocidad guardada ("+20%") al valor del slider (20). */
|
|
4697
|
+
function rateToSlider(rate) {
|
|
4698
|
+
const m = /^([+-]?)(\d+)%$/.exec(String(rate ?? '').trim());
|
|
4699
|
+
return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
|
|
4700
|
+
}
|
|
4701
|
+
|
|
4702
|
+
/** Convierte el valor del slider (-20) a la velocidad guardada. */
|
|
4703
|
+
function sliderToRate(n) {
|
|
4704
|
+
const v = Math.max(-100, Math.min(100, Math.round(Number(n) || 0)));
|
|
4705
|
+
return v === 0 ? '+0%' : `${v > 0 ? '+' : '-'}${Math.abs(v)}%`;
|
|
4706
|
+
}
|
|
4707
|
+
|
|
4708
|
+
/** Convierte el tono guardado ("-10Hz") al valor del slider (-10). */
|
|
4709
|
+
function pitchToSlider(pitch) {
|
|
4710
|
+
const m = /^([+-]?)(\d+)Hz$/.exec(String(pitch ?? '').trim());
|
|
4711
|
+
return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
|
|
4712
|
+
}
|
|
4713
|
+
|
|
4714
|
+
/** Convierte el valor del slider (-10) al tono guardado. */
|
|
4715
|
+
function sliderToPitch(n) {
|
|
4716
|
+
const v = Math.max(-50, Math.min(50, Math.round(Number(n) || 0)));
|
|
4717
|
+
return v === 0 ? '+0Hz' : `${v > 0 ? '+' : '-'}${Math.abs(v)}Hz`;
|
|
4718
|
+
}
|
|
4719
|
+
|
|
4720
|
+
function etiquetaRate(n) {
|
|
4721
|
+
const v = Math.round(Number(n) || 0);
|
|
4722
|
+
return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)}%`;
|
|
4723
|
+
}
|
|
4724
|
+
|
|
4725
|
+
function etiquetaPitch(n) {
|
|
4726
|
+
const v = Math.round(Number(n) || 0);
|
|
4727
|
+
return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)} Hz`;
|
|
4728
|
+
}
|
|
4729
|
+
|
|
4730
|
+
/** Refresca la etiqueta numérica de los sliders de voz. */
|
|
4731
|
+
function renderVozSliders() {
|
|
4732
|
+
if (vozRateValEl && vozRateEl) vozRateValEl.textContent = etiquetaRate(vozRateEl.value);
|
|
4733
|
+
if (vozPitchValEl && vozPitchEl) vozPitchValEl.textContent = etiquetaPitch(vozPitchEl.value);
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4487
4736
|
/** Encola un audio. La promesa se resuelve al terminar (o al detenerlo). */
|
|
4488
|
-
function enqueueSpeech({ texto, voice, rate, label = 'audio', maxAgeMs = 0 } = {}) {
|
|
4737
|
+
function enqueueSpeech({ texto, voice, rate, pitch, label = 'audio', maxAgeMs = 0 } = {}) {
|
|
4489
4738
|
const frase = String(texto ?? '').trim();
|
|
4490
4739
|
if (!frase) return Promise.resolve();
|
|
4491
4740
|
ensurePlayer();
|
|
4492
4741
|
return new Promise((resolve, reject) => {
|
|
4493
|
-
ap.queue.push({ texto: frase, voice, rate, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
|
|
4742
|
+
ap.queue.push({ texto: frase, voice, rate, pitch, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
|
|
4494
4743
|
if (ap.current) renderAudio();
|
|
4495
4744
|
else playNextAudio();
|
|
4496
4745
|
});
|
|
@@ -4512,6 +4761,7 @@ function playNextAudio() {
|
|
|
4512
4761
|
texto: item.texto,
|
|
4513
4762
|
voice: item.voice ?? voz.settings.voice,
|
|
4514
4763
|
rate: item.rate ?? voz.settings.rate,
|
|
4764
|
+
pitch: item.pitch ?? voz.settings.pitch,
|
|
4515
4765
|
});
|
|
4516
4766
|
ap.audio.src = `/api/tts?${params}`;
|
|
4517
4767
|
ap.audio.play().catch(() => {
|
|
@@ -4563,8 +4813,8 @@ function renderAudio() {
|
|
|
4563
4813
|
}
|
|
4564
4814
|
|
|
4565
4815
|
/** Reproduce una frase contra el endpoint de sintesis del server. */
|
|
4566
|
-
function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
|
|
4567
|
-
return enqueueSpeech({ texto: frase, voice, rate, label, maxAgeMs });
|
|
4816
|
+
function reproducirVoz(frase, { voice, rate, pitch, label, maxAgeMs } = {}) {
|
|
4817
|
+
return enqueueSpeech({ texto: frase, voice, rate, pitch, label, maxAgeMs });
|
|
4568
4818
|
}
|
|
4569
4819
|
|
|
4570
4820
|
/**
|
|
@@ -4619,6 +4869,7 @@ async function aplicarVoz(data) {
|
|
|
4619
4869
|
enabled: data.enabled === true,
|
|
4620
4870
|
voice: data.voice || 'es-AR-ElenaNeural',
|
|
4621
4871
|
rate: data.rate || '+0%',
|
|
4872
|
+
pitch: data.pitch || '+0Hz',
|
|
4622
4873
|
announce: {
|
|
4623
4874
|
start: data.announce?.start !== false,
|
|
4624
4875
|
tools: data.announce?.tools !== false,
|
|
@@ -4639,7 +4890,9 @@ async function aplicarVoz(data) {
|
|
|
4639
4890
|
}
|
|
4640
4891
|
vozVoiceEl.value = voz.settings.voice;
|
|
4641
4892
|
}
|
|
4642
|
-
if (vozRateEl) vozRateEl.value = voz.settings.rate;
|
|
4893
|
+
if (vozRateEl) vozRateEl.value = rateToSlider(voz.settings.rate);
|
|
4894
|
+
if (vozPitchEl) vozPitchEl.value = pitchToSlider(voz.settings.pitch);
|
|
4895
|
+
renderVozSliders();
|
|
4643
4896
|
if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
|
|
4644
4897
|
if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
|
|
4645
4898
|
if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
|
|
@@ -4664,7 +4917,8 @@ async function submitVoz() {
|
|
|
4664
4917
|
const body = {
|
|
4665
4918
|
enabled: vozEnabledEl?.checked === true,
|
|
4666
4919
|
voice: vozVoiceEl?.value,
|
|
4667
|
-
rate: vozRateEl?.value,
|
|
4920
|
+
rate: sliderToRate(vozRateEl?.value),
|
|
4921
|
+
pitch: sliderToPitch(vozPitchEl?.value),
|
|
4668
4922
|
announce: {
|
|
4669
4923
|
start: vozAnnStartEl?.checked !== false,
|
|
4670
4924
|
tools: vozAnnToolsEl?.checked !== false,
|
|
@@ -4838,10 +5092,15 @@ trTestBtn?.addEventListener('click', toggleTrTest);
|
|
|
4838
5092
|
|
|
4839
5093
|
async function testVoz() {
|
|
4840
5094
|
setStatus('#voz-msg', 'sintetizando…');
|
|
5095
|
+
const trato = ($('#perfil-address')?.value || '').trim();
|
|
5096
|
+
const frase = trato
|
|
5097
|
+
? `${trato}, esta sería la voz predeterminada para mí`
|
|
5098
|
+
: 'Esta sería la voz predeterminada para mí';
|
|
4841
5099
|
try {
|
|
4842
|
-
await reproducirVoz(
|
|
5100
|
+
await reproducirVoz(frase, {
|
|
4843
5101
|
voice: vozVoiceEl?.value,
|
|
4844
|
-
rate: vozRateEl?.value,
|
|
5102
|
+
rate: sliderToRate(vozRateEl?.value),
|
|
5103
|
+
pitch: sliderToPitch(vozPitchEl?.value),
|
|
4845
5104
|
});
|
|
4846
5105
|
setStatus('#voz-msg', 'sonó', 'ok');
|
|
4847
5106
|
} catch (error) {
|
|
@@ -5020,6 +5279,137 @@ async function importSkill() {
|
|
|
5020
5279
|
});
|
|
5021
5280
|
}
|
|
5022
5281
|
|
|
5282
|
+
/* --- ajustes: biblioteca y buscador de MCP y skills ----------------------- */
|
|
5283
|
+
|
|
5284
|
+
async function refreshMcpCatalog() {
|
|
5285
|
+
if (!mcpCatalogEl) return;
|
|
5286
|
+
const data = await api('/api/mcp/catalog').catch(() => null);
|
|
5287
|
+
if (!data) return;
|
|
5288
|
+
clear(mcpCatalogEl);
|
|
5289
|
+
for (const entry of data.catalog) {
|
|
5290
|
+
const li = el('li', entry.installed ? 'active' : '');
|
|
5291
|
+
const main = el('div', 'grow');
|
|
5292
|
+
main.append(el('div', '', entry.name), el('div', 'pmeta', entry.description || ''));
|
|
5293
|
+
li.appendChild(main);
|
|
5294
|
+
const actions = el('div', 'pactions');
|
|
5295
|
+
if (entry.requiredEnv?.length) actions.appendChild(el('span', 'pmeta', `env: ${entry.requiredEnv.join(', ')}`));
|
|
5296
|
+
if (entry.installed) actions.appendChild(el('span', 'pmeta', 'instalado'));
|
|
5297
|
+
else actions.appendChild(button('agregar', (event) => { event.stopPropagation(); installCatalogMcp(entry.id); }));
|
|
5298
|
+
li.appendChild(actions);
|
|
5299
|
+
mcpCatalogEl.appendChild(li);
|
|
5300
|
+
}
|
|
5301
|
+
}
|
|
5302
|
+
|
|
5303
|
+
async function installCatalogMcp(id) {
|
|
5304
|
+
setStatus('#mcp-import-status', `agregando ${id}…`);
|
|
5305
|
+
const data = await api('/api/mcp/import', { method: 'POST', body: { catalog: id } }).catch((e) => ({ error: e.message }));
|
|
5306
|
+
if (data?.error) return setStatus('#mcp-import-status', data.error, 'err');
|
|
5307
|
+
setStatus('#mcp-import-status', `agregado ${id} (deshabilitado)`, 'ok');
|
|
5308
|
+
await refreshMcp();
|
|
5309
|
+
await refreshMcpCatalog();
|
|
5310
|
+
}
|
|
5311
|
+
|
|
5312
|
+
async function searchMcp() {
|
|
5313
|
+
const input = $('#mcp-search');
|
|
5314
|
+
const q = input?.value.trim();
|
|
5315
|
+
if (!q) return;
|
|
5316
|
+
setStatus('#mcp-search-status', 'buscando…');
|
|
5317
|
+
const data = await api(`/api/mcp/search?q=${encodeURIComponent(q)}`).catch((e) => ({ error: e.message }));
|
|
5318
|
+
if (data?.error) return setStatus('#mcp-search-status', data.error, 'err');
|
|
5319
|
+
clear(mcpSearchResultsEl);
|
|
5320
|
+
if (!data.results.length) {
|
|
5321
|
+
setStatus('#mcp-search-status', 'sin resultados');
|
|
5322
|
+
return;
|
|
5323
|
+
}
|
|
5324
|
+
setStatus('#mcp-search-status', `${data.results.length} resultado(s)`);
|
|
5325
|
+
for (const r of data.results) {
|
|
5326
|
+
const li = el('li', r.installed ? 'active' : '');
|
|
5327
|
+
const main = el('div', 'grow');
|
|
5328
|
+
main.append(el('div', '', r.name || r.id), el('div', 'pmeta', r.description || r.id));
|
|
5329
|
+
li.appendChild(main);
|
|
5330
|
+
const actions = el('div', 'pactions');
|
|
5331
|
+
if (r.requiredEnv?.length) actions.appendChild(el('span', 'pmeta', `env: ${r.requiredEnv.join(', ')}`));
|
|
5332
|
+
if (r.installed) actions.appendChild(el('span', 'pmeta', 'instalado'));
|
|
5333
|
+
else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installMcpFromRegistry(r.registry || r.name); }));
|
|
5334
|
+
li.appendChild(actions);
|
|
5335
|
+
mcpSearchResultsEl.appendChild(li);
|
|
5336
|
+
}
|
|
5337
|
+
}
|
|
5338
|
+
|
|
5339
|
+
async function installMcpFromRegistry(name) {
|
|
5340
|
+
setStatus('#mcp-search-status', `instalando ${name}…`);
|
|
5341
|
+
const data = await api('/api/mcp/import', { method: 'POST', body: { name } }).catch((e) => ({ error: e.message }));
|
|
5342
|
+
if (data?.error) return setStatus('#mcp-search-status', data.error, 'err');
|
|
5343
|
+
const required = (data.requiredEnv ?? []).length ? ` · requiere env: ${data.requiredEnv.join(', ')}` : '';
|
|
5344
|
+
setStatus('#mcp-search-status', `instalado ${data.imported}${required}`, 'ok');
|
|
5345
|
+
await refreshMcp();
|
|
5346
|
+
await refreshMcpCatalog();
|
|
5347
|
+
await searchMcp();
|
|
5348
|
+
}
|
|
5349
|
+
|
|
5350
|
+
async function refreshSkillCatalog() {
|
|
5351
|
+
if (!skillCatalogEl) return;
|
|
5352
|
+
const data = await api('/api/skills/catalog').catch(() => null);
|
|
5353
|
+
if (!data) return;
|
|
5354
|
+
clear(skillCatalogEl);
|
|
5355
|
+
for (const entry of data.catalog) {
|
|
5356
|
+
const li = el('li', entry.installed ? 'active' : '');
|
|
5357
|
+
const main = el('div', 'grow');
|
|
5358
|
+
main.append(el('div', '', entry.name), el('div', 'pmeta', entry.description || ''));
|
|
5359
|
+
li.appendChild(main);
|
|
5360
|
+
const actions = el('div', 'pactions');
|
|
5361
|
+
if (entry.installed) actions.appendChild(el('span', 'pmeta', 'instalada'));
|
|
5362
|
+
else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installCatalogSkill(entry.id); }));
|
|
5363
|
+
li.appendChild(actions);
|
|
5364
|
+
skillCatalogEl.appendChild(li);
|
|
5365
|
+
}
|
|
5366
|
+
}
|
|
5367
|
+
|
|
5368
|
+
async function installCatalogSkill(id) {
|
|
5369
|
+
setStatus('#skill-import-status', `instalando ${id}…`);
|
|
5370
|
+
const data = await api('/api/skills', { method: 'POST', body: { id } }).catch((e) => ({ error: e.message }));
|
|
5371
|
+
if (data?.error) return setStatus('#skill-import-status', data.error, 'err');
|
|
5372
|
+
setStatus('#skill-import-status', `instalada ${id} (deshabilitada)`, 'ok');
|
|
5373
|
+
await refreshSkills();
|
|
5374
|
+
await refreshSkillCatalog();
|
|
5375
|
+
}
|
|
5376
|
+
|
|
5377
|
+
async function searchSkills() {
|
|
5378
|
+
const input = $('#skill-search');
|
|
5379
|
+
const q = input?.value.trim();
|
|
5380
|
+
if (!q) return;
|
|
5381
|
+
setStatus('#skill-search-status', 'buscando…');
|
|
5382
|
+
const data = await api(`/api/skills/search?q=${encodeURIComponent(q)}`).catch((e) => ({ error: e.message }));
|
|
5383
|
+
if (data?.error) return setStatus('#skill-search-status', data.error, 'err');
|
|
5384
|
+
clear(skillSearchResultsEl);
|
|
5385
|
+
if (!data.results.length) {
|
|
5386
|
+
setStatus('#skill-search-status', 'sin resultados');
|
|
5387
|
+
return;
|
|
5388
|
+
}
|
|
5389
|
+
setStatus('#skill-search-status', `${data.results.length} resultado(s)`);
|
|
5390
|
+
for (const r of data.results) {
|
|
5391
|
+
const li = el('li', r.installed ? 'active' : '');
|
|
5392
|
+
const main = el('div', 'grow');
|
|
5393
|
+
main.append(el('div', '', r.source), el('div', 'pmeta', r.description || r.ref));
|
|
5394
|
+
li.appendChild(main);
|
|
5395
|
+
const actions = el('div', 'pactions');
|
|
5396
|
+
if (r.installed) actions.appendChild(el('span', 'pmeta', 'instalada'));
|
|
5397
|
+
else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installSkillRef(r.ref); }));
|
|
5398
|
+
li.appendChild(actions);
|
|
5399
|
+
skillSearchResultsEl.appendChild(li);
|
|
5400
|
+
}
|
|
5401
|
+
}
|
|
5402
|
+
|
|
5403
|
+
async function installSkillRef(ref) {
|
|
5404
|
+
setStatus('#skill-search-status', `instalando ${ref}…`);
|
|
5405
|
+
const data = await api('/api/skills/import', { method: 'POST', body: { ref } }).catch((e) => ({ error: e.message }));
|
|
5406
|
+
if (data?.error) return setStatus('#skill-search-status', data.error, 'err');
|
|
5407
|
+
setStatus('#skill-search-status', `instalada ${data.installed}`, 'ok');
|
|
5408
|
+
await refreshSkills();
|
|
5409
|
+
await refreshSkillCatalog();
|
|
5410
|
+
await searchSkills();
|
|
5411
|
+
}
|
|
5412
|
+
|
|
5023
5413
|
if (mcpListEl) {
|
|
5024
5414
|
$('#mcp-new').addEventListener('click', () => openMcpForm());
|
|
5025
5415
|
$('#mcp-import-btn')?.addEventListener('click', importMcp);
|
|
@@ -5036,11 +5426,52 @@ if (mcpListEl) {
|
|
|
5036
5426
|
importSkill();
|
|
5037
5427
|
}
|
|
5038
5428
|
});
|
|
5429
|
+
$('#mcp-search-btn')?.addEventListener('click', searchMcp);
|
|
5430
|
+
$('#mcp-search')?.addEventListener('keydown', (event) => {
|
|
5431
|
+
if (event.key === 'Enter') {
|
|
5432
|
+
event.preventDefault();
|
|
5433
|
+
searchMcp();
|
|
5434
|
+
}
|
|
5435
|
+
});
|
|
5436
|
+
$('#skill-search-btn')?.addEventListener('click', searchSkills);
|
|
5437
|
+
$('#skill-search')?.addEventListener('keydown', (event) => {
|
|
5438
|
+
if (event.key === 'Enter') {
|
|
5439
|
+
event.preventDefault();
|
|
5440
|
+
searchSkills();
|
|
5441
|
+
}
|
|
5442
|
+
});
|
|
5443
|
+
refreshMcpCatalog();
|
|
5444
|
+
refreshSkillCatalog();
|
|
5039
5445
|
$('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
|
|
5446
|
+
mcpModeEl?.addEventListener('change', async () => {
|
|
5447
|
+
try {
|
|
5448
|
+
await api('/api/settings', { method: 'PUT', body: { mcpMode: mcpModeEl.value } });
|
|
5449
|
+
await refreshMcp({ connect: true });
|
|
5450
|
+
} catch (error) {
|
|
5451
|
+
toast(error.message);
|
|
5452
|
+
}
|
|
5453
|
+
});
|
|
5454
|
+
const savePinnedMcp = async () => {
|
|
5455
|
+
try {
|
|
5456
|
+
await api('/api/settings', { method: 'PUT', body: { mcpPinned: String(mcpPinnedEl.value || '') } });
|
|
5457
|
+
await refreshMcp({ connect: true });
|
|
5458
|
+
} catch (error) {
|
|
5459
|
+
toast(error.message);
|
|
5460
|
+
}
|
|
5461
|
+
};
|
|
5462
|
+
$('#mcp-pinned-save')?.addEventListener('click', savePinnedMcp);
|
|
5463
|
+
mcpPinnedEl?.addEventListener('keydown', (event) => {
|
|
5464
|
+
if (event.key === 'Enter') {
|
|
5465
|
+
event.preventDefault();
|
|
5466
|
+
savePinnedMcp();
|
|
5467
|
+
}
|
|
5468
|
+
});
|
|
5040
5469
|
$('#tg-save')?.addEventListener('click', submitChannel);
|
|
5041
5470
|
$('#tg-test')?.addEventListener('click', testChannel);
|
|
5042
5471
|
$('#voz-save')?.addEventListener('click', submitVoz);
|
|
5043
5472
|
$('#voz-test')?.addEventListener('click', testVoz);
|
|
5473
|
+
vozRateEl?.addEventListener('input', renderVozSliders);
|
|
5474
|
+
vozPitchEl?.addEventListener('input', renderVozSliders);
|
|
5044
5475
|
$('#asesor-save')?.addEventListener('click', submitAsesor);
|
|
5045
5476
|
$('#asesor-test')?.addEventListener('click', testAsesor);
|
|
5046
5477
|
$('#mcp-close').addEventListener('click', closeMcpForm);
|
|
@@ -5523,6 +5954,7 @@ function refreshExplorerMode() {
|
|
|
5523
5954
|
if (explorerMode === 'files') refreshExplorer();
|
|
5524
5955
|
else if (explorerMode === 'procs') detectProcs();
|
|
5525
5956
|
else if (explorerMode === 'git') refreshGit();
|
|
5957
|
+
else if (explorerMode === 'preview') syncPreviewTunnel();
|
|
5526
5958
|
}
|
|
5527
5959
|
|
|
5528
5960
|
document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
|
|
@@ -5575,16 +6007,75 @@ const previewTools = setupPreviewTools({
|
|
|
5575
6007
|
teleport: true,
|
|
5576
6008
|
});
|
|
5577
6009
|
restorePreviewMax = () => previewTools.setMax(false);
|
|
5578
|
-
|
|
6010
|
+
|
|
6011
|
+
const PREVIEW_URL_KEY = 'tamnora-preview-url';
|
|
6012
|
+
const PREVIEW_DEVICE_KEY = 'tamnora-preview-device';
|
|
6013
|
+
|
|
6014
|
+
/** True si la consola corre en la misma maquina que el server (loopback). */
|
|
6015
|
+
function isLocalHost() {
|
|
6016
|
+
const host = String(location.hostname || '').toLowerCase();
|
|
6017
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]' || host.endsWith('.localhost');
|
|
6018
|
+
}
|
|
6019
|
+
|
|
6020
|
+
/** Puerto si la URL apunta a loopback (127.0.0.1/localhost/::1). */
|
|
6021
|
+
function localPortOf(url) {
|
|
6022
|
+
const match = String(url ?? '').match(/^https?:\/\/(?:localhost|127(?:\.\d+){3}|\[::1\]):(\d+)/i);
|
|
6023
|
+
return match ? Number(match[1]) : 0;
|
|
6024
|
+
}
|
|
6025
|
+
|
|
6026
|
+
function setTunnelUi(tunnel) {
|
|
6027
|
+
const closeBtn = $('#preview-close');
|
|
6028
|
+
if (closeBtn) closeBtn.hidden = !tunnel;
|
|
6029
|
+
if (tunnel) {
|
|
6030
|
+
const why = tunnel.provider === 'tailscale' ? 'privado (tailscale)' : tunnel.provider ? tunnel.provider : '';
|
|
6031
|
+
setPreviewStatus(`acceso remoto activo: ${tunnel.url}${why ? ` · ${why}` : ''}`);
|
|
6032
|
+
}
|
|
6033
|
+
}
|
|
6034
|
+
|
|
6035
|
+
async function syncPreviewTunnel({ load = false } = {}) {
|
|
6036
|
+
const data = await api('/api/preview').catch(() => null);
|
|
6037
|
+
const tunnel = data?.tunnel || null;
|
|
6038
|
+
setTunnelUi(tunnel);
|
|
6039
|
+
if (tunnel?.url && load) loadPreview(tunnel.url);
|
|
6040
|
+
return tunnel;
|
|
6041
|
+
}
|
|
6042
|
+
|
|
6043
|
+
/** Espera a que el server confirme el tunel si la respuesta del POST se corto. */
|
|
6044
|
+
async function pollPreviewTunnel(tries = 5, delayMs = 1200) {
|
|
6045
|
+
for (let i = 0; i < tries; i++) {
|
|
6046
|
+
const data = await api('/api/preview').catch(() => null);
|
|
6047
|
+
if (data?.tunnel?.url) return data.tunnel;
|
|
6048
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
6049
|
+
}
|
|
6050
|
+
return null;
|
|
6051
|
+
}
|
|
5579
6052
|
|
|
5580
6053
|
function loadPreview(url) {
|
|
5581
6054
|
const value = String(url ?? '').trim();
|
|
5582
6055
|
if (!value) return setPreviewStatus('Poné una URL (p. ej. http://127.0.0.1:5173).', true);
|
|
5583
6056
|
previewUrlInput.value = value;
|
|
5584
6057
|
previewFrame.src = value;
|
|
6058
|
+
storage.set(PREVIEW_URL_KEY, value);
|
|
5585
6059
|
setPreviewStatus(`cargando ${value} …`);
|
|
5586
6060
|
}
|
|
5587
6061
|
|
|
6062
|
+
/** Abre acceso remoto al puerto (tailscale privado o tunel publico). */
|
|
6063
|
+
async function openRemoteTunnel(port) {
|
|
6064
|
+
const provider = $('#preview-provider')?.value || 'tailscale';
|
|
6065
|
+
setPreviewStatus(`abriendo acceso remoto (${provider})…`);
|
|
6066
|
+
const data = await api('/api/preview/tunnel', { method: 'POST', body: { port, provider } }).catch((e) => ({ error: e.message }));
|
|
6067
|
+
if (data?.error) {
|
|
6068
|
+
// La respuesta pudo cortarse (p. ej. la tablet en background): el server pudo
|
|
6069
|
+
// haber abierto igual el tunel, asi que lo confirmamos antes de fallar.
|
|
6070
|
+
const tunnel = await pollPreviewTunnel();
|
|
6071
|
+
if (!tunnel) return setPreviewStatus(`error: ${data.error}`, true);
|
|
6072
|
+
setTunnelUi(tunnel);
|
|
6073
|
+
return loadPreview(tunnel.url);
|
|
6074
|
+
}
|
|
6075
|
+
setTunnelUi(data.tunnel);
|
|
6076
|
+
loadPreview(data.tunnel.url);
|
|
6077
|
+
}
|
|
6078
|
+
|
|
5588
6079
|
$('#preview-open')?.addEventListener('click', () => loadPreview(previewUrlInput.value));
|
|
5589
6080
|
previewUrlInput?.addEventListener('keydown', (event) => {
|
|
5590
6081
|
if (event.key === 'Enter') {
|
|
@@ -5595,16 +6086,29 @@ previewUrlInput?.addEventListener('keydown', (event) => {
|
|
|
5595
6086
|
previewFrame?.addEventListener('load', () => {
|
|
5596
6087
|
if (previewFrame.src) setPreviewStatus(previewFrame.src);
|
|
5597
6088
|
});
|
|
5598
|
-
$('#preview-tunnel')?.addEventListener('click',
|
|
6089
|
+
$('#preview-tunnel')?.addEventListener('click', () => {
|
|
5599
6090
|
const match = String(previewUrlInput.value).match(/:(\d+)/);
|
|
5600
6091
|
const port = match ? Number(match[1]) : 0;
|
|
5601
6092
|
if (!port) return setPreviewStatus('Poné una URL con puerto (p. ej. http://127.0.0.1:5173).', true);
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
6093
|
+
openRemoteTunnel(port);
|
|
6094
|
+
});
|
|
6095
|
+
$('#preview-close')?.addEventListener('click', async () => {
|
|
6096
|
+
await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null);
|
|
6097
|
+
setTunnelUi(null);
|
|
6098
|
+
setPreviewStatus('acceso remoto cerrado.');
|
|
5607
6099
|
});
|
|
6100
|
+
$('#preview-device')?.addEventListener('change', (event) => storage.set(PREVIEW_DEVICE_KEY, event.target.value));
|
|
6101
|
+
|
|
6102
|
+
// Restaurar lo tipeado y el tamano elegido: en la tablet la pagina se recarga
|
|
6103
|
+
// sola (iOS la descarta en background) y antes se perdia todo el estado.
|
|
6104
|
+
(function restorePreview() {
|
|
6105
|
+
const savedDevice = storage.get(PREVIEW_DEVICE_KEY, '');
|
|
6106
|
+
if (savedDevice && $('#preview-device')) $('#preview-device').value = savedDevice;
|
|
6107
|
+
previewTools.applyDevice();
|
|
6108
|
+
const savedUrl = storage.get(PREVIEW_URL_KEY, '');
|
|
6109
|
+
if (savedUrl) previewUrlInput.value = savedUrl;
|
|
6110
|
+
syncPreviewTunnel({ load: true });
|
|
6111
|
+
})();
|
|
5608
6112
|
|
|
5609
6113
|
/* procs: deteccion y dev servers */
|
|
5610
6114
|
|
|
@@ -5646,7 +6150,6 @@ async function refreshProcs() {
|
|
|
5646
6150
|
}
|
|
5647
6151
|
if (!data.procs.length) {
|
|
5648
6152
|
procsListEl.appendChild(el('li', 'muted', 'sin procesos corriendo'));
|
|
5649
|
-
return;
|
|
5650
6153
|
}
|
|
5651
6154
|
for (const proc of data.procs) {
|
|
5652
6155
|
const li = el('li', proc.running ? 'active' : '');
|
|
@@ -5658,11 +6161,42 @@ async function refreshProcs() {
|
|
|
5658
6161
|
li.appendChild(main);
|
|
5659
6162
|
const actions = el('div', 'pactions');
|
|
5660
6163
|
actions.appendChild(button('logs', () => { selectedProc = proc.id; refreshProcLog(); }));
|
|
5661
|
-
if (proc.port) actions.appendChild(button('preview', () =>
|
|
6164
|
+
if (proc.port) actions.appendChild(button('preview', () => openProcPreview(proc.port)));
|
|
5662
6165
|
if (proc.running) actions.appendChild(button('detener', () => stopProc(proc.id), 'btn danger'));
|
|
5663
6166
|
li.appendChild(actions);
|
|
5664
6167
|
procsListEl.appendChild(li);
|
|
5665
6168
|
}
|
|
6169
|
+
await renderTunnelRow();
|
|
6170
|
+
}
|
|
6171
|
+
|
|
6172
|
+
/** Fila del tunel activo: no es un proc (lo maneja la ruta preview), pero
|
|
6173
|
+
* conviene verlo y poder cerrarlo aca. */
|
|
6174
|
+
async function renderTunnelRow() {
|
|
6175
|
+
const data = await api('/api/preview').catch(() => null);
|
|
6176
|
+
const tunnel = data?.tunnel;
|
|
6177
|
+
if (!tunnel?.url) return;
|
|
6178
|
+
const li = el('li', 'active');
|
|
6179
|
+
const main = el('div', 'grow');
|
|
6180
|
+
main.append(
|
|
6181
|
+
el('div', '', `túnel → puerto ${tunnel.port}`),
|
|
6182
|
+
el('div', 'pmeta', `${tunnel.provider || ''} · ${tunnel.url}`)
|
|
6183
|
+
);
|
|
6184
|
+
li.appendChild(main);
|
|
6185
|
+
const actions = el('div', 'pactions');
|
|
6186
|
+
actions.appendChild(button('preview', () => { setExplorerMode('preview'); loadPreview(tunnel.url); }));
|
|
6187
|
+
actions.appendChild(
|
|
6188
|
+
button('detener', async () => { await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null); refreshProcs(); }, 'btn danger')
|
|
6189
|
+
);
|
|
6190
|
+
li.appendChild(actions);
|
|
6191
|
+
procsListEl.appendChild(li);
|
|
6192
|
+
}
|
|
6193
|
+
|
|
6194
|
+
/** Preview de un proc: local si la consola corre en la misma maquina, remoto
|
|
6195
|
+
* (tailscale/tunel) si se accede desde otro dispositivo. */
|
|
6196
|
+
async function openProcPreview(port) {
|
|
6197
|
+
setExplorerMode('preview');
|
|
6198
|
+
if (isLocalHost()) return loadPreview(`http://127.0.0.1:${port}`);
|
|
6199
|
+
return openRemoteTunnel(port);
|
|
5666
6200
|
}
|
|
5667
6201
|
|
|
5668
6202
|
async function startProc(chip) {
|
|
@@ -5993,7 +6527,7 @@ let baseTitle = '';
|
|
|
5993
6527
|
* tienen que marcar que hay un permiso esperando.
|
|
5994
6528
|
*/
|
|
5995
6529
|
function renderAlertSignals() {
|
|
5996
|
-
const pending = state.pendingAsks.size;
|
|
6530
|
+
const pending = state.pendingAsks.size + state.pendingQuestions.size;
|
|
5997
6531
|
if (!baseTitle) baseTitle = document.title;
|
|
5998
6532
|
const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
|
|
5999
6533
|
document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
|