@gram-ai/elements 1.33.1 → 1.34.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.
Files changed (48) hide show
  1. package/dist/components/ui/time-range-picker.d.ts +17 -1
  2. package/dist/components/ui/time-range-picker.test.d.ts +1 -0
  3. package/dist/elements.cjs +1 -1
  4. package/dist/elements.js +19 -16
  5. package/dist/hooks/useGramThreadListAdapter.d.ts +13 -0
  6. package/dist/{index-CGoLfO5p.js → index-B5lZrrO2.js} +52 -37
  7. package/dist/index-B5lZrrO2.js.map +1 -0
  8. package/dist/{index-Dn0_JCoN.js → index-BFU6NvbL.js} +6867 -6816
  9. package/dist/index-BFU6NvbL.js.map +1 -0
  10. package/dist/{index-DAWGW1Nj.cjs → index-C08dvTEo.cjs} +43 -43
  11. package/dist/index-C08dvTEo.cjs.map +1 -0
  12. package/dist/index-DzZ1-jQY.cjs +194 -0
  13. package/dist/index-DzZ1-jQY.cjs.map +1 -0
  14. package/dist/index.d.ts +4 -1
  15. package/dist/lib/messageConverter.d.ts +58 -8
  16. package/dist/lib/models.d.ts +1 -1
  17. package/dist/lib/utils.d.ts +2 -0
  18. package/dist/plugins.cjs +1 -1
  19. package/dist/plugins.js +1 -1
  20. package/dist/{profiler-Ce0O8-iW.cjs → profiler-BRnyr1GA.cjs} +2 -2
  21. package/dist/{profiler-Ce0O8-iW.cjs.map → profiler-BRnyr1GA.cjs.map} +1 -1
  22. package/dist/{profiler-DtaHTumy.js → profiler-KLSTpp6I.js} +2 -2
  23. package/dist/{profiler-DtaHTumy.js.map → profiler-KLSTpp6I.js.map} +1 -1
  24. package/dist/{startRecording-DjP64hyw.js → startRecording-BfxB1xxR.js} +2 -2
  25. package/dist/{startRecording-DjP64hyw.js.map → startRecording-BfxB1xxR.js.map} +1 -1
  26. package/dist/{startRecording-Cpeh1_CL.cjs → startRecording-CKx-YWbq.cjs} +2 -2
  27. package/dist/{startRecording-Cpeh1_CL.cjs.map → startRecording-CKx-YWbq.cjs.map} +1 -1
  28. package/dist/types/index.d.ts +64 -1
  29. package/package.json +4 -2
  30. package/src/components/assistant-ui/thread.tsx +7 -3
  31. package/src/components/ui/time-range-picker.test.ts +57 -0
  32. package/src/components/ui/time-range-picker.tsx +29 -4
  33. package/src/contexts/ElementsProvider.tsx +57 -8
  34. package/src/hooks/useAuth.ts +13 -1
  35. package/src/hooks/useGramThreadListAdapter.tsx +68 -6
  36. package/src/index.ts +16 -0
  37. package/src/lib/cassette.ts +1 -19
  38. package/src/lib/contextCompaction.test.ts +2 -2
  39. package/src/lib/contextCompaction.ts +20 -8
  40. package/src/lib/messageConverter.ts +94 -56
  41. package/src/lib/models.ts +19 -7
  42. package/src/lib/utils.ts +20 -0
  43. package/src/types/index.ts +73 -1
  44. package/dist/index-BqBo07H_.cjs +0 -194
  45. package/dist/index-BqBo07H_.cjs.map +0 -1
  46. package/dist/index-CGoLfO5p.js.map +0 -1
  47. package/dist/index-DAWGW1Nj.cjs.map +0 -1
  48. package/dist/index-Dn0_JCoN.js.map +0 -1
@@ -67,6 +67,19 @@ import { ElementsContext } from "./contexts";
67
67
  import { ToolApprovalProvider } from "./ToolApprovalContext";
68
68
  import { ToolExecutionProvider } from "./ToolExecutionContext";
69
69
 
70
+ // Reads the active local thread id from the runtime's threads store. Reaches
71
+ // into an assistant-ui internal that isn't part of the public type, so it's
72
+ // isolated here as the single point of breakage if the API moves.
73
+ function getActiveLocalThreadId(
74
+ runtimeRef: React.RefObject<ReturnType<typeof useChatRuntime> | null>,
75
+ ): string | undefined {
76
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
+ const threadsState = (runtimeRef.current as any)?.threads?.getState?.();
78
+ return (threadsState?.mainThreadId ?? threadsState?.threadIds?.[0]) as
79
+ | string
80
+ | undefined;
81
+ }
82
+
70
83
  /**
71
84
  * Extracts executable tools from frontend tool definitions.
72
85
  * Frontend tools created via defineFrontendTool have an unstable_tool property
@@ -276,8 +289,10 @@ const ElementsProviderInner = ({ children, config }: ElementsProviderProps) => {
276
289
  // in a way that's accessible from the transport's sendMessages function.
277
290
  const currentRemoteIdRef = useRef<string | null>(null);
278
291
 
279
- // Create chat transport configuration
280
- const transport = useMemo<ChatTransport<UIMessage>>(
292
+ // Create chat transport configuration. This is the built-in client-side
293
+ // streaming transport; a consumer can override it via config.transport (see
294
+ // below) to route the conversation through a server-side assistant instead.
295
+ const defaultTransport = useMemo<ChatTransport<UIMessage>>(
281
296
  () => ({
282
297
  sendMessages: async ({ messages, abortSignal }) => {
283
298
  const usingCustomModel = !!config.languageModel;
@@ -298,12 +313,7 @@ const ElementsProviderInner = ({ children, config }: ElementsProviderProps) => {
298
313
  // chatId is already set correctly from the synced ref
299
314
  } else if (isLocalThreadId(chatId) || !chatId) {
300
315
  // For local thread IDs or no ID, check/generate UUID mapping
301
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
302
- const runtimeAny = runtimeRef.current as any;
303
- const threadsState = runtimeAny?.threads?.getState?.();
304
- const localThreadId = (threadsState?.mainThreadId ??
305
- threadsState?.threadIds?.[0]) as string | undefined;
306
-
316
+ const localThreadId = getActiveLocalThreadId(runtimeRef);
307
317
  const lookupKey = chatId ?? localThreadId;
308
318
  if (lookupKey) {
309
319
  const existingUuid = localIdToUuidMapRef.current.get(lookupKey);
@@ -521,6 +531,43 @@ const ElementsProviderInner = ({ children, config }: ElementsProviderProps) => {
521
531
  ],
522
532
  );
523
533
 
534
+ // A consumer-supplied transport (e.g. a server-side assistant transport) takes
535
+ // precedence over the built-in client-side one. It may be a ChatTransport or a
536
+ // factory: a factory is invoked here, inside the provider, with a getChatId()
537
+ // sourced from the synced thread state, so the transport can read the active
538
+ // chat id at send time without reaching into Elements internals. Local
539
+ // (unpersisted) thread ids read as null so the transport can treat them as a
540
+ // brand-new conversation.
541
+ const getChatId = useCallback(() => {
542
+ const id = currentRemoteIdRef.current;
543
+ return id && !isLocalThreadId(id) ? id : null;
544
+ }, []);
545
+ // Capture the active local thread identity now and return a bind function
546
+ // closing over it. Consumer transports call this at the start of
547
+ // `sendMessages`; once a server-minted chat id is known, invoking the
548
+ // returned function reconciles the captured thread to it — the same
549
+ // reconciliation the built-in transport does inline when it generates an id.
550
+ // Closing over the captured id (instead of re-reading active state at bind
551
+ // time) is what makes a thread switch or a parallel send on another thread
552
+ // during the round-trip safe.
553
+ const adoptChatId = useCallback(() => {
554
+ const capturedLocalThreadId = getActiveLocalThreadId(runtimeRef);
555
+ return (chatId: string) => {
556
+ if (capturedLocalThreadId) {
557
+ localIdToUuidMapRef.current.set(capturedLocalThreadId, chatId);
558
+ }
559
+ currentRemoteIdRef.current = chatId;
560
+ mcpHeaders["Gram-Chat-ID"] = chatId;
561
+ setCurrentChatId(chatId);
562
+ };
563
+ }, [mcpHeaders, setCurrentChatId]);
564
+ const transport = useMemo<ChatTransport<UIMessage>>(() => {
565
+ if (typeof config.transport === "function") {
566
+ return config.transport({ getChatId, adoptChatId });
567
+ }
568
+ return config.transport ?? defaultTransport;
569
+ }, [config.transport, defaultTransport, getChatId, adoptChatId]);
570
+
524
571
  const historyEnabled = config.history?.enabled ?? false;
525
572
 
526
573
  // Shared context value for ElementsContext
@@ -658,6 +705,8 @@ const ElementsProviderWithHistory = ({
658
705
  apiUrl,
659
706
  headers,
660
707
  localIdToUuidMap,
708
+ threadListFilters: contextValue?.config.history?.threadListFilters,
709
+ deferThreadIdMinting: contextValue?.config.history?.deferThreadIdMinting,
661
710
  });
662
711
  const initialThreadId = contextValue?.config.history?.initialThreadId;
663
712
 
@@ -125,6 +125,14 @@ export const useAuth = ({
125
125
 
126
126
  const shouldRefresh = !isAnyStaticSession(auth) && !isReplay;
127
127
 
128
+ // `api.headers` from ElementsConfig flows here untouched and is merged into
129
+ // every header set the hook returns, so it reaches both /chat/completions
130
+ // (via the OpenRouter model in ElementsProvider) AND /mcp/{slug} (via
131
+ // useMCPTools). Today the dashboard uses this to forward
132
+ // `Authorization: Bearer <user-session JWT>` so the runtime gateway can
133
+ // resolve the user's upstream credentials for issuer-gated toolsets.
134
+ const extraHeaders = auth?.headers ?? {};
135
+
128
136
  const ensureValidHeaders = useCallback(async (): Promise<
129
137
  Record<string, string>
130
138
  > => {
@@ -133,6 +141,7 @@ export const useAuth = ({
133
141
 
134
142
  if (!shouldRefresh || !getSession) {
135
143
  return {
144
+ ...extraHeaders,
136
145
  "Gram-Project": projectSlug,
137
146
  ...(cachedToken && { "Gram-Chat-Session": cachedToken }),
138
147
  };
@@ -151,16 +160,18 @@ export const useAuth = ({
151
160
  staleTime,
152
161
  });
153
162
  return {
163
+ ...extraHeaders,
154
164
  "Gram-Project": projectSlug,
155
165
  ...(token && { "Gram-Chat-Session": token }),
156
166
  };
157
167
  } catch {
158
168
  return {
169
+ ...extraHeaders,
159
170
  "Gram-Project": projectSlug,
160
171
  ...(cachedToken && { "Gram-Chat-Session": cachedToken }),
161
172
  };
162
173
  }
163
- }, [shouldRefresh, getSession, projectSlug, queryClient]);
174
+ }, [shouldRefresh, getSession, projectSlug, queryClient, extraHeaders]);
164
175
 
165
176
  // In replay mode, return immediately without waiting for session
166
177
  if (isReplay) {
@@ -178,6 +189,7 @@ export const useAuth = ({
178
189
  }
179
190
  : {
180
191
  headers: {
192
+ ...extraHeaders,
181
193
  "Gram-Project": projectSlug,
182
194
  "Gram-Chat-Session": session,
183
195
  },
@@ -13,6 +13,7 @@ import {
13
13
  convertGramMessagesToExported,
14
14
  convertGramMessagesToUIMessages,
15
15
  } from "@/lib/messageConverter";
16
+ import { sleep } from "@/lib/utils";
16
17
  import {
17
18
  useCallback,
18
19
  useEffect,
@@ -37,11 +38,46 @@ export function isLocalThreadId(threadId: string | null | undefined): boolean {
37
38
  return !!threadId?.startsWith(LOCAL_THREAD_ID_PREFIX);
38
39
  }
39
40
 
41
+ /**
42
+ * Polls the shared local→remote id map until the transport assigns an id for
43
+ * `threadId`, or a deadline passes. Used in deferred-minting mode so a new
44
+ * thread adopts the backend-minted chat id instead of a client-generated one.
45
+ * The timeout is generous to tolerate cold serverless boots on the first send.
46
+ */
47
+ async function waitForMappedId(
48
+ map: Map<string, string> | undefined,
49
+ threadId: string,
50
+ timeoutMs = 30_000,
51
+ intervalMs = 50,
52
+ ): Promise<string | undefined> {
53
+ if (!map) return undefined;
54
+ const deadline = Date.now() + timeoutMs;
55
+ for (;;) {
56
+ const id = map.get(threadId);
57
+ if (id) return id;
58
+ if (Date.now() >= deadline) return undefined;
59
+ await sleep(intervalMs);
60
+ }
61
+ }
62
+
40
63
  export interface ThreadListAdapterOptions {
41
64
  apiUrl: string;
42
65
  headers: Record<string, string>;
43
66
  /** Map to translate local thread IDs to UUIDs (shared with transport) */
44
67
  localIdToUuidMap?: Map<string, string>;
68
+ /**
69
+ * Extra query parameters forwarded to `chat.list` to filter which threads are
70
+ * listed. Opaque to the adapter — the consumer chooses the keys.
71
+ */
72
+ threadListFilters?: Record<string, string>;
73
+ /**
74
+ * Don't client-mint a chat id for a brand-new thread. When true, `initialize`
75
+ * waits for the transport to assign the id (via the shared `localIdToUuidMap`,
76
+ * e.g. a server-minted id reported through the transport context's
77
+ * `adoptChatId` bind closure) instead of generating one with
78
+ * `crypto.randomUUID()`. Use this when the backend owns chat-id creation.
79
+ */
80
+ deferThreadIdMinting?: boolean;
45
81
  }
46
82
 
47
83
  interface ListChatsResponse {
@@ -213,12 +249,14 @@ export function useGramThreadListAdapter(
213
249
 
214
250
  async list() {
215
251
  try {
216
- const response = await fetch(
217
- `${optionsRef.current.apiUrl}/rpc/chat.list`,
218
- {
219
- headers: optionsRef.current.headers,
220
- },
221
- );
252
+ const { apiUrl, headers, threadListFilters } = optionsRef.current;
253
+ const qs = threadListFilters
254
+ ? new URLSearchParams(threadListFilters).toString()
255
+ : "";
256
+ const listUrl = qs
257
+ ? `${apiUrl}/rpc/chat.list?${qs}`
258
+ : `${apiUrl}/rpc/chat.list`;
259
+ const response = await fetch(listUrl, { headers });
222
260
 
223
261
  if (!response.ok) {
224
262
  console.error("Failed to list chats:", response.status);
@@ -252,6 +290,30 @@ export function useGramThreadListAdapter(
252
290
  externalId: existingUuid,
253
291
  };
254
292
  }
293
+ // When the backend owns chat-id creation, don't client-mint: wait for
294
+ // the transport to report the server-minted id (it assigns it during
295
+ // the first send via the adoptChatId bind closure, populating the
296
+ // shared map — the same path the built-in transport uses).
297
+ if (optionsRef.current.deferThreadIdMinting) {
298
+ const mappedUuid = await waitForMappedId(
299
+ optionsRef.current.localIdToUuidMap,
300
+ threadId,
301
+ );
302
+ if (mappedUuid) {
303
+ return {
304
+ remoteId: mappedUuid,
305
+ externalId: mappedUuid,
306
+ };
307
+ }
308
+ // Falling through to crypto.randomUUID() here would defeat deferred
309
+ // minting: the client id would race the server-minted one reported
310
+ // later via the adoptChatId bind closure, leaving runtime state
311
+ // and the local→remote map disagreeing. Surface the failure to
312
+ // the user instead.
313
+ throw new Error(
314
+ "Backend did not mint a chat id in time — first send may have failed or is still in flight. Retry the send.",
315
+ );
316
+ }
255
317
  // Otherwise generate a new one and store it
256
318
  const uuid = crypto.randomUUID();
257
319
  optionsRef.current.localIdToUuidMap?.set(threadId, uuid);
package/src/index.ts CHANGED
@@ -56,6 +56,8 @@ export type {
56
56
  Dimension,
57
57
  Dimensions,
58
58
  ElementsConfig,
59
+ ElementsTransportContext,
60
+ ElementsTransportFactory,
59
61
  ErrorTrackingConfigOption,
60
62
  GetSessionFn,
61
63
  HistoryConfig,
@@ -80,6 +82,20 @@ export type {
80
82
 
81
83
  export { MODELS } from "./lib/models";
82
84
 
85
+ // Chat-message conversion — for consumers building a custom transport against
86
+ // the Gram chat service (e.g. the dashboard's server-assistant transport).
87
+ export {
88
+ convertGramMessagesToUIMessages,
89
+ convertGramMessagesToExported,
90
+ } from "@/lib/messageConverter";
91
+
92
+ export { sleep } from "@/lib/utils";
93
+ export type {
94
+ GramChat,
95
+ GramChatMessage,
96
+ GramChatOverview,
97
+ } from "@/lib/messageConverter";
98
+
83
99
  export type { Plugin } from "./types/plugins";
84
100
 
85
101
  // Time Range Picker
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { createUIMessageStream, type ChatTransport, type UIMessage } from "ai";
14
14
  import type { ThreadMessage } from "@assistant-ui/react";
15
+ import { sleep } from "@/lib/utils";
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Cassette types
@@ -112,25 +113,6 @@ export function recordCassette(messages: readonly ThreadMessage[]): Cassette {
112
113
  // Playback: Cassette → ChatTransport
113
114
  // ---------------------------------------------------------------------------
114
115
 
115
- /** Sleep that respects AbortSignal for clean cancellation. */
116
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
117
- return new Promise((resolve, reject) => {
118
- if (signal?.aborted) {
119
- reject(new DOMException("Aborted", "AbortError"));
120
- return;
121
- }
122
- const timeout = setTimeout(resolve, ms);
123
- signal?.addEventListener(
124
- "abort",
125
- () => {
126
- clearTimeout(timeout);
127
- reject(new DOMException("Aborted", "AbortError"));
128
- },
129
- { once: true },
130
- );
131
- });
132
- }
133
-
134
116
  /**
135
117
  * Creates a ChatTransport that replays pre-recorded assistant messages
136
118
  * from a cassette. Each call to `sendMessages` (triggered by a user message
@@ -38,8 +38,8 @@ describe("getModelContextLimit", () => {
38
38
  expect(getModelContextLimit("anthropic/claude-sonnet-4.6")).toBe(1_000_000);
39
39
  });
40
40
 
41
- it("returns known mapping for Claude 4 (non-1M)", () => {
42
- expect(getModelContextLimit("anthropic/claude-sonnet-4")).toBe(200_000);
41
+ it("returns known mapping for Claude Sonnet 4", () => {
42
+ expect(getModelContextLimit("anthropic/claude-sonnet-4")).toBe(1_000_000);
43
43
  });
44
44
 
45
45
  it("returns DEFAULT_CONTEXT_LIMIT for unknown models", () => {
@@ -32,39 +32,51 @@ export const DEFAULT_CONTEXT_LIMIT = 200_000;
32
32
  */
33
33
  const MODEL_CONTEXT_LIMITS: Partial<Record<KnownModelId, number>> = {
34
34
  // Anthropic (1M tier where available, else 200K)
35
+ "anthropic/claude-opus-4.8": 1_000_000,
36
+ "anthropic/claude-opus-4.7": 1_000_000,
35
37
  "anthropic/claude-opus-4.6": 1_000_000,
36
- "anthropic/claude-opus-4.5": 1_000_000,
37
- "anthropic/claude-opus-4.1": 200_000,
38
+ "anthropic/claude-opus-4.5": 200_000,
38
39
  "anthropic/claude-sonnet-4.6": 1_000_000,
39
40
  "anthropic/claude-sonnet-4.5": 1_000_000,
40
- "anthropic/claude-sonnet-4": 200_000,
41
+ "anthropic/claude-sonnet-4": 1_000_000,
41
42
  "anthropic/claude-haiku-4.5": 200_000,
42
43
 
43
44
  // OpenAI
44
- "openai/gpt-5.4": 400_000,
45
+ "openai/gpt-5.5": 1_000_000,
46
+ "openai/gpt-5.5-pro": 1_000_000,
47
+ "openai/gpt-5.4": 1_000_000,
45
48
  "openai/gpt-5.4-mini": 400_000,
49
+ "openai/gpt-5.4-nano": 400_000,
50
+ "openai/gpt-5.3-codex": 400_000,
46
51
  "openai/gpt-5.1": 400_000,
47
- "openai/gpt-5.1-codex": 400_000,
48
52
  "openai/gpt-5": 400_000,
49
53
  "openai/gpt-4.1": 1_000_000,
50
54
  "openai/o4-mini": 200_000,
51
55
  "openai/o3": 200_000,
52
56
 
53
57
  // Google
58
+ "google/gemini-3.5-flash": 1_000_000,
54
59
  "google/gemini-3.1-pro-preview": 1_000_000,
60
+ "google/gemini-3.1-flash-lite": 1_000_000,
55
61
  "google/gemini-2.5-pro": 1_000_000,
56
62
  "google/gemini-2.5-flash": 1_000_000,
57
63
 
58
64
  // Others
65
+ "deepseek/deepseek-v4-pro": 1_000_000,
66
+ "deepseek/deepseek-v4-flash": 1_000_000,
59
67
  "deepseek/deepseek-r1": 128_000,
60
68
  "deepseek/deepseek-v3.2": 128_000,
61
69
  "meta-llama/llama-4-maverick": 1_000_000,
62
- "x-ai/grok-4": 256_000,
70
+ "x-ai/grok-4.3": 1_000_000,
71
+ "x-ai/grok-4.20": 2_000_000,
72
+ "qwen/qwen3.7-max": 1_000_000,
63
73
  "qwen/qwen3-coder": 256_000,
64
- "moonshotai/kimi-k2.5": 128_000,
74
+ "moonshotai/kimi-k2.6": 256_000,
75
+ "moonshotai/kimi-k2.5": 256_000,
76
+ "mistralai/mistral-medium-3-5": 256_000,
65
77
  "mistralai/mistral-medium-3.1": 128_000,
66
78
  "mistralai/codestral-2508": 256_000,
67
- "mistralai/devstral-small": 128_000,
79
+ "mistralai/devstral-2512": 256_000,
68
80
  };
69
81
 
70
82
  /**
@@ -19,23 +19,69 @@ import type {
19
19
  ThreadAssistantMessagePart,
20
20
  TextMessagePart,
21
21
  } from "@assistant-ui/react";
22
- import type {
23
- Message,
24
- UserMessage,
25
- AssistantMessage,
26
- ToolResponseMessage,
27
- } from "@openrouter/sdk/models";
28
22
  import { UIMessage } from "ai";
29
23
 
30
24
  /**
31
- * Represents a chat message from the Gram API.
32
- * This mirrors the ChatMessage type from @gram/sdk without requiring the SDK dependency.
25
+ * A single text part of a multi-modal chat message.
26
+ */
27
+ export interface GramChatTextPart {
28
+ type: "text";
29
+ text: string;
30
+ }
31
+
32
+ /**
33
+ * A single image part of a multi-modal chat message. The wire shape mirrors the
34
+ * upstream OpenAI/OpenRouter chat schema (`image_url.url`) so the converter can
35
+ * read it without normalisation.
36
+ */
37
+ export interface GramChatImagePart {
38
+ type: "image_url";
39
+ image_url?: { url?: string };
40
+ }
41
+
42
+ /**
43
+ * A single audio part of a multi-modal chat message. Mirrors the OpenAI/OpenRouter
44
+ * `input_audio.{data, format}` shape on the wire.
45
+ */
46
+ export interface GramChatAudioPart {
47
+ type: "input_audio";
48
+ input_audio?: { data?: string; format?: string };
49
+ }
50
+
51
+ /**
52
+ * Content part of a multi-modal chat message.
53
+ */
54
+ export type GramChatContentPart =
55
+ | GramChatTextPart
56
+ | GramChatImagePart
57
+ | GramChatAudioPart;
58
+
59
+ /**
60
+ * Content of a chat message — either a plain string or an array of parts for
61
+ * multi-modal messages.
62
+ */
63
+ export type GramChatContent = string | GramChatContentPart[];
64
+
65
+ /**
66
+ * Represents a chat message from the Gram API. Only fields actually surfaced
67
+ * through Elements' public converters are modelled; provider-specific extras
68
+ * remain on the wire shape but are intentionally not part of the contract.
69
+ *
70
+ * `tool_calls` is the JSON-encoded string the Gram chat service stores on
71
+ * assistant rows; `tool_call_id` is the id the corresponding tool-response row
72
+ * carries when `role === "tool"`.
33
73
  */
34
- export type GramChatMessage = Message & {
74
+ export interface GramChatMessage {
35
75
  id: string;
36
76
  model: string;
37
77
  created_at: Date | string;
38
- };
78
+ role: "system" | "developer" | "user" | "assistant" | "tool";
79
+ content?: GramChatContent | null;
80
+ name?: string;
81
+ tool_calls?: string;
82
+ tool_call_id?: string;
83
+ reasoning?: string | null;
84
+ }
39
85
 
40
86
  /**
41
87
  * Represents a chat from the Gram API.
@@ -96,29 +142,23 @@ function buildUserContentParts(msg: GramChatMessage): ThreadUserMessagePart[] {
96
142
  text: item.text,
97
143
  });
98
144
  break;
99
- case "image_url":
145
+ case "image_url": {
146
+ const url = item.image_url?.url ?? "";
100
147
  parts.push({
101
148
  type: "image",
102
- image: (item as any).image_url?.url as FIXME<
103
- string,
104
- "Fixed by switching to Gram TS SDK."
105
- >,
149
+ image: url,
106
150
  });
107
151
  break;
152
+ }
108
153
  case "input_audio": {
109
- const format = (item as any).input_audio?.format as FIXME<
110
- string,
111
- "Fixed by switching to Gram TS SDK."
112
- >;
113
- if (format === "mp3" || format === "wav") {
154
+ const format = item.input_audio?.format;
155
+ const data = item.input_audio?.data;
156
+ if ((format === "mp3" || format === "wav") && data) {
114
157
  parts.push({
115
158
  type: "audio",
116
159
  audio: {
117
- data: (item as any).input_audio.data as FIXME<
118
- string,
119
- "Fixed by switching to Gram TS SDK."
120
- >,
121
- format: format,
160
+ data,
161
+ format,
122
162
  },
123
163
  });
124
164
  }
@@ -155,12 +195,7 @@ function buildAssistantContentParts(
155
195
  });
156
196
  }
157
197
 
158
- const toolCallsJSON = (msg as any).tool_calls as FIXME<
159
- string | undefined,
160
- "Fixed by switching to Gram TS SDK."
161
- >;
162
-
163
- let toolCalls = tryParseJSON(toolCallsJSON || "[]");
198
+ let toolCalls = tryParseJSON(msg.tool_calls || "[]");
164
199
  if (!Array.isArray(toolCalls)) {
165
200
  console.warn("Invalid tool_calls format, expected an array.");
166
201
  toolCalls = [];
@@ -280,8 +315,10 @@ function convertGramMessageToThreadMessage(
280
315
  * Converts an array of Gram ChatMessages to an ExportedMessageRepository.
281
316
  * Creates parent-child relationships based on message order.
282
317
  *
283
- * Note: System messages are filtered out because assistant-ui's
284
- * `fromThreadMessageLike` doesn't support them in the exported format.
318
+ * Note: system, developer, and tool messages are filtered out. assistant-ui's
319
+ * exported format only models user/assistant turns; system/developer rows are
320
+ * pre-prompt instructions the UI doesn't render, and tool rows are folded into
321
+ * the preceding assistant message as `tool-call` parts via `tool_calls`.
285
322
  */
286
323
  export function convertGramMessagesToExported(
287
324
  messages: GramChatMessage[],
@@ -294,8 +331,11 @@ export function convertGramMessagesToExported(
294
331
  let prevId: string | null = null;
295
332
 
296
333
  for (const msg of messages) {
297
- // Skip system messages - they're not supported in the exported message format
298
- if (msg.role === "system") {
334
+ if (
335
+ msg.role === "system" ||
336
+ msg.role === "developer" ||
337
+ msg.role === "tool"
338
+ ) {
299
339
  continue;
300
340
  }
301
341
 
@@ -322,17 +362,17 @@ export function convertGramMessagesToUIMessages(messages: GramChatMessage[]): {
322
362
  return { messages: [], headId: null };
323
363
  }
324
364
 
325
- const toolCallResults = new Map<string, ToolResponseMessage>();
365
+ const toolCallResults = new Map<string, GramChatMessage>();
326
366
  for (const msg of messages) {
327
367
  if (msg.role !== "tool") {
328
368
  continue;
329
369
  }
330
- const id = (msg as any).tool_call_id;
370
+ const id = msg.tool_call_id;
331
371
  if (typeof id !== "string") {
332
372
  continue;
333
373
  }
334
374
 
335
- toolCallResults.set(id, msg as ToolResponseMessage);
375
+ toolCallResults.set(id, msg);
336
376
  }
337
377
 
338
378
  const uiMessages: { parentId: string | null; message: UIMessage }[] = [];
@@ -415,9 +455,19 @@ export function convertGramMessagesToUIMessages(messages: GramChatMessage[]): {
415
455
  };
416
456
  }
417
457
 
458
+ /**
459
+ * Parsed shape of a single entry inside an assistant message's `tool_calls`
460
+ * JSON string. Mirrors the OpenAI/OpenRouter tool-call wire format.
461
+ */
462
+ interface GramToolCall {
463
+ id: string;
464
+ type?: "function";
465
+ function?: { name?: string; arguments?: string | Record<string, unknown> };
466
+ }
467
+
418
468
  export function convertGramMessagePartsToUIMessageParts(
419
- msg: UserMessage | AssistantMessage,
420
- toolResults: Map<string, ToolResponseMessage>,
469
+ msg: GramChatMessage,
470
+ toolResults: Map<string, GramChatMessage>,
421
471
  seenToolCallIds?: Set<string>,
422
472
  ): UIMessage["parts"] {
423
473
  const uiparts: UIMessage["parts"] = [];
@@ -440,10 +490,7 @@ export function convertGramMessagePartsToUIMessageParts(
440
490
  break;
441
491
  }
442
492
  case "image_url": {
443
- const url = (p as any).image_url?.url as FIXME<
444
- string | undefined,
445
- "Fixed by switching to Gram TS SDK."
446
- >;
493
+ const url = p.image_url?.url;
447
494
  if (!url) {
448
495
  break;
449
496
  }
@@ -456,10 +503,7 @@ export function convertGramMessagePartsToUIMessageParts(
456
503
  break;
457
504
  }
458
505
  case "input_audio": {
459
- const url = (p as any).input_audio?.data as FIXME<
460
- string | undefined,
461
- "Fixed by switching to Gram TS SDK."
462
- >;
506
+ const url = p.input_audio?.data;
463
507
  if (!url) {
464
508
  break;
465
509
  }
@@ -481,14 +525,8 @@ export function convertGramMessagePartsToUIMessageParts(
481
525
  });
482
526
  }
483
527
 
484
- if (msg.role === "assistant" && (msg as any).tool_calls) {
485
- const toolCallsJSON = (msg as any).tool_calls as FIXME<
486
- string,
487
- "Fixed by switching to Gram TS SDK."
488
- >;
489
- let toolCalls = tryParseJSON<AssistantMessage["toolCalls"]>(
490
- toolCallsJSON || "[]",
491
- );
528
+ if (msg.role === "assistant" && msg.tool_calls) {
529
+ let toolCalls = tryParseJSON<GramToolCall[]>(msg.tool_calls || "[]");
492
530
  if (!Array.isArray(toolCalls)) {
493
531
  console.warn("Invalid tool_calls format, expected an array.");
494
532
  toolCalls = [];