@alashi/cli 0.1.1 → 0.2.1
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 +296 -0
- package/assets/v1/system.css +532 -0
- package/dist/cli.mjs +8 -5
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +8 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{store-CwgDCAAx.mjs → store-Ckx0oLB6.mjs} +265 -43
- package/dist/store-Ckx0oLB6.mjs.map +1 -0
- package/package.json +3 -2
- package/templates/basic-app/AGENTS.md +95 -0
- package/templates/basic-app/README.md +1 -0
- package/templates/basic-app/ui/index.html +12 -281
- package/dist/store-CwgDCAAx.mjs.map +0 -1
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/* alashi chat component v1
|
|
2
|
+
*
|
|
3
|
+
* The system chat UI, served by the host. Apps drop in:
|
|
4
|
+
*
|
|
5
|
+
* <script src="/assets/v1/chat.js"></script>
|
|
6
|
+
* <alashi-chat></alashi-chat>
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* Attributes:
|
|
13
|
+
* app app name (default: derived from /app/<name>/ in the URL)
|
|
14
|
+
* placeholder input placeholder text
|
|
15
|
+
*
|
|
16
|
+
* Methods:
|
|
17
|
+
* adoptSession(sessionId, title) create a conversation resuming an
|
|
18
|
+
* existing engine session, then open it
|
|
19
|
+
* openConversation(id) open a conversation by id
|
|
20
|
+
* newChat() start a fresh conversation
|
|
21
|
+
*
|
|
22
|
+
* Events (bubble):
|
|
23
|
+
* alashi-chat:turn detail: { ok, costUsd, error, conversationId }
|
|
24
|
+
* alashi-chat:conversation detail: { conversationId }
|
|
25
|
+
*
|
|
26
|
+
* Breaking changes ship as /assets/v2/chat.js. */
|
|
27
|
+
|
|
28
|
+
(() => {
|
|
29
|
+
if (customElements.get('alashi-chat')) return;
|
|
30
|
+
|
|
31
|
+
const escapeHtml = (s) =>
|
|
32
|
+
s
|
|
33
|
+
.replace(/&/g, '&')
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>')
|
|
36
|
+
.replace(/"/g, '"')
|
|
37
|
+
.replace(/'/g, ''');
|
|
38
|
+
|
|
39
|
+
const safeHref = (url) => (/^https?:\/\//i.test(url) ? url : null);
|
|
40
|
+
// Images may also be same-origin paths (app-served artifacts, e.g.
|
|
41
|
+
// /app/<name>/api/chart.png) — but not protocol-relative //host URLs.
|
|
42
|
+
const safeSrc = (url) =>
|
|
43
|
+
safeHref(url) ?? (url.startsWith('/') && !url.startsWith('//') ? url : null);
|
|
44
|
+
|
|
45
|
+
/* Minimal markdown for chat: escape-first, then rebuild known-safe tags.
|
|
46
|
+
* Supports fenced code, inline code, bold, italic, links, images,
|
|
47
|
+
* headings, unordered/ordered lists, paragraphs. */
|
|
48
|
+
function renderMarkdown(text) {
|
|
49
|
+
// NUL delimits internal placeholders; never allow it in from outside.
|
|
50
|
+
const escaped = escapeHtml(text.replaceAll('\u0000', ''));
|
|
51
|
+
const blocks = [];
|
|
52
|
+
// Pull out fenced code blocks first so nothing inside is transformed.
|
|
53
|
+
const withoutFences = escaped.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m, _lang, code) => {
|
|
54
|
+
blocks.push(`<pre><code>${code.replace(/\n$/, '')}</code></pre>`);
|
|
55
|
+
return `\u0000${blocks.length - 1}\u0000`;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const inline = (s) =>
|
|
59
|
+
s
|
|
60
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
61
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
62
|
+
.replace(/(^|\W)\*([^*\s][^*]*)\*/g, '$1<em>$2</em>')
|
|
63
|
+
// Images before links:  contains a link-shaped tail.
|
|
64
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) => {
|
|
65
|
+
const src = safeSrc(url);
|
|
66
|
+
return src ? `<img src="${src}" alt="${alt}" loading="lazy">` : m;
|
|
67
|
+
})
|
|
68
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
|
|
69
|
+
const href = safeHref(url);
|
|
70
|
+
return href
|
|
71
|
+
? `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>`
|
|
72
|
+
: m;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const html = withoutFences
|
|
76
|
+
.split(/\n{2,}/)
|
|
77
|
+
.map((chunk) => {
|
|
78
|
+
const t = chunk.trim();
|
|
79
|
+
if (t === '') return '';
|
|
80
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is the internal placeholder delimiter
|
|
81
|
+
if (/^\u0000\d+\u0000$/.test(t)) return t; // fenced block placeholder
|
|
82
|
+
const heading = t.match(/^(#{1,4})\s+(.*)$/);
|
|
83
|
+
if (heading && !t.includes('\n')) {
|
|
84
|
+
const level = heading[1].length + 2; // h3..h6 inside a bubble
|
|
85
|
+
return `<h${level}>${inline(heading[2])}</h${level}>`;
|
|
86
|
+
}
|
|
87
|
+
const lines = t.split('\n');
|
|
88
|
+
if (lines.every((l) => /^\s*[-*]\s+/.test(l))) {
|
|
89
|
+
return `<ul>${lines.map((l) => `<li>${inline(l.replace(/^\s*[-*]\s+/, ''))}</li>`).join('')}</ul>`;
|
|
90
|
+
}
|
|
91
|
+
if (lines.every((l) => /^\s*\d+\.\s+/.test(l))) {
|
|
92
|
+
return `<ol>${lines.map((l) => `<li>${inline(l.replace(/^\s*\d+\.\s+/, ''))}</li>`).join('')}</ol>`;
|
|
93
|
+
}
|
|
94
|
+
return `<p>${lines.map(inline).join('<br>')}</p>`;
|
|
95
|
+
})
|
|
96
|
+
.join('');
|
|
97
|
+
|
|
98
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is the internal placeholder delimiter
|
|
99
|
+
return html.replace(/\u0000(\d+)\u0000/g, (_m, i) => blocks[Number(i)]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function el(tag, props = {}, children = []) {
|
|
103
|
+
const node = document.createElement(tag);
|
|
104
|
+
Object.assign(node, props);
|
|
105
|
+
for (const child of children) node.append(child);
|
|
106
|
+
return node;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class AlashiChat extends HTMLElement {
|
|
110
|
+
connectedCallback() {
|
|
111
|
+
if (this._initialized) return;
|
|
112
|
+
this._initialized = true;
|
|
113
|
+
this.conversationId = null;
|
|
114
|
+
this._render();
|
|
115
|
+
this._loadConversations()
|
|
116
|
+
.then((conversations) => {
|
|
117
|
+
if (conversations.length > 0) this.openConversation(conversations[0].id);
|
|
118
|
+
})
|
|
119
|
+
.catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get app() {
|
|
123
|
+
return this.getAttribute('app') ?? decodeURIComponent(window.location.pathname.split('/')[2]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_hostApi(path) {
|
|
127
|
+
return `/api/apps/${encodeURIComponent(this.app)}${path}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_render() {
|
|
131
|
+
this._log = el('div', { className: 'chat-log' });
|
|
132
|
+
this._input = el('input', {
|
|
133
|
+
type: 'text',
|
|
134
|
+
placeholder: this.getAttribute('placeholder') ?? 'Say something',
|
|
135
|
+
autocomplete: 'off',
|
|
136
|
+
});
|
|
137
|
+
this._send = el('button', { className: 'primary', type: 'submit', textContent: 'Send' });
|
|
138
|
+
this._status = el('div', { className: 'chat-status' });
|
|
139
|
+
this._list = el('ul', { className: 'conversations' });
|
|
140
|
+
const form = el('form', { className: 'chat-form' }, [this._input, this._send]);
|
|
141
|
+
form.addEventListener('submit', (event) => {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
this._submit();
|
|
144
|
+
});
|
|
145
|
+
const newChat = el('button', { type: 'button', textContent: '+ New chat' });
|
|
146
|
+
newChat.addEventListener('click', () => this.newChat());
|
|
147
|
+
this.append(
|
|
148
|
+
el('div', { className: 'chat-layout' }, [
|
|
149
|
+
el('div', { className: 'panel conversations-panel' }, [newChat, this._list]),
|
|
150
|
+
el('div', { className: 'panel chat-main' }, [this._log, form, this._status]),
|
|
151
|
+
]),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_appendMsg(text, role) {
|
|
156
|
+
const div = el('div', { className: `msg ${role}` });
|
|
157
|
+
this._setMsgText(div, text, role);
|
|
158
|
+
this._log.appendChild(div);
|
|
159
|
+
this._log.scrollTop = this._log.scrollHeight;
|
|
160
|
+
return div;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_setMsgText(div, text, role) {
|
|
164
|
+
// User text stays plain; assistant text is markdown-rendered from an
|
|
165
|
+
// escape-first pipeline (renderMarkdown escapes before rebuilding tags).
|
|
166
|
+
if (role === 'assistant') div.innerHTML = renderMarkdown(text);
|
|
167
|
+
else div.textContent = text;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async _loadConversations() {
|
|
171
|
+
const res = await fetch(this._hostApi('/chat/conversations'));
|
|
172
|
+
const { conversations } = await res.json();
|
|
173
|
+
this._list.replaceChildren(
|
|
174
|
+
...conversations.map((conv) => {
|
|
175
|
+
const li = el('li', {
|
|
176
|
+
textContent: conv.title || conv.id,
|
|
177
|
+
title: conv.title || conv.id,
|
|
178
|
+
className: conv.id === this.conversationId ? 'active' : '',
|
|
179
|
+
});
|
|
180
|
+
li.addEventListener('click', () => this.openConversation(conv.id));
|
|
181
|
+
return li;
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
return conversations;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async openConversation(id) {
|
|
188
|
+
this.conversationId = id;
|
|
189
|
+
this._log.replaceChildren();
|
|
190
|
+
this._status.textContent = 'loading history…';
|
|
191
|
+
try {
|
|
192
|
+
const res = await fetch(this._hostApi(`/chat/${encodeURIComponent(id)}/history`));
|
|
193
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
194
|
+
const { messages } = await res.json();
|
|
195
|
+
for (const message of messages) this._appendMsg(message.text, message.role);
|
|
196
|
+
this._status.textContent = '';
|
|
197
|
+
} catch (err) {
|
|
198
|
+
this._status.textContent = String(err);
|
|
199
|
+
}
|
|
200
|
+
this._loadConversations().catch(() => {});
|
|
201
|
+
this._emit('conversation', { conversationId: id });
|
|
202
|
+
this._input.focus();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async adoptSession(sessionId, title) {
|
|
206
|
+
const res = await fetch(this._hostApi('/chat/conversations'), {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'content-type': 'application/json' },
|
|
209
|
+
body: JSON.stringify({ sessionId, ...(title ? { title } : {}) }),
|
|
210
|
+
});
|
|
211
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
212
|
+
const { conversation } = await res.json();
|
|
213
|
+
await this.openConversation(conversation.id);
|
|
214
|
+
return conversation;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
newChat() {
|
|
218
|
+
this.conversationId = null;
|
|
219
|
+
this._log.replaceChildren();
|
|
220
|
+
this._status.textContent = '';
|
|
221
|
+
this._loadConversations().catch(() => {});
|
|
222
|
+
this._emit('conversation', { conversationId: null });
|
|
223
|
+
this._input.focus();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_emit(name, detail) {
|
|
227
|
+
this.dispatchEvent(new CustomEvent(`alashi-chat:${name}`, { detail, bubbles: true }));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async _submit() {
|
|
231
|
+
const message = this._input.value.trim();
|
|
232
|
+
if (!message) return;
|
|
233
|
+
this._input.value = '';
|
|
234
|
+
this._send.disabled = true;
|
|
235
|
+
this._appendMsg(message, 'user');
|
|
236
|
+
const reply = this._appendMsg('', 'assistant');
|
|
237
|
+
this._status.textContent = 'thinking…';
|
|
238
|
+
let replyText = '';
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const res = await fetch(this._hostApi('/chat'), {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'content-type': 'application/json' },
|
|
244
|
+
body: JSON.stringify({
|
|
245
|
+
message,
|
|
246
|
+
...(this.conversationId ? { conversationId: this.conversationId } : {}),
|
|
247
|
+
}),
|
|
248
|
+
});
|
|
249
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
250
|
+
const isNew = !this.conversationId;
|
|
251
|
+
this.conversationId = res.headers.get('x-conversation-id') || this.conversationId;
|
|
252
|
+
|
|
253
|
+
const reader = res.body.getReader();
|
|
254
|
+
const decoder = new TextDecoder();
|
|
255
|
+
let buffer = '';
|
|
256
|
+
for (;;) {
|
|
257
|
+
const { done, value } = await reader.read();
|
|
258
|
+
if (done) break;
|
|
259
|
+
buffer += decoder.decode(value, { stream: true });
|
|
260
|
+
const frames = buffer.split('\n\n');
|
|
261
|
+
buffer = frames.pop();
|
|
262
|
+
for (const frame of frames) {
|
|
263
|
+
const dataLine = frame.split('\n').find((line) => line.startsWith('data: '));
|
|
264
|
+
if (!dataLine) continue;
|
|
265
|
+
const event = JSON.parse(dataLine.slice(6));
|
|
266
|
+
if (event.type === 'text') {
|
|
267
|
+
replyText += event.text;
|
|
268
|
+
this._setMsgText(reply, replyText, 'assistant');
|
|
269
|
+
this._log.scrollTop = this._log.scrollHeight;
|
|
270
|
+
}
|
|
271
|
+
if (event.type === 'tool_use') this._status.textContent = `using tool: ${event.name}`;
|
|
272
|
+
if (event.type === 'result') {
|
|
273
|
+
this._status.textContent = event.ok
|
|
274
|
+
? `done${event.costUsd ? ` ($${event.costUsd.toFixed(4)})` : ''}`
|
|
275
|
+
: `error: ${event.error}`;
|
|
276
|
+
this._emit('turn', {
|
|
277
|
+
ok: event.ok,
|
|
278
|
+
costUsd: event.costUsd,
|
|
279
|
+
error: event.error,
|
|
280
|
+
conversationId: this.conversationId,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (isNew) this._loadConversations().catch(() => {});
|
|
286
|
+
} catch (err) {
|
|
287
|
+
this._status.textContent = String(err);
|
|
288
|
+
} finally {
|
|
289
|
+
this._send.disabled = false;
|
|
290
|
+
this._input.focus();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
customElements.define('alashi-chat', AlashiChat);
|
|
296
|
+
})();
|