├── .dev.vars.example
├── .env.example
├── .gitignore
├── LICENSE
├── README.md
├── biome.json
├── components.json
├── drizzle.config.ts
├── drizzle.do.config.ts
├── index.html
├── package-lock.json
├── package.json
├── public
├── chatsemble-app.jpg
├── logo.ico
├── logo.svg
└── notion-avatars
│ ├── avatar-01.svg
│ ├── avatar-02.svg
│ ├── avatar-03.svg
│ ├── avatar-04.svg
│ ├── avatar-05.svg
│ ├── avatar-06.svg
│ ├── avatar-07.svg
│ ├── avatar-08.svg
│ ├── avatar-09.svg
│ └── avatar-10.svg
├── src
├── client
│ ├── app.tsx
│ ├── components
│ │ ├── agents
│ │ │ ├── agent-edit.tsx
│ │ │ ├── agent-form.tsx
│ │ │ ├── agent-placeholder.tsx
│ │ │ ├── agent-skeleton.tsx
│ │ │ ├── agents-sidebar.tsx
│ │ │ └── new-agent-dialog.tsx
│ │ ├── chat-room-member
│ │ │ ├── chat-member-badge.tsx
│ │ │ ├── chat-member-combobox.tsx
│ │ │ ├── chat-member-list.tsx
│ │ │ ├── chat-member-multi-select.tsx
│ │ │ ├── chat-member-remove-button.tsx
│ │ │ └── new
│ │ │ │ ├── chat-member-add-dialog.tsx
│ │ │ │ └── chat-member-add-form.tsx
│ │ ├── chat-room
│ │ │ ├── chat-room-message.tsx
│ │ │ ├── chat-room-not-selected.tsx
│ │ │ ├── chat-room.tsx
│ │ │ ├── common
│ │ │ │ └── chat-room-type-badge.tsx
│ │ │ ├── details
│ │ │ │ ├── chat-details-dialog.tsx
│ │ │ │ ├── chat-details-members-section.tsx
│ │ │ │ ├── chat-details-section.tsx
│ │ │ │ └── chat-details-workflows-section.tsx
│ │ │ ├── list
│ │ │ │ └── chat-room-list.tsx
│ │ │ ├── main
│ │ │ │ ├── chat-room-main-display.tsx
│ │ │ │ └── chat-room-main-header.tsx
│ │ │ ├── new
│ │ │ │ ├── new-chat-dialog.tsx
│ │ │ │ └── new-chat-room-group.tsx
│ │ │ └── thread
│ │ │ │ ├── chat-room-thread-display.tsx
│ │ │ │ └── chat-room-thread-header.tsx
│ │ ├── common
│ │ │ ├── avatar-picker.tsx
│ │ │ ├── card-toggle-groups.tsx
│ │ │ ├── confirmation-dialog.tsx
│ │ │ ├── copy-button.tsx
│ │ │ └── theme-toggle.tsx
│ │ ├── icons
│ │ │ ├── loading-pyramid.tsx
│ │ │ └── logo-icon.tsx
│ │ ├── layout
│ │ │ ├── app-header.tsx
│ │ │ ├── app-layout-skeleton.tsx
│ │ │ ├── app-layout.tsx
│ │ │ ├── app-nav-user.tsx
│ │ │ ├── app-sidebar-group-skeleton.tsx
│ │ │ └── app-sidebar.tsx
│ │ ├── providers
│ │ │ ├── auth-provider.tsx
│ │ │ ├── organization-connection-provider.tsx
│ │ │ ├── query-provider.tsx
│ │ │ └── theme-provider.tsx
│ │ ├── settings
│ │ │ ├── invite-member-dialog.tsx
│ │ │ ├── organization-form.tsx
│ │ │ ├── profile-form.tsx
│ │ │ └── settings-dialog.tsx
│ │ ├── tools
│ │ │ ├── annotated-tool.tsx
│ │ │ ├── scheduled-workflow-tool.tsx
│ │ │ └── sources-tool.tsx
│ │ └── ui
│ │ │ ├── alert-dialog.tsx
│ │ │ ├── alert.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── breadcrumb.tsx
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── chat-message-area.tsx
│ │ │ ├── chat-message.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── collapsible.tsx
│ │ │ ├── command.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── form.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── markdown-content.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── resizable.tsx
│ │ │ ├── scroll-area.tsx
│ │ │ ├── select.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── sheet.tsx
│ │ │ ├── sidebar.tsx
│ │ │ ├── skeleton.tsx
│ │ │ ├── sonner.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── tiptap-chat-input.tsx
│ │ │ ├── tiptap
│ │ │ ├── mention-config.ts
│ │ │ ├── mention-list.tsx
│ │ │ ├── metion-plugin.ts
│ │ │ ├── style.css
│ │ │ └── tiptap.tsx
│ │ │ ├── toggle-group.tsx
│ │ │ ├── toggle.tsx
│ │ │ ├── tool-invocation.tsx
│ │ │ └── tooltip.tsx
│ ├── hooks
│ │ ├── organization
│ │ │ ├── use-main-chat-room-state.ts
│ │ │ ├── use-organization-connection.ts
│ │ │ ├── use-organization-state.ts
│ │ │ └── use-thread-chat-room-state.ts
│ │ ├── use-mobile.ts
│ │ ├── use-scroll-to-bottom.ts
│ │ ├── use-textarea-resize.ts
│ │ └── use-web-socket.ts
│ ├── index.css
│ ├── lib
│ │ ├── api-client.ts
│ │ ├── auth-client.ts
│ │ ├── chat.ts
│ │ ├── date.tsx
│ │ ├── id-parsing.ts
│ │ └── utils.ts
│ ├── main.tsx
│ ├── routeTree.gen.ts
│ ├── routes
│ │ ├── (app)
│ │ │ ├── agents.tsx
│ │ │ ├── chat.tsx
│ │ │ └── route.tsx
│ │ ├── __root.tsx
│ │ ├── auth
│ │ │ ├── signin.tsx
│ │ │ └── signup.tsx
│ │ └── index.tsx
│ ├── types
│ │ └── auth.ts
│ └── vite-env.d.ts
├── server
│ ├── ai
│ │ ├── prompts
│ │ │ ├── agent
│ │ │ │ ├── default-prompt.ts
│ │ │ │ ├── prompt-parts.ts
│ │ │ │ └── workflow-prompt.ts
│ │ │ └── router-prompt.ts
│ │ ├── tools
│ │ │ ├── create-thread-tool.ts
│ │ │ ├── deep-search-tool.ts
│ │ │ ├── index.ts
│ │ │ ├── schedule-workflow-tool.ts
│ │ │ ├── web-crawler-tool.ts
│ │ │ └── web-search-tool.ts
│ │ └── utils
│ │ │ ├── data-stream.ts
│ │ │ └── message.ts
│ ├── auth
│ │ ├── index.ts
│ │ └── organization-permissions.ts
│ ├── db
│ │ ├── index.ts
│ │ ├── migrations
│ │ │ ├── 0000_petite_norrin_radd.sql
│ │ │ └── meta
│ │ │ │ ├── 0000_snapshot.json
│ │ │ │ └── _journal.json
│ │ ├── schema
│ │ │ ├── auth.ts
│ │ │ └── index.ts
│ │ └── seed.sql
│ ├── email
│ │ ├── index.ts
│ │ └── templates
│ │ │ ├── email-verification.tsx
│ │ │ ├── organization-invitation.tsx
│ │ │ └── password-reset.tsx
│ ├── index.ts
│ ├── middleware
│ │ └── auth.ts
│ ├── organization-do
│ │ ├── agent.ts
│ │ ├── chat-room.ts
│ │ ├── db
│ │ │ ├── migrations
│ │ │ │ ├── 0000_medical_zzzax.sql
│ │ │ │ ├── meta
│ │ │ │ │ ├── 0000_snapshot.json
│ │ │ │ │ └── _journal.json
│ │ │ │ └── migrations.js
│ │ │ ├── schema.ts
│ │ │ └── services
│ │ │ │ ├── agents.ts
│ │ │ │ ├── chat-room-members.ts
│ │ │ │ ├── chat-room-message.ts
│ │ │ │ ├── chat-room.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── workflow.ts
│ │ ├── organization.ts
│ │ └── workflow.ts
│ ├── routes
│ │ ├── index.ts
│ │ ├── protected
│ │ │ ├── agents.ts
│ │ │ ├── chat
│ │ │ │ ├── chat-room-members.ts
│ │ │ │ ├── chat-room.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ ├── organization-user.ts
│ │ │ └── workflows.ts
│ │ └── websocket
│ │ │ └── organization.ts
│ └── types
│ │ ├── hono.ts
│ │ └── session.ts
└── shared
│ ├── lib
│ └── chat.ts
│ └── types
│ ├── agent.ts
│ ├── chat-ws.ts
│ ├── chat.ts
│ ├── helper.ts
│ ├── index.ts
│ └── workflow.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.worker.json
├── vite.config.ts
├── worker-configuration.d.ts
└── wrangler.jsonc
/.dev.vars.example:
--------------------------------------------------------------------------------
1 | AI_GATEWAY_OPENAI_URL=https://gateway.ai.cloudflare.com/v1/43243242/chatsemble/openai
2 | AI_GATEWAY_GROQ_URL=https://gateway.ai.cloudflare.com/v1/432432342/chatsemble/groq
3 |
4 | OPENAI_API_KEY=key
5 | GROQ_API_KEY=key
6 |
7 | BRAVE_API_KEY=key
8 | FIRECRAWL_API_KEY=key
9 |
10 | APP_URL=http://localhost:5173
11 |
12 | BETTER_AUTH_SECRET=secret
13 |
14 | EMAIL_SENDER=sender@email.com
15 | RESEND_API_KEY=key
16 | MOCK_SEND_EMAIL=true
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | VITE_APP_URL=http://localhost:5173
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | .wrangler
27 | .dev.vars
28 | .env
29 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "vcs": {
4 | "enabled": false,
5 | "clientKind": "git",
6 | "useIgnoreFile": true
7 | },
8 | "files": {
9 | "ignoreUnknown": false,
10 | "ignore": ["./src/client/routeTree.gen.ts"]
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "tab",
15 | "indentWidth": 2,
16 | "lineWidth": 80
17 | },
18 | "organizeImports": {
19 | "enabled": true
20 | },
21 | "linter": {
22 | "enabled": true,
23 | "rules": {
24 | "recommended": true,
25 | "correctness": {
26 | "noUnusedFunctionParameters": "error",
27 | "noUnusedImports": "error",
28 | "noUnusedVariables": "error",
29 | "useHookAtTopLevel": "error"
30 | },
31 | "style": {
32 | "useBlockStatements": "error",
33 | "useFilenamingConvention": {
34 | "level": "error",
35 | "options": {
36 | "strictCase": true,
37 | "filenameCases": ["kebab-case"]
38 | }
39 | }
40 | }
41 | }
42 | },
43 | "javascript": {
44 | "formatter": {
45 | "quoteStyle": "double",
46 | "trailingCommas": "all",
47 | "semicolons": "always",
48 | "arrowParentheses": "always"
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/client/index.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@client/components",
15 | "utils": "@client/lib/utils",
16 | "ui": "@client/components/ui",
17 | "lib": "@client/lib",
18 | "hooks": "@client/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import { defineConfig } from "drizzle-kit";
3 | import path from "node:path";
4 | import fs from "node:fs";
5 |
6 | function getLocalD1DB() {
7 | try {
8 | const basePath = path.resolve("./.wrangler/state/v3/d1");
9 | const dbFile = fs
10 | .readdirSync(basePath, { encoding: "utf-8", recursive: true })
11 | .find((f) => f.endsWith(".sqlite"));
12 |
13 | if (!dbFile) {
14 | throw new Error(`.sqlite file not found in ${basePath}`);
15 | }
16 |
17 | const url = path.resolve(basePath, dbFile);
18 | return url;
19 | } catch (err) {
20 | console.error(err);
21 |
22 | return null;
23 | }
24 | }
25 |
26 | export default defineConfig({
27 | out: "./src/server/db/migrations",
28 | schema: "./src/server/db/schema/index.ts",
29 | dialect: "sqlite",
30 | ...(process.env.NODE_ENV === "production"
31 | ? {
32 | driver: "d1-http",
33 | dbCredentials: {
34 | accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
35 | databaseId: process.env.DATABASE_ID,
36 | token: process.env.CLOUDFLARE_API_TOKEN,
37 | },
38 | }
39 | : {
40 | dbCredentials: {
41 | url: getLocalD1DB(),
42 | },
43 | }),
44 | });
45 |
--------------------------------------------------------------------------------
/drizzle.do.config.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import { defineConfig } from "drizzle-kit";
3 |
4 | const drizzleOut = process.env.DRIZZLE_OUT;
5 | const drizzleSchema = process.env.DRIZZLE_SCHEMA;
6 |
7 | // For Durable Objects
8 | export default defineConfig({
9 | out: drizzleOut,
10 | schema: drizzleSchema,
11 | dialect: "sqlite",
12 | driver: "durable-sqlite",
13 | });
14 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Chatsemble
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/public/chatsemble-app.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Alwurts/Chatsemble/2221748cebce401c466a786e77d2cebf9ca40ff9/public/chatsemble-app.jpg
--------------------------------------------------------------------------------
/public/logo.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Alwurts/Chatsemble/2221748cebce401c466a786e77d2cebf9ca40ff9/public/logo.ico
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/notion-avatars/avatar-01.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/notion-avatars/avatar-02.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/notion-avatars/avatar-03.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/notion-avatars/avatar-04.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/notion-avatars/avatar-07.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/notion-avatars/avatar-08.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/notion-avatars/avatar-09.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/notion-avatars/avatar-10.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/client/app.tsx:
--------------------------------------------------------------------------------
1 | function App() {
2 | return (
3 |
4 | Hello world
5 |
6 | );
7 | }
8 |
9 | export default App;
10 |
--------------------------------------------------------------------------------
/src/client/components/agents/agent-edit.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AgentNotFound } from "@client/components/agents/agent-placeholder";
4 |
5 | import { honoClient } from "@client/lib/api-client";
6 | import { useQuery } from "@tanstack/react-query";
7 | import { AgentSkeleton } from "./agent-skeleton";
8 |
9 | import { zodResolver } from "@hookform/resolvers/zod";
10 | import { useMutation, useQueryClient } from "@tanstack/react-query";
11 | import { useForm } from "react-hook-form";
12 |
13 | import { Form } from "@client/components/ui/form";
14 | import { type AgentFormValues, createAgentSchema } from "@shared/types";
15 | import { Bot } from "lucide-react";
16 | import {
17 | AppHeader,
18 | AppHeaderIcon,
19 | AppHeaderSeparator,
20 | AppHeaderTitle,
21 | } from "../layout/app-header";
22 |
23 | import { toast } from "sonner";
24 | import { Button } from "../ui/button";
25 | import { AgentForm } from "./agent-form";
26 |
27 | export function AgentEdit({ agentId }: { agentId: string }) {
28 | const { data: agent, isLoading } = useQuery({
29 | queryKey: ["agent", agentId],
30 | queryFn: async () => {
31 | const response = await honoClient.api.agents[":id"].$get({
32 | param: { id: agentId },
33 | });
34 | const agent = await response.json();
35 | return agent;
36 | },
37 | });
38 |
39 | const queryClient = useQueryClient();
40 |
41 | const form = useForm({
42 | resolver: zodResolver(createAgentSchema),
43 | values: {
44 | name: agent?.name ?? "",
45 | image: agent?.image ?? "",
46 | description: agent?.description ?? "",
47 | tone: agent?.tone ?? "formal",
48 | verbosity: agent?.verbosity ?? "concise",
49 | emojiUsage: agent?.emojiUsage ?? "none",
50 | languageStyle: agent?.languageStyle ?? "simple",
51 | },
52 | });
53 |
54 | const updateAgentMutation = useMutation({
55 | mutationFn: async (values: AgentFormValues) => {
56 | const response = await honoClient.api.agents[":id"].$put({
57 | param: { id: agentId },
58 | json: values,
59 | });
60 | return response.json();
61 | },
62 | onSuccess: () => {
63 | queryClient.invalidateQueries({ queryKey: ["agents"] });
64 | queryClient.invalidateQueries({ queryKey: ["agent", agentId] });
65 | toast.success("Agent updated successfully");
66 | },
67 | });
68 |
69 | const onSubmit = (values: AgentFormValues) => {
70 | updateAgentMutation.mutate(values);
71 | };
72 |
73 | return (
74 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/src/client/components/agents/agent-placeholder.tsx:
--------------------------------------------------------------------------------
1 | export function AgentNotSelected() {
2 | return (
3 |
4 |
No agent selected
5 |
6 | Please select an agent from the sidebar
7 |
8 |
9 | );
10 | }
11 |
12 | export function AgentNotFound() {
13 | return (
14 |
15 | Agent not found
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/client/components/agents/agent-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@client/components/ui/skeleton";
2 |
3 | export function AgentSkeleton() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | {Array.from({ length: 4 }).map((_, i) => (
34 | // biome-ignore lint/suspicious/noArrayIndexKey:
35 |
36 | ))}
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/client/components/agents/new-agent-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod";
2 | import { useMutation, useQueryClient } from "@tanstack/react-query";
3 | import { useForm } from "react-hook-form";
4 |
5 | import { Button } from "@client/components/ui/button";
6 | import {
7 | Dialog,
8 | DialogContent,
9 | DialogDescription,
10 | DialogFooter,
11 | DialogHeader,
12 | DialogTitle,
13 | } from "@client/components/ui/dialog";
14 | import { Form } from "@client/components/ui/form";
15 | import { honoClient } from "@client/lib/api-client";
16 | import { type AgentFormValues, createAgentSchema } from "@shared/types";
17 | import { useRouter } from "@tanstack/react-router";
18 | import { AgentForm } from "./agent-form";
19 |
20 | export function NewAgentDialog({
21 | open,
22 | setOpen,
23 | }: {
24 | open: boolean;
25 | setOpen: (open: boolean) => void;
26 | }) {
27 | return (
28 |
33 | );
34 | }
35 |
36 | function NewAgentDialogContent({
37 | setOpen,
38 | }: {
39 | setOpen: (open: boolean) => void;
40 | }) {
41 | const router = useRouter();
42 | const queryClient = useQueryClient();
43 | const form = useForm({
44 | resolver: zodResolver(createAgentSchema),
45 | defaultValues: {
46 | name: "",
47 | image: "/notion-avatars/avatar-01.svg",
48 | description: "",
49 | tone: "formal",
50 | verbosity: "concise",
51 | emojiUsage: "occasional",
52 | languageStyle: "simple",
53 | },
54 | });
55 |
56 | const createChatMutation = useMutation({
57 | mutationFn: async (values: AgentFormValues) => {
58 | const response = await honoClient.api.agents.$post({
59 | json: values,
60 | });
61 | const data = await response.json();
62 | return data;
63 | },
64 | onSuccess: (data) => {
65 | queryClient.invalidateQueries({ queryKey: ["agents"] });
66 | router.navigate({
67 | to: "/agents",
68 | search: (prev) => ({
69 | ...prev,
70 | agentId: data.agentId,
71 | }),
72 | });
73 | setOpen(false);
74 | },
75 | });
76 |
77 | const onSubmit = (values: AgentFormValues) => {
78 | createChatMutation.mutate(values);
79 | };
80 |
81 | return (
82 | <>
83 |
84 | Create New Agent
85 |
86 | Create a new agent to start conversations with your team.
87 |
88 |
89 |
102 |
103 | >
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/src/client/components/chat-room-member/chat-member-badge.tsx:
--------------------------------------------------------------------------------
1 | import { Badge } from "@client/components/ui/badge";
2 | import { cn } from "@client/lib/utils";
3 | import type { ChatRoomMemberType } from "@shared/types";
4 |
5 | interface ChatMemberBadgeProps {
6 | type: ChatRoomMemberType;
7 | }
8 |
9 | export function ChatMemberBadge({ type }: ChatMemberBadgeProps) {
10 | return (
11 |
18 | {type === "user" ? "User" : "Agent"}
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/client/components/chat-room-member/chat-member-list.tsx:
--------------------------------------------------------------------------------
1 | import { ChatMemberBadge } from "@client/components/chat-room-member/chat-member-badge";
2 | import { ChatMemberRemoveButton } from "@client/components/chat-room-member/chat-member-remove-button";
3 | import { ChatMemberAddDialog } from "@client/components/chat-room-member/new/chat-member-add-dialog";
4 | import { useOrganizationConnectionContext } from "@client/components/providers/organization-connection-provider";
5 | import {
6 | Avatar,
7 | AvatarFallback,
8 | AvatarImage,
9 | } from "@client/components/ui/avatar";
10 | import { authClient } from "@client/lib/auth-client";
11 | import type { ChatRoomMember } from "@shared/types";
12 |
13 | interface ChatMemberListProps {
14 | members: ChatRoomMember[];
15 | showRemoveButton?: boolean;
16 | }
17 |
18 | export function ChatMemberList({
19 | members,
20 | showRemoveButton = false,
21 | }: ChatMemberListProps) {
22 | const {
23 | mainChatRoomState: { room },
24 | } = useOrganizationConnectionContext();
25 |
26 | const { data: session, isPending: isSessionPending } =
27 | authClient.useSession();
28 |
29 | if (!members || members.length === 0) {
30 | return ;
31 | }
32 |
33 | return (
34 |
35 |
36 | {members.map((member: ChatRoomMember) => (
37 |
41 |
42 |
43 |
44 |
45 | {member.name[0]?.toUpperCase() ?? "?"}
46 |
47 |
48 |
{member.name}
49 |
50 |
51 | {showRemoveButton &&
52 | room &&
53 | !isSessionPending &&
54 | session?.user.id !== member.id && (
55 |
56 | )}
57 |
58 | ))}
59 |
60 | );
61 | }
62 |
63 | function ChatMemberListEmpty() {
64 | return (
65 |
66 | No members in this chat room
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/client/components/chat-room-member/chat-member-remove-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@client/components/ui/button";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogDescription,
8 | DialogFooter,
9 | DialogHeader,
10 | DialogTitle,
11 | DialogTrigger,
12 | } from "@client/components/ui/dialog";
13 | import { honoClient } from "@client/lib/api-client";
14 | import type { ChatRoomMember } from "@shared/types";
15 | import { useMutation } from "@tanstack/react-query";
16 | import { Trash2 } from "lucide-react";
17 | import { useState } from "react";
18 |
19 | interface ChatMemberRemoveButtonProps {
20 | member: ChatRoomMember;
21 | roomId: string;
22 | }
23 |
24 | export function ChatMemberRemoveButton({
25 | member,
26 | roomId,
27 | }: ChatMemberRemoveButtonProps) {
28 | const [open, setOpen] = useState(false);
29 |
30 | const removeMemberMutation = useMutation({
31 | mutationFn: async () => {
32 | const response = await honoClient.api.chat["chat-rooms"][
33 | ":chatRoomId"
34 | ].members[":memberId"].$delete({
35 | param: {
36 | chatRoomId: roomId,
37 | memberId: member.id,
38 | },
39 | });
40 | return response.json();
41 | },
42 | onSuccess: () => {
43 | setOpen(false);
44 | },
45 | });
46 |
47 | return (
48 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/src/client/components/chat-room-member/new/chat-member-add-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { ChatMemberAddForm } from "@client/components/chat-room-member/new/chat-member-add-form";
2 | import { useOrganizationConnectionContext } from "@client/components/providers/organization-connection-provider";
3 | import { Button } from "@client/components/ui/button";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogTrigger,
8 | } from "@client/components/ui/dialog";
9 | import { Plus } from "lucide-react";
10 | import { useState } from "react";
11 |
12 | export function ChatMemberAddDialog() {
13 | const [open, setOpen] = useState(false);
14 |
15 | return (
16 |
27 | );
28 | }
29 |
30 | function ChatMemberAddDialogContent({
31 | onSuccess,
32 | }: {
33 | onSuccess: () => void;
34 | }) {
35 | const {
36 | mainChatRoomState: { room },
37 | } = useOrganizationConnectionContext();
38 |
39 | if (!room) {
40 | return null;
41 | }
42 |
43 | return ;
44 | }
45 |
--------------------------------------------------------------------------------
/src/client/components/chat-room/chat-room-not-selected.tsx:
--------------------------------------------------------------------------------
1 | import { AppHeader, AppHeaderIcon } from "@client/components/layout/app-header";
2 | import { MessagesSquare } from "lucide-react";
3 |
4 | export function ChatRoomNotSelected() {
5 | return (
6 | <>
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
No chat room selected
15 |
16 | Select a chat room from the sidebar to start chatting
17 |
18 |
19 |
20 | >
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/client/components/chat-room/chat-room.tsx:
--------------------------------------------------------------------------------
1 | import { ChatRoomNotSelected } from "@client/components/chat-room/chat-room-not-selected";
2 | import { ChatRoomMainDisplay } from "@client/components/chat-room/main/chat-room-main-display";
3 | import { ChatRoomMainHeader } from "@client/components/chat-room/main/chat-room-main-header";
4 | import { ChatRoomThreadHeader } from "@client/components/chat-room/thread/chat-room-thread-header";
5 | import {
6 | ResizableHandle,
7 | ResizablePanel,
8 | ResizablePanelGroup,
9 | } from "@client/components/ui/resizable";
10 | import { useSearch } from "@tanstack/react-router";
11 | import { ChatRoomThreadDisplay } from "./thread/chat-room-thread-display";
12 |
13 | export function ChatRoom() {
14 | const { roomId, threadId } = useSearch({ strict: false });
15 |
16 | if (!roomId) {
17 | return ;
18 | }
19 |
20 | return (
21 | <>
22 |
23 |
29 |
30 |
31 |
32 |
33 |
34 | {threadId && }
35 | {threadId && (
36 |
42 |
43 |
44 |
45 |
46 |
47 | )}
48 |
49 | >
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/client/components/chat-room/common/chat-room-type-badge.tsx:
--------------------------------------------------------------------------------
1 | import { Badge } from "@client/components/ui/badge";
2 | import { cn } from "@client/lib/utils";
3 | import type { ChatRoomType } from "@shared/types";
4 |
5 | interface ChatRoomTypeBadgeProps {
6 | type: ChatRoomType;
7 | label?: string;
8 | }
9 |
10 | export function ChatRoomTypeBadge({ type, label }: ChatRoomTypeBadgeProps) {
11 | const labels: Record = {
12 | public: "Public",
13 | };
14 | return (
15 |
22 | {label ?? labels[type]}
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/client/components/chat-room/details/chat-details-members-section.tsx:
--------------------------------------------------------------------------------
1 | import { ChatMemberList } from "@client/components/chat-room-member/chat-member-list";
2 | import { useOrganizationConnectionContext } from "@client/components/providers/organization-connection-provider";
3 |
4 | export function ChatDetailsMembersSection() {
5 | const {
6 | mainChatRoomState: { members },
7 | } = useOrganizationConnectionContext();
8 |
9 | // TODO: Show or hide the add and remove member button based on the user's permissions
10 |
11 | return ;
12 | }
13 |
--------------------------------------------------------------------------------
/src/client/components/chat-room/details/chat-details-section.tsx:
--------------------------------------------------------------------------------
1 | import { ChatRoomTypeBadge } from "@client/components/chat-room/common/chat-room-type-badge";
2 | import { useOrganizationConnectionContext } from "@client/components/providers/organization-connection-provider";
3 | import { Separator } from "@client/components/ui/separator";
4 | import { CalendarIcon } from "lucide-react";
5 | import { UsersIcon } from "lucide-react";
6 |
7 | export function ChatDetailsSection() {
8 | const {
9 | mainChatRoomState: { room, members },
10 | } = useOrganizationConnectionContext();
11 |
12 | if (!room) {
13 | return null;
14 | }
15 |
16 | return (
17 |
18 |
19 |
{room.name}
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
Created
32 |
33 | {new Date(room.createdAt).toLocaleDateString("en-US", {
34 | year: "numeric",
35 | month: "long",
36 | day: "numeric",
37 | })}
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
Members
48 |
{members.length || 0}
49 |
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/client/components/chat-room/new/new-chat-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import type { z } from "zod";
3 |
4 | import { NewChatRoomGroupForm } from "@client/components/chat-room/new/new-chat-room-group";
5 | import {
6 | Dialog,
7 | DialogContent,
8 | DialogDescription,
9 | DialogHeader,
10 | DialogTitle,
11 | } from "@client/components/ui/dialog";
12 | import { honoClient } from "@client/lib/api-client";
13 | import type { ChatRoomType, createChatRoomSchema } from "@shared/types";
14 | import { useRouter } from "@tanstack/react-router";
15 | import type { Dispatch, SetStateAction } from "react";
16 | import { toast } from "sonner";
17 |
18 | type CreateChatRoomFormValues = z.infer;
19 |
20 | export type NewChatDialogState = {
21 | type: ChatRoomType;
22 | } | null;
23 |
24 | type NewGroupChatDialogProps = {
25 | dialogState: NewChatDialogState;
26 | setDialogState: Dispatch>;
27 | };
28 |
29 | export function NewChatRoomDialog({
30 | dialogState,
31 | setDialogState,
32 | }: NewGroupChatDialogProps) {
33 | return (
34 |
51 | );
52 | }
53 |
54 | function NewChatRoomDialogContent({
55 | dialogState,
56 | setDialogState,
57 | }: NewGroupChatDialogProps & {
58 | dialogState: NonNullable;
59 | }) {
60 | const router = useRouter();
61 |
62 | const createChatMutation = useMutation({
63 | mutationFn: async (values: CreateChatRoomFormValues) => {
64 | const response = await honoClient.api.chat["chat-rooms"].$post({
65 | json: values,
66 | });
67 | const data = await response.json();
68 | if ("error" in data) {
69 | throw new Error(data.error);
70 | }
71 | return data;
72 | },
73 | onSuccess: (data) => {
74 | router.navigate({
75 | to: "/chat",
76 | search: { roomId: data.roomId },
77 | });
78 | setDialogState(null);
79 | toast.success("Chat room created successfully");
80 | },
81 | onError: (error) => {
82 | console.error(error);
83 | toast.error("Failed to create chat room");
84 | },
85 | });
86 |
87 | const onSubmit = (values: CreateChatRoomFormValues) => {
88 | createChatMutation.mutate(values);
89 | };
90 |
91 | return (
92 | <>
93 |
94 | Create New Chat Room
95 |
96 | Create a new chat room to start conversations with your team.
97 |
98 |
99 |
100 |
105 | >
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/src/client/components/chat-room/thread/chat-room-thread-display.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | ChatMessageSkeleton,
5 | ChatRoomMessage,
6 | } from "@client/components/chat-room/chat-room-message";
7 | import { ChatMessagesSkeleton } from "@client/components/chat-room/chat-room-message";
8 | import { useAuthSession } from "@client/components/providers/auth-provider";
9 | import { useOrganizationConnectionContext } from "@client/components/providers/organization-connection-provider";
10 | import { ChatMessageArea } from "@client/components/ui/chat-message-area";
11 | import { Separator } from "@client/components/ui/separator";
12 | import {
13 | ChatInput,
14 | ChatInputSubmit,
15 | ChatInputTiptap,
16 | } from "@client/components/ui/tiptap-chat-input";
17 | import type { ChatInputValue } from "@shared/types";
18 | import { useMemo } from "react";
19 |
20 | export function ChatRoomThreadDisplay() {
21 | const {
22 | mainChatRoomState: { members },
23 | chatRoomThreadState: { handleSubmit, messages, status, threadMessage },
24 | connectionStatus,
25 | } = useOrganizationConnectionContext();
26 |
27 | const { user } = useAuthSession();
28 |
29 | const isLoading = connectionStatus !== "connected" || status !== "success";
30 |
31 | const membersWithoutCurrentUser = useMemo(
32 | () => members.filter((member) => member.id !== user.id),
33 | [members, user.id],
34 | );
35 |
36 | const onSubmit = (value: ChatInputValue) => {
37 | handleSubmit({ value });
38 | };
39 |
40 | return (
41 |
42 |
46 |
47 |
48 | {threadMessage ? (
49 |
50 | ) : (
51 | isLoading &&
52 | )}
53 |
54 |
55 |
56 | {isLoading ? (
57 |
58 | ) : messages.length > 0 ? (
59 | messages.map((message) => (
60 |
61 | ))
62 | ) : (
63 |
64 | Send a message to start the thread
65 |
66 | )}
67 |
68 |
69 |
70 |
71 |
76 |
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/src/client/components/chat-room/thread/chat-room-thread-header.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@client/components/ui/button";
4 | import { Separator } from "@client/components/ui/separator";
5 | import { useRouter } from "@tanstack/react-router";
6 | import { X } from "lucide-react";
7 |
8 | export function ChatRoomThreadHeader() {
9 | const router = useRouter();
10 |
11 | return (
12 |
13 |
14 |
31 |
35 | Thread
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/client/components/common/avatar-picker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@client/components/ui/button";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "@client/components/ui/dialog";
10 | import { UserRound } from "lucide-react";
11 | import { useState } from "react";
12 |
13 | interface AvatarPickerProps {
14 | value: string;
15 | onChange: (value: string) => void;
16 | }
17 |
18 | export function AvatarPicker({ value, onChange }: AvatarPickerProps) {
19 | const [dialogOpen, setDialogOpen] = useState(false);
20 |
21 | return (
22 |
23 |
24 | {value ? (
25 |

30 | ) : (
31 |
32 |
33 |
34 | )}
35 |
36 |
44 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/src/client/components/common/card-toggle-groups.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ToggleGroup,
3 | ToggleGroupItem,
4 | } from "@client/components/ui/toggle-group";
5 | import { idToReadableText } from "@client/lib/id-parsing";
6 |
7 | interface ToggleGroupProps {
8 | value: T | undefined;
9 | options: Readonly;
10 | descriptions: Readonly>;
11 | iconMap: Readonly<
12 | Record>
13 | >;
14 | onValueChange: (value: T) => void;
15 | }
16 |
17 | export function CardToggleGroup({
18 | value,
19 | onValueChange,
20 | options,
21 | descriptions,
22 | iconMap,
23 | }: ToggleGroupProps) {
24 | return (
25 |
31 | {options.map((option) => {
32 | const Icon = iconMap[option] ?? (() => null);
33 | return (
34 |
40 |
41 | {idToReadableText(option, { capitalize: true })}
42 |
43 | {descriptions[option]}
44 |
45 |
46 | );
47 | })}
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/client/components/common/confirmation-dialog.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogAction,
4 | AlertDialogCancel,
5 | AlertDialogContent,
6 | AlertDialogDescription,
7 | AlertDialogFooter,
8 | AlertDialogHeader,
9 | AlertDialogTitle,
10 | AlertDialogTrigger,
11 | } from "@client/components/ui/alert-dialog";
12 |
13 | export function ConfirmationDialog({
14 | children,
15 | title,
16 | description,
17 | open,
18 | onOpenChange,
19 | onConfirm,
20 | }: {
21 | children: React.ReactNode;
22 | title: string;
23 | description: string;
24 | open: boolean;
25 | onOpenChange: (open: boolean) => void;
26 | onConfirm: () => void;
27 | }) {
28 | return (
29 |
30 | {children}
31 |
32 |
33 | {title}
34 | {description}
35 |
36 |
37 | Cancel
38 | Continue
39 |
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/client/components/common/copy-button.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@client/components/ui/button";
2 | import { useState } from "react";
3 |
4 | export const CopyButton = ({ textToCopy }: { textToCopy: string }) => {
5 | const [copied, setCopied] = useState(false);
6 |
7 | const handleCopy = async () => {
8 | await navigator.clipboard.writeText(textToCopy);
9 | setCopied(true);
10 | setTimeout(() => setCopied(false), 2000);
11 | };
12 |
13 | return (
14 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/client/components/common/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { MoonIcon, SunIcon } from "lucide-react";
4 | import { useTheme } from "next-themes";
5 | import { useCallback } from "react";
6 | import { Button } from "../ui/button";
7 | import { SidebarMenuButton } from "../ui/sidebar";
8 |
9 | export const ThemeToggle = ({
10 | variant = "default",
11 | }: { variant?: "default" | "icon" }) => {
12 | const { theme, setTheme } = useTheme();
13 |
14 | const toggleTheme = useCallback(() => {
15 | setTheme(theme === "light" ? "dark" : "light");
16 | }, [theme, setTheme]);
17 |
18 | if (variant === "icon") {
19 | return (
20 |
25 | );
26 | }
27 |
28 | return (
29 |
30 |
31 |
32 | Toggle theme
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/src/client/components/icons/loading-pyramid.tsx:
--------------------------------------------------------------------------------
1 | interface AnimatedPyramidIconProps
2 | extends Omit<
3 | React.SVGProps,
4 | "width" | "height" | "viewBox" | "color"
5 | > {
6 | // Exclude color prop as it's internally set
7 | /** Animation duration in seconds (e.g., "3s") */
8 | duration?: string;
9 | /** Radius of the spheres */
10 | sphereRadius?: number;
11 | }
12 |
13 | /**
14 | * An animated SVG icon showing 3 spheres in a pyramid formation,
15 | * cycling through positions and using the current CSS color.
16 | * Size is controlled externally via CSS/Tailwind classes.
17 | */
18 | export function AnimatedPyramidIcon({
19 | duration = "3s",
20 | sphereRadius = 20,
21 | ...props
22 | }: AnimatedPyramidIconProps) {
23 | const topPos = { x: 50, y: sphereRadius };
24 | const bottomLeftPos = { x: sphereRadius, y: 100 - sphereRadius };
25 | const bottomRightPos = { x: 100 - sphereRadius, y: 100 - sphereRadius };
26 |
27 | const path1to2 = `M 0 0 L ${bottomLeftPos.x - topPos.x} ${bottomLeftPos.y - topPos.y}`;
28 | const path2to3 = `M 0 0 L ${bottomRightPos.x - bottomLeftPos.x} ${bottomRightPos.y - bottomLeftPos.y}`;
29 | const path3to1 = `M 0 0 L ${topPos.x - bottomRightPos.x} ${topPos.y - bottomRightPos.y}`;
30 |
31 | return (
32 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/client/components/icons/logo-icon.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from "react";
2 |
3 | export function LogoIcon(props: SVGProps) {
4 | return (
5 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/client/components/layout/app-header.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from "@client/components/ui/separator";
2 | import { SidebarTrigger } from "@client/components/ui/sidebar";
3 | import { cn } from "@client/lib/utils";
4 |
5 | export function AppHeader({ children }: React.ComponentProps<"div">) {
6 | return (
7 |
8 |
9 |
10 |
11 | {children}
12 |
13 |
14 | );
15 | }
16 |
17 | export function AppHeaderIcon({
18 | children,
19 | className,
20 | }: React.ComponentProps<"span">) {
21 | return (
22 |
28 | {children}
29 |
30 | );
31 | }
32 |
33 | export function AppHeaderTitle({
34 | children,
35 | className,
36 | }: React.ComponentProps<"span">) {
37 | return (
38 | {children}
39 | );
40 | }
41 |
42 | export function AppHeaderSeparator({ className }: React.ComponentProps<"div">) {
43 | return (
44 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/client/components/layout/app-layout-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { SidebarInset } from "@client/components/ui/sidebar";
2 | import { Skeleton } from "@client/components/ui/skeleton";
3 |
4 | function SkeletonSidebar() {
5 | return (
6 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | {Array.from({ length: 5 }).map((_, i) => (
18 |
21 | i
22 | }`}
23 | className="h-8 w-full mb-2"
24 | />
25 | ))}
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | export function AppLayoutSkeleton() {
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/client/components/layout/app-layout.tsx:
--------------------------------------------------------------------------------
1 | import { AppSidebar } from "@client/components/layout/app-sidebar";
2 | import { SidebarInset } from "@client/components/ui/sidebar";
3 |
4 | export function AppLayout({
5 | children,
6 | sidebarChildren,
7 | }: {
8 | children: React.ReactNode;
9 | sidebarChildren: React.ReactNode;
10 | }) {
11 | return (
12 | <>
13 | {sidebarChildren}
14 | {children}
15 | >
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/client/components/layout/app-sidebar-group-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | SidebarGroup,
3 | SidebarGroupAction,
4 | SidebarGroupLabel,
5 | SidebarMenu,
6 | SidebarMenuItem,
7 | SidebarMenuSkeleton,
8 | } from "@client/components/ui/sidebar";
9 | import { Skeleton } from "@client/components/ui/skeleton";
10 |
11 | export function AppSidebarGroupSkeleton({
12 | listLength,
13 | }: { listLength: number }) {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | {Array.from({ length: listLength }).map((_, index) => (
24 | // biome-ignore lint/suspicious/noArrayIndexKey:
25 |
26 |
27 |
28 | ))}
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/client/components/providers/auth-provider.tsx:
--------------------------------------------------------------------------------
1 | import { authClient } from "@client/lib/auth-client";
2 | import { createContext, useContext } from "react";
3 |
4 | type SessionContextType = ReturnType["data"];
5 |
6 | const SessionContext = createContext(null);
7 |
8 | export function useAuthSession() {
9 | const session = useContext(SessionContext);
10 |
11 | if (!session) {
12 | throw new Error("useSession must be used within an AuthProvider");
13 | }
14 |
15 | return session;
16 | }
17 |
18 | export function AuthProvider({ children }: { children: React.ReactNode }) {
19 | const { data: session } = authClient.useSession();
20 |
21 | return (
22 |
23 | {children}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/client/components/providers/organization-connection-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | type UseOrganizationConnectionProps,
5 | useOrganizationConnection,
6 | } from "@client/hooks/organization/use-organization-connection";
7 |
8 | import { type ReactNode, createContext, useContext } from "react";
9 |
10 | const OrganizationConnectionContext = createContext | null>(null);
13 |
14 | interface OrganizationConnectionProviderProps
15 | extends UseOrganizationConnectionProps {
16 | children: ReactNode;
17 | }
18 |
19 | export function OrganizationConnectionProvider({
20 | children,
21 | ...props
22 | }: OrganizationConnectionProviderProps) {
23 | const connectionState = useOrganizationConnection(props);
24 |
25 | return (
26 |
27 | {children}
28 |
29 | );
30 | }
31 |
32 | export function useOrganizationConnectionContext() {
33 | const context = useContext(OrganizationConnectionContext);
34 | if (!context) {
35 | throw new Error(
36 | "useOrganizationConnectionContext must be used within a OrganizationConnectionProvider",
37 | );
38 | }
39 | return context;
40 | }
41 |
--------------------------------------------------------------------------------
/src/client/components/providers/query-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4 | import type { ReactNode } from "react";
5 |
6 | const queryClient = new QueryClient();
7 |
8 | export function QueryProvider({ children }: { children: ReactNode }) {
9 | return (
10 | {children}
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/client/components/providers/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | ThemeProvider as NextThemesProvider,
5 | type ThemeProviderProps,
6 | } from "next-themes";
7 |
8 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
9 | return {children};
10 | }
11 |
--------------------------------------------------------------------------------
/src/client/components/settings/profile-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { useForm } from "react-hook-form";
5 | import * as z from "zod";
6 |
7 | import { Button } from "@client/components/ui/button";
8 | import {
9 | Form,
10 | FormControl,
11 | FormDescription,
12 | FormField,
13 | FormItem,
14 | FormLabel,
15 | FormMessage,
16 | } from "@client/components/ui/form";
17 | import { Input } from "@client/components/ui/input";
18 | import { toast } from "sonner";
19 |
20 | const profileFormSchema = z.object({
21 | username: z
22 | .string()
23 | .min(2, {
24 | message: "Username must be at least 2 characters.",
25 | })
26 | .max(30, {
27 | message: "Username must not be longer than 30 characters.",
28 | }),
29 | email: z
30 | .string({
31 | required_error: "Please select an email to display.",
32 | })
33 | .email(),
34 | });
35 |
36 | type ProfileFormValues = z.infer;
37 |
38 | // TODO: Implement profile editing
39 | const defaultValues: Partial = {
40 | username: "john-doe",
41 | email: "john@example.com",
42 | };
43 |
44 | export function ProfileForm() {
45 | const form = useForm({
46 | resolver: zodResolver(profileFormSchema),
47 | defaultValues,
48 | mode: "onChange",
49 | });
50 |
51 | function onSubmit() {
52 | toast.success("You submitted the following values:");
53 | }
54 |
55 | return (
56 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/client/components/tools/sources-tool.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollArea } from "@client/components/ui/scroll-area";
2 | import { cn } from "@client/lib/utils";
3 | import type { ToolSource } from "@shared/types";
4 | import { Link2 } from "lucide-react";
5 |
6 | export interface ToolInvocationSourceProps {
7 | source: ToolSource;
8 | }
9 |
10 | export function ToolInvocationSource({ source }: ToolInvocationSourceProps) {
11 | return (
12 |
18 | {source.icon ? (
19 |
20 | ) : (
21 |
22 | )}
23 |
24 |
25 | {source.title}
26 |
27 |
28 |
34 | {source.url}
35 |
36 |
37 |
38 | {source.content}
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | export interface ToolInvocationSourcesListProps {
46 | sources: ToolSource[];
47 | maxVisible?: number;
48 | maxHeight?: string;
49 | }
50 |
51 | export function ToolInvocationSourcesList({
52 | sources,
53 | }: ToolInvocationSourcesListProps) {
54 | return (
55 |
56 |
Sources ({sources.length})
57 |
58 |
59 | {sources.map((source, index) => (
60 |
64 | ))}
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/client/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import { type VariantProps, cva } from "class-variance-authority";
2 | import type * as React from "react";
3 |
4 | import { cn } from "@client/lib/utils";
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-card text-card-foreground",
12 | destructive:
13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | },
20 | );
21 |
22 | function Alert({
23 | className,
24 | variant,
25 | ...props
26 | }: React.ComponentProps<"div"> & VariantProps) {
27 | return (
28 |
34 | );
35 | }
36 |
37 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
38 | return (
39 |
47 | );
48 | }
49 |
50 | function AlertDescription({
51 | className,
52 | ...props
53 | }: React.ComponentProps<"div">) {
54 | return (
55 |
63 | );
64 | }
65 |
66 | export { Alert, AlertTitle, AlertDescription };
67 |
--------------------------------------------------------------------------------
/src/client/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
2 | import type * as React from "react";
3 |
4 | import { cn } from "@client/lib/utils";
5 |
6 | function Avatar({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
19 | );
20 | }
21 |
22 | function AvatarImage({
23 | className,
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
32 | );
33 | }
34 |
35 | function AvatarFallback({
36 | className,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
48 | );
49 | }
50 |
51 | export { Avatar, AvatarImage, AvatarFallback };
52 |
--------------------------------------------------------------------------------
/src/client/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { type VariantProps, cva } from "class-variance-authority";
3 | import type * as React from "react";
4 |
5 | import { cn } from "@client/lib/utils";
6 |
7 | const badgeVariants = cva(
8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14 | secondary:
15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
16 | destructive:
17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
18 | outline:
19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20 | },
21 | },
22 | defaultVariants: {
23 | variant: "default",
24 | },
25 | },
26 | );
27 |
28 | function Badge({
29 | className,
30 | variant,
31 | asChild = false,
32 | ...props
33 | }: React.ComponentProps<"span"> &
34 | VariantProps & { asChild?: boolean }) {
35 | const Comp = asChild ? Slot : "span";
36 |
37 | return (
38 |
43 | );
44 | }
45 |
46 | export { Badge, badgeVariants };
47 |
--------------------------------------------------------------------------------
/src/client/components/ui/breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { ChevronRight, MoreHorizontal } from "lucide-react";
3 | import type * as React from "react";
4 |
5 | import { cn } from "@client/lib/utils";
6 |
7 | function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
8 | return ;
9 | }
10 |
11 | function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
12 | return (
13 |
21 | );
22 | }
23 |
24 | function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
25 | return (
26 |
31 | );
32 | }
33 |
34 | function BreadcrumbLink({
35 | asChild,
36 | className,
37 | ...props
38 | }: React.ComponentProps<"a"> & {
39 | asChild?: boolean;
40 | }) {
41 | const Comp = asChild ? Slot : "a";
42 |
43 | return (
44 |
49 | );
50 | }
51 |
52 | function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
53 | return (
54 | // biome-ignore lint/a11y/useFocusableInteractive:
55 |
63 | );
64 | }
65 |
66 | function BreadcrumbSeparator({
67 | children,
68 | className,
69 | ...props
70 | }: React.ComponentProps<"li">) {
71 | return (
72 | svg]:size-3.5", className)}
77 | {...props}
78 | >
79 | {children ?? }
80 |
81 | );
82 | }
83 |
84 | function BreadcrumbEllipsis({
85 | className,
86 | ...props
87 | }: React.ComponentProps<"span">) {
88 | return (
89 |
96 |
97 | More
98 |
99 | );
100 | }
101 |
102 | export {
103 | Breadcrumb,
104 | BreadcrumbList,
105 | BreadcrumbItem,
106 | BreadcrumbLink,
107 | BreadcrumbPage,
108 | BreadcrumbSeparator,
109 | BreadcrumbEllipsis,
110 | };
111 |
--------------------------------------------------------------------------------
/src/client/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { type VariantProps, cva } from "class-variance-authority";
3 | import type * as React from "react";
4 |
5 | import { cn } from "@client/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | },
36 | );
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<"button"> &
45 | VariantProps & {
46 | asChild?: boolean;
47 | }) {
48 | const Comp = asChild ? Slot : "button";
49 |
50 | return (
51 |
56 | );
57 | }
58 |
59 | export { Button, buttonVariants };
60 |
--------------------------------------------------------------------------------
/src/client/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from "react";
2 |
3 | import { cn } from "@client/lib/utils";
4 |
5 | function Card({ className, ...props }: React.ComponentProps<"div">) {
6 | return (
7 |
15 | );
16 | }
17 |
18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19 | return (
20 |
28 | );
29 | }
30 |
31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32 | return (
33 |
38 | );
39 | }
40 |
41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42 | return (
43 |
48 | );
49 | }
50 |
51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52 | return (
53 |
61 | );
62 | }
63 |
64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65 | return (
66 |
71 | );
72 | }
73 |
74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
75 | return (
76 |
81 | );
82 | }
83 |
84 | export {
85 | Card,
86 | CardHeader,
87 | CardFooter,
88 | CardTitle,
89 | CardAction,
90 | CardDescription,
91 | CardContent,
92 | };
93 |
--------------------------------------------------------------------------------
/src/client/components/ui/chat-message-area.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@client/components/ui/button";
2 | import { ScrollArea } from "@client/components/ui/scroll-area";
3 | import { useScrollToBottom } from "@client/hooks/use-scroll-to-bottom";
4 | import { cn } from "@client/lib/utils";
5 | import { ChevronDown } from "lucide-react";
6 | import type { ReactNode } from "react";
7 |
8 | type ScrollButtonAlignment = "left" | "center" | "right";
9 |
10 | interface ScrollButtonProps {
11 | onClick: () => void;
12 | alignment?: ScrollButtonAlignment;
13 | className?: string;
14 | }
15 |
16 | export function ScrollButton({
17 | onClick,
18 | alignment = "right",
19 | className,
20 | }: ScrollButtonProps) {
21 | const alignmentClasses = {
22 | left: "left-4",
23 | center: "left-1/2 -translate-x-1/2",
24 | right: "right-4",
25 | };
26 |
27 | return (
28 |
40 | );
41 | }
42 |
43 | interface ChatMessageAreaProps {
44 | children: ReactNode;
45 | className?: string;
46 | scrollButtonAlignment?: ScrollButtonAlignment;
47 | }
48 |
49 | export function ChatMessageArea({
50 | children,
51 | className,
52 | scrollButtonAlignment = "right",
53 | }: ChatMessageAreaProps) {
54 | const [containerRef, showScrollButton, scrollToBottom] =
55 | useScrollToBottom();
56 |
57 | return (
58 |
59 |
60 | {children}
61 |
62 | {showScrollButton && (
63 |
68 | )}
69 |
70 | );
71 | }
72 |
73 | ChatMessageArea.displayName = "ChatMessageArea";
74 |
--------------------------------------------------------------------------------
/src/client/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
2 | import { CheckIcon } from "lucide-react";
3 | import type * as React from "react";
4 |
5 | import { cn } from "@client/lib/utils";
6 |
7 | function Checkbox({
8 | className,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
20 |
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | export { Checkbox };
31 |
--------------------------------------------------------------------------------
/src/client/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
2 |
3 | function Collapsible({
4 | ...props
5 | }: React.ComponentProps) {
6 | return ;
7 | }
8 |
9 | function CollapsibleTrigger({
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
17 | );
18 | }
19 |
20 | function CollapsibleContent({
21 | ...props
22 | }: React.ComponentProps) {
23 | return (
24 |
28 | );
29 | }
30 |
31 | export { Collapsible, CollapsibleTrigger, CollapsibleContent };
32 |
--------------------------------------------------------------------------------
/src/client/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from "react";
2 |
3 | import { cn } from "@client/lib/utils";
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | );
19 | }
20 |
21 | export { Input };
22 |
--------------------------------------------------------------------------------
/src/client/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as LabelPrimitive from "@radix-ui/react-label";
4 | import type * as React from "react";
5 |
6 | import { cn } from "@client/lib/utils";
7 |
8 | function Label({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | );
22 | }
23 |
24 | export { Label };
25 |
--------------------------------------------------------------------------------
/src/client/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as PopoverPrimitive from "@radix-ui/react-popover";
2 | import type * as React from "react";
3 |
4 | import { cn } from "@client/lib/utils";
5 |
6 | function Popover({
7 | ...props
8 | }: React.ComponentProps) {
9 | return ;
10 | }
11 |
12 | function PopoverTrigger({
13 | ...props
14 | }: React.ComponentProps) {
15 | return ;
16 | }
17 |
18 | function PopoverContent({
19 | className,
20 | align = "center",
21 | sideOffset = 4,
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
26 |
36 |
37 | );
38 | }
39 |
40 | function PopoverAnchor({
41 | ...props
42 | }: React.ComponentProps) {
43 | return ;
44 | }
45 |
46 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
47 |
--------------------------------------------------------------------------------
/src/client/components/ui/resizable.tsx:
--------------------------------------------------------------------------------
1 | import { GripVerticalIcon } from "lucide-react";
2 | import type * as React from "react";
3 | import * as ResizablePrimitive from "react-resizable-panels";
4 |
5 | import { cn } from "@client/lib/utils";
6 |
7 | function ResizablePanelGroup({
8 | className,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
20 | );
21 | }
22 |
23 | function ResizablePanel({
24 | ...props
25 | }: React.ComponentProps) {
26 | return ;
27 | }
28 |
29 | function ResizableHandle({
30 | withHandle,
31 | className,
32 | ...props
33 | }: React.ComponentProps & {
34 | withHandle?: boolean;
35 | }) {
36 | return (
37 | div]:rotate-90",
41 | className,
42 | )}
43 | {...props}
44 | >
45 | {withHandle && (
46 |
47 |
48 |
49 | )}
50 |
51 | );
52 | }
53 |
54 | export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
55 |
--------------------------------------------------------------------------------
/src/client/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
4 | import type * as React from "react";
5 |
6 | import { cn } from "@client/lib/utils";
7 |
8 | function ScrollArea({
9 | className,
10 | children,
11 | ...props
12 | }: React.ComponentProps) {
13 | return (
14 |
19 |
23 | {children}
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
31 | function ScrollBar({
32 | className,
33 | orientation = "vertical",
34 | ...props
35 | }: React.ComponentProps) {
36 | return (
37 |
50 |
54 |
55 | );
56 | }
57 |
58 | export { ScrollArea, ScrollBar };
59 |
--------------------------------------------------------------------------------
/src/client/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as SeparatorPrimitive from "@radix-ui/react-separator";
4 | import type * as React from "react";
5 |
6 | import { cn } from "@client/lib/utils";
7 |
8 | function Separator({
9 | className,
10 | orientation = "horizontal",
11 | decorative = true,
12 | ...props
13 | }: React.ComponentProps) {
14 | return (
15 |
25 | );
26 | }
27 |
28 | export { Separator };
29 |
--------------------------------------------------------------------------------
/src/client/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@client/lib/utils";
2 |
3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 | return (
5 |
10 | );
11 | }
12 |
13 | export { Skeleton };
14 |
--------------------------------------------------------------------------------
/src/client/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "next-themes";
2 | import { Toaster as Sonner, type ToasterProps } from "sonner";
3 |
4 | const Toaster = ({ ...props }: ToasterProps) => {
5 | const { theme = "system" } = useTheme();
6 |
7 | return (
8 |
20 | );
21 | };
22 |
23 | export { Toaster };
24 |
--------------------------------------------------------------------------------
/src/client/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as SwitchPrimitive from "@radix-ui/react-switch";
2 | import type * as React from "react";
3 |
4 | import { cn } from "@client/lib/utils";
5 |
6 | function Switch({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
19 |
25 |
26 | );
27 | }
28 |
29 | export { Switch };
30 |
--------------------------------------------------------------------------------
/src/client/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as TabsPrimitive from "@radix-ui/react-tabs";
4 | import type * as React from "react";
5 |
6 | import { cn } from "@client/lib/utils";
7 |
8 | function Tabs({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
18 | );
19 | }
20 |
21 | function TabsList({
22 | className,
23 | ...props
24 | }: React.ComponentProps) {
25 | return (
26 |
34 | );
35 | }
36 |
37 | function TabsTrigger({
38 | className,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
50 | );
51 | }
52 |
53 | function TabsContent({
54 | className,
55 | ...props
56 | }: React.ComponentProps) {
57 | return (
58 |
63 | );
64 | }
65 |
66 | export { Tabs, TabsList, TabsTrigger, TabsContent };
67 |
--------------------------------------------------------------------------------
/src/client/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from "react";
2 |
3 | import { cn } from "@client/lib/utils";
4 |
5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6 | return (
7 |
15 | );
16 | }
17 |
18 | export { Textarea };
19 |
--------------------------------------------------------------------------------
/src/client/components/ui/tiptap/mention-config.ts:
--------------------------------------------------------------------------------
1 | import type { ChatRoomMember } from "@shared/types";
2 | import { ReactRenderer } from "@tiptap/react";
3 | import type { SuggestionProps } from "@tiptap/suggestion";
4 | import tippy from "tippy.js";
5 | import MentionList from "./mention-list";
6 | import type { TiptapMentionItem } from "./tiptap";
7 |
8 | export function getMentionSuggestion(
9 | membersRef: React.RefObject,
10 | ) {
11 | return {
12 | items: ({ query }: { query: string }) => {
13 | // Access the latest members from the ref, default to empty array if undefined
14 | const members = membersRef.current || [];
15 | return members
16 | .filter((item) =>
17 | item.name.toLowerCase().startsWith(query.toLowerCase()),
18 | )
19 | .slice(0, 5);
20 | },
21 |
22 | render: () => {
23 | let component: ReactRenderer;
24 | let popup: ReturnType;
25 |
26 | return {
27 | onStart: (props: SuggestionProps) => {
28 | component = new ReactRenderer(MentionList, {
29 | props: {
30 | items: props.items,
31 | command: props.command,
32 | },
33 | editor: props.editor,
34 | });
35 |
36 | if (!props.clientRect) {
37 | return;
38 | }
39 |
40 | popup = tippy("body", {
41 | getReferenceClientRect: props.clientRect as () => DOMRect,
42 | appendTo: () => document.body,
43 | content: component.element,
44 | showOnCreate: true,
45 | interactive: true,
46 | trigger: "manual",
47 | placement: "bottom-start",
48 | });
49 | },
50 |
51 | onUpdate: (props: SuggestionProps) => {
52 | component.updateProps(props);
53 |
54 | if (!props.clientRect) {
55 | return;
56 | }
57 |
58 | popup[0].setProps({
59 | // Type assertion due to potential Tippy.js type inaccuracies
60 | getReferenceClientRect: props.clientRect as () => DOMRect,
61 | });
62 | },
63 |
64 | onKeyDown: (props: { event: KeyboardEvent }) => {
65 | if (props.event.key === "Escape") {
66 | popup[0].hide();
67 | return true;
68 | }
69 |
70 | // @ts-expect-error
71 | return component.ref?.onKeyDown({ event: props.event }) || false;
72 | },
73 |
74 | onExit: () => {
75 | popup[0].destroy();
76 | component.destroy();
77 | },
78 | };
79 | },
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/src/client/components/ui/tiptap/mention-list.tsx:
--------------------------------------------------------------------------------
1 | import { ChatMemberBadge } from "@client/components/chat-room-member/chat-member-badge";
2 | import {
3 | Avatar,
4 | AvatarFallback,
5 | AvatarImage,
6 | } from "@client/components/ui/avatar";
7 | import { Button } from "@client/components/ui/button";
8 | import { cn } from "@client/lib/utils";
9 | import type { ChatRoomMember } from "@shared/types";
10 | import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
11 | import type { TiptapMentionItem } from "./tiptap";
12 |
13 | interface MentionListProps {
14 | items: ChatRoomMember[];
15 | command: (item: TiptapMentionItem) => void;
16 | }
17 |
18 | interface MentionListRef {
19 | onKeyDown: (props: { event: KeyboardEvent }) => boolean;
20 | }
21 |
22 | export default forwardRef((props, ref) => {
23 | const [selectedIndex, setSelectedIndex] = useState(0);
24 |
25 | const selectItem = (index: number) => {
26 | const item = props.items[index];
27 |
28 | if (item) {
29 | props.command({
30 | id: item.id,
31 | label: item.name,
32 | });
33 | }
34 | };
35 |
36 | const upHandler = () => {
37 | setSelectedIndex(
38 | (selectedIndex + props.items.length - 1) % props.items.length,
39 | );
40 | };
41 |
42 | const downHandler = () => {
43 | setSelectedIndex((selectedIndex + 1) % props.items.length);
44 | };
45 |
46 | const enterHandler = () => {
47 | selectItem(selectedIndex);
48 | };
49 |
50 | // biome-ignore lint/correctness/useExhaustiveDependencies:
51 | useEffect(() => setSelectedIndex(0), [props.items]);
52 |
53 | useImperativeHandle(ref, () => ({
54 | onKeyDown: ({ event }: { event: KeyboardEvent }) => {
55 | if (event.key === "ArrowUp") {
56 | upHandler();
57 | return true;
58 | }
59 |
60 | if (event.key === "ArrowDown") {
61 | downHandler();
62 | return true;
63 | }
64 |
65 | if (event.key === "Enter") {
66 | enterHandler();
67 | return true;
68 | }
69 |
70 | return false;
71 | },
72 | }));
73 |
74 | return (
75 |
76 | {props.items.length ? (
77 | props.items.map((item, index) => (
78 |
104 | ))
105 | ) : (
106 |
107 | No results found
108 |
109 | )}
110 |
111 | );
112 | });
113 |
--------------------------------------------------------------------------------
/src/client/components/ui/tiptap/metion-plugin.ts:
--------------------------------------------------------------------------------
1 | import Mention from "@tiptap/extension-mention";
2 | /* import type { Node } from "@tiptap/pm/model"; */
3 |
4 | export const MentioPlugin = Mention.extend({
5 | name: "mention",
6 | /* addStorage() {
7 | return {
8 | markdown: {
9 | serialize: (state: { write: (arg: string) => void }, node: Node) => {
10 | state.write(`@{"id":${node.attrs.id},"label":"${node.attrs.label}"}`);
11 | },
12 | },
13 | };
14 | }, */
15 | });
16 |
--------------------------------------------------------------------------------
/src/client/components/ui/toggle-group.tsx:
--------------------------------------------------------------------------------
1 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
2 | import type { VariantProps } from "class-variance-authority";
3 | import * as React from "react";
4 |
5 | import { toggleVariants } from "@client/components/ui/toggle";
6 | import { cn } from "@client/lib/utils";
7 |
8 | const ToggleGroupContext = React.createContext<
9 | VariantProps
10 | >({
11 | size: "default",
12 | variant: "default",
13 | });
14 |
15 | function ToggleGroup({
16 | className,
17 | variant,
18 | size,
19 | children,
20 | ...props
21 | }: React.ComponentProps &
22 | VariantProps) {
23 | return (
24 |
34 |
35 | {children}
36 |
37 |
38 | );
39 | }
40 |
41 | function ToggleGroupItem({
42 | className,
43 | children,
44 | variant,
45 | size,
46 | ...props
47 | }: React.ComponentProps &
48 | VariantProps) {
49 | const context = React.useContext(ToggleGroupContext);
50 |
51 | return (
52 |
66 | {children}
67 |
68 | );
69 | }
70 |
71 | export { ToggleGroup, ToggleGroupItem };
72 |
--------------------------------------------------------------------------------
/src/client/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as TogglePrimitive from "@radix-ui/react-toggle";
4 | import { type VariantProps, cva } from "class-variance-authority";
5 | import type * as React from "react";
6 |
7 | import { cn } from "@client/lib/utils";
8 |
9 | const toggleVariants = cva(
10 | "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
11 | {
12 | variants: {
13 | variant: {
14 | default: "bg-transparent",
15 | outline:
16 | "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
17 | },
18 | size: {
19 | default: "h-9 px-2 min-w-9",
20 | sm: "h-8 px-1.5 min-w-8",
21 | lg: "h-10 px-2.5 min-w-10",
22 | },
23 | },
24 | defaultVariants: {
25 | variant: "default",
26 | size: "default",
27 | },
28 | },
29 | );
30 |
31 | function Toggle({
32 | className,
33 | variant,
34 | size,
35 | ...props
36 | }: React.ComponentProps &
37 | VariantProps) {
38 | return (
39 |
44 | );
45 | }
46 |
47 | export { Toggle, toggleVariants };
48 |
--------------------------------------------------------------------------------
/src/client/components/ui/tool-invocation.tsx:
--------------------------------------------------------------------------------
1 | import { Card } from "@client/components/ui/card";
2 | import { idToReadableText } from "@client/lib/id-parsing";
3 | import { cn } from "@client/lib/utils";
4 | import { CheckCircleIcon } from "lucide-react";
5 | import { AnimatedPyramidIcon } from "../icons/loading-pyramid";
6 |
7 | export function ToolInvocation({
8 | children,
9 | className,
10 | }: {
11 | children: React.ReactNode;
12 | className?: string;
13 | }) {
14 | return (
15 | {children}
16 | );
17 | }
18 |
19 | export function ToolInvocationHeader({
20 | children,
21 | className,
22 | }: { children: React.ReactNode; className?: string }) {
23 | return (
24 |
25 | {children}
26 |
27 | );
28 | }
29 |
30 | export function ToolInvocationContent({
31 | children,
32 | className,
33 | }: { children: React.ReactNode; className?: string }) {
34 | return {children}
;
35 | }
36 |
37 | export function ToolInvocationName({
38 | name,
39 | capitalize = true,
40 | type,
41 | className,
42 | }: {
43 | name: string;
44 | capitalize?: boolean;
45 | type: "tool-call" | "tool-result";
46 | className?: string;
47 | }) {
48 | return (
49 |
50 | {type === "tool-call" && (
51 |
55 | )}
56 | {type === "tool-result" && (
57 |
58 | )}
59 |
60 | {idToReadableText(name, { capitalize })}
61 |
62 |
63 | );
64 | }
65 |
66 | // biome-ignore lint/suspicious/noExplicitAny:
67 | export function ToolInvocationResult({ result }: { result: any }) {
68 | return (
69 |
70 |
Result:
71 |
72 | {JSON.stringify(result, null, 2)}
73 |
74 |
75 | );
76 | }
77 |
78 | // biome-ignore lint/suspicious/noExplicitAny:
79 | export function ToolInvocationArgs({ args }: { args: any }) {
80 | return (
81 |
82 |
83 | {JSON.stringify(args, null, 2)}
84 |
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/client/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
4 | import type * as React from "react";
5 |
6 | import { cn } from "@client/lib/utils";
7 |
8 | function TooltipProvider({
9 | delayDuration = 0,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
18 | );
19 | }
20 |
21 | function Tooltip({
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
26 |
27 |
28 | );
29 | }
30 |
31 | function TooltipTrigger({
32 | ...props
33 | }: React.ComponentProps) {
34 | return ;
35 | }
36 |
37 | function TooltipContent({
38 | className,
39 | sideOffset = 0,
40 | children,
41 | ...props
42 | }: React.ComponentProps) {
43 | return (
44 |
45 |
54 | {children}
55 |
56 |
57 |
58 | );
59 | }
60 |
61 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
62 |
--------------------------------------------------------------------------------
/src/client/hooks/organization/use-organization-connection.ts:
--------------------------------------------------------------------------------
1 | import { useWebSocket } from "../use-web-socket";
2 | import { useOrganizationState } from "./use-organization-state";
3 |
4 | import type { User } from "better-auth";
5 | import { useMainChatRoomState } from "./use-main-chat-room-state";
6 | import { useThreadChatRoomState } from "./use-thread-chat-room-state";
7 |
8 | export interface UseOrganizationConnectionProps {
9 | organizationId: string;
10 | roomId: string | null;
11 | threadId: number | null;
12 | user: User;
13 | }
14 |
15 | export function useOrganizationConnection({
16 | organizationId,
17 | roomId,
18 | threadId,
19 | user,
20 | }: UseOrganizationConnectionProps) {
21 | const { sendMessage, connectionStatus } = useWebSocket({
22 | organizationId,
23 | onMessage: (message) => {
24 | organizationState.handleMessage(message);
25 | mainChatRoomState.handleMessage(message);
26 | chatRoomThreadState.handleMessage(message);
27 | },
28 | });
29 |
30 | const organizationState = useOrganizationState({
31 | sendMessage,
32 | connectionStatus,
33 | });
34 |
35 | const mainChatRoomState = useMainChatRoomState({
36 | roomId,
37 | user,
38 | sendMessage,
39 | connectionStatus,
40 | });
41 |
42 | const chatRoomThreadState = useThreadChatRoomState({
43 | roomId,
44 | threadId,
45 | user,
46 | topLevelMessages: mainChatRoomState.messages,
47 | sendMessage,
48 | connectionStatus,
49 | });
50 |
51 | return {
52 | connectionStatus,
53 | organizationState,
54 | mainChatRoomState,
55 | chatRoomThreadState,
56 | };
57 | }
58 |
--------------------------------------------------------------------------------
/src/client/hooks/organization/use-organization-state.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | ChatRoom,
3 | WsChatIncomingMessage,
4 | WsChatOutgoingMessage,
5 | } from "@shared/types";
6 | import { useCallback, useEffect, useState } from "react";
7 | import type { UseWebSocketConnectionStatus } from "../use-web-socket";
8 |
9 | export function useOrganizationState({
10 | sendMessage,
11 | connectionStatus,
12 | }: {
13 | sendMessage: (message: WsChatIncomingMessage) => void;
14 | connectionStatus: UseWebSocketConnectionStatus;
15 | }) {
16 | const [chatRooms, setChatRooms] = useState([]);
17 | const [status, setStatus] = useState<"loading" | "success" | "error">(
18 | "loading",
19 | );
20 |
21 | useEffect(() => {
22 | if (connectionStatus === "connected") {
23 | sendMessage({ type: "organization-init-request" });
24 | }
25 | }, [connectionStatus, sendMessage]);
26 |
27 | const handleMessage = useCallback((wsMessage: WsChatOutgoingMessage) => {
28 | switch (wsMessage.type) {
29 | case "organization-init-response":
30 | setChatRooms(wsMessage.chatRooms);
31 | setStatus("success");
32 | break;
33 | case "chat-rooms-update":
34 | setChatRooms(wsMessage.chatRooms);
35 | setStatus("success");
36 | break;
37 | }
38 | }, []);
39 |
40 | return {
41 | chatRooms,
42 | status,
43 | handleMessage,
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/src/client/hooks/use-mobile.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | const MOBILE_BREAKPOINT = 768;
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(
7 | undefined,
8 | );
9 |
10 | React.useEffect(() => {
11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
12 | const onChange = () => {
13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
14 | };
15 | mql.addEventListener("change", onChange);
16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
17 | return () => mql.removeEventListener("change", onChange);
18 | }, []);
19 |
20 | return !!isMobile;
21 | }
22 |
--------------------------------------------------------------------------------
/src/client/hooks/use-textarea-resize.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useLayoutEffect, useRef } from "react";
4 | import type { ComponentProps } from "react";
5 |
6 | export function useTextareaResize(
7 | value: ComponentProps<"textarea">["value"],
8 | rows = 1,
9 | ) {
10 | const textareaRef = useRef(null);
11 |
12 | // biome-ignore lint/correctness/useExhaustiveDependencies:
13 | useLayoutEffect(() => {
14 | const textArea = textareaRef.current;
15 |
16 | if (textArea) {
17 | // Get the line height to calculate minimum height based on rows
18 | const computedStyle = window.getComputedStyle(textArea);
19 | const lineHeight = Number.parseInt(computedStyle.lineHeight, 10) || 20;
20 | const padding =
21 | Number.parseInt(computedStyle.paddingTop, 10) +
22 | Number.parseInt(computedStyle.paddingBottom, 10);
23 |
24 | // Calculate minimum height based on rows
25 | const minHeight = lineHeight * rows + padding;
26 |
27 | // Reset height to auto first to get the correct scrollHeight
28 | textArea.style.height = "0px";
29 | const scrollHeight = Math.max(textArea.scrollHeight, minHeight);
30 |
31 | // Set the final height
32 | textArea.style.height = `${scrollHeight + 2}px`;
33 | }
34 | }, [textareaRef, value, rows]);
35 |
36 | return textareaRef;
37 | }
38 |
--------------------------------------------------------------------------------
/src/client/lib/api-client.ts:
--------------------------------------------------------------------------------
1 | import type { AppType } from "@server/routes";
2 | import { hc } from "hono/client";
3 |
4 | const API_HOST = import.meta.env.VITE_APP_URL || "http://localhost:5173";
5 |
6 | export const honoClient = hc(API_HOST);
7 |
--------------------------------------------------------------------------------
/src/client/lib/auth-client.ts:
--------------------------------------------------------------------------------
1 | import { organizationPermissions } from "@server/auth/organization-permissions";
2 | import { organizationClient } from "better-auth/client/plugins";
3 | import { createAuthClient } from "better-auth/react";
4 |
5 | const API_HOST = import.meta.env.VITE_APP_URL || "http://localhost:5173";
6 |
7 | export const authClient = createAuthClient({
8 | baseURL: API_HOST,
9 | plugins: [
10 | organizationClient({
11 | ac: organizationPermissions.accessControl,
12 | roles: {
13 | member: organizationPermissions.member,
14 | admin: organizationPermissions.admin,
15 | owner: organizationPermissions.owner,
16 | },
17 | }),
18 | ],
19 | });
20 |
--------------------------------------------------------------------------------
/src/client/lib/chat.ts:
--------------------------------------------------------------------------------
1 | import type { ChatRoomMessage } from "@shared/types";
2 |
3 | export function updateMessageList({
4 | messages,
5 | newMessage,
6 | addAsNew,
7 | }: {
8 | messages: ChatRoomMessage[];
9 | newMessage: ChatRoomMessage;
10 | addAsNew?: boolean;
11 | }): ChatRoomMessage[] {
12 | if (addAsNew) {
13 | return [...messages, newMessage];
14 | }
15 |
16 | const optimisticId = newMessage.metadata.optimisticData?.id;
17 | if (optimisticId) {
18 | const existingOptimisticIndex = messages.findIndex(
19 | (message) => message.id === optimisticId,
20 | );
21 | if (existingOptimisticIndex >= 0) {
22 | const updatedMessages = [...messages];
23 | updatedMessages[existingOptimisticIndex] = newMessage;
24 | return updatedMessages;
25 | }
26 | }
27 |
28 | const existingMessageIndex = messages.findIndex(
29 | (message) => message.id === newMessage.id,
30 | );
31 | if (existingMessageIndex >= 0) {
32 | const updatedMessages = [...messages];
33 | updatedMessages[existingMessageIndex] = newMessage;
34 | return updatedMessages;
35 | }
36 |
37 | return [...messages, newMessage];
38 | }
39 |
--------------------------------------------------------------------------------
/src/client/lib/date.tsx:
--------------------------------------------------------------------------------
1 | export function dateToPrettyTimeAgo(date: Date) {
2 | const now = new Date();
3 | const diff = now.getTime() - date.getTime();
4 | const diffMinutes = Math.floor(diff / (1000 * 60));
5 |
6 | if (diffMinutes < 60) {
7 | return `${diffMinutes} minutes ago`;
8 | }
9 |
10 | const diffHours = Math.floor(diff / (1000 * 60 * 60));
11 |
12 | if (diffHours < 24) {
13 | return `${diffHours} hours ago`;
14 | }
15 |
16 | const diffDays = Math.floor(diff / (1000 * 60 * 60 * 24));
17 |
18 | if (diffDays < 30) {
19 | return `${diffDays} days ago`;
20 | }
21 |
22 | const diffMonths = Math.floor(diff / (1000 * 60 * 60 * 24 * 30));
23 |
24 | if (diffMonths < 12) {
25 | return `${diffMonths} months ago`;
26 | }
27 |
28 | const diffYears = Math.floor(diff / (1000 * 60 * 60 * 24 * 30 * 12));
29 |
30 | return `${diffYears} years ago`;
31 | }
32 |
--------------------------------------------------------------------------------
/src/client/lib/id-parsing.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Convert various ID formats to human-readable text with only the first letter capitalized
3 | * Handles: kebab-case, snake_case, PascalCase, camelCase, and all caps
4 | * @param idToConvert
5 | * @returns
6 | */
7 | export const idToReadableText = (
8 | idToConvert: string,
9 | options: { capitalize?: boolean } = {},
10 | ) => {
11 | // Split into words based on various separators and casing
12 | const words = idToConvert
13 | .replace(/[_-]/g, " ") // Replace underscores and hyphens with spaces
14 | .split(/(?=[A-Z][a-z])/) // Split before capital letters followed by lowercase (for camel/pascal case)
15 | .join(" ")
16 | .toLowerCase()
17 | .split(/\s+/)
18 | .filter((word) => word.length > 0);
19 |
20 | // Join words with spaces and capitalize only the first letter of the entire string
21 | return words
22 | .join(" ")
23 | .replace(/^./, (firstChar) =>
24 | options.capitalize ? firstChar.toUpperCase() : firstChar,
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/src/client/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/client/main.tsx:
--------------------------------------------------------------------------------
1 | import "./index.css";
2 | import { RouterProvider, createRouter } from "@tanstack/react-router";
3 | import { StrictMode } from "react";
4 | import ReactDOM from "react-dom/client";
5 | import { routeTree } from "./routeTree.gen";
6 |
7 | const router = createRouter({ routeTree });
8 |
9 | declare module "@tanstack/react-router" {
10 | interface Register {
11 | router: typeof router;
12 | }
13 | }
14 |
15 | // biome-ignore lint/style/noNonNullAssertion:
16 | const rootElement = document.getElementById("root")!;
17 | if (!rootElement.innerHTML) {
18 | const root = ReactDOM.createRoot(rootElement);
19 | root.render(
20 |
21 |
22 | ,
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/client/routes/(app)/agents.tsx:
--------------------------------------------------------------------------------
1 | import { AgentEdit } from "@client/components/agents/agent-edit";
2 | import { AgentNotSelected } from "@client/components/agents/agent-placeholder";
3 | import { AgentsSidebar } from "@client/components/agents/agents-sidebar";
4 | import { AppHeader, AppHeaderIcon } from "@client/components/layout/app-header";
5 | import { AppLayout } from "@client/components/layout/app-layout";
6 | import { createFileRoute } from "@tanstack/react-router";
7 | import { Bot } from "lucide-react";
8 | import { z } from "zod";
9 |
10 | const chatParamsSchema = z.object({
11 | agentId: z.string().optional(),
12 | });
13 |
14 | export const Route = createFileRoute("/(app)/agents")({
15 | validateSearch: (search) => chatParamsSchema.parse(search),
16 | component: Agents,
17 | });
18 |
19 | function Agents() {
20 | return (
21 | }>
22 |
23 |
24 | );
25 | }
26 |
27 | function AgentsContent() {
28 | const { agentId } = Route.useSearch();
29 |
30 | if (!agentId) {
31 | return (
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | return ;
44 | }
45 |
--------------------------------------------------------------------------------
/src/client/routes/(app)/chat.tsx:
--------------------------------------------------------------------------------
1 | import { ChatRoom } from "@client/components/chat-room/chat-room";
2 | import { ChatRoomList } from "@client/components/chat-room/list/chat-room-list";
3 | import { AppLayout } from "@client/components/layout/app-layout";
4 | import { createFileRoute } from "@tanstack/react-router";
5 | import { z } from "zod";
6 |
7 | const chatParamsSchema = z.object({
8 | roomId: z.string().optional(),
9 | threadId: z.number().optional(),
10 | });
11 |
12 | export const Route = createFileRoute("/(app)/chat")({
13 | validateSearch: (search) => chatParamsSchema.parse(search),
14 | component: Chat,
15 | });
16 |
17 | function Chat() {
18 | return (
19 | }>
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/client/routes/(app)/route.tsx:
--------------------------------------------------------------------------------
1 | import { AppLayoutSkeleton } from "@client/components/layout/app-layout-skeleton";
2 | import { AuthProvider } from "@client/components/providers/auth-provider";
3 | import { OrganizationConnectionProvider } from "@client/components/providers/organization-connection-provider";
4 | import { SidebarProvider } from "@client/components/ui/sidebar";
5 | import { authClient } from "@client/lib/auth-client";
6 | import {
7 | Navigate,
8 | Outlet,
9 | createFileRoute,
10 | useSearch,
11 | } from "@tanstack/react-router";
12 |
13 | export const Route = createFileRoute("/(app)")({
14 | component: Root,
15 | });
16 |
17 | function Root() {
18 | const { data, isPending } = authClient.useSession();
19 |
20 | const searchParams = useSearch({ strict: false });
21 |
22 | if (isPending) {
23 | return ;
24 | }
25 |
26 | if (!data || !data.session) {
27 | return ;
28 | }
29 |
30 | if (!data.session.activeOrganizationId) {
31 | console.log("!data.session.activeOrganizationId");
32 | // TODO: Redirect to the organization selection page
33 | return ;
34 | }
35 |
36 | // TODO: Add organization routes to select an organization
37 |
38 | return (
39 |
40 |
46 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/client/routes/__root.tsx:
--------------------------------------------------------------------------------
1 | import { QueryProvider } from "@client/components/providers/query-provider";
2 | import { ThemeProvider } from "@client/components/providers/theme-provider";
3 | import { Toaster } from "@client/components/ui/sonner";
4 | import { Outlet, createRootRoute } from "@tanstack/react-router";
5 |
6 | export const Route = createRootRoute({
7 | component: Root,
8 | });
9 |
10 | function Root() {
11 | return (
12 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | // TODO: Check development branch with multi project and also dev branch with o per chatroom for todos and code to port
27 |
--------------------------------------------------------------------------------
/src/client/types/auth.ts:
--------------------------------------------------------------------------------
1 | import type { authClient } from "@client/lib/auth-client";
2 |
3 | export type ActiveOrganization = typeof authClient.$Infer.ActiveOrganization;
4 | export type Invitation = typeof authClient.$Infer.Invitation;
5 | export type Session = typeof authClient.$Infer.Session;
6 |
--------------------------------------------------------------------------------
/src/client/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/server/ai/prompts/agent/default-prompt.ts:
--------------------------------------------------------------------------------
1 | import type { Agent } from "@shared/types";
2 | import {
3 | getAssistantPersonaPrompt,
4 | getChatRoomContextPrompt,
5 | getCoreAssistantInstructionsPrompt,
6 | getResponseFormattingRulesPrompt,
7 | getStandardToolUsageRulesPrompt,
8 | } from "./prompt-parts";
9 |
10 | export function getDefaultAgentSystemPrompt({
11 | agentConfig,
12 | chatRoomId,
13 | threadId,
14 | }: {
15 | agentConfig: Agent;
16 | chatRoomId: string;
17 | threadId: number | null;
18 | }): string {
19 | const coreContext = getCoreAssistantInstructionsPrompt();
20 | const persona = getAssistantPersonaPrompt(agentConfig);
21 | const responseRules = getResponseFormattingRulesPrompt();
22 | const toolRules = getStandardToolUsageRulesPrompt();
23 | const chatRoomContext = getChatRoomContextPrompt(chatRoomId, threadId);
24 |
25 | return `
26 | ${coreContext}
27 |
28 | ${persona}
29 |
30 | ${responseRules}
31 |
32 | ${toolRules}
33 |
34 | ${chatRoomContext}
35 | `.trim();
36 | }
37 |
--------------------------------------------------------------------------------
/src/server/ai/prompts/agent/workflow-prompt.ts:
--------------------------------------------------------------------------------
1 | import type { Agent } from "@shared/types";
2 | import type { WorkflowPartial } from "@shared/types/workflow";
3 | import {
4 | getAssistantPersonaPrompt,
5 | getChatRoomContextPrompt,
6 | getCoreAssistantInstructionsPrompt,
7 | getResponseFormattingRulesPrompt,
8 | getWorkflowExecutionRulesPrompt,
9 | } from "./prompt-parts";
10 |
11 | export function getWorkflowAgentSystemPrompt({
12 | agentConfig,
13 | chatRoomId,
14 | }: {
15 | agentConfig: Agent;
16 | chatRoomId: string;
17 | }): string {
18 | const coreContext = getCoreAssistantInstructionsPrompt();
19 | const persona = getAssistantPersonaPrompt(agentConfig);
20 | const responseRules = getResponseFormattingRulesPrompt();
21 | const workflowRules = getWorkflowExecutionRulesPrompt();
22 | const chatRoomContext = getChatRoomContextPrompt(chatRoomId, null);
23 |
24 | return `
25 | ${coreContext}
26 |
27 | ${persona}
28 |
29 | ${responseRules}
30 |
31 | ${workflowRules}
32 |
33 | ${chatRoomContext}
34 |
35 | `.trim();
36 | }
37 |
38 | export function getWorkflowAgentUserPrompt({
39 | workflow,
40 | }: {
41 | workflow: WorkflowPartial;
42 | }) {
43 | return `
44 | ## Scheduled Workflow
45 |
46 | ### Workflow ID
47 | ${workflow.id}
48 |
49 | ### Overall Goal
50 | ${workflow.goal}
51 |
52 | ### Steps
53 | ${JSON.stringify(workflow.steps.data, null, 2)}
54 | `.trim();
55 | }
56 |
--------------------------------------------------------------------------------
/src/server/ai/prompts/router-prompt.ts:
--------------------------------------------------------------------------------
1 | import type { Agent, ChatRoom } from "@shared/types";
2 |
3 | interface RouterPromptArgs {
4 | agents: Agent[];
5 | room: Pick;
6 | }
7 |
8 | export function routeMessageToAgentSystemPrompt({
9 | agents,
10 | room,
11 | }: RouterPromptArgs): string {
12 | const agentList = agents
13 | .map(
14 | (a) =>
15 | `- Agent ID: ${a.id}, Name: ${a.name}${a.description ? `, Description: ${a.description}` : ""}`,
16 | )
17 | .join("\n");
18 |
19 | return `You are an AI routing assistant for a chat application.
20 | Your goal is to determine which agent(s), if any, should respond to the new messages in a conversation.
21 |
22 | You are in chat room "${room.name}" (ID: ${room.id}, Type: ${room.type}).
23 |
24 | The available agents in this room are:
25 | ${agentList}
26 |
27 |
28 | Evaluate the provided messages (including context and the new messages) and decide which agent ID(s) from the list above are the most relevant to respond.
29 | - Consider the content of the messages, mentions, and the likely expertise or role implied by the agent's name or description (if provided).
30 | - Messages contain metadata about the member who sent the message like member-id, member-name, member-type, and is-new-message.
31 | - The new messages are marked as is-new-message=true.
32 | - If a message explicitly mentions an agent, that agent should likely be chosen.
33 | - If multiple agents seem relevant, you can include multiple IDs.
34 | - If NO agent seems relevant or necessary to respond, return an empty list.
35 | - Only return IDs from the provided list of available agents.
36 | `;
37 | }
38 |
--------------------------------------------------------------------------------
/src/server/ai/tools/create-thread-tool.ts:
--------------------------------------------------------------------------------
1 | import type { ChatRoomMessage, ChatRoomMessagePartial } from "@shared/types";
2 | import { tool } from "ai";
3 | import { customAlphabet } from "nanoid";
4 | import { z } from "zod";
5 |
6 | export const createMessageThreadTool = ({
7 | onMessage,
8 | onNewThread,
9 | roomId,
10 | }: {
11 | onMessage: ({
12 | newMessagePartial,
13 | }: {
14 | newMessagePartial: ChatRoomMessagePartial;
15 | }) => Promise;
16 | onNewThread: (newThreadId: number) => void;
17 | roomId: string;
18 | }) =>
19 | tool({
20 | description:
21 | "Use this tool to create a new message thread if we are not already responding in a thread (threadId is null)",
22 | parameters: z.object({
23 | message: z.string().describe("The message to include in the thread"),
24 | }),
25 | execute: async ({ message }) => {
26 | const { id: newThreadId } = await onMessage({
27 | newMessagePartial: {
28 | id: Number(customAlphabet("0123456789", 20)()),
29 | content: message,
30 | mentions: [],
31 | toolUses: [],
32 | createdAt: Date.now(),
33 | threadId: null,
34 | roomId,
35 | },
36 | });
37 | onNewThread(newThreadId);
38 | return {
39 | success: true,
40 | newThreadId,
41 | };
42 | },
43 | });
44 |
--------------------------------------------------------------------------------
/src/server/ai/tools/deep-search-tool.ts:
--------------------------------------------------------------------------------
1 | import { env } from "cloudflare:workers";
2 | import type { AgentToolAnnotation, ToolSource } from "@shared/types";
3 | import { type DataStreamWriter, tool } from "ai";
4 | import FirecrawlApp from "firecrawl";
5 | import { nanoid } from "nanoid";
6 | import { z } from "zod";
7 |
8 | export const deepResearchTool = (dataStream: DataStreamWriter) =>
9 | tool({
10 | description:
11 | "Deeply research a topic when user asks for detailed information, opinions, comprehensive analysis, or when webSearchTool is insufficient. Use specific queries.",
12 | parameters: z.object({
13 | query: z.string().describe("The specific query for deep research."),
14 | }),
15 | execute: async ({ query }, { toolCallId }) => {
16 | try {
17 | console.log("[deepResearchTool] Starting deep research for:", query);
18 |
19 | const firecrawl = new FirecrawlApp({ apiKey: env.FIRECRAWL_API_KEY });
20 |
21 | const result = await firecrawl.deepResearch(
22 | query,
23 | {
24 | maxDepth: 1, // Number of research iterations
25 | timeLimit: 40, // Time limit in seconds
26 | maxUrls: 2, // Maximum URLs to analyze
27 | },
28 | ({ type, message, depth, status, timestamp }) => {
29 | const annotation = {
30 | toolCallId,
31 | id: nanoid(),
32 | type: type,
33 | message: message,
34 | status: status as "processing" | "complete" | "failed",
35 | data: {
36 | depth,
37 | status,
38 | timestamp,
39 | },
40 | timestamp: Date.now(),
41 | } satisfies AgentToolAnnotation;
42 | dataStream.writeMessageAnnotation(annotation);
43 | },
44 | );
45 |
46 | if (result.success) {
47 | const { finalAnalysis, sources } = result.data;
48 | const processedSources: ToolSource[] = sources.map(
49 | (s: {
50 | url: string;
51 | title: string;
52 | description: string;
53 | icon?: string;
54 | }) => ({
55 | type: "url",
56 | url: s.url,
57 | title: s.title,
58 | content: s.description,
59 | icon: s.icon && s.icon.trim().length > 0 ? s.icon : undefined,
60 | }),
61 | );
62 | console.log("[deepResearchTool] Finished deep research.");
63 | return {
64 | sources: processedSources,
65 | finalAnalysis,
66 | };
67 | }
68 |
69 | console.error("[deepResearchTool] Failed to perform deep research.");
70 |
71 | return { sources: [], finalAnalysis: "" };
72 | } catch (error) {
73 | const errorMessage =
74 | error instanceof Error ? error.message : String(error);
75 | const errorAnnotation = {
76 | toolCallId,
77 | id: nanoid(),
78 | type: "deep_research_error",
79 | message: `Deep research failed: ${errorMessage}`,
80 | status: "failed",
81 | timestamp: Date.now(),
82 | } satisfies AgentToolAnnotation;
83 | dataStream.writeMessageAnnotation(errorAnnotation);
84 | return { sources: [], finalAnalysis: "", error: errorMessage };
85 | }
86 | },
87 | });
88 |
--------------------------------------------------------------------------------
/src/server/ai/tools/index.ts:
--------------------------------------------------------------------------------
1 | import type { createMessageThreadTool } from "./create-thread-tool";
2 | import type { deepResearchTool } from "./deep-search-tool";
3 | import type { scheduleWorkflowTool } from "./schedule-workflow-tool";
4 | import type { webCrawlerTool } from "./web-crawler-tool";
5 | import type { webSearchTool } from "./web-search-tool";
6 |
7 | export type AgentToolSet = {
8 | webSearch: ReturnType;
9 | deepResearch: ReturnType;
10 | webCrawl: ReturnType;
11 | createMessageThread: ReturnType;
12 | scheduleWorkflow: ReturnType;
13 | };
14 |
15 | export const agentToolSetKeys: (keyof AgentToolSet)[] = [
16 | "webSearch",
17 | "deepResearch",
18 | "webCrawl",
19 | "createMessageThread",
20 | "scheduleWorkflow",
21 | ];
22 |
--------------------------------------------------------------------------------
/src/server/ai/tools/schedule-workflow-tool.ts:
--------------------------------------------------------------------------------
1 | import type { ChatRoomDbServices } from "@server/organization-do/db/services";
2 | import { type WorkflowPartial, workflowStepSchema } from "@shared/types";
3 | import { tool } from "ai";
4 | import { CronExpressionParser } from "cron-parser";
5 | import { z } from "zod";
6 |
7 | export const scheduleWorkflowTool = ({
8 | createWorkflow,
9 | chatRoomId,
10 | agentId,
11 | }: {
12 | createWorkflow: (
13 | params: Parameters[0],
14 | ) => Promise;
15 | chatRoomId: string;
16 | agentId: string;
17 | }) =>
18 | tool({
19 | description:
20 | "Schedules a multi-step workflow to be executed at a specified time or recurring interval.",
21 | parameters: z.object({
22 | scheduleExpression: z
23 | .string()
24 | .describe(
25 | "The schedule (e.g., Date string ending with 'Z' like '2025-04-06T12:00:00Z' for one-off, CRON string like '0 9 * * 1' for recurring).",
26 | ),
27 | goal: z
28 | .string()
29 | .describe(
30 | "The goal of the workflow, In this goal DO NOT include information about schedule like 'every Monday at 9am'.",
31 | ),
32 | steps: z
33 | .array(workflowStepSchema)
34 | .describe("The JSON object defining the steps of the workflow."),
35 | }),
36 | execute: async ({ scheduleExpression, goal, steps }) => {
37 | try {
38 | let nextExecutionTime: number;
39 | let isRecurring: boolean;
40 |
41 | try {
42 | const interval = CronExpressionParser.parse(scheduleExpression, {
43 | tz: "UTC",
44 | });
45 |
46 | nextExecutionTime = interval.next().getTime();
47 | isRecurring = true;
48 | } catch (_error) {
49 | const date = new Date(scheduleExpression);
50 | if (!Number.isNaN(date.getTime())) {
51 | nextExecutionTime = date.getTime();
52 | isRecurring = false;
53 | if (nextExecutionTime <= Date.now()) {
54 | throw new Error("Scheduled time must be in the future.");
55 | }
56 | } else {
57 | throw new Error(
58 | `Invalid scheduleExpression: ${scheduleExpression}`,
59 | );
60 | }
61 | }
62 |
63 | const workflow = await createWorkflow({
64 | agentId,
65 | goal,
66 | steps: {
67 | version: 1,
68 | type: "workflowSteps",
69 | data: steps,
70 | },
71 | scheduleExpression,
72 | isRecurring,
73 | nextExecutionTime,
74 | chatRoomId,
75 | });
76 |
77 | return {
78 | success: true,
79 | workflowId: workflow.id,
80 | nextRun: new Date(nextExecutionTime).toISOString(),
81 | };
82 | } catch (error) {
83 | console.error("Error scheduling workflow:", error);
84 | return {
85 | success: false,
86 | error: error instanceof Error ? error.message : String(error),
87 | };
88 | }
89 | },
90 | });
91 |
--------------------------------------------------------------------------------
/src/server/ai/utils/message.ts:
--------------------------------------------------------------------------------
1 | import type { ChatRoomMessage } from "@shared/types";
2 | import type { Message } from "ai";
3 |
4 | function chatRoomMessageToAIMessage({
5 | message,
6 | isNew,
7 | agentIdForAssistant,
8 | }: {
9 | message: ChatRoomMessage;
10 | isNew: boolean;
11 | agentIdForAssistant?: string;
12 | }): Message {
13 | const role = agentIdForAssistant
14 | ? message.member.id === agentIdForAssistant
15 | ? "assistant"
16 | : "user"
17 | : message.member.type === "user"
18 | ? "user"
19 | : "assistant";
20 |
21 | const messageMetadata = ``;
22 |
23 | const content =
24 | message.content.trim().length > 0
25 | ? `${messageMetadata}\n\n${message.content}`
26 | : messageMetadata;
27 |
28 | const partsWithContent =
29 | message.content.trim().length > 0
30 | ? [
31 | {
32 | type: "text" as const,
33 | text: content,
34 | },
35 | ]
36 | : [];
37 |
38 | const toolInvocations = message.toolUses.map((toolUse) => {
39 | if (toolUse.type === "tool-result") {
40 | return {
41 | type: "tool-invocation" as const,
42 | toolInvocation: {
43 | state: "result" as const,
44 | toolCallId: toolUse.toolCallId,
45 | toolName: toolUse.toolName,
46 | args: toolUse.args,
47 | result: toolUse.result,
48 | },
49 | };
50 | }
51 | return {
52 | type: "tool-invocation" as const,
53 | toolInvocation: {
54 | state: "call" as const,
55 | toolCallId: toolUse.toolCallId,
56 | toolName: toolUse.toolName,
57 | args: toolUse.args,
58 | },
59 | };
60 | });
61 |
62 | const parts = [...partsWithContent, ...toolInvocations];
63 |
64 | return {
65 | id: message.id.toString(),
66 | role: role,
67 | content,
68 | parts,
69 | createdAt: new Date(message.createdAt),
70 | };
71 | }
72 |
73 | export function contextAndNewchatRoomMessagesToAIMessages({
74 | contextMessages,
75 | newMessages,
76 | agentIdForAssistant,
77 | }: {
78 | contextMessages: ChatRoomMessage[];
79 | newMessages: ChatRoomMessage[];
80 | agentIdForAssistant?: string;
81 | }): Message[] {
82 | const contextAIMessages: Message[] = contextMessages.map((msg) =>
83 | chatRoomMessageToAIMessage({
84 | message: msg,
85 | isNew: false,
86 | agentIdForAssistant,
87 | }),
88 | );
89 | const newAIMessages: Message[] = newMessages.map((msg) =>
90 | chatRoomMessageToAIMessage({
91 | message: msg,
92 | isNew: true,
93 | agentIdForAssistant,
94 | }),
95 | );
96 |
97 | return [...contextAIMessages, ...newAIMessages];
98 | }
99 |
--------------------------------------------------------------------------------
/src/server/auth/index.ts:
--------------------------------------------------------------------------------
1 | import { env } from "cloudflare:workers";
2 | import { organizationPermissions } from "@server/auth/organization-permissions";
3 | import { db } from "@server/db";
4 | import * as globalSchema from "@server/db/schema";
5 | import { sendMail } from "@server/email";
6 | import { betterAuth } from "better-auth";
7 | import { drizzleAdapter } from "better-auth/adapters/drizzle";
8 | import { organization } from "better-auth/plugins";
9 | import { eq } from "drizzle-orm";
10 |
11 | export const auth = betterAuth({
12 | appName: "Chatsemble",
13 | baseURL: env.APP_URL,
14 | secret: env.BETTER_AUTH_SECRET,
15 | trustedOrigins: [env.APP_URL],
16 | database: drizzleAdapter(db, {
17 | provider: "sqlite",
18 | }),
19 | emailVerification: {
20 | sendOnSignUp: true,
21 | autoSignInAfterVerification: true,
22 | sendVerificationEmail: async ({ user, url }) => {
23 | await sendMail(user.email, "email-verification", {
24 | verificationUrl: url,
25 | username: user.email,
26 | });
27 | },
28 | },
29 | emailAndPassword: {
30 | enabled: true,
31 | requireEmailVerification: true,
32 | sendResetPassword: async ({ user, url }) => {
33 | await sendMail(user.email, "password-reset", {
34 | resetLink: url,
35 | username: user.email,
36 | });
37 | },
38 | },
39 | plugins: [
40 | organization({
41 | ac: organizationPermissions.accessControl,
42 | roles: {
43 | member: organizationPermissions.member,
44 | admin: organizationPermissions.admin,
45 | owner: organizationPermissions.owner,
46 | },
47 | schema: {
48 | member: {
49 | modelName: "organizationMember",
50 | },
51 | invitation: {
52 | modelName: "organizationInvitation",
53 | },
54 | },
55 | sendInvitationEmail: async (data) => {
56 | const url = `${env.APP_URL}/auth/accept-invitation/${data.id}`;
57 | await sendMail(data.email, "organization-invitation", {
58 | inviteLink: url,
59 | username: data.email,
60 | invitedByUsername: data.inviter.user.name,
61 | invitedByEmail: data.inviter.user.email,
62 | teamName: data.organization.name,
63 | });
64 | },
65 | }),
66 | ],
67 | databaseHooks: {
68 | session: {
69 | create: {
70 | before: async (session) => {
71 | const orgSession = await db.query.organizationMember.findFirst({
72 | where: eq(globalSchema.organizationMember.userId, session.userId),
73 | });
74 |
75 | return {
76 | data: {
77 | ...session,
78 | activeOrganizationId: orgSession?.organizationId ?? null,
79 | },
80 | };
81 | },
82 | },
83 | },
84 | },
85 | });
86 |
--------------------------------------------------------------------------------
/src/server/auth/organization-permissions.ts:
--------------------------------------------------------------------------------
1 | import { createAccessControl } from "better-auth/plugins/access";
2 | import {
3 | adminAc,
4 | defaultStatements,
5 | memberAc,
6 | ownerAc,
7 | } from "better-auth/plugins/organization/access";
8 |
9 | export const chatRoomPermissionTypes = ["create", "update", "delete"] as const;
10 | export const chatRoomMemberPermissionTypes = ["create", "delete"] as const;
11 |
12 | const statement = {
13 | ...defaultStatements,
14 | chatRoom: chatRoomPermissionTypes,
15 | chatRoomMember: chatRoomMemberPermissionTypes,
16 | } as const;
17 |
18 | const accessControl = createAccessControl(statement);
19 |
20 | const member = accessControl.newRole({
21 | ...memberAc.statements,
22 | chatRoom: ["create"],
23 | chatRoomMember: [],
24 | });
25 |
26 | const admin = accessControl.newRole({
27 | ...adminAc.statements,
28 | chatRoom: ["create", "update", "delete"],
29 | chatRoomMember: ["create", "delete"],
30 | });
31 |
32 | const owner = accessControl.newRole({
33 | ...ownerAc.statements,
34 | chatRoom: ["create", "update", "delete"],
35 | chatRoomMember: ["create", "delete"],
36 | });
37 |
38 | const organizationPermissions = {
39 | member,
40 | admin,
41 | owner,
42 | accessControl,
43 | };
44 |
45 | export { organizationPermissions };
46 |
--------------------------------------------------------------------------------
/src/server/db/index.ts:
--------------------------------------------------------------------------------
1 | import { env } from "cloudflare:workers";
2 | import * as schema from "@server/db/schema";
3 | import { drizzle } from "drizzle-orm/d1";
4 |
5 | export const db = drizzle(env.DB, { schema: schema });
6 |
--------------------------------------------------------------------------------
/src/server/db/migrations/0000_petite_norrin_radd.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE `account` (
2 | `id` text PRIMARY KEY NOT NULL,
3 | `user_id` text NOT NULL,
4 | `account_id` text NOT NULL,
5 | `provider_id` text NOT NULL,
6 | `access_token` text,
7 | `refresh_token` text,
8 | `access_token_expires_at` integer,
9 | `refresh_token_expires_at` integer,
10 | `scope` text,
11 | `id_token` text,
12 | `password` text,
13 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
14 | `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
15 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
16 | );
17 | --> statement-breakpoint
18 | CREATE TABLE `organization` (
19 | `id` text PRIMARY KEY NOT NULL,
20 | `name` text NOT NULL,
21 | `slug` text NOT NULL,
22 | `logo` text,
23 | `metadata` text,
24 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
25 | );
26 | --> statement-breakpoint
27 | CREATE UNIQUE INDEX `organization_slug_unique` ON `organization` (`slug`);--> statement-breakpoint
28 | CREATE TABLE `organization_invitation` (
29 | `id` text PRIMARY KEY NOT NULL,
30 | `email` text NOT NULL,
31 | `inviter_id` text NOT NULL,
32 | `organization_id` text NOT NULL,
33 | `role` text NOT NULL,
34 | `status` text NOT NULL,
35 | `expires_at` integer NOT NULL,
36 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
37 | FOREIGN KEY (`inviter_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
38 | FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE no action
39 | );
40 | --> statement-breakpoint
41 | CREATE TABLE `organization_member` (
42 | `id` text PRIMARY KEY NOT NULL,
43 | `user_id` text NOT NULL,
44 | `organization_id` text NOT NULL,
45 | `role` text NOT NULL,
46 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
47 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
48 | FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE no action
49 | );
50 | --> statement-breakpoint
51 | CREATE TABLE `session` (
52 | `id` text PRIMARY KEY NOT NULL,
53 | `user_id` text NOT NULL,
54 | `token` text NOT NULL,
55 | `expires_at` integer NOT NULL,
56 | `ip_address` text,
57 | `user_agent` text,
58 | `active_organization_id` text,
59 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
60 | `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
61 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
62 | );
63 | --> statement-breakpoint
64 | CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
65 | CREATE TABLE `user` (
66 | `id` text PRIMARY KEY NOT NULL,
67 | `name` text NOT NULL,
68 | `email` text NOT NULL,
69 | `email_verified` integer DEFAULT false NOT NULL,
70 | `image` text,
71 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
72 | `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
73 | );
74 | --> statement-breakpoint
75 | CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
76 | CREATE TABLE `verification` (
77 | `id` text PRIMARY KEY NOT NULL,
78 | `identifier` text NOT NULL,
79 | `value` text NOT NULL,
80 | `expires_at` integer NOT NULL,
81 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
82 | `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
83 | );
84 |
--------------------------------------------------------------------------------
/src/server/db/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "sqlite",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "6",
8 | "when": 1744782664771,
9 | "tag": "0000_petite_norrin_radd",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/src/server/db/schema/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./auth";
2 |
--------------------------------------------------------------------------------
/src/server/db/seed.sql:
--------------------------------------------------------------------------------
1 | -- Insert into user table
2 | INSERT OR IGNORE INTO user (id, name, email, email_verified, image, created_at, updated_at)
3 | VALUES
4 | ('Fe7gvakGA5tVO5Ulho1BIqVGpMBan8r5', 'Alejandro Wurts', 'alejandrowurts@gmail.com', 1, '/notion-avatars/avatar-08.svg', unixepoch('now') * 1000, unixepoch('now') * 1000),
5 | ('Ks9mPqR4Nt2Wx5Yz8Abc3Def7Ghi1JkL', 'Sarah Johnson', 'sarah.johnson@example.com', 1, '/notion-avatars/avatar-02.svg', unixepoch('now') * 1000, unixepoch('now') * 1000),
6 | ('Lm4nQpR8St2Uv5Wx7Yza3Bcd6Efg9HiJ', 'Michael Chen', 'michael.chen@example.com', 1, '/notion-avatars/avatar-03.svg', unixepoch('now') * 1000, unixepoch('now') * 1000);
7 |
8 | -- Insert into account table
9 | INSERT OR IGNORE INTO account (id, user_id, account_id, provider_id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, scope, id_token, password, created_at, updated_at)
10 | VALUES
11 | ('account_Fe7gvakGA5tVO5Ulho1BIqVGpMBan8r5', 'Fe7gvakGA5tVO5Ulho1BIqVGpMBan8r5', 'Fe7gvakGA5tVO5Ulho1BIqVGpMBan8r5', 'credential', NULL, NULL, NULL, NULL, NULL, NULL, '20a5f24e5bd045ef0d039df14b8b7089:86f8b272107169850cbd07a6d8413f222c9ebfa3a6ec23de1254cde97e875becfb183d378b801a3ed21a618c32afcc1dd5c095d600928574ea42620962ed7738', unixepoch('now') * 1000, unixepoch('now') * 1000),
12 | ('account_Ks9mPqR4Nt2Wx5Yz8Abc3Def7Ghi1JkL', 'Ks9mPqR4Nt2Wx5Yz8Abc3Def7Ghi1JkL', 'Ks9mPqR4Nt2Wx5Yz8Abc3Def7Ghi1JkL', 'credential', NULL, NULL, NULL, NULL, NULL, NULL, '20a5f24e5bd045ef0d039df14b8b7089:86f8b272107169850cbd07a6d8413f222c9ebfa3a6ec23de1254cde97e875becfb183d378b801a3ed21a618c32afcc1dd5c095d600928574ea42620962ed7738', unixepoch('now') * 1000, unixepoch('now') * 1000),
13 | ('account_Lm4nQpR8St2Uv5Wx7Yza3Bcd6Efg9HiJ', 'Lm4nQpR8St2Uv5Wx7Yza3Bcd6Efg9HiJ', 'Lm4nQpR8St2Uv5Wx7Yza3Bcd6Efg9HiJ', 'credential', NULL, NULL, NULL, NULL, NULL, NULL, '20a5f24e5bd045ef0d039df14b8b7089:86f8b272107169850cbd07a6d8413f222c9ebfa3a6ec23de1254cde97e875becfb183d378b801a3ed21a618c32afcc1dd5c095d600928574ea42620962ed7738', unixepoch('now') * 1000, unixepoch('now') * 1000);
14 |
15 | -- Insert into organization table
16 | INSERT OR IGNORE INTO organization (id, name, slug, logo, metadata, created_at)
17 | VALUES ('bu1cEXJI1PLWqnU7nQyvmDTEaEiqE9oR', 'Alwurts', 'alwurts', NULL, NULL, unixepoch('now') * 1000);
18 |
19 | -- Insert into org_member table
20 | INSERT OR IGNORE INTO organization_member (id, user_id, organization_id, role, created_at)
21 | VALUES
22 | ('member_Fe7gvakGA5tVO5Ulho1BIqVGpMBan8r5', 'Fe7gvakGA5tVO5Ulho1BIqVGpMBan8r5', 'bu1cEXJI1PLWqnU7nQyvmDTEaEiqE9oR', 'owner', unixepoch('now') * 1000),
23 | ('member_Ks9mPqR4Nt2Wx5Yz8Abc3Def7Ghi1JkL', 'Ks9mPqR4Nt2Wx5Yz8Abc3Def7Ghi1JkL', 'bu1cEXJI1PLWqnU7nQyvmDTEaEiqE9oR', 'member', unixepoch('now') * 1000),
24 | ('member_Lm4nQpR8St2Uv5Wx7Yza3Bcd6Efg9HiJ', 'Lm4nQpR8St2Uv5Wx7Yza3Bcd6Efg9HiJ', 'bu1cEXJI1PLWqnU7nQyvmDTEaEiqE9oR', 'member', unixepoch('now') * 1000);
--------------------------------------------------------------------------------
/src/server/email/index.ts:
--------------------------------------------------------------------------------
1 | import { env } from "cloudflare:workers";
2 | import {
3 | EmailVerificationTemplate,
4 | type EmailVerificationTemplateProps,
5 | } from "@server/email/templates/email-verification";
6 | import {
7 | OrganizationInvitationTemplate,
8 | type OrganizationInvitationTemplateProps,
9 | } from "@server/email/templates/organization-invitation";
10 | import {
11 | PasswordResetTemplate,
12 | type PasswordResetTemplateProps,
13 | } from "@server/email/templates/password-reset";
14 | import { type ComponentType, createElement } from "react";
15 | import { Resend } from "resend";
16 |
17 | const resend = new Resend(env.RESEND_API_KEY);
18 |
19 | // Create a record of all available email templates
20 | const emailTemplates = {
21 | "email-verification": {
22 | id: "email-verification",
23 | subject: "Verify your email address",
24 | component: EmailVerificationTemplate,
25 | },
26 | "password-reset": {
27 | id: "password-reset",
28 | subject: "Reset your password",
29 | component: PasswordResetTemplate,
30 | },
31 | "organization-invitation": {
32 | id: "organization-invitation",
33 | subject: "You've been invited to join an organization",
34 | component: OrganizationInvitationTemplate,
35 | },
36 | } as const;
37 |
38 | // Create a type that represents all possible template IDs
39 | type TemplateId = keyof typeof emailTemplates;
40 |
41 | // Create a type that maps template IDs to their respective prop types
42 | type TemplateProps = {
43 | "email-verification": EmailVerificationTemplateProps;
44 | "password-reset": PasswordResetTemplateProps;
45 | "organization-invitation": OrganizationInvitationTemplateProps;
46 | };
47 |
48 | export const sendMail = async (
49 | to: string,
50 | templateId: T,
51 | props: TemplateProps[T],
52 | ): Promise => {
53 | if (env.MOCK_SEND_EMAIL === "true") {
54 | console.log(
55 | "📨 Email sent to:",
56 | to,
57 | "with template:",
58 | templateId,
59 | "and props:",
60 | props,
61 | );
62 | return;
63 | }
64 |
65 | const emailSender = env.EMAIL_SENDER;
66 |
67 | if (!emailSender) {
68 | throw new Error("EMAIL_SENDER is not set");
69 | }
70 |
71 | const template = emailTemplates[templateId];
72 | const subject = template.subject;
73 | const Component = template.component;
74 |
75 | // Use a type assertion to help TypeScript understand the component type
76 | const emailElement = createElement(
77 | Component as ComponentType,
78 | props,
79 | );
80 |
81 | try {
82 | await resend.emails.send({
83 | from: emailSender,
84 | to,
85 | subject,
86 | react: emailElement,
87 | });
88 |
89 | console.log("📨 Email sent to:", to, "with template:", templateId);
90 | } catch (error) {
91 | console.error("Failed to send email:", error);
92 | throw new Error("Failed to send email");
93 | }
94 | };
95 |
--------------------------------------------------------------------------------
/src/server/email/templates/email-verification.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Button,
4 | Container,
5 | Head,
6 | Heading,
7 | Html,
8 | Preview,
9 | Section,
10 | Text,
11 | } from "@react-email/components";
12 |
13 | export interface EmailVerificationTemplateProps {
14 | verificationUrl: string;
15 | username: string;
16 | }
17 |
18 | export function EmailVerificationTemplate({
19 | verificationUrl,
20 | username,
21 | }: EmailVerificationTemplateProps) {
22 | return (
23 |
24 |
25 | Verify your email address
26 |
27 |
28 | Verify your email address
29 |
30 | Hi {username}, please verify your email address by clicking the
31 | button below.
32 |
33 |
34 |
37 |
38 |
39 | If you didn't request this email, you can safely ignore it.
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | const main = {
48 | backgroundColor: "#ffffff",
49 | fontFamily:
50 | "-apple-system,BlinkMacSystemFont," +
51 | '"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
52 | };
53 |
54 | const container = {
55 | margin: "0 auto",
56 | padding: "20px 0 48px",
57 | maxWidth: "560px",
58 | };
59 |
60 | const h1 = {
61 | fontSize: "24px",
62 | fontWeight: "bold",
63 | marginTop: "48px",
64 | marginBottom: "24px",
65 | };
66 |
67 | const text = {
68 | fontSize: "16px",
69 | lineHeight: "26px",
70 | marginBottom: "24px",
71 | };
72 |
73 | const buttonContainer = {
74 | marginBottom: "24px",
75 | };
76 |
77 | const button = {
78 | backgroundColor: "#5F51E8",
79 | borderRadius: "3px",
80 | color: "#fff",
81 | fontSize: "16px",
82 | textDecoration: "none",
83 | textAlign: "center" as const,
84 | display: "block",
85 | padding: "12px",
86 | };
87 |
--------------------------------------------------------------------------------
/src/server/email/templates/organization-invitation.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Button,
4 | Container,
5 | Head,
6 | Heading,
7 | Html,
8 | Preview,
9 | Section,
10 | Text,
11 | } from "@react-email/components";
12 |
13 | export interface OrganizationInvitationTemplateProps {
14 | inviteLink: string;
15 | username: string;
16 | invitedByUsername: string;
17 | invitedByEmail: string;
18 | teamName: string;
19 | }
20 |
21 | export function OrganizationInvitationTemplate({
22 | inviteLink,
23 | username,
24 | invitedByUsername,
25 | invitedByEmail,
26 | teamName,
27 | }: OrganizationInvitationTemplateProps) {
28 | return (
29 |
30 |
31 | You've been invited to join {teamName}
32 |
33 |
34 | Team Invitation
35 |
36 | Hi {username}, you've been invited by {invitedByUsername} (
37 | {invitedByEmail}) to join the team {teamName}.
38 |
39 |
40 |
43 |
44 |
45 | If you didn't expect this invitation, you can safely ignore it.
46 |
47 |
48 |
49 |
50 | );
51 | }
52 |
53 | const main = {
54 | backgroundColor: "#ffffff",
55 | fontFamily:
56 | "-apple-system,BlinkMacSystemFont," +
57 | '"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
58 | };
59 |
60 | const container = {
61 | margin: "0 auto",
62 | padding: "20px 0 48px",
63 | maxWidth: "560px",
64 | };
65 |
66 | const h1 = {
67 | fontSize: "24px",
68 | fontWeight: "bold",
69 | marginTop: "48px",
70 | marginBottom: "24px",
71 | };
72 |
73 | const text = {
74 | fontSize: "16px",
75 | lineHeight: "26px",
76 | marginBottom: "24px",
77 | };
78 |
79 | const buttonContainer = {
80 | marginBottom: "24px",
81 | };
82 |
83 | const button = {
84 | backgroundColor: "#5F51E8",
85 | borderRadius: "3px",
86 | color: "#fff",
87 | fontSize: "16px",
88 | textDecoration: "none",
89 | textAlign: "center" as const,
90 | display: "block",
91 | padding: "12px",
92 | };
93 |
--------------------------------------------------------------------------------
/src/server/email/templates/password-reset.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Button,
4 | Container,
5 | Head,
6 | Heading,
7 | Html,
8 | Preview,
9 | Section,
10 | Text,
11 | } from "@react-email/components";
12 |
13 | export interface PasswordResetTemplateProps {
14 | resetLink: string;
15 | username: string;
16 | }
17 |
18 | export function PasswordResetTemplate({
19 | resetLink,
20 | username,
21 | }: PasswordResetTemplateProps) {
22 | return (
23 |
24 |
25 | Reset your password
26 |
27 |
28 | Reset your password
29 |
30 | Hi {username}, you requested to reset your password. Click the
31 | button below to create a new password.
32 |
33 |
34 |
37 |
38 |
39 | If you didn't request this email, you can safely ignore it.
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | const main = {
48 | backgroundColor: "#ffffff",
49 | fontFamily:
50 | "-apple-system,BlinkMacSystemFont," +
51 | '"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
52 | };
53 |
54 | const container = {
55 | margin: "0 auto",
56 | padding: "20px 0 48px",
57 | maxWidth: "560px",
58 | };
59 |
60 | const h1 = {
61 | fontSize: "24px",
62 | fontWeight: "bold",
63 | marginTop: "48px",
64 | marginBottom: "24px",
65 | };
66 |
67 | const text = {
68 | fontSize: "16px",
69 | lineHeight: "26px",
70 | marginBottom: "24px",
71 | };
72 |
73 | const buttonContainer = {
74 | marginBottom: "24px",
75 | };
76 |
77 | const button = {
78 | backgroundColor: "#5F51E8",
79 | borderRadius: "3px",
80 | color: "#fff",
81 | fontSize: "16px",
82 | textDecoration: "none",
83 | textAlign: "center" as const,
84 | display: "block",
85 | padding: "12px",
86 | };
87 |
--------------------------------------------------------------------------------
/src/server/index.ts:
--------------------------------------------------------------------------------
1 | import { app } from "@server/routes";
2 |
3 | export { OrganizationDurableObject } from "@server/organization-do/organization";
4 |
5 | export default app;
6 |
--------------------------------------------------------------------------------
/src/server/middleware/auth.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@server/auth";
2 | import type { HonoContext } from "@server/types/hono";
3 | import type { Context, Next } from "hono";
4 |
5 | export const honoAuthMiddleware = async (
6 | c: Context,
7 | next: Next,
8 | ) => {
9 | const session = await auth.api.getSession({
10 | headers: c.req.raw.headers,
11 | });
12 |
13 | c.set("user", session?.user ?? null);
14 | c.set("session", session?.session ?? null);
15 | await next();
16 | };
17 |
18 | export const honoAuthCheckMiddleware = async (
19 | c: Context,
20 | next: Next,
21 | ) => {
22 | const session = c.get("session");
23 | const user = c.get("user");
24 |
25 | if (!session || !user || !session.activeOrganizationId) {
26 | return c.json({ error: "Unauthorized" }, 401);
27 | }
28 |
29 | c.set("user", user);
30 | c.set("session", session);
31 | await next();
32 | };
33 |
--------------------------------------------------------------------------------
/src/server/organization-do/db/migrations/0000_medical_zzzax.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE `agent` (
2 | `id` text PRIMARY KEY NOT NULL,
3 | `email` text NOT NULL,
4 | `name` text NOT NULL,
5 | `image` text NOT NULL,
6 | `description` text NOT NULL,
7 | `tone` text NOT NULL,
8 | `verbosity` text NOT NULL,
9 | `emoji_usage` text NOT NULL,
10 | `language_style` text NOT NULL,
11 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
12 | );
13 | --> statement-breakpoint
14 | CREATE UNIQUE INDEX `agent_email_unique` ON `agent` (`email`);--> statement-breakpoint
15 | CREATE TABLE `chat_message` (
16 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
17 | `content` text NOT NULL,
18 | `mentions` text NOT NULL,
19 | `tool_uses` text NOT NULL,
20 | `member_id` text NOT NULL,
21 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
22 | `metadata` text NOT NULL,
23 | `thread_metadata` text,
24 | `room_id` text NOT NULL,
25 | `thread_id` integer,
26 | FOREIGN KEY (`room_id`) REFERENCES `chat_room`(`id`) ON UPDATE no action ON DELETE no action
27 | );
28 | --> statement-breakpoint
29 | CREATE TABLE `chat_room` (
30 | `id` text PRIMARY KEY NOT NULL,
31 | `name` text NOT NULL,
32 | `type` text NOT NULL,
33 | `organization_id` text NOT NULL,
34 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
35 | );
36 | --> statement-breakpoint
37 | CREATE TABLE `chat_room_member` (
38 | `id` text NOT NULL,
39 | `room_id` text NOT NULL,
40 | `type` text NOT NULL,
41 | `role` text NOT NULL,
42 | `name` text NOT NULL,
43 | `email` text NOT NULL,
44 | `image` text,
45 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
46 | PRIMARY KEY(`room_id`, `id`),
47 | FOREIGN KEY (`room_id`) REFERENCES `chat_room`(`id`) ON UPDATE no action ON DELETE no action
48 | );
49 | --> statement-breakpoint
50 | CREATE TABLE `workflows` (
51 | `id` text PRIMARY KEY NOT NULL,
52 | `agent_id` text NOT NULL,
53 | `chat_room_id` text NOT NULL,
54 | `goal` text NOT NULL,
55 | `steps` text NOT NULL,
56 | `schedule_expression` text NOT NULL,
57 | `next_execution_time` integer NOT NULL,
58 | `last_execution_time` integer,
59 | `is_active` integer NOT NULL,
60 | `is_recurring` integer NOT NULL,
61 | `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
62 | `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
63 | );
64 |
--------------------------------------------------------------------------------
/src/server/organization-do/db/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "sqlite",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "6",
8 | "when": 1744954562124,
9 | "tag": "0000_medical_zzzax",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/src/server/organization-do/db/migrations/migrations.js:
--------------------------------------------------------------------------------
1 | import m0000 from "./0000_medical_zzzax.sql";
2 | import journal from "./meta/_journal.json";
3 |
4 | export default {
5 | journal,
6 | migrations: {
7 | m0000,
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/src/server/organization-do/db/services/agents.ts:
--------------------------------------------------------------------------------
1 | import type { Agent } from "@shared/types";
2 | import { eq, inArray } from "drizzle-orm";
3 | import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
4 | import { agent } from "../schema";
5 |
6 | export function createAgentsService(db: DrizzleSqliteDODatabase) {
7 | return {
8 | /**
9 | * Get all agents
10 | * @returns All agents
11 | */
12 | async getAgents(): Promise {
13 | return await db.select().from(agent);
14 | },
15 |
16 | /**
17 | * Create an agent
18 | * @param agent - The agent to create
19 | * @returns The created agent
20 | */
21 | async createAgent(newAgent: typeof agent.$inferInsert): Promise {
22 | const [createdAgent] = await db
23 | .insert(agent)
24 | .values(newAgent)
25 | .returning();
26 | return createdAgent;
27 | },
28 |
29 | /**
30 | * Get an agent by ID
31 | * @param id - The ID of the agent
32 | * @returns The agent
33 | */
34 | async getAgentById(id: string): Promise {
35 | return await db.select().from(agent).where(eq(agent.id, id)).get();
36 | },
37 |
38 | /**
39 | * Get agents by IDs
40 | * @param ids - The IDs of the agents
41 | * @returns The agents
42 | */
43 | async getAgentsByIds(ids: string[]): Promise {
44 | return await db.select().from(agent).where(inArray(agent.id, ids));
45 | },
46 |
47 | /**
48 | * Update an agent
49 | * @param id - The ID of the agent
50 | * @param agent - The agent to update
51 | * @returns The updated agent
52 | */
53 | async updateAgent(
54 | id: string,
55 | agentUpdates: Partial>,
56 | ): Promise {
57 | const [updatedAgent] = await db
58 | .update(agent)
59 | .set(agentUpdates)
60 | .where(eq(agent.id, id))
61 | .returning();
62 | return updatedAgent;
63 | },
64 | };
65 | }
66 |
--------------------------------------------------------------------------------
/src/server/organization-do/db/services/chat-room-members.ts:
--------------------------------------------------------------------------------
1 | import type { ChatRoomMember, ChatRoomMemberType } from "@shared/types";
2 | import { and, eq } from "drizzle-orm";
3 | import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
4 | import { chatRoomMember } from "../schema";
5 |
6 | export function createChatRoomMemberService(db: DrizzleSqliteDODatabase) {
7 | return {
8 | /**
9 | * Get all members of a chat room
10 | * @param roomId - The id of the chat room
11 | * @returns All members of the chat room
12 | */
13 | async getChatRoomMembers({
14 | roomId,
15 | type,
16 | }: {
17 | roomId: string;
18 | type?: ChatRoomMemberType;
19 | }): Promise {
20 | const query = db.select().from(chatRoomMember);
21 |
22 | const whereClauses = [eq(chatRoomMember.roomId, roomId)];
23 |
24 | if (type) {
25 | whereClauses.push(eq(chatRoomMember.type, type));
26 | }
27 |
28 | return await query.where(and(...whereClauses)).all();
29 | },
30 |
31 | /**
32 | * Delete a member from a chat room
33 | * @param roomId - The id of the chat room
34 | * @param memberId - The id of the member to delete
35 | */
36 | async deleteChatRoomMember({
37 | roomId,
38 | memberId,
39 | }: {
40 | roomId: string;
41 | memberId: string;
42 | }) {
43 | await db
44 | .delete(chatRoomMember)
45 | .where(
46 | and(
47 | eq(chatRoomMember.roomId, roomId),
48 | eq(chatRoomMember.id, memberId),
49 | ),
50 | );
51 | },
52 |
53 | /**
54 | * Add a member to a chat room
55 | * @param newChatRoomMember - The member to add
56 | */
57 | async addChatRoomMember(
58 | newChatRoomMember: typeof chatRoomMember.$inferInsert,
59 | ) {
60 | return await db
61 | .insert(chatRoomMember)
62 | .values(newChatRoomMember)
63 | .returning()
64 | .get();
65 | },
66 |
67 | /**
68 | * Check if a user is a member of a chat room
69 | * @param roomId - The id of the chat room
70 | * @param userId - The id of the user
71 | * @returns true if the user is a member, false otherwise
72 | */
73 | async isUserMemberOfRoom({
74 | roomId,
75 | userId,
76 | }: {
77 | roomId: string;
78 | userId: string;
79 | }): Promise {
80 | const member = await db
81 | .select()
82 | .from(chatRoomMember)
83 | .where(
84 | and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.id, userId)),
85 | )
86 | .limit(1);
87 | return !!member;
88 | },
89 | };
90 | }
91 |
--------------------------------------------------------------------------------
/src/server/organization-do/db/services/chat-room.ts:
--------------------------------------------------------------------------------
1 | import type { ChatRoom, ChatRoomMember } from "@shared/types";
2 | import { eq } from "drizzle-orm";
3 | import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
4 | import { chatRoom, chatRoomMember } from "../schema";
5 |
6 | export function createChatRoomService(db: DrizzleSqliteDODatabase) {
7 | return {
8 | /**
9 | * Create a new chat room
10 | * @param newChatRoom - The new chat room to create
11 | * @param members - The members to add to the chat room
12 | * @returns The created chat room
13 | */
14 | async createChatRoom({
15 | newChatRoom,
16 | members,
17 | }: {
18 | newChatRoom: typeof chatRoom.$inferInsert;
19 | members: Omit[];
20 | }) {
21 | // TODO: Transactions have some weird error
22 | const [createdChatRoom] = await db
23 | .insert(chatRoom)
24 | .values(newChatRoom)
25 | .returning();
26 |
27 | await db.insert(chatRoomMember).values(
28 | members.map((member) => ({
29 | ...member,
30 | roomId: createdChatRoom.id,
31 | })),
32 | );
33 |
34 | return createdChatRoom;
35 | },
36 |
37 | /**
38 | * Get a chat room by id
39 | * @param id - The id of the chat room
40 | * @returns The chat room
41 | */
42 | async getChatRoomById(id: string): Promise {
43 | return await db.select().from(chatRoom).where(eq(chatRoom.id, id)).get();
44 | },
45 |
46 | /**
47 | * Get all chat rooms
48 | * @returns All chat rooms
49 | */
50 | async getChatRoomsUserIsMemberOf(userId: string): Promise {
51 | return await db
52 | .select({
53 | id: chatRoom.id,
54 | name: chatRoom.name,
55 | type: chatRoom.type,
56 | organizationId: chatRoom.organizationId,
57 | createdAt: chatRoom.createdAt,
58 | })
59 | .from(chatRoomMember)
60 | .where(eq(chatRoomMember.id, userId))
61 | .innerJoin(chatRoom, eq(chatRoom.id, chatRoomMember.roomId));
62 | },
63 | };
64 | }
65 |
--------------------------------------------------------------------------------
/src/server/organization-do/db/services/index.ts:
--------------------------------------------------------------------------------
1 | import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
2 | import { createAgentsService } from "./agents";
3 | import { createChatRoomService } from "./chat-room";
4 | import { createChatRoomMemberService } from "./chat-room-members";
5 | import { createChatRoomMessageService } from "./chat-room-message";
6 | import { createWorkflowService } from "./workflow";
7 |
8 | export function createChatRoomDbServices(db: DrizzleSqliteDODatabase) {
9 | const chatRoomService = createChatRoomService(db);
10 | const chatRoomMessageService = createChatRoomMessageService(db);
11 | const chatRoomMemberService = createChatRoomMemberService(db);
12 | const agentsService = createAgentsService(db);
13 | const workflowService = createWorkflowService(db);
14 |
15 | return {
16 | ...chatRoomService,
17 | ...chatRoomMessageService,
18 | ...chatRoomMemberService,
19 | ...agentsService,
20 | ...workflowService,
21 | };
22 | }
23 |
24 | export type ChatRoomDbServices = ReturnType;
25 |
--------------------------------------------------------------------------------
/src/server/routes/index.ts:
--------------------------------------------------------------------------------
1 | import { env } from "cloudflare:workers";
2 | import { auth } from "@server/auth";
3 | import protectedRoutes from "@server/routes/protected";
4 | import websocketOrganizationRoutes from "@server/routes/websocket/organization";
5 | import type { HonoContext } from "@server/types/hono";
6 | import { Hono } from "hono";
7 | import { cors } from "hono/cors";
8 | export const app = new Hono().use(
9 | "/api/*",
10 | cors({
11 | origin: env.APP_URL,
12 | allowMethods: ["GET", "POST", "OPTIONS", "PUT", "DELETE"],
13 | allowHeaders: ["Content-Type", "Authorization"],
14 | exposeHeaders: ["Content-Length"],
15 | maxAge: 600,
16 | credentials: true,
17 | }),
18 | );
19 |
20 | app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw));
21 |
22 | const routes = app
23 | .route("/api", protectedRoutes)
24 | .route("/websocket", websocketOrganizationRoutes);
25 |
26 | app.all("*", async (c) => {
27 | return c.env.ASSETS.fetch(c.req.raw);
28 | });
29 |
30 | export type AppType = typeof routes;
31 |
--------------------------------------------------------------------------------
/src/server/routes/protected/chat/chat-room-members.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from "hono";
2 |
3 | import { zValidator } from "@hono/zod-validator";
4 | import { db } from "@server/db";
5 | import * as globalSchema from "@server/db/schema";
6 | import type { HonoContextWithAuth } from "@server/types/hono";
7 | import { createChatRoomMemberSchema } from "@shared/types";
8 | import { and, eq } from "drizzle-orm";
9 |
10 | const chatRoomMembers = new Hono()
11 | .delete("/:chatRoomId/members/:memberId", async (c) => {
12 | const { ORGANIZATION_DURABLE_OBJECT } = c.env;
13 | const chatRoomId = c.req.param("chatRoomId");
14 | const memberId = c.req.param("memberId");
15 | const session = c.get("session");
16 | const { activeOrganizationId } = session;
17 |
18 | const organizationDoId =
19 | ORGANIZATION_DURABLE_OBJECT.idFromName(activeOrganizationId);
20 | const organizationDo = ORGANIZATION_DURABLE_OBJECT.get(organizationDoId);
21 |
22 | await organizationDo.deleteChatRoomMember({
23 | roomId: chatRoomId,
24 | memberId,
25 | });
26 |
27 | return c.json({ success: true });
28 | })
29 | .post(
30 | "/:chatRoomId/members",
31 | zValidator("json", createChatRoomMemberSchema),
32 | async (c) => {
33 | const { ORGANIZATION_DURABLE_OBJECT } = c.env;
34 | const chatRoomId = c.req.param("chatRoomId");
35 | const session = c.get("session");
36 | const { activeOrganizationId } = session;
37 |
38 | const { id, role, type } = c.req.valid("json");
39 |
40 | let member: {
41 | memberId: string;
42 | name: string;
43 | email: string;
44 | image: string | null;
45 | } | null = null;
46 |
47 | const organizationDoId =
48 | ORGANIZATION_DURABLE_OBJECT.idFromName(activeOrganizationId);
49 | const organizationDo = ORGANIZATION_DURABLE_OBJECT.get(organizationDoId);
50 |
51 | if (type === "user") {
52 | const result = await db
53 | .select({
54 | user: globalSchema.user,
55 | })
56 | .from(globalSchema.organizationMember)
57 | .innerJoin(
58 | globalSchema.user,
59 | eq(globalSchema.organizationMember.userId, globalSchema.user.id),
60 | )
61 | .where(
62 | and(
63 | eq(globalSchema.organizationMember.userId, id),
64 | eq(
65 | globalSchema.organizationMember.organizationId,
66 | activeOrganizationId,
67 | ),
68 | ),
69 | )
70 | .get();
71 |
72 | if (!result?.user) {
73 | throw new Error("User not found");
74 | }
75 |
76 | member = {
77 | memberId: result.user.id,
78 | name: result.user.name,
79 | email: result.user.email,
80 | image: result.user.image,
81 | };
82 | }
83 |
84 | if (type === "agent") {
85 | const agent = await organizationDo.getAgentById(id);
86 |
87 | if (!agent) {
88 | throw new Error("Agent not found");
89 | }
90 |
91 | member = {
92 | memberId: agent.id,
93 | name: agent.name,
94 | email: agent.email,
95 | image: agent.image,
96 | };
97 | }
98 |
99 | if (!member) {
100 | throw new Error("Member not found");
101 | }
102 |
103 | await organizationDo.addChatRoomMember({
104 | id: member.memberId,
105 | name: member.name,
106 | email: member.email,
107 | role,
108 | type,
109 | roomId: chatRoomId,
110 | image: member.image,
111 | });
112 |
113 | return c.json({ success: true });
114 | },
115 | );
116 |
117 | export default chatRoomMembers;
118 |
--------------------------------------------------------------------------------
/src/server/routes/protected/chat/chat-room.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from "hono";
2 |
3 | import { zValidator } from "@hono/zod-validator";
4 | import { db } from "@server/db";
5 | import * as globalSchema from "@server/db/schema";
6 | import type { HonoContextWithAuth } from "@server/types/hono";
7 | import { createChatRoomSchema } from "@shared/types";
8 | import { inArray } from "drizzle-orm";
9 |
10 | const chatRoom = new Hono().post(
11 | "/",
12 | zValidator("json", createChatRoomSchema),
13 | async (c) => {
14 | const { ORGANIZATION_DURABLE_OBJECT } = c.env;
15 | const user = c.get("user");
16 | const session = c.get("session");
17 | const { activeOrganizationId } = session;
18 | const { name, members } = c.req.valid("json");
19 |
20 | const organizationDoId =
21 | ORGANIZATION_DURABLE_OBJECT.idFromName(activeOrganizationId);
22 | const organizationDo = ORGANIZATION_DURABLE_OBJECT.get(organizationDoId);
23 |
24 | // Prepare members
25 | const newMembersWithoutCurrentUser = members.filter(
26 | (member) => member.id !== user.id,
27 | );
28 |
29 | const newUserMembers = newMembersWithoutCurrentUser.filter(
30 | (member) => member.type === "user",
31 | );
32 |
33 | const newAgentMembers = newMembersWithoutCurrentUser.filter(
34 | (member) => member.type === "agent",
35 | );
36 |
37 | const newOwnerChatRoomMember = {
38 | id: user.id,
39 | name: user.name,
40 | email: user.email,
41 | image: user.image,
42 | role: "owner" as const,
43 | type: "user" as const,
44 | };
45 |
46 | const [newUserMemberDetails, newAgentMemberDetails] = await Promise.all([
47 | db
48 | .select()
49 | .from(globalSchema.user)
50 | .where(
51 | inArray(
52 | globalSchema.user.id,
53 | newUserMembers.map((member) => member.id),
54 | ),
55 | ),
56 | organizationDo.getAgentsByIds(newAgentMembers.map((member) => member.id)),
57 | ]);
58 |
59 | const membersToAddDetailsPartial = [
60 | newOwnerChatRoomMember,
61 | ...newUserMemberDetails.map((member) => ({
62 | id: member.id,
63 | name: member.name,
64 | email: member.email,
65 | image: member.image,
66 | role: "member" as const,
67 | type: "user" as const,
68 | })),
69 | ...newAgentMemberDetails.map((member) => ({
70 | id: member.id,
71 | name: member.name,
72 | email: member.email,
73 | image: member.image,
74 | role: "member" as const,
75 | type: "agent" as const,
76 | })),
77 | ];
78 |
79 | try {
80 | const newChatRoom = await organizationDo.createChatRoom({
81 | newChatRoom: {
82 | name,
83 | type: "public",
84 | organizationId: activeOrganizationId,
85 | },
86 | members: membersToAddDetailsPartial,
87 | });
88 |
89 | return c.json({ roomId: newChatRoom.id });
90 | } catch (error) {
91 | console.error(error);
92 | return c.json({ error: "Failed to create chat room" }, 500);
93 | }
94 | },
95 | );
96 |
97 | export default chatRoom;
98 |
--------------------------------------------------------------------------------
/src/server/routes/protected/chat/index.ts:
--------------------------------------------------------------------------------
1 | import chatRoomRoutes from "@server/routes/protected/chat/chat-room";
2 | import chatRoomMemberRoutes from "@server/routes/protected/chat/chat-room-members";
3 | import type { HonoContext } from "@server/types/hono";
4 | import { Hono } from "hono";
5 |
6 | const app = new Hono()
7 | .route("/chat-rooms", chatRoomRoutes)
8 | .route("/chat-rooms", chatRoomMemberRoutes);
9 |
10 | export default app;
11 |
--------------------------------------------------------------------------------
/src/server/routes/protected/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | honoAuthCheckMiddleware,
3 | honoAuthMiddleware,
4 | } from "@server/middleware/auth";
5 | import agentRoutes from "@server/routes/protected/agents";
6 | import chatRoomRoutes from "@server/routes/protected/chat";
7 | import organizationUserRoutes from "@server/routes/protected/organization-user";
8 | import workflowRoutes from "@server/routes/protected/workflows";
9 | import type { HonoContext } from "@server/types/hono";
10 | import { Hono } from "hono";
11 |
12 | const app = new Hono()
13 | .use(honoAuthMiddleware)
14 | .use(honoAuthCheckMiddleware)
15 | .route("/chat", chatRoomRoutes)
16 | .route("/organization", organizationUserRoutes)
17 | .route("/agents", agentRoutes)
18 | .route("/workflows", workflowRoutes);
19 |
20 | export default app;
21 |
--------------------------------------------------------------------------------
/src/server/routes/protected/organization-user.ts:
--------------------------------------------------------------------------------
1 | import { zValidator } from "@hono/zod-validator";
2 | import { db } from "@server/db";
3 | import * as globalSchema from "@server/db/schema";
4 | import type { HonoContextWithAuth } from "@server/types/hono";
5 | import { and, eq, not } from "drizzle-orm";
6 | import { Hono } from "hono";
7 | import { z } from "zod";
8 |
9 | const app = new Hono().get(
10 | "/users",
11 | zValidator(
12 | "query",
13 | z.object({
14 | includeUser: z.enum(["true", "false"]).transform((v) => v === "true"),
15 | }),
16 | ),
17 | async (c) => {
18 | const session = c.get("session");
19 | const user = c.get("user");
20 | const { activeOrganizationId } = session;
21 | const { includeUser } = c.req.valid("query");
22 | if (!activeOrganizationId) {
23 | throw new Error("Organization not set");
24 | }
25 |
26 | const users = await db
27 | .select({
28 | id: globalSchema.user.id,
29 | name: globalSchema.user.name,
30 | email: globalSchema.user.email,
31 | image: globalSchema.user.image,
32 | })
33 | .from(globalSchema.organizationMember)
34 | .innerJoin(
35 | globalSchema.user,
36 | eq(globalSchema.organizationMember.userId, globalSchema.user.id),
37 | )
38 | .where(
39 | and(
40 | eq(
41 | globalSchema.organizationMember.organizationId,
42 | activeOrganizationId,
43 | ),
44 | !includeUser ? not(eq(globalSchema.user.id, user.id)) : undefined,
45 | ),
46 | );
47 |
48 | return c.json(users);
49 | },
50 | );
51 |
52 | export default app;
53 |
--------------------------------------------------------------------------------
/src/server/routes/protected/workflows.ts:
--------------------------------------------------------------------------------
1 | import type { HonoContextWithAuth } from "@server/types/hono";
2 | import { Hono } from "hono";
3 |
4 | const app = new Hono().delete(
5 | "/:workflowId",
6 | async (c) => {
7 | const { ORGANIZATION_DURABLE_OBJECT } = c.env;
8 | const workflowId = c.req.param("workflowId");
9 | const session = c.get("session");
10 | const { activeOrganizationId } = session;
11 |
12 | const organizationDoId =
13 | ORGANIZATION_DURABLE_OBJECT.idFromName(activeOrganizationId);
14 | const organizationDo = ORGANIZATION_DURABLE_OBJECT.get(organizationDoId);
15 |
16 | await organizationDo.deleteWorkflow(workflowId);
17 |
18 | return c.json({
19 | success: true,
20 | });
21 | },
22 | );
23 |
24 | export default app;
25 |
--------------------------------------------------------------------------------
/src/server/routes/websocket/organization.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from "hono";
2 |
3 | import {
4 | honoAuthCheckMiddleware,
5 | honoAuthMiddleware,
6 | } from "@server/middleware/auth";
7 | import type { HonoContextWithAuth } from "@server/types/hono";
8 |
9 | const app = new Hono()
10 | .use(honoAuthMiddleware)
11 | .use(honoAuthCheckMiddleware)
12 | .get("/organization/:organizationSlug", async (c) => {
13 | const upgradeHeader = c.req.header("Upgrade");
14 | if (!upgradeHeader || upgradeHeader !== "websocket") {
15 | return c.text("Expected Upgrade: websocket", 426);
16 | }
17 |
18 | const user = c.get("user");
19 |
20 | const { organizationSlug } = c.req.param();
21 |
22 | // Proceed with WebSocket connection
23 | const organizationDoId =
24 | c.env.ORGANIZATION_DURABLE_OBJECT.idFromName(organizationSlug);
25 | const organizationDo =
26 | c.env.ORGANIZATION_DURABLE_OBJECT.get(organizationDoId);
27 |
28 | const url = new URL(c.req.url);
29 | url.searchParams.set("userId", user.id);
30 |
31 | return await organizationDo.fetch(new Request(url, c.req.raw));
32 | });
33 |
34 | export default app;
35 |
--------------------------------------------------------------------------------
/src/server/types/hono.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import type { auth } from "@server/auth";
4 |
5 | type Auth = typeof auth;
6 |
7 | export type HonoContext = {
8 | Bindings: Env;
9 | Variables: {
10 | user: Auth["$Infer"]["Session"]["user"] | null;
11 | session: Auth["$Infer"]["Session"]["session"] | null;
12 | };
13 | };
14 |
15 | export type HonoContextWithAuth = HonoContext & {
16 | Variables: HonoContext["Variables"] & {
17 | user: NonNullable;
18 | session: NonNullable & {
19 | activeOrganizationId: string;
20 | };
21 | };
22 | };
23 |
--------------------------------------------------------------------------------
/src/server/types/session.ts:
--------------------------------------------------------------------------------
1 | export interface Session {
2 | userId: string;
3 | activeRoomId: string | null;
4 | }
5 |
--------------------------------------------------------------------------------
/src/shared/lib/chat.ts:
--------------------------------------------------------------------------------
1 | import type { User } from "better-auth";
2 | import { customAlphabet } from "nanoid";
3 | import type { ChatRoomMessage, ChatRoomMessagePartial } from "../types/chat";
4 |
5 | export function createChatRoomMessagePartial({
6 | content,
7 | toolUses,
8 | mentions,
9 | threadId,
10 | roomId,
11 | }: Pick<
12 | ChatRoomMessagePartial,
13 | "content" | "toolUses" | "threadId" | "mentions" | "roomId"
14 | >): ChatRoomMessagePartial {
15 | return {
16 | id: Number(customAlphabet("0123456789", 20)()),
17 | threadId,
18 | roomId,
19 | content,
20 | toolUses,
21 | mentions,
22 | createdAt: Date.now(),
23 | };
24 | }
25 |
26 | export function createChatRoomOptimisticMessage({
27 | message,
28 | user,
29 | roomId,
30 | }: {
31 | message: ChatRoomMessagePartial;
32 | user: User;
33 | roomId: string;
34 | }): ChatRoomMessage {
35 | return {
36 | ...message,
37 | member: {
38 | id: user.id,
39 | roomId: roomId,
40 | name: user.name,
41 | type: "user",
42 | role: "member",
43 | email: user.email,
44 | image: user.image,
45 | },
46 | metadata: {
47 | optimisticData: {
48 | createdAt: message.createdAt,
49 | id: message.id,
50 | },
51 | },
52 | };
53 | }
54 |
--------------------------------------------------------------------------------
/src/shared/types/chat-ws.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | ChatRoom,
3 | ChatRoomMember,
4 | ChatRoomMessage,
5 | ChatRoomMessagePartial,
6 | } from "@shared/types/chat";
7 | import type { Workflow } from "@shared/types/workflow";
8 |
9 | export type WsMessageOrganizationInitRequest = {
10 | type: "organization-init-request";
11 | };
12 |
13 | export type WsMessageChatRoomInitRequest = {
14 | type: "chat-room-init-request";
15 | roomId: string;
16 | };
17 |
18 | export type WsMessageChatRoomThreadInitRequest = {
19 | type: "chat-room-thread-init-request";
20 | roomId: string;
21 | threadId: number;
22 | };
23 |
24 | export type WsMessageChatRoomMessageSend = {
25 | type: "chat-room-message-send";
26 | roomId: string;
27 | threadId: number | null;
28 | message: ChatRoomMessagePartial;
29 | };
30 |
31 | export type WsChatIncomingMessage =
32 | | WsMessageOrganizationInitRequest
33 | | WsMessageChatRoomInitRequest
34 | | WsMessageChatRoomMessageSend
35 | | WsMessageChatRoomThreadInitRequest;
36 |
37 | export type WsMessageOrganizationInitResponse = {
38 | type: "organization-init-response";
39 | chatRooms: ChatRoom[];
40 | };
41 |
42 | export type WsMessageChatRoomInitResponse = {
43 | type: "chat-room-init-response";
44 | roomId: string;
45 | messages: ChatRoomMessage[];
46 | members: ChatRoomMember[];
47 | room: ChatRoom;
48 | workflows: Workflow[];
49 | };
50 |
51 | export type WsMessageChatRoomThreadInitResponse = {
52 | type: "chat-room-thread-init-response";
53 | roomId: string;
54 | threadId: number;
55 | threadMessage: ChatRoomMessage;
56 | messages: ChatRoomMessage[];
57 | };
58 |
59 | export type WsMessageChatRoomsUpdate = {
60 | type: "chat-rooms-update";
61 | chatRooms: ChatRoom[];
62 | };
63 |
64 | export type WsMessageChatRoomMessageBroadcast = {
65 | type: "chat-room-message-broadcast";
66 | roomId: string;
67 | threadId: number | null;
68 | message: ChatRoomMessage;
69 | };
70 |
71 | export type WsMessageChatRoomWorkflowsUpdate = {
72 | type: "chat-room-workflows-update";
73 | roomId: string;
74 | workflows: Workflow[];
75 | };
76 |
77 | export type WsMessageChatRoomMembersUpdate = {
78 | type: "chat-room-members-update";
79 | roomId: string;
80 | members: ChatRoomMember[];
81 | };
82 |
83 | export type WsChatOutgoingMessage =
84 | | WsMessageOrganizationInitResponse
85 | | WsMessageChatRoomsUpdate
86 | | WsMessageChatRoomInitResponse
87 | | WsMessageChatRoomMessageBroadcast
88 | | WsMessageChatRoomThreadInitResponse
89 | | WsMessageChatRoomWorkflowsUpdate
90 | | WsMessageChatRoomMembersUpdate;
91 |
92 | export type WsChatMessage = WsChatIncomingMessage | WsChatOutgoingMessage;
93 |
--------------------------------------------------------------------------------
/src/shared/types/chat.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import type { AgentToolUse } from "./agent";
3 |
4 | export type ChatMessageMetadata = {
5 | optimisticData?: {
6 | createdAt: number;
7 | id: number;
8 | };
9 | };
10 |
11 | export type ChatMessageThreadMetadata = {
12 | lastMessage: ChatRoomMessage;
13 | messageCount: number;
14 | } | null;
15 |
16 | // ChatMention
17 | export type ChatMention = {
18 | id: string;
19 | name: string;
20 | };
21 |
22 | export type ChatMentions = ChatMention[];
23 |
24 | // ChatInputValue
25 | export type ChatInputValue = {
26 | content: string;
27 | mentions: ChatMention[];
28 | };
29 |
30 | // ChatRoomMessage
31 | export interface ChatRoomMessagePartial {
32 | id: number;
33 | content: string;
34 | mentions: ChatMention[];
35 | toolUses: AgentToolUse[];
36 | createdAt: number;
37 | threadId: number | null;
38 | roomId: string;
39 | }
40 |
41 | export interface ChatRoomMessage extends ChatRoomMessagePartial {
42 | member: ChatRoomMember;
43 | metadata: ChatMessageMetadata;
44 | threadMetadata?: ChatMessageThreadMetadata;
45 | }
46 |
47 | // ChatRoomMember
48 | const CHAT_ROOM_MEMBER_ROLES = ["member", "owner", "admin"] as const;
49 | export type ChatRoomMemberRole = (typeof CHAT_ROOM_MEMBER_ROLES)[number];
50 |
51 | const CHAT_ROOM_MEMBER_TYPES = ["user", "agent"] as const;
52 | export type ChatRoomMemberType = (typeof CHAT_ROOM_MEMBER_TYPES)[number];
53 |
54 | export interface ChatRoomMember {
55 | id: string;
56 | roomId: string;
57 | role: ChatRoomMemberRole;
58 | type: ChatRoomMemberType;
59 | name: string;
60 | email: string;
61 | image?: string | null;
62 | }
63 |
64 | export const createChatRoomMemberSchema = z.object({
65 | id: z.string().min(1),
66 | roomId: z.string().min(1),
67 | role: z.enum(CHAT_ROOM_MEMBER_ROLES),
68 | type: z.enum(CHAT_ROOM_MEMBER_TYPES),
69 | });
70 |
71 | export type CreateChatRoomMember = z.infer;
72 |
73 | // ChatRoom
74 | const CHAT_ROOM_TYPES = ["public"] as const; // TODO: Make everything private and add permissions
75 | export type ChatRoomType = (typeof CHAT_ROOM_TYPES)[number];
76 |
77 | export interface ChatRoom {
78 | id: string;
79 | name: string;
80 | type: ChatRoomType;
81 | organizationId: string;
82 | createdAt: number;
83 | }
84 |
85 | export const createChatRoomSchema = z.object({
86 | name: z.string().min(1),
87 | members: z.array(createChatRoomMemberSchema.omit({ roomId: true })),
88 | });
89 |
--------------------------------------------------------------------------------
/src/shared/types/helper.ts:
--------------------------------------------------------------------------------
1 | export type Versioned = {
2 | version: Version;
3 | type: TypeName;
4 | data: T;
5 | };
6 |
--------------------------------------------------------------------------------
/src/shared/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./agent";
2 | export * from "./chat";
3 | export * from "./chat-ws";
4 | export * from "./workflow";
5 |
--------------------------------------------------------------------------------
/src/shared/types/workflow.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import type { ChatRoomMember } from "./chat";
3 | import type { Versioned } from "./helper";
4 |
5 | export const workflowStepSchema = z.object({
6 | stepId: z.string().describe("Unique identifier within the workflow"),
7 | description: z
8 | .string()
9 | .describe(
10 | "Natural language instruction for this step (guides AI or tool execution)",
11 | ),
12 | toolName: z
13 | .string()
14 | .nullable()
15 | .describe(
16 | "Name of the specific tool to prioritize for this step (e.g., 'webSearch', 'deepResearch'). Null if AI should use internal capabilities or decide based on description.",
17 | ),
18 | inputFromSteps: z
19 | .array(z.string())
20 | .optional()
21 | .describe(
22 | "Optional: Array of stepIds whose outputs should be considered as input for this step",
23 | ),
24 | });
25 |
26 | type WorkflowStep = z.infer;
27 |
28 | export interface WorkflowSteps
29 | extends Versioned {}
30 |
31 | export interface WorkflowPartial {
32 | id: string;
33 | agentId: string;
34 | chatRoomId: string;
35 | goal: string;
36 | steps: WorkflowSteps;
37 | isRecurring: boolean;
38 | scheduleExpression: string;
39 | nextExecutionTime: number;
40 | lastExecutionTime: number | null;
41 | isActive: boolean;
42 | createdAt: number;
43 | updatedAt: number;
44 | }
45 |
46 | export interface Workflow extends WorkflowPartial {
47 | agent: ChatRoomMember;
48 | }
49 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 | "allowJs": true,
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true,
24 | "baseUrl": ".",
25 | "paths": {
26 | "@client/*": ["src/client/*"],
27 | "@shared/*": ["src/shared/*"],
28 | "@server/*": ["src/server/*"]
29 | }
30 | },
31 | "include": ["src/client"]
32 | }
33 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" },
6 | { "path": "./tsconfig.worker.json" }
7 | ],
8 | "compilerOptions": {
9 | "baseUrl": ".",
10 | "paths": {
11 | "@client/*": ["src/client/*"],
12 | "@shared/*": ["src/shared/*"],
13 | "@server/*": ["src/server/*"]
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/tsconfig.worker.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.node.json",
3 | "compilerOptions": {
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo",
5 | "types": ["@types/node", "vite/client", "./worker-configuration.d.ts"],
6 | "allowJs": true,
7 | "jsx": "react-jsx",
8 | "baseUrl": ".",
9 | "paths": {
10 | "@server/*": ["src/server/*"],
11 | "@shared/*": ["src/shared/*"]
12 | }
13 | },
14 | "include": ["src/server"]
15 | }
16 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import { cloudflare } from "@cloudflare/vite-plugin";
4 | import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
5 | import tailwindcss from "@tailwindcss/vite";
6 | import path from "node:path";
7 | import rawPlugin from "vite-raw-plugin";
8 |
9 | // https://vite.dev/config/
10 | export default defineConfig({
11 | plugins: [
12 | TanStackRouterVite({
13 | target: "react",
14 | autoCodeSplitting: true,
15 | routesDirectory: "src/client/routes",
16 | generatedRouteTree: "src/client/routeTree.gen.ts",
17 | }),
18 | react(),
19 | cloudflare(),
20 | tailwindcss(),
21 | rawPlugin({
22 | fileRegex: /\.sql$/,
23 | }),
24 | ],
25 | resolve: {
26 | alias: {
27 | "@client": path.resolve(__dirname, "./src/client"),
28 | "@shared": path.resolve(__dirname, "./src/shared"),
29 | "@server": path.resolve(__dirname, "./src/server"),
30 | },
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/wrangler.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "name": "chatsemble",
3 | "compatibility_date": "2025-02-04",
4 | "compatibility_flags": [
5 | "nodejs_compat",
6 | "nodejs_compat_populate_process_env"
7 | ],
8 | "main": "./src/server/index.ts",
9 | "assets": {
10 | "not_found_handling": "single-page-application",
11 | "binding": "ASSETS"
12 | },
13 | "observability": {
14 | "enabled": true
15 | },
16 | "rules": [
17 | {
18 | "type": "Text",
19 | "globs": ["**/*.sql"],
20 | "fallthrough": true
21 | }
22 | ],
23 | "migrations": [
24 | {
25 | "new_sqlite_classes": ["OrganizationDurableObject"],
26 | "tag": "v1"
27 | }
28 | ],
29 | "durable_objects": {
30 | "bindings": [
31 | {
32 | "name": "ORGANIZATION_DURABLE_OBJECT",
33 | "class_name": "OrganizationDurableObject"
34 | }
35 | ]
36 | },
37 | "d1_databases": [
38 | {
39 | "binding": "DB",
40 | "database_name": "chatsemble-db",
41 | "database_id": "2815d782-18dc-415f-8237-cba2361e3144",
42 | "migrations_dir": "src/server/db/migrations"
43 | }
44 | ]
45 | }
46 |
--------------------------------------------------------------------------------