@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 +249 -55
- package/assets/v1/system.css +40 -0
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +272 -78
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{store-DoAGOynU.mjs → store-dipRcfKX.mjs} +815 -47
- package/dist/store-dipRcfKX.mjs.map +1 -0
- package/package.json +3 -3
- package/templates/basic-app/index.mjs +1 -1
- package/templates/basic-app/package.json +1 -1
- package/dist/store-DoAGOynU.mjs.map +0 -1
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,
|
|
9
|
-
* and cost display. Renders into
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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.
|
|
358
|
-
|
|
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
|
}
|
package/assets/v1/system.css
CHANGED
|
@@ -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-
|
|
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";
|