@danieltmn/openbridge 0.1.0 → 0.2.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 +11 -0
- package/README.md +1 -0
- package/package.json +9 -1
- package/src/bridge/bridge.js +8 -0
- package/src/cli.js +43 -0
- package/src/qr.js +224 -0
- package/src/web/assets/app.js +138 -8
- package/src/web/templates/chat.html +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ Todos los cambios relevantes de OpenBridge. Formato basado en
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y
|
|
5
5
|
[Versionado Semantico](https://semver.org/lang/es/).
|
|
6
6
|
|
|
7
|
+
## [0.2.0] - 2026-09-13
|
|
8
|
+
|
|
9
|
+
### Cambios
|
|
10
|
+
|
|
11
|
+
- web: muestra el costo acumulado por sesion y total
|
|
12
|
+
- cli: QR de la URL (init/status/server y comando qr)
|
|
13
|
+
- web: dictado por voz en el compositor (Web Speech API)
|
|
14
|
+
- release: exporta logica de version/changelog y agrega tests
|
|
15
|
+
- release: proceso privado con guard de dueno, semver y dist-tags
|
|
16
|
+
- UI: porcentaje de contexto, boton ver archivos y badge plan; refresco de tokens al responder
|
|
17
|
+
|
|
7
18
|
## [0.1.0] - 2026-09-11
|
|
8
19
|
|
|
9
20
|
Primera version publicada. Paquete npm **`@danieltmn/openbridge`** y repo
|
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop
|
|
|
71
71
|
| `openbridge server` | Arranca app + puente + túnel en **segundo plano** y muestra el estado (`--stream` = primer plano) |
|
|
72
72
|
| `openbridge stop` | Detiene el server y su árbol de procesos |
|
|
73
73
|
| `openbridge status` | Estado, URL, puente en línea y chats |
|
|
74
|
+
| `openbridge qr` | Muestra la URL (pública o local) como QR para escanear desde el celular |
|
|
74
75
|
| `openbridge logs` | Logs (`--follow`, `--server`, `--bridge`) |
|
|
75
76
|
| `openbridge bridge` | Corre **solo** el puente (`--api --token --id --name`) |
|
|
76
77
|
| `openbridge import` | Trae `data/` de OpenConex (`<data-dir> [--force]`) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danieltmn/openbridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Tu opencode en el celular, sin hosting: corre la app, el puente y un tunel publico desde tu PC.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"author": "tamnora",
|
|
@@ -43,8 +43,16 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"start": "node bin/openbridge.js server",
|
|
45
45
|
"test": "node --test",
|
|
46
|
+
"release": "node scripts/release.mjs",
|
|
47
|
+
"release:dry": "node scripts/release.mjs --dry-run",
|
|
46
48
|
"prepublishOnly": "npm test"
|
|
47
49
|
},
|
|
50
|
+
"release": {
|
|
51
|
+
"npmOwner": "danieltmn",
|
|
52
|
+
"gitRemote": "tamnora/openbridge",
|
|
53
|
+
"gitOwner": "tamnora",
|
|
54
|
+
"branch": "master"
|
|
55
|
+
},
|
|
48
56
|
"publishConfig": {
|
|
49
57
|
"access": "public"
|
|
50
58
|
},
|
package/src/bridge/bridge.js
CHANGED
|
@@ -2194,6 +2194,14 @@ async function tick(opts) {
|
|
|
2194
2194
|
} catch (e) {
|
|
2195
2195
|
log('error al publicar la respuesta #' + m.id + ': ' + e.message);
|
|
2196
2196
|
}
|
|
2197
|
+
// Refresca tokens/costo enseguida: el barrido solo corre cada 15 min.
|
|
2198
|
+
if (r.opencodeSession && m._t) {
|
|
2199
|
+
try {
|
|
2200
|
+
await refreshTokens(path.resolve(m.session.folder || ''), { id: r.opencodeSession }, m._t);
|
|
2201
|
+
} catch (e) {
|
|
2202
|
+
log('aviso: no pude refrescar tokens de ' + r.opencodeSession + ': ' + e.message);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2197
2205
|
}
|
|
2198
2206
|
for (const f of folders) {
|
|
2199
2207
|
await handleFolderRequest(f);
|
package/src/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ const config = require('./config');
|
|
|
10
10
|
const store = require('./store');
|
|
11
11
|
const auth = require('./auth');
|
|
12
12
|
const push = require('./push');
|
|
13
|
+
const qr = require('./qr');
|
|
13
14
|
const web = require('./web/server');
|
|
14
15
|
const tunnel = require('./tunnel');
|
|
15
16
|
const log = require('./log');
|
|
@@ -117,6 +118,15 @@ function clearRuntime() {
|
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
// Muestra la URL como QR en la consola (para abrirla en el celular).
|
|
122
|
+
// Devuelve false si la URL es demasiado larga para el QR.
|
|
123
|
+
function printQr(url) {
|
|
124
|
+
const art = qr.qrTerminal(url, 2);
|
|
125
|
+
if (!art) return false;
|
|
126
|
+
console.log(art);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
120
130
|
// Detiene el server en ejecucion (si lo hay) matando server + hijos. Devuelve
|
|
121
131
|
// true si habia uno. Se usa antes de reconfigurar para no dejar un proceso con
|
|
122
132
|
// la config vieja en memoria (p.ej. la contrasena).
|
|
@@ -255,6 +265,9 @@ async function cmdInit(argv) {
|
|
|
255
265
|
console.log(' carpetas : ' + folders.length + ' en folders.json');
|
|
256
266
|
if (generated) console.log(' -> guarda esta contrasena: no se vuelve a mostrar.');
|
|
257
267
|
console.log('');
|
|
268
|
+
console.log(' QR local (misma PC):');
|
|
269
|
+
printQr('http://127.0.0.1:' + port + '/chat.php');
|
|
270
|
+
console.log('');
|
|
258
271
|
console.log('Siguiente paso: openbridge server');
|
|
259
272
|
return 0;
|
|
260
273
|
}
|
|
@@ -439,6 +452,12 @@ async function printStatus() {
|
|
|
439
452
|
const sessions = await store.sessionsListFull();
|
|
440
453
|
console.log(' chats : ' + sessions.length);
|
|
441
454
|
} catch (e) { /* sin datos todavia */ }
|
|
455
|
+
const target = rt.publicUrl || rt.localUrl;
|
|
456
|
+
if (target) {
|
|
457
|
+
console.log('');
|
|
458
|
+
console.log(rt.publicUrl ? ' escanea para abrir en el celular:' : ' QR local (misma PC):');
|
|
459
|
+
if (!printQr(target)) console.log(' (URL demasiado larga para el QR; usa el link de arriba)');
|
|
460
|
+
}
|
|
442
461
|
return true;
|
|
443
462
|
}
|
|
444
463
|
|
|
@@ -447,6 +466,28 @@ async function cmdStatus() {
|
|
|
447
466
|
return 0;
|
|
448
467
|
}
|
|
449
468
|
|
|
469
|
+
// Muestra la URL como QR para escanear desde el celular.
|
|
470
|
+
async function cmdQr() {
|
|
471
|
+
const rt = readRuntime();
|
|
472
|
+
if (rt && pidAlive(rt.pid) && (rt.publicUrl || rt.localUrl)) {
|
|
473
|
+
const target = rt.publicUrl || rt.localUrl;
|
|
474
|
+
console.log((rt.publicUrl ? 'URL publica' : 'URL local') + ': ' + target);
|
|
475
|
+
console.log('');
|
|
476
|
+
if (!printQr(target)) { console.log('URL demasiado larga para el QR.'); return 1; }
|
|
477
|
+
return 0;
|
|
478
|
+
}
|
|
479
|
+
if (!paths.exists()) {
|
|
480
|
+
console.error('No hay configuracion. Corre primero: openbridge init');
|
|
481
|
+
return 1;
|
|
482
|
+
}
|
|
483
|
+
const app = config.readApp();
|
|
484
|
+
const url = 'http://' + (app.host || '127.0.0.1') + ':' + (app.port || 8799) + '/chat.php';
|
|
485
|
+
console.log('URL local (el server no esta corriendo): ' + url);
|
|
486
|
+
console.log('');
|
|
487
|
+
if (!printQr(url)) { console.log('URL demasiado larga para el QR.'); return 1; }
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
|
|
450
491
|
async function cmdLogs(argv) {
|
|
451
492
|
const { flags } = parseArgs(argv);
|
|
452
493
|
const n = parseInt(flags.n || '40', 10) || 40;
|
|
@@ -743,6 +784,7 @@ function usage() {
|
|
|
743
784
|
console.log(' passwd Cambia la contrasena de acceso (--password <clave>)');
|
|
744
785
|
console.log(' stop Detiene el server en segundo plano');
|
|
745
786
|
console.log(' status Estado del server, puente y chats');
|
|
787
|
+
console.log(' qr Muestra la URL (publica o local) como QR para el celular');
|
|
746
788
|
console.log(' logs Ultimas lineas de los logs (--follow --server --bridge)');
|
|
747
789
|
console.log(' bridge Corre solo el puente (--api --token --id --name)');
|
|
748
790
|
console.log(' import Trae data/ de OpenConex (<data-dir> [--force])');
|
|
@@ -773,6 +815,7 @@ async function main(argv) {
|
|
|
773
815
|
case 'server': case 'start': return cmdServer(args.slice(1));
|
|
774
816
|
case 'stop': return cmdStop();
|
|
775
817
|
case 'status': return cmdStatus();
|
|
818
|
+
case 'qr': return cmdQr();
|
|
776
819
|
case 'logs': return cmdLogs(args.slice(1));
|
|
777
820
|
case 'bridge': return cmdBridge(args.slice(1));
|
|
778
821
|
case 'import': return cmdImport(args.slice(1));
|
package/src/qr.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generador de QR minimo (ISO/IEC 18004, Model 2) sin dependencias.
|
|
5
|
+
*
|
|
6
|
+
* Soporta modo byte (UTF-8) con correccion de errores nivel L, versiones 1 a 5
|
|
7
|
+
* (hasta 106 bytes, de sobra para una URL de tunel) y un solo bloque RS. La
|
|
8
|
+
* mascara es fija (0): cualquier mascara es valida y la info de formato la
|
|
9
|
+
* declara, asi que el resultado es escaneable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// dataCodewords, eccCodewords y centros de patrones de alineacion por version.
|
|
13
|
+
const VERSIONS = {
|
|
14
|
+
1: { data: 19, ecc: 7, align: [] },
|
|
15
|
+
2: { data: 34, ecc: 10, align: [6, 18] },
|
|
16
|
+
3: { data: 55, ecc: 15, align: [6, 22] },
|
|
17
|
+
4: { data: 80, ecc: 20, align: [6, 26] },
|
|
18
|
+
5: { data: 108, ecc: 26, align: [6, 30] },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const MASK = 0;
|
|
22
|
+
const ECC_FORMAT_BITS = 1; // nivel L -> 01
|
|
23
|
+
|
|
24
|
+
function gfMul(x, y) {
|
|
25
|
+
let z = 0;
|
|
26
|
+
for (let i = 7; i >= 0; i--) {
|
|
27
|
+
z = (z << 1) ^ ((z >>> 7) * 0x11d);
|
|
28
|
+
z ^= ((y >>> i) & 1) * x;
|
|
29
|
+
}
|
|
30
|
+
return z & 0xff;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rsDivisor(degree) {
|
|
34
|
+
const result = new Array(degree).fill(0);
|
|
35
|
+
result[degree - 1] = 1;
|
|
36
|
+
let root = 1;
|
|
37
|
+
for (let i = 0; i < degree; i++) {
|
|
38
|
+
for (let j = 0; j < result.length; j++) {
|
|
39
|
+
result[j] = gfMul(result[j], root);
|
|
40
|
+
if (j + 1 < result.length) result[j] ^= result[j + 1];
|
|
41
|
+
}
|
|
42
|
+
root = gfMul(root, 0x02);
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function rsRemainder(data, divisor) {
|
|
48
|
+
const result = divisor.map(() => 0);
|
|
49
|
+
for (const b of data) {
|
|
50
|
+
const factor = b ^ result.shift();
|
|
51
|
+
result.push(0);
|
|
52
|
+
for (let i = 0; i < divisor.length; i++) result[i] ^= gfMul(divisor[i], factor);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getBit(x, i) {
|
|
58
|
+
return ((x >>> i) & 1) !== 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function chooseVersion(byteLen) {
|
|
62
|
+
for (let v = 1; v <= 5; v++) {
|
|
63
|
+
if (byteLen <= VERSIONS[v].data - 2) return v;
|
|
64
|
+
}
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function dataCodewords(bytes, version) {
|
|
69
|
+
const cap = VERSIONS[version].data;
|
|
70
|
+
const bits = [];
|
|
71
|
+
const push = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
|
|
72
|
+
push(0b0100, 4); // modo byte
|
|
73
|
+
push(bytes.length, 8); // conteo (v1-9: 8 bits)
|
|
74
|
+
for (const b of bytes) push(b, 8);
|
|
75
|
+
for (let i = 0; i < 4 && bits.length < cap * 8; i++) bits.push(0);
|
|
76
|
+
while (bits.length % 8 !== 0) bits.push(0);
|
|
77
|
+
const out = [];
|
|
78
|
+
for (let i = 0; i < bits.length; i += 8) {
|
|
79
|
+
let b = 0;
|
|
80
|
+
for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j];
|
|
81
|
+
out.push(b);
|
|
82
|
+
}
|
|
83
|
+
const pad = [0xec, 0x11];
|
|
84
|
+
let k = 0;
|
|
85
|
+
while (out.length < cap) out.push(pad[k++ % 2]);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildMatrix(version, codewords) {
|
|
90
|
+
const size = version * 4 + 17;
|
|
91
|
+
const modules = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
92
|
+
const isFn = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
93
|
+
const set = (x, y, dark) => { modules[y][x] = dark; isFn[y][x] = true; };
|
|
94
|
+
|
|
95
|
+
// Patrones de sincronizacion.
|
|
96
|
+
for (let i = 0; i < size; i++) {
|
|
97
|
+
set(6, i, i % 2 === 0);
|
|
98
|
+
set(i, 6, i % 2 === 0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Localizadores (finder) + separadores.
|
|
102
|
+
const drawFinder = (cx, cy) => {
|
|
103
|
+
for (let dy = -4; dy <= 4; dy++) {
|
|
104
|
+
for (let dx = -4; dx <= 4; dx++) {
|
|
105
|
+
const x = cx + dx, y = cy + dy;
|
|
106
|
+
if (x < 0 || x >= size || y < 0 || y >= size) continue;
|
|
107
|
+
const dist = Math.max(Math.abs(dx), Math.abs(dy));
|
|
108
|
+
set(x, y, dist !== 2 && dist !== 4);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
drawFinder(3, 3);
|
|
113
|
+
drawFinder(size - 4, 3);
|
|
114
|
+
drawFinder(3, size - 4);
|
|
115
|
+
|
|
116
|
+
// Patrones de alineacion.
|
|
117
|
+
const pos = VERSIONS[version].align;
|
|
118
|
+
for (const cy of pos) {
|
|
119
|
+
for (const cx of pos) {
|
|
120
|
+
if ((cx === 6 && cy === 6) || (cx === 6 && cy === size - 7) || (cx === size - 7 && cy === 6)) continue;
|
|
121
|
+
for (let dy = -2; dy <= 2; dy++) {
|
|
122
|
+
for (let dx = -2; dx <= 2; dx++) {
|
|
123
|
+
set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Reserva la info de formato (se reescribe abajo) y el modulo oscuro.
|
|
130
|
+
drawFormatBits(size, modules, isFn, 0);
|
|
131
|
+
|
|
132
|
+
// Colocacion de datos en zigzag.
|
|
133
|
+
let i = 0;
|
|
134
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
135
|
+
if (right === 6) right = 5;
|
|
136
|
+
for (let vert = 0; vert < size; vert++) {
|
|
137
|
+
for (let j = 0; j < 2; j++) {
|
|
138
|
+
const x = right - j;
|
|
139
|
+
const upward = ((right + 1) & 2) === 0;
|
|
140
|
+
const y = upward ? size - 1 - vert : vert;
|
|
141
|
+
if (isFn[y][x] || i >= codewords.length * 8) continue;
|
|
142
|
+
modules[y][x] = getBit(codewords[i >>> 3], 7 - (i & 7));
|
|
143
|
+
i++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Mascara fija.
|
|
149
|
+
for (let y = 0; y < size; y++) {
|
|
150
|
+
for (let x = 0; x < size; x++) {
|
|
151
|
+
if (!isFn[y][x] && (x + y) % 2 === 0) modules[y][x] = !modules[y][x];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
drawFormatBits(size, modules, isFn, MASK);
|
|
156
|
+
return { size, modules };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function drawFormatBits(size, modules, isFn, mask) {
|
|
160
|
+
const data = (ECC_FORMAT_BITS << 3) | mask;
|
|
161
|
+
let rem = data;
|
|
162
|
+
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
|
163
|
+
const bits = ((data << 10) | rem) ^ 0x5412;
|
|
164
|
+
const put = (x, y, dark) => { modules[y][x] = dark; isFn[y][x] = true; };
|
|
165
|
+
|
|
166
|
+
for (let i = 0; i <= 5; i++) put(8, i, getBit(bits, i));
|
|
167
|
+
put(8, 7, getBit(bits, 6));
|
|
168
|
+
put(8, 8, getBit(bits, 7));
|
|
169
|
+
put(7, 8, getBit(bits, 8));
|
|
170
|
+
for (let i = 9; i < 15; i++) put(14 - i, 8, getBit(bits, i));
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < 8; i++) put(size - 1 - i, 8, getBit(bits, i));
|
|
173
|
+
for (let i = 8; i < 15; i++) put(8, size - 15 + i, getBit(bits, i));
|
|
174
|
+
put(8, size - 8, true);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Devuelve { size, modules } o null si el texto no entra (URL muy larga).
|
|
178
|
+
function qrMatrix(text) {
|
|
179
|
+
const bytes = Buffer.from(String(text), 'utf8');
|
|
180
|
+
const version = chooseVersion(bytes.length);
|
|
181
|
+
if (!version) return null;
|
|
182
|
+
const data = dataCodewords(bytes, version);
|
|
183
|
+
const ecc = rsRemainder(data, rsDivisor(VERSIONS[version].ecc));
|
|
184
|
+
return buildMatrix(version, data.concat(ecc));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// QR para terminal con medios bloques (cuadrado en consolas 1:2).
|
|
188
|
+
function qrTerminal(text, quiet) {
|
|
189
|
+
const qr = qrMatrix(text);
|
|
190
|
+
if (!qr) return '';
|
|
191
|
+
const q = quiet === undefined ? 2 : quiet;
|
|
192
|
+
const size = qr.size;
|
|
193
|
+
const at = (x, y) => (x >= 0 && x < size && y >= 0 && y < size) && qr.modules[y][x];
|
|
194
|
+
const lines = [];
|
|
195
|
+
for (let y = -q; y < size + q; y += 2) {
|
|
196
|
+
let line = '';
|
|
197
|
+
for (let x = -q; x < size + q; x++) {
|
|
198
|
+
const top = at(x, y), bot = at(x, y + 1);
|
|
199
|
+
line += top && bot ? '\u2588' : top ? '\u2580' : bot ? '\u2584' : ' ';
|
|
200
|
+
}
|
|
201
|
+
lines.push(line);
|
|
202
|
+
}
|
|
203
|
+
return lines.join('\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// QR como SVG (para la web o para guardar).
|
|
207
|
+
function qrSvg(text, quiet) {
|
|
208
|
+
const qr = qrMatrix(text);
|
|
209
|
+
if (!qr) return '';
|
|
210
|
+
const q = quiet === undefined ? 4 : quiet;
|
|
211
|
+
const size = qr.size;
|
|
212
|
+
let path = '';
|
|
213
|
+
for (let y = 0; y < size; y++) {
|
|
214
|
+
for (let x = 0; x < size; x++) {
|
|
215
|
+
if (qr.modules[y][x]) path += 'M' + (x + q) + ' ' + (y + q) + 'h1v1h-1z';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const total = size + q * 2;
|
|
219
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + total + ' ' + total
|
|
220
|
+
+ '" shape-rendering="crispEdges"><rect width="' + total + '" height="' + total
|
|
221
|
+
+ '" fill="#fff"/><path d="' + path + '" fill="#000"/></svg>';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { qrMatrix, qrTerminal, qrSvg };
|
package/src/web/assets/app.js
CHANGED
|
@@ -27,6 +27,7 @@ var els = {
|
|
|
27
27
|
input: document.getElementById('input'),
|
|
28
28
|
sendBtn: document.getElementById('sendBtn'),
|
|
29
29
|
btnImg: document.getElementById('btnImg'),
|
|
30
|
+
btnMic: document.getElementById('btnMic'),
|
|
30
31
|
imgInput: document.getElementById('imgInput'),
|
|
31
32
|
imgPreview: document.getElementById('imgPreview'),
|
|
32
33
|
imgThumb: document.getElementById('imgThumb'),
|
|
@@ -220,8 +221,17 @@ function updateStatusbar() {
|
|
|
220
221
|
if (state.currentId !== null && state.currentSession) {
|
|
221
222
|
extra = '· ' + extra;
|
|
222
223
|
var tokTxt = sessionTokensLabel(state.currentSession);
|
|
223
|
-
if (tokTxt)
|
|
224
|
+
if (tokTxt) {
|
|
225
|
+
var pct = sessionTokensPct(state.currentSession);
|
|
226
|
+
extra += ' · ' + tokTxt + ' tokens' + (pct ? ' (' + pct + ')' : '');
|
|
227
|
+
}
|
|
228
|
+
var cost = fmtCost(state.currentSession.cost);
|
|
229
|
+
if (cost) extra += ' · ' + cost;
|
|
224
230
|
}
|
|
231
|
+
var totalCost = 0;
|
|
232
|
+
for (var ci = 0; ci < state.sessions.length; ci++) totalCost += Number(state.sessions[ci].cost) || 0;
|
|
233
|
+
var totalTxt = fmtCost(totalCost);
|
|
234
|
+
if (totalTxt) extra += ' · ' + totalTxt + ' total';
|
|
225
235
|
els.sbExtra.textContent = extra;
|
|
226
236
|
}
|
|
227
237
|
|
|
@@ -761,6 +771,14 @@ function fmtTokens(n) {
|
|
|
761
771
|
return String(n);
|
|
762
772
|
}
|
|
763
773
|
|
|
774
|
+
// Costo acumulado (USD) con precision segun magnitud ('' si no hay dato).
|
|
775
|
+
function fmtCost(n) {
|
|
776
|
+
var v = Number(n) || 0;
|
|
777
|
+
if (v <= 0) return '';
|
|
778
|
+
var s = v >= 1 ? v.toFixed(2) : (v >= 0.01 ? v.toFixed(3) : v.toFixed(4));
|
|
779
|
+
return '$' + s.replace('.', ',');
|
|
780
|
+
}
|
|
781
|
+
|
|
764
782
|
// Ventana de contexto del modelo segun el catalogo (0 si no se conoce).
|
|
765
783
|
function modelContext(model) {
|
|
766
784
|
var c = state.catalog || {};
|
|
@@ -779,7 +797,16 @@ function sessionTokensLabel(s) {
|
|
|
779
797
|
function sessionTokensTitle(s) {
|
|
780
798
|
var tok = Math.round(Number(s.tokens) || 0);
|
|
781
799
|
var ctx = modelContext(s.model);
|
|
782
|
-
|
|
800
|
+
var cost = fmtCost(s.cost);
|
|
801
|
+
return tok + ' tokens' + (ctx ? ' de ' + ctx + ' de contexto' : '') + (cost ? ' · ' + cost + ' acumulado' : '');
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Porcentaje de contexto consumido ('' si no hay datos).
|
|
805
|
+
function sessionTokensPct(s) {
|
|
806
|
+
var tok = Math.round(Number(s.tokens) || 0);
|
|
807
|
+
var ctx = modelContext(s.model);
|
|
808
|
+
if (!tok || !ctx) return '';
|
|
809
|
+
return Math.min(100, Math.round((tok / ctx) * 100)) + '%';
|
|
783
810
|
}
|
|
784
811
|
|
|
785
812
|
// Etiqueta "consumidos / capacidad" de la sesion.
|
|
@@ -789,11 +816,23 @@ function sessionTokensHtml(s) {
|
|
|
789
816
|
return '<span class="stok" title="' + sessionTokensTitle(s) + '">' + label + '</span>';
|
|
790
817
|
}
|
|
791
818
|
|
|
792
|
-
//
|
|
819
|
+
// Costo acumulado de la sesion ('' si no hay dato).
|
|
820
|
+
function sessionCostHtml(s) {
|
|
821
|
+
var cost = fmtCost(s.cost);
|
|
822
|
+
if (!cost) return '';
|
|
823
|
+
return '<span class="scost" title="' + sessionTokensTitle(s) + '">' + cost + '</span>';
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Subtitulo del encabezado: proyecto · modelo · agente · tokens/contexto · costo.
|
|
793
827
|
function sessionSubtitle(s) {
|
|
794
828
|
var txt = projectLabel(s.folder) + ' · ' + (s.model || 'sin modelo') + ' · ' + (s.agent || 'build');
|
|
795
829
|
var label = sessionTokensLabel(s);
|
|
796
|
-
if (label)
|
|
830
|
+
if (label) {
|
|
831
|
+
var pct = sessionTokensPct(s);
|
|
832
|
+
txt += ' · ' + label + ' tokens' + (pct ? ' (' + pct + ')' : '');
|
|
833
|
+
}
|
|
834
|
+
var cost = fmtCost(s.cost);
|
|
835
|
+
if (cost) txt += ' · ' + cost;
|
|
797
836
|
return txt;
|
|
798
837
|
}
|
|
799
838
|
|
|
@@ -818,6 +857,7 @@ function sessionItemHtml(s, withFolder) {
|
|
|
818
857
|
+ (withFolder ? '<span class="sfolder">' + esc(projectLabel(s.folder)) + '</span>' : '')
|
|
819
858
|
+ pen
|
|
820
859
|
+ sessionTokensHtml(s)
|
|
860
|
+
+ sessionCostHtml(s)
|
|
821
861
|
+ '<span class="stime">' + esc(timeShort(s.last_ts)) + '</span>'
|
|
822
862
|
+ '</button>';
|
|
823
863
|
}
|
|
@@ -1057,6 +1097,7 @@ function composeOpenSheet() {
|
|
|
1057
1097
|
}
|
|
1058
1098
|
function composeCloseSheet() {
|
|
1059
1099
|
if (!els.sendForm) return;
|
|
1100
|
+
voiceStop();
|
|
1060
1101
|
els.sendForm.classList.remove('open');
|
|
1061
1102
|
document.body.classList.remove('compose-open');
|
|
1062
1103
|
if (els.imgPreview && els.imgPreview.parentNode === els.sendForm && els.composeOpen) {
|
|
@@ -1216,9 +1257,12 @@ function renderHome(sessions) {
|
|
|
1216
1257
|
+ '</div>';
|
|
1217
1258
|
}
|
|
1218
1259
|
html += '</div>';
|
|
1219
|
-
html += '<
|
|
1260
|
+
html += '<div class="histrow">'
|
|
1261
|
+
+ '<button type="button" class="histbtn" data-folder="' + esc(key) + '">'
|
|
1220
1262
|
+ (hidden > 0 ? 'ver historial · ' + hidden + ' más' : 'ver historial')
|
|
1221
|
-
+ '</button>'
|
|
1263
|
+
+ '</button>'
|
|
1264
|
+
+ '<button type="button" class="histbtn filesbtn" data-folder="' + esc(key) + '" title="Archivos del proyecto">ver archivos</button>'
|
|
1265
|
+
+ '</div>';
|
|
1222
1266
|
html += '</div></div>';
|
|
1223
1267
|
}
|
|
1224
1268
|
}
|
|
@@ -1594,7 +1638,8 @@ function msgSig(m) {
|
|
|
1594
1638
|
function msgNodeHtml(m, chatQuery) {
|
|
1595
1639
|
var cls = m.role === 'user' ? 'mine' : 'theirs';
|
|
1596
1640
|
if (m.status === 'canceled') cls += ' canceled';
|
|
1597
|
-
var who = m.role === 'user' ? '❯ vos' : '●
|
|
1641
|
+
var who = m.role === 'user' ? '❯ vos' : '● Agente';
|
|
1642
|
+
if (m.agent === 'plan') cls += ' plan';
|
|
1598
1643
|
var agentBadge = m.agent ? ' <span class="abadge ' + esc(m.agent) + '">' + esc(m.agent) + '</span>' : '';
|
|
1599
1644
|
var stopBadge = m.canceled ? ' <span class="stopbadge">⏹ detenido</span>' : '';
|
|
1600
1645
|
var streaming = m.role === 'assistant' && m.status === 'streaming';
|
|
@@ -1855,6 +1900,19 @@ function focusProject(folder) {
|
|
|
1855
1900
|
}
|
|
1856
1901
|
}
|
|
1857
1902
|
|
|
1903
|
+
// Convierte la carpeta absoluta de un proyecto en ruta relativa al workspace
|
|
1904
|
+
// (para la vista de archivos). '' si no se puede derivar (usa la raiz).
|
|
1905
|
+
function folderToFilesPath(folder) {
|
|
1906
|
+
var ws = state.catalog && state.catalog.workspace ? state.catalog.workspace : '';
|
|
1907
|
+
if (!folder || !ws) return '';
|
|
1908
|
+
var norm = function (p) { return String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); };
|
|
1909
|
+
var nws = norm(ws), nf = norm(folder);
|
|
1910
|
+
if (nf === nws || nf.indexOf(nws + '/') !== 0) return '';
|
|
1911
|
+
var raw = String(folder).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
1912
|
+
var rawWs = String(ws).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
1913
|
+
return raw.slice(rawWs.length + 1);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1858
1916
|
// Historial de un proyecto: vista aparte con buscador dentro del proyecto.
|
|
1859
1917
|
function openHistory(folder) {
|
|
1860
1918
|
state.histFolder = folder;
|
|
@@ -2364,6 +2422,69 @@ if (els.btnImg) {
|
|
|
2364
2422
|
els.imgRemove.addEventListener('click', function () { setPendingImage(null); });
|
|
2365
2423
|
}
|
|
2366
2424
|
|
|
2425
|
+
// ---------------------------------------------------------------------------
|
|
2426
|
+
// Dictado por voz (Web Speech API nativa del navegador; sin dependencias).
|
|
2427
|
+
// ---------------------------------------------------------------------------
|
|
2428
|
+
var SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
2429
|
+
var voice = { rec: null, listening: false, base: '' };
|
|
2430
|
+
|
|
2431
|
+
function voiceSetListening(on) {
|
|
2432
|
+
voice.listening = on;
|
|
2433
|
+
if (!els.btnMic) return;
|
|
2434
|
+
els.btnMic.classList.toggle('listening', on);
|
|
2435
|
+
els.btnMic.title = on ? 'Detener dictado' : 'Dictar por voz';
|
|
2436
|
+
els.btnMic.setAttribute('aria-label', on ? 'Detener dictado' : 'Dictar por voz');
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
function voiceStop() {
|
|
2440
|
+
if (voice.rec && voice.listening) {
|
|
2441
|
+
try { voice.rec.stop(); } catch (e) {}
|
|
2442
|
+
}
|
|
2443
|
+
voiceSetListening(false);
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
function voiceStart() {
|
|
2447
|
+
if (!voice.rec || state.currentId === null) return;
|
|
2448
|
+
voice.base = els.input.value ? els.input.value.replace(/\s+$/, ' ') : '';
|
|
2449
|
+
try { voice.rec.start(); } catch (e) { /* ya estaba iniciado */ }
|
|
2450
|
+
voiceSetListening(true);
|
|
2451
|
+
toast('dictado activo · hablá ahora', 'ok');
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
if (els.btnMic) {
|
|
2455
|
+
if (!SpeechRec) {
|
|
2456
|
+
els.btnMic.style.display = 'none';
|
|
2457
|
+
} else {
|
|
2458
|
+
voice.rec = new SpeechRec();
|
|
2459
|
+
voice.rec.lang = navigator.language || 'es-AR';
|
|
2460
|
+
voice.rec.continuous = true;
|
|
2461
|
+
voice.rec.interimResults = true;
|
|
2462
|
+
voice.rec.onresult = function (ev) {
|
|
2463
|
+
var fin = '', interim = '';
|
|
2464
|
+
for (var i = ev.resultIndex; i < ev.results.length; i++) {
|
|
2465
|
+
var r = ev.results[i];
|
|
2466
|
+
if (r.isFinal) fin += r[0].transcript;
|
|
2467
|
+
else interim += r[0].transcript;
|
|
2468
|
+
}
|
|
2469
|
+
if (fin) voice.base = (voice.base + fin).replace(/\s+/g, ' ');
|
|
2470
|
+
var tail = interim ? (voice.base ? ' ' : '') + interim : '';
|
|
2471
|
+
els.input.value = (voice.base + tail).replace(/^\s+/, '');
|
|
2472
|
+
autoGrow();
|
|
2473
|
+
};
|
|
2474
|
+
voice.rec.onerror = function (ev) {
|
|
2475
|
+
voiceSetListening(false);
|
|
2476
|
+
var m = (ev && ev.error) || '';
|
|
2477
|
+
if (m === 'not-allowed' || m === 'service-not-allowed') toast('permiso de micrófono denegado', 'error');
|
|
2478
|
+
else if (m !== 'aborted' && m !== 'no-speech') toast('dictado: ' + m, 'error');
|
|
2479
|
+
};
|
|
2480
|
+
voice.rec.onend = function () { voiceSetListening(false); };
|
|
2481
|
+
els.btnMic.addEventListener('click', function () {
|
|
2482
|
+
if (voice.listening) voiceStop();
|
|
2483
|
+
else voiceStart();
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2367
2488
|
// ---------------------------------------------------------------------------
|
|
2368
2489
|
// Composer: textarea multilínea (Enter envía, Shift+Enter salta de línea)
|
|
2369
2490
|
// y menú de comandos "/" con filtro, flechas y Enter.
|
|
@@ -2472,6 +2593,7 @@ els.sendForm.addEventListener('submit', async function (ev) {
|
|
|
2472
2593
|
var text = els.input.value.trim();
|
|
2473
2594
|
if ((!text && !state.pendingImage) || state.isSending) return;
|
|
2474
2595
|
state.isSending = true;
|
|
2596
|
+
voiceStop();
|
|
2475
2597
|
try {
|
|
2476
2598
|
var body = { session: state.currentId, text: text };
|
|
2477
2599
|
if (state.pendingImage) body.image = state.pendingImage;
|
|
@@ -2644,9 +2766,17 @@ els.home.addEventListener('click', function (e) {
|
|
|
2644
2766
|
return;
|
|
2645
2767
|
}
|
|
2646
2768
|
var hist = e.target.closest('.histbtn');
|
|
2647
|
-
if (hist) {
|
|
2769
|
+
if (hist && !e.target.closest('.filesbtn')) {
|
|
2648
2770
|
e.stopPropagation();
|
|
2649
2771
|
openHistory(hist.getAttribute('data-folder'));
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
var fbtn = e.target.closest('.filesbtn');
|
|
2775
|
+
if (fbtn) {
|
|
2776
|
+
e.stopPropagation();
|
|
2777
|
+
state.filesPath = folderToFilesPath(fbtn.getAttribute('data-folder'));
|
|
2778
|
+
delete state.filesCache[state.filesPath];
|
|
2779
|
+
showView('files');
|
|
2650
2780
|
}
|
|
2651
2781
|
});
|
|
2652
2782
|
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
z-index: 30;
|
|
70
70
|
}
|
|
71
71
|
#sidebar .brand {
|
|
72
|
+
min-height: 54px;
|
|
72
73
|
padding: 12px 14px 10px;
|
|
73
74
|
display: flex;
|
|
74
75
|
align-items: center;
|
|
@@ -233,6 +234,7 @@
|
|
|
233
234
|
.sitem .sname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .78; }
|
|
234
235
|
.sitem.active .sname { opacity: 1; }
|
|
235
236
|
.sitem .stok { font-size: 10.5px; color: var(--muted); flex-shrink: 0; }
|
|
237
|
+
.sitem .scost { font-size: 10.5px; color: var(--warn-text, #d29922); flex-shrink: 0; }
|
|
236
238
|
.sitem .stime { font-size: 11px; color: var(--muted); flex-shrink: 0; }
|
|
237
239
|
.sitem .spen { width: 6px; height: 6px; border-radius: 50%; background: var(--mine); flex-shrink: 0; }
|
|
238
240
|
.sdot {
|
|
@@ -495,8 +497,9 @@
|
|
|
495
497
|
.projsec .pshead .pscount { font-size: 11px; color: var(--muted); flex-shrink: 0; }
|
|
496
498
|
.projsec .pbody { display: none; }
|
|
497
499
|
.projsec.open .pbody { display: block; }
|
|
500
|
+
.histrow { display: flex; gap: 6px; margin-top: 6px; }
|
|
498
501
|
.histbtn {
|
|
499
|
-
|
|
502
|
+
flex: 1; min-width: 0; padding: 8px; border: 1px dashed var(--border); border-radius: 8px;
|
|
500
503
|
background: transparent; color: var(--muted); font: inherit; font-size: 12px; cursor: pointer;
|
|
501
504
|
}
|
|
502
505
|
.histbtn:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
|
@@ -598,6 +601,8 @@
|
|
|
598
601
|
.msg.mine .who { color: var(--mine); }
|
|
599
602
|
.msg.theirs { border-left-color: var(--accent); }
|
|
600
603
|
.msg.theirs .who { color: var(--accent); }
|
|
604
|
+
.msg.theirs.plan { border-left-color: var(--warn-text, #d29922); }
|
|
605
|
+
.msg.theirs.plan .who { color: var(--warn-text, #d29922); }
|
|
601
606
|
.msg.error { border-left-color: var(--danger); }
|
|
602
607
|
/* Aire extra en el cambio de turno: lo tuyo ↔ lo que responde el agente */
|
|
603
608
|
.msg.mine + .msg.theirs,
|
|
@@ -778,6 +783,14 @@
|
|
|
778
783
|
}
|
|
779
784
|
form.sendbar .imgbtn:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
|
|
780
785
|
form.sendbar .imgbtn svg { flex-shrink: 0; display: block; }
|
|
786
|
+
form.sendbar .imgbtn.listening {
|
|
787
|
+
color: var(--danger); border-color: var(--danger); background: transparent;
|
|
788
|
+
animation: micpulse 1.1s ease-in-out infinite;
|
|
789
|
+
}
|
|
790
|
+
@keyframes micpulse {
|
|
791
|
+
0%, 100% { box-shadow: 0 0 0 0 transparent; }
|
|
792
|
+
50% { box-shadow: 0 0 0 4px var(--accent-soft); }
|
|
793
|
+
}
|
|
781
794
|
/* ----- Botón que abre la hoja de escritura (móvil) ----- */
|
|
782
795
|
.composeopen {
|
|
783
796
|
display: none; align-items: center; gap: 10px; width: 100%;
|
|
@@ -1209,6 +1222,7 @@
|
|
|
1209
1222
|
<span class="prompt">❯</span>
|
|
1210
1223
|
<textarea id="input" rows="1" placeholder="escribí un mensaje…" autocomplete="off"></textarea>
|
|
1211
1224
|
<button type="button" class="imgbtn" id="btnImg" title="Adjuntar imagen (modelos con visión)" aria-label="Adjuntar imagen"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/></svg></button>
|
|
1225
|
+
<button type="button" class="imgbtn" id="btnMic" title="Dictar por voz" aria-label="Dictar por voz"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"/></svg></button>
|
|
1212
1226
|
<input type="file" id="imgInput" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none">
|
|
1213
1227
|
<button type="submit" id="sendBtn">enviar</button>
|
|
1214
1228
|
<div id="slashPanel" class="slash-panel"></div>
|