@pikku/assistant-ui 0.12.4 → 0.12.6

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,110 @@
1
+ import { useState, useCallback, useRef } from 'react'
2
+
3
+ export type PendingFile = {
4
+ id: string
5
+ name: string
6
+ mimeType: string
7
+ previewUrl: string
8
+ contentUrl: string
9
+ isImage: boolean
10
+ }
11
+
12
+ export type UploadAttachmentFn = (args: {
13
+ contentType: string
14
+ sizeBytes: number
15
+ }) => Promise<{
16
+ uploadUrl: string
17
+ signedReadUrl: string
18
+ uploadMethod?: string
19
+ }>
20
+
21
+ export const INLINE_SIZE_LIMIT = 1 * 1024 * 1024
22
+
23
+ export function useFileAttachment(upload: UploadAttachmentFn) {
24
+ const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([])
25
+ const [uploading, setUploading] = useState(false)
26
+ const [uploadError, setUploadError] = useState<string | null>(null)
27
+ const fileInputRef = useRef<HTMLInputElement>(null)
28
+
29
+ const handleFileChange = useCallback(
30
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
31
+ const files = Array.from(e.target.files ?? [])
32
+ e.target.value = ''
33
+ if (!files.length) return
34
+ setUploading(true)
35
+ setUploadError(null)
36
+ try {
37
+ for (const file of files) {
38
+ const previewUrl = URL.createObjectURL(file)
39
+ let contentUrl: string
40
+
41
+ if (
42
+ file.type.startsWith('image/') &&
43
+ file.size <= INLINE_SIZE_LIMIT
44
+ ) {
45
+ contentUrl = await new Promise<string>((resolve, reject) => {
46
+ const reader = new FileReader()
47
+ reader.onload = (ev) => resolve(ev.target!.result as string)
48
+ reader.onerror = reject
49
+ reader.readAsDataURL(file)
50
+ })
51
+ } else {
52
+ const { uploadUrl, signedReadUrl, uploadMethod } = await upload({
53
+ contentType: file.type,
54
+ sizeBytes: file.size,
55
+ })
56
+ const resp = await fetch(uploadUrl, {
57
+ method: uploadMethod ?? 'PUT',
58
+ body: file,
59
+ headers: { 'Content-Type': file.type },
60
+ })
61
+ if (!resp.ok) throw new Error(`Upload failed (${resp.status})`)
62
+ contentUrl = signedReadUrl
63
+ }
64
+
65
+ setPendingFiles((prev) => [
66
+ ...prev,
67
+ {
68
+ id: `file_${Date.now().toString(36)}`,
69
+ name: file.name,
70
+ mimeType: file.type,
71
+ previewUrl,
72
+ contentUrl,
73
+ isImage: file.type.startsWith('image/'),
74
+ },
75
+ ])
76
+ }
77
+ } catch (err) {
78
+ setUploadError(err instanceof Error ? err.message : 'Upload failed')
79
+ } finally {
80
+ setUploading(false)
81
+ }
82
+ },
83
+ [upload]
84
+ )
85
+
86
+ const removeFile = useCallback((id: string) => {
87
+ setPendingFiles((prev) => {
88
+ const f = prev.find((x) => x.id === id)
89
+ if (f) URL.revokeObjectURL(f.previewUrl)
90
+ return prev.filter((x) => x.id !== id)
91
+ })
92
+ }, [])
93
+
94
+ const clearFiles = useCallback(() => {
95
+ setPendingFiles((prev) => {
96
+ prev.forEach((f) => URL.revokeObjectURL(f.previewUrl))
97
+ return []
98
+ })
99
+ }, [])
100
+
101
+ return {
102
+ pendingFiles,
103
+ uploading,
104
+ uploadError,
105
+ fileInputRef,
106
+ handleFileChange,
107
+ removeFile,
108
+ clearFiles,
109
+ }
110
+ }
@@ -23,6 +23,10 @@ export interface PikkuAgentRuntimeOptions {
23
23
  headers?: Record<string, string>
24
24
  model?: string
25
25
  temperature?: number
26
+ /** Structured context injected into the agent's system instructions.
27
+ * Provide upfront state (e.g. current org/project/branch/deployment IDs)
28
+ * so the agent can call tools without asking the user. */
29
+ context?: string
26
30
  }
27
31
 
28
32
  export interface PendingApproval {
@@ -87,6 +91,17 @@ type ToolCall = {
87
91
  isError?: boolean
88
92
  }
89
93
 
94
+ type StructuredPart =
95
+ | {
96
+ type: 'generative-ui'
97
+ spec: unknown
98
+ }
99
+ | {
100
+ type: 'data'
101
+ name: string
102
+ data: unknown
103
+ }
104
+
90
105
  /**
91
106
  * Shared helper: consume an SSE stream and populate text/toolCalls.
92
107
  * Returns an array of PendingApprovals when the stream requests them, or empty when done.
@@ -95,6 +110,7 @@ async function processStream(
95
110
  reader: ReadableStreamDefaultReader<Uint8Array>,
96
111
  text: { value: string },
97
112
  toolCalls: ToolCall[],
113
+ structuredParts: StructuredPart[],
98
114
  yieldContent: () => void,
99
115
  onFinish?: () => void
100
116
  ): Promise<PendingApproval[]> {
@@ -140,6 +156,31 @@ async function processStream(
140
156
  }
141
157
  break
142
158
  }
159
+ case 'generative-ui': {
160
+ const nextPart = {
161
+ type: 'generative-ui' as const,
162
+ spec: event.spec,
163
+ }
164
+ const existingIndex = structuredParts.findIndex(
165
+ (part) => part.type === 'generative-ui'
166
+ )
167
+ if (existingIndex === -1) structuredParts.push(nextPart)
168
+ else structuredParts[existingIndex] = nextPart
169
+ break
170
+ }
171
+ case 'data': {
172
+ const nextPart = {
173
+ type: 'data' as const,
174
+ name: event.name,
175
+ data: event.data,
176
+ }
177
+ const existingIndex = structuredParts.findIndex(
178
+ (part) => part.type === 'data' && part.name === event.name
179
+ )
180
+ if (existingIndex === -1) structuredParts.push(nextPart)
181
+ else structuredParts[existingIndex] = nextPart
182
+ break
183
+ }
143
184
  case 'approval-request':
144
185
  pendingApprovals.push({
145
186
  toolCallId: event.toolCallId,
@@ -237,13 +278,39 @@ export function resolvePikkuToolStatus(
237
278
  return { type: 'completed' }
238
279
  }
239
280
 
240
- function buildContent(text: { value: string }, toolCalls: ToolCall[]): any[] {
281
+ function buildRichContent(
282
+ text: { value: string },
283
+ structuredParts: StructuredPart[],
284
+ toolCalls: ToolCall[]
285
+ ): any[] {
241
286
  const content: any[] = []
242
287
  if (text.value) content.push({ type: 'text' as const, text: text.value })
288
+ content.push(...structuredParts)
243
289
  content.push(...toolCalls)
244
290
  return content
245
291
  }
246
292
 
293
+ function buildContentFromAgentResult(result: unknown): any[] {
294
+ const content: any[] = []
295
+
296
+ if (typeof result === 'string') {
297
+ if (result) content.push({ type: 'text' as const, text: result })
298
+ return content
299
+ }
300
+
301
+ if (!result || typeof result !== 'object') return content
302
+
303
+ const record = result as Record<string, unknown>
304
+ if (typeof record.text === 'string' && record.text) {
305
+ content.push({ type: 'text' as const, text: record.text })
306
+ }
307
+ if (record.ui != null) {
308
+ content.push({ type: 'generative-ui' as const, spec: record.ui })
309
+ }
310
+
311
+ return content
312
+ }
313
+
247
314
  function createPikkuStreamingAdapter(
248
315
  optionsRef: React.RefObject<PikkuAgentRuntimeOptions>,
249
316
  pendingApprovalsRef: React.RefObject<PendingApproval[]>,
@@ -281,6 +348,7 @@ function createPikkuStreamingAdapter(
281
348
  // The last resume triggers continuation (next LLM step).
282
349
  let lastText = { value: '' }
283
350
  let lastToolCalls: ToolCall[] = []
351
+ let lastStructuredParts: StructuredPart[] = []
284
352
  let nextApprovals: PendingApproval[] = []
285
353
 
286
354
  for (let i = 0; i < decisions.length; i++) {
@@ -320,11 +388,13 @@ function createPikkuStreamingAdapter(
320
388
 
321
389
  const text = { value: '' }
322
390
  const toolCalls: ToolCall[] = []
391
+ const structuredParts: StructuredPart[] = []
323
392
  const reader = resumeResponse.body.getReader()
324
393
  const streamApprovals = await processStream(
325
394
  reader,
326
395
  text,
327
396
  toolCalls,
397
+ structuredParts,
328
398
  () => {},
329
399
  i === decisions.length - 1
330
400
  ? (onFinishRef.current ?? undefined)
@@ -334,13 +404,18 @@ function createPikkuStreamingAdapter(
334
404
  // Keep the last resume's output (it has continuation content)
335
405
  lastText = text
336
406
  lastToolCalls = toolCalls
407
+ lastStructuredParts = structuredParts
337
408
  if (streamApprovals.length > 0) {
338
409
  nextApprovals = streamApprovals
339
410
  }
340
411
  }
341
412
 
342
413
  // Build content from the last resume's output
343
- const content = buildContent(lastText, lastToolCalls)
414
+ const content = buildRichContent(
415
+ lastText,
416
+ lastStructuredParts,
417
+ lastToolCalls
418
+ )
344
419
 
345
420
  if (nextApprovals.length > 0) {
346
421
  // More approvals from continuation — show them
@@ -372,7 +447,11 @@ function createPikkuStreamingAdapter(
372
447
  }
373
448
  }
374
449
 
375
- const updatedContent = buildContent(lastText, lastToolCalls)
450
+ const updatedContent = buildRichContent(
451
+ lastText,
452
+ lastStructuredParts,
453
+ lastToolCalls
454
+ )
376
455
  yield {
377
456
  content: updatedContent,
378
457
  status: {
@@ -411,6 +490,7 @@ function createPikkuStreamingAdapter(
411
490
  resourceId: opts.resourceId,
412
491
  model: opts.model,
413
492
  temperature: opts.temperature,
493
+ ...(opts.context ? { context: opts.context } : {}),
414
494
  }),
415
495
  signal: abortSignal,
416
496
  credentials: opts.credentials,
@@ -451,9 +531,10 @@ function createPikkuStreamingAdapter(
451
531
 
452
532
  const text = { value: '' }
453
533
  const toolCalls: ToolCall[] = []
534
+ const structuredParts: StructuredPart[] = []
454
535
  let pendingContent: any[] | null = null
455
536
  const yieldContent = () => {
456
- const content = buildContent(text, toolCalls)
537
+ const content = buildRichContent(text, structuredParts, toolCalls)
457
538
  if (content.length > 0) pendingContent = content
458
539
  }
459
540
 
@@ -462,6 +543,7 @@ function createPikkuStreamingAdapter(
462
543
  reader,
463
544
  text,
464
545
  toolCalls,
546
+ structuredParts,
465
547
  yieldContent,
466
548
  onFinishRef.current ?? undefined
467
549
  )
@@ -516,7 +598,7 @@ function createPikkuStreamingAdapter(
516
598
  }
517
599
  }
518
600
 
519
- const content = buildContent(text, toolCalls)
601
+ const content = buildRichContent(text, structuredParts, toolCalls)
520
602
  yield {
521
603
  content,
522
604
  status: {
@@ -596,7 +678,11 @@ export const convertDbMessages = (dbMessages: any[]): ThreadMessageLike[] => {
596
678
  const parts: any[] = []
597
679
 
598
680
  if (msg.content) {
599
- parts.push({ type: 'text' as const, text: msg.content })
681
+ if (Array.isArray(msg.content)) {
682
+ parts.push(...msg.content)
683
+ } else {
684
+ parts.push({ type: 'text' as const, text: msg.content })
685
+ }
600
686
  }
601
687
 
602
688
  if (Array.isArray(msg.toolCalls)) {
@@ -732,6 +818,7 @@ function createPikkuNonStreamingAdapter(
732
818
  // Resume uses SSE (same as streaming mode)
733
819
  let lastText = { value: '' }
734
820
  let lastToolCalls: ToolCall[] = []
821
+ let lastStructuredParts: StructuredPart[] = []
735
822
  let nextApprovals: PendingApproval[] = []
736
823
 
737
824
  for (let i = 0; i < decisions.length; i++) {
@@ -770,11 +857,13 @@ function createPikkuNonStreamingAdapter(
770
857
 
771
858
  const text = { value: '' }
772
859
  const toolCalls: ToolCall[] = []
860
+ const structuredParts: StructuredPart[] = []
773
861
  const reader = resumeResponse.body.getReader()
774
862
  const streamApprovals = await processStream(
775
863
  reader,
776
864
  text,
777
865
  toolCalls,
866
+ structuredParts,
778
867
  () => {},
779
868
  i === decisions.length - 1
780
869
  ? (onFinishRef.current ?? undefined)
@@ -783,12 +872,17 @@ function createPikkuNonStreamingAdapter(
783
872
 
784
873
  lastText = text
785
874
  lastToolCalls = toolCalls
875
+ lastStructuredParts = structuredParts
786
876
  if (streamApprovals.length > 0) {
787
877
  nextApprovals = streamApprovals
788
878
  }
789
879
  }
790
880
 
791
- const content = buildContent(lastText, lastToolCalls)
881
+ const content = buildRichContent(
882
+ lastText,
883
+ lastStructuredParts,
884
+ lastToolCalls
885
+ )
792
886
 
793
887
  if (nextApprovals.length > 0) {
794
888
  pendingApprovalsRef.current = nextApprovals
@@ -818,7 +912,11 @@ function createPikkuNonStreamingAdapter(
818
912
  }
819
913
  }
820
914
 
821
- const updatedContent = buildContent(lastText, lastToolCalls)
915
+ const updatedContent = buildRichContent(
916
+ lastText,
917
+ lastStructuredParts,
918
+ lastToolCalls
919
+ )
822
920
  yield {
823
921
  content: updatedContent,
824
922
  status: {
@@ -855,6 +953,7 @@ function createPikkuNonStreamingAdapter(
855
953
  resourceId: opts.resourceId,
856
954
  model: opts.model,
857
955
  temperature: opts.temperature,
956
+ ...(opts.context ? { context: opts.context } : {}),
858
957
  }),
859
958
  signal: abortSignal,
860
959
  credentials: opts.credentials,
@@ -884,9 +983,7 @@ function createPikkuNonStreamingAdapter(
884
983
  }))
885
984
 
886
985
  const content: any[] = []
887
- if (json.result) {
888
- content.push({ type: 'text' as const, text: json.result })
889
- }
986
+ content.push(...buildContentFromAgentResult(json.result))
890
987
  content.push(...toolCalls)
891
988
 
892
989
  yield {
@@ -901,10 +998,7 @@ function createPikkuNonStreamingAdapter(
901
998
 
902
999
  // No approvals — yield complete content
903
1000
  onFinishRef.current?.()
904
- const content: any[] = []
905
- if (json.result) {
906
- content.push({ type: 'text' as const, text: String(json.result) })
907
- }
1001
+ const content = buildContentFromAgentResult(json.result)
908
1002
  if (content.length > 0) {
909
1003
  yield { content }
910
1004
  }