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.
- package/package.json +1 -1
- package/templates/default/api/apps/admin.ts +1 -1
- package/templates/default/api/apps/llm/index.ts +63 -0
- package/templates/default/api/apps/llm/infra.ts +322 -0
- package/templates/default/api/apps/llm/prompts.ts +21 -0
- package/templates/default/api/apps/llm/tooled-handler.ts +116 -0
- package/templates/default/api/apps/llm/tools.ts +150 -0
- package/templates/default/api/apps/llm/types.ts +42 -0
- package/templates/default/api/apps/llm/utils.ts +164 -0
- package/templates/default/api/index.ts +8 -6
- package/templates/default/api/package.json +8 -0
- package/templates/default/deployment-config.ts +6 -0
- package/templates/default/package.json +5 -5
- package/templates/default/src/ssg/index.ts +2 -2
- package/templates/default/src/ssg/llm-test.ts +493 -0
- package/templates/default/src/ssg/realtime-test.ts +2 -1
- package/templates/default/src/ssg/web-worker-test.ts +2 -1
- package/templates/default/web-worker/package.json +3 -0
- package/templates/default/api/apps/llm.ts +0 -57
- package/templates/default/src/ssg/chatbot.ts +0 -218
|
@@ -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='>' 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: '
|
|
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: '
|
|
20
|
+
pageTitle: 'Welcome!',
|
|
20
21
|
content: await App()
|
|
21
22
|
})
|
|
22
23
|
}
|
|
@@ -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
|
-
}));
|