agents-city 0.3.0 → 0.4.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/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +54 -5
- package/README.md +53 -5
- package/bin/agents-city.js +4 -0
- package/bin/hall.html +92 -5
- package/bin/navegador.mjs +141 -12
- package/bin/serve.py +94 -38
- package/bin/test +6 -3
- package/bin/test-demo.py +67 -0
- package/bin/test-desinstala.py +170 -0
- package/bin/test-navegador.py +2 -0
- package/bin/test-serve.py +122 -2
- package/bin/uninstall +5 -0
- package/city/web/dist/city.js +65 -65
- package/city/web/dist/index.html +18 -5
- package/city/web/dist-hall/hall.js +941 -385
- package/city/web/index.html +17 -4
- package/city/web/src/bienvenida.ts +35 -329
- package/city/web/src/casa.ts +282 -0
- package/city/web/src/demo.ts +247 -0
- package/city/web/src/dialogo.ts +178 -0
- package/city/web/src/es.ts +96 -0
- package/city/web/src/explorador.ts +189 -0
- package/city/web/src/hall.ts +157 -54
- package/city/web/src/main.ts +28 -5
- package/demo/graba.py +132 -0
- package/demo/grabaciones/legal.jsonl +22 -0
- package/demo/grabaciones/medicina.jsonl +22 -0
- package/demo/grabaciones/software.jsonl +22 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/scripts/demos.py +107 -0
- package/plugin/scripts/desinstala.py +222 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building one house: name, work, role, engine and everything it works on.
|
|
3
|
+
*
|
|
4
|
+
* This lived inside the first-run guide, and the Hall had its own version of the
|
|
5
|
+
* same question made of three chained `window.prompt` boxes — name, then kind,
|
|
6
|
+
* then role, with no engine and no way to pick a folder. Two implementations of
|
|
7
|
+
* one question, and the worse one was the one people met after the first day.
|
|
8
|
+
*
|
|
9
|
+
* So it is one form, mounted wherever it is needed: inside the guide as a step,
|
|
10
|
+
* inside a dialog from the houses view. It owns its own state and repaints
|
|
11
|
+
* itself; the host owns the frame around it and decides what the buttons say.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { plural, t as _ } from './idioma';
|
|
15
|
+
import { Explorador } from './explorador';
|
|
16
|
+
import { ESFUERZOS, MODELOS, RUNTIMES, opciones } from './motores';
|
|
17
|
+
|
|
18
|
+
export interface Rol {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
summary: string;
|
|
22
|
+
trade: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DatosDeCasa {
|
|
26
|
+
nombre: string;
|
|
27
|
+
clase: string;
|
|
28
|
+
rol: string;
|
|
29
|
+
montajes: string[];
|
|
30
|
+
/** Which CLI runs it, and — on Claude — the engine and how hard it thinks.
|
|
31
|
+
* Empty means the owner's default, which is a real answer and the one most
|
|
32
|
+
* people should give. */
|
|
33
|
+
runtime: string;
|
|
34
|
+
modelo: string;
|
|
35
|
+
esfuerzo: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What the form needs from whoever is hosting it. */
|
|
39
|
+
export interface Puerta {
|
|
40
|
+
api: <T>(ruta: string, opts?: RequestInit) => Promise<T>;
|
|
41
|
+
esc: (s: unknown) => string;
|
|
42
|
+
aviso: (texto: string, malo?: boolean) => void;
|
|
43
|
+
yo: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const CLASES: Array<[string, string, string]> = [
|
|
47
|
+
[
|
|
48
|
+
'code',
|
|
49
|
+
'It writes code',
|
|
50
|
+
'Its house grows with the pull requests it merges. Mounts repositories and worktrees.',
|
|
51
|
+
],
|
|
52
|
+
[
|
|
53
|
+
'knowledge',
|
|
54
|
+
'It keeps knowledge',
|
|
55
|
+
'Its house grows with the documents it writes. Needs no git at all.',
|
|
56
|
+
],
|
|
57
|
+
['coordinator', 'It coordinates', 'Its house grows with the decisions it records.'],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
export class FormularioDeCasa {
|
|
61
|
+
readonly datos: DatosDeCasa = {
|
|
62
|
+
nombre: '',
|
|
63
|
+
clase: 'code',
|
|
64
|
+
rol: 'blank',
|
|
65
|
+
montajes: [],
|
|
66
|
+
runtime: '',
|
|
67
|
+
modelo: '',
|
|
68
|
+
esfuerzo: '',
|
|
69
|
+
};
|
|
70
|
+
private roles: Rol[] = [];
|
|
71
|
+
private host: HTMLElement | null = null;
|
|
72
|
+
|
|
73
|
+
constructor(
|
|
74
|
+
private readonly p: Puerta,
|
|
75
|
+
roles: Rol[] = [],
|
|
76
|
+
) {
|
|
77
|
+
this.roles = roles;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Render into `host` and wire it. Safe to call again: that is the repaint. */
|
|
81
|
+
monta(host: HTMLElement): void {
|
|
82
|
+
this.host = host;
|
|
83
|
+
void this.aseguraRoles();
|
|
84
|
+
host.innerHTML = this.html();
|
|
85
|
+
this.enlaza(host);
|
|
86
|
+
const hueco = host.querySelector<HTMLElement>('.casaExp');
|
|
87
|
+
if (hueco) this.explora().monta(hueco);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private repinta(): void {
|
|
91
|
+
if (this.host) this.monta(this.host);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async aseguraRoles(): Promise<void> {
|
|
95
|
+
if (this.roles.length) return;
|
|
96
|
+
try {
|
|
97
|
+
const r = await this.p.api<{ roles: Rol[] }>('/api/roles?scope=agent');
|
|
98
|
+
this.roles = r.roles ?? [];
|
|
99
|
+
this.repinta();
|
|
100
|
+
} catch {
|
|
101
|
+
this.roles = []; // the role list is a convenience; blank is always valid
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The folder picker, built once so walking survives a repaint. */
|
|
106
|
+
private explorador: Explorador | null = null;
|
|
107
|
+
|
|
108
|
+
private explora(): Explorador {
|
|
109
|
+
if (!this.explorador)
|
|
110
|
+
this.explorador = new Explorador(
|
|
111
|
+
this.p,
|
|
112
|
+
(ruta) => {
|
|
113
|
+
const ya = this.datos.montajes.indexOf(ruta);
|
|
114
|
+
if (ya >= 0) this.datos.montajes.splice(ya, 1);
|
|
115
|
+
else this.datos.montajes.push(ruta);
|
|
116
|
+
this.repinta();
|
|
117
|
+
},
|
|
118
|
+
() => this.datos.montajes,
|
|
119
|
+
);
|
|
120
|
+
return this.explorador;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
html(): string {
|
|
124
|
+
const a = this.datos;
|
|
125
|
+
const esc = this.p.esc;
|
|
126
|
+
const clases = CLASES.map(
|
|
127
|
+
([id, titulo, porque]) => `
|
|
128
|
+
<button type="button" class="bvOpcion ${a.clase === id ? 'elegida' : ''}"
|
|
129
|
+
data-bv="clase" data-id="${id}">
|
|
130
|
+
<b>${_(titulo)}</b><span>${_(porque)}</span></button>`,
|
|
131
|
+
).join('');
|
|
132
|
+
const roles = this.roles
|
|
133
|
+
.map(
|
|
134
|
+
(r) =>
|
|
135
|
+
`<option value="${esc(r.id)}"${r.id === a.rol ? ' selected' : ''}>${esc(r.name)}</option>`,
|
|
136
|
+
)
|
|
137
|
+
.join('');
|
|
138
|
+
const esClaude = (a.runtime || 'claude') === 'claude';
|
|
139
|
+
return `
|
|
140
|
+
<div class="campo"><label>${_('What do you call it?')}</label>
|
|
141
|
+
<input type="text" id="bvNombre" value="${esc(a.nombre)}"
|
|
142
|
+
placeholder="${_('urgencias, api, the handbook — whatever you would say out loud')}"></div>
|
|
143
|
+
<label class="bvEtiqueta">${_('What kind of work does it do?')}</label>
|
|
144
|
+
<div class="bvRejilla bvTres">${clases}</div>
|
|
145
|
+
<div class="campo"><label>${_('Its role — its speciality, never authority')}</label>
|
|
146
|
+
<select id="bvRol">${roles}</select></div>
|
|
147
|
+
<label class="bvEtiqueta">${_('What runs it?')}</label>
|
|
148
|
+
<p class="pista">${_(
|
|
149
|
+
'Leave all three on default and it runs the way you do. Model and effort are Claude’s to read; another CLI carries its own flags.',
|
|
150
|
+
)}</p>
|
|
151
|
+
<div class="bvMotor">
|
|
152
|
+
<div class="campo"><label>${_('provider')}</label>
|
|
153
|
+
<select id="bvRuntime">${opciones(RUNTIMES, a.runtime, esc, 'claude')}</select></div>
|
|
154
|
+
<div class="campo"><label>${_('engine')}</label>
|
|
155
|
+
<select id="bvModelo" ${esClaude ? '' : 'disabled'}>${opciones(
|
|
156
|
+
[''].concat(MODELOS),
|
|
157
|
+
a.modelo,
|
|
158
|
+
esc,
|
|
159
|
+
_('default'),
|
|
160
|
+
)}</select></div>
|
|
161
|
+
<div class="campo"><label>${_('effort')}</label>
|
|
162
|
+
<select id="bvEsfuerzo" ${esClaude ? '' : 'disabled'}>${opciones(
|
|
163
|
+
[''].concat(ESFUERZOS),
|
|
164
|
+
a.esfuerzo,
|
|
165
|
+
esc,
|
|
166
|
+
_('default'),
|
|
167
|
+
)}</select></div>
|
|
168
|
+
</div>
|
|
169
|
+
<label class="bvEtiqueta">${_('What does it work on?')}</label>
|
|
170
|
+
<p class="pista">${_(`Walk your disk and pick whatever you like: a repository, a
|
|
171
|
+
worktree, a folder of documents, one exact file. As many as you want. Nothing is
|
|
172
|
+
copied — each one is linked into this agent's own home.`)}</p>
|
|
173
|
+
<div class="casaExp"></div>
|
|
174
|
+
${
|
|
175
|
+
a.montajes.length
|
|
176
|
+
? `<div class="expElegidas"><label class="bvEtiqueta">${_('Working on')}</label>
|
|
177
|
+
${a.montajes
|
|
178
|
+
.map(
|
|
179
|
+
(m) => `<button type="button" class="bvChip elegida" data-bv="quita"
|
|
180
|
+
data-ruta="${esc(m)}" title="${esc(m)}">${esc(
|
|
181
|
+
m.split('/').pop() || m,
|
|
182
|
+
)}<i class="expQuita">✕</i></button>`,
|
|
183
|
+
)
|
|
184
|
+
.join('')}</div>`
|
|
185
|
+
: `<p class="pista">${_('Nothing chosen yet — an agent with no mounts is fine too.')}</p>`
|
|
186
|
+
}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private enlaza(raiz: HTMLElement): void {
|
|
190
|
+
raiz.querySelectorAll<HTMLElement>('[data-bv]').forEach((el) => {
|
|
191
|
+
el.onclick = (evento) => {
|
|
192
|
+
evento.preventDefault();
|
|
193
|
+
const que = el.dataset.bv;
|
|
194
|
+
this.recoge(raiz);
|
|
195
|
+
if (que === 'clase') this.datos.clase = el.dataset.id ?? 'code';
|
|
196
|
+
else if (que === 'quita') {
|
|
197
|
+
const ya = this.datos.montajes.indexOf(el.dataset.ruta ?? '');
|
|
198
|
+
if (ya >= 0) this.datos.montajes.splice(ya, 1);
|
|
199
|
+
}
|
|
200
|
+
this.repinta();
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
raiz.querySelector<HTMLSelectElement>('#bvRuntime')?.addEventListener('change', () => {
|
|
204
|
+
this.recoge(raiz);
|
|
205
|
+
this.repinta();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Keep what is typed when a click forces a repaint. */
|
|
210
|
+
private recoge(raiz: HTMLElement): void {
|
|
211
|
+
const nombre = raiz.querySelector<HTMLInputElement>('#bvNombre');
|
|
212
|
+
const rol = raiz.querySelector<HTMLSelectElement>('#bvRol');
|
|
213
|
+
if (nombre) this.datos.nombre = nombre.value;
|
|
214
|
+
if (rol) this.datos.rol = rol.value;
|
|
215
|
+
for (const [id, campo] of [
|
|
216
|
+
['#bvRuntime', 'runtime'],
|
|
217
|
+
['#bvModelo', 'modelo'],
|
|
218
|
+
['#bvEsfuerzo', 'esfuerzo'],
|
|
219
|
+
] as const) {
|
|
220
|
+
const sel = raiz.querySelector<HTMLSelectElement>(id);
|
|
221
|
+
if (sel) this.datos[campo] = sel.value;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Build it. Returns the new agent's slug, or null when nothing was written.
|
|
227
|
+
*
|
|
228
|
+
* Three endpoints by necessity, not by sloppiness: `/api/agentes` owns who is
|
|
229
|
+
* on the roster, `/api/montaje` owns what an agent can reach, and
|
|
230
|
+
* `/api/agente` owns how one is run. None of them is allowed to learn
|
|
231
|
+
* another's job just to save a round trip.
|
|
232
|
+
*/
|
|
233
|
+
async guarda(): Promise<{ slug: string; nombre: string } | null> {
|
|
234
|
+
if (this.host) this.recoge(this.host);
|
|
235
|
+
const a = this.datos;
|
|
236
|
+
const nombre = a.nombre.trim();
|
|
237
|
+
if (!nombre) {
|
|
238
|
+
this.p.aviso(_('Give it a name — it is how you will call it in its window'), true);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const r = await this.p.api<{ ok?: boolean; agent?: string; error?: string }>('/api/agentes', {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
body: JSON.stringify({ name: nombre, kind: a.clase, role: a.rol }),
|
|
245
|
+
});
|
|
246
|
+
if (!r.ok || !r.agent) {
|
|
247
|
+
this.p.aviso(r.error || _('Could not add that agent'), true);
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
for (const ruta of a.montajes) {
|
|
251
|
+
const m = await this.p.api<{ ok?: boolean; error?: string }>('/api/montaje', {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
body: JSON.stringify({ agent: r.agent, add: ruta }),
|
|
254
|
+
});
|
|
255
|
+
if (!m.ok) this.p.aviso(`${ruta}: ${m.error ?? _('could not mount')}`, true);
|
|
256
|
+
}
|
|
257
|
+
const motor: Record<string, string> = {};
|
|
258
|
+
if (a.runtime) motor.runtime = a.runtime;
|
|
259
|
+
if (a.modelo) motor.model = a.modelo;
|
|
260
|
+
if (a.esfuerzo) motor.effort = a.esfuerzo;
|
|
261
|
+
if (Object.keys(motor).length) {
|
|
262
|
+
const m = await this.p.api<{ ok?: boolean; error?: string }>('/api/agente', {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
body: JSON.stringify({ agent: r.agent, ...motor }),
|
|
265
|
+
});
|
|
266
|
+
if (!m.ok) this.p.aviso(m.error ?? _('Could not set its engine'), true);
|
|
267
|
+
}
|
|
268
|
+
return { slug: r.agent, nombre };
|
|
269
|
+
} catch (e) {
|
|
270
|
+
this.p.aviso(String(e), true);
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** How this house reads on a list, once it is built. */
|
|
276
|
+
resumen(): string {
|
|
277
|
+
const a = this.datos;
|
|
278
|
+
return a.montajes.length
|
|
279
|
+
? plural(a.montajes.length, '{n} mount', '{n} mounts')
|
|
280
|
+
: _('nothing mounted yet');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The demo shelf: a committee, played back.
|
|
3
|
+
*
|
|
4
|
+
* `agents-city demo` has always existed and it is the honest one — an ephemeral
|
|
5
|
+
* city, the real bus, the real state machine. It also asks for a terminal, a
|
|
6
|
+
* wrangler download and two minutes. Somebody deciding whether this product is
|
|
7
|
+
* for their clinic or their law firm will not do that first; they will look for
|
|
8
|
+
* a play button, and there wasn't one.
|
|
9
|
+
*
|
|
10
|
+
* So the Hall plays a **recording**. Not a simulation: `demo/graba.py` runs each
|
|
11
|
+
* story over that same real bus and keeps the exact event stream a spectator
|
|
12
|
+
* saw, and this plays those events back through the very same renderer the live
|
|
13
|
+
* rail uses. Nothing here knows what a committee is, which is the point — a
|
|
14
|
+
* second implementation of the state machine is a second thing to drift.
|
|
15
|
+
*
|
|
16
|
+
* It says on screen that it is a recording. A demo that pretends to be live is
|
|
17
|
+
* the one kind of demo this product must never ship.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ActivityEvent } from './activity';
|
|
21
|
+
import { t as _ } from './idioma';
|
|
22
|
+
|
|
23
|
+
export interface Ficha {
|
|
24
|
+
id: string;
|
|
25
|
+
dominio: string;
|
|
26
|
+
ciudad: string;
|
|
27
|
+
titulo: string;
|
|
28
|
+
turnos: number;
|
|
29
|
+
reparto: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Grabacion extends Ficha {
|
|
33
|
+
eventos: ActivityEvent[];
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PuertaDemo {
|
|
38
|
+
api: <T>(ruta: string, opts?: RequestInit) => Promise<T>;
|
|
39
|
+
esc: (s: unknown) => string;
|
|
40
|
+
/** The live rail's own turn renderer — the same one, deliberately. */
|
|
41
|
+
pinta: (evento: ActivityEvent) => string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How fast a turn arrives, in milliseconds. The middle one is the pace the
|
|
45
|
+
* terminal demo uses, so the two feel like the same product. */
|
|
46
|
+
const VELOCIDADES: Array<[string, number]> = [
|
|
47
|
+
['0.5×', 2900],
|
|
48
|
+
['1×', 1450],
|
|
49
|
+
['2×', 700],
|
|
50
|
+
['4×', 350],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export class Demos {
|
|
54
|
+
private catalogo: Ficha[] = [];
|
|
55
|
+
private cargando = false;
|
|
56
|
+
private elegida: Grabacion | null = null;
|
|
57
|
+
private hasta = 0;
|
|
58
|
+
private corriendo = false;
|
|
59
|
+
private velocidad = 1450;
|
|
60
|
+
private reloj: number | null = null;
|
|
61
|
+
private host: HTMLElement | null = null;
|
|
62
|
+
|
|
63
|
+
constructor(private readonly p: PuertaDemo) {}
|
|
64
|
+
|
|
65
|
+
monta(host: HTMLElement): void {
|
|
66
|
+
this.host = host;
|
|
67
|
+
if (!this.catalogo.length && !this.cargando) void this.lee();
|
|
68
|
+
this.repinta();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Stop the clock. Called when the section is left, or the page repaints
|
|
72
|
+
* elsewhere: a timer still firing into a detached node is a leak with a view. */
|
|
73
|
+
para(): void {
|
|
74
|
+
this.corriendo = false;
|
|
75
|
+
if (this.reloj !== null) {
|
|
76
|
+
clearTimeout(this.reloj);
|
|
77
|
+
this.reloj = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private repinta(): void {
|
|
82
|
+
if (!this.host) return;
|
|
83
|
+
this.host.innerHTML = this.html();
|
|
84
|
+
this.enlaza(this.host);
|
|
85
|
+
if (this.elegida) {
|
|
86
|
+
const lista = this.host.querySelector<HTMLElement>('#demoLista');
|
|
87
|
+
if (lista) lista.scrollTop = lista.scrollHeight;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async lee(): Promise<void> {
|
|
92
|
+
this.cargando = true;
|
|
93
|
+
try {
|
|
94
|
+
const r = await this.p.api<{ demos: Ficha[] }>('/api/demos');
|
|
95
|
+
this.catalogo = r.demos ?? [];
|
|
96
|
+
} catch {
|
|
97
|
+
this.catalogo = [];
|
|
98
|
+
} finally {
|
|
99
|
+
this.cargando = false;
|
|
100
|
+
this.repinta();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async abre(id: string): Promise<void> {
|
|
105
|
+
this.para();
|
|
106
|
+
this.hasta = 0;
|
|
107
|
+
this.elegida = null;
|
|
108
|
+
this.repinta();
|
|
109
|
+
try {
|
|
110
|
+
const r = await this.p.api<Grabacion>('/api/demos?story=' + encodeURIComponent(id));
|
|
111
|
+
if (!r.error) this.elegida = r;
|
|
112
|
+
} catch {
|
|
113
|
+
this.elegida = null;
|
|
114
|
+
}
|
|
115
|
+
this.repinta();
|
|
116
|
+
if (this.elegida) this.arranca();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private arranca(): void {
|
|
120
|
+
if (!this.elegida) return;
|
|
121
|
+
if (this.hasta >= this.elegida.eventos.length) this.hasta = 0;
|
|
122
|
+
this.corriendo = true;
|
|
123
|
+
this.repinta();
|
|
124
|
+
this.siguiente();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private siguiente(): void {
|
|
128
|
+
if (this.reloj !== null) clearTimeout(this.reloj);
|
|
129
|
+
this.reloj = window.setTimeout(() => {
|
|
130
|
+
if (!this.corriendo || !this.elegida) return;
|
|
131
|
+
this.hasta += 1;
|
|
132
|
+
if (this.hasta >= this.elegida.eventos.length) {
|
|
133
|
+
this.hasta = this.elegida.eventos.length;
|
|
134
|
+
this.corriendo = false;
|
|
135
|
+
this.repinta();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
this.repinta();
|
|
139
|
+
this.siguiente();
|
|
140
|
+
}, this.velocidad);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private html(): string {
|
|
144
|
+
if (this.elegida) return this.reproductor(this.elegida);
|
|
145
|
+
if (this.cargando) return `<p class="cargando">${_('reading the demo shelf')}</p>`;
|
|
146
|
+
if (!this.catalogo.length)
|
|
147
|
+
return `<div><span class="sub">${_('demos')}</span>
|
|
148
|
+
<h1 style="margin-top:6px">${_('Nothing recorded here')}</h1>
|
|
149
|
+
<p class="prosa">${_('This install has no demo recordings. Make them with demo/graba.py, or run the full thing from a terminal with agents-city demo.')}</p></div>`;
|
|
150
|
+
const esc = this.p.esc;
|
|
151
|
+
return `<div>
|
|
152
|
+
<span class="sub">${_('demos')}</span>
|
|
153
|
+
<h1 style="margin-top:6px">${_('See a committee happen')}</h1>
|
|
154
|
+
<p class="prosa">${_(`A real question, answered by a city of agents that never talk to each
|
|
155
|
+
other behind the chair's back. Pick the field closest to yours — the machine is the same
|
|
156
|
+
one in all three; only the work changes.`)}</p>
|
|
157
|
+
<div class="demoRejilla">${this.catalogo
|
|
158
|
+
.map(
|
|
159
|
+
(d) => `<button type="button" class="demoCarta" data-demo="abre" data-id="${esc(d.id)}">
|
|
160
|
+
<span class="demoDominio">${esc(d.dominio)}</span>
|
|
161
|
+
<b>${esc(d.titulo)}</b>
|
|
162
|
+
<span class="demoCiudad">${esc(d.ciudad)}</span>
|
|
163
|
+
<span class="demoReparto">${esc(d.reparto.join(' · '))}</span>
|
|
164
|
+
<span class="demoPlay">▶ ${_('{n} turns', { n: d.turnos })}</span>
|
|
165
|
+
</button>`,
|
|
166
|
+
)
|
|
167
|
+
.join('')}</div>
|
|
168
|
+
<p class="pista">${_('These are recordings of real runs over the real bus, played back here. To run one live in a terminal: agents-city demo --domain software.')}</p>
|
|
169
|
+
</div>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private reproductor(d: Grabacion): string {
|
|
173
|
+
const esc = this.p.esc;
|
|
174
|
+
const total = d.eventos.length;
|
|
175
|
+
const hecho = Math.min(this.hasta, total);
|
|
176
|
+
const turnos = d.eventos.slice(0, hecho).map(this.p.pinta).join('');
|
|
177
|
+
const acabado = hecho >= total;
|
|
178
|
+
return `<div class="demoPlayer">
|
|
179
|
+
<div class="demoCab">
|
|
180
|
+
<button type="button" class="bt bvMini" data-demo="atras">← ${_('All demos')}</button>
|
|
181
|
+
<div><span class="sub">${esc(d.dominio)} · ${esc(d.ciudad)}</span>
|
|
182
|
+
<h2>${esc(d.titulo)}</h2></div>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="demoMandos">
|
|
185
|
+
<button type="button" class="bt ppal" data-demo="${this.corriendo ? 'pausa' : 'play'}">${
|
|
186
|
+
this.corriendo ? `❚❚ ${_('Pause')}` : acabado ? `↺ ${_('Replay')}` : `▶ ${_('Play')}`
|
|
187
|
+
}</button>
|
|
188
|
+
<button type="button" class="bt" data-demo="replay" ${hecho ? '' : 'disabled'}>↺ ${_(
|
|
189
|
+
'Replay',
|
|
190
|
+
)}</button>
|
|
191
|
+
<span class="demoVel">${VELOCIDADES.map(
|
|
192
|
+
([etiqueta, ms]) =>
|
|
193
|
+
`<button type="button" class="demoVelBoton ${
|
|
194
|
+
ms === this.velocidad ? 'aqui' : ''
|
|
195
|
+
}" data-demo="vel" data-ms="${ms}">${esc(etiqueta)}</button>`,
|
|
196
|
+
).join('')}</span>
|
|
197
|
+
<span class="demoCuenta">${_('{done} of {total}', { done: hecho, total })}</span>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="demoBarra"><i style="width:${total ? (hecho / total) * 100 : 0}%"></i></div>
|
|
200
|
+
<ol class="liveLista" id="demoLista">${
|
|
201
|
+
turnos ||
|
|
202
|
+
`<li class="liveEmpty">${_('Press play. The turns arrive one by one, exactly as they did.')}</li>`
|
|
203
|
+
}</ol>
|
|
204
|
+
<p class="pista">${_('A recording of a real run: these events came off the real bus, from the real committee. Nothing here is being decided now.')}</p>
|
|
205
|
+
</div>`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private enlaza(raiz: HTMLElement): void {
|
|
209
|
+
raiz.querySelectorAll<HTMLElement>('[data-demo]').forEach((el) => {
|
|
210
|
+
el.onclick = (evento) => {
|
|
211
|
+
evento.preventDefault();
|
|
212
|
+
switch (el.dataset.demo) {
|
|
213
|
+
case 'abre':
|
|
214
|
+
void this.abre(el.dataset.id ?? '');
|
|
215
|
+
break;
|
|
216
|
+
case 'atras':
|
|
217
|
+
this.para();
|
|
218
|
+
this.elegida = null;
|
|
219
|
+
this.hasta = 0;
|
|
220
|
+
this.repinta();
|
|
221
|
+
break;
|
|
222
|
+
case 'play':
|
|
223
|
+
this.arranca();
|
|
224
|
+
break;
|
|
225
|
+
case 'pausa':
|
|
226
|
+
this.para();
|
|
227
|
+
this.repinta();
|
|
228
|
+
break;
|
|
229
|
+
case 'replay':
|
|
230
|
+
this.para();
|
|
231
|
+
this.hasta = 0;
|
|
232
|
+
this.arranca();
|
|
233
|
+
break;
|
|
234
|
+
case 'vel': {
|
|
235
|
+
this.velocidad = Number(el.dataset.ms) || 1450;
|
|
236
|
+
// Take effect on the next turn rather than after the one already
|
|
237
|
+
// scheduled at the old pace: a speed control that waits three
|
|
238
|
+
// seconds to be believed reads as broken.
|
|
239
|
+
if (this.corriendo) this.siguiente();
|
|
240
|
+
this.repinta();
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|