diff --git a/.changeset/strong-walls-add.md b/.changeset/strong-walls-add.md new file mode 100644 index 000000000..0626a9b9f --- /dev/null +++ b/.changeset/strong-walls-add.md @@ -0,0 +1,10 @@ +--- +"@gram-ai/elements": minor +--- + +Gram Elements is a library of UI primitives for building chat-like experiences for MCP Servers. + +The first release of Gram Elements includes: + +- An all-in-one `` component that encapsulates the entire chat lifecycle, including built-in support for tool calling and streaming responses. +- A powerful configuration framework to refine the chat experience, including different layouts, theming, and much more. diff --git a/.github/filters.yaml b/.github/filters.yaml index 701230bc9..27ecb121d 100644 --- a/.github/filters.yaml +++ b/.github/filters.yaml @@ -18,4 +18,7 @@ cli: - 'server/gen/**' tsframework: - - 'ts-framework/**' \ No newline at end of file + - 'ts-framework/**' + +elements: + - 'elements/**' \ No newline at end of file diff --git a/.github/workflows/elements-docs.yml b/.github/workflows/elements-docs.yml new file mode 100644 index 000000000..0cbdd662e --- /dev/null +++ b/.github/workflows/elements-docs.yml @@ -0,0 +1,59 @@ +name: Generate Elements TypeDoc Documentation + +on: + push: + branches: + - main + paths: + - "elements/**" + workflow_dispatch: # Allow manual triggering + +permissions: + contents: write + +jobs: + generate-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0 + with: + fetch-depth: 0 + + - name: Setup Mise + uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3.5.1 + with: + install: true + cache: true + env: false + + - name: Prepare GitHub Actions environment + run: mise run github + + - name: Cache PNPM + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + with: + key: ${{ env.GH_CACHE_PNPM_KEY }} + restore-keys: | + ${{ env.GH_CACHE_PNPM_KEY }} + ${{ env.GH_CACHE_PNPM_KEY_PARTIAL }} + path: | + ${{ env.PNPM_STORE_PATH }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate TypeDoc + working-directory: elements + run: pnpm run docs + + - name: Commit and push if changed + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git add elements/docs/ + git diff --quiet && git diff --staged --quiet || (git commit -m "docs: update TypeDoc API documentation [skip ci]" && git push) + + - name: Prune PNPM store + if: success() + run: pnpm store prune diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 964a4c3d3..5ffa14b1d 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -28,6 +28,7 @@ jobs: cli: ${{ steps.gates.outputs.cli }} functions: ${{ steps.gates.outputs.functions }} tsframework: ${{ steps.gates.outputs.tsframework }} + elements: ${{ steps.gates.outputs.elements }} steps: - name: Checkout source code uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0 @@ -74,6 +75,13 @@ jobs: echo "TypeScript framework jobs will be skipped." fi + if [[ "${{ steps.filter.outputs.elements }}" == "true" || "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "elements=true" >> $GITHUB_OUTPUT + echo "Elements jobs will run." + else + echo "Elements jobs will be skipped." + fi + docker-build-server: runs-on: ubicloud-standard-4 needs: [changes, server-build-lint] @@ -867,3 +875,61 @@ jobs: - name: Prune PNPM store if: ${{ needs.changes.outputs.tsframework == 'true' && success() }} run: pnpm store prune + + elements-build-lint: + runs-on: ubicloud-standard-4 + needs: changes + steps: + - name: Skip if no elements changes exist + if: ${{ needs.changes.outputs.elements != 'true' }} + run: echo "No elements changes detected — skipping elements-build-lint." + + - name: Checkout + if: ${{ needs.changes.outputs.elements == 'true' }} + uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0 + + - name: Setup Mise + if: ${{ needs.changes.outputs.elements == 'true' }} + uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3.5.1 + with: + install: true + cache: true + env: false + + - name: Prepare GitHub Actions environment + if: ${{ needs.changes.outputs.elements == 'true' }} + run: mise run github + + - name: Cache PNPM + if: ${{ needs.changes.outputs.elements == 'true' }} + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + with: + key: ${{ env.GH_CACHE_PNPM_KEY }} + restore-keys: | + ${{ env.GH_CACHE_PNPM_KEY }} + ${{ env.GH_CACHE_PNPM_KEY_PARTIAL }} + path: | + ${{ env.PNPM_STORE_PATH }} + + - name: Install dependencies + if: ${{ needs.changes.outputs.elements == 'true' }} + run: pnpm install --frozen-lockfile + + - name: Build + if: ${{ needs.changes.outputs.elements == 'true' }} + run: pnpm build + working-directory: elements + + - name: Lint + if: ${{ needs.changes.outputs.elements == 'true' }} + run: pnpm lint + working-directory: elements + + - name: Type check + if: ${{ needs.changes.outputs.elements == 'true' }} + run: pnpm type-check + working-directory: elements + + - name: Prune PNPM store + if: ${{ needs.changes.outputs.elements == 'true' && success() }} + run: pnpm store prune diff --git a/elements/.env.local.template b/elements/.env.local.template new file mode 100644 index 000000000..6aaf47554 --- /dev/null +++ b/elements/.env.local.template @@ -0,0 +1 @@ +GRAM_API_KEY=xxx \ No newline at end of file diff --git a/elements/.gitignore b/elements/.gitignore new file mode 100644 index 000000000..2b87e613f --- /dev/null +++ b/elements/.gitignore @@ -0,0 +1,18 @@ +dist +node_modules +*storybook.log +storybook-static +*.env +.env.* + +# Allow the local environment template file +!.env.local.template + +# mise local overrides +.mise.local.toml + +# Ignore tgz files +*.tgz + +# Ignore .DS_Store +.DS_Store \ No newline at end of file diff --git a/elements/.npmrc b/elements/.npmrc new file mode 100644 index 000000000..4241b534e --- /dev/null +++ b/elements/.npmrc @@ -0,0 +1 @@ +--auto-install-peers=true \ No newline at end of file diff --git a/elements/.prettierrc.json b/elements/.prettierrc.json new file mode 100644 index 000000000..8c13594e5 --- /dev/null +++ b/elements/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/elements/.storybook/GlobalDecorator.tsx b/elements/.storybook/GlobalDecorator.tsx new file mode 100644 index 000000000..34219d5d0 --- /dev/null +++ b/elements/.storybook/GlobalDecorator.tsx @@ -0,0 +1,65 @@ +import React, { useMemo } from 'react' +import { ElementsProvider } from '../src/contexts/ElementsProvider' +import { ElementsConfig } from '../src/types' +import merge from 'lodash.merge' +import { recommended } from '../src/plugins' + +interface ElementsDecoratorProps { + children: React.ReactNode + // Partial so stories can override only what they need + config?: Partial +} + +const DEFAULT_ELEMENTS_CONFIG: ElementsConfig = { + projectSlug: 'adamtest', + mcp: 'https://chat.speakeasy.com/mcp/speakeasy-team-my_api', + variant: 'widget', + welcome: { + title: 'Hello there!', + subtitle: 'How can I help you today?', + suggestions: [ + { + title: 'Discover available tools', + label: 'Find out what tools are available', + action: 'Call all tools available', + }, + ], + }, + composer: { + placeholder: 'Ask me anything...', + attachments: true, + }, + modal: { + defaultOpen: true, + expandable: true, + defaultExpanded: true, + title: 'Gram Elements Demo', + }, + tools: { + expandToolGroupsByDefault: true, + }, + plugins: recommended, +} + +/** + * Global decorator that wraps all stories in the AssistantRuntimeProvider, + * which provides the chat runtime to the story. + * Note: This assumes that all stories require a chat runtime, but we move back to + * per story decorator in the future. + * @param children - The children to render. + * @returns + */ +export const ElementsDecorator: React.FC = ({ + children, + config, +}) => { + const finalConfig = useMemo( + () => merge({}, DEFAULT_ELEMENTS_CONFIG, config ?? {}), + [config] + ) + return ( + +
{children}
+
+ ) +} diff --git a/elements/.storybook/main.ts b/elements/.storybook/main.ts new file mode 100644 index 000000000..842c11f44 --- /dev/null +++ b/elements/.storybook/main.ts @@ -0,0 +1,15 @@ +import type { StorybookConfig } from '@storybook/react-vite' + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-docs'], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: './.storybook/vite.config.mts', + }, + }, + }, +} +export default config diff --git a/elements/.storybook/preview.tsx b/elements/.storybook/preview.tsx new file mode 100644 index 000000000..d3f4705c4 --- /dev/null +++ b/elements/.storybook/preview.tsx @@ -0,0 +1,28 @@ +import type { Preview } from '@storybook/react-vite' +import { ElementsDecorator } from './GlobalDecorator' +import React from 'react' +import '../src/global.css' + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + decorators: [ + (Story, context) => { + // Stories can override config via parameters.elements + const elementsParams = context.parameters.elements ?? {} + return ( + + + + ) + }, + ], +} + +export default preview diff --git a/elements/.storybook/vite.config.mts b/elements/.storybook/vite.config.mts new file mode 100644 index 000000000..948c97ede --- /dev/null +++ b/elements/.storybook/vite.config.mts @@ -0,0 +1,25 @@ +import { defineConfig, ViteDevServer } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createElementsServerHandlers } from '../src/server' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const apiMiddlewarePlugin = () => ({ + name: 'chat-api-middleware', + configureServer(server: ViteDevServer) { + const handlers = createElementsServerHandlers() + server.middlewares.use('/chat/completions', handlers.chat) + }, +}) + +export default defineConfig({ + plugins: [react(), tailwindcss(), apiMiddlewarePlugin()], + resolve: { + alias: { + '@': resolve(__dirname, '../src'), + }, + }, +}) diff --git a/elements/CONTRIBUTING.md b/elements/CONTRIBUTING.md new file mode 100644 index 000000000..fce29e3db --- /dev/null +++ b/elements/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing to `@gram-ai/elements` + +## Setup + +Ensure that you have your Gram API key setup in your `.env.local` file (rename the template). + +Then simply run the Storybook which has a preconfigured dev middleware for chat completions so that you can test the components against a real LLM: + +```bash +pnpm storybook +``` + +## Third party dependencies + +If adding a heavy dependency, please make sure you mark it as a peer in `peerDependencies`, mark it as optional in `peerDependenciesMeta` (so the server package doesn't require it), and mark it as an `external` in the vite configuration. + +## Documentation + +We use TypeDoc to automatically generate markdown documentation via a github action - the documentation is generated from the TypeScript types. However you can generate documentation locally by following the guide below: + +```bash +# Generate documentation +pnpm run docs + +# Watch mode (regenerate on changes) +pnpm run docs:watch +``` diff --git a/elements/README.md b/elements/README.md new file mode 100644 index 000000000..200feb894 --- /dev/null +++ b/elements/README.md @@ -0,0 +1,230 @@ +# `@gram-ai/elements` + +Elements is a library built for the agentic age. We provide customizable and elegant UI primitives for building chat-like experiences for MCP Servers. Works best with Gram MCP, but also supports connecting any MCP Server. + +## Setup + +### Package Exports + +This package provides two separate exports with different dependencies: + +- **`@gram-ai/elements`** - React UI components (requires React and related dependencies) +- **`@gram-ai/elements/server`** - Server-side handlers (does NOT require React) + +### Frontend Setup + +First ensure that you have installed the required peer dependencies: + +```bash +pnpm add react react-dom @assistant-ui/react @assistant-ui/react-markdown motion remark-gfm zustand vega shiki +``` + +Then install Elements: + +```bash +pnpm add @gram-ai/elements +``` + +### Backend Setup + +If you're only using the server handlers (`@gram-ai/elements/server`), you can install without React: + +```bash +pnpm add @gram-ai/elements +``` + +> **Note:** Your package manager may show peer dependency warnings for React packages. These warnings are safe to ignore when using only `/server` exports, as React dependencies are marked as optional. + +## Setting up your backend + +At the moment, we provide a set of handlers via the `@gram-ai/elements/server` package that you can use to automatically setup your backend for usage with Gram Elements. The example below demonstrates the setup for Express: + +```typescript +import { createElementsServerHandlers } from '@gram-ai/elements/server' +import express from 'express' + +const handlers = createElementsServerHandlers() +const app = express() + +app.use(express.json()) + +app.post('/chat/completions', handlers.chat) +``` + +You will need to add an environment variable to your backend and make it available to the process: + +``` +GRAM_API_KEY=xxx +``` + +This will enable your backend chat endpoint to talk to our servers securely. + +## Setting up your frontend + +`@gram-ai/elements` requires that you wrap your React tree with our context provider and reference our CSS: + +```jsx +import { GramElementsProvider, Chat, type ElementsConfig } from '@gram-ai/elements' +import '@gram-ai/elements/elements.css' + +// Please fill out projectSlug and mcp +const config: ElementsConfig = { + projectSlug: 'xxx', + mcp: 'https://app.getgram.ai/mcp/{your_slug}', + // Points to your backend endpoint + chatEndpoint: '/chat/completions', + variant: 'widget', + welcome: { + title: 'Hello!', + subtitle: 'How can I help you today?', + }, + composer: { + placeholder: 'Ask me anything...', + }, + modal: { + defaultOpen: true, + }, +} + +export const App = () => { + return ( + + + + ) +} +``` + +## Configuration + +For complete configuration options and TypeScript type definitions, see the [API documentation](./docs/interfaces/ElementsConfig.md). + +### Quick Configuration Example + +```typescript +import { GramElementsProvider, Chat, type ElementsConfig } from '@gram-ai/elements' +import '@gram-ai/elements/elements.css' + +const config: ElementsConfig = { + projectSlug: 'your-project', + mcp: 'https://app.getgram.ai/mcp/your-mcp-slug', + chatEndpoint: '/chat/completions', + variant: 'widget', // 'widget' | 'sidecar' | 'standalone' + welcome: { + title: 'Hello!', + subtitle: 'How can I help you today?', + }, +} + +export const App = () => { + return ( + + + + ) +} +``` + +## Plugins + +Plugins extend the Gram Elements library with custom rendering capabilities for specific content types. They allow you to transform markdown code blocks into rich, interactive visualizations and components. + +### How Plugins Work + +When you add a plugin: + +1. The plugin extends the system prompt with instructions for the LLM +2. The LLM returns code blocks marked with the plugin's language identifier +3. The plugin's custom component renders the code block content + +For example, the built-in chart plugin instructs the LLM to return Vega specifications for visualizations, which are then rendered as interactive charts. + +### Using Recommended Plugins + +Gram Elements includes a set of recommended plugins that you can use out of the box: + +```typescript +import { GramElementsProvider, Chat, type ElementsConfig } from '@gram-ai/elements' +import { recommended } from '@gram-ai/elements/plugins' +import '@gram-ai/elements/elements.css' + +const config: ElementsConfig = { + projectSlug: 'my-project', + mcp: 'https://app.getgram.ai/mcp/my-mcp-slug', + welcome: { + title: 'Hello!', + subtitle: 'How can I help you today?', + }, + // Add all recommended plugins + plugins: recommended, +} + +export const App = () => { + return ( + + + + ) +} +``` + +#### Available Recommended Plugins + +- **`chart`** - Renders Vega chart specifications as interactive visualizations + +### Using Individual Plugins + +You can also import and use plugins individually: + +```typescript +import { chart } from '@gram-ai/elements/plugins' + +const config: ElementsConfig = { + // ... other config + plugins: [chart], +} +``` + +### Using Custom Plugins + +You can create your own custom plugins to add specialized rendering capabilities: + +```typescript +import { GramElementsProvider, Chat, type ElementsConfig } from '@gram-ai/elements' +import { chart } from '@gram-ai/elements/plugins' +import { myCustomPlugin } from './plugins/myCustomPlugin' +import '@gram-ai/elements/elements.css' + +const config: ElementsConfig = { + projectSlug: 'my-project', + mcp: 'https://app.getgram.ai/mcp/my-mcp-slug', + welcome: { + title: 'Hello!', + subtitle: 'How can I help you today?', + }, + // Combine built-in and custom plugins + plugins: [chart, myCustomPlugin], +} + +export const App = () => { + return ( + + + + ) +} +``` + +### Creating Custom Plugins + +To create your own plugins, see the comprehensive [Plugin Development Guide](./src/plugins/README.md). The guide covers: + +- Plugin architecture and interface +- Step-by-step tutorial for creating plugins +- Best practices and common patterns +- Real-world examples +- Troubleshooting tips + +## Contributing + +We welcome pull requests to Elements. Please read the contributing guide. diff --git a/elements/components.json b/elements/components.json new file mode 100644 index 000000000..ed0f0d3b7 --- /dev/null +++ b/elements/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/global.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@assistant-ui": "https://r.assistant-ui.com/{name}.json" + } +} diff --git a/elements/docs/README.md b/elements/docs/README.md new file mode 100644 index 000000000..cdcd186c9 --- /dev/null +++ b/elements/docs/README.md @@ -0,0 +1,37 @@ +**@gram-ai/elements v0.0.1** + +*** + +# @gram-ai/elements v0.0.1 + +## Interfaces + +- [ElementsConfig](interfaces/ElementsConfig.md) +- [ModelConfig](interfaces/ModelConfig.md) +- [ThemeConfig](interfaces/ThemeConfig.md) +- [ToolsConfig](interfaces/ToolsConfig.md) +- [WelcomeConfig](interfaces/WelcomeConfig.md) +- [Suggestion](interfaces/Suggestion.md) +- [Dimensions](interfaces/Dimensions.md) +- [Dimension](interfaces/Dimension.md) +- [ModalConfig](interfaces/ModalConfig.md) +- [ComposerConfig](interfaces/ComposerConfig.md) +- [AttachmentsConfig](interfaces/AttachmentsConfig.md) +- [SidecarConfig](interfaces/SidecarConfig.md) +- [Plugin](interfaces/Plugin.md) + +## Type Aliases + +- [FrontendTool](type-aliases/FrontendTool.md) +- [Variant](type-aliases/Variant.md) +- [Model](type-aliases/Model.md) +- [Density](type-aliases/Density.md) +- [ColorScheme](type-aliases/ColorScheme.md) +- [Radius](type-aliases/Radius.md) +- [ModalTriggerPosition](type-aliases/ModalTriggerPosition.md) + +## Functions + +- [Chat](functions/Chat.md) +- [GramElementsProvider](functions/GramElementsProvider.md) +- [defineFrontendTool](functions/defineFrontendTool.md) diff --git a/elements/docs/functions/Chat.md b/elements/docs/functions/Chat.md new file mode 100644 index 000000000..22eff088f --- /dev/null +++ b/elements/docs/functions/Chat.md @@ -0,0 +1,13 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / Chat + +# Function: Chat() + +> **Chat**(): `Element` + +## Returns + +`Element` diff --git a/elements/docs/functions/GramElementsProvider.md b/elements/docs/functions/GramElementsProvider.md new file mode 100644 index 000000000..940faa1c4 --- /dev/null +++ b/elements/docs/functions/GramElementsProvider.md @@ -0,0 +1,19 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / GramElementsProvider + +# Function: GramElementsProvider() + +> **GramElementsProvider**(`__namedParameters`): `Element` + +## Parameters + +### \_\_namedParameters + +`ElementsProviderProps` + +## Returns + +`Element` diff --git a/elements/docs/functions/defineFrontendTool.md b/elements/docs/functions/defineFrontendTool.md new file mode 100644 index 000000000..d6763acd1 --- /dev/null +++ b/elements/docs/functions/defineFrontendTool.md @@ -0,0 +1,35 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / defineFrontendTool + +# Function: defineFrontendTool() + +> **defineFrontendTool**\<`TArgs`, `TResult`\>(`tool`, `name`): [`FrontendTool`](../type-aliases/FrontendTool.md)\<`TArgs`, `TResult`\> + +Make a frontend tool + +## Type Parameters + +### TArgs + +`TArgs` *extends* `Record`\<`string`, `unknown`\> + +### TResult + +`TResult` + +## Parameters + +### tool + +`Tool` + +### name + +`string` + +## Returns + +[`FrontendTool`](../type-aliases/FrontendTool.md)\<`TArgs`, `TResult`\> diff --git a/elements/docs/interfaces/AttachmentsConfig.md b/elements/docs/interfaces/AttachmentsConfig.md new file mode 100644 index 000000000..4458ca7d2 --- /dev/null +++ b/elements/docs/interfaces/AttachmentsConfig.md @@ -0,0 +1,54 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / AttachmentsConfig + +# Interface: AttachmentsConfig + +AttachmentsConfig provides fine-grained control over file attachments. +Inspired by OpenAI ChatKit's attachment options. + +Note: not yet implemented on the Gram side. + +## Properties + +### accept? + +> `optional` **accept**: `string`[] + +Accepted file types. Can be MIME types or file extensions. + +#### Example + +```ts +['image/*', '.pdf', '.docx'] +``` + +*** + +### maxCount? + +> `optional` **maxCount**: `number` + +Maximum number of files that can be attached at once. + +#### Default + +```ts +10 +``` + +*** + +### maxSize? + +> `optional` **maxSize**: `number` + +Maximum file size in bytes. + +#### Default + +```ts +104857600 (100MB) +``` diff --git a/elements/docs/interfaces/ComposerConfig.md b/elements/docs/interfaces/ComposerConfig.md new file mode 100644 index 000000000..225a54dd6 --- /dev/null +++ b/elements/docs/interfaces/ComposerConfig.md @@ -0,0 +1,38 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / ComposerConfig + +# Interface: ComposerConfig + +## Properties + +### placeholder? + +> `optional` **placeholder**: `string` + +The placeholder text for the composer input. + +#### Default + +```ts +'Send a message...' +``` + +*** + +### attachments? + +> `optional` **attachments**: `boolean` \| [`AttachmentsConfig`](AttachmentsConfig.md) + +Configuration for file attachments in the composer. +Set to `false` to disable attachments entirely. +Set to `true` for default attachment behavior. +Or provide an object for fine-grained control. + +#### Default + +```ts +true +``` diff --git a/elements/docs/interfaces/Dimension.md b/elements/docs/interfaces/Dimension.md new file mode 100644 index 000000000..5a80674ee --- /dev/null +++ b/elements/docs/interfaces/Dimension.md @@ -0,0 +1,25 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / Dimension + +# Interface: Dimension + +## Properties + +### width + +> **width**: `string` \| `number` + +*** + +### height + +> **height**: `string` \| `number` + +*** + +### maxHeight? + +> `optional` **maxHeight**: `string` \| `number` diff --git a/elements/docs/interfaces/Dimensions.md b/elements/docs/interfaces/Dimensions.md new file mode 100644 index 000000000..32718ef4b --- /dev/null +++ b/elements/docs/interfaces/Dimensions.md @@ -0,0 +1,27 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / Dimensions + +# Interface: Dimensions + +## Properties + +### default + +> **default**: [`Dimension`](Dimension.md) + +*** + +### expanded? + +> `optional` **expanded**: `object` + +#### width + +> **width**: `string` \| `number` + +#### height + +> **height**: `string` \| `number` diff --git a/elements/docs/interfaces/ElementsConfig.md b/elements/docs/interfaces/ElementsConfig.md new file mode 100644 index 000000000..48d9243ec --- /dev/null +++ b/elements/docs/interfaces/ElementsConfig.md @@ -0,0 +1,318 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / ElementsConfig + +# Interface: ElementsConfig + +The top level configuration object for the Elements library. + +## Example + +```ts +const config: ElementsConfig = { + systemPrompt: 'You are a helpful assistant.', +} +``` + +## Properties + +### systemPrompt? + +> `optional` **systemPrompt**: `string` + +The system prompt to use for the Elements library. + +*** + +### plugins? + +> `optional` **plugins**: [`Plugin`](Plugin.md)[] + +Any plugins to use for the Elements library. + +#### Default + +```ts +import { recommended } from '@gram-ai/elements/plugins' +``` + +*** + +### components? + +> `optional` **components**: `ComponentOverrides` + +Override the default components used by the Elements library. + +The available components are: +- Composer +- UserMessage +- EditComposer +- AssistantMessage +- ThreadWelcome +- Text +- Image +- ToolFallback +- Reasoning +- ReasoningGroup +- ToolGroup + +To understand how to override these components, please consult the [assistant-ui documentation](https://www.assistant-ui.com/docs). + +#### Example + +```ts +const config: ElementsConfig = { + components: { + Composer: CustomComposerComponent, + }, +} +``` + +*** + +### projectSlug + +> **projectSlug**: `string` + +The project slug to use for the Elements library. + +Your project slug can be found within the Gram dashboard. + +#### Example + +```ts +const config: ElementsConfig = { + projectSlug: 'your-project-slug', +} +``` + +*** + +### mcp + +> **mcp**: `string` + +The Gram Server URL to use for the Elements library. +Can be retrieved from https://app.getgram.ai/{team}/{project}/mcp/{mcp_slug} + +Note: This config option will likely change in the future + +#### Example + +```ts +const config: ElementsConfig = { + mcp: 'https://app.getgram.ai/mcp/your-mcp-slug', +} +``` + +*** + +### chatEndpoint? + +> `optional` **chatEndpoint**: `string` + +The path of your backend's chat endpoint. + +#### Default + +```ts +'/chat/completions' +``` + +#### Example + +```ts +const config: ElementsConfig = { + chatEndpoint: '/my-custom-chat-endpoint', +} +``` + +*** + +### environment? + +> `optional` **environment**: `Record`\<`string`, `unknown`\> + +Custom environment variable overrides for the Elements library. +Will be used to override the environment variables for the MCP server. + +For more documentation on passing through different kinds of environment variables, including bearer tokens, see the [Gram documentation](https://www.speakeasy.com/docs/gram/host-mcp/public-private-servers#pass-through-authentication). + +*** + +### variant? + +> `optional` **variant**: `"widget"` \| `"sidecar"` \| `"standalone"` + +The layout variant for the chat interface. + +- `widget`: A popup modal anchored to the bottom-right corner (default) +- `sidecar`: A side panel that slides in from the right edge of the screen +- `standalone`: A full-page chat experience + +#### Default + +```ts +'widget' +``` + +*** + +### model? + +> `optional` **model**: [`ModelConfig`](ModelConfig.md) + +LLM model configuration. + +#### Example + +```ts +const config: ElementsConfig = { + model: { + availableModels: ['gpt-4o-mini', 'gpt-4o'], + showModelPicker: true, + }, +} +``` + +*** + +### theme? + +> `optional` **theme**: [`ThemeConfig`](ThemeConfig.md) + +Visual appearance configuration options. +Similar to OpenAI ChatKit's ThemeOption.\ + +#### Example + +```ts +const config: ElementsConfig = { + theme: { + colorScheme: 'dark', + density: 'compact', + radius: 'round', + }, +} +``` + +*** + +### welcome + +> **welcome**: [`WelcomeConfig`](WelcomeConfig.md) + +The configuration for the welcome message and initial suggestions. + +#### Example + +```ts +const config: ElementsConfig = { + welcome: { + title: 'Welcome to the chat', + subtitle: 'This is a chat with a bot', + suggestions: [ + { title: 'Suggestion 1', label: 'Suggestion 1', action: 'action1' }, + ], + }, +} +``` + +*** + +### composer? + +> `optional` **composer**: [`ComposerConfig`](ComposerConfig.md) + +The configuration for the composer. + +#### Example + +```ts +const config: ElementsConfig = { + composer: { + placeholder: 'Enter your message...', + }, +} +``` + +*** + +### modal? + +> `optional` **modal**: [`ModalConfig`](ModalConfig.md) + +The configuration for the modal window. +Does not apply if variant is 'standalone'. + +#### Example + +```ts +const config: ElementsConfig = { + modal: { + title: 'Chat', + position: 'bottom-right', + }, + expandable: true, + defaultExpanded: false, + dimensions: { + width: 400, + height: 600, + }, +} +``` + +*** + +### sidecar? + +> `optional` **sidecar**: [`SidecarConfig`](SidecarConfig.md) + +The configuration for the sidecar panel. +Only applies if variant is 'sidecar'. + +#### Example + +```ts +const config: ElementsConfig = { + sidecar: { + title: 'Chat', + }, + expandable: true, + defaultExpanded: false, + dimensions: { + width: 400, + height: 600, + }, +} +``` + +*** + +### tools? + +> `optional` **tools**: [`ToolsConfig`](ToolsConfig.md) + +The configuration for the tools. + +#### Example + +```ts +const config: ElementsConfig = { + tools: { + expandToolGroupsByDefault: true, + }, + components: { + ToolFallback: CustomToolFallbackComponent, + }, + frontendTools: { + fetchUrl: FetchTool, + }, + components: { + fetchUrl: FetchToolComponent, + }, +} +``` diff --git a/elements/docs/interfaces/ModalConfig.md b/elements/docs/interfaces/ModalConfig.md new file mode 100644 index 000000000..b29b94698 --- /dev/null +++ b/elements/docs/interfaces/ModalConfig.md @@ -0,0 +1,108 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / ModalConfig + +# Interface: ModalConfig + +## Extends + +- `ExpandableConfig` + +## Properties + +### expandable? + +> `optional` **expandable**: `boolean` + +Whether the modal or sidecar can be expanded + +#### Inherited from + +`ExpandableConfig.expandable` + +*** + +### defaultExpanded? + +> `optional` **defaultExpanded**: `boolean` + +Whether the modal or sidecar should be expanded by default. + +#### Default + +```ts +false +``` + +#### Inherited from + +`ExpandableConfig.defaultExpanded` + +*** + +### dimensions? + +> `optional` **dimensions**: [`Dimensions`](Dimensions.md) + +The dimensions for the modal or sidecar window. + +#### Inherited from + +`ExpandableConfig.dimensions` + +*** + +### defaultOpen? + +> `optional` **defaultOpen**: `boolean` + +Whether to open the modal window by default. + +*** + +### title? + +> `optional` **title**: `string` + +The title displayed in the modal header. + +#### Default + +```ts +'Chat' +``` + +*** + +### position? + +> `optional` **position**: [`ModalTriggerPosition`](../type-aliases/ModalTriggerPosition.md) + +The position of the modal trigger + +#### Default + +```ts +'bottom-right' +``` + +*** + +### icon()? + +> `optional` **icon**: (`state`) => `ReactNode` + +The icon to use for the modal window. +Receives the current state of the modal window. + +#### Parameters + +##### state + +`"open"` | `"closed"` | `undefined` + +#### Returns + +`ReactNode` diff --git a/elements/docs/interfaces/ModelConfig.md b/elements/docs/interfaces/ModelConfig.md new file mode 100644 index 000000000..7f99cff59 --- /dev/null +++ b/elements/docs/interfaces/ModelConfig.md @@ -0,0 +1,25 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / ModelConfig + +# Interface: ModelConfig + +ModelConfig is used to configure model support in the Elements library. + +## Properties + +### showModelPicker? + +> `optional` **showModelPicker**: `boolean` + +Whether to show the model picker in the composer. + +*** + +### defaultModel? + +> `optional` **defaultModel**: `"anthropic/claude-sonnet-4.5"` \| `"anthropic/claude-haiku-4.5"` \| `"anthropic/claude-sonnet-4"` \| `"anthropic/claude-opus-4.5"` \| `"openai/gpt-4o"` \| `"openai/gpt-4o-mini"` \| `"openai/gpt-5.1-codex"` \| `"openai/gpt-5"` \| `"openai/gpt-5.1"` \| `"openai/gpt-4.1"` \| `"anthropic/claude-3.7-sonnet"` \| `"anthropic/claude-opus-4"` \| `"google/gemini-2.5-pro-preview"` \| `"google/gemini-3-pro-preview"` \| `"moonshotai/kimi-k2"` \| `"mistralai/mistral-medium-3"` \| `"mistralai/mistral-medium-3.1"` \| `"mistralai/codestral-2501"` + +The default model to use for the Elements library. diff --git a/elements/docs/interfaces/Plugin.md b/elements/docs/interfaces/Plugin.md new file mode 100644 index 000000000..87c0d6f8e --- /dev/null +++ b/elements/docs/interfaces/Plugin.md @@ -0,0 +1,91 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / Plugin + +# Interface: Plugin + +A plugin enables addition of custom rendering capabilities to the Elements library. +For example, a plugin could provide a custom renderer for a specific language such as +D3.js or Mermaid. + +The general flow of a plugin is: +1. Plugin extends the system prompt with a custom prompt instructing the LLM to return code fences marked with the specified language / format +2. The LLM returns a code fence marked with the specified language / format +3. The code fence is rendered using the custom renderer + +## Properties + +### prompt + +> **prompt**: `string` + +Any prompt that the plugin may need to add to the system prompt. +Will be appended to the built-in system prompt. + +#### Example + +``` +If the user asks for a chart, use D3 to render it. +Return only a d3 code block. The code will execute in a sandboxed environment where: +- \`d3\` is the D3 library +- \`container\` is the DOM element to render into (use \`d3.select(container)\` NOT \`d3.select('body')\`) +The code should be wrapped in a \`\`\`d3 +\`\`\` block. +``` + +*** + +### language + +> **language**: `string` + +The language identifier for the syntax highlighter +e.g mermaid or d3 + +Does not need to be an official language identifier, can be any string. The important part is that the +prompt adequately instructs the LLM to return code fences marked with the specified language / format + +#### Example + +``` +d3 +``` + +*** + +### Component + +> **Component**: `ComponentType`\<`SyntaxHighlighterProps`\> + +The component to use for the syntax highlighter. + +*** + +### Header? + +> `optional` **Header**: `ComponentType`\<`CodeHeaderProps`\> + +The component to use for the code header. +Will be rendered above the code block. + +#### Default + +```ts +() => null +``` + +*** + +### overrideExisting? + +> `optional` **overrideExisting**: `boolean` + +Whether to override existing plugins with the same language. + +#### Default + +```ts +false +``` diff --git a/elements/docs/interfaces/SidecarConfig.md b/elements/docs/interfaces/SidecarConfig.md new file mode 100644 index 000000000..7e8012950 --- /dev/null +++ b/elements/docs/interfaces/SidecarConfig.md @@ -0,0 +1,67 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / SidecarConfig + +# Interface: SidecarConfig + +## Extends + +- `ExpandableConfig` + +## Properties + +### expandable? + +> `optional` **expandable**: `boolean` + +Whether the modal or sidecar can be expanded + +#### Inherited from + +`ExpandableConfig.expandable` + +*** + +### defaultExpanded? + +> `optional` **defaultExpanded**: `boolean` + +Whether the modal or sidecar should be expanded by default. + +#### Default + +```ts +false +``` + +#### Inherited from + +`ExpandableConfig.defaultExpanded` + +*** + +### dimensions? + +> `optional` **dimensions**: [`Dimensions`](Dimensions.md) + +The dimensions for the modal or sidecar window. + +#### Inherited from + +`ExpandableConfig.dimensions` + +*** + +### title? + +> `optional` **title**: `string` + +The title displayed in the sidecar header. + +#### Default + +```ts +'Chat' +``` diff --git a/elements/docs/interfaces/Suggestion.md b/elements/docs/interfaces/Suggestion.md new file mode 100644 index 000000000..b6885fdf2 --- /dev/null +++ b/elements/docs/interfaces/Suggestion.md @@ -0,0 +1,25 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / Suggestion + +# Interface: Suggestion + +## Properties + +### title + +> **title**: `string` + +*** + +### label + +> **label**: `string` + +*** + +### action + +> **action**: `string` diff --git a/elements/docs/interfaces/ThemeConfig.md b/elements/docs/interfaces/ThemeConfig.md new file mode 100644 index 000000000..1a9e524fa --- /dev/null +++ b/elements/docs/interfaces/ThemeConfig.md @@ -0,0 +1,70 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / ThemeConfig + +# Interface: ThemeConfig + +ThemeConfig provides visual appearance customization options. +Inspired by OpenAI ChatKit's ThemeOption. + +## Example + +```ts +const config: ElementsConfig = { + theme: { + colorScheme: 'dark', + density: 'compact', + radius: 'round', + }, +} +``` + +## Properties + +### colorScheme? + +> `optional` **colorScheme**: `"light"` \| `"dark"` \| `"system"` + +The color scheme to use for the UI. + +#### Default + +```ts +'light' +``` + +*** + +### density? + +> `optional` **density**: `"compact"` \| `"normal"` \| `"spacious"` + +Determines the overall spacing of the UI. +- `compact`: Reduced padding and margins for dense layouts +- `normal`: Standard spacing (default) +- `spacious`: Increased padding and margins for airy layouts + +#### Default + +```ts +'normal' +``` + +*** + +### radius? + +> `optional` **radius**: `"round"` \| `"soft"` \| `"sharp"` + +Determines the overall roundness of the UI. +- `round`: Large border radius +- `soft`: Moderate border radius (default) +- `sharp`: Minimal border radius + +#### Default + +```ts +'soft' +``` diff --git a/elements/docs/interfaces/ToolsConfig.md b/elements/docs/interfaces/ToolsConfig.md new file mode 100644 index 000000000..688cf96bb --- /dev/null +++ b/elements/docs/interfaces/ToolsConfig.md @@ -0,0 +1,112 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / ToolsConfig + +# Interface: ToolsConfig + +ToolsConfig is used to configure tool support in the Elements library. +At the moment, you can override the default React components used by +individual tool results. + +## Example + +```ts +const config: ElementsConfig = { + tools: { + components: { + "get_current_weather": WeatherComponent, + }, + }, +} +``` + +## Properties + +### expandToolGroupsByDefault? + +> `optional` **expandToolGroupsByDefault**: `boolean` + +Whether individual tool calls within a group should be expanded by default. + +#### Default + +```ts +false +``` + +*** + +### components? + +> `optional` **components**: `Record`\<`string`, `ToolCallMessagePartComponent` \| `undefined`\> + +`components` can be used to override the default components used by the +Elements library for a given tool result. + +Please ensure that the tool name directly matches the tool name in your Gram toolset. + +#### Example + +```ts +const config: ElementsConfig = { + tools: { + components: { + "get_current_weather": WeatherComponent, + }, + }, +} +``` + +*** + +### frontendTools? + +> `optional` **frontendTools**: `Record`\<`string`, `AssistantTool`\> + +The frontend tools to use for the Elements library. + +#### Examples + +```ts +import { defineFrontendTool } from '@gram-ai/elements' + +const FetchTool = defineFrontendTool<{ url: string }, string>( + { + description: 'Fetch a URL (supports CORS-enabled URLs like httpbin.org)', + parameters: z.object({ + url: z.string().describe('URL to fetch (must support CORS)'), + }), + execute: async ({ url }) => { + const response = await fetch(url as string) + const text = await response.text() + return text + }, + }, + 'fetchUrl' +) +const config: ElementsConfig = { + frontendTools: { + fetchUrl: FetchTool, + }, +} +``` + +You can also override the default components used by the +Elements library for a given tool result. + +```ts +import { FetchToolComponent } from './components/FetchToolComponent' + +const config: ElementsConfig = { + tools: { + frontendTools: { + fetchUrl: FetchTool, + }, + components: { + 'fetchUrl': FetchToolComponent, // will override the default component used by the Elements library for the 'fetchUrl' tool + }, + }, +} +``` diff --git a/elements/docs/interfaces/WelcomeConfig.md b/elements/docs/interfaces/WelcomeConfig.md new file mode 100644 index 000000000..c774bab5c --- /dev/null +++ b/elements/docs/interfaces/WelcomeConfig.md @@ -0,0 +1,31 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / WelcomeConfig + +# Interface: WelcomeConfig + +## Properties + +### title + +> **title**: `string` + +The welcome message to display when the thread is empty. + +*** + +### subtitle + +> **subtitle**: `string` + +The subtitle to display when the thread is empty. + +*** + +### suggestions? + +> `optional` **suggestions**: [`Suggestion`](Suggestion.md)[] + +The suggestions to display when the thread is empty. diff --git a/elements/docs/type-aliases/ColorScheme.md b/elements/docs/type-aliases/ColorScheme.md new file mode 100644 index 000000000..cdb793ad3 --- /dev/null +++ b/elements/docs/type-aliases/ColorScheme.md @@ -0,0 +1,9 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / ColorScheme + +# Type Alias: ColorScheme + +> **ColorScheme** = *typeof* `COLOR_SCHEMES`\[`number`\] diff --git a/elements/docs/type-aliases/Density.md b/elements/docs/type-aliases/Density.md new file mode 100644 index 000000000..7575e345b --- /dev/null +++ b/elements/docs/type-aliases/Density.md @@ -0,0 +1,9 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / Density + +# Type Alias: Density + +> **Density** = *typeof* `DENSITIES`\[`number`\] diff --git a/elements/docs/type-aliases/FrontendTool.md b/elements/docs/type-aliases/FrontendTool.md new file mode 100644 index 000000000..a63a5f80d --- /dev/null +++ b/elements/docs/type-aliases/FrontendTool.md @@ -0,0 +1,27 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / FrontendTool + +# Type Alias: FrontendTool\ + +> **FrontendTool**\<`TArgs`, `TResult`\> = `FC`\<`AssistantToolProps`\<`TArgs`, `TResult`\>\> & `object` + +A frontend tool is a tool that is defined by the user and can be used in the chat. + +## Type Declaration + +### unstable\_tool + +> **unstable\_tool**: `AssistantToolProps`\<`TArgs`, `TResult`\> + +## Type Parameters + +### TArgs + +`TArgs` *extends* `Record`\<`string`, `unknown`\> + +### TResult + +`TResult` diff --git a/elements/docs/type-aliases/ModalTriggerPosition.md b/elements/docs/type-aliases/ModalTriggerPosition.md new file mode 100644 index 000000000..8c4af442a --- /dev/null +++ b/elements/docs/type-aliases/ModalTriggerPosition.md @@ -0,0 +1,9 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / ModalTriggerPosition + +# Type Alias: ModalTriggerPosition + +> **ModalTriggerPosition** = `"bottom-right"` \| `"bottom-left"` \| `"top-right"` \| `"top-left"` diff --git a/elements/docs/type-aliases/Model.md b/elements/docs/type-aliases/Model.md new file mode 100644 index 000000000..5c459299a --- /dev/null +++ b/elements/docs/type-aliases/Model.md @@ -0,0 +1,21 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / Model + +# Type Alias: Model + +> **Model** = *typeof* `MODELS`\[`number`\] + +The LLM model to use for the Elements library. + +## Example + +```ts +const config: ElementsConfig = { + model: { + defaultModel: 'openai/gpt-4o', + }, +} +``` diff --git a/elements/docs/type-aliases/Radius.md b/elements/docs/type-aliases/Radius.md new file mode 100644 index 000000000..0e8c5787e --- /dev/null +++ b/elements/docs/type-aliases/Radius.md @@ -0,0 +1,9 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / Radius + +# Type Alias: Radius + +> **Radius** = *typeof* `RADII`\[`number`\] diff --git a/elements/docs/type-aliases/Variant.md b/elements/docs/type-aliases/Variant.md new file mode 100644 index 000000000..e4e0e4465 --- /dev/null +++ b/elements/docs/type-aliases/Variant.md @@ -0,0 +1,9 @@ +[**@gram-ai/elements v0.0.1**](../README.md) + +*** + +[@gram-ai/elements](../README.md) / Variant + +# Type Alias: Variant + +> **Variant** = *typeof* `VARIANTS`\[`number`\] diff --git a/elements/eslint.config.mjs b/elements/eslint.config.mjs new file mode 100644 index 000000000..4dbddea12 --- /dev/null +++ b/elements/eslint.config.mjs @@ -0,0 +1,60 @@ +// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format +import storybook from 'eslint-plugin-storybook' + +// @ts-check + +import eslint from '@eslint/js' +import tseslint from 'typescript-eslint' +import { includeIgnoreFile } from '@eslint/compat' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import prettier from 'eslint-plugin-prettier' +import reactRefresh from 'eslint-plugin-react-refresh' +import unusedImports from 'eslint-plugin-unused-imports' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const gitignorePath = path.resolve(__dirname, '.gitignore') + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + includeIgnoreFile(gitignorePath), + ...storybook.configs['flat/recommended'], + { + ignores: ['scripts/generate-utility-docs.js'], + }, + { + languageOptions: { + globals: { + console: true, + }, + }, + }, + { + plugins: { + prettier, + 'react-refresh': reactRefresh, + 'unused-imports': unusedImports, + }, + rules: { + 'prettier/prettier': ['error', {}, { usePrettierrc: true }], + 'no-unused-vars': 'off', // or "@typescript-eslint/no-unused-vars": "off", + 'unused-imports/no-unused-imports': 'error', + + 'storybook/no-redundant-story-name': 'off', + + 'react-refresh/only-export-components': 'error', + + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + }, + } +) diff --git a/elements/package.json b/elements/package.json new file mode 100644 index 000000000..f5d54340c --- /dev/null +++ b/elements/package.json @@ -0,0 +1,154 @@ +{ + "name": "@gram-ai/elements", + "description": "Gram Elements is a library of UI primitives for building chat-like experiences for MCP Servers.", + "type": "module", + "version": "1.15.0", + "main": "dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/elements.js", + "require": "./dist/elements.cjs" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js", + "require": "./dist/server.cjs" + }, + "./plugins": { + "types": "./dist/plugins/index.d.ts", + "import": "./dist/plugins.js", + "require": "./dist/plugins.cjs" + }, + "./elements.css": "./dist/elements.css" + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/speakeasy-api/gram.git" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "vite build", + "lint": "eslint src", + "analyze": "pnpm dlx vite-bundle-visualizer", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "type-check": "tsc --noEmit", + "docs": "typedoc", + "docs:watch": "typedoc --watch", + "prepare": "pnpm build" + }, + "keywords": [], + "author": "Adam Bull ", + "license": "ISC", + "packageManager": "pnpm@10.18.2", + "peerDependencies": { + "@assistant-ui/react": "^0.11.0", + "@assistant-ui/react-markdown": "^0.11.0", + "@types/react": ">=18 <20", + "@types/react-dom": ">=18 <20", + "motion": "^12.0.0", + "react": ">=18 <20", + "react-dom": ">=18 <20", + "remark-gfm": "^4.0.0", + "shiki": "^3.20.0", + "vega": "^6.2.0", + "zustand": "^5.0.0" + }, + "peerDependenciesMeta": { + "@assistant-ui/react": { + "optional": true + }, + "@assistant-ui/react-markdown": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "motion": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "remark-gfm": { + "optional": true + }, + "vega": { + "optional": true + }, + "zustand": { + "optional": true + }, + "shiki": { + "optional": true + } + }, + "dependencies": { + "@ai-sdk/mcp": "^0.0.11", + "@openrouter/ai-sdk-provider": "^1.4.1", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", + "ai": "5.0.90", + "assistant-stream": "^0.2.42", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.544.0", + "tailwind-merge": "^3.3.1", + "tw-shimmer": "^0.4.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@ai-sdk/openai": "^2.0.0-beta.5", + "@assistant-ui/react": "^0.11.53", + "@assistant-ui/react-ai-sdk": "^1.1.16", + "@assistant-ui/react-markdown": "^0.11.4", + "@eslint/compat": "^2.0.0", + "@eslint/js": "^9.39.1", + "@modelcontextprotocol/sdk": "^1.24.3", + "@storybook/addon-docs": "^10.0.8", + "@storybook/react-vite": "^10.0.8", + "@tailwindcss/vite": "^4.1.13", + "@types/lodash.merge": "^4.6.9", + "@types/node": "^24.10.1", + "@vitejs/plugin-react": "^5.0.3", + "chromatic": "^13.3.3", + "eslint": "^9.39.1", + "eslint-config-prettier": "^10.1.8", + "eslint-js": "github:eslint/js", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-react-refresh": "^0.4.24", + "eslint-plugin-storybook": "^10.1.4", + "eslint-plugin-unused-imports": "^4.3.0", + "lodash.merge": "^4.6.2", + "motion": "^12.23.14", + "openai": "^6.9.1", + "prettier": "^3.7.4", + "prettier-plugin-tailwindcss": "^0.7.2", + "remark-gfm": "^4.0.1", + "storybook": "^10.0.8", + "tailwindcss": "^4.1.13", + "tw-animate-css": "^1.3.8", + "typedoc": "^0.28.15", + "typedoc-plugin-markdown": "^4.9.0", + "typescript-eslint": "^8.48.1", + "vite": "^7.1.6", + "vite-plugin-dts": "^4.5.4", + "zustand": "^5.0.8" + } +} diff --git a/elements/scripts/zero b/elements/scripts/zero new file mode 100755 index 000000000..7ddda2952 --- /dev/null +++ b/elements/scripts/zero @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# script/zero - Bootstrap the development environment +# +# Usage: script/zero +# +# This script sets up everything you need to start developing on this project. +# Run it after cloning the repo or whenever you need to reset your environment. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "🔧 Bootstrapping gram-elements development environment..." +echo "" + +# Check for mise +if ! command -v mise &> /dev/null; then + echo "❌ mise is not installed." + echo "" + echo "Install mise first:" + echo " curl https://mise.run | sh" + echo "" + echo "Then add to your shell profile and restart your terminal." + exit 1 +fi + +echo "📦 Installing mise tools (node, pnpm, gum)..." +mise install + +echo "" +echo "📥 Installing npm dependencies..." +pnpm install + +echo "" +echo "✅ Setup complete!" +echo "" +echo "Available commands:" +echo " pnpm storybook - Start Storybook dev server" +echo " pnpm build - Build the library" +echo " pnpm lint - Run linting" +echo " pnpm type-check - Run TypeScript type checking" +echo "" + + + diff --git a/elements/src/components/Chat/index.stories.tsx b/elements/src/components/Chat/index.stories.tsx new file mode 100644 index 000000000..bc8b83fd0 --- /dev/null +++ b/elements/src/components/Chat/index.stories.tsx @@ -0,0 +1,770 @@ +import React, { useState } from 'react' +import { Chat } from '.' +import { ElementsProvider } from '../../contexts/ElementsProvider' +import type { Meta, StoryFn } from '@storybook/react-vite' +import { HomeIcon } from 'lucide-react' +import { + ToolCallMessagePartProps, + useAssistantState, +} from '@assistant-ui/react' +import { + COLOR_SCHEMES, + ColorScheme, + ComponentOverrides, + DENSITIES, + Density, + ElementsConfig, + RADII, + Radius, + Variant, + VARIANTS, +} from '../../types' +import { recommended } from '../../plugins' +import { defineFrontendTool, FrontendTool } from '../../lib/tools' +import z from 'zod' + +const meta: Meta = { + component: Chat, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta + +export default meta + +type Story = StoryFn + +export const Default: Story = () => ( +
+

Modal example

+ +

Click the button in the bottom right corner to open the chat.

+ +
+) + +const baseConfig: ElementsConfig = { + projectSlug: 'demo', + mcp: 'https://chat.speakeasy.com/mcp/speakeasy-team-my_api', + welcome: { + title: 'Hello!', + subtitle: 'How can I help you today?', + suggestions: [ + { + title: 'Search', + label: 'in your data', + action: 'Search for recent activity', + }, + { + title: 'Write a report', + label: 'from your metrics', + action: 'Write a summary report of this month', + }, + { + title: 'Analyze trends', + label: 'in your business', + action: 'Analyze recent trends', + }, + { + title: 'Get recommendations', + label: 'for improvement', + action: 'Give me recommendations', + }, + ], + }, + modal: { + defaultOpen: true, + title: 'Gram Elements Demo', + expandable: true, + }, + plugins: recommended, +} + +// Playground with Storybook controls for all theme options +interface PlaygroundArgs { + // Theme + colorScheme: ColorScheme + density: Density + radius: Radius + // Layout + variant: Variant + // Start screen + greeting: string + subtitle: string + starterPrompts: 'none' | 'some' | 'many' + // Composer + placeholder: string + attachments: boolean + // Modal/Sidecar + headerTitle: string +} + +const starterPromptOptions = { + none: [], + some: [ + { + title: 'Search for anything', + label: 'in your data', + action: 'Search for recent activity', + }, + { + title: 'Write a report', + label: 'from your metrics', + action: 'Write a summary report of this month', + }, + ], + many: [ + { + title: 'Search for anything', + label: 'in your data', + action: 'Search for recent activity', + }, + { + title: 'Write a report', + label: 'from your metrics', + action: 'Write a summary report of this month', + }, + { + title: 'Analyze trends', + label: 'in your business', + action: 'Analyze recent trends', + }, + { + title: 'Get recommendations', + label: 'for improvement', + action: 'Give me recommendations', + }, + ], +} + +export const ThemePlayground: StoryFn = (args) => { + const config: ElementsConfig = { + projectSlug: 'demo', + mcp: 'https://chat.speakeasy.com/mcp/speakeasy-team-my_api', + variant: args.variant, + theme: { + colorScheme: args.colorScheme, + density: args.density, + radius: args.radius, + }, + welcome: { + title: args.greeting, + subtitle: args.subtitle, + suggestions: starterPromptOptions[args.starterPrompts], + }, + composer: { + placeholder: args.placeholder, + attachments: args.attachments, + }, + modal: { + title: args.headerTitle, + defaultOpen: args.variant === 'widget', + }, + sidecar: { + title: args.headerTitle, + }, + } + + // Determine if dark mode should be applied + const isDark = + args.colorScheme === 'dark' || + (args.colorScheme === 'system' && + typeof window !== 'undefined' && + window.matchMedia('(prefers-color-scheme: dark)').matches) + + return ( + +
+
+

Theme Playground

+

+ Use the controls panel to customize the chat appearance. +

+
+ +
+
+ ) +} +ThemePlayground.args = { + // Theme defaults + colorScheme: 'light', + density: 'normal', + radius: 'soft', + // Layout + variant: 'widget', + // Start screen + greeting: 'Hello!', + subtitle: 'How can I help you today?', + starterPrompts: 'some', + // Composer + placeholder: 'Send a message...', + attachments: true, +} +ThemePlayground.argTypes = { + colorScheme: { + control: 'inline-radio', + options: COLOR_SCHEMES, + description: 'The color scheme for the UI', + table: { category: 'Theme' }, + }, + density: { + control: 'select', + options: DENSITIES, + description: 'Controls the overall spacing of the UI', + table: { category: 'Theme' }, + }, + radius: { + control: 'select', + options: RADII, + description: 'Controls the roundness of UI elements', + table: { category: 'Theme' }, + }, + variant: { + control: 'inline-radio', + options: VARIANTS, + description: 'The layout variant', + table: { category: 'Layout' }, + }, + greeting: { + control: 'text', + description: 'The main welcome message', + table: { category: 'Start Screen' }, + }, + subtitle: { + control: 'text', + description: 'Secondary welcome text', + table: { category: 'Start Screen' }, + }, + starterPrompts: { + control: 'select', + options: ['none', 'some', 'many'], + description: 'Suggested prompts shown on the start screen', + table: { category: 'Start Screen' }, + }, + placeholder: { + control: 'text', + description: 'Placeholder text in the composer input', + table: { category: 'Composer' }, + }, + attachments: { + control: 'boolean', + description: 'Enable file attachments', + table: { category: 'Composer' }, + }, + headerTitle: { + control: 'text', + description: 'Title shown in the modal/sidecar header', + table: { category: 'Header' }, + }, +} +ThemePlayground.parameters = { + layout: 'fullscreen', + elements: false, // Disable the global decorator for this story +} + +export const VariantPlayground: Story = () => { + const [variant, setVariant] = useState('widget') + + return ( + +
+ {/* Variant switcher */} +
+ {VARIANTS.map((v) => ( + + ))} +
+ + +
+
+ ) +} +VariantPlayground.parameters = { + layout: 'fullscreen', + elements: false, // Disable the global decorator for this story +} + +export const Standalone: Story = () => +Standalone.parameters = { + elements: { config: { variant: 'standalone' } }, +} +Standalone.decorators = [ + (Story) => ( +
+ +
+ ), +] + +export const Sidecar: Story = () => ( +
+

Sidecar Variant

+

The sidebar is always visible on the right.

+ +
+) +Sidecar.parameters = { + elements: { config: { variant: 'sidecar' } }, +} + +export const WithCustomModalIcon: Story = () => +WithCustomModalIcon.parameters = { + elements: { + config: { + modal: { + icon: (state: 'open' | 'closed' | undefined) => ( + + ), + }, + }, + }, +} + +export const WithCustomComposerPlaceholder: Story = () => +WithCustomComposerPlaceholder.parameters = { + elements: { + config: { + composer: { placeholder: 'What would you like to know?' }, + }, + }, +} + +export const WithAttachmentsDisabled: Story = () => +WithAttachmentsDisabled.parameters = { + elements: { + config: { + composer: { attachments: false }, + }, + }, +} + +export const WithCustomWelcomeMessage: Story = () => +WithCustomWelcomeMessage.parameters = { + elements: { + config: { + welcome: { + title: 'Hello there!', + subtitle: "How can I serve your organization's needs today?", + suggestions: [ + { + title: 'Write a SQL query', + label: 'to find top customers', + action: 'Write a SQL query to find top customers', + }, + ], + }, + }, + }, +} + +const CardPinRevealComponent = ({ + result, + argsText, +}: ToolCallMessagePartProps) => { + const [isFlipped, setIsFlipped] = React.useState(false) + + // Parse the result to get the pin + let pin = '****' + try { + if (result) { + const parsed = typeof result === 'string' ? JSON.parse(result) : result + if (parsed?.content?.[0]?.text) { + const content = JSON.parse(parsed.content[0].text) + pin = content.pin || '****' + } else if (parsed?.pin) { + pin = parsed.pin + } + } + } catch { + // Fallback to default + } + + const args = JSON.parse(argsText || '{}') + const cardNumber = args?.queryParameters?.cardNumber || '4532 •••• •••• 1234' + const cardHolder = 'JOHN DOE' + const expiry = '12/25' + const cvv = '123' + + if (!cardNumber) { + return null + } + + return ( +
+
setIsFlipped(!isFlipped)} + > + {/* Front of card */} +
+
+ {/* Card pattern overlay */} +
+
+
+
+ + {/* Card content */} +
+
+
VISA
+
+
+ +
+
+ {cardNumber} +
+
+
+
CARDHOLDER
+
{cardHolder}
+
+
+
EXPIRES
+
{expiry}
+
+
+
+
+ + {/* Click hint */} +
+ Click to flip +
+
+
+ + {/* Back of card */} +
+
+ {/* Magnetic strip */} +
+ + {/* Card content */} +
+
+
+
+ {cvv} +
+
CVV
+
+ + {/* PIN Display */} +
+
PIN
+
+
+ + {pin} + +
+
+ Keep this PIN secure +
+
+
+
+ +
+
VISA
+
{cardNumber}
+
+
+ + {/* Click hint */} +
+ Click to flip back +
+
+
+
+
+ ) +} + +export const WithCustomTools: Story = () => +WithCustomTools.parameters = { + elements: { + config: { + welcome: { + suggestions: [ + { + title: 'Get card details', + label: 'for your card', + action: 'Get card details for your card number 4532 •••• •••• 1234', + }, + ], + }, + tools: { + components: { + my_api_get_get_card_details: CardPinRevealComponent, + }, + }, + }, + }, +} + +export const WithModelPicker: Story = () => +WithModelPicker.parameters = { + elements: { + config: { + model: { showModelPicker: true }, + }, + }, +} + +export const WithExpandableModal: Story = () => +WithExpandableModal.parameters = { + elements: { + config: { + modal: { + expandable: true, + dimensions: { + default: { width: '500px', height: '600px', maxHeight: '100vh' }, + expanded: { width: '80vw', height: '90vh' }, + }, + }, + }, + }, +} + +export const WithCustomModalTriggerPositionTopRight: Story = () => +WithCustomModalTriggerPositionTopRight.parameters = { + elements: { + config: { + modal: { position: 'top-right' }, + }, + }, +} + +export const WithCustomModalTriggerPositionBottomRight: Story = () => +WithCustomModalTriggerPositionBottomRight.parameters = { + elements: { + config: { + modal: { position: 'bottom-right' }, + }, + }, +} + +export const WithCustomModalTriggerPositionBottomLeft: Story = () => +WithCustomModalTriggerPositionBottomLeft.parameters = { + elements: { + config: { + modal: { position: 'bottom-left' }, + }, + }, +} + +export const WithCustomModalTriggerPositionTopLeft: Story = () => +WithCustomModalTriggerPositionTopLeft.parameters = { + elements: { + config: { + modal: { position: 'top-left' }, + }, + }, +} + +export const WithCustomSystemPrompt: Story = () => +WithCustomSystemPrompt.parameters = { + elements: { + config: { + systemPrompt: 'Please speak like a pirate', + }, + }, +} + +const countryData = JSON.stringify({ + countries: [ + { name: 'USA', gdp: 22000 }, + { name: 'Canada', gdp: 16000 }, + { name: 'Mexico', gdp: 10000 }, + ], +}) + +export const WithChartPlugin: Story = () => +WithChartPlugin.parameters = { + elements: { + config: { + welcome: { + suggestions: [ + { + title: 'Create a chart', + label: 'Visualize your data', + action: `Create a bar chart for the following country + GDP data: + ${countryData} + `, + }, + ], + }, + }, + }, +} + +const customComponents: ComponentOverrides = { + Text: () => { + const message = useAssistantState(({ message }) => message) + return ( +
+ {message.parts + .map((part) => (part.type === 'text' ? part.text : '')) + .join('')} +
+ ) + }, +} +export const WithCustomComponentOverrides: Story = () => +WithCustomComponentOverrides.parameters = { + elements: { + config: { + variant: 'standalone', + components: customComponents, + }, + }, +} + +const FetchTool = defineFrontendTool<{ url: string }, string>( + { + description: 'Fetch a URL (supports CORS-enabled URLs like httpbin.org)', + parameters: z.object({ + url: z.string().describe('URL to fetch (must support CORS)'), + }), + execute: async ({ url }) => { + try { + const response = await fetch(url as string) + const text = await response.text() + return text + } catch (error) { + return `Error fetching ${url}: ${error instanceof Error ? error.message : 'Unknown error'}. Note: URL must support CORS for browser requests.` + } + }, + }, + 'fetchUrl' +) +const frontendTools: Record> = { + fetchUrl: FetchTool, +} + +// Render OS X style browser window with the fetched URL html rendered +const FetchToolComponent = ({ result, args }: ToolCallMessagePartProps) => { + const url = (args as { url?: string })?.url || 'about:blank' + const [isLoading, setIsLoading] = React.useState(true) + + return ( +
+ {/* macOS Window Controls Bar */} +
+
+ {/* Traffic lights */} +
+
+
+
+
+ + {/* Address bar */} +
+ + + + + {url} + +
+
+ + {/* Loading bar */} + {isLoading && ( +
+
+
+ )} +
+ + {/* Content */} +
+