create-wirejs-app 2.0.168-llm → 2.0.170

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.
@@ -0,0 +1,493 @@
1
+ import { marked } from 'marked';
2
+ import DOMPurify from 'dompurify';
3
+ import { html, id, css, hydrate, list, text, node } from 'wirejs-dom/v2';
4
+ import { AuthenticatedContent } from 'wirejs-components';
5
+ import { Main } from '../layouts/main.js';
6
+ import { llm, Chunk, Conversation, Role } from 'internal-api';
7
+
8
+ const sheet = css`
9
+ .messages {
10
+ height: calc(100vh - 30rem);
11
+ overflow: scroll;
12
+ }
13
+ .flex-row {
14
+ display: flex;
15
+ flex-direction: row;
16
+ }
17
+ .flex-row > textarea {
18
+ margin-right: 10px;
19
+ }
20
+ think, thinking {
21
+ display: none;
22
+ }
23
+ `;
24
+
25
+ function formatMessage(message: string): string {
26
+ const htmlString = marked.parse(message) as string;
27
+ const purified = DOMPurify.sanitize(htmlString, {
28
+ ADD_TAGS: ['think', 'thinking']
29
+ });
30
+ return purified;
31
+ }
32
+
33
+ class Message {
34
+ private roomId: string;
35
+ private chunks: Chunk[] = [];
36
+ private originalContent: string = '';
37
+
38
+ view = html`<div style='margin-top: 1em;'>
39
+ <b>${text('role', 'Assistant' as Role)}</b>
40
+ <br />
41
+ ${node('body', md => html`<div>${md}</div>`)}
42
+ </div>`;
43
+
44
+ constructor(roomId: string, role: Role, body: string = '', isDone: boolean = false) {
45
+ this.roomId = roomId;
46
+ this.isDone = isDone;
47
+ this.role = role;
48
+ this.originalContent = body;
49
+ this.view.data.body = formatMessage(body);
50
+ }
51
+
52
+ get isDone() {
53
+ return this.view.classList.contains('done');
54
+ }
55
+
56
+ set isDone(isDone: boolean) {
57
+ if (isDone) {
58
+ this.view.classList.add('done');
59
+ } else {
60
+ this.view.classList.remove('done');
61
+ }
62
+ }
63
+
64
+ get role(): Role {
65
+ return this.view.data.role as Role;
66
+ }
67
+
68
+ set role(role: Role) {
69
+ this.view.data.role = role;
70
+ if (role === 'step') {
71
+ this.view.style.opacity = "0.5";
72
+ }
73
+ }
74
+
75
+ // Returns the original unformatted content
76
+ get content(): string {
77
+ return this.originalContent;
78
+ }
79
+
80
+ // Returns the formatted HTML body for display
81
+ get body() {
82
+ return this.view.data.body;
83
+ }
84
+
85
+ set body(content: string) {
86
+ this.originalContent = content;
87
+ this.view.data.body = formatMessage(content);
88
+ }
89
+
90
+ async appendChunk(chunk: Chunk) {
91
+ this.chunks.push(chunk);
92
+ this.chunks.sort((a, b) => a.seq > b.seq ? 1 : -1);
93
+
94
+ let md: string[] = [];
95
+ for (const c of this.chunks) {
96
+ if (c.data.type === 'text') {
97
+ md.push(c.data.text);
98
+ }
99
+ }
100
+
101
+ const newContent = md.join('');
102
+ this.originalContent = newContent;
103
+ // Shouldn't need to filter here. But, seems to matter. *WHY!? 🤔
104
+ this.view.data.body = formatMessage(newContent);
105
+
106
+ if (chunk.data.type === 'start') {
107
+ this.isDone = false;
108
+ } else if (chunk.data.type === 'end') {
109
+ const fullMessage = await llm.getMessage(null, this.roomId, chunk.mid);
110
+ if (fullMessage) {
111
+ this.body = fullMessage.content;
112
+ this.isDone = true;
113
+ }
114
+ } else if (chunk.data.type === 'status' || chunk.data.type === 'title') {
115
+ // Keep the message in processing state during tool calls
116
+ this.isDone = false;
117
+ }
118
+ }
119
+ }
120
+
121
+ async function Chat() {
122
+ const messageIndex = new Map<number, Message>();
123
+
124
+ const self = html`<div id='chat'>
125
+ ${sheet}
126
+
127
+ <!-- Conversation Management -->
128
+ <div style='margin-bottom: 1em; display: flex; gap: 10px; align-items: center; flex-wrap: wrap;'>
129
+ <label style='font-weight: bold;'>Conversations:</label>
130
+ <select ${id('conversationSelect', HTMLSelectElement)} style='min-width: 200px;'>
131
+ <option value="">New Conversation</option>
132
+ </select>
133
+ <button ${id('newConversationBtn', HTMLButtonElement)} style='padding: 5px 10px;'>New</button>
134
+ <button ${id('deleteConversationBtn', HTMLButtonElement)} style='padding: 5px 10px; background-color: #dc3545; color: white; border: none; border-radius: 3px;' disabled>Delete</button>
135
+ </div>
136
+
137
+ <!-- All messages. Markdown formatted. Sanitized. -->
138
+ <div ${id('messageContainer', HTMLDivElement)} class='messages'>
139
+ ${list('messages', (m: Message) => m.view)}
140
+ ${node('messageStatus', (md) => md ? html`<div style='color: #333;'>
141
+ ${formatMessage(md || '')}
142
+ </div>` : html`<div></div>`)}
143
+ </div>
144
+
145
+ <!-- New message form -->
146
+ <form ${id('messageForm', HTMLFormElement)}
147
+ onsubmit=${async (event: Event) => {
148
+ event.preventDefault();
149
+ if (!self.activeRoom) {
150
+ await self.createConversation();
151
+ }
152
+
153
+ const userMessage = self.data.message.value.trim();
154
+ if (!userMessage) return;
155
+
156
+ // Add user message to UI with original text
157
+ self.data.messages.push(new Message(self.activeRoom!, 'user', userMessage));
158
+ self.data.message.value = '';
159
+ self.data.message.disabled = true;
160
+ self.data.submitButton.disabled = true;
161
+ self.data.message.style.height = 'auto';
162
+ self.data.messageStatus = '📨 Sending ...';
163
+ self.autoscroll();
164
+
165
+ // Send only the latest user message to the server
166
+ llm.send(null, self.activeRoom!, userMessage).catch(error => {
167
+ console.error(error);
168
+ self.data.status = 'Error. Try again.';
169
+ self.data.message.disabled = false;
170
+ self.data.submitButton.disabled = false;
171
+ self.data.messageStatus = '';
172
+ });
173
+ }}
174
+ ><div class='flex-row'>
175
+ <textarea
176
+ ${id('message', HTMLTextAreaElement)}
177
+ autocomplete="on"
178
+ autocorrect="on"
179
+ autocapitalize="on"
180
+ type='text'
181
+ style="
182
+ width: calc(100% - 5rem);
183
+ height: auto;
184
+ tab-size: 4;
185
+ "
186
+ oninput=${() => {
187
+ // reset height to auto to calculate scrollHeight correctly
188
+ self.data.message.style.height = 'auto';
189
+ // only then set it to scrollHeight, which will not be based on the raw
190
+ // height of the content, excluding padding, etc.
191
+ self.data.message.style.height = self.data.message.scrollHeight + 'px';
192
+ }}
193
+ onkeydown=${(event: KeyboardEvent) => {
194
+ if (event.key === 'Enter' && !event.shiftKey) {
195
+ event.preventDefault();
196
+ self.data.messageForm.dispatchEvent(new Event('submit'));
197
+ }
198
+ if (event.key === 'Tab') {
199
+ event.preventDefault();
200
+ const textarea = self.data.message;
201
+ const start = textarea.selectionStart;
202
+ const end = textarea.selectionEnd;
203
+ if (start === end) {
204
+ // If no selection, insert a tab at the cursor position
205
+ textarea.value = textarea.value.substring(0, start)
206
+ + '\t' + textarea.value.substring(end);
207
+ textarea.selectionStart = textarea.selectionEnd = start + 1;
208
+ return;
209
+ }
210
+ let lines = textarea.value.split('\n');
211
+ const selectedLines = lines.slice(
212
+ textarea.value.substring(0, start).split('\n').length - 1,
213
+ textarea.value.substring(0, end).split('\n').length
214
+ );
215
+ let updatedLines = [];
216
+ let selectionStartOffset = 0;
217
+ if (event.shiftKey) {
218
+ updatedLines = selectedLines.map(line => line.startsWith('\t') ? line.substring(1) : line);
219
+ selectionStartOffset = -1;
220
+ } else {
221
+ updatedLines = selectedLines.map(line => '\t' + line);
222
+ selectionStartOffset = 1;
223
+ }
224
+ lines.splice(
225
+ start === end ? start : textarea.value.substring(0, start).split('\n').length - 1,
226
+ selectedLines.length,
227
+ ...updatedLines
228
+ );
229
+ textarea.value = lines.join('\n');
230
+ textarea.selectionStart = start + selectionStartOffset;
231
+ textarea.selectionEnd = end + updatedLines.length;
232
+ }
233
+ }}
234
+ ></textarea>
235
+ <input ${id('submitButton', HTMLInputElement)}
236
+ type='submit' value='&gt;' style='width: 2em; height: 2em;' />
237
+ </div></form>
238
+
239
+ <!-- Connection status -->
240
+ <span style='color: var(--color-muted)'>${text('status', 'Just waiting for you!')}</span>
241
+
242
+ </div>`.extend(() => ({
243
+ activeRoom: undefined as string | undefined,
244
+ conversations: [] as Conversation[],
245
+
246
+ isScrolledDownWithinMargin(margin: number) {
247
+ const container = self.data.messageContainer;
248
+ const scrollTop = container.scrollTop;
249
+ const scrollHeight = container.scrollHeight;
250
+ const clientHeight = container.clientHeight;
251
+ return (scrollHeight - (scrollTop + clientHeight)) <= margin;
252
+ },
253
+ autoscroll() {
254
+ const container = self.data.messageContainer;
255
+ container.scrollTop = container.scrollHeight - container.clientHeight;
256
+ },
257
+ disconnect() {
258
+ // Will be replaced with actual unsubscribe function when connected
259
+ },
260
+
261
+ async loadConversations() {
262
+ try {
263
+ self.conversations = await llm.getConversations(null);
264
+ const select = self.data.conversationSelect;
265
+
266
+ // Clear existing options except the first one
267
+ while (select.options.length > 1) {
268
+ select.remove(1);
269
+ }
270
+
271
+ // Add conversation options
272
+ for (const conv of self.conversations) {
273
+ const option = document.createElement('option');
274
+ option.value = conv.conversationId;
275
+ option.text = conv.name;
276
+ select.add(option);
277
+ }
278
+ } catch (error) {
279
+ console.error('Failed to load conversations:', error);
280
+ }
281
+ },
282
+
283
+ async createConversation() {
284
+ try {
285
+ // New conversation - just create room, don't save to database yet
286
+ self.data.messageStatus = '';
287
+ self.disconnect();
288
+ self.activeRoom = await llm.createRoom(null);
289
+ self.data.messages.splice(0); // Clear messages
290
+ messageIndex.clear();
291
+
292
+ // Set dropdown to show "New Conversation" but don't add permanent entry yet
293
+ self.data.conversationSelect.value = "";
294
+ self.data.deleteConversationBtn.disabled = true; // Can't delete unsaved conversations
295
+ await self.connect();
296
+ return;
297
+ } catch (error) {
298
+ console.error('Failed to create conversation:', error);
299
+ self.data.status = 'Error creating conversation.';
300
+ }
301
+ },
302
+
303
+ async loadConversation(roomId: string) {
304
+ try {
305
+ self.data.messageStatus = '';
306
+
307
+ if (roomId !== self.activeRoom) {
308
+ // reset states to blank.
309
+ self.activeRoom = undefined;
310
+ self.disconnect();
311
+ self.data.messages.splice(0);
312
+ messageIndex.clear();
313
+ }
314
+
315
+ if (roomId) {
316
+ // Load conversation history
317
+ const history = await llm.getHistory(null, roomId);
318
+ console.log('loaded history', history);
319
+ for (const msg of history) {
320
+ const message = new Message(roomId, msg.role, msg.content);
321
+ self.data.messages.push(message);
322
+ }
323
+
324
+ // Set active room and connect
325
+ self.activeRoom = roomId;
326
+ await self.connect();
327
+ }
328
+
329
+ self.data.conversationSelect.value = roomId;
330
+ self.data.deleteConversationBtn.disabled = false;
331
+ self.autoscroll();
332
+ } catch (error) {
333
+ console.error('Failed to load conversation:', error);
334
+ self.data.status = 'Error loading conversation.';
335
+ }
336
+ },
337
+
338
+ async deleteCurrentConversation() {
339
+ if (!self.activeRoom) return;
340
+
341
+ try {
342
+ await llm.deleteConversation(null, self.activeRoom);
343
+ self.data.messageStatus = '';
344
+
345
+ // Clear UI and start new conversation
346
+ self.data.messages.splice(0);
347
+ messageIndex.clear();
348
+ self.data.conversationSelect.value = "";
349
+ self.data.deleteConversationBtn.disabled = true;
350
+ self.activeRoom = undefined;
351
+
352
+ // Create new room
353
+ self.disconnect();
354
+
355
+ // Reload conversation list
356
+ await self.loadConversations();
357
+ } catch (error) {
358
+ console.error('Failed to delete conversation:', error);
359
+ self.data.status = 'Error deleting conversation.';
360
+ }
361
+ },
362
+
363
+ updateConversationTitle(newTitle: string) {
364
+ // Update the dropdown option for the current conversation
365
+ const select = self.data.conversationSelect;
366
+ let currentOption = Array.from(select.options).find(opt => opt.value === self.activeRoom);
367
+
368
+ // If no option exists yet (new conversation getting its first title), create it
369
+ if (!currentOption && self.activeRoom) {
370
+ currentOption = document.createElement('option');
371
+ currentOption.value = self.activeRoom;
372
+ currentOption.text = newTitle;
373
+ select.add(currentOption, 1); // Add after "New Conversation" option
374
+ select.value = self.activeRoom;
375
+
376
+ // Enable delete button now that conversation is saved
377
+ self.data.deleteConversationBtn.disabled = false;
378
+ } else if (currentOption) {
379
+ // Update existing option
380
+ currentOption.text = newTitle;
381
+ }
382
+ },
383
+ async connect() {
384
+ if (!self.activeRoom) {
385
+ console.error('No active room to connect to');
386
+ return;
387
+ }
388
+
389
+ // Disconnect any existing connection first
390
+ self.disconnect();
391
+
392
+ const roomStream = await llm.getRoom(null, self.activeRoom);
393
+ self.disconnect = roomStream.subscribe({
394
+ onopen() {
395
+ self.data.status = `Connected.`;
396
+ },
397
+ async onmessage(chunk) {
398
+ const startedAtBottom = self.isScrolledDownWithinMargin(50);
399
+
400
+ // Handle special title update messages
401
+ if (chunk.data.type === 'title') {
402
+ const newTitle = chunk.data.value;
403
+ self.updateConversationTitle(newTitle);
404
+ return;
405
+ }
406
+
407
+ if (chunk.data.type === 'start') {
408
+ self.data.messageStatus = '👀 Reading ...';
409
+ return;
410
+ }
411
+
412
+ if (chunk.data.type === 'status') {
413
+ self.data.messageStatus = `${chunk.data.status}`;
414
+ return;
415
+ }
416
+
417
+ let message: Message;
418
+ if (messageIndex.has(chunk.mid)) {
419
+ message = messageIndex.get(chunk.mid)!;
420
+ } else {
421
+ message = new Message(
422
+ self.activeRoom!,
423
+ chunk.data.type === 'text' ? chunk.data.role : 'assistant'
424
+ );
425
+ self.data.messages.push(message);
426
+ messageIndex.set(chunk.mid, message);
427
+ }
428
+
429
+ await message.appendChunk(chunk);
430
+
431
+ if (!message.isDone) {
432
+ self.data.message.disabled = true;
433
+ self.data.submitButton.disabled = true;
434
+ } else {
435
+ self.data.messageStatus = '<span class="muted">✔️ Done responding.</span>';
436
+ self.data.submitButton.disabled = false;
437
+ self.data.message.disabled = false;
438
+ self.data.message.focus();
439
+ }
440
+
441
+ if (startedAtBottom) self.autoscroll();
442
+ },
443
+ onclose(reason) {
444
+ if (reason !== 'unsubscribed') {
445
+ self.data.status = 'Disconnected. (Refresh to try reconnecting.)';
446
+ }
447
+ }
448
+ });
449
+ }
450
+ }))
451
+ .onadd(async () => {
452
+ // Load conversations first
453
+ await self.loadConversations();
454
+
455
+ // Set up event handlers
456
+ self.data.conversationSelect.addEventListener('change', (e) => {
457
+ const target = e.target as HTMLSelectElement;
458
+ self.loadConversation(target.value);
459
+ });
460
+
461
+ self.data.newConversationBtn.addEventListener('click', () => {
462
+ self.loadConversation(""); // Empty string = new conversation
463
+ });
464
+
465
+ self.data.deleteConversationBtn.addEventListener('click', () => {
466
+ if (confirm('Are you sure you want to delete this conversation? This cannot be undone.')) {
467
+ self.deleteCurrentConversation();
468
+ }
469
+ });
470
+
471
+ // Start with new conversation
472
+ self.data.message.value = '';
473
+ });
474
+ return self;
475
+ }
476
+
477
+ async function App() {
478
+ return html`<div id='app'>
479
+ ${await AuthenticatedContent({
480
+ authenticated: Chat,
481
+ unauthenticated: () => html`<p>Sign in for the LLM demo.</p>`
482
+ })}
483
+ </div>`;
484
+ }
485
+
486
+ export async function generate() {
487
+ return Main({
488
+ pageTitle: 'LLM Demo',
489
+ content: await App()
490
+ })
491
+ }
492
+
493
+ hydrate('app', App as any);
@@ -104,6 +104,7 @@ async function Chat() {
104
104
 
105
105
  async function App() {
106
106
  return html`<div id='app'>
107
+ <h4>Realtime Demo</h4>
107
108
  ${await AuthenticatedContent({
108
109
  authenticated: Chat,
109
110
  unauthenticated: () => html`<p>Sign in for the realtime demo.</p>`
@@ -113,7 +114,7 @@ async function App() {
113
114
 
114
115
  export async function generate() {
115
116
  return Main({
116
- pageTitle: 'Realtime Demo',
117
+ pageTitle: 'Welcome!',
117
118
  content: await App()
118
119
  })
119
120
  }
@@ -4,6 +4,7 @@ import { worker } from 'web-worker';
4
4
 
5
5
  async function App() {
6
6
  return html`<div id='app'>
7
+ <h4>Web Worker Demo</h4>
7
8
  <div>${text('status', '...')}</div>
8
9
  <div>Web Worker output: ${text('output', '...')}</div>
9
10
  </div>`.onadd(async self => {
@@ -16,7 +17,7 @@ async function App() {
16
17
 
17
18
  export async function generate() {
18
19
  return Main({
19
- pageTitle: 'Web Worker Demo',
20
+ pageTitle: 'Welcome!',
20
21
  content: await App()
21
22
  })
22
23
  }
@@ -10,5 +10,8 @@
10
10
  "prebuild": "wirejs-web-worker-build",
11
11
  "prestart": "npm run prebuild",
12
12
  "start": "wirejs-scripts watch src npm run prebuild"
13
+ },
14
+ "dependencies": {
15
+ "wirejs-web-worker": "*"
13
16
  }
14
17
  }
@@ -1,57 +0,0 @@
1
- import {
2
- AuthenticationApi,
3
- BackgroundJob,
4
- RealtimeService,
5
- User,
6
- LLM as LLMResource,
7
- LLMMessage,
8
- withContext
9
- } from "wirejs-resources";
10
-
11
- export type Message = '**start**' | '**end**' | LLMMessage;
12
-
13
- const llm = new LLMResource('app', 'llm', {
14
- models: ['claude-haiku', 'llama3.2', 'llama3:8b', 'llama2']
15
- });
16
- const llmRealtimeService = new RealtimeService<Message>('app', 'llm');
17
-
18
- const chatRunner = new BackgroundJob('app', 'chatRunner', {
19
- handler: async (room: string, history: LLMMessage[]) => {
20
- await llmRealtimeService.publish(room, [`**start**`]);
21
- await llm.continueConversation([
22
- { role: 'system', content: 'You are a helpful (but generally concise) assistant.'},
23
- ...history
24
- ], chunk => {
25
- llmRealtimeService.publish(room, [chunk.message]);
26
- })
27
- await llmRealtimeService.publish(room, [`**end**`]);
28
- }
29
- });
30
-
31
-
32
- const assertIsAuthorized = (user: User, room: string) => {
33
- if (!room.startsWith(`${user.id}/`)) {
34
- throw new Error("Not authorized");
35
- }
36
- }
37
-
38
- export const LLM = (auth: AuthenticationApi) => withContext(context => ({
39
- async send(room: string, history: LLMMessage[]) {
40
- const user = await auth.requireCurrentUser(context);
41
- assertIsAuthorized(user, room);
42
- if (!room || !history || !history.length) {
43
- throw new Error('Room and history are required');
44
- }
45
- chatRunner.start(room, history);
46
- },
47
- async getRoom(room: string) {
48
- const user = await auth.requireCurrentUser(context);
49
- assertIsAuthorized(user, room);
50
- return llmRealtimeService.getStream(context, room);
51
- },
52
- async createRoom() {
53
- const user = await auth.requireCurrentUser(context);
54
- const id = crypto.randomUUID();
55
- return `${user.id}/${id}`;
56
- }
57
- }));