@gram-ai/elements 1.19.0 → 1.20.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.
@@ -14,7 +14,11 @@ import {
14
14
  import { recommended } from '@/plugins'
15
15
  import { ElementsConfig, Model } from '@/types'
16
16
  import { Plugin } from '@/types/plugins'
17
- import { AssistantRuntimeProvider } from '@assistant-ui/react'
17
+ import {
18
+ AssistantRuntimeProvider,
19
+ AssistantTool,
20
+ unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime,
21
+ } from '@assistant-ui/react'
18
22
  import {
19
23
  frontendTools as convertFrontendToolsToAISDKTools,
20
24
  useChatRuntime,
@@ -41,6 +45,7 @@ import {
41
45
  import { useAuth } from '../hooks/useAuth'
42
46
  import { ElementsContext } from './contexts'
43
47
  import { ToolApprovalProvider } from './ToolApprovalContext'
48
+ import { useGramThreadListAdapter } from '@/hooks/useGramThreadListAdapter'
44
49
 
45
50
  export interface ElementsProviderProps {
46
51
  children: ReactNode
@@ -78,8 +83,8 @@ function cleanMessagesForModel(messages: UIMessage[]): UIMessage[] {
78
83
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
84
  const cleanedParts = partsArray.map((part: any) => {
80
85
  // Strip providerOptions and providerMetadata from all remaining parts
81
- // eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars
82
- const { callProviderMetadata, ...cleanPart } = part
86
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
87
+ const { callProviderMetadata: _, ...cleanPart } = part
83
88
  return cleanPart
84
89
  })
85
90
 
@@ -90,6 +95,10 @@ function cleanMessagesForModel(messages: UIMessage[]): UIMessage[] {
90
95
  })
91
96
  }
92
97
 
98
+ /**
99
+ * Main provider component that sets up auth, tools, and transport.
100
+ * Delegates to either WithHistory or WithoutHistory based on config.
101
+ */
93
102
  const ElementsProviderWithApproval = ({
94
103
  children,
95
104
  config,
@@ -109,7 +118,6 @@ const ElementsProviderWithApproval = ({
109
118
  )
110
119
  const [isOpen, setIsOpen] = useState(config.modal?.defaultOpen)
111
120
 
112
- // If there are any user provided plugins, use them, otherwise use the recommended plugins
113
121
  const plugins = config.plugins ?? recommended
114
122
 
115
123
  const systemPrompt = mergeInternalSystemPromptWith(
@@ -117,7 +125,7 @@ const ElementsProviderWithApproval = ({
117
125
  plugins
118
126
  )
119
127
 
120
- const { data: mcpTools, isLoading: mcpToolsLoading } = useMCPTools({
128
+ const { data: mcpTools } = useMCPTools({
121
129
  auth,
122
130
  mcp: config.mcp,
123
131
  environment: config.environment ?? {},
@@ -160,8 +168,13 @@ const ElementsProviderWithApproval = ({
160
168
  }
161
169
  }, [config.tools?.toolsRequiringApproval, getApprovalHelpers])
162
170
 
163
- // Create custom transport
164
- const transport = useMemo<ChatTransport<UIMessage> | undefined>(
171
+ // Ref to access runtime from within transport's sendMessages.
172
+ // This solves a circular dependency: transport needs runtime.thread.getModelContext(),
173
+ // but runtime is created using transport. The ref gets populated after runtime creation.
174
+ const runtimeRef = useRef<ReturnType<typeof useChatRuntime> | null>(null)
175
+
176
+ // Create chat transport configuration
177
+ const transport = useMemo<ChatTransport<UIMessage>>(
165
178
  () => ({
166
179
  sendMessages: async ({ messages, abortSignal }) => {
167
180
  const usingCustomModel = !!config.languageModel
@@ -170,7 +183,7 @@ const ElementsProviderWithApproval = ({
170
183
  throw new Error('Session is loading')
171
184
  }
172
185
 
173
- const context = runtime.thread.getModelContext()
186
+ const context = runtimeRef.current?.thread.getModelContext()
174
187
  const frontendTools = toAISDKTools(
175
188
  getEnabledTools(context?.tools ?? {})
176
189
  )
@@ -231,52 +244,160 @@ const ElementsProviderWithApproval = ({
231
244
  }
232
245
  },
233
246
  reconnectToStream: async () => {
234
- // Not implemented for client-side streaming
235
247
  throw new Error('Stream reconnection not supported')
236
248
  },
237
249
  }),
238
250
  [
239
- config,
240
251
  config.languageModel,
252
+ config.tools?.toolsRequiringApproval,
241
253
  model,
242
254
  systemPrompt,
243
255
  mcpTools,
244
- mcpToolsLoading,
245
256
  getApprovalHelpers,
246
257
  apiUrl,
247
258
  auth.headers,
259
+ auth.isLoading,
248
260
  ]
249
261
  )
250
262
 
251
- const runtime = useChatRuntime({
252
- transport,
263
+ const historyEnabled = config.history?.enabled ?? false
264
+
265
+ // Shared context value for ElementsContext
266
+ const contextValue = useMemo(
267
+ () => ({
268
+ config,
269
+ setModel,
270
+ model,
271
+ isExpanded,
272
+ setIsExpanded,
273
+ isOpen: isOpen ?? false,
274
+ setIsOpen,
275
+ plugins,
276
+ }),
277
+ [config, model, isExpanded, isOpen, plugins]
278
+ )
279
+
280
+ const frontendTools = config.tools?.frontendTools ?? {}
281
+
282
+ // Render the appropriate runtime provider based on history config.
283
+ // We use separate components to avoid conditional hook calls.
284
+ if (historyEnabled && !auth.isLoading) {
285
+ return (
286
+ <ElementsProviderWithHistory
287
+ transport={transport}
288
+ apiUrl={apiUrl}
289
+ headers={auth.headers}
290
+ contextValue={contextValue}
291
+ runtimeRef={runtimeRef}
292
+ frontendTools={frontendTools}
293
+ >
294
+ {children}
295
+ </ElementsProviderWithHistory>
296
+ )
297
+ }
298
+
299
+ return (
300
+ <ElementsProviderWithoutHistory
301
+ transport={transport}
302
+ contextValue={contextValue}
303
+ runtimeRef={runtimeRef}
304
+ frontendTools={frontendTools}
305
+ >
306
+ {children}
307
+ </ElementsProviderWithoutHistory>
308
+ )
309
+ }
310
+
311
+ // Separate component for history-enabled mode to avoid conditional hook calls
312
+ interface ElementsProviderWithHistoryProps {
313
+ children: ReactNode
314
+ transport: ChatTransport<UIMessage>
315
+ apiUrl: string
316
+ headers: Record<string, string>
317
+ contextValue: React.ContextType<typeof ElementsContext>
318
+ runtimeRef: React.RefObject<ReturnType<typeof useChatRuntime> | null>
319
+ frontendTools: Record<string, AssistantTool>
320
+ }
321
+
322
+ const ElementsProviderWithHistory = ({
323
+ children,
324
+ transport,
325
+ apiUrl,
326
+ headers,
327
+ contextValue,
328
+ runtimeRef,
329
+ frontendTools,
330
+ }: ElementsProviderWithHistoryProps) => {
331
+ const threadListAdapter = useGramThreadListAdapter({ apiUrl, headers })
332
+
333
+ // Hook factory for creating the base chat runtime
334
+ const useChatRuntimeHook = useCallback(() => {
335
+ return useChatRuntime({ transport })
336
+ }, [transport])
337
+
338
+ const runtime = useRemoteThreadListRuntime({
339
+ adapter: threadListAdapter,
340
+ runtimeHook: useChatRuntimeHook,
253
341
  })
254
342
 
343
+ // Populate runtimeRef so transport can access thread context
344
+ useEffect(() => {
345
+ runtimeRef.current = runtime as ReturnType<typeof useChatRuntime>
346
+ }, [runtime, runtimeRef])
347
+
348
+ // Get the Provider from our adapter to wrap the content
349
+ const HistoryProvider =
350
+ threadListAdapter.unstable_Provider ??
351
+ (({ children }: { children: React.ReactNode }) => <>{children}</>)
352
+
255
353
  return (
256
354
  <AssistantRuntimeProvider runtime={runtime}>
257
- <ElementsContext.Provider
258
- value={{
259
- config,
260
- setModel,
261
- model,
262
- isExpanded,
263
- setIsExpanded,
264
- isOpen: isOpen ?? false,
265
- setIsOpen,
266
- plugins,
267
- }}
268
- >
269
- {children}
355
+ <HistoryProvider>
356
+ <ElementsContext.Provider value={contextValue}>
357
+ {children}
358
+ <FrontendTools tools={frontendTools} />
359
+ </ElementsContext.Provider>
360
+ </HistoryProvider>
361
+ </AssistantRuntimeProvider>
362
+ )
363
+ }
270
364
 
271
- {/* Doesn't render anything, but is used to register frontend tools */}
272
- <FrontendTools tools={config.tools?.frontendTools ?? {}} />
365
+ // Separate component for non-history mode to avoid conditional hook calls
366
+ interface ElementsProviderWithoutHistoryProps {
367
+ children: ReactNode
368
+ transport: ChatTransport<UIMessage>
369
+ contextValue: React.ContextType<typeof ElementsContext>
370
+ runtimeRef: React.RefObject<ReturnType<typeof useChatRuntime> | null>
371
+ frontendTools: Record<string, AssistantTool>
372
+ }
373
+
374
+ const ElementsProviderWithoutHistory = ({
375
+ children,
376
+ transport,
377
+ contextValue,
378
+ runtimeRef,
379
+ frontendTools,
380
+ }: ElementsProviderWithoutHistoryProps) => {
381
+ const runtime = useChatRuntime({ transport })
382
+
383
+ // Populate runtimeRef so transport can access thread context
384
+ useEffect(() => {
385
+ runtimeRef.current = runtime
386
+ }, [runtime, runtimeRef])
387
+
388
+ return (
389
+ <AssistantRuntimeProvider runtime={runtime}>
390
+ <ElementsContext.Provider value={contextValue}>
391
+ {children}
392
+ <FrontendTools tools={frontendTools} />
273
393
  </ElementsContext.Provider>
274
394
  </AssistantRuntimeProvider>
275
395
  )
276
396
  }
277
397
 
398
+ const queryClient = new QueryClient()
399
+
278
400
  export const ElementsProvider = (props: ElementsProviderProps) => {
279
- const queryClient = new QueryClient()
280
401
  return (
281
402
  <QueryClientProvider client={queryClient}>
282
403
  <ToolApprovalProvider>
@@ -0,0 +1,302 @@
1
+ import {
2
+ unstable_RemoteThreadListAdapter as RemoteThreadListAdapter,
3
+ ThreadMessage,
4
+ RuntimeAdapterProvider,
5
+ ThreadHistoryAdapter,
6
+ useAssistantApi,
7
+ type AssistantApi,
8
+ } from '@assistant-ui/react'
9
+ import type { AssistantStream } from 'assistant-stream'
10
+ import {
11
+ GramChatOverview,
12
+ GramChat,
13
+ convertGramMessagesToExported,
14
+ } from '@/lib/messageConverter'
15
+ import {
16
+ useCallback,
17
+ useEffect,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ type PropsWithChildren,
22
+ } from 'react'
23
+
24
+ export interface ThreadListAdapterOptions {
25
+ apiUrl: string
26
+ headers: Record<string, string>
27
+ }
28
+
29
+ interface ListChatsResponse {
30
+ chats: GramChatOverview[]
31
+ }
32
+
33
+ /**
34
+ * Thread history adapter that loads messages from Gram API.
35
+ * Note: We use `as ThreadHistoryAdapter` cast because the withFormat generic
36
+ * signature doesn't match our concrete implementation, but it works at runtime.
37
+ */
38
+ class GramThreadHistoryAdapter {
39
+ private apiUrl: string
40
+ private headers: Record<string, string>
41
+ private store: AssistantApi
42
+
43
+ constructor(
44
+ apiUrl: string,
45
+ headers: Record<string, string>,
46
+ store: AssistantApi
47
+ ) {
48
+ this.apiUrl = apiUrl
49
+ this.headers = headers
50
+ this.store = store
51
+ }
52
+
53
+ async load() {
54
+ const remoteId = this.store.threadListItem().getState().remoteId
55
+ if (!remoteId) {
56
+ return { messages: [], headId: null }
57
+ }
58
+
59
+ try {
60
+ const response = await fetch(
61
+ `${this.apiUrl}/rpc/chat.load?id=${encodeURIComponent(remoteId)}`,
62
+ { headers: this.headers }
63
+ )
64
+
65
+ if (!response.ok) {
66
+ console.error('Failed to load chat:', response.status)
67
+ return { messages: [], headId: null }
68
+ }
69
+
70
+ const chat = (await response.json()) as GramChat
71
+ return convertGramMessagesToExported(chat.messages)
72
+ } catch (error) {
73
+ console.error('Error loading chat:', error)
74
+ return { messages: [], headId: null }
75
+ }
76
+ }
77
+
78
+ async append() {
79
+ // No-op: Gram persists messages server-side during streaming.
80
+ }
81
+
82
+ // Required by ThreadHistoryAdapter - wraps adapter with format conversion.
83
+ // The _formatAdapter param is part of the interface but unused since we handle conversion ourselves.
84
+ // Using arrow functions to capture `this` lexically.
85
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
86
+ withFormat(_formatAdapter: unknown) {
87
+ return {
88
+ load: async () => {
89
+ const remoteId = this.store.threadListItem().getState().remoteId
90
+ if (!remoteId) {
91
+ return { messages: [], headId: null }
92
+ }
93
+
94
+ try {
95
+ const response = await fetch(
96
+ `${this.apiUrl}/rpc/chat.load?id=${encodeURIComponent(remoteId)}`,
97
+ { headers: this.headers }
98
+ )
99
+
100
+ if (!response.ok) {
101
+ console.error('Failed to load chat (withFormat):', response.status)
102
+ return { messages: [], headId: null }
103
+ }
104
+
105
+ const chat = (await response.json()) as GramChat
106
+
107
+ // Filter out system messages (assistant-ui doesn't support them in the import path)
108
+ const filteredMessages = chat.messages.filter(
109
+ (msg) => msg.role !== 'system'
110
+ )
111
+
112
+ if (filteredMessages.length === 0) {
113
+ return { messages: [], headId: null }
114
+ }
115
+
116
+ // Convert to the format expected by useExternalHistory
117
+ // It expects UIMessage format with role and parts array
118
+ let prevId: string | null = null
119
+ const messages = filteredMessages.map((msg, index) => {
120
+ // Generate a fallback ID if missing (required by assistant-ui's MessageRepository)
121
+ const messageId = msg.id || `fallback-${index}-${Date.now()}`
122
+ const uiMessage = {
123
+ parentId: prevId,
124
+ message: {
125
+ id: messageId,
126
+ role: msg.role as 'user' | 'assistant',
127
+ parts: [{ type: 'text' as const, text: msg.content || '' }],
128
+ createdAt: msg.createdAt ? new Date(msg.createdAt) : new Date(),
129
+ },
130
+ }
131
+ prevId = messageId
132
+ return uiMessage
133
+ })
134
+
135
+ return {
136
+ headId: prevId,
137
+ messages,
138
+ }
139
+ } catch (error) {
140
+ console.error('Error loading chat (withFormat):', error)
141
+ return { messages: [], headId: null }
142
+ }
143
+ },
144
+ append: async () => {
145
+ // No-op
146
+ },
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Hook to create a Gram thread history adapter.
153
+ */
154
+ function useGramThreadHistoryAdapter(
155
+ optionsRef: React.RefObject<ThreadListAdapterOptions>
156
+ ): ThreadHistoryAdapter {
157
+ const store = useAssistantApi()
158
+ const [adapter] = useState(
159
+ () =>
160
+ new GramThreadHistoryAdapter(
161
+ optionsRef.current.apiUrl,
162
+ optionsRef.current.headers,
163
+ store
164
+ )
165
+ )
166
+ // Cast to ThreadHistoryAdapter - the withFormat generic doesn't match but works at runtime
167
+ return adapter as unknown as ThreadHistoryAdapter
168
+ }
169
+
170
+ /**
171
+ * Hook that creates a RemoteThreadListAdapter for the Gram API.
172
+ * This properly handles React component identity for the Provider.
173
+ */
174
+ export function useGramThreadListAdapter(
175
+ options: ThreadListAdapterOptions
176
+ ): RemoteThreadListAdapter {
177
+ const optionsRef = useRef(options)
178
+ useEffect(() => {
179
+ optionsRef.current = options
180
+ }, [options])
181
+
182
+ // Create stable Provider component using useCallback
183
+ const unstable_Provider = useCallback(function GramHistoryProvider({
184
+ children,
185
+ }: PropsWithChildren) {
186
+ const history = useGramThreadHistoryAdapter(optionsRef)
187
+ const adapters = useMemo(() => ({ history }), [history])
188
+ return (
189
+ <RuntimeAdapterProvider adapters={adapters}>
190
+ {children}
191
+ </RuntimeAdapterProvider>
192
+ )
193
+ }, [])
194
+
195
+ // Return adapter with stable methods
196
+ return useMemo(
197
+ () => ({
198
+ unstable_Provider,
199
+
200
+ async list() {
201
+ try {
202
+ const response = await fetch(
203
+ `${optionsRef.current.apiUrl}/rpc/chat.list`,
204
+ {
205
+ headers: optionsRef.current.headers,
206
+ }
207
+ )
208
+
209
+ if (!response.ok) {
210
+ console.error('Failed to list chats:', response.status)
211
+ return { threads: [] }
212
+ }
213
+
214
+ const data = (await response.json()) as ListChatsResponse
215
+ return {
216
+ threads: data.chats.map((chat) => ({
217
+ remoteId: chat.id,
218
+ externalId: chat.id,
219
+ status: 'regular' as const,
220
+ title: chat.title || 'New Chat',
221
+ })),
222
+ }
223
+ } catch (error) {
224
+ console.error('Error listing chats:', error)
225
+ return { threads: [] }
226
+ }
227
+ },
228
+
229
+ async initialize(threadId: string) {
230
+ return {
231
+ remoteId: threadId,
232
+ externalId: threadId,
233
+ }
234
+ },
235
+
236
+ async rename() {
237
+ // No-op
238
+ },
239
+
240
+ async archive() {
241
+ // No-op
242
+ },
243
+
244
+ async unarchive() {
245
+ // No-op
246
+ },
247
+
248
+ async delete() {
249
+ // No-op
250
+ },
251
+
252
+ async generateTitle(
253
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
254
+ _remoteId: string,
255
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
256
+ _messages: readonly ThreadMessage[]
257
+ ): Promise<AssistantStream> {
258
+ // Return an empty stream that immediately completes
259
+ // Server generates titles automatically, so we just provide a placeholder
260
+ return new ReadableStream({
261
+ start(controller) {
262
+ controller.close()
263
+ },
264
+ }) as AssistantStream
265
+ },
266
+
267
+ async fetch(threadId: string) {
268
+ try {
269
+ const response = await fetch(
270
+ `${optionsRef.current.apiUrl}/rpc/chat.load?id=${encodeURIComponent(threadId)}`,
271
+ {
272
+ headers: optionsRef.current.headers,
273
+ }
274
+ )
275
+
276
+ if (!response.ok) {
277
+ console.error('Failed to fetch thread:', response.status)
278
+ return {
279
+ remoteId: threadId,
280
+ status: 'regular' as const,
281
+ }
282
+ }
283
+
284
+ const chat = await response.json()
285
+ return {
286
+ remoteId: chat.id,
287
+ externalId: chat.id,
288
+ status: 'regular' as const,
289
+ title: chat.title || 'New Chat',
290
+ }
291
+ } catch (error) {
292
+ console.error('Error fetching thread:', error)
293
+ return {
294
+ remoteId: threadId,
295
+ status: 'regular' as const,
296
+ }
297
+ }
298
+ },
299
+ }),
300
+ [unstable_Provider]
301
+ )
302
+ }
package/src/index.ts CHANGED
@@ -3,10 +3,13 @@ import './global.css'
3
3
 
4
4
  // Context Providers
5
5
  export { ElementsProvider as GramElementsProvider } from './contexts/ElementsProvider'
6
+ export { ElementsProvider } from './contexts/ElementsProvider'
6
7
  export { useElements as useGramElements } from './hooks/useElements'
8
+ export { useElements } from './hooks/useElements'
7
9
 
8
10
  // Core Components
9
11
  export { Chat } from '@/components/Chat'
12
+ export { ThreadList as ChatHistory } from '@/components/assistant-ui/thread-list'
10
13
 
11
14
  // Frontend Tools
12
15
  export { defineFrontendTool } from './lib/tools'
@@ -25,6 +28,7 @@ export type {
25
28
  Dimensions,
26
29
  ElementsConfig,
27
30
  GetSessionFn,
31
+ HistoryConfig,
28
32
  ModalConfig,
29
33
  ModalTriggerPosition,
30
34
  Model,