@alashi/cli 0.14.0 → 0.15.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/assets/v1/chat.js CHANGED
@@ -5,9 +5,15 @@
5
5
  * <script src="/assets/v1/chat.js"></script>
6
6
  * <alashi-chat></alashi-chat>
7
7
  *
8
- * and get conversations, SSE streaming, markdown rendering, tool-use status,
9
- * and cost display. Renders into light DOM using system.css classes, so it
10
- * inherits the app's theme tokens.
8
+ * and get conversations, live event streaming, markdown rendering, tool-use
9
+ * status, interactive tool-approval cards, and cost display. Renders into
10
+ * light DOM using system.css classes, so it inherits the app's theme tokens.
11
+ *
12
+ * Turns are enqueue-only: POST /chat returns 202 and every event (including
13
+ * the echo of the user's own message) arrives over a standing per-conversation
14
+ * EventSource, so any number of tabs render the same transcript and the
15
+ * stream survives refreshes via Last-Event-ID replay + the pending-approvals
16
+ * snapshot.
11
17
  *
12
18
  * The same script also registers <alashi-markdown>, the markdown renderer on
13
19
  * its own for apps that display prose outside a conversation (see the bottom
@@ -121,6 +127,8 @@
121
127
  if (this._initialized) return;
122
128
  this._initialized = true;
123
129
  this.conversationId = null;
130
+ this._events = null;
131
+ this._resetTurn();
124
132
  this._render();
125
133
  // Single-conversation mode: the embedding app opens the transcript it
126
134
  // wants, so skip both the listing fetch and the auto-open.
@@ -238,6 +246,8 @@
238
246
 
239
247
  async openConversation(id) {
240
248
  this.conversationId = id;
249
+ this._disconnect();
250
+ this._resetTurn();
241
251
  this._log.replaceChildren();
242
252
  this._status.textContent = 'loading history…';
243
253
  const { bubble, note } = this._appendPending();
@@ -253,11 +263,232 @@
253
263
  this._showError(bubble, false, `Failed to load history: ${String(err)}`);
254
264
  this._status.textContent = String(err);
255
265
  }
266
+ await this._loadPendingApprovals().catch(() => {});
267
+ // Fresh connect: the host replays the in-flight turn (everything before
268
+ // it is already in the history we just rendered), then follows live.
269
+ this._connect(id);
256
270
  this._loadConversations().catch(() => {});
257
271
  this._emit('conversation', { conversationId: id });
258
272
  this._input.focus();
259
273
  }
260
274
 
275
+ /* Standing per-conversation EventSource. `fromStart` replays the whole
276
+ * ring — used right after creating a conversation, whose only events are
277
+ * this first turn. Reconnects resume via Last-Event-ID automatically; a
278
+ * gap beyond the ring arrives as a `resync` event instead. */
279
+ _connect(id, { fromStart = false } = {}) {
280
+ this._disconnect();
281
+ const url = this._hostApi(
282
+ `/chat/${encodeURIComponent(id)}/events${fromStart ? '?lastEventId=0' : ''}`,
283
+ );
284
+ this._events = new EventSource(url);
285
+ this._events.onmessage = (e) => {
286
+ let event;
287
+ try {
288
+ event = JSON.parse(e.data);
289
+ } catch {
290
+ return;
291
+ }
292
+ if (this.conversationId === id) this._handleEvent(event);
293
+ };
294
+ }
295
+
296
+ _disconnect() {
297
+ if (this._events) {
298
+ this._events.close();
299
+ this._events = null;
300
+ }
301
+ }
302
+
303
+ _resetTurn() {
304
+ this._reply = null;
305
+ this._replyNote = null;
306
+ this._replyText = '';
307
+ }
308
+
309
+ /* The current turn's assistant bubble, created on demand so a turn echoed
310
+ * from another tab renders the same as one submitted here. */
311
+ _ensureReply() {
312
+ if (!this._reply?.isConnected) {
313
+ const { bubble, note } = this._appendPending();
314
+ this._reply = bubble;
315
+ this._replyNote = note;
316
+ this._replyText = '';
317
+ }
318
+ return this._reply;
319
+ }
320
+
321
+ _handleEvent(event) {
322
+ if (event.type === 'user_message') {
323
+ this._appendMsg(event.text, 'user');
324
+ this._ensureReply();
325
+ this._status.textContent = 'thinking…';
326
+ return;
327
+ }
328
+ if (event.type === 'text') {
329
+ const reply = this._ensureReply();
330
+ this._replyText += event.text;
331
+ reply.classList.remove('pending');
332
+ this._setMsgText(reply, this._replyText, 'assistant');
333
+ this._log.scrollTop = this._log.scrollHeight;
334
+ return;
335
+ }
336
+ if (event.type === 'tool_use') {
337
+ this._status.textContent = `using tool: ${event.name}`;
338
+ if (this._reply?.classList.contains('pending'))
339
+ this._replyNote.textContent = `using tool: ${event.name}…`;
340
+ return;
341
+ }
342
+ if (event.type === 'approval_required') {
343
+ this._renderApproval(event);
344
+ return;
345
+ }
346
+ if (event.type === 'approval_resolved') {
347
+ this._resolveApproval(event);
348
+ return;
349
+ }
350
+ if (event.type === 'compaction') {
351
+ this._log.append(
352
+ el('div', {
353
+ className: 'chat-divider',
354
+ textContent: `context compacted · ${event.trigger}`,
355
+ }),
356
+ );
357
+ this._log.scrollTop = this._log.scrollHeight;
358
+ return;
359
+ }
360
+ if (event.type === 'resync') {
361
+ // Missed too much to replay: rebuild from history + pending snapshot.
362
+ this.openConversation(this.conversationId).catch(() => {});
363
+ return;
364
+ }
365
+ if (event.type === 'result') {
366
+ this._status.textContent = event.ok
367
+ ? `done${event.costUsd ? ` ($${event.costUsd.toFixed(4)})` : ''}`
368
+ : `error: ${event.error}`;
369
+ const reply = this._ensureReply();
370
+ if (!event.ok) {
371
+ this._showError(reply, this._replyText !== '', `Something went wrong: ${event.error}`);
372
+ } else if (this._replyText === '') {
373
+ reply.className = 'msg assistant';
374
+ reply.replaceChildren(
375
+ el('span', { className: 'typing-note', textContent: '(no reply)' }),
376
+ );
377
+ }
378
+ this._emit('turn', {
379
+ ok: event.ok,
380
+ costUsd: event.costUsd,
381
+ error: event.error,
382
+ conversationId: this.conversationId,
383
+ });
384
+ this._resetTurn();
385
+ this._send.disabled = false;
386
+ this._input.focus();
387
+ }
388
+ }
389
+
390
+ /* Inline approval card: tool, input preview, and the four decisions. The
391
+ * decision travels over the respond endpoint; the card collapses when the
392
+ * matching approval_resolved event comes back on the bus (so every tab
393
+ * collapses, not just the one that clicked). */
394
+ _renderApproval(event) {
395
+ if (this._approvalCard(event.approvalId)) return; // snapshot + replay overlap
396
+ const card = el('div', { className: 'approval-card' });
397
+ card.dataset.approvalId = event.approvalId;
398
+ card.dataset.tool = event.tool;
399
+ let preview = '';
400
+ try {
401
+ preview = JSON.stringify(event.input, null, 2);
402
+ } catch {
403
+ preview = String(event.input);
404
+ }
405
+ if (preview && preview.length > 2000) preview = `${preview.slice(0, 2000)}…`;
406
+ const actions = el('div', { className: 'approval-actions' });
407
+ const act = (label, body, primary) => {
408
+ const button = el('button', {
409
+ type: 'button',
410
+ textContent: label,
411
+ className: primary ? 'primary' : '',
412
+ });
413
+ button.addEventListener('click', () => {
414
+ for (const b of actions.querySelectorAll('button')) b.disabled = true;
415
+ this._respondToApproval(event.approvalId, body).catch(() => {
416
+ for (const b of actions.querySelectorAll('button')) b.disabled = false;
417
+ });
418
+ });
419
+ actions.append(button);
420
+ };
421
+ act('Approve', { behavior: 'allow' }, true);
422
+ act('Approve for session', { behavior: 'allow', scope: 'session' });
423
+ act('Always allow', { behavior: 'allow', scope: 'always' });
424
+ act('Deny', { behavior: 'deny' });
425
+ card.append(
426
+ el('div', {
427
+ className: 'approval-title',
428
+ textContent: `Approval required: ${event.tool}`,
429
+ }),
430
+ ...(preview ? [el('pre', {}, [el('code', { textContent: preview })])] : []),
431
+ actions,
432
+ );
433
+ // Keep the typing indicator visually last while the engine is suspended.
434
+ if (this._reply?.isConnected) this._log.insertBefore(card, this._reply);
435
+ else this._log.append(card);
436
+ this._log.scrollTop = this._log.scrollHeight;
437
+ }
438
+
439
+ _approvalCard(approvalId) {
440
+ for (const card of this._log.querySelectorAll('.approval-card')) {
441
+ if (card.dataset.approvalId === approvalId) return card;
442
+ }
443
+ return null;
444
+ }
445
+
446
+ async _respondToApproval(approvalId, body) {
447
+ const res = await fetch(
448
+ this._hostApi(
449
+ `/chat/${encodeURIComponent(this.conversationId)}/approvals/${encodeURIComponent(approvalId)}`,
450
+ ),
451
+ {
452
+ method: 'POST',
453
+ headers: { 'content-type': 'application/json' },
454
+ body: JSON.stringify(body),
455
+ },
456
+ );
457
+ if (!res.ok && res.status !== 409) throw new Error(`HTTP ${res.status}`);
458
+ }
459
+
460
+ _resolveApproval(event) {
461
+ const card = this._approvalCard(event.approvalId);
462
+ if (!card) return;
463
+ const verdict = event.behavior === 'allow' ? 'Approved' : 'Denied';
464
+ const why =
465
+ event.reason && event.reason !== 'user' ? ` · ${event.reason.replace('_', ' ')}` : '';
466
+ card.className = 'approval-card resolved';
467
+ card.replaceChildren(
468
+ el('div', {
469
+ className: 'approval-title',
470
+ textContent: `${verdict}: ${card.dataset.tool}${why}`,
471
+ }),
472
+ );
473
+ }
474
+
475
+ async _loadPendingApprovals() {
476
+ if (!this.conversationId) return;
477
+ const res = await fetch(
478
+ this._hostApi(`/chat/${encodeURIComponent(this.conversationId)}/pending-approvals`),
479
+ );
480
+ if (!res.ok) return;
481
+ const { approvals } = await res.json();
482
+ for (const approval of approvals) {
483
+ this._renderApproval({
484
+ type: 'approval_required',
485
+ approvalId: approval.approvalId,
486
+ tool: approval.tool,
487
+ input: approval.input,
488
+ });
489
+ }
490
+ }
491
+
261
492
  async adoptSession(sessionId, title) {
262
493
  const res = await fetch(this._hostApi('/chat/conversations'), {
263
494
  method: 'POST',
@@ -272,6 +503,8 @@
272
503
 
273
504
  newChat() {
274
505
  this.conversationId = null;
506
+ this._disconnect();
507
+ this._resetTurn();
275
508
  this._log.replaceChildren();
276
509
  this._status.textContent = '';
277
510
  this._loadConversations().catch(() => {});
@@ -283,15 +516,14 @@
283
516
  this.dispatchEvent(new CustomEvent(`alashi-chat:${name}`, { detail, bubbles: true }));
284
517
  }
285
518
 
519
+ /* Enqueue-only: the 202 carries the conversation id, and the turn itself
520
+ * (including the echo of this message) arrives over the EventSource. */
286
521
  async _submit() {
287
522
  const message = this._input.value.trim();
288
523
  if (!message) return;
289
524
  this._input.value = '';
290
525
  this._send.disabled = true;
291
- this._appendMsg(message, 'user');
292
- const { bubble: reply, note } = this._appendPending();
293
526
  this._status.textContent = 'thinking…';
294
- let replyText = '';
295
527
 
296
528
  try {
297
529
  const res = await fetch(this._hostApi('/chat'), {
@@ -304,58 +536,20 @@
304
536
  });
305
537
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
306
538
  const isNew = !this.conversationId;
307
- this.conversationId = res.headers.get('x-conversation-id') || this.conversationId;
308
-
309
- const reader = res.body.getReader();
310
- const decoder = new TextDecoder();
311
- let buffer = '';
312
- for (;;) {
313
- const { done, value } = await reader.read();
314
- if (done) break;
315
- buffer += decoder.decode(value, { stream: true });
316
- const frames = buffer.split('\n\n');
317
- buffer = frames.pop();
318
- for (const frame of frames) {
319
- const dataLine = frame.split('\n').find((line) => line.startsWith('data: '));
320
- if (!dataLine) continue;
321
- const event = JSON.parse(dataLine.slice(6));
322
- if (event.type === 'text') {
323
- replyText += event.text;
324
- reply.classList.remove('pending');
325
- this._setMsgText(reply, replyText, 'assistant');
326
- this._log.scrollTop = this._log.scrollHeight;
327
- }
328
- if (event.type === 'tool_use') {
329
- this._status.textContent = `using tool: ${event.name}`;
330
- if (reply.classList.contains('pending'))
331
- note.textContent = `using tool: ${event.name}…`;
332
- }
333
- if (event.type === 'result') {
334
- this._status.textContent = event.ok
335
- ? `done${event.costUsd ? ` ($${event.costUsd.toFixed(4)})` : ''}`
336
- : `error: ${event.error}`;
337
- if (!event.ok) {
338
- this._showError(reply, replyText !== '', `Something went wrong: ${event.error}`);
339
- } else if (replyText === '') {
340
- reply.className = 'msg assistant';
341
- reply.replaceChildren(
342
- el('span', { className: 'typing-note', textContent: '(no reply)' }),
343
- );
344
- }
345
- this._emit('turn', {
346
- ok: event.ok,
347
- costUsd: event.costUsd,
348
- error: event.error,
349
- conversationId: this.conversationId,
350
- });
351
- }
352
- }
539
+ const { conversationId } = await res.json();
540
+ this.conversationId = conversationId || this.conversationId;
541
+ if (isNew) {
542
+ // New conversation: nothing on the bus predates this turn, so replay
543
+ // it all — the submit happened before the stream existed.
544
+ this._connect(this.conversationId, { fromStart: true });
545
+ this._emit('conversation', { conversationId: this.conversationId });
546
+ this._loadConversations().catch(() => {});
353
547
  }
354
- if (isNew) this._loadConversations().catch(() => {});
355
548
  } catch (err) {
356
549
  this._status.textContent = String(err);
357
- this._showError(reply, replyText !== '', `Something went wrong: ${String(err)}`);
358
- } finally {
550
+ this._appendMsg(message, 'user');
551
+ const { bubble } = this._appendPending();
552
+ this._showError(bubble, false, `Something went wrong: ${String(err)}`);
359
553
  this._send.disabled = false;
360
554
  this._input.focus();
361
555
  }
@@ -675,3 +675,43 @@ alashi-markdown {
675
675
  color: var(--muted);
676
676
  margin-top: 0.4rem;
677
677
  }
678
+ .approval-card {
679
+ border: 1px solid var(--warn, var(--border));
680
+ border-radius: 8px;
681
+ padding: 0.6rem 0.8rem;
682
+ margin: 0.25rem 0 0.6rem;
683
+ font-size: 0.9rem;
684
+ }
685
+ .approval-card .approval-title {
686
+ font-weight: 600;
687
+ margin-bottom: 0.4rem;
688
+ }
689
+ .approval-card pre {
690
+ max-height: 10rem;
691
+ overflow: auto;
692
+ margin: 0 0 0.5rem;
693
+ font-size: 0.8rem;
694
+ }
695
+ .approval-card.resolved {
696
+ border-color: var(--border);
697
+ color: var(--muted);
698
+ }
699
+ .approval-card.resolved .approval-title {
700
+ font-weight: 400;
701
+ margin-bottom: 0;
702
+ font-size: 0.85rem;
703
+ }
704
+ .approval-actions {
705
+ display: flex;
706
+ flex-wrap: wrap;
707
+ gap: 0.4rem;
708
+ }
709
+ .chat-divider {
710
+ color: var(--muted);
711
+ font-size: 0.75rem;
712
+ border-top: 1px dashed var(--border);
713
+ padding-top: 0.25rem;
714
+ margin: 0.4rem auto;
715
+ width: 60%;
716
+ text-align: center;
717
+ }
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { A as UsageRecorder, E as budgetStatus, N as AuthService, O as setAppBudget, b as loadApp, c as installApp, d as updateApp, f as addRegistry, h as removeRegistry, i as resolveAppConfig, n as createServer, o as exportApp, s as importApp, t as Store, u as removeApp } from "./store-DoAGOynU.mjs";
2
+ import { A as UsageRecorder, E as budgetStatus, N as AuthService, O as setAppBudget, b as loadApp, c as installApp, d as updateApp, f as addRegistry, h as removeRegistry, i as resolveAppConfig, n as createServer, o as exportApp, s as importApp, t as Store, u as removeApp } from "./store-dipRcfKX.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { APP_NAME_PATTERN, AppSdkError } from "@alashi/apps-sdk";
5
5
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";