chore: add docker deployment for coolify
Some checks are pending
Validate Build / validate (push) Waiting to run

This commit is contained in:
Tiago Ribeiro 2026-03-04 18:50:49 -03:00
commit 8a58e13db8
89 changed files with 12370 additions and 0 deletions

11
.dockerignore Normal file
View file

@ -0,0 +1,11 @@
.git
.github
.vscode
.next
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.env*
*.local

21
.github/workflows/validate.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: Validate Build
on:
push:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Install dependencies
run: pnpm install
- name: Run validate script
run: pnpm run validate

42
.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

7
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}

20
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,20 @@
# Contributing to Agent UI
Contributions are welcome! Please feel free to submit a Pull Request or raise an issue.
## How to Contribute
1. For bugs and feature requests, please raise a ticket first
2. Fork the repository
3. Create your feature branch (`git checkout -b feature/amazing-feature`)
4. Commit your changes (`git commit -m 'Add some amazing feature'`)
5. Push to the branch (`git push origin feature/amazing-feature`)
6. Open a Pull Request
## Code of Conduct
Please be respectful and considerate of others when contributing to this project.
## Questions?
If you have any questions about contributing, please feel free to reach out to the maintainers.

19
Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM node:20-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app ./
EXPOSE 3000
CMD ["pnpm", "start", "-p", "3000", "-H", "0.0.0.0"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Agno
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

122
README.md Normal file
View file

@ -0,0 +1,122 @@
# Agent UI
A modern chat interface for AgentOS built with Next.js, Tailwind CSS, and TypeScript. This template provides a ready-to-use UI for connecting to and interacting with your AgentOS instances through the Agno platform.
<img src="https://agno-public.s3.us-east-1.amazonaws.com/assets/agent_ui_banner.svg" alt="agent-ui" style="border-radius: 10px; width: 100%; max-width: 800px;" />
## Features
- 🔗 **AgentOS Integration**: Seamlessly connect to local and live AgentOS instances
- 💬 **Modern Chat Interface**: Clean design with real-time streaming support
- 🧩 **Tool Calls Support**: Visualizes agent tool calls and their results
- 🧠 **Reasoning Steps**: Displays agent reasoning process (when available)
- 📚 **References Support**: Show sources used by the agent
- 🖼️ **Multi-modality Support**: Handles various content types including images, video, and audio
- 🎨 **Customizable UI**: Built with Tailwind CSS for easy styling
- 🧰 **Built with Modern Stack**: Next.js, TypeScript, shadcn/ui, Framer Motion, and more
## Version Support
- **Main Branch**: Supports Agno v2.x (recommended)
- **v1 Branch**: Supports Agno v1.x for legacy compatibility
## Getting Started
### Prerequisites
Before setting up Agent UI, you need a running AgentOS instance. If you haven't created one yet, check out our [Creating Your First OS](/agent-os/creating-your-first-os) guide.
> **Note**: Agent UI connects to AgentOS instances through the Agno platform. Make sure your AgentOS is running before attempting to connect.
### Installation
### Automatic Installation (Recommended)
```bash
npx create-agent-ui@latest
```
### Manual Installation
1. Clone the repository:
```bash
git clone https://github.com/agno-agi/agent-ui.git
cd agent-ui
```
2. Install dependencies:
```bash
pnpm install
```
3. Start the development server:
```bash
pnpm dev
```
4. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Connecting to Your AgentOS
Agent UI connects directly to your AgentOS instance, allowing you to interact with your agents through a modern chat interface.
> **Prerequisites**: You need a running AgentOS instance before you can connect Agent UI to it. If you haven't created one yet, check out our [Creating Your First OS](https://docs.agno.com/agent-os/creating-your-first-os) guide.
## Step-by-Step Connection Process
### 1. Configure the Endpoint
By default, Agent UI connects to `http://localhost:7777`. You can easily change this by:
1. Hovering over the endpoint URL in the left sidebar
2. Clicking the edit option to modify the connection settings
### 2. Choose Your Environment
- **Local Development**: Use `http://localhost:7777` (default) or your custom local port
- **Production**: Enter your production AgentOS HTTPS URL
> **Warning**: Make sure your AgentOS is actually running on the specified endpoint before attempting to connect.
### 3. Configure Authentication (Optional)
If your AgentOS instance requires authentication, you can configure it in two ways:
#### Option 1: Environment Variable (Recommended)
Set the `OS_SECURITY_KEY` environment variable:
```bash
# In your .env.local file or shell environment
NEXT_PUBLIC_OS_SECURITY_KEY=your_auth_token_here
```
> **Note**: This uses the same environment variable as AgentOS, so if you're running both on the same machine, you only need to set it once. The token will be automatically loaded when the application starts.
#### Option 2: UI Configuration
1. In the left sidebar, locate the "Auth Token" section
2. Click on the token field to edit it
3. Enter your authentication token
4. The token will be securely stored and included in all API requests
> **Security Note**: Authentication tokens are stored locally in global store and are included as Bearer tokens in API requests to your AgentOS instance.
### 4. Test the Connection
Once you've configured the endpoint:
1. The Agent UI will automatically attempt to connect to your AgentOS
2. If successful, you'll see your agents available in the chat interface
3. If there are connection issues, check that your AgentOS is running and accessible. Check out the troubleshooting guide [here](https://docs.agno.com/faq/agentos-connection)
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution guidelines.
## License
This project is licensed under the [MIT License](./LICENSE).

21
components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

16
eslint.config.mjs Normal file
View file

@ -0,0 +1,16 @@
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname
})
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript')
]
export default eslintConfig

7
next.config.ts Normal file
View file

@ -0,0 +1,7 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
devIndicators: false
}
export default nextConfig

55
package.json Normal file
View file

@ -0,0 +1,55 @@
{
"name": "agent-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"format": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"format:fix": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"typecheck": "tsc --noEmit",
"validate": "pnpm run lint && pnpm run format && pnpm run typecheck"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"framer-motion": "^12.4.1",
"lucide-react": "^0.474.0",
"next": "15.2.8",
"next-themes": "^0.4.4",
"nuqs": "^2.3.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.3",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0",
"sonner": "^1.7.4",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.0.46",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.2.3",
"postcss": "^8",
"prettier": "3.4.2",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

5555
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

8
postcss.config.mjs Normal file
View file

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {}
}
}
export default config

8
prettier.config.cjs Normal file
View file

@ -0,0 +1,8 @@
/** @type {import('prettier').Config} */
module.exports = {
singleQuote: true,
semi: false,
trailingComma: 'none',
plugins: ['prettier-plugin-tailwindcss'],
filepath: './src/**/*.{js,ts,jsx,tsx}'
}

168
src/api/os.ts Normal file
View file

@ -0,0 +1,168 @@
import { toast } from 'sonner'
import { APIRoutes } from './routes'
import { AgentDetails, Sessions, TeamDetails } from '@/types/os'
// Helper function to create headers with optional auth token
const createHeaders = (authToken?: string): HeadersInit => {
const headers: HeadersInit = {
'Content-Type': 'application/json'
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`
}
return headers
}
export const getAgentsAPI = async (
endpoint: string,
authToken?: string
): Promise<AgentDetails[]> => {
const url = APIRoutes.GetAgents(endpoint)
try {
const response = await fetch(url, {
method: 'GET',
headers: createHeaders(authToken)
})
if (!response.ok) {
toast.error(`Failed to fetch agents: ${response.statusText}`)
return []
}
const data = await response.json()
return data
} catch {
toast.error('Error fetching agents')
return []
}
}
export const getStatusAPI = async (
base: string,
authToken?: string
): Promise<number> => {
const response = await fetch(APIRoutes.Status(base), {
method: 'GET',
headers: createHeaders(authToken)
})
return response.status
}
export const getAllSessionsAPI = async (
base: string,
type: 'agent' | 'team',
componentId: string,
dbId: string,
authToken?: string
): Promise<Sessions | { data: [] }> => {
try {
const url = new URL(APIRoutes.GetSessions(base))
url.searchParams.set('type', type)
url.searchParams.set('component_id', componentId)
url.searchParams.set('db_id', dbId)
const response = await fetch(url.toString(), {
method: 'GET',
headers: createHeaders(authToken)
})
if (!response.ok) {
if (response.status === 404) {
return { data: [] }
}
throw new Error(`Failed to fetch sessions: ${response.statusText}`)
}
return response.json()
} catch {
return { data: [] }
}
}
export const getSessionAPI = async (
base: string,
type: 'agent' | 'team',
sessionId: string,
dbId?: string,
authToken?: string
) => {
// build query string
const queryParams = new URLSearchParams({ type })
if (dbId) queryParams.append('db_id', dbId)
const response = await fetch(
`${APIRoutes.GetSession(base, sessionId)}?${queryParams.toString()}`,
{
method: 'GET',
headers: createHeaders(authToken)
}
)
if (!response.ok) {
throw new Error(`Failed to fetch session: ${response.statusText}`)
}
return response.json()
}
export const deleteSessionAPI = async (
base: string,
dbId: string,
sessionId: string,
authToken?: string
) => {
const queryParams = new URLSearchParams()
if (dbId) queryParams.append('db_id', dbId)
const response = await fetch(
`${APIRoutes.DeleteSession(base, sessionId)}?${queryParams.toString()}`,
{
method: 'DELETE',
headers: createHeaders(authToken)
}
)
return response
}
export const getTeamsAPI = async (
endpoint: string,
authToken?: string
): Promise<TeamDetails[]> => {
const url = APIRoutes.GetTeams(endpoint)
try {
const response = await fetch(url, {
method: 'GET',
headers: createHeaders(authToken)
})
if (!response.ok) {
toast.error(`Failed to fetch teams: ${response.statusText}`)
return []
}
const data = await response.json()
return data
} catch {
toast.error('Error fetching teams')
return []
}
}
export const deleteTeamSessionAPI = async (
base: string,
teamId: string,
sessionId: string,
authToken?: string
) => {
const response = await fetch(
APIRoutes.DeleteTeamSession(base, teamId, sessionId),
{
method: 'DELETE',
headers: createHeaders(authToken)
}
)
if (!response.ok) {
throw new Error(`Failed to delete team session: ${response.statusText}`)
}
return response
}

17
src/api/routes.ts Normal file
View file

@ -0,0 +1,17 @@
export const APIRoutes = {
GetAgents: (agentOSUrl: string) => `${agentOSUrl}/agents`,
AgentRun: (agentOSUrl: string) => `${agentOSUrl}/agents/{agent_id}/runs`,
Status: (agentOSUrl: string) => `${agentOSUrl}/health`,
GetSessions: (agentOSUrl: string) => `${agentOSUrl}/sessions`,
GetSession: (agentOSUrl: string, sessionId: string) =>
`${agentOSUrl}/sessions/${sessionId}/runs`,
DeleteSession: (agentOSUrl: string, sessionId: string) =>
`${agentOSUrl}/sessions/${sessionId}`,
GetTeams: (agentOSUrl: string) => `${agentOSUrl}/teams`,
TeamRun: (agentOSUrl: string, teamId: string) =>
`${agentOSUrl}/teams/${teamId}/runs`,
DeleteTeamSession: (agentOSUrl: string, teamId: string, sessionId: string) =>
`${agentOSUrl}/v1//teams/${teamId}/sessions/${sessionId}`
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

31
src/app/globals.css Normal file
View file

@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-border-default: 255, 255, 255, 0.2;
--scrollbar-width: 0.1rem;
}
body {
@apply bg-background/80 text-secondary;
}
}
::-webkit-scrollbar {
width: var(--scrollbar-width);
}
::-webkit-scrollbar-thumb {
--tw-border-opacity: 1;
@apply bg-border;
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
border-radius: 9999px;
border-width: 1px;
}
::-webkit-scrollbar-track {
background-color: transparent;
border-radius: 9999px;
}

37
src/app/layout.tsx Normal file
View file

@ -0,0 +1,37 @@
import type { Metadata } from 'next'
import { DM_Mono, Geist } from 'next/font/google'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { Toaster } from '@/components/ui/sonner'
import './globals.css'
const geistSans = Geist({
variable: '--font-geist-sans',
weight: '400',
subsets: ['latin']
})
const dmMono = DM_Mono({
subsets: ['latin'],
variable: '--font-dm-mono',
weight: '400'
})
export const metadata: Metadata = {
title: 'Agent UI',
description:
'A modern chat interface for AI agents built with Next.js, Tailwind CSS, and TypeScript. This template provides a ready-to-use UI for interacting with Agno agents.'
}
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${dmMono.variable} antialiased`}>
<NuqsAdapter>{children}</NuqsAdapter>
<Toaster />
</body>
</html>
)
}

18
src/app/page.tsx Normal file
View file

@ -0,0 +1,18 @@
'use client'
import Sidebar from '@/components/chat/Sidebar/Sidebar'
import { ChatArea } from '@/components/chat/ChatArea'
import { Suspense } from 'react'
export default function Home() {
// Check if OS_SECURITY_KEY is defined on server-side
const hasEnvToken = !!process.env.NEXT_PUBLIC_OS_SECURITY_KEY
const envToken = process.env.NEXT_PUBLIC_OS_SECURITY_KEY || ''
return (
<Suspense fallback={<div>Loading...</div>}>
<div className="flex h-screen bg-background/80">
<Sidebar hasEnvToken={hasEnvToken} envToken={envToken} />
<ChatArea />
</div>
</Suspense>
)
}

View file

@ -0,0 +1,16 @@
'use client'
import ChatInput from './ChatInput'
import MessageArea from './MessageArea'
const ChatArea = () => {
return (
<main className="relative m-1.5 flex flex-grow flex-col rounded-xl bg-background">
<MessageArea />
<div className="sticky bottom-0 ml-9 px-4 pb-2">
<ChatInput />
</div>
</main>
)
}
export default ChatArea

View file

@ -0,0 +1,71 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { TextArea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { useStore } from '@/store'
import useAIChatStreamHandler from '@/hooks/useAIStreamHandler'
import { useQueryState } from 'nuqs'
import Icon from '@/components/ui/icon'
const ChatInput = () => {
const { chatInputRef } = useStore()
const { handleStreamResponse } = useAIChatStreamHandler()
const [selectedAgent] = useQueryState('agent')
const [teamId] = useQueryState('team')
const [inputMessage, setInputMessage] = useState('')
const isStreaming = useStore((state) => state.isStreaming)
const handleSubmit = async () => {
if (!inputMessage.trim()) return
const currentMessage = inputMessage
setInputMessage('')
try {
await handleStreamResponse(currentMessage)
} catch (error) {
toast.error(
`Error in handleSubmit: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
return (
<div className="relative mx-auto mb-1 flex w-full max-w-2xl items-end justify-center gap-x-2 font-geist">
<TextArea
placeholder={'Ask anything'}
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
!e.nativeEvent.isComposing &&
!e.shiftKey &&
!isStreaming
) {
e.preventDefault()
handleSubmit()
}
}}
className="w-full border border-accent bg-primaryAccent px-4 text-sm text-primary focus:border-accent"
disabled={!(selectedAgent || teamId)}
ref={chatInputRef}
/>
<Button
onClick={handleSubmit}
disabled={
!(selectedAgent || teamId) || !inputMessage.trim() || isStreaming
}
size="icon"
className="rounded-xl bg-primary p-5 text-primaryAccent"
>
<Icon type="send" color="primaryAccent" />
</Button>
</div>
)
}
export default ChatInput

View file

@ -0,0 +1,3 @@
import ChatInput from './ChatInput'
export default ChatInput

View file

@ -0,0 +1,27 @@
'use client'
import { useStore } from '@/store'
import Messages from './Messages'
import ScrollToBottom from '@/components/chat/ChatArea/ScrollToBottom'
import { StickToBottom } from 'use-stick-to-bottom'
const MessageArea = () => {
const { messages } = useStore()
return (
<StickToBottom
className="relative mb-4 flex max-h-[calc(100vh-64px)] min-h-0 flex-grow flex-col"
resize="smooth"
initial="smooth"
>
<StickToBottom.Content className="flex min-h-full flex-col justify-center">
<div className="mx-auto w-full max-w-2xl space-y-9 px-4 pb-4">
<Messages messages={messages} />
</div>
</StickToBottom.Content>
<ScrollToBottom />
</StickToBottom>
)
}
export default MessageArea

View file

@ -0,0 +1,9 @@
const AgentThinkingLoader = () => (
<div className="flex items-center justify-center gap-1">
<div className="size-2 animate-bounce rounded-full bg-primary/20 [animation-delay:-0.3s] [animation-duration:0.70s]" />
<div className="size-2 animate-bounce rounded-full bg-primary/20 [animation-delay:-0.10s] [animation-duration:0.70s]" />
<div className="size-2 animate-bounce rounded-full bg-primary/20 [animation-duration:0.70s]" />
</div>
)
export default AgentThinkingLoader

View file

@ -0,0 +1,195 @@
'use client'
import Link from 'next/link'
import { motion, Variants } from 'framer-motion'
import Icon from '@/components/ui/icon'
import { IconType } from '@/components/ui/icon/types'
import React, { useState } from 'react'
const EXTERNAL_LINKS = {
documentation: 'https://agno.link/agent-ui',
agenOS: 'https://os.agno.com',
agno: 'https://agno.com'
}
const TECH_ICONS = [
{
type: 'nextjs' as IconType,
position: 'left-0',
link: 'https://nextjs.org',
name: 'Next.js',
zIndex: 10
},
{
type: 'shadcn' as IconType,
position: 'left-[15px]',
link: 'https://ui.shadcn.com',
name: 'shadcn/ui',
zIndex: 20
},
{
type: 'tailwind' as IconType,
position: 'left-[30px]',
link: 'https://tailwindcss.com',
name: 'Tailwind CSS',
zIndex: 30
}
]
interface ActionButtonProps {
href: string
variant?: 'primary'
text: string
}
const ActionButton = ({ href, variant, text }: ActionButtonProps) => {
const baseStyles =
'px-4 py-2 text-sm transition-colors font-dmmono tracking-tight'
const variantStyles = {
primary: 'border border-border hover:bg-neutral-800 rounded-xl'
}
return (
<Link
href={href}
target="_blank"
className={`${baseStyles} ${variant ? variantStyles[variant] : ''}`}
>
{text}
</Link>
)
}
const ChatBlankState = () => {
const [hoveredIcon, setHoveredIcon] = useState<string | null>(null)
// Animation variants for the icon
const iconVariants: Variants = {
initial: { y: 0 },
hover: {
y: -8,
transition: {
type: 'spring',
stiffness: 150,
damping: 10,
mass: 0.5
}
},
exit: {
y: 0,
transition: {
type: 'spring',
stiffness: 200,
damping: 15,
mass: 0.6
}
}
}
// Animation variants for the tooltip
const tooltipVariants: Variants = {
hidden: {
opacity: 0,
transition: {
duration: 0.15,
ease: 'easeInOut'
}
},
visible: {
opacity: 1,
transition: {
duration: 0.15,
ease: 'easeInOut'
}
}
}
return (
<section
className="flex flex-col items-center text-center font-geist"
aria-label="Welcome message"
>
<div className="flex max-w-3xl flex-col gap-y-8">
<motion.h1
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="text-3xl font-[600] tracking-tight"
>
<div className="flex items-center justify-center gap-x-2 whitespace-nowrap font-medium">
<span className="flex items-center font-[600]">
This is an open-source
</span>
<span className="inline-flex translate-y-[10px] scale-125 items-center transition-transform duration-200 hover:rotate-6">
<Link
href={EXTERNAL_LINKS.agno}
target="_blank"
rel="noopener"
className="cursor-pointer"
>
<Icon type="agno-tag" size="default" />
</Link>
</span>
<span className="flex items-center font-[600]">
Agent UI, built with
</span>
<span className="inline-flex translate-y-[5px] scale-125 items-center">
<div className="relative ml-2 h-[40px] w-[90px]">
{TECH_ICONS.map((icon) => (
<motion.div
key={icon.type}
className={`absolute ${icon.position} top-0`}
style={{ zIndex: icon.zIndex }}
variants={iconVariants}
initial="initial"
whileHover="hover"
animate={hoveredIcon === icon.type ? 'hover' : 'exit'}
onHoverStart={() => setHoveredIcon(icon.type)}
onHoverEnd={() => setHoveredIcon(null)}
>
<Link
href={icon.link}
target="_blank"
rel="noopener"
className="relative block cursor-pointer"
>
<div>
<Icon type={icon.type} size="default" />
</div>
<motion.div
className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 transform whitespace-nowrap rounded bg-neutral-800 px-2 py-1 text-xs text-primary"
variants={tooltipVariants}
initial="hidden"
animate={
hoveredIcon === icon.type ? 'visible' : 'hidden'
}
>
{icon.name}
</motion.div>
</Link>
</motion.div>
))}
</div>
</span>
</div>
<p>For the full experience, visit the AgentOS</p>
</motion.h1>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="flex justify-center gap-4"
>
<ActionButton
href={EXTERNAL_LINKS.documentation}
variant="primary"
text="GO TO DOCS"
/>
<ActionButton href={EXTERNAL_LINKS.agenOS} text="VISIT AGENTOS" />
</motion.div>
</div>
</section>
)
}
export default ChatBlankState

View file

@ -0,0 +1,96 @@
import Icon from '@/components/ui/icon'
import MarkdownRenderer from '@/components/ui/typography/MarkdownRenderer'
import { useStore } from '@/store'
import type { ChatMessage } from '@/types/os'
import Videos from './Multimedia/Videos'
import Images from './Multimedia/Images'
import Audios from './Multimedia/Audios'
import { memo } from 'react'
import AgentThinkingLoader from './AgentThinkingLoader'
interface MessageProps {
message: ChatMessage
}
const AgentMessage = ({ message }: MessageProps) => {
const { streamingErrorMessage } = useStore()
let messageContent
if (message.streamingError) {
messageContent = (
<p className="text-destructive">
Oops! Something went wrong while streaming.{' '}
{streamingErrorMessage ? (
<>{streamingErrorMessage}</>
) : (
'Please try refreshing the page or try again later.'
)}
</p>
)
} else if (message.content) {
messageContent = (
<div className="flex w-full flex-col gap-4">
<MarkdownRenderer>{message.content}</MarkdownRenderer>
{message.videos && message.videos.length > 0 && (
<Videos videos={message.videos} />
)}
{message.images && message.images.length > 0 && (
<Images images={message.images} />
)}
{message.audio && message.audio.length > 0 && (
<Audios audio={message.audio} />
)}
</div>
)
} else if (message.response_audio) {
if (!message.response_audio.transcript) {
messageContent = (
<div className="mt-2 flex items-start">
<AgentThinkingLoader />
</div>
)
} else {
messageContent = (
<div className="flex w-full flex-col gap-4">
<MarkdownRenderer>
{message.response_audio.transcript}
</MarkdownRenderer>
{message.response_audio.content && message.response_audio && (
<Audios audio={[message.response_audio]} />
)}
</div>
)
}
} else {
messageContent = (
<div className="mt-2">
<AgentThinkingLoader />
</div>
)
}
return (
<div className="flex flex-row items-start gap-4 font-geist">
<div className="flex-shrink-0">
<Icon type="agent" size="sm" />
</div>
{messageContent}
</div>
)
}
const UserMessage = memo(({ message }: MessageProps) => {
return (
<div className="flex items-start gap-4 pt-4 text-start max-md:break-words">
<div className="flex-shrink-0">
<Icon type="user" size="sm" />
</div>
<div className="text-md rounded-lg font-geist text-secondary">
{message.content}
</div>
</div>
)
})
AgentMessage.displayName = 'AgentMessage'
UserMessage.displayName = 'UserMessage'
export { AgentMessage, UserMessage }

View file

@ -0,0 +1,180 @@
import type { ChatMessage } from '@/types/os'
import { AgentMessage, UserMessage } from './MessageItem'
import Tooltip from '@/components/ui/tooltip'
import { memo } from 'react'
import {
ToolCallProps,
ReasoningStepProps,
ReasoningProps,
ReferenceData,
Reference
} from '@/types/os'
import React, { type FC } from 'react'
import Icon from '@/components/ui/icon'
import ChatBlankState from './ChatBlankState'
interface MessageListProps {
messages: ChatMessage[]
}
interface MessageWrapperProps {
message: ChatMessage
isLastMessage: boolean
}
interface ReferenceProps {
references: ReferenceData[]
}
interface ReferenceItemProps {
reference: Reference
}
const ReferenceItem: FC<ReferenceItemProps> = ({ reference }) => (
<div className="relative flex h-[63px] w-[190px] cursor-default flex-col justify-between overflow-hidden rounded-md bg-background-secondary p-3 transition-colors hover:bg-background-secondary/80">
<p className="text-sm font-medium text-primary">{reference.name}</p>
<p className="truncate text-xs text-primary/40">{reference.content}</p>
</div>
)
const References: FC<ReferenceProps> = ({ references }) => (
<div className="flex flex-col gap-4">
{references.map((referenceData, index) => (
<div
key={`${referenceData.query}-${index}`}
className="flex flex-col gap-3"
>
<div className="flex flex-wrap gap-3">
{referenceData.references.map((reference, refIndex) => (
<ReferenceItem
key={`${reference.name}-${reference.meta_data.chunk}-${refIndex}`}
reference={reference}
/>
))}
</div>
</div>
))}
</div>
)
const AgentMessageWrapper = ({ message }: MessageWrapperProps) => {
return (
<div className="flex flex-col gap-y-9">
{message.extra_data?.reasoning_steps &&
message.extra_data.reasoning_steps.length > 0 && (
<div className="flex items-start gap-4">
<Tooltip
delayDuration={0}
content={<p className="text-accent">Reasoning</p>}
side="top"
>
<Icon type="reasoning" size="sm" />
</Tooltip>
<div className="flex flex-col gap-3">
<p className="text-xs uppercase">Reasoning</p>
<Reasonings reasoning={message.extra_data.reasoning_steps} />
</div>
</div>
)}
{message.extra_data?.references &&
message.extra_data.references.length > 0 && (
<div className="flex items-start gap-4">
<Tooltip
delayDuration={0}
content={<p className="text-accent">References</p>}
side="top"
>
<Icon type="references" size="sm" />
</Tooltip>
<div className="flex flex-col gap-3">
<References references={message.extra_data.references} />
</div>
</div>
)}
{message.tool_calls && message.tool_calls.length > 0 && (
<div className="flex items-start gap-3">
<Tooltip
delayDuration={0}
content={<p className="text-accent">Tool Calls</p>}
side="top"
>
<Icon
type="hammer"
className="rounded-lg bg-background-secondary p-1"
size="sm"
color="secondary"
/>
</Tooltip>
<div className="flex flex-wrap gap-2">
{message.tool_calls.map((toolCall, index) => (
<ToolComponent
key={
toolCall.tool_call_id ||
`${toolCall.tool_name}-${toolCall.created_at}-${index}`
}
tools={toolCall}
/>
))}
</div>
</div>
)}
<AgentMessage message={message} />
</div>
)
}
const Reasoning: FC<ReasoningStepProps> = ({ index, stepTitle }) => (
<div className="flex items-center gap-2 text-secondary">
<div className="flex h-[20px] items-center rounded-md bg-background-secondary p-2">
<p className="text-xs">STEP {index + 1}</p>
</div>
<p className="text-xs">{stepTitle}</p>
</div>
)
const Reasonings: FC<ReasoningProps> = ({ reasoning }) => (
<div className="flex flex-col items-start justify-center gap-2">
{reasoning.map((title, index) => (
<Reasoning
key={`${title.title}-${title.action}-${index}`}
stepTitle={title.title}
index={index}
/>
))}
</div>
)
const ToolComponent = memo(({ tools }: ToolCallProps) => (
<div className="cursor-default rounded-full bg-accent px-2 py-1.5 text-xs">
<p className="font-dmmono uppercase text-primary/80">{tools.tool_name}</p>
</div>
))
ToolComponent.displayName = 'ToolComponent'
const Messages = ({ messages }: MessageListProps) => {
if (messages.length === 0) {
return <ChatBlankState />
}
return (
<>
{messages.map((message, index) => {
const key = `${message.role}-${message.created_at}-${index}`
const isLastMessage = index === messages.length - 1
if (message.role === 'agent') {
return (
<AgentMessageWrapper
key={key}
message={message}
isLastMessage={isLastMessage}
/>
)
}
return <UserMessage key={key} message={message} />
})}
</>
)
}
export default Messages

View file

@ -0,0 +1,63 @@
'use client'
import { memo, useMemo } from 'react'
import { type AudioData } from '@/types/os'
import { decodeBase64Audio } from '@/lib/audio'
/**
* Renders a single audio item with controls
* @param audio - AudioData object containing url or base64 audio data
*/
const AudioItem = memo(({ audio }: { audio: AudioData }) => {
const audioUrl = useMemo(() => {
if (audio?.url) {
return audio.url
}
if (audio.base64_audio) {
return decodeBase64Audio(
audio.base64_audio,
audio.mime_type || 'audio/wav'
)
}
if (audio.content) {
return decodeBase64Audio(
audio.content,
'audio/pcm16',
audio.sample_rate,
audio.channels
)
}
return null
}, [audio])
if (!audioUrl) return null
return (
<audio
src={audioUrl}
controls
className="w-full rounded-lg"
preload="metadata"
/>
)
})
AudioItem.displayName = 'AudioItem'
/**
* Renders a list of audio elements
* @param audio - Array of AudioData objects
*/
const Audios = memo(({ audio }: { audio: AudioData[] }) => (
<div className="flex flex-col gap-4">
{audio.map((audio_item, index) => (
// TODO :: find a better way to handle the key
<AudioItem key={audio_item.id ?? `audio-${index}`} audio={audio_item} />
))}
</div>
))
Audios.displayName = 'Audios'
export default Audios

View file

@ -0,0 +1,3 @@
import Audios from './Audios'
export default Audios

View file

@ -0,0 +1,41 @@
import { memo } from 'react'
import { type ImageData } from '@/types/os'
import { cn } from '@/lib/utils'
const Images = ({ images }: { images: ImageData[] }) => (
<div
className={cn(
'grid max-w-xl gap-4',
images.length > 1 ? 'grid-cols-2' : 'grid-cols-1'
)}
>
{images.map((image) => (
<div key={image.url} className="group relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.url}
alt={image.revised_prompt || 'AI generated image'}
className="w-full rounded-lg"
onError={(e) => {
const parent = e.currentTarget.parentElement
if (parent) {
parent.innerHTML = `
<div class="flex h-40 flex-col items-center justify-center gap-2 rounded-md bg-secondary/50 text-muted" >
<p class="text-primary">Image unavailable</p>
<a href="${image.url}" target="_blank" class="max-w-md truncate underline text-primary text-xs w-full text-center p-2">
${image.url}
</a>
</div>
`
}
}}
/>
</div>
))}
</div>
)
export default memo(Images)
Images.displayName = 'Images'

View file

@ -0,0 +1,3 @@
import Images from './Images'
export default Images

View file

@ -0,0 +1,79 @@
'use client'
import { memo } from 'react'
import { toast } from 'sonner'
import { type VideoData } from '@/types/os'
import Icon from '@/components/ui/icon'
const VideoItem = memo(({ video }: { video: VideoData }) => {
const videoUrl = video.url
const handleDownload = async () => {
try {
toast.loading('Downloading video...')
const response = await fetch(videoUrl)
if (!response.ok) throw new Error('Network response was not ok')
const blob = await response.blob()
const fileExtension = videoUrl.split('.').pop() ?? 'mp4'
const fileName = `video-${Date.now()}.${fileExtension}`
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
toast.dismiss()
toast.success('Video downloaded successfully')
} catch {
toast.dismiss()
toast.error('Failed to download video')
}
}
return (
<div>
<div className="group relative w-full max-w-xl">
{}
<video
src={videoUrl}
autoPlay
muted
loop
controls
className="w-full rounded-lg"
style={{ aspectRatio: '16 / 9' }}
/>
<button
type="button"
onClick={handleDownload}
className="absolute right-2 top-2 flex items-center justify-center rounded-sm bg-secondary/80 p-1.5 opacity-0 transition-opacity duration-200 hover:bg-secondary group-hover:opacity-100"
aria-label="Download GIF"
>
<Icon type="download" size="xs" />
</button>
</div>
</div>
)
})
VideoItem.displayName = 'VideoItem'
const Videos = memo(({ videos }: { videos: VideoData[] }) => (
<div className="flex flex-col gap-4">
{videos.map((video) => (
<VideoItem key={video.id} video={video} />
))}
</div>
))
Videos.displayName = 'Videos'
export default Videos

View file

@ -0,0 +1,3 @@
import Videos from './Videos'
export default Videos

View file

@ -0,0 +1,3 @@
import Messages from './Messages'
export default Messages

View file

@ -0,0 +1,39 @@
'use client'
import type React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useStickToBottomContext } from 'use-stick-to-bottom'
import { Button } from '@/components/ui/button'
import Icon from '@/components/ui/icon'
const ScrollToBottom: React.FC = () => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
return (
<AnimatePresence>
{!isAtBottom && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="absolute bottom-4 left-1/2 -translate-x-1/2"
>
<Button
onClick={() => scrollToBottom()}
type="button"
size="icon"
variant="secondary"
className="border border-border bg-background text-primary shadow-md transition-shadow duration-300 hover:bg-background-secondary"
>
<Icon type="arrow-down" size="xs" />
</Button>
</motion.div>
)}
</AnimatePresence>
)
}
export default ScrollToBottom

View file

@ -0,0 +1,3 @@
import ChatArea from './ChatArea'
export { ChatArea }

View file

@ -0,0 +1,143 @@
'use client'
import { Button } from '@/components/ui/button'
import { useStore } from '@/store'
import { motion, AnimatePresence } from 'framer-motion'
import { useState, useEffect } from 'react'
import Icon from '@/components/ui/icon'
const AuthToken = ({
hasEnvToken,
envToken
}: {
hasEnvToken?: boolean
envToken?: string
}) => {
const { authToken, setAuthToken } = useStore()
const [isEditing, setIsEditing] = useState(false)
const [tokenValue, setTokenValue] = useState('')
const [isMounted, setIsMounted] = useState(false)
const [isHovering, setIsHovering] = useState(false)
useEffect(() => {
// Initialize with environment variable if available and no token is set
if (hasEnvToken && envToken && !authToken) {
setAuthToken(envToken)
setTokenValue(envToken)
} else {
setTokenValue(authToken)
}
setIsMounted(true)
}, [authToken, setAuthToken, hasEnvToken, envToken])
const handleSave = () => {
const cleanToken = tokenValue.trim()
setAuthToken(cleanToken)
setIsEditing(false)
setIsHovering(false)
}
const handleCancel = () => {
setTokenValue(authToken)
setIsEditing(false)
setIsHovering(false)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSave()
} else if (e.key === 'Escape') {
handleCancel()
}
}
const handleClear = () => {
setAuthToken('')
setTokenValue('')
}
const displayValue = authToken
? `${'*'.repeat(Math.min(authToken.length, 20))}${authToken.length > 20 ? '...' : ''}`
: 'NO TOKEN SET'
return (
<div className="flex flex-col items-start gap-2">
<div className="text-xs font-medium uppercase text-primary">
Auth Token
</div>
{isEditing ? (
<div className="flex w-full items-center gap-1">
<input
type="password"
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter authentication token..."
className="flex h-9 w-full items-center text-ellipsis rounded-xl border border-primary/15 bg-accent p-3 text-xs font-medium text-muted placeholder:text-muted/50"
autoFocus
/>
<Button
variant="ghost"
size="icon"
onClick={handleSave}
className="hover:cursor-pointer hover:bg-transparent"
>
<Icon type="save" size="xs" />
</Button>
</div>
) : (
<div className="flex w-full items-center gap-1">
<motion.div
className="relative flex h-9 w-full cursor-pointer items-center justify-between rounded-xl border border-primary/15 bg-accent p-3 uppercase"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onClick={() => setIsEditing(true)}
transition={{ type: 'spring', stiffness: 400, damping: 10 }}
>
<AnimatePresence mode="wait">
{isHovering ? (
<motion.div
key="token-display-hover"
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<p className="flex items-center gap-2 whitespace-nowrap text-xs font-medium text-primary">
<Icon type="edit" size="xxs" /> EDIT TOKEN
</p>
</motion.div>
) : (
<motion.div
key="token-display"
className="absolute inset-0 flex items-center justify-between px-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<p className="text-xs font-medium text-muted">
{isMounted ? displayValue : 'NO TOKEN SET'}
</p>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{authToken && (
<Button
variant="ghost"
size="icon"
onClick={handleClear}
className="hover:cursor-pointer hover:bg-transparent"
title="Clear token"
>
<Icon type="x" size="xs" />
</Button>
)}
</div>
)}
</div>
)
}
export default AuthToken

View file

@ -0,0 +1,107 @@
'use client'
import * as React from 'react'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem
} from '@/components/ui/select'
import { useStore } from '@/store'
import { useQueryState } from 'nuqs'
import Icon from '@/components/ui/icon'
import { useEffect } from 'react'
import useChatActions from '@/hooks/useChatActions'
export function EntitySelector() {
const { mode, agents, teams, setMessages, setSelectedModel } = useStore()
const { focusChatInput } = useChatActions()
const [agentId, setAgentId] = useQueryState('agent', {
parse: (value) => value || undefined,
history: 'push'
})
const [teamId, setTeamId] = useQueryState('team', {
parse: (value) => value || undefined,
history: 'push'
})
const [, setSessionId] = useQueryState('session')
const currentEntities = mode === 'team' ? teams : agents
const currentValue = mode === 'team' ? teamId : agentId
const placeholder = mode === 'team' ? 'Select Team' : 'Select Agent'
useEffect(() => {
if (currentValue && currentEntities.length > 0) {
const entity = currentEntities.find((item) => item.id === currentValue)
if (entity) {
setSelectedModel(entity.model?.model || '')
if (mode === 'team') {
setTeamId(entity.id)
}
if (entity.model?.model) {
focusChatInput()
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentValue, currentEntities, setSelectedModel, mode])
const handleOnValueChange = (value: string) => {
const newValue = value === currentValue ? null : value
const selectedEntity = currentEntities.find((item) => item.id === newValue)
setSelectedModel(selectedEntity?.model?.provider || '')
if (mode === 'team') {
setTeamId(newValue)
setAgentId(null)
} else {
setAgentId(newValue)
setTeamId(null)
}
setMessages([])
setSessionId(null)
if (selectedEntity?.model?.provider) {
focusChatInput()
}
}
if (currentEntities.length === 0) {
return (
<Select disabled>
<SelectTrigger className="h-9 w-full rounded-xl border border-primary/15 bg-primaryAccent text-xs font-medium uppercase opacity-50">
<SelectValue placeholder={`No ${mode}s Available`} />
</SelectTrigger>
</Select>
)
}
return (
<Select
value={currentValue || ''}
onValueChange={(value) => handleOnValueChange(value)}
>
<SelectTrigger className="h-9 w-full rounded-xl border border-primary/15 bg-primaryAccent text-xs font-medium uppercase">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent className="border-none bg-primaryAccent font-dmmono shadow-lg">
{currentEntities.map((entity, index) => (
<SelectItem
className="cursor-pointer"
key={`${entity.id}-${index}`}
value={entity.id}
>
<div className="flex items-center gap-3 text-xs font-medium uppercase">
<Icon type={'user'} size="xs" />
{entity.name || entity.id}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View file

@ -0,0 +1,57 @@
'use client'
import * as React from 'react'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem
} from '@/components/ui/select'
import { useStore } from '@/store'
import { useQueryState } from 'nuqs'
import useChatActions from '@/hooks/useChatActions'
export function ModeSelector() {
const { mode, setMode, setMessages, setSelectedModel } = useStore()
const { clearChat } = useChatActions()
const [, setAgentId] = useQueryState('agent')
const [, setTeamId] = useQueryState('team')
const [, setSessionId] = useQueryState('session')
const handleModeChange = (newMode: 'agent' | 'team') => {
if (newMode === mode) return
setMode(newMode)
setAgentId(null)
setTeamId(null)
setSelectedModel('')
setMessages([])
setSessionId(null)
clearChat()
}
return (
<>
<Select
defaultValue={mode}
value={mode}
onValueChange={(value) => handleModeChange(value as 'agent' | 'team')}
>
<SelectTrigger className="h-9 w-full rounded-xl border border-primary/15 bg-primaryAccent text-xs font-medium uppercase">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-none bg-primaryAccent font-dmmono shadow-lg">
<SelectItem value="agent" className="cursor-pointer">
<div className="text-xs font-medium uppercase">Agent</div>
</SelectItem>
<SelectItem value="team" className="cursor-pointer">
<div className="text-xs font-medium uppercase">Team</div>
</SelectItem>
</SelectContent>
</Select>
</>
)
}

View file

@ -0,0 +1,25 @@
'use client'
import { Button } from '@/components/ui/button'
import Icon from '@/components/ui/icon'
import useChatActions from '@/hooks/useChatActions'
import { useStore } from '@/store'
function NewChatButton() {
const { clearChat } = useChatActions()
const { messages } = useStore()
return (
<Button
className="z-10 cursor-pointer rounded bg-brand px-4 py-2 font-bold text-primary hover:bg-brand/80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={clearChat}
disabled={messages.length === 0}
>
<div className="flex items-center gap-2">
<p>New Chat</p>{' '}
<Icon type="plus-icon" size="xs" className="text-background" />
</div>
</Button>
)
}
export default NewChatButton

View file

@ -0,0 +1,57 @@
import { type FC } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
interface DeleteSessionModalProps {
isOpen: boolean
onClose: () => void
onDelete: () => Promise<void>
isDeleting: boolean
}
const DeleteSessionModal: FC<DeleteSessionModalProps> = ({
isOpen,
onClose,
onDelete,
isDeleting
}) => (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="font-geist">
<DialogHeader>
<DialogTitle>Confirm deletion</DialogTitle>
<DialogDescription>
This will permanently delete the session. This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
className="rounded-xl border-border font-geist"
onClick={onClose}
disabled={isDeleting}
>
CANCEL
</Button>
<Button
variant="destructive"
onClick={onDelete}
disabled={isDeleting}
className="rounded-xl font-geist"
>
DELETE
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
export default DeleteSessionModal

View file

@ -0,0 +1,120 @@
import React from 'react'
import { useStore } from '@/store'
import { useQueryState } from 'nuqs'
const HistoryBlankStateIcon = () => (
<svg
width="90"
height="89"
viewBox="0 0 90 89"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M60.0192 18.2484L75.7339 21.2565C80.9549 22.2558 84.3771 27.2984 83.3777 32.5194L80.3697 48.2341C79.3703 53.455 74.3277 56.8773 69.1067 55.8779L53.3921 52.8698C48.1711 51.8704 44.7489 46.8278 45.7482 41.6069L48.7563 25.8922C49.7557 20.6712 54.7983 17.249 60.0192 18.2484Z"
stroke="white"
strokeOpacity="0.15"
strokeWidth="0.75"
/>
<path
d="M52.6787 34.7885C53.9351 28.225 60.2744 23.9228 66.8378 25.1792V25.1792C73.4013 26.4355 77.7036 32.7748 76.4472 39.3383V39.3383C75.1908 45.9017 68.8516 50.204 62.2881 48.9476V48.9476C55.7246 47.6913 51.4224 41.352 52.6787 34.7885V34.7885Z"
fill="#FF4017"
/>
<g clipPath="url(#clip1_9008_17675)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M59.8503 32.7422C59.5795 32.6962 59.3962 32.4446 59.4409 32.1802C59.6051 31.2079 60.0017 30.4826 60.7415 30.1543C61.4471 29.8413 62.3116 29.9643 63.221 30.2791C63.4804 30.3689 63.6182 30.6467 63.5286 30.8995C63.4391 31.1524 63.1562 31.2846 62.8968 31.1948C62.0492 30.9014 61.5019 30.8876 61.1589 31.0398C60.8501 31.1768 60.5613 31.519 60.4215 32.3469C60.3768 32.6112 60.1211 32.7882 59.8503 32.7422Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M70.6365 34.8074C70.9052 34.8646 71.1684 34.6985 71.2246 34.4363C71.431 33.4721 71.3304 32.6517 70.7641 32.0734C70.224 31.5218 69.3752 31.3169 68.4138 31.2736C68.1395 31.2612 67.909 31.4685 67.8988 31.7365C67.8886 32.0046 68.1026 32.2319 68.3769 32.2443C69.2729 32.2847 69.7866 32.474 70.0492 32.7421C70.2855 32.9835 70.4276 33.4081 70.2518 34.2291C70.1956 34.4912 70.3679 34.7502 70.6365 34.8074Z"
fill="white"
/>
<path
d="M63.6581 36.939C63.5614 37.4443 63.0733 37.7755 62.5681 37.6787C62.0628 37.582 61.7316 37.094 61.8283 36.5887C61.925 36.0834 62.413 35.7522 62.9183 35.849C63.4236 35.9457 63.7548 36.4337 63.6581 36.939Z"
fill="white"
/>
<path
d="M67.318 37.6392C67.2213 38.1444 66.7332 38.4756 66.228 38.3789C65.7227 38.2822 65.3915 37.7942 65.4882 37.2889C65.5849 36.7836 66.0729 36.4524 66.5782 36.5492C67.0835 36.6459 67.4147 37.1339 67.318 37.6392Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M69.4411 41.0527C69.7119 41.0986 69.8945 41.3539 69.8489 41.6229C69.6814 42.6122 69.2823 43.351 68.5409 43.6873C67.8339 44.0079 66.9693 43.8856 66.0603 43.5684C65.801 43.478 65.6641 43.1959 65.7545 42.9385C65.8449 42.6811 66.1284 42.5457 66.3877 42.6362C67.2348 42.9318 67.7825 42.944 68.1262 42.7881C68.4356 42.6478 68.7257 42.2989 68.8683 41.4566C68.9138 41.1876 69.1703 41.0068 69.4411 41.0527Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M58.6548 38.9885C58.3862 38.9312 58.1223 39.101 58.0652 39.3678C57.8555 40.349 57.9536 41.183 58.5184 41.7692C59.057 42.3283 59.9057 42.534 60.8675 42.5749C61.1419 42.5866 61.3733 42.3751 61.3844 42.1025C61.3954 41.8298 61.182 41.5994 60.9076 41.5877C60.0112 41.5495 59.4977 41.3586 59.2359 41.0868C59.0002 40.8422 58.8594 40.4108 59.038 39.5754C59.095 39.3086 58.9234 39.0458 58.6548 38.9885Z"
fill="white"
/>
</g>
<path
d="M4.32008 49.0567C3.54981 43.797 7.18916 38.9088 12.4488 38.1386L28.2799 35.8201C33.5396 35.0498 38.4278 38.6892 39.198 43.9488L41.5165 59.7799C42.2868 65.0396 38.6474 69.9278 33.3878 70.698L17.5567 73.0165C12.297 73.7868 7.40882 70.1474 6.63855 64.8878L4.32008 49.0567Z"
stroke="white"
strokeOpacity="0.15"
strokeWidth="0.75"
/>
<path
d="M11.0451 56.1568C10.085 49.5994 14.6225 43.5051 21.18 42.545C27.7375 41.5848 33.8318 46.1224 34.7919 52.6799C35.7521 59.2374 31.2145 65.3316 24.657 66.2918C18.0995 67.2519 12.0053 62.7143 11.0451 56.1568Z"
fill="white"
/>
<g clipPath="url(#clip0_7378_12246)">
<path
d="M29.1447 55.5281C29.1959 55.878 29.1061 56.2339 28.8949 56.5175C28.6837 56.8011 28.3685 56.9893 28.0186 57.0405L20.103 58.1995L17.8508 61.2244L16.3055 50.6702C16.2542 50.3203 16.3441 49.9644 16.5553 49.6808C16.7665 49.3971 17.0817 49.209 17.4316 49.1578L26.6664 47.8056C27.0163 47.7544 27.3722 47.8443 27.6559 48.0554C27.9395 48.2666 28.1276 48.5818 28.1789 48.9317L29.1447 55.5281Z"
stroke="black"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_7378_12246">
<rect
width="16"
height="16"
fill="white"
transform="translate(13.8438 47.6617) rotate(-8.33)"
/>
</clipPath>
</defs>
</svg>
)
const SessionBlankState = () => {
const { selectedEndpoint, isEndpointActive } = useStore()
const [agentId] = useQueryState('agent')
const errorMessage = (() => {
switch (true) {
case !isEndpointActive:
return 'Endpoint is not connected. Please connect the endpoint to see the history.'
case !selectedEndpoint:
return 'Select an endpoint to see the history.'
case !agentId:
return 'Select an agent to see the history.'
default:
return 'No session records yet. Start a conversation to create one.'
}
})()
return (
<div className="mt-1 flex items-center justify-center rounded-lg bg-background-secondary/50 pb-6 pt-4">
<div className="flex flex-col items-center gap-1">
<HistoryBlankStateIcon />
<div className="flex flex-col items-center gap-2">
<h3 className="text-sm font-medium text-primary">No Session found</h3>
<p className="max-w-[210px] text-center text-sm text-muted">
{errorMessage}
</p>
</div>
</div>
</div>
)
}
export default SessionBlankState

View file

@ -0,0 +1,127 @@
import { useQueryState } from 'nuqs'
import { SessionEntry } from '@/types/os'
import { Button } from '../../../ui/button'
import useSessionLoader from '@/hooks/useSessionLoader'
import { deleteSessionAPI } from '@/api/os'
import { useStore } from '@/store'
import { toast } from 'sonner'
import Icon from '@/components/ui/icon'
import { useState } from 'react'
import DeleteSessionModal from './DeleteSessionModal'
import useChatActions from '@/hooks/useChatActions'
import { truncateText, cn } from '@/lib/utils'
type SessionItemProps = SessionEntry & {
isSelected: boolean
currentSessionId: string | null
onSessionClick: () => void
}
const SessionItem = ({
session_name: title,
session_id,
isSelected,
currentSessionId,
onSessionClick
}: SessionItemProps) => {
const [agentId] = useQueryState('agent')
const [teamId] = useQueryState('team')
const [dbId] = useQueryState('db_id')
const [, setSessionId] = useQueryState('session')
const authToken = useStore((state) => state.authToken)
const { getSession } = useSessionLoader()
const { selectedEndpoint, sessionsData, setSessionsData, mode } = useStore()
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const { clearChat } = useChatActions()
const handleGetSession = async () => {
if (!(agentId || teamId || dbId)) return
onSessionClick()
await getSession(
{
entityType: mode,
agentId,
teamId,
dbId: dbId ?? ''
},
session_id
)
setSessionId(session_id)
}
const handleDeleteSession = async () => {
if (!(agentId || teamId || dbId)) return
setIsDeleting(true)
try {
const response = await deleteSessionAPI(
selectedEndpoint,
dbId ?? '',
session_id,
authToken
)
if (response?.ok && sessionsData) {
setSessionsData(sessionsData.filter((s) => s.session_id !== session_id))
// If the deleted session was the active one, clear the chat
if (currentSessionId === session_id) {
setSessionId(null)
clearChat()
}
toast.success('Session deleted')
} else {
const errorMsg = await response?.text()
toast.error(
`Failed to delete session: ${response?.statusText || 'Unknown error'} ${errorMsg || ''}`
)
}
} catch (error) {
toast.error(
`Failed to delete session: ${error instanceof Error ? error.message : String(error)}`
)
} finally {
setIsDeleteModalOpen(false)
setIsDeleting(false)
}
}
return (
<>
<div
className={cn(
'group flex h-11 w-full items-center justify-between rounded-lg px-3 py-2 transition-colors duration-200',
isSelected
? 'cursor-default bg-primary/10'
: 'cursor-pointer bg-background-secondary hover:bg-background-secondary/80'
)}
onClick={handleGetSession}
>
<div className="flex flex-col gap-1">
<h4
className={cn('text-sm font-medium', isSelected && 'text-primary')}
>
{truncateText(title, 20)}
</h4>
</div>
<Button
variant="ghost"
size="icon"
className="transform opacity-0 transition-all duration-200 ease-in-out group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
setIsDeleteModalOpen(true)
}}
>
<Icon type="trash" size="xs" />
</Button>
</div>
<DeleteSessionModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onDelete={handleDeleteSession}
isDeleting={isDeleting}
/>
</>
)
}
export default SessionItem

View file

@ -0,0 +1,172 @@
'use client'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQueryState } from 'nuqs'
import { useStore } from '@/store'
import useSessionLoader from '@/hooks/useSessionLoader'
import SessionItem from './SessionItem'
import SessionBlankState from './SessionBlankState'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
interface SkeletonListProps {
skeletonCount: number
}
const SkeletonList: FC<SkeletonListProps> = ({ skeletonCount }) => {
const list = useMemo(
() => Array.from({ length: skeletonCount }, (_, i) => i),
[skeletonCount]
)
return list.map((k, idx) => (
<Skeleton
key={k}
className={cn(
'mb-1 h-11 rounded-lg px-3 py-2',
idx > 0 && 'bg-background-secondary'
)}
/>
))
}
const Sessions = () => {
const [agentId] = useQueryState('agent', {
parse: (v: string | null) => v || undefined,
history: 'push'
})
const [teamId] = useQueryState('team')
const [sessionId] = useQueryState('session')
const [dbId] = useQueryState('db_id')
const {
selectedEndpoint,
mode,
isEndpointActive,
isEndpointLoading,
hydrated,
sessionsData,
setSessionsData,
isSessionsLoading
} = useStore()
const [isScrolling, setIsScrolling] = useState(false)
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
null
)
const { getSessions, getSession } = useSessionLoader()
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleScroll = () => {
setIsScrolling(true)
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current)
}
scrollTimeoutRef.current = setTimeout(() => {
setIsScrolling(false)
}, 1500)
}
// Cleanup the scroll timeout when component unmounts
useEffect(() => {
return () => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current)
}
}
}, [])
useEffect(() => {
if (hydrated && sessionId && selectedEndpoint && (agentId || teamId)) {
const entityType = agentId ? 'agent' : 'team'
getSession({ entityType, agentId, teamId, dbId }, sessionId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hydrated, sessionId, selectedEndpoint, agentId, teamId, dbId])
useEffect(() => {
if (!selectedEndpoint || isEndpointLoading) return
if (!(agentId || teamId || dbId)) {
setSessionsData([])
return
}
setSessionsData([])
getSessions({
entityType: mode,
agentId,
teamId,
dbId
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedEndpoint,
agentId,
teamId,
mode,
isEndpointLoading,
getSessions,
dbId
])
useEffect(() => {
if (sessionId) setSelectedSessionId(sessionId)
}, [sessionId])
const handleSessionClick = useCallback(
(id: string) => () => setSelectedSessionId(id),
[]
)
if (isSessionsLoading || isEndpointLoading) {
return (
<div className="w-full">
<div className="mb-2 text-xs font-medium uppercase">Sessions</div>
<div className="mt-4 h-[calc(100vh-325px)] w-full overflow-y-auto">
<SkeletonList skeletonCount={5} />
</div>
</div>
)
}
return (
<div className="w-full">
<div className="mb-2 w-full text-xs font-medium uppercase">Sessions</div>
<div
className={`h-[calc(100vh-345px)] overflow-y-auto font-geist transition-all duration-300 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar]:transition-opacity [&::-webkit-scrollbar]:duration-300 ${
isScrolling
? '[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-background [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:opacity-0'
: '[&::-webkit-scrollbar]:opacity-100'
}`}
onScroll={handleScroll}
onMouseOver={() => setIsScrolling(true)}
onMouseLeave={handleScroll}
>
{!isEndpointActive ||
(!isSessionsLoading &&
(!sessionsData || sessionsData?.length === 0)) ? (
<SessionBlankState />
) : (
<div className="flex flex-col gap-y-1 pr-1">
{sessionsData?.map((entry, idx) => (
<SessionItem
key={`${entry?.session_id}-${idx}`}
currentSessionId={selectedSessionId}
isSelected={selectedSessionId === entry?.session_id}
onSessionClick={handleSessionClick(entry?.session_id)}
session_name={entry?.session_name ?? '-'}
session_id={entry?.session_id}
created_at={entry?.created_at}
/>
))}
</div>
)}
</div>
</div>
)
}
export default Sessions

View file

@ -0,0 +1,3 @@
import Sessions from './Sessions'
export default Sessions

View file

@ -0,0 +1,315 @@
'use client'
import { Button } from '@/components/ui/button'
import { ModeSelector } from '@/components/chat/Sidebar/ModeSelector'
import { EntitySelector } from '@/components/chat/Sidebar/EntitySelector'
import useChatActions from '@/hooks/useChatActions'
import { useStore } from '@/store'
import { motion, AnimatePresence } from 'framer-motion'
import { useState, useEffect } from 'react'
import Icon from '@/components/ui/icon'
import { getProviderIcon } from '@/lib/modelProvider'
import Sessions from './Sessions'
import AuthToken from './AuthToken'
import { isValidUrl } from '@/lib/utils'
import { toast } from 'sonner'
import { useQueryState } from 'nuqs'
import { truncateText } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'
const ENDPOINT_PLACEHOLDER = 'NO ENDPOINT ADDED'
const SidebarHeader = () => (
<div className="flex items-center gap-2">
<Icon type="agno" size="xs" />
<span className="text-xs font-medium uppercase text-white">Agent UI</span>
</div>
)
const NewChatButton = ({
disabled,
onClick
}: {
disabled: boolean
onClick: () => void
}) => (
<Button
onClick={onClick}
disabled={disabled}
size="lg"
className="h-9 w-full rounded-xl bg-primary text-xs font-medium text-background hover:bg-primary/80"
>
<Icon type="plus-icon" size="xs" className="text-background" />
<span className="uppercase">New Chat</span>
</Button>
)
const ModelDisplay = ({ model }: { model: string }) => (
<div className="flex h-9 w-full items-center gap-3 rounded-xl border border-primary/15 bg-accent p-3 text-xs font-medium uppercase text-muted">
{(() => {
const icon = getProviderIcon(model)
return icon ? <Icon type={icon} className="shrink-0" size="xs" /> : null
})()}
{model}
</div>
)
const Endpoint = () => {
const {
selectedEndpoint,
isEndpointActive,
setSelectedEndpoint,
setAgents,
setSessionsData,
setMessages
} = useStore()
const { initialize } = useChatActions()
const [isEditing, setIsEditing] = useState(false)
const [endpointValue, setEndpointValue] = useState('')
const [isMounted, setIsMounted] = useState(false)
const [isHovering, setIsHovering] = useState(false)
const [isRotating, setIsRotating] = useState(false)
const [, setAgentId] = useQueryState('agent')
const [, setSessionId] = useQueryState('session')
useEffect(() => {
setEndpointValue(selectedEndpoint)
setIsMounted(true)
}, [selectedEndpoint])
const getStatusColor = (isActive: boolean) =>
isActive ? 'bg-positive' : 'bg-destructive'
const handleSave = async () => {
if (!isValidUrl(endpointValue)) {
toast.error('Please enter a valid URL')
return
}
const cleanEndpoint = endpointValue.replace(/\/$/, '').trim()
setSelectedEndpoint(cleanEndpoint)
setAgentId(null)
setSessionId(null)
setIsEditing(false)
setIsHovering(false)
setAgents([])
setSessionsData([])
setMessages([])
}
const handleCancel = () => {
setEndpointValue(selectedEndpoint)
setIsEditing(false)
setIsHovering(false)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSave()
} else if (e.key === 'Escape') {
handleCancel()
}
}
const handleRefresh = async () => {
setIsRotating(true)
await initialize()
setTimeout(() => setIsRotating(false), 500)
}
return (
<div className="flex flex-col items-start gap-2">
<div className="text-xs font-medium uppercase text-primary">AgentOS</div>
{isEditing ? (
<div className="flex w-full items-center gap-1">
<input
type="text"
value={endpointValue}
onChange={(e) => setEndpointValue(e.target.value)}
onKeyDown={handleKeyDown}
className="flex h-9 w-full items-center text-ellipsis rounded-xl border border-primary/15 bg-accent p-3 text-xs font-medium text-muted"
autoFocus
/>
<Button
variant="ghost"
size="icon"
onClick={handleSave}
className="hover:cursor-pointer hover:bg-transparent"
>
<Icon type="save" size="xs" />
</Button>
</div>
) : (
<div className="flex w-full items-center gap-1">
<motion.div
className="relative flex h-9 w-full cursor-pointer items-center justify-between rounded-xl border border-primary/15 bg-accent p-3 uppercase"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onClick={() => setIsEditing(true)}
transition={{ type: 'spring', stiffness: 400, damping: 10 }}
>
<AnimatePresence mode="wait">
{isHovering ? (
<motion.div
key="endpoint-display-hover"
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<p className="flex items-center gap-2 whitespace-nowrap text-xs font-medium text-primary">
<Icon type="edit" size="xxs" /> EDIT AGENTOS
</p>
</motion.div>
) : (
<motion.div
key="endpoint-display"
className="absolute inset-0 flex items-center justify-between px-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<p className="text-xs font-medium text-muted">
{isMounted
? truncateText(selectedEndpoint, 21) ||
ENDPOINT_PLACEHOLDER
: 'http://localhost:7777'}
</p>
<div
className={`size-2 shrink-0 rounded-full ${getStatusColor(isEndpointActive)}`}
/>
</motion.div>
)}
</AnimatePresence>
</motion.div>
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
className="hover:cursor-pointer hover:bg-transparent"
>
<motion.div
key={isRotating ? 'rotating' : 'idle'}
animate={{ rotate: isRotating ? 360 : 0 }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
>
<Icon type="refresh" size="xs" />
</motion.div>
</Button>
</div>
)}
</div>
)
}
const Sidebar = ({
hasEnvToken,
envToken
}: {
hasEnvToken?: boolean
envToken?: string
}) => {
const [isCollapsed, setIsCollapsed] = useState(false)
const { clearChat, focusChatInput, initialize } = useChatActions()
const {
messages,
selectedEndpoint,
isEndpointActive,
selectedModel,
hydrated,
isEndpointLoading,
mode
} = useStore()
const [isMounted, setIsMounted] = useState(false)
const [agentId] = useQueryState('agent')
const [teamId] = useQueryState('team')
useEffect(() => {
setIsMounted(true)
if (hydrated) initialize()
}, [selectedEndpoint, initialize, hydrated, mode])
const handleNewChat = () => {
clearChat()
focusChatInput()
}
return (
<motion.aside
className="relative flex h-screen shrink-0 grow-0 flex-col overflow-hidden px-2 py-3 font-dmmono"
initial={{ width: '16rem' }}
animate={{ width: isCollapsed ? '2.5rem' : '16rem' }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<motion.button
onClick={() => setIsCollapsed(!isCollapsed)}
className="absolute right-2 top-2 z-10 p-1"
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
type="button"
whileTap={{ scale: 0.95 }}
>
<Icon
type="sheet"
size="xs"
className={`transform ${isCollapsed ? 'rotate-180' : 'rotate-0'}`}
/>
</motion.button>
<motion.div
className="w-60 space-y-5"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: isCollapsed ? 0 : 1, x: isCollapsed ? -20 : 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{
pointerEvents: isCollapsed ? 'none' : 'auto'
}}
>
<SidebarHeader />
<NewChatButton
disabled={messages.length === 0}
onClick={handleNewChat}
/>
{isMounted && (
<>
<Endpoint />
<AuthToken hasEnvToken={hasEnvToken} envToken={envToken} />
{isEndpointActive && (
<>
<motion.div
className="flex w-full flex-col items-start gap-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
>
<div className="text-xs font-medium uppercase text-primary">
Mode
</div>
{isEndpointLoading ? (
<div className="flex w-full flex-col gap-2">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton
key={index}
className="h-9 w-full rounded-xl"
/>
))}
</div>
) : (
<>
<ModeSelector />
<EntitySelector />
{selectedModel && (agentId || teamId) && (
<ModelDisplay model={selectedModel} />
)}
</>
)}
</motion.div>
<Sessions />
</>
)}
</>
)}
</motion.div>
</motion.aside>
)
}
export default Sidebar

View file

@ -0,0 +1,3 @@
import Sidebar from './Sidebar'
export default Sidebar

View file

@ -0,0 +1,57 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View file

@ -0,0 +1,122 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '@/lib/utils'
import Icon from './icon'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid max-h-[623px] w-full max-w-[440px] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-hidden rounded-[12px] border border-border bg-background/100 p-6 shadow-md duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className
)}
{...props}
>
<div className="scrollbar-none overflow-auto">{children}</div>
<DialogPrimitive.Close className="focus:ring-ring data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent">
<Icon type="x" size="xs" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}

View file

@ -0,0 +1,35 @@
import { type FC } from 'react'
import { cn } from '@/lib/utils'
import { ICONS } from './constants'
import { type IconProps } from './types'
const Icon: FC<IconProps> = ({
type,
size = 'sm',
className,
color,
disabled = false
}) => {
const IconElement = ICONS[type]
return (
<IconElement
className={cn(
color && !disabled ? `text-${color}` : 'text-primary',
disabled && 'cursor-default text-muted/50',
className,
size === 'xxs' && 'size-3',
size === 'xs' && 'size-4',
size === 'sm' && 'size-6',
size === 'md' && 'size-[42px]',
size === 'lg' && 'size-7',
size === 'dot' && 'size-[5.07px]',
size === 'default' && ' '
)}
/>
)
}
export default Icon

View file

@ -0,0 +1,79 @@
import {
MistralLogo,
OpenAILogo,
GeminiLogo,
AwsLogo,
AzureLogo,
AnthropicLogo,
GroqLogo,
FireworksLogo,
DeepseekLogo,
CohereLogo,
OllamaLogo,
XaiLogo,
AgnoIcon,
UserIcon,
AgentIcon,
SheetIcon,
NextjsTag,
ShadcnTag,
TailwindTag,
AgnoTag,
ReasoningIcon,
ReferencesIcon
} from './custom-icons'
import { IconTypeMap } from './types'
import {
RefreshCw,
Edit,
Save,
X,
ArrowDown,
SendIcon,
Download,
HammerIcon,
Check,
ChevronDown,
ChevronUp,
Trash
} from 'lucide-react'
import { PlusIcon } from '@radix-ui/react-icons'
export const ICONS: IconTypeMap = {
'open-ai': OpenAILogo,
mistral: MistralLogo,
gemini: GeminiLogo,
aws: AwsLogo,
azure: AzureLogo,
anthropic: AnthropicLogo,
groq: GroqLogo,
fireworks: FireworksLogo,
deepseek: DeepseekLogo,
cohere: CohereLogo,
ollama: OllamaLogo,
xai: XaiLogo,
agno: AgnoIcon,
user: UserIcon,
agent: AgentIcon,
sheet: SheetIcon,
nextjs: NextjsTag,
shadcn: ShadcnTag,
tailwind: TailwindTag,
reasoning: ReasoningIcon,
'agno-tag': AgnoTag,
refresh: RefreshCw,
edit: Edit,
save: Save,
x: X,
'arrow-down': ArrowDown,
send: SendIcon,
download: Download,
hammer: HammerIcon,
check: Check,
'chevron-down': ChevronDown,
'chevron-up': ChevronUp,
'plus-icon': PlusIcon,
references: ReferencesIcon,
trash: Trash
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,5 @@
import Icon from './Icon'
export { type IconType } from './types'
export default Icon

View file

@ -0,0 +1,50 @@
import { type ElementType } from 'react'
export type IconType =
| 'mistral'
| 'gemini'
| 'aws'
| 'azure'
| 'anthropic'
| 'groq'
| 'fireworks'
| 'deepseek'
| 'cohere'
| 'ollama'
| 'xai'
| 'agno'
| 'user'
| 'agent'
| 'open-ai'
| 'sheet'
| 'nextjs'
| 'shadcn'
| 'tailwind'
| 'reasoning'
| 'agno-tag'
| 'refresh'
| 'edit'
| 'save'
| 'x'
| 'arrow-down'
| 'send'
| 'download'
| 'hammer'
| 'check'
| 'chevron-down'
| 'chevron-up'
| 'plus-icon'
| 'references'
| 'trash'
export interface IconProps {
type: IconType
size?: 'xs' | 'sm' | 'md' | 'lg' | 'dot' | 'xxs' | 'default'
className?: string
color?: string
disabled?: boolean
}
export type IconTypeMap = {
[key in IconType]: ElementType
}

View file

@ -0,0 +1,159 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { cn } from '@/lib/utils'
import Icon from './icon'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'border-input placeholder:text-muted-foreground focus:ring-ring flex w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent p-3 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<Icon type="chevron-down" size="xs" className="opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<Icon type="chevron-up" size="xs" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<Icon type="chevron-down" size="xs" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border bg-primary text-secondary shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'flex flex-col gap-1 p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-xl px-2 py-2.5 text-sm outline-none focus:bg-primary/10 focus:text-secondary data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Icon type="check" size="xs" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton
}

View file

@ -0,0 +1,15 @@
import { cn } from '@/lib/utils'
const Skeleton = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
className={cn('animate-pulse rounded-md bg-primary/10', className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -0,0 +1,31 @@
'use client'
import { useTheme } from 'next-themes'
import { Toaster as Sonner } from 'sonner'
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground'
}
}}
{...props}
/>
)
}
export { Toaster }

View file

@ -0,0 +1,101 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
className?: string
value?: string
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
}
const MIN_HEIGHT = 40
const MAX_HEIGHT = 96
const TextArea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, value, onChange, ...props }, forwardedRef) => {
const [showScroll, setShowScroll] = React.useState(false)
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
const adjustHeight = React.useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.style.height = `${MIN_HEIGHT}px`
const { scrollHeight } = textarea
const newHeight = Math.min(Math.max(scrollHeight, MIN_HEIGHT), MAX_HEIGHT)
textarea.style.height = `${newHeight}px`
setShowScroll(scrollHeight > MAX_HEIGHT)
}, [])
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const cursorPosition = e.target.selectionStart
onChange?.(e)
requestAnimationFrame(() => {
adjustHeight()
if (textareaRef.current) {
textareaRef.current.setSelectionRange(
cursorPosition,
cursorPosition
)
}
})
},
[onChange, adjustHeight]
)
const handleRef = React.useCallback(
(node: HTMLTextAreaElement | null) => {
const ref = forwardedRef as
| React.MutableRefObject<HTMLTextAreaElement | null>
| ((instance: HTMLTextAreaElement | null) => void)
| null
if (typeof ref === 'function') {
ref(node)
} else if (ref) {
ref.current = node
}
textareaRef.current = node
},
[forwardedRef]
)
React.useEffect(() => {
if (textareaRef.current) {
adjustHeight()
}
}, [value, adjustHeight])
return (
<textarea
className={cn(
'w-full resize-none bg-transparent shadow-sm',
'rounded-xl border border-border',
'px-3 py-2',
'text-sm leading-5',
'placeholder:text-muted-foreground',
'focus-visible:ring-0.5 focus-visible:ring-ring focus-visible:border-primary/50 focus-visible:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
showScroll ? 'overflow-y-auto' : 'overflow-hidden',
className
)}
style={{
minHeight: `${MIN_HEIGHT}px`,
height: `${MIN_HEIGHT}px`,
maxHeight: `${MAX_HEIGHT}px`
}}
ref={handleRef}
value={value}
onChange={handleChange}
{...props}
/>
)
}
)
TextArea.displayName = 'TextArea'
export type { TextareaProps }
export { TextArea }

View file

@ -0,0 +1,30 @@
import { type FC } from 'react'
import {
TooltipProvider,
Tooltip as BaseTooltip,
TooltipContent,
TooltipTrigger
} from '@/components/ui/tooltip/tooltip'
import type { TooltipProps } from '@/components/ui/tooltip/types'
const Tooltip: FC<TooltipProps> = ({
className,
children,
content,
side,
delayDuration,
contentClassName
}) => (
<TooltipProvider delayDuration={delayDuration}>
<BaseTooltip>
<TooltipTrigger className={className}>{children}</TooltipTrigger>
<TooltipContent side={side} className={contentClassName}>
{content}
</TooltipContent>
</BaseTooltip>
</TooltipProvider>
)
export default Tooltip

View file

@ -0,0 +1,3 @@
import Tooltip from './CustomTooltip'
export default Tooltip

View file

@ -0,0 +1,44 @@
'use client'
import * as React from 'react'
import {
type ComponentPropsWithoutRef,
type ElementRef,
forwardRef
} from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
interface TooltipContentProps
extends ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> {
type?: 'default' | 'copy'
}
const TooltipContent = forwardRef<
ElementRef<typeof TooltipPrimitive.Content>,
TooltipContentProps
>(({ className, sideOffset = 4, type = 'default', ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-sm px-2 py-1 text-sm animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
type === 'default' && 'bg-primary text-accent',
type === 'copy' && 'bg-custom-gradient text-primary',
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -0,0 +1,10 @@
import type { ReactNode } from 'react'
export interface TooltipProps {
children: ReactNode
content: ReactNode
className?: string
side?: 'top' | 'right' | 'bottom' | 'left' | undefined
delayDuration?: number
contentClassName?: string
}

View file

@ -0,0 +1,25 @@
'use client'
import { type FC, type JSX } from 'react'
import { cn } from '@/lib/utils'
import { HEADING_SIZES } from './constants'
import { type HeadingProps } from './types'
const Heading: FC<HeadingProps> = ({ children, size, fontSize, className }) => {
const Tag = `h${size}` as keyof JSX.IntrinsicElements
return (
<Tag
className={cn(
'flex items-center gap-x-3 font-semibold',
fontSize ? HEADING_SIZES[fontSize] : HEADING_SIZES[size],
className
)}
>
{children}
</Tag>
)
}
export default Heading

View file

@ -0,0 +1,10 @@
import { type HeadingSizeMap } from './types'
export const HEADING_SIZES: HeadingSizeMap = {
1: 'text-4xl font-semibold font-inter',
2: 'text-3xl font-medium font-inter',
3: 'text-2xl font-inter font-medium',
4: 'text-xl',
5: 'text-lg',
6: 'text-base'
}

View file

@ -0,0 +1,3 @@
import Heading from './Heading'
export default Heading

View file

@ -0,0 +1,17 @@
import { type ReactNode } from 'react'
import { type IconType } from '@/components/ui/icon'
type HeadingSize = 1 | 2 | 3 | 4 | 5 | 6
export interface HeadingProps {
children: string | ReactNode
size: HeadingSize
fontSize?: HeadingSize
className?: string
icon?: IconType
}
export type HeadingSizeMap = {
[key in HeadingSize]: string
}

View file

@ -0,0 +1,31 @@
import { type FC } from 'react'
import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
import { type MarkdownRendererProps } from './types'
import { inlineComponents } from './inlineStyles'
import { components } from './styles'
const MarkdownRenderer: FC<MarkdownRendererProps> = ({
children,
classname,
inline = false
}) => (
<ReactMarkdown
className={cn(
'prose prose-h1:text-xl dark:prose-invert flex w-full flex-col gap-y-5 rounded-lg',
classname
)}
components={{ ...(inline ? inlineComponents : components) }}
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{children}
</ReactMarkdown>
)
export default MarkdownRenderer

View file

@ -0,0 +1,3 @@
import MarkdownRenderer from './MarkdownRenderer'
export default MarkdownRenderer

View file

@ -0,0 +1,204 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import type {
UnorderedListProps,
OrderedListProps,
EmphasizedTextProps,
ItalicTextProps,
StrongTextProps,
BoldTextProps,
DeletedTextProps,
UnderlinedTextProps,
HorizontalRuleProps,
BlockquoteProps,
AnchorLinkProps,
HeadingProps,
ImgProps,
ParagraphProps
} from './types'
import { PARAGRAPH_SIZES } from '../Paragraph/constants'
const filterProps = (props: object) => {
const newProps = { ...props }
if ('node' in newProps) {
delete newProps.node
}
return newProps
}
const UnorderedList = ({ className, ...props }: UnorderedListProps) => (
<ul
className={cn(
className,
PARAGRAPH_SIZES.lead,
'flex list-disc flex-col pl-10'
)}
{...filterProps(props)}
/>
)
const OrderedList = ({ className, ...props }: OrderedListProps) => (
<ol
className={cn(
className,
PARAGRAPH_SIZES.lead,
'flex list-decimal flex-col pl-10'
)}
{...filterProps(props)}
/>
)
const Paragraph = ({ className, ...props }: ParagraphProps) => (
<p className={cn(className, PARAGRAPH_SIZES.lead)} {...filterProps(props)} />
)
const EmphasizedText = ({ className, ...props }: EmphasizedTextProps) => (
<em
className={cn(className, 'PARAGRAPH_SIZES.lead')}
{...filterProps(props)}
/>
)
const ItalicText = ({ className, ...props }: ItalicTextProps) => (
<i className={cn(className, PARAGRAPH_SIZES.lead)} {...filterProps(props)} />
)
const StrongText = ({ className, ...props }: StrongTextProps) => (
<strong
className={cn(className, 'PARAGRAPH_SIZES.lead')}
{...filterProps(props)}
/>
)
const BoldText = ({ className, ...props }: BoldTextProps) => (
<b
className={cn(className, 'PARAGRAPH_SIZES.lead')}
{...filterProps(props)}
/>
)
const UnderlinedText = ({ className, ...props }: UnderlinedTextProps) => (
<u
className={cn(className, 'underline', PARAGRAPH_SIZES.lead)}
{...filterProps(props)}
/>
)
const DeletedText = ({ className, ...props }: DeletedTextProps) => (
<del
className={cn(className, 'text-muted line-through', PARAGRAPH_SIZES.lead)}
{...filterProps(props)}
/>
)
const HorizontalRule = ({ className, ...props }: HorizontalRuleProps) => (
<hr
className={cn(className, 'mx-auto w-48 border-b border-border')}
{...filterProps(props)}
/>
)
const Blockquote = ({ className, ...props }: BlockquoteProps) => (
<blockquote
className={cn(className, PARAGRAPH_SIZES.lead)}
{...filterProps(props)}
/>
)
const AnchorLink = ({ className, ...props }: AnchorLinkProps) => (
<a
className={cn(className, 'cursor-pointer text-xs underline')}
target="_blank"
rel="noopener noreferrer"
{...filterProps(props)}
/>
)
const Heading1 = ({ className, ...props }: HeadingProps) => (
<h1 className={cn(className, PARAGRAPH_SIZES.lead)} {...filterProps(props)} />
)
const Heading2 = ({ className, ...props }: HeadingProps) => (
<h2 className={cn(className, PARAGRAPH_SIZES.lead)} {...filterProps(props)} />
)
const Heading3 = ({ className, ...props }: HeadingProps) => (
<h3 className={cn(className, PARAGRAPH_SIZES.lead)} {...filterProps(props)} />
)
const Heading4 = ({ className, ...props }: HeadingProps) => (
<h4 className={cn(className, PARAGRAPH_SIZES.lead)} {...filterProps(props)} />
)
const Heading5 = ({ className, ...props }: HeadingProps) => (
<h5 className={cn(className, PARAGRAPH_SIZES.lead)} {...filterProps(props)} />
)
const Heading6 = ({ className, ...props }: HeadingProps) => (
<h6 className={cn(className, PARAGRAPH_SIZES.lead)} {...filterProps(props)} />
)
const Img = ({ src, alt }: ImgProps) => {
const [error, setError] = useState(false)
if (!src) return null
return (
<div className="w-full max-w-xl">
{error ? (
<div className="flex h-40 flex-col items-center justify-center gap-2 rounded-md bg-secondary/50 text-muted">
<Paragraph className="text-primary">Image unavailable</Paragraph>
<Link
href={src}
target="_blank"
className="max-w-md truncate underline"
>
{src}
</Link>
</div>
) : (
<Image
src={src}
width={96}
height={56}
alt={alt ?? 'Rendered image'}
className="size-full rounded-md object-cover"
onError={() => setError(true)}
unoptimized
/>
)}
</div>
)
}
export const inlineComponents = {
h1: Heading1,
h2: Heading2,
h3: Heading3,
h4: Heading4,
h5: Heading5,
h6: Heading6,
ul: UnorderedList,
ol: OrderedList,
em: EmphasizedText,
i: ItalicText,
strong: StrongText,
b: BoldText,
u: UnderlinedText,
del: DeletedText,
hr: HorizontalRule,
blockquote: Blockquote,
a: AnchorLink,
img: Img,
p: Paragraph
}

View file

@ -0,0 +1,281 @@
'use client'
import { FC, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import type {
UnorderedListProps,
OrderedListProps,
EmphasizedTextProps,
ItalicTextProps,
StrongTextProps,
BoldTextProps,
DeletedTextProps,
UnderlinedTextProps,
HorizontalRuleProps,
BlockquoteProps,
AnchorLinkProps,
HeadingProps,
ImgProps,
ParagraphProps,
TableHeaderCellProps,
TableProps,
TableHeaderProps,
TableBodyProps,
TableRowProps,
TableCellProps,
PreparedTextProps
} from './types'
import { HEADING_SIZES } from '../Heading/constants'
import { PARAGRAPH_SIZES } from '../Paragraph/constants'
const filterProps = (props: object) => {
const newProps = { ...props }
if ('node' in newProps) {
delete newProps.node
}
return newProps
}
const UnorderedList = ({ className, ...props }: UnorderedListProps) => (
<ul
className={cn(
className,
PARAGRAPH_SIZES.body,
'flex list-disc flex-col pl-10'
)}
{...filterProps(props)}
/>
)
const OrderedList = ({ className, ...props }: OrderedListProps) => (
<ol
className={cn(
className,
PARAGRAPH_SIZES.body,
'flex list-decimal flex-col pl-10'
)}
{...filterProps(props)}
/>
)
const Paragraph = ({ className, ...props }: ParagraphProps) => (
<div
className={cn(className, PARAGRAPH_SIZES.body)}
{...filterProps(props)}
/>
)
const EmphasizedText = ({ className, ...props }: EmphasizedTextProps) => (
<em
className={cn(className, 'text-sm font-semibold')}
{...filterProps(props)}
/>
)
const ItalicText = ({ className, ...props }: ItalicTextProps) => (
<i
className={cn(className, 'italic', PARAGRAPH_SIZES.body)}
{...filterProps(props)}
/>
)
const StrongText = ({ className, ...props }: StrongTextProps) => (
<strong
className={cn(className, 'text-sm font-semibold')}
{...filterProps(props)}
/>
)
const BoldText = ({ className, ...props }: BoldTextProps) => (
<b
className={cn(className, 'text-sm font-semibold')}
{...filterProps(props)}
/>
)
const UnderlinedText = ({ className, ...props }: UnderlinedTextProps) => (
<u
className={cn(className, 'underline', PARAGRAPH_SIZES.body)}
{...filterProps(props)}
/>
)
const DeletedText = ({ className, ...props }: DeletedTextProps) => (
<del
className={cn(className, 'text-muted line-through', PARAGRAPH_SIZES.body)}
{...filterProps(props)}
/>
)
const HorizontalRule = ({ className, ...props }: HorizontalRuleProps) => (
<hr
className={cn(className, 'mx-auto w-48 border-b border-border')}
{...filterProps(props)}
/>
)
const InlineCode: FC<PreparedTextProps> = ({ children }) => {
return (
<code className="relative whitespace-pre-wrap rounded-sm bg-background-secondary/50 p-1">
{children}
</code>
)
}
const Blockquote = ({ className, ...props }: BlockquoteProps) => (
<blockquote
className={cn(className, 'italic', PARAGRAPH_SIZES.body)}
{...filterProps(props)}
/>
)
const AnchorLink = ({ className, ...props }: AnchorLinkProps) => (
<a
className={cn(className, 'cursor-pointer text-xs underline')}
target="_blank"
rel="noopener noreferrer"
{...filterProps(props)}
/>
)
const Heading1 = ({ className, ...props }: HeadingProps) => (
<h1 className={cn(className, HEADING_SIZES[3])} {...filterProps(props)} />
)
const Heading2 = ({ className, ...props }: HeadingProps) => (
<h2 className={cn(className, HEADING_SIZES[3])} {...filterProps(props)} />
)
const Heading3 = ({ className, ...props }: HeadingProps) => (
<h3 className={cn(className, PARAGRAPH_SIZES.lead)} {...filterProps(props)} />
)
const Heading4 = ({ className, ...props }: HeadingProps) => (
<h4 className={cn(className, PARAGRAPH_SIZES.lead)} {...filterProps(props)} />
)
const Heading5 = ({ className, ...props }: HeadingProps) => (
<h5
className={cn(className, PARAGRAPH_SIZES.title)}
{...filterProps(props)}
/>
)
const Heading6 = ({ className, ...props }: HeadingProps) => (
<h6
className={cn(className, PARAGRAPH_SIZES.title)}
{...filterProps(props)}
/>
)
const Img = ({ src, alt }: ImgProps) => {
const [error, setError] = useState(false)
if (!src) return null
return (
<div className="w-full max-w-xl">
{error ? (
<div className="flex h-40 flex-col items-center justify-center gap-2 rounded-md bg-secondary/50 text-muted">
<Paragraph className="text-primary">Image unavailable</Paragraph>
<Link
href={src}
target="_blank"
className="max-w-md truncate underline"
>
{src}
</Link>
</div>
) : (
<Image
src={src}
width={1280}
height={720}
alt={alt ?? 'Rendered image'}
className="size-full rounded-md object-cover"
onError={() => setError(true)}
unoptimized
/>
)}
</div>
)
}
const Table = ({ className, ...props }: TableProps) => (
<div className="w-full max-w-[560px] overflow-hidden rounded-md border border-border">
<div className="w-full overflow-x-auto">
<table className={cn(className, 'w-full')} {...filterProps(props)} />
</div>
</div>
)
const TableHead = ({ className, ...props }: TableHeaderProps) => (
<thead
className={cn(
className,
'rounded-md border-b border-border bg-transparent p-2 text-left text-sm font-[600]'
)}
{...filterProps(props)}
/>
)
const TableHeadCell = ({ className, ...props }: TableHeaderCellProps) => (
<th
className={cn(className, 'p-2 text-sm font-[600]')}
{...filterProps(props)}
/>
)
const TableBody = ({ className, ...props }: TableBodyProps) => (
<tbody className={cn(className, 'text-xs')} {...filterProps(props)} />
)
const TableRow = ({ className, ...props }: TableRowProps) => (
<tr
className={cn(className, 'border-b border-border last:border-b-0')}
{...filterProps(props)}
/>
)
const TableCell = ({ className, ...props }: TableCellProps) => (
<td
className={cn(className, 'whitespace-nowrap p-2 font-[400]')}
{...filterProps(props)}
/>
)
export const components = {
h1: Heading1,
h2: Heading2,
h3: Heading3,
h4: Heading4,
h5: Heading5,
h6: Heading6,
ul: UnorderedList,
ol: OrderedList,
em: EmphasizedText,
i: ItalicText,
strong: StrongText,
b: BoldText,
u: UnderlinedText,
del: DeletedText,
hr: HorizontalRule,
blockquote: Blockquote,
code: InlineCode,
a: AnchorLink,
img: Img,
p: Paragraph,
table: Table,
thead: TableHead,
th: TableHeadCell,
tbody: TableBody,
tr: TableRow,
td: TableCell
}

View file

@ -0,0 +1,133 @@
import {
type HTMLAttributes,
type DetailedHTMLProps,
type OlHTMLAttributes,
type DelHTMLAttributes,
type BlockquoteHTMLAttributes,
type AnchorHTMLAttributes,
type ImgHTMLAttributes
} from 'react'
interface MarkdownRendererProps {
children?: string
classname?: string
inline?: boolean
}
type DefaultHTMLElement = DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
HTMLElement
>
type UnorderedListProps = DetailedHTMLProps<
HTMLAttributes<HTMLUListElement>,
HTMLUListElement
>
type OrderedListProps = DetailedHTMLProps<
OlHTMLAttributes<HTMLOListElement>,
HTMLOListElement
>
type EmphasizedTextProps = DefaultHTMLElement
type ItalicTextProps = DefaultHTMLElement
type StrongTextProps = DefaultHTMLElement
type BoldTextProps = DefaultHTMLElement
type UnderlinedTextProps = DefaultHTMLElement
type DeletedTextProps = DetailedHTMLProps<
DelHTMLAttributes<HTMLModElement>,
HTMLModElement
>
type HorizontalRuleProps = DetailedHTMLProps<
HTMLAttributes<HTMLHRElement>,
HTMLHRElement
>
type PreparedTextProps = DetailedHTMLProps<
HTMLAttributes<HTMLPreElement>,
HTMLPreElement
>
type BlockquoteProps = DetailedHTMLProps<
BlockquoteHTMLAttributes<HTMLQuoteElement>,
HTMLQuoteElement
>
type AnchorLinkProps = DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>
type HeadingProps = DetailedHTMLProps<
HTMLAttributes<HTMLHeadingElement>,
HTMLHeadingElement
>
type ImgProps = DetailedHTMLProps<
ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>
type ParagraphProps = DetailedHTMLProps<
HTMLAttributes<HTMLParagraphElement>,
HTMLParagraphElement
>
type TableProps = React.DetailedHTMLProps<
React.TableHTMLAttributes<HTMLTableElement>,
HTMLTableElement
>
type TableBodyProps = React.DetailedHTMLProps<
React.HTMLAttributes<HTMLTableSectionElement>,
HTMLTableSectionElement
>
type TableHeaderProps = React.DetailedHTMLProps<
React.HTMLAttributes<HTMLTableSectionElement>,
HTMLTableSectionElement
>
type TableHeaderCellProps = DetailedHTMLProps<
HTMLAttributes<HTMLTableHeaderCellElement>,
HTMLTableHeaderCellElement
>
type TableRowProps = DetailedHTMLProps<
HTMLAttributes<HTMLTableRowElement>,
HTMLTableRowElement
>
type TableCellProps = DetailedHTMLProps<
HTMLAttributes<HTMLTableCellElement>,
HTMLTableCellElement
>
export type {
MarkdownRendererProps,
UnorderedListProps,
OrderedListProps,
EmphasizedTextProps,
ItalicTextProps,
StrongTextProps,
BoldTextProps,
UnderlinedTextProps,
DeletedTextProps,
HorizontalRuleProps,
PreparedTextProps,
BlockquoteProps,
AnchorLinkProps,
HeadingProps,
ImgProps,
ParagraphProps,
TableProps,
TableHeaderProps,
TableHeaderCellProps,
TableBodyProps,
TableRowProps,
TableCellProps
}

View file

@ -0,0 +1,19 @@
import { type FC } from 'react'
import { cn } from '@/lib/utils'
import { PARAGRAPH_SIZES } from './constants'
import { type ParagraphProps } from './types'
const Paragraph: FC<ParagraphProps> = ({
children,
size = 'default',
className,
id
}) => (
<p id={id} className={cn(PARAGRAPH_SIZES[size], className)}>
{children}
</p>
)
export default Paragraph

View file

@ -0,0 +1,14 @@
import { type ParagraphSizeMap } from './types'
export const PARAGRAPH_SIZES: ParagraphSizeMap = {
xs: 'text-xs',
sm: 'text-sm',
default: 'text-base',
lg: 'text-lg',
lead: 'font-inter text-[1.125rem] font-medium leading-[1.35rem] tracking-[-0.01em] ',
title: 'font-inter text-[0.875rem] font-medium leading-5 tracking-[-0.02em]',
body: 'font-inter text-[0.875rem] font-normal leading-5 tracking-[-0.02em]',
mono: 'font-dmmono text-[0.75rem] font-normal leading-[1.125rem] tracking-[-0.02em]',
xsmall:
'font-inter text-[0.75rem] font-normal leading-[1.0625rem] tracking-[-0.02em]'
}

View file

@ -0,0 +1,3 @@
import Paragraph from './Paragraph'
export default Paragraph

View file

@ -0,0 +1,23 @@
import type { ReactNode } from 'react'
type ParagraphSizes =
| 'xs'
| 'sm'
| 'default'
| 'lg'
| 'lead'
| 'title'
| 'body'
| 'mono'
| 'xsmall'
export interface ParagraphProps {
children: ReactNode
size?: ParagraphSizes
className?: string
id?: string
}
export type ParagraphSizeMap = {
[key in ParagraphSizes]: string
}

View file

@ -0,0 +1,256 @@
import { RunResponseContent } from '@/types/os'
import { useCallback } from 'react'
/**
* Processes a single JSON chunk by passing it to the provided callback.
* @param chunk - A parsed JSON object of type RunResponseContent.
* @param onChunk - Callback to handle the chunk.
*/
function processChunk(
chunk: RunResponseContent,
onChunk: (chunk: RunResponseContent) => void
) {
onChunk(chunk)
}
// TODO: Make new format the default and phase out legacy format
/**
* Detects if the incoming data is in the legacy format (direct RunResponseContent)
* @param data - The parsed data object
* @returns true if it's in the legacy format, false if it's in the new format
*/
function isLegacyFormat(data: RunResponseContent): boolean {
return (
typeof data === 'object' &&
data !== null &&
'event' in data &&
!('data' in data) &&
typeof data.event === 'string'
)
}
interface NewFormatData {
event: string
data: string | Record<string, unknown>
}
type LegacyEventFormat = RunResponseContent & { event: string }
function convertNewFormatToLegacy(
newFormatData: NewFormatData
): LegacyEventFormat {
const { event, data } = newFormatData
// Parse the data field if it's a string
let parsedData: Record<string, unknown>
if (typeof data === 'string') {
try {
// First try to parse as JSON
parsedData = JSON.parse(data)
} catch {
parsedData = {}
}
} else {
parsedData = data
}
const { ...cleanData } = parsedData
// Convert to legacy format by flattening the structure
return {
event: event,
...cleanData
} as LegacyEventFormat
}
/**
* Parses a string buffer to extract complete JSON objects.
*
* This function discards any extraneous data before the first '{', then
* repeatedly finds and processes complete JSON objects.
*
* @param text - The accumulated string buffer.
* @param onChunk - Callback to process each parsed JSON object.
* @returns Remaining string that did not form a complete JSON object.
*/
/**
* Extracts complete JSON objects from a buffer string **incrementally**.
* - It allows partial JSON objects to accumulate across chunks.
* - It ensures real-time streaming updates.
*/
function parseBuffer(
buffer: string,
onChunk: (chunk: RunResponseContent) => void
): string {
let currentIndex = 0
let jsonStartIndex = buffer.indexOf('{', currentIndex)
// Process as many complete JSON objects as possible.
while (jsonStartIndex !== -1 && jsonStartIndex < buffer.length) {
let braceCount = 0
let inString = false
let escapeNext = false
let jsonEndIndex = -1
let i = jsonStartIndex
// Walk through the string to find the matching closing brace.
for (; i < buffer.length; i++) {
const char = buffer[i]
if (inString) {
if (escapeNext) {
escapeNext = false
} else if (char === '\\') {
escapeNext = true
} else if (char === '"') {
inString = false
}
} else {
if (char === '"') {
inString = true
} else if (char === '{') {
braceCount++
} else if (char === '}') {
braceCount--
if (braceCount === 0) {
jsonEndIndex = i
break
}
}
}
}
// If we found a complete JSON object, try to parse it.
if (jsonEndIndex !== -1) {
const jsonString = buffer.slice(jsonStartIndex, jsonEndIndex + 1)
try {
const parsed = JSON.parse(jsonString)
// Check if it's in the legacy format - use as is
if (isLegacyFormat(parsed)) {
processChunk(parsed, onChunk)
} else {
// New format - convert to legacy format for compatibility
const legacyChunk = convertNewFormatToLegacy(parsed)
processChunk(legacyChunk, onChunk)
}
} catch {
// Move past the starting brace to avoid re-parsing the same invalid JSON.
jsonStartIndex = buffer.indexOf('{', jsonStartIndex + 1)
continue
}
// Move currentIndex past the parsed JSON and trim any leading whitespace.
currentIndex = jsonEndIndex + 1
buffer = buffer.slice(currentIndex).trim()
// Reset currentIndex and search for the next JSON object.
currentIndex = 0
jsonStartIndex = buffer.indexOf('{', currentIndex)
} else {
// If a complete JSON object is not found, break out and wait for more data.
break
}
}
// Return any unprocessed (partial) data.
return buffer
}
/**
* Custom React hook to handle streaming API responses as JSON objects.
*
* This hook supports two streaming formats:
* 1. Legacy format: Direct JSON objects matching RunResponseContent interface
* 2. New format: Event/data structure with { event: string, data: string|object }
*
* The hook:
* - Accumulates partial JSON data from streaming responses.
* - Extracts complete JSON objects and processes them via onChunk.
* - Automatically detects new format and converts it to legacy format for compatibility.
* - Parses stringified data field if it's a string (supports both JSON and Python dict syntax).
* - Removes redundant event field from data object during conversion.
* - Handles errors via onError and signals completion with onComplete.
*
* @returns An object containing the streamResponse function.
*/
export default function useAIResponseStream() {
const streamResponse = useCallback(
async (options: {
apiUrl: string
headers?: Record<string, string>
requestBody: FormData | Record<string, unknown>
onChunk: (chunk: RunResponseContent) => void
onError: (error: Error) => void
onComplete: () => void
}): Promise<void> => {
const {
apiUrl,
headers = {},
requestBody,
onChunk,
onError,
onComplete
} = options
// Buffer to accumulate partial JSON data.
let buffer = ''
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
// Set content-type only for non-FormData requests.
...(!(requestBody instanceof FormData) && {
'Content-Type': 'application/json'
}),
...headers
},
body:
requestBody instanceof FormData
? requestBody
: JSON.stringify(requestBody)
})
if (!response.ok) {
const errorData = await response.json()
throw errorData
}
if (!response.body) {
throw new Error('No response body')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
// Recursively process the stream.
const processStream = async (): Promise<void> => {
const { done, value } = await reader.read()
if (done) {
// Process any final data in the buffer.
buffer = parseBuffer(buffer, onChunk)
onComplete()
return
}
// Decode, sanitize, and accumulate the chunk
buffer += decoder.decode(value, { stream: true })
// Parse any complete JSON objects available in the buffer.
buffer = parseBuffer(buffer, onChunk)
await processStream()
}
await processStream()
} catch (error) {
if (typeof error === 'object' && error !== null && 'detail' in error) {
onError(new Error(String(error.detail)))
} else {
onError(new Error(String(error)))
}
}
},
[]
)
return { streamResponse }
}

View file

@ -0,0 +1,453 @@
import { useCallback } from 'react'
import { APIRoutes } from '@/api/routes'
import useChatActions from '@/hooks/useChatActions'
import { useStore } from '../store'
import { RunEvent, RunResponseContent, type RunResponse } from '@/types/os'
import { constructEndpointUrl } from '@/lib/constructEndpointUrl'
import useAIResponseStream from './useAIResponseStream'
import { ToolCall } from '@/types/os'
import { useQueryState } from 'nuqs'
import { getJsonMarkdown } from '@/lib/utils'
const useAIChatStreamHandler = () => {
const setMessages = useStore((state) => state.setMessages)
const { addMessage, focusChatInput } = useChatActions()
const [agentId] = useQueryState('agent')
const [teamId] = useQueryState('team')
const [sessionId, setSessionId] = useQueryState('session')
const selectedEndpoint = useStore((state) => state.selectedEndpoint)
const authToken = useStore((state) => state.authToken)
const mode = useStore((state) => state.mode)
const setStreamingErrorMessage = useStore(
(state) => state.setStreamingErrorMessage
)
const setIsStreaming = useStore((state) => state.setIsStreaming)
const setSessionsData = useStore((state) => state.setSessionsData)
const { streamResponse } = useAIResponseStream()
const updateMessagesWithErrorState = useCallback(() => {
setMessages((prevMessages) => {
const newMessages = [...prevMessages]
const lastMessage = newMessages[newMessages.length - 1]
if (lastMessage && lastMessage.role === 'agent') {
lastMessage.streamingError = true
}
return newMessages
})
}, [setMessages])
/**
* Processes a new tool call and adds it to the message
* @param toolCall - The tool call to add
* @param prevToolCalls - The previous tool calls array
* @returns Updated tool calls array
*/
const processToolCall = useCallback(
(toolCall: ToolCall, prevToolCalls: ToolCall[] = []) => {
const toolCallId =
toolCall.tool_call_id || `${toolCall.tool_name}-${toolCall.created_at}`
const existingToolCallIndex = prevToolCalls.findIndex(
(tc) =>
(tc.tool_call_id && tc.tool_call_id === toolCall.tool_call_id) ||
(!tc.tool_call_id &&
toolCall.tool_name &&
toolCall.created_at &&
`${tc.tool_name}-${tc.created_at}` === toolCallId)
)
if (existingToolCallIndex >= 0) {
const updatedToolCalls = [...prevToolCalls]
updatedToolCalls[existingToolCallIndex] = {
...updatedToolCalls[existingToolCallIndex],
...toolCall
}
return updatedToolCalls
} else {
return [...prevToolCalls, toolCall]
}
},
[]
)
/**
* Processes tool calls from a chunk, handling both single tool object and tools array formats
* @param chunk - The chunk containing tool call data
* @param existingToolCalls - The existing tool calls array
* @returns Updated tool calls array
*/
const processChunkToolCalls = useCallback(
(
chunk: RunResponseContent | RunResponse,
existingToolCalls: ToolCall[] = []
) => {
let updatedToolCalls = [...existingToolCalls]
// Handle new single tool object format
if (chunk.tool) {
updatedToolCalls = processToolCall(chunk.tool, updatedToolCalls)
}
// Handle legacy tools array format
if (chunk.tools && chunk.tools.length > 0) {
for (const toolCall of chunk.tools) {
updatedToolCalls = processToolCall(toolCall, updatedToolCalls)
}
}
return updatedToolCalls
},
[processToolCall]
)
const handleStreamResponse = useCallback(
async (input: string | FormData) => {
setIsStreaming(true)
const formData = input instanceof FormData ? input : new FormData()
if (typeof input === 'string') {
formData.append('message', input)
}
setMessages((prevMessages) => {
if (prevMessages.length >= 2) {
const lastMessage = prevMessages[prevMessages.length - 1]
const secondLastMessage = prevMessages[prevMessages.length - 2]
if (
lastMessage.role === 'agent' &&
lastMessage.streamingError &&
secondLastMessage.role === 'user'
) {
return prevMessages.slice(0, -2)
}
}
return prevMessages
})
addMessage({
role: 'user',
content: formData.get('message') as string,
created_at: Math.floor(Date.now() / 1000)
})
addMessage({
role: 'agent',
content: '',
tool_calls: [],
streamingError: false,
created_at: Math.floor(Date.now() / 1000) + 1
})
let lastContent = ''
let newSessionId = sessionId
try {
const endpointUrl = constructEndpointUrl(selectedEndpoint)
let RunUrl: string | null = null
if (mode === 'team' && teamId) {
RunUrl = APIRoutes.TeamRun(endpointUrl, teamId)
} else if (mode === 'agent' && agentId) {
RunUrl = APIRoutes.AgentRun(endpointUrl).replace(
'{agent_id}',
agentId
)
}
if (!RunUrl) {
updateMessagesWithErrorState()
setStreamingErrorMessage('Please select an agent or team first.')
setIsStreaming(false)
return
}
formData.append('stream', 'true')
formData.append('session_id', sessionId ?? '')
// Create headers with auth token if available
const headers: Record<string, string> = {}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`
}
await streamResponse({
apiUrl: RunUrl,
headers,
requestBody: formData,
onChunk: (chunk: RunResponse) => {
if (
chunk.event === RunEvent.RunStarted ||
chunk.event === RunEvent.TeamRunStarted ||
chunk.event === RunEvent.ReasoningStarted ||
chunk.event === RunEvent.TeamReasoningStarted
) {
newSessionId = chunk.session_id as string
setSessionId(chunk.session_id as string)
if (
(!sessionId || sessionId !== chunk.session_id) &&
chunk.session_id
) {
const sessionData = {
session_id: chunk.session_id as string,
session_name: formData.get('message') as string,
created_at: chunk.created_at
}
setSessionsData((prevSessionsData) => {
const sessionExists = prevSessionsData?.some(
(session) => session.session_id === chunk.session_id
)
if (sessionExists) {
return prevSessionsData
}
return [sessionData, ...(prevSessionsData ?? [])]
})
}
} else if (
chunk.event === RunEvent.ToolCallStarted ||
chunk.event === RunEvent.TeamToolCallStarted ||
chunk.event === RunEvent.ToolCallCompleted ||
chunk.event === RunEvent.TeamToolCallCompleted
) {
setMessages((prevMessages) => {
const newMessages = [...prevMessages]
const lastMessage = newMessages[newMessages.length - 1]
if (lastMessage && lastMessage.role === 'agent') {
lastMessage.tool_calls = processChunkToolCalls(
chunk,
lastMessage.tool_calls
)
}
return newMessages
})
} else if (
chunk.event === RunEvent.RunContent ||
chunk.event === RunEvent.TeamRunContent
) {
setMessages((prevMessages) => {
const newMessages = [...prevMessages]
const lastMessage = newMessages[newMessages.length - 1]
if (
lastMessage &&
lastMessage.role === 'agent' &&
typeof chunk.content === 'string'
) {
const uniqueContent = chunk.content.replace(lastContent, '')
lastMessage.content += uniqueContent
lastContent = chunk.content
// Handle tool calls streaming
lastMessage.tool_calls = processChunkToolCalls(
chunk,
lastMessage.tool_calls
)
if (chunk.extra_data?.reasoning_steps) {
lastMessage.extra_data = {
...lastMessage.extra_data,
reasoning_steps: chunk.extra_data.reasoning_steps
}
}
if (chunk.extra_data?.references) {
lastMessage.extra_data = {
...lastMessage.extra_data,
references: chunk.extra_data.references
}
}
lastMessage.created_at =
chunk.created_at ?? lastMessage.created_at
if (chunk.images) {
lastMessage.images = chunk.images
}
if (chunk.videos) {
lastMessage.videos = chunk.videos
}
if (chunk.audio) {
lastMessage.audio = chunk.audio
}
} else if (
lastMessage &&
lastMessage.role === 'agent' &&
typeof chunk?.content !== 'string' &&
chunk.content !== null
) {
const jsonBlock = getJsonMarkdown(chunk?.content)
lastMessage.content += jsonBlock
lastContent = jsonBlock
} else if (
chunk.response_audio?.transcript &&
typeof chunk.response_audio?.transcript === 'string'
) {
const transcript = chunk.response_audio.transcript
lastMessage.response_audio = {
...lastMessage.response_audio,
transcript:
lastMessage.response_audio?.transcript + transcript
}
}
return newMessages
})
} else if (
chunk.event === RunEvent.ReasoningStep ||
chunk.event === RunEvent.TeamReasoningStep
) {
setMessages((prevMessages) => {
const newMessages = [...prevMessages]
const lastMessage = newMessages[newMessages.length - 1]
if (lastMessage && lastMessage.role === 'agent') {
const existingSteps =
lastMessage.extra_data?.reasoning_steps ?? []
const incomingSteps = chunk.extra_data?.reasoning_steps ?? []
lastMessage.extra_data = {
...lastMessage.extra_data,
reasoning_steps: [...existingSteps, ...incomingSteps]
}
}
return newMessages
})
} else if (
chunk.event === RunEvent.ReasoningCompleted ||
chunk.event === RunEvent.TeamReasoningCompleted
) {
setMessages((prevMessages) => {
const newMessages = [...prevMessages]
const lastMessage = newMessages[newMessages.length - 1]
if (lastMessage && lastMessage.role === 'agent') {
if (chunk.extra_data?.reasoning_steps) {
lastMessage.extra_data = {
...lastMessage.extra_data,
reasoning_steps: chunk.extra_data.reasoning_steps
}
}
}
return newMessages
})
} else if (
chunk.event === RunEvent.RunError ||
chunk.event === RunEvent.TeamRunError ||
chunk.event === RunEvent.TeamRunCancelled
) {
updateMessagesWithErrorState()
const errorContent =
(chunk.content as string) ||
(chunk.event === RunEvent.TeamRunCancelled
? 'Run cancelled'
: 'Error during run')
setStreamingErrorMessage(errorContent)
if (newSessionId) {
setSessionsData(
(prevSessionsData) =>
prevSessionsData?.filter(
(session) => session.session_id !== newSessionId
) ?? null
)
}
} else if (
chunk.event === RunEvent.UpdatingMemory ||
chunk.event === RunEvent.TeamMemoryUpdateStarted ||
chunk.event === RunEvent.TeamMemoryUpdateCompleted
) {
// No-op for now; could surface a lightweight UI indicator in the future
} else if (
chunk.event === RunEvent.RunCompleted ||
chunk.event === RunEvent.TeamRunCompleted
) {
setMessages((prevMessages) => {
const newMessages = prevMessages.map((message, index) => {
if (
index === prevMessages.length - 1 &&
message.role === 'agent'
) {
let updatedContent: string
if (typeof chunk.content === 'string') {
updatedContent = chunk.content
} else {
try {
updatedContent = JSON.stringify(chunk.content)
} catch {
updatedContent = 'Error parsing response'
}
}
return {
...message,
content: updatedContent,
tool_calls: processChunkToolCalls(
chunk,
message.tool_calls
),
images: chunk.images ?? message.images,
videos: chunk.videos ?? message.videos,
response_audio: chunk.response_audio,
created_at: chunk.created_at ?? message.created_at,
extra_data: {
reasoning_steps:
chunk.extra_data?.reasoning_steps ??
message.extra_data?.reasoning_steps,
references:
chunk.extra_data?.references ??
message.extra_data?.references
}
}
}
return message
})
return newMessages
})
}
},
onError: (error) => {
updateMessagesWithErrorState()
setStreamingErrorMessage(error.message)
if (newSessionId) {
setSessionsData(
(prevSessionsData) =>
prevSessionsData?.filter(
(session) => session.session_id !== newSessionId
) ?? null
)
}
},
onComplete: () => {}
})
} catch (error) {
updateMessagesWithErrorState()
setStreamingErrorMessage(
error instanceof Error ? error.message : String(error)
)
if (newSessionId) {
setSessionsData(
(prevSessionsData) =>
prevSessionsData?.filter(
(session) => session.session_id !== newSessionId
) ?? null
)
}
} finally {
focusChatInput()
setIsStreaming(false)
}
},
[
setMessages,
addMessage,
updateMessagesWithErrorState,
selectedEndpoint,
authToken,
streamResponse,
agentId,
teamId,
mode,
setStreamingErrorMessage,
setIsStreaming,
focusChatInput,
setSessionsData,
sessionId,
setSessionId,
processChunkToolCalls
]
)
return { handleStreamResponse }
}
export default useAIChatStreamHandler

186
src/hooks/useChatActions.ts Normal file
View file

@ -0,0 +1,186 @@
import { useCallback } from 'react'
import { toast } from 'sonner'
import { useStore } from '../store'
import { AgentDetails, TeamDetails, type ChatMessage } from '@/types/os'
import { getAgentsAPI, getStatusAPI, getTeamsAPI } from '@/api/os'
import { useQueryState } from 'nuqs'
const useChatActions = () => {
const { chatInputRef } = useStore()
const selectedEndpoint = useStore((state) => state.selectedEndpoint)
const authToken = useStore((state) => state.authToken)
const [, setSessionId] = useQueryState('session')
const setMessages = useStore((state) => state.setMessages)
const setIsEndpointActive = useStore((state) => state.setIsEndpointActive)
const setIsEndpointLoading = useStore((state) => state.setIsEndpointLoading)
const setAgents = useStore((state) => state.setAgents)
const setTeams = useStore((state) => state.setTeams)
const setSelectedModel = useStore((state) => state.setSelectedModel)
const setMode = useStore((state) => state.setMode)
const [agentId, setAgentId] = useQueryState('agent')
const [teamId, setTeamId] = useQueryState('team')
const [, setDbId] = useQueryState('db_id')
const getStatus = useCallback(async () => {
try {
const status = await getStatusAPI(selectedEndpoint, authToken)
return status
} catch {
return 503
}
}, [selectedEndpoint, authToken])
const getAgents = useCallback(async () => {
try {
const agents = await getAgentsAPI(selectedEndpoint, authToken)
return agents
} catch {
toast.error('Error fetching agents')
return []
}
}, [selectedEndpoint, authToken])
const getTeams = useCallback(async () => {
try {
const teams = await getTeamsAPI(selectedEndpoint, authToken)
return teams
} catch {
toast.error('Error fetching teams')
return []
}
}, [selectedEndpoint, authToken])
const clearChat = useCallback(() => {
setMessages([])
setSessionId(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const focusChatInput = useCallback(() => {
setTimeout(() => {
requestAnimationFrame(() => chatInputRef?.current?.focus())
}, 0)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const addMessage = useCallback(
(message: ChatMessage) => {
setMessages((prevMessages) => [...prevMessages, message])
},
[setMessages]
)
const initialize = useCallback(async () => {
setIsEndpointLoading(true)
try {
const status = await getStatus()
let agents: AgentDetails[] = []
let teams: TeamDetails[] = []
if (status === 200) {
setIsEndpointActive(true)
teams = await getTeams()
agents = await getAgents()
if (!agentId && !teamId) {
const currentMode = useStore.getState().mode
if (currentMode === 'team' && teams.length > 0) {
const firstTeam = teams[0]
setTeamId(firstTeam.id)
setSelectedModel(firstTeam.model?.provider || '')
setDbId(firstTeam.db_id || '')
setAgentId(null)
setTeams(teams)
} else if (currentMode === 'agent' && agents.length > 0) {
const firstAgent = agents[0]
setMode('agent')
setAgentId(firstAgent.id)
setSelectedModel(firstAgent.model?.model || '')
setDbId(firstAgent.db_id || '')
setAgents(agents)
}
} else {
setAgents(agents)
setTeams(teams)
if (agentId) {
const agent = agents.find((a) => a.id === agentId)
if (agent) {
setMode('agent')
setSelectedModel(agent.model?.model || '')
setDbId(agent.db_id || '')
setTeamId(null)
} else if (agents.length > 0) {
const firstAgent = agents[0]
setMode('agent')
setAgentId(firstAgent.id)
setSelectedModel(firstAgent.model?.model || '')
setDbId(firstAgent.db_id || '')
setTeamId(null)
}
} else if (teamId) {
const team = teams.find((t) => t.id === teamId)
if (team) {
setMode('team')
setSelectedModel(team.model?.provider || '')
setDbId(team.db_id || '')
setAgentId(null)
} else if (teams.length > 0) {
const firstTeam = teams[0]
setMode('team')
setTeamId(firstTeam.id)
setSelectedModel(firstTeam.model?.provider || '')
setDbId(firstTeam.db_id || '')
setAgentId(null)
}
}
}
} else {
setIsEndpointActive(false)
setMode('agent')
setSelectedModel('')
setAgentId(null)
setTeamId(null)
}
return { agents, teams }
} catch (error) {
console.error('Error initializing :', error)
setIsEndpointActive(false)
setMode('agent')
setSelectedModel('')
setAgentId(null)
setTeamId(null)
setAgents([])
setTeams([])
} finally {
setIsEndpointLoading(false)
}
}, [
getStatus,
getAgents,
getTeams,
setIsEndpointActive,
setIsEndpointLoading,
setAgents,
setTeams,
setAgentId,
setSelectedModel,
setMode,
setTeamId,
setDbId,
agentId,
teamId
])
return {
clearChat,
addMessage,
getAgents,
focusChatInput,
getTeams,
initialize
}
}
export default useChatActions

View file

@ -0,0 +1,172 @@
import { useCallback } from 'react'
import { getSessionAPI, getAllSessionsAPI } from '@/api/os'
import { useStore } from '../store'
import { toast } from 'sonner'
import { ChatMessage, ToolCall, ReasoningMessage, ChatEntry } from '@/types/os'
import { getJsonMarkdown } from '@/lib/utils'
interface SessionResponse {
session_id: string
agent_id: string
user_id: string | null
runs?: ChatEntry[]
memory: {
runs?: ChatEntry[]
chats?: ChatEntry[]
}
agent_data: Record<string, unknown>
}
interface LoaderArgs {
entityType: 'agent' | 'team' | null
agentId?: string | null
teamId?: string | null
dbId: string | null
}
const useSessionLoader = () => {
const setMessages = useStore((state) => state.setMessages)
const selectedEndpoint = useStore((state) => state.selectedEndpoint)
const authToken = useStore((state) => state.authToken)
const setIsSessionsLoading = useStore((state) => state.setIsSessionsLoading)
const setSessionsData = useStore((state) => state.setSessionsData)
const getSessions = useCallback(
async ({ entityType, agentId, teamId, dbId }: LoaderArgs) => {
const selectedId = entityType === 'agent' ? agentId : teamId
if (!selectedEndpoint || !entityType || !selectedId || !dbId) return
try {
setIsSessionsLoading(true)
const sessions = await getAllSessionsAPI(
selectedEndpoint,
entityType,
selectedId,
dbId,
authToken
)
setSessionsData(sessions.data ?? [])
} catch {
toast.error('Error loading sessions')
setSessionsData([])
} finally {
setIsSessionsLoading(false)
}
},
[selectedEndpoint, authToken, setSessionsData, setIsSessionsLoading]
)
const getSession = useCallback(
async (
{ entityType, agentId, teamId, dbId }: LoaderArgs,
sessionId: string
) => {
const selectedId = entityType === 'agent' ? agentId : teamId
if (
!selectedEndpoint ||
!sessionId ||
!entityType ||
!selectedId ||
!dbId
)
return
try {
const response: SessionResponse = await getSessionAPI(
selectedEndpoint,
entityType,
sessionId,
dbId,
authToken
)
if (response) {
if (Array.isArray(response)) {
const messagesFor = response.flatMap((run) => {
const filteredMessages: ChatMessage[] = []
if (run) {
filteredMessages.push({
role: 'user',
content: run.run_input ?? '',
created_at: run.created_at
})
}
if (run) {
const toolCalls = [
...(run.tools ?? []),
...(run.extra_data?.reasoning_messages ?? []).reduce(
(acc: ToolCall[], msg: ReasoningMessage) => {
if (msg.role === 'tool') {
acc.push({
role: msg.role,
content: msg.content,
tool_call_id: msg.tool_call_id ?? '',
tool_name: msg.tool_name ?? '',
tool_args: msg.tool_args ?? {},
tool_call_error: msg.tool_call_error ?? false,
metrics: msg.metrics ?? { time: 0 },
created_at:
msg.created_at ?? Math.floor(Date.now() / 1000)
})
}
return acc
},
[]
)
]
filteredMessages.push({
role: 'agent',
content: (run.content as string) ?? '',
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
extra_data: run.extra_data,
images: run.images,
videos: run.videos,
audio: run.audio,
response_audio: run.response_audio,
created_at: run.created_at
})
}
return filteredMessages
})
const processedMessages = messagesFor.map(
(message: ChatMessage) => {
if (Array.isArray(message.content)) {
const textContent = message.content
.filter((item: { type: string }) => item.type === 'text')
.map((item) => item.text)
.join(' ')
return {
...message,
content: textContent
}
}
if (typeof message.content !== 'string') {
return {
...message,
content: getJsonMarkdown(message.content)
}
}
return message
}
)
setMessages(processedMessages)
return processedMessages
}
}
} catch {
return null
}
},
[selectedEndpoint, authToken, setMessages]
)
return { getSession, getSessions }
}
export default useSessionLoader

64
src/lib/audio.ts Normal file
View file

@ -0,0 +1,64 @@
export function decodeBase64Audio(
base64String: string,
mimeType = 'audio/mpeg',
sampleRate = 44100,
numChannels = 1
): string {
// Convert the Base64 string to binary
const byteString = atob(base64String)
const byteArray = new Uint8Array(byteString.length)
for (let i = 0; i < byteString.length; i += 1) {
byteArray[i] = byteString.charCodeAt(i)
}
let blob: Blob
if (mimeType === 'audio/pcm16') {
// Convert PCM16 raw audio to WAV format
const wavHeader = createWavHeader(byteArray.length, sampleRate, numChannels)
const wavData = new Uint8Array(wavHeader.length + byteArray.length)
wavData.set(wavHeader, 0)
wavData.set(byteArray, wavHeader.length)
blob = new Blob([wavData], { type: 'audio/wav' }) // Convert PCM to WAV
} else {
blob = new Blob([byteArray], { type: mimeType })
}
return URL.createObjectURL(blob)
}
// Function to generate WAV header for PCM16
function createWavHeader(
dataLength: number,
sampleRate: number,
numChannels: number
): Uint8Array {
const header = new ArrayBuffer(44)
const view = new DataView(header)
const blockAlign = numChannels * 2 // 16-bit PCM = 2 bytes per sample
const byteRate = sampleRate * blockAlign
// "RIFF" chunk descriptor
view.setUint32(0, 0x52494646, false) // "RIFF"
view.setUint32(4, 36 + dataLength, true) // File size
view.setUint32(8, 0x57415645, false) // "WAVE"
// "fmt " sub-chunk
view.setUint32(12, 0x666d7420, false) // "fmt "
view.setUint32(16, 16, true) // Subchunk1 size
view.setUint16(20, 1, true) // Audio format (1 = PCM)
view.setUint16(22, numChannels, true) // Number of channels
view.setUint32(24, sampleRate, true) // Sample rate
view.setUint32(28, byteRate, true) // Byte rate
view.setUint16(32, blockAlign, true) // Block align
view.setUint16(34, 16, true) // Bits per sample (16-bit)
// "data" sub-chunk
view.setUint32(36, 0x64617461, false) // "data"
view.setUint32(40, dataLength, true) // Data size
return new Uint8Array(header)
}

View file

@ -0,0 +1,20 @@
export const constructEndpointUrl = (
value: string | null | undefined
): string => {
if (!value) return ''
if (value.startsWith('http://') || value.startsWith('https://')) {
return decodeURIComponent(value)
}
// Check if the endpoint is localhost or an IP address
if (
value.startsWith('localhost') ||
/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(value)
) {
return `http://${decodeURIComponent(value)}`
}
// For all other cases, default to HTTPS
return `https://${decodeURIComponent(value)}`
}

25
src/lib/modelProvider.ts Normal file
View file

@ -0,0 +1,25 @@
import { IconType } from '@/components/ui/icon'
const PROVIDER_ICON_MAP: Record<string, IconType> = {
aws: 'aws',
openai: 'open-ai',
anthropic: 'anthropic',
mistral: 'mistral',
gemini: 'gemini',
azure: 'azure',
groq: 'groq',
fireworks: 'fireworks',
deepseek: 'deepseek',
cohere: 'cohere',
ollama: 'ollama',
xai: 'xai'
}
export const getProviderIcon = (provider: string): IconType | null => {
const normalizedProvider = provider.toLowerCase()
return (
Object.entries(PROVIDER_ICON_MAP).find(([key]) =>
normalizedProvider.includes(key)
)?.[1] ?? null
)
}

44
src/lib/utils.ts Normal file
View file

@ -0,0 +1,44 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export const truncateText = (text: string, limit: number) => {
if (text) {
return text.length > limit ? `${text.slice(0, limit)}..` : text
}
return ''
}
export const isValidUrl = (url: string): boolean => {
try {
const pattern = new RegExp(
'^https?:\\/\\/' +
'((([a-zA-Z\\d]([a-zA-Z\\d-]*[a-zA-Z\\d])*)\\.)+[a-zA-Z]{2,}|' +
'localhost|' +
'\\d{1,3}(\\.\\d{1,3}){3})' +
'(\\:\\d+)?' +
'(\\/[-a-zA-Z\\d%@_.~+&:]*)*' +
'(\\?[;&a-zA-Z\\d%@_.,~+&:=-]*)?' +
'(\\#[-a-zA-Z\\d_]*)?$',
'i'
)
return pattern.test(url.trim())
} catch {
return false
}
}
export const getJsonMarkdown = (content: object = {}) => {
let jsonBlock = ''
try {
jsonBlock = `\`\`\`json\n${JSON.stringify(content, null, 2)}\n\`\`\``
} catch {
jsonBlock = `\`\`\`\n${String(content)}\n\`\`\``
}
return jsonBlock
}

120
src/store.ts Normal file
View file

@ -0,0 +1,120 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import {
AgentDetails,
SessionEntry,
TeamDetails,
type ChatMessage
} from '@/types/os'
interface Store {
hydrated: boolean
setHydrated: () => void
streamingErrorMessage: string
setStreamingErrorMessage: (streamingErrorMessage: string) => void
endpoints: {
endpoint: string
id__endpoint: string
}[]
setEndpoints: (
endpoints: {
endpoint: string
id__endpoint: string
}[]
) => void
isStreaming: boolean
setIsStreaming: (isStreaming: boolean) => void
isEndpointActive: boolean
setIsEndpointActive: (isActive: boolean) => void
isEndpointLoading: boolean
setIsEndpointLoading: (isLoading: boolean) => void
messages: ChatMessage[]
setMessages: (
messages: ChatMessage[] | ((prevMessages: ChatMessage[]) => ChatMessage[])
) => void
chatInputRef: React.RefObject<HTMLTextAreaElement | null>
selectedEndpoint: string
setSelectedEndpoint: (selectedEndpoint: string) => void
authToken: string
setAuthToken: (authToken: string) => void
agents: AgentDetails[]
setAgents: (agents: AgentDetails[]) => void
teams: TeamDetails[]
setTeams: (teams: TeamDetails[]) => void
selectedModel: string
setSelectedModel: (model: string) => void
mode: 'agent' | 'team'
setMode: (mode: 'agent' | 'team') => void
sessionsData: SessionEntry[] | null
setSessionsData: (
sessionsData:
| SessionEntry[]
| ((prevSessions: SessionEntry[] | null) => SessionEntry[] | null)
) => void
isSessionsLoading: boolean
setIsSessionsLoading: (isSessionsLoading: boolean) => void
}
export const useStore = create<Store>()(
persist(
(set) => ({
hydrated: false,
setHydrated: () => set({ hydrated: true }),
streamingErrorMessage: '',
setStreamingErrorMessage: (streamingErrorMessage) =>
set(() => ({ streamingErrorMessage })),
endpoints: [],
setEndpoints: (endpoints) => set(() => ({ endpoints })),
isStreaming: false,
setIsStreaming: (isStreaming) => set(() => ({ isStreaming })),
isEndpointActive: false,
setIsEndpointActive: (isActive) =>
set(() => ({ isEndpointActive: isActive })),
isEndpointLoading: true,
setIsEndpointLoading: (isLoading) =>
set(() => ({ isEndpointLoading: isLoading })),
messages: [],
setMessages: (messages) =>
set((state) => ({
messages:
typeof messages === 'function' ? messages(state.messages) : messages
})),
chatInputRef: { current: null },
selectedEndpoint: 'http://localhost:7777',
setSelectedEndpoint: (selectedEndpoint) =>
set(() => ({ selectedEndpoint })),
authToken: '',
setAuthToken: (authToken) => set(() => ({ authToken })),
agents: [],
setAgents: (agents) => set({ agents }),
teams: [],
setTeams: (teams) => set({ teams }),
selectedModel: '',
setSelectedModel: (selectedModel) => set(() => ({ selectedModel })),
mode: 'agent',
setMode: (mode) => set(() => ({ mode })),
sessionsData: null,
setSessionsData: (sessionsData) =>
set((state) => ({
sessionsData:
typeof sessionsData === 'function'
? sessionsData(state.sessionsData)
: sessionsData
})),
isSessionsLoading: false,
setIsSessionsLoading: (isSessionsLoading) =>
set(() => ({ isSessionsLoading }))
}),
{
name: 'endpoint-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
selectedEndpoint: state.selectedEndpoint
}),
onRehydrateStorage: () => (state) => {
state?.setHydrated?.()
}
}
)
)

308
src/types/os.ts Normal file
View file

@ -0,0 +1,308 @@
export interface ToolCall {
role: 'user' | 'tool' | 'system' | 'assistant'
content: string | null
tool_call_id: string
tool_name: string
tool_args: Record<string, string>
tool_call_error: boolean
metrics: {
time: number
}
created_at: number
}
export interface ReasoningSteps {
title: string
action?: string
result: string
reasoning: string
confidence?: number
next_action?: string
}
export interface ReasoningStepProps {
index: number
stepTitle: string
}
export interface ReasoningProps {
reasoning: ReasoningSteps[]
}
export type ToolCallProps = {
tools: ToolCall
}
interface ModelMessage {
content: string | null
context?: MessageContext[]
created_at: number
metrics?: {
time: number
prompt_tokens: number
input_tokens: number
completion_tokens: number
output_tokens: number
}
name: string | null
role: string
tool_args?: unknown
tool_call_id: string | null
tool_calls: Array<{
function: {
arguments: string
name: string
}
id: string
type: string
}> | null
}
export interface Model {
name: string
model: string
provider: string
}
export interface Agent {
agent_id: string
name: string
description: string
model: Model
storage?: boolean
}
export interface Team {
team_id: string
name: string
description: string
model: Model
storage?: boolean
}
interface MessageContext {
query: string
docs?: Array<Record<string, object>>
time?: number
}
export enum RunEvent {
RunStarted = 'RunStarted',
RunContent = 'RunContent',
RunCompleted = 'RunCompleted',
RunError = 'RunError',
RunOutput = 'RunOutput',
UpdatingMemory = 'UpdatingMemory',
ToolCallStarted = 'ToolCallStarted',
ToolCallCompleted = 'ToolCallCompleted',
MemoryUpdateStarted = 'MemoryUpdateStarted',
MemoryUpdateCompleted = 'MemoryUpdateCompleted',
ReasoningStarted = 'ReasoningStarted',
ReasoningStep = 'ReasoningStep',
ReasoningCompleted = 'ReasoningCompleted',
RunCancelled = 'RunCancelled',
RunPaused = 'RunPaused',
RunContinued = 'RunContinued',
// Team Events
TeamRunStarted = 'TeamRunStarted',
TeamRunContent = 'TeamRunContent',
TeamRunCompleted = 'TeamRunCompleted',
TeamRunError = 'TeamRunError',
TeamRunCancelled = 'TeamRunCancelled',
TeamToolCallStarted = 'TeamToolCallStarted',
TeamToolCallCompleted = 'TeamToolCallCompleted',
TeamReasoningStarted = 'TeamReasoningStarted',
TeamReasoningStep = 'TeamReasoningStep',
TeamReasoningCompleted = 'TeamReasoningCompleted',
TeamMemoryUpdateStarted = 'TeamMemoryUpdateStarted',
TeamMemoryUpdateCompleted = 'TeamMemoryUpdateCompleted'
}
export interface ResponseAudio {
id?: string
content?: string
transcript?: string
channels?: number
sample_rate?: number
}
export interface NewRunResponse {
status: 'RUNNING' | 'PAUSED' | 'CANCELLED'
}
export interface RunResponseContent {
content?: string | object
content_type: string
context?: MessageContext[]
event: RunEvent
event_data?: object
messages?: ModelMessage[]
metrics?: object
model?: string
run_id?: string
agent_id?: string
session_id?: string
tool?: ToolCall
tools?: Array<ToolCall>
created_at: number
extra_data?: AgentExtraData
images?: ImageData[]
videos?: VideoData[]
audio?: AudioData[]
response_audio?: ResponseAudio
}
export interface RunResponse {
content?: string | object
content_type: string
context?: MessageContext[]
event: RunEvent
event_data?: object
messages?: ModelMessage[]
metrics?: object
model?: string
run_id?: string
agent_id?: string
session_id?: string
tool?: ToolCall
tools?: Array<ToolCall>
created_at: number
extra_data?: AgentExtraData
images?: ImageData[]
videos?: VideoData[]
audio?: AudioData[]
response_audio?: ResponseAudio
}
export interface AgentExtraData {
reasoning_steps?: ReasoningSteps[]
reasoning_messages?: ReasoningMessage[]
references?: ReferenceData[]
}
export interface AgentExtraData {
reasoning_messages?: ReasoningMessage[]
references?: ReferenceData[]
}
export interface ReasoningMessage {
role: 'user' | 'tool' | 'system' | 'assistant'
content: string | null
tool_call_id?: string
tool_name?: string
tool_args?: Record<string, string>
tool_call_error?: boolean
metrics?: {
time: number
}
created_at?: number
}
export interface ChatMessage {
role: 'user' | 'agent' | 'system' | 'tool'
content: string
streamingError?: boolean
created_at: number
tool_calls?: ToolCall[]
extra_data?: {
reasoning_steps?: ReasoningSteps[]
reasoning_messages?: ReasoningMessage[]
references?: ReferenceData[]
}
images?: ImageData[]
videos?: VideoData[]
audio?: AudioData[]
response_audio?: ResponseAudio
}
export interface AgentDetails {
id: string
name?: string
db_id?: string
// Model
model?: Model
}
export interface TeamDetails {
id: string
name?: string
db_id?: string
// Model
model?: Model
}
export interface ImageData {
revised_prompt: string
url: string
}
export interface VideoData {
id: number
eta: number
url: string
}
export interface AudioData {
base64_audio?: string
mime_type?: string
url?: string
id?: string
content?: string
channels?: number
sample_rate?: number
}
export interface ReferenceData {
query: string
references: Reference[]
time?: number
}
export interface Reference {
content: string
meta_data: {
chunk: number
chunk_size: number
}
name: string
}
export interface SessionEntry {
session_id: string
session_name: string
created_at: number
updated_at?: number
}
export interface Pagination {
page: number
limit: number
total_pages: number
total_count: number
}
export interface Sessions extends SessionEntry {
data: SessionEntry[]
meta: Pagination
}
export interface ChatEntry {
message: {
role: 'user' | 'system' | 'tool' | 'assistant'
content: string
created_at: number
}
response: {
content: string
tools?: ToolCall[]
extra_data?: {
reasoning_steps?: ReasoningSteps[]
reasoning_messages?: ReasoningMessage[]
references?: ReferenceData[]
}
images?: ImageData[]
videos?: VideoData[]
audio?: AudioData[]
response_audio?: {
transcript?: string
}
created_at: number
}
}

38
tailwind.config.ts Normal file
View file

@ -0,0 +1,38 @@
import type { Config } from 'tailwindcss'
import tailwindcssAnimate from 'tailwindcss-animate'
export default {
darkMode: ['class'],
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}'
],
theme: {
extend: {
colors: {
primary: '#FAFAFA',
primaryAccent: '#18181B',
brand: '#FF4017',
background: {
DEFAULT: '#111113',
secondary: '#27272A'
},
secondary: '#f5f5f5',
border: 'rgba(var(--color-border-default))',
accent: '#27272A',
muted: '#A1A1AA',
destructive: '#E53935',
positive: '#22C55E'
},
fontFamily: {
geist: 'var(--font-geist-sans)',
dmmono: 'var(--font-dm-mono)'
},
borderRadius: {
xl: '10px'
}
}
},
plugins: [tailwindcssAnimate]
} satisfies Config

27
tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}