chore: add docker deployment for coolify
Some checks are pending
Validate Build / validate (push) Waiting to run
Some checks are pending
Validate Build / validate (push) Waiting to run
This commit is contained in:
commit
8a58e13db8
89 changed files with 12370 additions and 0 deletions
11
.dockerignore
Normal file
11
.dockerignore
Normal 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
21
.github/workflows/validate.yml
vendored
Normal 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
42
.gitignore
vendored
Normal 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
7
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
}
|
||||
20
CONTRIBUTING.md
Normal file
20
CONTRIBUTING.md
Normal 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
19
Dockerfile
Normal 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
21
LICENSE
Normal 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
122
README.md
Normal 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
21
components.json
Normal 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
16
eslint.config.mjs
Normal 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
7
next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
55
package.json
Normal file
55
package.json
Normal 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
5555
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {}
|
||||
}
|
||||
}
|
||||
|
||||
export default config
|
||||
8
prettier.config.cjs
Normal file
8
prettier.config.cjs
Normal 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
168
src/api/os.ts
Normal 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
17
src/api/routes.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
31
src/app/globals.css
Normal file
31
src/app/globals.css
Normal 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
37
src/app/layout.tsx
Normal 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
18
src/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
src/components/chat/ChatArea/ChatArea.tsx
Normal file
16
src/components/chat/ChatArea/ChatArea.tsx
Normal 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
|
||||
71
src/components/chat/ChatArea/ChatInput/ChatInput.tsx
Normal file
71
src/components/chat/ChatArea/ChatInput/ChatInput.tsx
Normal 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
|
||||
3
src/components/chat/ChatArea/ChatInput/index.ts
Normal file
3
src/components/chat/ChatArea/ChatInput/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import ChatInput from './ChatInput'
|
||||
|
||||
export default ChatInput
|
||||
27
src/components/chat/ChatArea/MessageArea.tsx
Normal file
27
src/components/chat/ChatArea/MessageArea.tsx
Normal 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
|
||||
|
|
@ -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
|
||||
195
src/components/chat/ChatArea/Messages/ChatBlankState.tsx
Normal file
195
src/components/chat/ChatArea/Messages/ChatBlankState.tsx
Normal 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
|
||||
96
src/components/chat/ChatArea/Messages/MessageItem.tsx
Normal file
96
src/components/chat/ChatArea/Messages/MessageItem.tsx
Normal 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 }
|
||||
180
src/components/chat/ChatArea/Messages/Messages.tsx
Normal file
180
src/components/chat/ChatArea/Messages/Messages.tsx
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Audios from './Audios'
|
||||
|
||||
export default Audios
|
||||
|
|
@ -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'
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Images from './Images'
|
||||
|
||||
export default Images
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Videos from './Videos'
|
||||
|
||||
export default Videos
|
||||
3
src/components/chat/ChatArea/Messages/index.ts
Normal file
3
src/components/chat/ChatArea/Messages/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Messages from './Messages'
|
||||
|
||||
export default Messages
|
||||
39
src/components/chat/ChatArea/ScrollToBottom.tsx
Normal file
39
src/components/chat/ChatArea/ScrollToBottom.tsx
Normal 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
|
||||
3
src/components/chat/ChatArea/index.ts
Normal file
3
src/components/chat/ChatArea/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import ChatArea from './ChatArea'
|
||||
|
||||
export { ChatArea }
|
||||
143
src/components/chat/Sidebar/AuthToken.tsx
Normal file
143
src/components/chat/Sidebar/AuthToken.tsx
Normal 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
|
||||
107
src/components/chat/Sidebar/EntitySelector.tsx
Normal file
107
src/components/chat/Sidebar/EntitySelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
src/components/chat/Sidebar/ModeSelector.tsx
Normal file
57
src/components/chat/Sidebar/ModeSelector.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
25
src/components/chat/Sidebar/NewChatButton.tsx
Normal file
25
src/components/chat/Sidebar/NewChatButton.tsx
Normal 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
|
||||
57
src/components/chat/Sidebar/Sessions/DeleteSessionModal.tsx
Normal file
57
src/components/chat/Sidebar/Sessions/DeleteSessionModal.tsx
Normal 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
|
||||
120
src/components/chat/Sidebar/Sessions/SessionBlankState.tsx
Normal file
120
src/components/chat/Sidebar/Sessions/SessionBlankState.tsx
Normal 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
|
||||
127
src/components/chat/Sidebar/Sessions/SessionItem.tsx
Normal file
127
src/components/chat/Sidebar/Sessions/SessionItem.tsx
Normal 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
|
||||
172
src/components/chat/Sidebar/Sessions/Sessions.tsx
Normal file
172
src/components/chat/Sidebar/Sessions/Sessions.tsx
Normal 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
|
||||
3
src/components/chat/Sidebar/Sessions/index.ts
Normal file
3
src/components/chat/Sidebar/Sessions/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Sessions from './Sessions'
|
||||
|
||||
export default Sessions
|
||||
315
src/components/chat/Sidebar/Sidebar.tsx
Normal file
315
src/components/chat/Sidebar/Sidebar.tsx
Normal 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
|
||||
3
src/components/chat/Sidebar/index.ts
Normal file
3
src/components/chat/Sidebar/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Sidebar from './Sidebar'
|
||||
|
||||
export default Sidebar
|
||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal 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 }
|
||||
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal 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
|
||||
}
|
||||
35
src/components/ui/icon/Icon.tsx
Normal file
35
src/components/ui/icon/Icon.tsx
Normal 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
|
||||
79
src/components/ui/icon/constants.tsx
Normal file
79
src/components/ui/icon/constants.tsx
Normal 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
|
||||
}
|
||||
983
src/components/ui/icon/custom-icons.tsx
Normal file
983
src/components/ui/icon/custom-icons.tsx
Normal file
File diff suppressed because one or more lines are too long
5
src/components/ui/icon/index.ts
Normal file
5
src/components/ui/icon/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import Icon from './Icon'
|
||||
|
||||
export { type IconType } from './types'
|
||||
|
||||
export default Icon
|
||||
50
src/components/ui/icon/types.ts
Normal file
50
src/components/ui/icon/types.ts
Normal 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
|
||||
}
|
||||
159
src/components/ui/select.tsx
Normal file
159
src/components/ui/select.tsx
Normal 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
|
||||
}
|
||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
31
src/components/ui/sonner.tsx
Normal file
31
src/components/ui/sonner.tsx
Normal 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 }
|
||||
101
src/components/ui/textarea.tsx
Normal file
101
src/components/ui/textarea.tsx
Normal 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 }
|
||||
30
src/components/ui/tooltip/CustomTooltip.tsx
Normal file
30
src/components/ui/tooltip/CustomTooltip.tsx
Normal 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
|
||||
3
src/components/ui/tooltip/index.ts
Normal file
3
src/components/ui/tooltip/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Tooltip from './CustomTooltip'
|
||||
|
||||
export default Tooltip
|
||||
44
src/components/ui/tooltip/tooltip.tsx
Normal file
44
src/components/ui/tooltip/tooltip.tsx
Normal 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 }
|
||||
10
src/components/ui/tooltip/types.ts
Normal file
10
src/components/ui/tooltip/types.ts
Normal 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
|
||||
}
|
||||
25
src/components/ui/typography/Heading/Heading.tsx
Normal file
25
src/components/ui/typography/Heading/Heading.tsx
Normal 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
|
||||
10
src/components/ui/typography/Heading/constants.ts
Normal file
10
src/components/ui/typography/Heading/constants.ts
Normal 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'
|
||||
}
|
||||
3
src/components/ui/typography/Heading/index.ts
Normal file
3
src/components/ui/typography/Heading/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Heading from './Heading'
|
||||
|
||||
export default Heading
|
||||
17
src/components/ui/typography/Heading/types.ts
Normal file
17
src/components/ui/typography/Heading/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
3
src/components/ui/typography/MarkdownRenderer/index.ts
Normal file
3
src/components/ui/typography/MarkdownRenderer/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import MarkdownRenderer from './MarkdownRenderer'
|
||||
|
||||
export default MarkdownRenderer
|
||||
204
src/components/ui/typography/MarkdownRenderer/inlineStyles.tsx
Normal file
204
src/components/ui/typography/MarkdownRenderer/inlineStyles.tsx
Normal 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
|
||||
}
|
||||
281
src/components/ui/typography/MarkdownRenderer/styles.tsx
Normal file
281
src/components/ui/typography/MarkdownRenderer/styles.tsx
Normal 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
|
||||
}
|
||||
133
src/components/ui/typography/MarkdownRenderer/types.ts
Normal file
133
src/components/ui/typography/MarkdownRenderer/types.ts
Normal 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
|
||||
}
|
||||
19
src/components/ui/typography/Paragraph/Paragraph.tsx
Normal file
19
src/components/ui/typography/Paragraph/Paragraph.tsx
Normal 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
|
||||
14
src/components/ui/typography/Paragraph/constants.ts
Normal file
14
src/components/ui/typography/Paragraph/constants.ts
Normal 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]'
|
||||
}
|
||||
3
src/components/ui/typography/Paragraph/index.ts
Normal file
3
src/components/ui/typography/Paragraph/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Paragraph from './Paragraph'
|
||||
|
||||
export default Paragraph
|
||||
23
src/components/ui/typography/Paragraph/types.ts
Normal file
23
src/components/ui/typography/Paragraph/types.ts
Normal 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
|
||||
}
|
||||
256
src/hooks/useAIResponseStream.tsx
Normal file
256
src/hooks/useAIResponseStream.tsx
Normal 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 }
|
||||
}
|
||||
453
src/hooks/useAIStreamHandler.tsx
Normal file
453
src/hooks/useAIStreamHandler.tsx
Normal 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
186
src/hooks/useChatActions.ts
Normal 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
|
||||
172
src/hooks/useSessionLoader.tsx
Normal file
172
src/hooks/useSessionLoader.tsx
Normal 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
64
src/lib/audio.ts
Normal 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)
|
||||
}
|
||||
20
src/lib/constructEndpointUrl.ts
Normal file
20
src/lib/constructEndpointUrl.ts
Normal 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
25
src/lib/modelProvider.ts
Normal 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
44
src/lib/utils.ts
Normal 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
120
src/store.ts
Normal 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
308
src/types/os.ts
Normal 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
38
tailwind.config.ts
Normal 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
27
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue