@danieltmn/tamnora 0.26.1 → 0.27.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 +37 -0
- package/README.md +28 -10
- package/harness/packages/cli/src/commands/server.js +10 -10
- package/harness/packages/cli/src/commands/update.js +1 -1
- package/harness/packages/core/src/agent/modes.js +2 -0
- package/harness/packages/core/src/index.js +4 -0
- package/harness/packages/core/src/mcp/registry.js +43 -2
- package/harness/packages/core/src/providers/capabilities.js +67 -0
- package/harness/packages/core/src/providers/index.js +25 -7
- package/harness/packages/core/src/providers/ollama.js +3 -2
- package/harness/packages/core/src/providers/utils.js +16 -6
- package/harness/packages/core/src/skills/remote.js +45 -12
- package/harness/packages/core/src/tools/executors.js +84 -0
- package/harness/packages/core/src/tools/labels.js +7 -0
- package/harness/packages/core/src/tools/permissions.js +3 -0
- package/harness/packages/core/src/tools/registry.js +3 -2
- package/harness/packages/core/src/tools/schema.js +53 -0
- package/harness/packages/core/test/capabilities.test.js +51 -0
- package/harness/packages/core/test/mcp-registry.test.js +63 -1
- package/harness/packages/core/test/providers.test.js +53 -1
- package/harness/packages/core/test/skills.test.js +66 -1
- package/harness/packages/core/test/tools.test.js +63 -0
- package/harness/packages/server/public/app.js +2 -0
- package/harness/packages/server/public/cards.js +22 -6
- package/harness/packages/server/public/chart.js +237 -0
- package/harness/packages/server/public/chat.js +7 -1
- package/harness/packages/server/public/chatFormat.js +15 -4
- package/harness/packages/server/public/chrome.js +10 -4
- package/harness/packages/server/public/index.html +9 -7
- package/harness/packages/server/public/markdown.js +22 -0
- package/harness/packages/server/public/models.js +38 -7
- package/harness/packages/server/public/modelsQuery.js +44 -4
- package/harness/packages/server/public/settings.js +39 -1
- package/harness/packages/server/public/styles.css +33 -0
- package/harness/packages/server/src/lib/channels/telegram/media.js +11 -8
- package/harness/packages/server/src/lib/channels/telegram/run.js +5 -0
- package/harness/packages/server/src/lib/imagegen.js +201 -0
- package/harness/packages/server/src/lib/scheduler.js +2 -0
- package/harness/packages/server/src/lib/securityHeaders.js +1 -1
- package/harness/packages/server/src/lib/tasks.js +2 -0
- package/harness/packages/server/src/lib/transcribe.js +3 -1
- package/harness/packages/server/src/lib/ttsEngine.js +69 -0
- package/harness/packages/server/src/routes/chat.js +2 -0
- package/harness/packages/server/src/routes/files.js +8 -3
- package/harness/packages/server/src/routes/imagegen.js +35 -0
- package/harness/packages/server/src/routes/models.js +2 -1
- package/harness/packages/server/src/routes/tts.js +4 -2
- package/harness/packages/server/src/server.js +2 -0
- package/harness/packages/server/test/chart.test.js +70 -0
- package/harness/packages/server/test/chat.test.js +1 -0
- package/harness/packages/server/test/chatFormat.test.js +9 -5
- package/harness/packages/server/test/imagegen.test.js +215 -0
- package/harness/packages/server/test/markdown.test.js +20 -0
- package/harness/packages/server/test/markdownSecurity.test.js +25 -0
- package/harness/packages/server/test/modelsQuery.test.js +25 -1
- package/harness/packages/server/test/securityHeaders.test.js +1 -0
- package/harness/packages/server/test/ttsEngine.test.js +58 -0
- package/harness/scripts/self-update.mjs +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Graficos SVG (barras, lineas, torta) para la tool `show_chart`. Sin
|
|
2
|
+
// dependencias y con todo el texto escapado: los datos vienen del modelo y no
|
|
3
|
+
// pueden inyectar markup. Es puro (sin DOM) para poder testearlo en Node.
|
|
4
|
+
|
|
5
|
+
const PALETTE = [
|
|
6
|
+
'#4e79a7',
|
|
7
|
+
'#f28e2b',
|
|
8
|
+
'#e15759',
|
|
9
|
+
'#76b7b2',
|
|
10
|
+
'#59a14f',
|
|
11
|
+
'#edc948',
|
|
12
|
+
'#b07aa1',
|
|
13
|
+
'#ff9da7',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const W = 720;
|
|
17
|
+
const H = 420;
|
|
18
|
+
const PAD = { top: 16, right: 16, bottom: 34, left: 64 };
|
|
19
|
+
const MAX_LABELS = 24;
|
|
20
|
+
const MAX_SERIES = 6;
|
|
21
|
+
|
|
22
|
+
function esc(value) {
|
|
23
|
+
return String(value ?? '')
|
|
24
|
+
.replace(/&/g, '&')
|
|
25
|
+
.replace(/</g, '<')
|
|
26
|
+
.replace(/>/g, '>')
|
|
27
|
+
.replace(/"/g, '"')
|
|
28
|
+
.replace(/'/g, ''');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fmt(value) {
|
|
32
|
+
const v = Number(value) || 0;
|
|
33
|
+
return String(Math.round(v * 100) / 100);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function shorten(value, max) {
|
|
37
|
+
const raw = String(value ?? '');
|
|
38
|
+
return raw.length > max ? `${raw.slice(0, max - 1)}…` : raw;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Redondeo "lindo" hacia arriba: 1/2/5 × 10^k. */
|
|
42
|
+
function niceCeil(value) {
|
|
43
|
+
const v = Number(value);
|
|
44
|
+
if (!Number.isFinite(v) || v <= 0) return 1;
|
|
45
|
+
const pow = 10 ** Math.floor(Math.log10(v));
|
|
46
|
+
const unit = v / pow;
|
|
47
|
+
return (unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 5 ? 5 : 10) * pow;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normaliza la spec de un grafico (labels y series alineados y numericos).
|
|
52
|
+
* Devuelve null si no queda nada para dibujar.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeChart(spec) {
|
|
55
|
+
const s = spec && typeof spec === 'object' ? spec : {};
|
|
56
|
+
const labels = (Array.isArray(s.labels) ? s.labels : [])
|
|
57
|
+
.slice(0, MAX_LABELS)
|
|
58
|
+
.map((l, idx) => String(l ?? '').trim() || `#${idx + 1}`);
|
|
59
|
+
if (!labels.length) return null;
|
|
60
|
+
const n = labels.length;
|
|
61
|
+
const series = (Array.isArray(s.series) ? s.series : [])
|
|
62
|
+
.filter((item) => item && typeof item === 'object' && Array.isArray(item.values))
|
|
63
|
+
.slice(0, MAX_SERIES)
|
|
64
|
+
.map((item, idx) => ({
|
|
65
|
+
name: shorten(String(item.name ?? '').trim() || `Serie ${idx + 1}`, 24),
|
|
66
|
+
values: Array.from({ length: n }, (_, i) => {
|
|
67
|
+
const v = Number(item.values[i]);
|
|
68
|
+
return Number.isFinite(v) ? v : 0;
|
|
69
|
+
}),
|
|
70
|
+
}));
|
|
71
|
+
if (!series.length) return null;
|
|
72
|
+
const type = ['bar', 'line', 'pie'].includes(s.type) ? s.type : 'bar';
|
|
73
|
+
if (type === 'pie' && series[0].values.every((v) => v <= 0)) return null;
|
|
74
|
+
return {
|
|
75
|
+
type,
|
|
76
|
+
title: shorten(String(s.title ?? '').trim(), 120),
|
|
77
|
+
unit: shorten(String(s.unit ?? '').trim(), 20),
|
|
78
|
+
labels,
|
|
79
|
+
series,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function gridAndAxes(series, unit, plot) {
|
|
84
|
+
const { x0, y0, plotW, plotH } = plot;
|
|
85
|
+
const values = series.flatMap((s) => s.values);
|
|
86
|
+
const max = Math.max(0, ...values);
|
|
87
|
+
const min = Math.min(0, ...values);
|
|
88
|
+
const yTop = niceCeil(max);
|
|
89
|
+
const yBottom = min < 0 ? -niceCeil(-min) : 0;
|
|
90
|
+
const y = (v) => y0 + (plotH * (yTop - v)) / (yTop - yBottom || 1);
|
|
91
|
+
|
|
92
|
+
const out = [];
|
|
93
|
+
for (let i = 0; i <= 4; i += 1) {
|
|
94
|
+
const value = yBottom + ((yTop - yBottom) * i) / 4;
|
|
95
|
+
const yy = y(value);
|
|
96
|
+
out.push(
|
|
97
|
+
`<line x1="${x0}" y1="${yy}" x2="${x0 + plotW}" y2="${yy}" stroke="currentColor" stroke-opacity="${
|
|
98
|
+
value === 0 ? 0.45 : 0.12
|
|
99
|
+
}" />`
|
|
100
|
+
);
|
|
101
|
+
const label = i === 4 && unit ? `${fmt(value)} ${unit}` : fmt(value);
|
|
102
|
+
out.push(
|
|
103
|
+
`<text class="chart-tick" x="${x0 - 8}" y="${yy + 4}" text-anchor="end">${esc(label)}</text>`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return { svg: out.join(''), y };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function xLabels(labels, plot) {
|
|
110
|
+
const { x0, y0, plotW, plotH } = plot;
|
|
111
|
+
const step = Math.ceil(labels.length / 12);
|
|
112
|
+
const groupW = plotW / labels.length;
|
|
113
|
+
const out = [];
|
|
114
|
+
labels.forEach((label, i) => {
|
|
115
|
+
if (i % step !== 0) return;
|
|
116
|
+
out.push(
|
|
117
|
+
`<text class="chart-tick" x="${x0 + groupW * (i + 0.5)}" y="${y0 + plotH + 20}" text-anchor="middle">${esc(
|
|
118
|
+
shorten(label, 12)
|
|
119
|
+
)}</text>`
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
return out.join('');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function legend(names, y) {
|
|
126
|
+
let x = PAD.left;
|
|
127
|
+
const out = [];
|
|
128
|
+
names.forEach((name, i) => {
|
|
129
|
+
out.push(`<rect x="${x}" y="${y - 10}" width="12" height="12" rx="2" fill="${PALETTE[i % PALETTE.length]}" />`);
|
|
130
|
+
out.push(`<text class="chart-legend" x="${x + 18}" y="${y}">${esc(name)}</text>`);
|
|
131
|
+
x += 30 + shorten(name, 18).length * 7;
|
|
132
|
+
});
|
|
133
|
+
return out.join('');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function barLineChart(spec) {
|
|
137
|
+
const { labels, series, unit, type } = spec;
|
|
138
|
+
const legendH = series.length > 1 ? 30 : 0;
|
|
139
|
+
const plot = {
|
|
140
|
+
x0: PAD.left,
|
|
141
|
+
y0: PAD.top,
|
|
142
|
+
plotW: W - PAD.left - PAD.right,
|
|
143
|
+
plotH: H - PAD.top - PAD.bottom - legendH,
|
|
144
|
+
};
|
|
145
|
+
const { svg: axes, y } = gridAndAxes(series, unit, plot);
|
|
146
|
+
const { x0, plotW } = plot;
|
|
147
|
+
const n = labels.length;
|
|
148
|
+
const groupW = plotW / n;
|
|
149
|
+
const zero = y(0);
|
|
150
|
+
const out = [axes];
|
|
151
|
+
|
|
152
|
+
if (type === 'line') {
|
|
153
|
+
series.forEach((s, j) => {
|
|
154
|
+
const points = s.values.map((v, i) => `${x0 + groupW * (i + 0.5)},${y(v)}`).join(' ');
|
|
155
|
+
out.push(
|
|
156
|
+
`<polyline points="${points}" fill="none" stroke="${PALETTE[j % PALETTE.length]}" stroke-width="2.5" stroke-linejoin="round" />`
|
|
157
|
+
);
|
|
158
|
+
s.values.forEach((v, i) => {
|
|
159
|
+
out.push(
|
|
160
|
+
`<circle cx="${x0 + groupW * (i + 0.5)}" cy="${y(v)}" r="3.5" fill="${PALETTE[j % PALETTE.length]}" />`
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
} else {
|
|
165
|
+
const barW = Math.min((groupW * 0.72) / series.length, 60);
|
|
166
|
+
series.forEach((s, j) => {
|
|
167
|
+
s.values.forEach((v, i) => {
|
|
168
|
+
const yy = y(v);
|
|
169
|
+
const top = Math.min(yy, zero);
|
|
170
|
+
const height = Math.max(Math.abs(zero - yy), 1);
|
|
171
|
+
out.push(
|
|
172
|
+
`<rect x="${x0 + groupW * i + (groupW - barW * series.length) / 2 + barW * j}" y="${top}" width="${
|
|
173
|
+
barW - 2
|
|
174
|
+
}" height="${height}" rx="2" fill="${PALETTE[j % PALETTE.length]}" />`
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
out.push(xLabels(labels, plot));
|
|
181
|
+
if (series.length > 1) out.push(legend(series.map((s) => s.name), H - 12));
|
|
182
|
+
return out.join('');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function pieChart(spec) {
|
|
186
|
+
const { labels, series, unit } = spec;
|
|
187
|
+
const values = series[0].values.map((v) => Math.max(0, v));
|
|
188
|
+
const total = values.reduce((a, b) => a + b, 0);
|
|
189
|
+
if (total <= 0) return '';
|
|
190
|
+
const cx = 220;
|
|
191
|
+
const cy = H / 2;
|
|
192
|
+
const r = 150;
|
|
193
|
+
const out = [];
|
|
194
|
+
let angle = -Math.PI / 2;
|
|
195
|
+
values.forEach((v, i) => {
|
|
196
|
+
const slice = (v / total) * Math.PI * 2;
|
|
197
|
+
const color = PALETTE[i % PALETTE.length];
|
|
198
|
+
if (slice >= Math.PI * 2 - 1e-6) {
|
|
199
|
+
out.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}" />`);
|
|
200
|
+
} else if (slice > 1e-6) {
|
|
201
|
+
const x1 = cx + r * Math.cos(angle);
|
|
202
|
+
const y1 = cy + r * Math.sin(angle);
|
|
203
|
+
const x2 = cx + r * Math.cos(angle + slice);
|
|
204
|
+
const y2 = cy + r * Math.sin(angle + slice);
|
|
205
|
+
const large = slice > Math.PI ? 1 : 0;
|
|
206
|
+
out.push(
|
|
207
|
+
`<path d="M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z" fill="${color}" />`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
angle += slice;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const startY = cy - (labels.length * 26) / 2 + 8;
|
|
214
|
+
labels.forEach((label, i) => {
|
|
215
|
+
const pct = Math.round((values[i] / total) * 1000) / 10;
|
|
216
|
+
const yy = startY + i * 26;
|
|
217
|
+
out.push(`<rect x="420" y="${yy - 11}" width="12" height="12" rx="2" fill="${PALETTE[i % PALETTE.length]}" />`);
|
|
218
|
+
out.push(
|
|
219
|
+
`<text class="chart-legend" x="440" y="${yy}">${esc(shorten(label, 22))} — ${esc(fmt(values[i]))}${
|
|
220
|
+
unit ? ` ${esc(unit)}` : ''
|
|
221
|
+
} (${esc(pct)}%)</text>`
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
return out.join('');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Renderiza un grafico como SVG. Devuelve '' si la spec no tiene datos. */
|
|
228
|
+
export function renderChart(spec) {
|
|
229
|
+
const chart = normalizeChart(spec);
|
|
230
|
+
if (!chart) return '';
|
|
231
|
+
const body = chart.type === 'pie' ? pieChart(chart) : barLineChart(chart);
|
|
232
|
+
const label = chart.title || `gráfico de ${chart.type === 'pie' ? 'torta' : chart.type === 'line' ? 'líneas' : 'barras'}`;
|
|
233
|
+
return (
|
|
234
|
+
`<svg class="chart" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" ` +
|
|
235
|
+
`role="img" aria-label="${esc(label)}" preserveAspectRatio="xMidYMid meet">${body}</svg>`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -16,11 +16,11 @@ export {
|
|
|
16
16
|
READ_ONLY_TOOLS,
|
|
17
17
|
basename,
|
|
18
18
|
formatDuration,
|
|
19
|
+
imageName,
|
|
19
20
|
isFailedToolText,
|
|
20
21
|
isReadOnlyTool,
|
|
21
22
|
parseToolArgs,
|
|
22
23
|
previewText,
|
|
23
|
-
screenshotName,
|
|
24
24
|
toolSummary,
|
|
25
25
|
userImages,
|
|
26
26
|
userText,
|
|
@@ -51,6 +51,7 @@ import { setStatus } from './settings.js';
|
|
|
51
51
|
import { reconcile, renderAlertSignals } from './system.js';
|
|
52
52
|
import { narrar, reproducirVoz } from './voice.js';
|
|
53
53
|
import {
|
|
54
|
+
addChartCard,
|
|
54
55
|
addChoiceCard,
|
|
55
56
|
addCompactCard,
|
|
56
57
|
addPermissionCard,
|
|
@@ -66,6 +67,7 @@ import {
|
|
|
66
67
|
toolCardIndex,} from './cards.js';
|
|
67
68
|
export {
|
|
68
69
|
PLAN_MARKS,
|
|
70
|
+
addChartCard,
|
|
69
71
|
addChoiceCard,
|
|
70
72
|
addCompactCard,
|
|
71
73
|
addPermissionCard,
|
|
@@ -447,6 +449,7 @@ export function renderStored(message, pane) {
|
|
|
447
449
|
if (call.id) toolCardIndex.set(call.id, tool);
|
|
448
450
|
if (name === 'offer_choices') addChoiceCard(bubble, { question: args.question, options: args.options });
|
|
449
451
|
if (name === 'show_plan') addPlanCard(bubble, { title: args.title, steps: args.steps });
|
|
452
|
+
if (name === 'show_chart') addChartCard(bubble, args);
|
|
450
453
|
}
|
|
451
454
|
} else if (message.role === 'tool') {
|
|
452
455
|
const tool = toolCardIndex.get(message.tool_call_id);
|
|
@@ -823,6 +826,9 @@ export function handleChatEvent(event, data, turn) {
|
|
|
823
826
|
if (turn.live && data.name === 'show_plan') {
|
|
824
827
|
addPlanCard(target, { title: data.args?.title, steps: data.args?.steps });
|
|
825
828
|
}
|
|
829
|
+
if (turn.live && data.name === 'show_chart') {
|
|
830
|
+
addChartCard(target, data.args);
|
|
831
|
+
}
|
|
826
832
|
} else if (event === 'tool_result') {
|
|
827
833
|
setActivity(turn.assistant(), 'thinking');
|
|
828
834
|
finishToolCard(turn.lastTool, data);
|
|
@@ -57,6 +57,13 @@ export function toolSummary(name, args) {
|
|
|
57
57
|
return shortText(a.url, 90);
|
|
58
58
|
case 'speak':
|
|
59
59
|
return shortText(a.texto, 90);
|
|
60
|
+
case 'show_chart': {
|
|
61
|
+
const tipo = a.type === 'pie' ? 'torta' : a.type === 'line' ? 'líneas' : 'barras';
|
|
62
|
+
const datos = Array.isArray(a.labels) ? a.labels.length : 0;
|
|
63
|
+
return [a.title ? shortText(a.title, 40) : '', datos ? `${tipo} · ${datos} datos` : tipo].filter(Boolean).join(' · ');
|
|
64
|
+
}
|
|
65
|
+
case 'generate_image':
|
|
66
|
+
return shortText(a.prompt, 90);
|
|
60
67
|
case 'screenshot':
|
|
61
68
|
case 'console_errors':
|
|
62
69
|
case 'preview_url':
|
|
@@ -92,11 +99,15 @@ export function isFailedToolText(text) {
|
|
|
92
99
|
return /^Error|^Permiso denegado/i.test(String(text ?? ''));
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Nombre de archivo de una imagen del tool (`screenshot` o `generate_image`),
|
|
104
|
+
* del meta o del texto del resultado. Las imagenes viven en la casa y se sirven
|
|
105
|
+
* por `/api/screenshots/:name`.
|
|
106
|
+
*/
|
|
107
|
+
export function imageName(data, text) {
|
|
108
|
+
const fromMeta = data?.meta?.image?.name ?? data?.meta?.screenshot?.name;
|
|
98
109
|
if (fromMeta) return String(fromMeta);
|
|
99
|
-
const match = /capturas\/([a-z0-9][a-z0-9-]*\.png)/i.exec(String(text ?? ''));
|
|
110
|
+
const match = /capturas\/([a-z0-9][a-z0-9-]*\.(?:png|jpe?g|webp|gif))/i.exec(String(text ?? ''));
|
|
100
111
|
return match ? match[1] : null;
|
|
101
112
|
}
|
|
102
113
|
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { basename, chatInput, scheduleScrollNav, sendMessage } from './chat.js';
|
|
13
13
|
import { $, api, clear, el, state, storage } from './core.js';
|
|
14
14
|
import { refreshExplorerMode } from './explorer.js';
|
|
15
|
-
import { pickVisible } from './modelsQuery.js';
|
|
15
|
+
import { capabilityTags, pickVisible } from './modelsQuery.js';
|
|
16
16
|
import { activateSession, registerSession } from './sessions.js';
|
|
17
17
|
import {
|
|
18
18
|
activateAjustesPanel,
|
|
@@ -212,7 +212,10 @@ export function renderModelPill() {
|
|
|
212
212
|
const provider = record?.provider || state.active.provider;
|
|
213
213
|
const model = record?.model || state.active.model;
|
|
214
214
|
modelPillLabel.textContent = model || 'elegí un modelo';
|
|
215
|
-
if (modelPill)
|
|
215
|
+
if (modelPill) {
|
|
216
|
+
const caps = capabilityTags(state.modelCatalog?.find((m) => m.id === model)?.capabilities);
|
|
217
|
+
modelPill.title = `${provider || '—'} / ${model || '—'}${caps.length ? `\n${caps.join(' · ')}` : ''}`;
|
|
218
|
+
}
|
|
216
219
|
}
|
|
217
220
|
|
|
218
221
|
export function closeModeMenu() {
|
|
@@ -627,7 +630,9 @@ export async function applySessionAgent(record) {
|
|
|
627
630
|
|
|
628
631
|
/** Firma de la lista dibujada: si no cambia, no hay que repintar el select. */
|
|
629
632
|
function modelsSignature(shown) {
|
|
630
|
-
return shown
|
|
633
|
+
return shown
|
|
634
|
+
.map((m) => `${m.id}\u0000${m.label ?? ''}\u0000${m.vision ? 1 : 0}\u0000${m.context ?? ''}\u0000${capabilityTags(m.capabilities).join(',')}`)
|
|
635
|
+
.join('\u0001');
|
|
631
636
|
}
|
|
632
637
|
|
|
633
638
|
/**
|
|
@@ -686,7 +691,8 @@ export async function loadModels({ save = false, prefer = null, force = false }
|
|
|
686
691
|
const option = document.createElement('option');
|
|
687
692
|
option.value = model.id;
|
|
688
693
|
option.textContent = model.label && model.label !== model.id ? model.label : model.id;
|
|
689
|
-
|
|
694
|
+
const caps = capabilityTags(model.capabilities).slice(0, 3);
|
|
695
|
+
if (caps.length) option.textContent += ` - ${caps.join(' · ')}`;
|
|
690
696
|
if (model.context) option.dataset.context = String(model.context);
|
|
691
697
|
modelSelect.appendChild(option);
|
|
692
698
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
</head>
|
|
21
21
|
<body>
|
|
22
22
|
<header class="topbar">
|
|
23
|
-
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <button id="back-chat" type="button" class="back-chat" title="volver al chat" aria-label="volver al chat" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg><span>chat</span></button> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.
|
|
23
|
+
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <button id="back-chat" type="button" class="back-chat" title="volver al chat" aria-label="volver al chat" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg><span>chat</span></button> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.27.0</span></div>
|
|
24
24
|
<div class="settings">
|
|
25
25
|
<button id="new-session-top" type="button" class="btn topbar-new" title="nueva sesión" aria-label="nueva sesión">
|
|
26
26
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
|
@@ -402,13 +402,13 @@
|
|
|
402
402
|
</button>
|
|
403
403
|
</div>
|
|
404
404
|
<div class="row">
|
|
405
|
-
<label class="field"><span>id</span><input id="pf-id" spellcheck="false" placeholder="
|
|
405
|
+
<label class="field"><span>id</span><input id="pf-id" spellcheck="false" placeholder="openai" /></label>
|
|
406
406
|
<label class="field"><span>nombre</span><input id="pf-label" spellcheck="false" placeholder="DeepSeek" /></label>
|
|
407
|
-
<label class="field grow"><span>base URL</span><input id="pf-baseurl" spellcheck="false" placeholder="https://api.
|
|
407
|
+
<label class="field grow"><span>base URL</span><input id="pf-baseurl" spellcheck="false" placeholder="https://api.ejemplo.com/v1" /></label>
|
|
408
408
|
</div>
|
|
409
409
|
<div class="row">
|
|
410
410
|
<label class="field grow"><span>API key</span><input id="pf-key" type="password" spellcheck="false" placeholder="dejar vacío para no cambiar" /></label>
|
|
411
|
-
<label class="field grow"><span>modelos (coma, opcional)</span><input id="pf-models" spellcheck="false" placeholder="
|
|
411
|
+
<label class="field grow"><span>modelos (coma, opcional)</span><input id="pf-models" spellcheck="false" placeholder="modelo-1, modelo-2" /></label>
|
|
412
412
|
</div>
|
|
413
413
|
<div class="row">
|
|
414
414
|
<button type="button" id="pf-test" class="btn">probar conexión</button>
|
|
@@ -1207,11 +1207,13 @@
|
|
|
1207
1207
|
<input id="model-query" aria-label="buscar modelos" type="text" placeholder="buscar por nombre o id…" spellcheck="false" autocomplete="off" />
|
|
1208
1208
|
</div>
|
|
1209
1209
|
<div class="model-actions">
|
|
1210
|
-
<button type="button" id="model-all" class="btn">todos</button>
|
|
1211
|
-
<button type="button" id="model-
|
|
1212
|
-
<button type="button" id="model-
|
|
1210
|
+
<button type="button" id="model-all" class="btn" aria-pressed="true">todos</button>
|
|
1211
|
+
<button type="button" id="model-free" class="btn" aria-pressed="false">gratis</button>
|
|
1212
|
+
<button type="button" id="model-sel" class="btn" aria-pressed="false">seleccionados</button>
|
|
1213
1213
|
<span class="spacer"></span>
|
|
1214
1214
|
<span id="model-count" class="summary"></span>
|
|
1215
|
+
<button type="button" id="model-bulk-all" class="btn">marcar todo</button>
|
|
1216
|
+
<button type="button" id="model-none" class="btn">vaciar</button>
|
|
1215
1217
|
</div>
|
|
1216
1218
|
<ul id="model-list" class="model-list"></ul>
|
|
1217
1219
|
<div class="row between settings-actions">
|
|
@@ -109,6 +109,20 @@ export function highlight(code, lang) {
|
|
|
109
109
|
return highlightCode(code, language);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Fuentes de imagen seguras para ``: URLs http(s), data-URLs de
|
|
114
|
+
* imagen y rutas del propio server (`/api/screenshots/...`, `capturas/...`).
|
|
115
|
+
* Todo lo demas (javascript:, data:text/html, ...) no se renderiza como imagen.
|
|
116
|
+
*/
|
|
117
|
+
export function safeImageSrc(url) {
|
|
118
|
+
const value = String(url ?? '').trim();
|
|
119
|
+
if (/^https?:\/\/[^\s)]+$/i.test(value)) return value;
|
|
120
|
+
if (/^data:image\/(png|jpe?g|gif|webp|avif);base64,[a-z0-9+/=]+$/i.test(value)) return value;
|
|
121
|
+
if (/^\/(?!\/)[^\s)]+$/.test(value)) return value;
|
|
122
|
+
if (/^capturas\/[a-z0-9][a-z0-9-]*\.(png|jpe?g|webp|gif)$/i.test(value)) return value;
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
112
126
|
function inline(text) {
|
|
113
127
|
let out = escapeHtml(text).replace(/\uE000/g, '');
|
|
114
128
|
const codes = [];
|
|
@@ -120,6 +134,14 @@ function inline(text) {
|
|
|
120
134
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
121
135
|
.replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
|
122
136
|
.replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>')
|
|
137
|
+
.replace(/!\[([^\]]*)\]\(([^\s)]+)\)/g, (match, alt, src) => {
|
|
138
|
+
const safe = safeImageSrc(src);
|
|
139
|
+
if (!safe) return match;
|
|
140
|
+
// El alt puede traer marcadores de codigo inline: se restauran a texto
|
|
141
|
+
// plano para no meter markup dentro del atributo.
|
|
142
|
+
const altText = alt.replace(/\uE000(\d+)\uE000/g, (_, idx) => codes[Number(idx)] ?? '');
|
|
143
|
+
return `<img class="md-img" src="${safe}" alt="${altText}" loading="lazy" referrerpolicy="no-referrer">`;
|
|
144
|
+
})
|
|
123
145
|
.replace(
|
|
124
146
|
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
|
125
147
|
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
// proveedor: vacio = "mostrar todo"; un subconjunto = "mostrar estos".
|
|
5
5
|
import { $, api, clear, el, state } from './core.js';
|
|
6
6
|
import { loadModels, providerSelect } from './chrome.js';
|
|
7
|
-
import { filterModels } from './modelsQuery.js';
|
|
7
|
+
import { applyView, capabilityTags, filterModels } from './modelsQuery.js';
|
|
8
8
|
|
|
9
|
-
const picker = { catalog: [], checked: new Set(), query: '' };
|
|
9
|
+
const picker = { catalog: [], checked: new Set(), query: '', view: 'all' };
|
|
10
|
+
|
|
11
|
+
const VIEW_BUTTONS = { all: '#model-all', free: '#model-free', selected: '#model-sel' };
|
|
10
12
|
|
|
11
13
|
function fmtPrice(n) {
|
|
12
14
|
return n == null ? '?' : Number.isInteger(n) ? String(n) : n.toFixed(2);
|
|
@@ -35,14 +37,36 @@ function setModelStatus(text, cls = '') {
|
|
|
35
37
|
status.className = `summary${cls ? ` ${cls}` : ''}`;
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** Marca el chip de vista activo (los otros son solo vistas, no acciones). */
|
|
41
|
+
function syncViewButtons() {
|
|
42
|
+
for (const [view, selector] of Object.entries(VIEW_BUTTONS)) {
|
|
43
|
+
const btn = $(selector);
|
|
44
|
+
if (!btn) continue;
|
|
45
|
+
const active = view === picker.view;
|
|
46
|
+
btn.classList.toggle('active', active);
|
|
47
|
+
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setView(view) {
|
|
52
|
+
picker.view = view;
|
|
53
|
+
syncViewButtons();
|
|
54
|
+
renderModelPicker();
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
function renderModelPicker() {
|
|
39
58
|
const list = $('#model-list');
|
|
40
59
|
if (!list) return;
|
|
41
60
|
clear(list);
|
|
42
|
-
const shown = filterModels(picker.query, picker.catalog);
|
|
61
|
+
const shown = applyView(picker.view, filterModels(picker.query, picker.catalog), picker.checked);
|
|
43
62
|
|
|
44
63
|
if (!shown.length) {
|
|
45
|
-
|
|
64
|
+
const empty = picker.catalog.length
|
|
65
|
+
? picker.view === 'selected'
|
|
66
|
+
? 'sin modelos seleccionados'
|
|
67
|
+
: 'sin coincidencias'
|
|
68
|
+
: 'sin modelos';
|
|
69
|
+
list.appendChild(el('li', 'model-empty', empty));
|
|
46
70
|
updateModelCount();
|
|
47
71
|
return;
|
|
48
72
|
}
|
|
@@ -57,6 +81,8 @@ function renderModelPicker() {
|
|
|
57
81
|
if (cb.checked) picker.checked.add(model.id);
|
|
58
82
|
else picker.checked.delete(model.id);
|
|
59
83
|
updateModelCount();
|
|
84
|
+
// En la vista "seleccionados" un modelo sin tildar deja de pertenecer.
|
|
85
|
+
if (picker.view === 'selected') renderModelPicker();
|
|
60
86
|
});
|
|
61
87
|
|
|
62
88
|
label.appendChild(cb);
|
|
@@ -64,6 +90,7 @@ function renderModelPicker() {
|
|
|
64
90
|
el('span', 'model-item-name', model.label && model.label !== model.id ? model.label : model.id)
|
|
65
91
|
);
|
|
66
92
|
label.appendChild(el('span', 'model-item-id', model.id));
|
|
93
|
+
for (const tag of capabilityTags(model.capabilities)) label.appendChild(el('span', 'model-cap', tag));
|
|
67
94
|
label.appendChild(priceEl(model));
|
|
68
95
|
li.appendChild(label);
|
|
69
96
|
list.appendChild(li);
|
|
@@ -72,7 +99,7 @@ function renderModelPicker() {
|
|
|
72
99
|
}
|
|
73
100
|
|
|
74
101
|
function setDisplayed(predicate) {
|
|
75
|
-
for (const m of
|
|
102
|
+
for (const m of picker.catalog) {
|
|
76
103
|
if (predicate(m)) picker.checked.add(m.id);
|
|
77
104
|
else picker.checked.delete(m.id);
|
|
78
105
|
}
|
|
@@ -94,10 +121,12 @@ export async function openModelPicker() {
|
|
|
94
121
|
const selected = Array.isArray(state.visibleModels) ? state.visibleModels : [];
|
|
95
122
|
picker.checked = new Set(selected.length ? selected : catalog.map((m) => m.id));
|
|
96
123
|
picker.query = '';
|
|
124
|
+
picker.view = 'all';
|
|
97
125
|
|
|
98
126
|
const queryInput = $('#model-query');
|
|
99
127
|
if (queryInput) queryInput.value = '';
|
|
100
128
|
setModelStatus('');
|
|
129
|
+
syncViewButtons();
|
|
101
130
|
renderModelPicker();
|
|
102
131
|
$('#model-modal')?.showModal();
|
|
103
132
|
}
|
|
@@ -141,7 +170,9 @@ export function wireModelPicker() {
|
|
|
141
170
|
picker.query = event.target.value;
|
|
142
171
|
renderModelPicker();
|
|
143
172
|
});
|
|
144
|
-
$('#model-all')?.addEventListener('click', () =>
|
|
173
|
+
$('#model-all')?.addEventListener('click', () => setView('all'));
|
|
174
|
+
$('#model-free')?.addEventListener('click', () => setView('free'));
|
|
175
|
+
$('#model-sel')?.addEventListener('click', () => setView('selected'));
|
|
176
|
+
$('#model-bulk-all')?.addEventListener('click', () => setDisplayed(() => true));
|
|
145
177
|
$('#model-none')?.addEventListener('click', () => setDisplayed(() => false));
|
|
146
|
-
$('#model-free')?.addEventListener('click', () => setDisplayed((m) => Boolean(m.free)));
|
|
147
178
|
}
|
|
@@ -2,15 +2,43 @@
|
|
|
2
2
|
// Vive separada del picker (`models.js`) porque ese importa `chrome.js`, que
|
|
3
3
|
// toca el DOM al cargarse; este modulo no importa nada.
|
|
4
4
|
|
|
5
|
+
// Mismo vocabulario que `core/src/providers/capabilities.js` (el cliente no
|
|
6
|
+
// puede importar del core: esto se sirve como modulo estatico del navegador).
|
|
7
|
+
export const CAP_TAGS = {
|
|
8
|
+
vision: 'visión',
|
|
9
|
+
image: 'imagen',
|
|
10
|
+
video: 'video',
|
|
11
|
+
tts: 'voz',
|
|
12
|
+
stt: 'escucha',
|
|
13
|
+
tools: 'tools',
|
|
14
|
+
reasoning: 'razona',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Etiquetas activas de un modelo, para badges y para buscar. */
|
|
18
|
+
export function capabilityTags(caps) {
|
|
19
|
+
const source = caps && typeof caps === 'object' ? caps : {};
|
|
20
|
+
return Object.entries(CAP_TAGS)
|
|
21
|
+
.filter(([key]) => source[key])
|
|
22
|
+
.map(([, label]) => label);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Sin acentos ni mayúsculas, para que "vision" encuentre "visión". */
|
|
26
|
+
function fold(text) {
|
|
27
|
+
return String(text ?? '')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.normalize('NFD')
|
|
30
|
+
.replace(/\p{M}/gu, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
5
33
|
/**
|
|
6
34
|
* ¿El modelo coincide con la consulta? La consulta se parte en términos y
|
|
7
|
-
* todos deben aparecer (AND) en el id o
|
|
8
|
-
* Vacío coincide con todo.
|
|
35
|
+
* todos deben aparecer (AND) en el id, el label o sus capacidades, sin
|
|
36
|
+
* distinguir mayúsculas ni acentos. Vacío coincide con todo.
|
|
9
37
|
*/
|
|
10
38
|
export function matchModel(query, model) {
|
|
11
|
-
const q =
|
|
39
|
+
const q = fold(query).trim();
|
|
12
40
|
if (!q) return true;
|
|
13
|
-
const hay = `${model?.id ?? ''} ${model?.label ?? ''}
|
|
41
|
+
const hay = fold(`${model?.id ?? ''} ${model?.label ?? ''} ${capabilityTags(model?.capabilities).join(' ')}`);
|
|
14
42
|
return q.split(/\s+/).every((term) => hay.includes(term));
|
|
15
43
|
}
|
|
16
44
|
|
|
@@ -19,6 +47,18 @@ export function filterModels(query, models) {
|
|
|
19
47
|
return Array.isArray(models) ? models.filter((m) => matchModel(query, m)) : [];
|
|
20
48
|
}
|
|
21
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Vista del picker: `'all'` (todo el catálogo), `'free'` (solo gratis) o
|
|
52
|
+
* `'selected'` (solo lo tildado). `checked` es un Set o lista de ids.
|
|
53
|
+
*/
|
|
54
|
+
export function applyView(view, models, checked = []) {
|
|
55
|
+
const list = Array.isArray(models) ? models : [];
|
|
56
|
+
const ids = checked instanceof Set ? checked : new Set(checked ?? []);
|
|
57
|
+
if (view === 'free') return list.filter((m) => Boolean(m?.free));
|
|
58
|
+
if (view === 'selected') return list.filter((m) => ids.has(m?.id));
|
|
59
|
+
return list;
|
|
60
|
+
}
|
|
61
|
+
|
|
22
62
|
/**
|
|
23
63
|
* Aplica la seleccion guardada a un catalogo. Sin seleccion devuelve todo.
|
|
24
64
|
* Espeja `selectVisibleModels` del core para poder recalcular en el cliente sin
|