@danieltmn/tamnora 0.26.0 → 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.
Files changed (71) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +28 -10
  3. package/harness/packages/cli/src/commands/server.js +10 -10
  4. package/harness/packages/cli/src/commands/update.js +1 -1
  5. package/harness/packages/core/src/agent/modes.js +2 -0
  6. package/harness/packages/core/src/index.js +4 -0
  7. package/harness/packages/core/src/mcp/registry.js +43 -2
  8. package/harness/packages/core/src/providers/capabilities.js +67 -0
  9. package/harness/packages/core/src/providers/index.js +25 -7
  10. package/harness/packages/core/src/providers/ollama.js +3 -2
  11. package/harness/packages/core/src/providers/utils.js +16 -6
  12. package/harness/packages/core/src/skills/remote.js +45 -12
  13. package/harness/packages/core/src/tools/executors.js +84 -0
  14. package/harness/packages/core/src/tools/labels.js +7 -0
  15. package/harness/packages/core/src/tools/permissions.js +3 -0
  16. package/harness/packages/core/src/tools/registry.js +3 -2
  17. package/harness/packages/core/src/tools/schema.js +53 -0
  18. package/harness/packages/core/test/capabilities.test.js +51 -0
  19. package/harness/packages/core/test/mcp-registry.test.js +63 -1
  20. package/harness/packages/core/test/providers.test.js +53 -1
  21. package/harness/packages/core/test/skills.test.js +66 -1
  22. package/harness/packages/core/test/tools.test.js +63 -0
  23. package/harness/packages/server/public/app.js +2 -0
  24. package/harness/packages/server/public/cards.js +22 -6
  25. package/harness/packages/server/public/chart.js +237 -0
  26. package/harness/packages/server/public/chat.js +7 -1
  27. package/harness/packages/server/public/chatFormat.js +15 -4
  28. package/harness/packages/server/public/chrome.js +10 -4
  29. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  30. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  31. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  32. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  33. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  34. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  35. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  36. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  37. package/harness/packages/server/public/icons/logo.png +0 -0
  38. package/harness/packages/server/public/icons/logo_light.png +0 -0
  39. package/harness/packages/server/public/index.html +9 -7
  40. package/harness/packages/server/public/logo.png +0 -0
  41. package/harness/packages/server/public/markdown.js +22 -0
  42. package/harness/packages/server/public/models.js +38 -7
  43. package/harness/packages/server/public/modelsQuery.js +44 -4
  44. package/harness/packages/server/public/settings.js +39 -1
  45. package/harness/packages/server/public/styles.css +33 -0
  46. package/harness/packages/server/src/lib/channels/telegram/media.js +11 -8
  47. package/harness/packages/server/src/lib/channels/telegram/run.js +5 -0
  48. package/harness/packages/server/src/lib/imagegen.js +201 -0
  49. package/harness/packages/server/src/lib/scheduler.js +2 -0
  50. package/harness/packages/server/src/lib/securityHeaders.js +1 -1
  51. package/harness/packages/server/src/lib/tasks.js +2 -0
  52. package/harness/packages/server/src/lib/transcribe.js +3 -1
  53. package/harness/packages/server/src/lib/ttsEngine.js +69 -0
  54. package/harness/packages/server/src/routes/chat.js +2 -0
  55. package/harness/packages/server/src/routes/files.js +8 -3
  56. package/harness/packages/server/src/routes/imagegen.js +35 -0
  57. package/harness/packages/server/src/routes/models.js +2 -1
  58. package/harness/packages/server/src/routes/tts.js +4 -2
  59. package/harness/packages/server/src/server.js +2 -0
  60. package/harness/packages/server/test/chart.test.js +70 -0
  61. package/harness/packages/server/test/chat.test.js +1 -0
  62. package/harness/packages/server/test/chatFormat.test.js +9 -5
  63. package/harness/packages/server/test/imagegen.test.js +215 -0
  64. package/harness/packages/server/test/markdown.test.js +20 -0
  65. package/harness/packages/server/test/markdownSecurity.test.js +25 -0
  66. package/harness/packages/server/test/modelsQuery.test.js +25 -1
  67. package/harness/packages/server/test/securityHeaders.test.js +1 -0
  68. package/harness/packages/server/test/ttsEngine.test.js +58 -0
  69. package/harness/scripts/self-update.mjs +1 -1
  70. package/package.json +1 -1
  71. package/harness/packages/server/public/icons (1).zip +0 -0
@@ -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, '&lt;')
26
+ .replace(/>/g, '&gt;')
27
+ .replace(/"/g, '&quot;')
28
+ .replace(/'/g, '&#39;');
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
- /** Nombre de archivo de una captura, del meta o del texto del resultado. */
96
- export function screenshotName(data, text) {
97
- const fromMeta = data?.meta?.screenshot?.name;
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) modelPill.title = `${provider || '—'} / ${model || '—'}`;
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.map((m) => `${m.id}\u0000${m.label ?? ''}\u0000${m.vision ? 1 : 0}\u0000${m.context ?? ''}`).join('\u0001');
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
- if (model.vision) option.textContent += ' - vision';
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.26.0</span></div>
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="deepseek" /></label>
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.deepseek.com/v1" /></label>
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="deepseek-chat, deepseek-reasoner" /></label>
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-none" class="btn">ninguno</button>
1212
- <button type="button" id="model-free" class="btn">solo gratis</button>
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 `![alt](src)`: 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
- list.appendChild(el('li', 'model-empty', picker.catalog.length ? 'sin coincidencias' : 'sin modelos'));
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 filterModels(picker.query, picker.catalog)) {
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', () => setDisplayed(() => true));
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 el label, sin distinguir mayúsculas.
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 = String(query ?? '').trim().toLowerCase();
39
+ const q = fold(query).trim();
12
40
  if (!q) return true;
13
- const hay = `${model?.id ?? ''} ${model?.label ?? ''}`.toLowerCase();
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