@gram-ai/elements 1.18.4 → 1.18.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.
Files changed (87) hide show
  1. package/dist/components/Chat/stories/Sidecar.stories.d.ts +6 -0
  2. package/dist/elements.cjs +23 -22
  3. package/dist/elements.cjs.map +1 -0
  4. package/dist/elements.js +609 -599
  5. package/dist/elements.js.map +1 -0
  6. package/dist/index-Bj7jPiuy.cjs +1 -0
  7. package/dist/index-Bj7jPiuy.cjs.map +1 -0
  8. package/dist/index-CJRypLIa.js +1 -0
  9. package/dist/index-CJRypLIa.js.map +1 -0
  10. package/dist/lib/api.d.ts +2 -0
  11. package/dist/lib/api.test.d.ts +1 -0
  12. package/dist/plugins.cjs +1 -0
  13. package/dist/plugins.cjs.map +1 -0
  14. package/dist/plugins.js +1 -0
  15. package/dist/plugins.js.map +1 -0
  16. package/dist/server.cjs +1 -0
  17. package/dist/server.cjs.map +1 -0
  18. package/dist/server.js +1 -0
  19. package/dist/server.js.map +1 -0
  20. package/package.json +6 -2
  21. package/src/components/Chat/index.tsx +21 -0
  22. package/src/components/Chat/stories/ColorScheme.stories.tsx +52 -0
  23. package/src/components/Chat/stories/Composer.stories.tsx +42 -0
  24. package/src/components/Chat/stories/Customization.stories.tsx +88 -0
  25. package/src/components/Chat/stories/Density.stories.tsx +52 -0
  26. package/src/components/Chat/stories/FrontendTools.stories.tsx +145 -0
  27. package/src/components/Chat/stories/Modal.stories.tsx +84 -0
  28. package/src/components/Chat/stories/Model.stories.tsx +32 -0
  29. package/src/components/Chat/stories/Plugins.stories.tsx +50 -0
  30. package/src/components/Chat/stories/Radius.stories.tsx +52 -0
  31. package/src/components/Chat/stories/Sidecar.stories.tsx +27 -0
  32. package/src/components/Chat/stories/ToolApproval.stories.tsx +110 -0
  33. package/src/components/Chat/stories/Tools.stories.tsx +175 -0
  34. package/src/components/Chat/stories/Variants.stories.tsx +46 -0
  35. package/src/components/Chat/stories/Welcome.stories.tsx +42 -0
  36. package/src/components/FrontendTools/index.tsx +9 -0
  37. package/src/components/assistant-ui/assistant-modal.tsx +255 -0
  38. package/src/components/assistant-ui/assistant-sidecar.tsx +88 -0
  39. package/src/components/assistant-ui/attachment.tsx +233 -0
  40. package/src/components/assistant-ui/markdown-text.tsx +240 -0
  41. package/src/components/assistant-ui/reasoning.tsx +261 -0
  42. package/src/components/assistant-ui/thread-list.tsx +97 -0
  43. package/src/components/assistant-ui/thread.tsx +632 -0
  44. package/src/components/assistant-ui/tool-fallback.tsx +111 -0
  45. package/src/components/assistant-ui/tool-group.tsx +59 -0
  46. package/src/components/assistant-ui/tooltip-icon-button.tsx +57 -0
  47. package/src/components/ui/avatar.tsx +51 -0
  48. package/src/components/ui/button.tsx +27 -0
  49. package/src/components/ui/buttonVariants.ts +33 -0
  50. package/src/components/ui/collapsible.tsx +31 -0
  51. package/src/components/ui/dialog.tsx +141 -0
  52. package/src/components/ui/popover.tsx +46 -0
  53. package/src/components/ui/skeleton.tsx +13 -0
  54. package/src/components/ui/tool-ui.stories.tsx +146 -0
  55. package/src/components/ui/tool-ui.tsx +676 -0
  56. package/src/components/ui/tooltip.tsx +61 -0
  57. package/src/contexts/ElementsProvider.tsx +256 -0
  58. package/src/contexts/ToolApprovalContext.tsx +120 -0
  59. package/src/contexts/contexts.ts +10 -0
  60. package/src/global.css +136 -0
  61. package/src/hooks/useAuth.ts +71 -0
  62. package/src/hooks/useDensity.ts +110 -0
  63. package/src/hooks/useElements.ts +14 -0
  64. package/src/hooks/useExpanded.ts +20 -0
  65. package/src/hooks/useMCPTools.ts +73 -0
  66. package/src/hooks/usePluginComponents.ts +34 -0
  67. package/src/hooks/useRadius.ts +42 -0
  68. package/src/hooks/useSession.ts +38 -0
  69. package/src/hooks/useThemeProps.ts +24 -0
  70. package/src/hooks/useToolApproval.ts +16 -0
  71. package/src/index.ts +45 -0
  72. package/src/lib/api.test.ts +90 -0
  73. package/src/lib/api.ts +8 -0
  74. package/src/lib/auth.ts +10 -0
  75. package/src/lib/easing.ts +1 -0
  76. package/src/lib/humanize.ts +14 -0
  77. package/src/lib/models.ts +22 -0
  78. package/src/lib/tools.ts +210 -0
  79. package/src/lib/utils.ts +16 -0
  80. package/src/plugins/README.md +49 -0
  81. package/src/plugins/chart/component.tsx +102 -0
  82. package/src/plugins/chart/index.ts +27 -0
  83. package/src/plugins/index.ts +7 -0
  84. package/src/server.ts +89 -0
  85. package/src/types/index.ts +726 -0
  86. package/src/types/plugins.ts +65 -0
  87. package/src/vite-env.d.ts +12 -0
@@ -0,0 +1,110 @@
1
+ import { useElements } from './useElements'
2
+
3
+ /**
4
+ * Density class mappings for different UI elements
5
+ */
6
+ const densityClasses = {
7
+ compact: {
8
+ // Padding - small increments (1, 1.5, 2, 2.5, 3)
9
+ 'p-xs': 'p-1',
10
+ 'p-sm': 'p-1.5',
11
+ 'p-md': 'p-2',
12
+ 'p-lg': 'p-2.5',
13
+ 'p-xl': 'p-3',
14
+ 'px-xs': 'px-1',
15
+ 'px-sm': 'px-1.5',
16
+ 'px-md': 'px-2',
17
+ 'px-lg': 'px-2.5',
18
+ 'px-xl': 'px-3',
19
+ 'py-xs': 'py-1',
20
+ 'py-sm': 'py-1.5',
21
+ 'py-md': 'py-2',
22
+ 'py-lg': 'py-2.5',
23
+ 'py-xl': 'py-3',
24
+ // Gaps - small increments
25
+ 'gap-sm': 'gap-1',
26
+ 'gap-md': 'gap-1.5',
27
+ 'gap-lg': 'gap-2',
28
+ 'gap-xl': 'gap-2.5',
29
+ // Heights
30
+ 'h-header': 'h-10',
31
+ 'h-input': 'min-h-10',
32
+ // Text
33
+ 'text-base': 'text-sm',
34
+ 'text-title': 'text-xl',
35
+ 'text-subtitle': 'text-sm',
36
+ },
37
+ normal: {
38
+ // Padding - medium increments (1, 2, 3, 4, 5, 6)
39
+ 'p-xs': 'p-1',
40
+ 'p-sm': 'p-2',
41
+ 'p-md': 'p-3',
42
+ 'p-lg': 'p-4',
43
+ 'p-xl': 'p-6',
44
+ 'px-xs': 'px-1',
45
+ 'px-sm': 'px-2',
46
+ 'px-md': 'px-3',
47
+ 'px-lg': 'px-4',
48
+ 'px-xl': 'px-6',
49
+ 'py-xs': 'py-1',
50
+ 'py-sm': 'py-2',
51
+ 'py-md': 'py-3',
52
+ 'py-lg': 'py-4',
53
+ 'py-xl': 'py-6',
54
+ // Gaps - medium increments
55
+ 'gap-sm': 'gap-1.5',
56
+ 'gap-md': 'gap-2',
57
+ 'gap-lg': 'gap-3',
58
+ 'gap-xl': 'gap-4',
59
+ // Heights
60
+ 'h-header': 'h-12',
61
+ 'h-input': 'min-h-12',
62
+ // Text
63
+ 'text-base': 'text-base',
64
+ 'text-title': 'text-2xl',
65
+ 'text-subtitle': 'text-base',
66
+ },
67
+ spacious: {
68
+ // Padding - large increments (2, 3, 4, 6, 8, 10)
69
+ 'p-xs': 'p-2',
70
+ 'p-sm': 'p-3',
71
+ 'p-md': 'p-4',
72
+ 'p-lg': 'p-6',
73
+ 'p-xl': 'p-10',
74
+ 'px-xs': 'px-2',
75
+ 'px-sm': 'px-3',
76
+ 'px-md': 'px-4',
77
+ 'px-lg': 'px-6',
78
+ 'px-xl': 'px-10',
79
+ 'py-xs': 'py-2',
80
+ 'py-sm': 'py-3',
81
+ 'py-md': 'py-4',
82
+ 'py-lg': 'py-6',
83
+ 'py-xl': 'py-10',
84
+ // Gaps - large increments
85
+ 'gap-sm': 'gap-2',
86
+ 'gap-md': 'gap-3',
87
+ 'gap-lg': 'gap-4',
88
+ 'gap-xl': 'gap-6',
89
+ // Heights
90
+ 'h-header': 'h-14',
91
+ 'h-input': 'min-h-16',
92
+ // Text
93
+ 'text-base': 'text-lg',
94
+ 'text-title': 'text-3xl',
95
+ 'text-subtitle': 'text-lg',
96
+ },
97
+ } as const
98
+
99
+ type DensityToken = keyof (typeof densityClasses)['normal']
100
+
101
+ /**
102
+ * Hook to get density classes based on theme config
103
+ * Use: const d = useDensity(); then d('p-md') returns the appropriate padding class
104
+ */
105
+ export const useDensity = () => {
106
+ const { config } = useElements()
107
+ const density = config.theme?.density ?? 'normal'
108
+
109
+ return (token: DensityToken) => densityClasses[density][token]
110
+ }
@@ -0,0 +1,14 @@
1
+ import { useContext } from 'react'
2
+ import { ElementsContext } from '@/contexts/contexts'
3
+
4
+ /**
5
+ * @private Internal hook to access the ElementsContext
6
+ *
7
+ */
8
+ export const useElements = () => {
9
+ const context = useContext(ElementsContext)
10
+ if (!context) {
11
+ throw new Error('useElements must be used within a ElementsProvider')
12
+ }
13
+ return context
14
+ }
@@ -0,0 +1,20 @@
1
+ import { Dispatch, SetStateAction } from 'react'
2
+ import { useElements } from './useElements'
3
+
4
+ interface UseExpandedAPI {
5
+ expandable: boolean
6
+ isExpanded: boolean
7
+ defaultExpanded: boolean
8
+ setIsExpanded: Dispatch<SetStateAction<boolean>>
9
+ }
10
+
11
+ export const useExpanded = (): UseExpandedAPI => {
12
+ const { config, isExpanded, setIsExpanded } = useElements()
13
+ const defaultExpanded = config.modal?.defaultExpanded ?? false
14
+ return {
15
+ expandable: config.modal?.expandable ?? false,
16
+ isExpanded,
17
+ setIsExpanded,
18
+ defaultExpanded,
19
+ }
20
+ }
@@ -0,0 +1,73 @@
1
+ import { assert } from '@/lib/utils'
2
+ import { experimental_createMCPClient as createMCPClient } from '@ai-sdk/mcp'
3
+ import { useQuery, type UseQueryResult } from '@tanstack/react-query'
4
+ import { Auth } from './useAuth'
5
+
6
+ type MCPToolsResult = Awaited<
7
+ ReturnType<Awaited<ReturnType<typeof createMCPClient>>['tools']>
8
+ >
9
+
10
+ export function useMCPTools({
11
+ auth,
12
+ mcp,
13
+ environment,
14
+ }: {
15
+ auth: Auth
16
+ mcp: string | undefined
17
+ environment: Record<string, unknown>
18
+ }): UseQueryResult<MCPToolsResult, Error> {
19
+ const authQueryKey = Object.entries(auth.headers ?? {}).map(
20
+ (k, v) => `${k}:${v}`
21
+ )
22
+
23
+ const queryResult = useQuery({
24
+ queryKey: ['mcpTools', mcp, ...authQueryKey],
25
+ queryFn: async () => {
26
+ assert(!auth.isLoading, 'No auth found')
27
+ assert(mcp, 'No MCP URL found')
28
+
29
+ const mcpClient = await createMCPClient({
30
+ name: 'gram-elements-mcp-client',
31
+ transport: {
32
+ type: 'http',
33
+ url: mcp,
34
+ headers: {
35
+ ...transformEnvironmentToHeaders(environment ?? {}),
36
+ ...auth.headers,
37
+ },
38
+ },
39
+ })
40
+
41
+ const mcpTools = await mcpClient.tools()
42
+ return mcpTools
43
+ },
44
+ enabled: !auth.isLoading && !!mcp,
45
+ staleTime: Infinity,
46
+ gcTime: Infinity,
47
+ })
48
+
49
+ return queryResult
50
+ }
51
+
52
+ const HEADER_PREFIX = 'MCP-'
53
+
54
+ function transformEnvironmentToHeaders(environment: Record<string, unknown>) {
55
+ if (typeof environment !== 'object' || environment === null) {
56
+ return {}
57
+ }
58
+ return Object.entries(environment).reduce(
59
+ (acc, [key, value]) => {
60
+ // Normalize key: replace underscores with dashes
61
+ const normalizedKey = key.replace(/_/g, '-')
62
+
63
+ // Add MCP- prefix if it doesn't already have it
64
+ const headerKey = normalizedKey.startsWith(HEADER_PREFIX)
65
+ ? normalizedKey
66
+ : `${HEADER_PREFIX}${normalizedKey}`
67
+
68
+ acc[headerKey] = value as string
69
+ return acc
70
+ },
71
+ {} as Record<string, string>
72
+ )
73
+ }
@@ -0,0 +1,34 @@
1
+ import {
2
+ CodeHeaderProps,
3
+ SyntaxHighlighterProps,
4
+ } from '@assistant-ui/react-markdown'
5
+ import { ComponentType, useMemo } from 'react'
6
+ import { Plugin } from '@/types/plugins'
7
+
8
+ type ComponentsByLanguage =
9
+ | Record<
10
+ string,
11
+ {
12
+ CodeHeader?: ComponentType<CodeHeaderProps> | undefined
13
+ SyntaxHighlighter?: ComponentType<SyntaxHighlighterProps> | undefined
14
+ }
15
+ >
16
+ | undefined
17
+
18
+ export function useComponentsByLanguage(plugins: Plugin[]) {
19
+ return useMemo(() => {
20
+ return plugins.reduce((acc, plugin) => {
21
+ if (acc?.[plugin.language] && !plugin.overrideExisting) {
22
+ return acc
23
+ }
24
+ acc = {
25
+ ...acc,
26
+ [plugin.language]: {
27
+ CodeHeader: plugin.Header ?? (() => null),
28
+ SyntaxHighlighter: plugin.Component ?? undefined,
29
+ },
30
+ }
31
+ return acc
32
+ }, {} as ComponentsByLanguage)
33
+ }, [plugins])
34
+ }
@@ -0,0 +1,42 @@
1
+ import { Radius } from '@/types'
2
+ import { useElements } from './useElements'
3
+
4
+ /**
5
+ * Radius class mappings for different UI elements
6
+ */
7
+ const radiusClasses: Record<Radius, Record<RadiusSize, string>> = {
8
+ sharp: {
9
+ sm: 'rounded-sm',
10
+ md: 'rounded',
11
+ lg: 'rounded-md',
12
+ xl: 'rounded-lg',
13
+ full: 'rounded-lg',
14
+ },
15
+ soft: {
16
+ sm: 'rounded',
17
+ md: 'rounded-lg',
18
+ lg: 'rounded-xl',
19
+ xl: 'rounded-2xl',
20
+ full: 'rounded-full',
21
+ },
22
+ round: {
23
+ sm: 'rounded-lg',
24
+ md: 'rounded-xl',
25
+ lg: 'rounded-2xl',
26
+ xl: 'rounded-3xl',
27
+ full: 'rounded-full',
28
+ },
29
+ } as const
30
+
31
+ type RadiusSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'
32
+
33
+ /**
34
+ * Hook to get radius classes based on theme config
35
+ * Use: const r = useRadius(); then r('lg') returns the appropriate rounded class
36
+ */
37
+ export const useRadius = () => {
38
+ const { config } = useElements()
39
+ const radius = config.theme?.radius ?? 'soft'
40
+
41
+ return (size: RadiusSize) => radiusClasses[radius][size]
42
+ }
@@ -0,0 +1,38 @@
1
+ import { GetSessionFn } from '@/types'
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
3
+
4
+ /**
5
+ * Hook to fetch or retrieve the session token for the chat.
6
+ * @returns The session token string or null
7
+ */
8
+ export const useSession = ({
9
+ getSession,
10
+ projectSlug,
11
+ enabled,
12
+ }: {
13
+ getSession: GetSessionFn | null
14
+ projectSlug: string
15
+ enabled: boolean
16
+ }): string | null => {
17
+ const queryClient = useQueryClient()
18
+ const queryKey = ['chatSession', projectSlug]
19
+
20
+ const queryState = queryClient.getQueryState(queryKey)
21
+ const hasData = queryState?.data !== undefined
22
+ // Check if data is stale - with staleTime: Infinity, data never becomes stale
23
+ // but we check dataUpdatedAt to determine if we should refetch
24
+ const dataUpdatedAt = queryState?.dataUpdatedAt ?? 0
25
+ const staleTime = Infinity // Matches the staleTime in useQuery options
26
+ const isStale = hasData && Date.now() - dataUpdatedAt > staleTime
27
+ const shouldFetch = !hasData || isStale
28
+
29
+ const { data: fetchedSessionToken } = useQuery({
30
+ queryKey,
31
+ queryFn: () => getSession!({ projectSlug }),
32
+ enabled: enabled && shouldFetch && getSession !== null,
33
+ staleTime: Infinity, // Session tokens don't need to be refetched
34
+ gcTime: Infinity, // Keep in cache indefinitely
35
+ })
36
+
37
+ return fetchedSessionToken ?? null
38
+ }
@@ -0,0 +1,24 @@
1
+ import { useMemo } from 'react'
2
+ import { useElements } from './useElements'
3
+
4
+ /**
5
+ * Hook to get theme-related props including dark mode class
6
+ */
7
+ export const useThemeProps = () => {
8
+ const { config } = useElements()
9
+ const theme = config.theme ?? {}
10
+
11
+ return useMemo(() => {
12
+ const { colorScheme = 'light' } = theme
13
+
14
+ const isDark =
15
+ colorScheme === 'dark' ||
16
+ (colorScheme === 'system' &&
17
+ typeof window !== 'undefined' &&
18
+ window.matchMedia('(prefers-color-scheme: dark)').matches)
19
+
20
+ return {
21
+ className: isDark ? 'dark' : undefined,
22
+ } as const
23
+ }, [theme])
24
+ }
@@ -0,0 +1,16 @@
1
+ import { useContext } from 'react'
2
+ import { ToolApprovalContext } from '@/contexts/contexts'
3
+
4
+ /**
5
+ * Hook to access the tool approval context for managing human-in-the-loop
6
+ * tool execution approval.
7
+ */
8
+ export const useToolApproval = () => {
9
+ const context = useContext(ToolApprovalContext)
10
+ if (!context) {
11
+ throw new Error(
12
+ 'useToolApproval must be used within a ToolApprovalProvider'
13
+ )
14
+ }
15
+ return context
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ // Side-effect import to include CSS in build (consumers import via @gram-ai/elements/elements.css)
2
+ import './global.css'
3
+
4
+ // Context Providers
5
+ export { ElementsProvider as GramElementsProvider } from './contexts/ElementsProvider'
6
+ export { useElements as useGramElements } from './hooks/useElements'
7
+
8
+ // Core Components
9
+ export { Chat } from '@/components/Chat'
10
+
11
+ // Frontend Tools
12
+ export { defineFrontendTool } from './lib/tools'
13
+ export type { FrontendTool } from './lib/tools'
14
+
15
+ // Types
16
+ export type {
17
+ AttachmentsConfig,
18
+ COLOR_SCHEMES,
19
+ ColorScheme,
20
+ ComponentOverrides,
21
+ ComposerConfig,
22
+ DENSITIES,
23
+ Density,
24
+ Dimension,
25
+ Dimensions,
26
+ ElementsConfig,
27
+ GetSessionFn,
28
+ ModalConfig,
29
+ ModalTriggerPosition,
30
+ Model,
31
+ ModelConfig,
32
+ RADII,
33
+ Radius,
34
+ SidecarConfig,
35
+ Suggestion,
36
+ ThemeConfig,
37
+ ToolsConfig,
38
+ Variant,
39
+ VARIANTS,
40
+ WelcomeConfig,
41
+ } from './types'
42
+
43
+ export { MODELS } from './lib/models'
44
+
45
+ export type { Plugin } from './types/plugins'
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import type { ElementsConfig } from '@/types'
3
+
4
+ describe('getApiUrl', () => {
5
+ beforeEach(() => {
6
+ vi.resetModules()
7
+ })
8
+
9
+ async function loadGetApiUrl(gramApiUrl: string | undefined) {
10
+ vi.stubGlobal('__GRAM_API_URL__', gramApiUrl)
11
+ const { getApiUrl } = await import('./api')
12
+ return getApiUrl
13
+ }
14
+
15
+ it('uses config.api.url when set', async () => {
16
+ const getApiUrl = await loadGetApiUrl('https://env.example.com')
17
+ const config: ElementsConfig = {
18
+ projectSlug: 'test',
19
+ api: { url: 'https://config.example.com', UNSAFE_apiKey: 'test-key' },
20
+ }
21
+
22
+ expect(getApiUrl(config)).toBe('https://config.example.com')
23
+ })
24
+
25
+ it('falls back to __GRAM_API_URL__ when config.api.url is not set', async () => {
26
+ const getApiUrl = await loadGetApiUrl('https://env.example.com')
27
+ const config: ElementsConfig = {
28
+ projectSlug: 'test',
29
+ api: { UNSAFE_apiKey: 'test-key' },
30
+ }
31
+
32
+ expect(getApiUrl(config)).toBe('https://env.example.com')
33
+ })
34
+
35
+ it('falls back to __GRAM_API_URL__ when config.api is undefined', async () => {
36
+ const getApiUrl = await loadGetApiUrl('https://env.example.com')
37
+ const config: ElementsConfig = {
38
+ projectSlug: 'test',
39
+ }
40
+
41
+ expect(getApiUrl(config)).toBe('https://env.example.com')
42
+ })
43
+
44
+ it('falls back to default URL when both config.api.url and __GRAM_API_URL__ are not set', async () => {
45
+ const getApiUrl = await loadGetApiUrl('')
46
+ const config: ElementsConfig = {
47
+ projectSlug: 'test',
48
+ }
49
+
50
+ expect(getApiUrl(config)).toBe('https://app.getgram.ai')
51
+ })
52
+
53
+ it('falls back to default URL when __GRAM_API_URL__ is undefined', async () => {
54
+ const getApiUrl = await loadGetApiUrl(undefined)
55
+ const config: ElementsConfig = {
56
+ projectSlug: 'test',
57
+ }
58
+
59
+ expect(getApiUrl(config)).toBe('https://app.getgram.ai')
60
+ })
61
+
62
+ it('skips empty string config.api.url and uses __GRAM_API_URL__', async () => {
63
+ const getApiUrl = await loadGetApiUrl('https://env.example.com')
64
+ const config: ElementsConfig = {
65
+ projectSlug: 'test',
66
+ api: { url: '', UNSAFE_apiKey: 'test-key' },
67
+ }
68
+
69
+ expect(getApiUrl(config)).toBe('https://env.example.com')
70
+ })
71
+
72
+ it('removes trailing slashes from the URL', async () => {
73
+ const getApiUrl = await loadGetApiUrl('')
74
+ const config: ElementsConfig = {
75
+ projectSlug: 'test',
76
+ api: { url: 'https://config.example.com///', UNSAFE_apiKey: 'test-key' },
77
+ }
78
+
79
+ expect(getApiUrl(config)).toBe('https://config.example.com')
80
+ })
81
+
82
+ it('removes trailing slashes from __GRAM_API_URL__', async () => {
83
+ const getApiUrl = await loadGetApiUrl('https://env.example.com//')
84
+ const config: ElementsConfig = {
85
+ projectSlug: 'test',
86
+ }
87
+
88
+ expect(getApiUrl(config)).toBe('https://env.example.com')
89
+ })
90
+ })
package/src/lib/api.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { ElementsConfig } from '@/types'
2
+
3
+ export function getApiUrl(config: ElementsConfig): string {
4
+ // The api.url in the config should take precedence over the __GRAM_API_URL__ environment variable
5
+ // because it is a user-defined override
6
+ const apiURL = config.api?.url || __GRAM_API_URL__ || 'https://app.getgram.ai'
7
+ return apiURL.replace(/\/+$/, '') // Remove trailing slashes
8
+ }
@@ -0,0 +1,10 @@
1
+ import { ApiKeyAuthConfig, AuthConfig } from '@/types'
2
+
3
+ /**
4
+ * Checks if the auth config is an API key auth config
5
+ */
6
+ export function isApiKeyAuth(
7
+ auth: AuthConfig | undefined
8
+ ): auth is ApiKeyAuthConfig {
9
+ return !!auth && 'UNSAFE_apiKey' in auth
10
+ }
@@ -0,0 +1 @@
1
+ export const EASE_OUT_QUINT = [0.23, 1, 0.32, 1] as const
@@ -0,0 +1,14 @@
1
+ // humanize tool name:
2
+ // - split camel case into words
3
+ // - capitalize first letter of each word
4
+ // - remove hyphens / underscores
5
+ // - title case the string
6
+ export function humanizeToolName(toolName: string): string {
7
+ return toolName
8
+ .replace(/[-_]/g, ' ') // Replace hyphens and underscores with spaces
9
+ .split(/(?=[A-Z])/) // Split on camelCase boundaries
10
+ .join(' ') // Join with spaces
11
+ .split(/\s+/) // Split on any whitespace to normalize
12
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) // Title case each word
13
+ .join(' ')
14
+ }
@@ -0,0 +1,22 @@
1
+ // List of openrouter models available to the user
2
+ // This list should be updated to match the model whitelist on the backend side.
3
+ export const MODELS = [
4
+ 'anthropic/claude-sonnet-4.5',
5
+ 'anthropic/claude-haiku-4.5',
6
+ 'anthropic/claude-sonnet-4',
7
+ 'anthropic/claude-opus-4.5',
8
+ 'openai/gpt-4o',
9
+ 'openai/gpt-4o-mini',
10
+ 'openai/gpt-5.1-codex',
11
+ 'openai/gpt-5',
12
+ 'openai/gpt-5.1',
13
+ 'openai/gpt-4.1',
14
+ 'anthropic/claude-3.7-sonnet',
15
+ 'anthropic/claude-opus-4',
16
+ 'google/gemini-2.5-pro-preview',
17
+ 'google/gemini-3-pro-preview',
18
+ 'moonshotai/kimi-k2',
19
+ 'mistralai/mistral-medium-3',
20
+ 'mistralai/mistral-medium-3.1',
21
+ 'mistralai/codestral-2501',
22
+ ] as const