@pikku/assistant-ui 0.12.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.
@@ -0,0 +1,455 @@
1
+ import { useState, type FunctionComponent } from 'react'
2
+ import {
3
+ AssistantRuntimeProvider,
4
+ ThreadPrimitive,
5
+ MessagePrimitive,
6
+ ComposerPrimitive,
7
+ } from '@assistant-ui/react'
8
+ import {
9
+ usePikkuAgentRuntime,
10
+ type PikkuAgentRuntimeOptions,
11
+ } from './use-pikku-agent-runtime.js'
12
+
13
+ export interface PikkuAgentChatProps extends PikkuAgentRuntimeOptions {
14
+ emptyMessage?: string
15
+ }
16
+
17
+ const ToolCallDisplay: FunctionComponent<{
18
+ toolName: string
19
+ args: Record<string, unknown>
20
+ result?: unknown
21
+ status: { type: string }
22
+ addResult?: (result: unknown) => void
23
+ }> = ({ toolName, args, result, status, addResult }) => {
24
+ const [expanded, setExpanded] = useState(false)
25
+ const isApproval = status.type === 'requires-action'
26
+ const approvalReason = (args as any)?.__approvalReason
27
+ const displayArgs = { ...args }
28
+ delete (displayArgs as any).__approvalReason
29
+ const [responded, setResponded] = useState<'approved' | 'denied' | null>(
30
+ null
31
+ )
32
+
33
+ if (isApproval && !responded) {
34
+ return (
35
+ <div
36
+ style={{
37
+ border: '1px solid #e9a211',
38
+ borderRadius: 6,
39
+ padding: 12,
40
+ margin: '4px 0',
41
+ backgroundColor: '#fef9e7',
42
+ }}
43
+ >
44
+ <div
45
+ style={{
46
+ display: 'flex',
47
+ alignItems: 'center',
48
+ gap: 6,
49
+ marginBottom: 8,
50
+ fontWeight: 600,
51
+ fontSize: 13,
52
+ }}
53
+ >
54
+ Approval required
55
+ </div>
56
+ {approvalReason && (
57
+ <div style={{ fontSize: 13, marginBottom: 4 }}>{approvalReason}</div>
58
+ )}
59
+ <div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
60
+ The agent wants to call <code>{toolName}</code>
61
+ </div>
62
+ <pre
63
+ style={{
64
+ fontSize: 11,
65
+ background: '#f5f5f5',
66
+ padding: 8,
67
+ borderRadius: 4,
68
+ overflow: 'auto',
69
+ marginBottom: 8,
70
+ }}
71
+ >
72
+ {JSON.stringify(displayArgs, null, 2)}
73
+ </pre>
74
+ <div style={{ display: 'flex', gap: 8 }}>
75
+ <button
76
+ onClick={() => {
77
+ setResponded('approved')
78
+ addResult?.({ approved: true })
79
+ }}
80
+ style={{
81
+ padding: '4px 12px',
82
+ fontSize: 12,
83
+ border: '1px solid #2e7d32',
84
+ borderRadius: 4,
85
+ background: '#e8f5e9',
86
+ color: '#2e7d32',
87
+ cursor: 'pointer',
88
+ }}
89
+ >
90
+ Approve
91
+ </button>
92
+ <button
93
+ onClick={() => {
94
+ setResponded('denied')
95
+ addResult?.({ approved: false })
96
+ }}
97
+ style={{
98
+ padding: '4px 12px',
99
+ fontSize: 12,
100
+ border: '1px solid #c62828',
101
+ borderRadius: 4,
102
+ background: '#ffebee',
103
+ color: '#c62828',
104
+ cursor: 'pointer',
105
+ }}
106
+ >
107
+ Deny
108
+ </button>
109
+ </div>
110
+ </div>
111
+ )
112
+ }
113
+
114
+ if (isApproval && responded) {
115
+ return (
116
+ <div
117
+ style={{
118
+ border: '1px solid #ddd',
119
+ borderRadius: 6,
120
+ padding: 8,
121
+ margin: '4px 0',
122
+ display: 'flex',
123
+ alignItems: 'center',
124
+ gap: 8,
125
+ fontSize: 13,
126
+ }}
127
+ >
128
+ <span style={{ fontWeight: 500 }}>{toolName}</span>
129
+ <span
130
+ style={{
131
+ fontSize: 11,
132
+ padding: '2px 6px',
133
+ borderRadius: 3,
134
+ background: responded === 'approved' ? '#e8f5e9' : '#ffebee',
135
+ color: responded === 'approved' ? '#2e7d32' : '#c62828',
136
+ }}
137
+ >
138
+ {responded}
139
+ </span>
140
+ </div>
141
+ )
142
+ }
143
+
144
+ return (
145
+ <div
146
+ style={{
147
+ border: '1px solid #ddd',
148
+ borderRadius: 6,
149
+ padding: 8,
150
+ margin: '4px 0',
151
+ }}
152
+ >
153
+ <button
154
+ onClick={() => setExpanded((e) => !e)}
155
+ style={{
156
+ background: 'none',
157
+ border: 'none',
158
+ cursor: 'pointer',
159
+ display: 'flex',
160
+ alignItems: 'center',
161
+ gap: 6,
162
+ width: '100%',
163
+ padding: 0,
164
+ fontSize: 13,
165
+ }}
166
+ >
167
+ <span>{expanded ? '\u25BC' : '\u25B6'}</span>
168
+ <span style={{ fontFamily: 'monospace', fontWeight: 500 }}>
169
+ {toolName}
170
+ </span>
171
+ {status.type === 'running' && (
172
+ <span style={{ fontSize: 11, color: '#888' }}>running...</span>
173
+ )}
174
+ {status.type === 'complete' && (
175
+ <span
176
+ style={{
177
+ fontSize: 11,
178
+ padding: '1px 5px',
179
+ borderRadius: 3,
180
+ background: '#e8f5e9',
181
+ color: '#2e7d32',
182
+ }}
183
+ >
184
+ done
185
+ </span>
186
+ )}
187
+ </button>
188
+ {expanded && (
189
+ <div style={{ marginTop: 8 }}>
190
+ <div style={{ fontSize: 12, color: '#888', marginBottom: 2 }}>
191
+ Arguments:
192
+ </div>
193
+ <pre
194
+ style={{
195
+ fontSize: 11,
196
+ background: '#f5f5f5',
197
+ padding: 8,
198
+ borderRadius: 4,
199
+ overflow: 'auto',
200
+ }}
201
+ >
202
+ {JSON.stringify(displayArgs, null, 2)}
203
+ </pre>
204
+ {result !== undefined && (
205
+ <>
206
+ <div
207
+ style={{ fontSize: 12, color: '#888', marginTop: 8, marginBottom: 2 }}
208
+ >
209
+ Result:
210
+ </div>
211
+ <pre
212
+ style={{
213
+ fontSize: 11,
214
+ background: '#f5f5f5',
215
+ padding: 8,
216
+ borderRadius: 4,
217
+ overflow: 'auto',
218
+ }}
219
+ >
220
+ {typeof result === 'string'
221
+ ? result
222
+ : JSON.stringify(result, null, 2)}
223
+ </pre>
224
+ </>
225
+ )}
226
+ </div>
227
+ )}
228
+ </div>
229
+ )
230
+ }
231
+
232
+ const UserMessage: FunctionComponent = () => (
233
+ <div
234
+ style={{
235
+ display: 'flex',
236
+ justifyContent: 'flex-end',
237
+ width: '100%',
238
+ }}
239
+ >
240
+ <div style={{ maxWidth: '80%' }}>
241
+ <div
242
+ style={{
243
+ fontSize: 12,
244
+ color: '#888',
245
+ marginBottom: 4,
246
+ textAlign: 'right',
247
+ }}
248
+ >
249
+ You
250
+ </div>
251
+ <div
252
+ style={{
253
+ padding: 12,
254
+ borderRadius: 12,
255
+ backgroundColor: '#e3f2fd',
256
+ }}
257
+ >
258
+ <MessagePrimitive.Content
259
+ components={{
260
+ Text: ({ text }) => (
261
+ <span style={{ fontSize: 14, whiteSpace: 'pre-wrap' }}>
262
+ {text}
263
+ </span>
264
+ ),
265
+ }}
266
+ />
267
+ </div>
268
+ </div>
269
+ </div>
270
+ )
271
+
272
+ const AssistantMessage: FunctionComponent = () => (
273
+ <div
274
+ style={{
275
+ display: 'flex',
276
+ justifyContent: 'flex-start',
277
+ width: '100%',
278
+ }}
279
+ >
280
+ <div style={{ maxWidth: '80%' }}>
281
+ <div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
282
+ Assistant
283
+ </div>
284
+ <div
285
+ style={{
286
+ padding: 12,
287
+ borderRadius: 12,
288
+ backgroundColor: '#f5f5f5',
289
+ }}
290
+ >
291
+ <MessagePrimitive.Content
292
+ components={{
293
+ Text: ({ text }) => (
294
+ <span style={{ fontSize: 14, whiteSpace: 'pre-wrap' }}>
295
+ {text}
296
+ </span>
297
+ ),
298
+ tools: {
299
+ Fallback: (props) => (
300
+ <ToolCallDisplay
301
+ toolName={props.toolName}
302
+ args={props.args as Record<string, unknown>}
303
+ result={props.result}
304
+ status={props.status}
305
+ addResult={props.addResult}
306
+ />
307
+ ),
308
+ },
309
+ }}
310
+ />
311
+ <MessagePrimitive.If last>
312
+ <ThreadPrimitive.If running>
313
+ <div
314
+ style={{
315
+ display: 'flex',
316
+ alignItems: 'center',
317
+ gap: 6,
318
+ marginTop: 8,
319
+ fontSize: 13,
320
+ color: '#888',
321
+ }}
322
+ >
323
+ Thinking...
324
+ </div>
325
+ </ThreadPrimitive.If>
326
+ </MessagePrimitive.If>
327
+ </div>
328
+ </div>
329
+ </div>
330
+ )
331
+
332
+ const PikkuComposer: FunctionComponent = () => (
333
+ <div style={{ padding: '8px 0 16px' }}>
334
+ <ComposerPrimitive.Root>
335
+ <div
336
+ style={{
337
+ border: '1px solid #ddd',
338
+ borderRadius: 12,
339
+ overflow: 'hidden',
340
+ display: 'flex',
341
+ alignItems: 'flex-end',
342
+ padding: '6px 12px',
343
+ gap: 8,
344
+ }}
345
+ >
346
+ <ComposerPrimitive.Input
347
+ placeholder="Message..."
348
+ rows={2}
349
+ style={{
350
+ flex: 1,
351
+ border: 'none',
352
+ outline: 'none',
353
+ resize: 'none',
354
+ fontSize: 14,
355
+ fontFamily: 'inherit',
356
+ padding: '4px 0',
357
+ background: 'transparent',
358
+ }}
359
+ />
360
+ <ComposerPrimitive.Send
361
+ style={{
362
+ width: 28,
363
+ height: 28,
364
+ borderRadius: '50%',
365
+ border: 'none',
366
+ background: '#1976d2',
367
+ color: '#fff',
368
+ cursor: 'pointer',
369
+ display: 'flex',
370
+ alignItems: 'center',
371
+ justifyContent: 'center',
372
+ flexShrink: 0,
373
+ marginBottom: 2,
374
+ fontSize: 14,
375
+ }}
376
+ >
377
+ &#9654;
378
+ </ComposerPrimitive.Send>
379
+ </div>
380
+ </ComposerPrimitive.Root>
381
+ </div>
382
+ )
383
+
384
+ export function PikkuAgentChat(props: PikkuAgentChatProps) {
385
+ const { emptyMessage, ...runtimeOptions } = props
386
+ const runtime = usePikkuAgentRuntime(runtimeOptions)
387
+
388
+ return (
389
+ <AssistantRuntimeProvider runtime={runtime}>
390
+ <div
391
+ style={{
392
+ height: '100%',
393
+ display: 'flex',
394
+ flexDirection: 'column',
395
+ }}
396
+ >
397
+ <ThreadPrimitive.Root
398
+ style={{
399
+ display: 'flex',
400
+ flexDirection: 'column',
401
+ flex: 1,
402
+ minHeight: 0,
403
+ }}
404
+ >
405
+ <ThreadPrimitive.Viewport
406
+ style={{
407
+ flex: 1,
408
+ minHeight: 0,
409
+ overflowY: 'auto',
410
+ }}
411
+ >
412
+ <div
413
+ style={{
414
+ maxWidth: 768,
415
+ margin: '0 auto',
416
+ padding: 16,
417
+ display: 'flex',
418
+ flexDirection: 'column',
419
+ gap: 16,
420
+ }}
421
+ >
422
+ <ThreadPrimitive.Empty>
423
+ <div
424
+ style={{
425
+ display: 'flex',
426
+ alignItems: 'center',
427
+ justifyContent: 'center',
428
+ minHeight: 300,
429
+ color: '#888',
430
+ textAlign: 'center',
431
+ fontSize: 14,
432
+ }}
433
+ >
434
+ {emptyMessage ??
435
+ (props.threadId
436
+ ? 'Send a message to start the conversation.'
437
+ : 'Start a new conversation.')}
438
+ </div>
439
+ </ThreadPrimitive.Empty>
440
+ <ThreadPrimitive.Messages
441
+ components={{
442
+ UserMessage,
443
+ AssistantMessage,
444
+ }}
445
+ />
446
+ </div>
447
+ </ThreadPrimitive.Viewport>
448
+ <div style={{ maxWidth: 768, margin: '0 auto', width: '100%', padding: '0 16px' }}>
449
+ <PikkuComposer />
450
+ </div>
451
+ </ThreadPrimitive.Root>
452
+ </div>
453
+ </AssistantRuntimeProvider>
454
+ )
455
+ }
@@ -0,0 +1,210 @@
1
+ import { useMemo, useEffect, useRef, useCallback } from 'react'
2
+ import { type ThreadMessageLike } from '@assistant-ui/react'
3
+ import { useDataStreamRuntime } from '@assistant-ui/react-data-stream'
4
+
5
+ export interface PikkuAgentRuntimeOptions {
6
+ api: string
7
+ threadId?: string | null
8
+ initialMessages?: any[]
9
+ onThreadCreated?: (id: string) => void
10
+ onFinish?: () => void
11
+ onApprovalRequest?: (data: { toolCallId: string }) => void
12
+ credentials?: RequestCredentials
13
+ headers?: Record<string, string>
14
+ }
15
+
16
+ const convertDbMessages = (dbMessages: any[]): ThreadMessageLike[] => {
17
+ const result: ThreadMessageLike[] = []
18
+ let currentAssistant: ThreadMessageLike | null = null
19
+
20
+ for (const msg of dbMessages) {
21
+ if (msg.role === 'user') {
22
+ if (currentAssistant) {
23
+ result.push(currentAssistant)
24
+ currentAssistant = null
25
+ }
26
+ result.push({
27
+ role: 'user',
28
+ content: msg.content || '',
29
+ id: msg.id,
30
+ createdAt: new Date(msg.createdAt),
31
+ })
32
+ continue
33
+ }
34
+
35
+ if (
36
+ msg.role === 'tool' &&
37
+ currentAssistant &&
38
+ Array.isArray(msg.toolResults)
39
+ ) {
40
+ const parts: any[] = Array.isArray(currentAssistant.content)
41
+ ? [...(currentAssistant.content as any[])]
42
+ : currentAssistant.content
43
+ ? [
44
+ {
45
+ type: 'text' as const,
46
+ text: currentAssistant.content as string,
47
+ },
48
+ ]
49
+ : []
50
+
51
+ for (const tr of msg.toolResults) {
52
+ const tcIdx = parts.findIndex(
53
+ (p: any) => p.type === 'tool-call' && p.toolCallId === tr.id
54
+ )
55
+ if (tcIdx !== -1) {
56
+ parts[tcIdx] = {
57
+ ...parts[tcIdx],
58
+ result:
59
+ typeof tr.result === 'string'
60
+ ? tr.result
61
+ : JSON.stringify(tr.result),
62
+ }
63
+ }
64
+ }
65
+
66
+ currentAssistant = {
67
+ role: currentAssistant.role,
68
+ id: currentAssistant.id,
69
+ createdAt: currentAssistant.createdAt,
70
+ status: currentAssistant.status,
71
+ content: parts,
72
+ }
73
+ continue
74
+ }
75
+
76
+ if (msg.role === 'tool') continue
77
+
78
+ const parts: any[] = []
79
+
80
+ if (msg.content) {
81
+ parts.push({ type: 'text' as const, text: msg.content })
82
+ }
83
+
84
+ if (Array.isArray(msg.toolCalls)) {
85
+ for (const tc of msg.toolCalls) {
86
+ parts.push({
87
+ type: 'tool-call' as const,
88
+ toolCallId: tc.id,
89
+ toolName: tc.name,
90
+ args: tc.args || {},
91
+ })
92
+ }
93
+ }
94
+
95
+ if (currentAssistant) {
96
+ const prev = currentAssistant.content
97
+ const existingParts: any[] = Array.isArray(prev)
98
+ ? [...prev]
99
+ : prev
100
+ ? [{ type: 'text' as const, text: prev as string }]
101
+ : []
102
+ currentAssistant = {
103
+ role: currentAssistant.role,
104
+ id: currentAssistant.id,
105
+ createdAt: currentAssistant.createdAt,
106
+ status: currentAssistant.status,
107
+ content: [...existingParts, ...parts],
108
+ }
109
+ } else {
110
+ currentAssistant = {
111
+ role: 'assistant' as const,
112
+ content: parts.length > 0 ? parts : '',
113
+ id: msg.id,
114
+ createdAt: new Date(msg.createdAt),
115
+ status: { type: 'complete' as const, reason: 'stop' as const },
116
+ }
117
+ }
118
+ }
119
+
120
+ if (currentAssistant) {
121
+ result.push(currentAssistant)
122
+ }
123
+
124
+ return result
125
+ }
126
+
127
+ export function usePikkuAgentRuntime(options: PikkuAgentRuntimeOptions) {
128
+ const {
129
+ api,
130
+ threadId = null,
131
+ initialMessages: rawInitialMessages,
132
+ onThreadCreated,
133
+ onFinish,
134
+ onApprovalRequest,
135
+ credentials,
136
+ headers,
137
+ } = options
138
+
139
+ const threadIdRef = useRef(threadId)
140
+ threadIdRef.current = threadId
141
+
142
+ const justCreatedThreadRef = useRef(false)
143
+
144
+ const bodyFn = useCallback(() => {
145
+ let currentThreadId = threadIdRef.current
146
+ if (!currentThreadId) {
147
+ currentThreadId = crypto.randomUUID()
148
+ justCreatedThreadRef.current = true
149
+ onThreadCreated?.(currentThreadId)
150
+ }
151
+ return { threadId: currentThreadId }
152
+ }, [onThreadCreated])
153
+
154
+ const onData = useCallback(
155
+ (event: { type: string; name: string; data: unknown }) => {
156
+ if (event.name === 'approval-request') {
157
+ const approval = event.data as any
158
+ onApprovalRequest?.({
159
+ toolCallId: approval.toolCallId,
160
+ })
161
+ }
162
+ },
163
+ [onApprovalRequest]
164
+ )
165
+
166
+ const onFinishCb = useCallback(() => {
167
+ onFinish?.()
168
+ }, [onFinish])
169
+
170
+ const initialMessages = useMemo(
171
+ () => (rawInitialMessages ? convertDbMessages(rawInitialMessages) : []),
172
+ [rawInitialMessages]
173
+ )
174
+
175
+ const runtime = useDataStreamRuntime({
176
+ api,
177
+ protocol: 'ui-message-stream',
178
+ body: bodyFn,
179
+ onData,
180
+ onFinish: onFinishCb,
181
+ initialMessages,
182
+ credentials,
183
+ headers,
184
+ })
185
+
186
+ const prevThreadIdRef = useRef(threadId)
187
+ const hasResetRef = useRef(false)
188
+ useEffect(() => {
189
+ if (prevThreadIdRef.current !== threadId) {
190
+ prevThreadIdRef.current = threadId
191
+ if (justCreatedThreadRef.current) {
192
+ justCreatedThreadRef.current = false
193
+ hasResetRef.current = true
194
+ return
195
+ }
196
+ hasResetRef.current = false
197
+ if (rawInitialMessages) {
198
+ ;(runtime as any).thread.reset(convertDbMessages(rawInitialMessages))
199
+ hasResetRef.current = true
200
+ } else {
201
+ ;(runtime as any).thread.reset([])
202
+ }
203
+ } else if (!hasResetRef.current && rawInitialMessages) {
204
+ ;(runtime as any).thread.reset(convertDbMessages(rawInitialMessages))
205
+ hasResetRef.current = true
206
+ }
207
+ }, [threadId, rawInitialMessages, runtime])
208
+
209
+ return runtime
210
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "module": "commonjs",
6
+ "outDir": "dist/cjs",
7
+ "target": "es2015",
8
+ "moduleResolution": "node"
9
+ },
10
+ "exclude": ["**/*.test.ts", "node_modules", "dist"]
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "module": "Node18",
6
+ "outDir": "dist/esm",
7
+ "target": "esnext",
8
+ "declaration": true,
9
+ "resolveJsonModule": true,
10
+ "jsx": "react-jsx",
11
+ "lib": ["DOM", "ES2020"]
12
+ },
13
+ "include": ["src/*.ts", "src/*.tsx"],
14
+ "exclude": ["**/*.test.ts", "node_modules"]
15
+ }