├── packages
├── database
│ ├── .gitignore
│ ├── package.json
│ └── index.ts
└── ui
│ ├── .gitignore
│ └── package.json
├── pnpm-workspace.yaml
├── apps
├── local
│ ├── src
│ │ ├── app
│ │ │ ├── favicon.ico
│ │ │ ├── fonts
│ │ │ │ ├── GeistVF.woff
│ │ │ │ └── GeistMonoVF.woff
│ │ │ ├── page.tsx
│ │ │ ├── api
│ │ │ │ ├── chat
│ │ │ │ │ └── route.ts
│ │ │ │ └── conversations
│ │ │ │ │ └── route.ts
│ │ │ ├── c
│ │ │ │ └── [id]
│ │ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ └── globals.css
│ │ ├── lib
│ │ │ ├── prisma.ts
│ │ │ └── utils.ts
│ │ ├── components
│ │ │ ├── theme-provider.tsx
│ │ │ ├── ui
│ │ │ │ ├── textarea.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── theme-toggle.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── alert-dialog.tsx
│ │ │ │ └── select.tsx
│ │ │ ├── ChatSidebar.tsx
│ │ │ ├── icons
│ │ │ │ └── MoonPhaseIcon.tsx
│ │ │ ├── SettingsDialog.tsx
│ │ │ └── ChatInterface.tsx
│ │ └── contexts
│ │ │ └── SidebarContext.tsx
│ ├── prisma
│ │ ├── migrations
│ │ │ ├── migration_lock.toml
│ │ │ ├── 20241204213722_init
│ │ │ │ └── migration.sql
│ │ │ ├── 20250112071547_default_updated_at
│ │ │ │ └── migration.sql
│ │ │ ├── 20250110054210_add_cascade_delete
│ │ │ │ └── migration.sql
│ │ │ └── 20250112071445_adding_updated_at
│ │ │ │ └── migration.sql
│ │ └── schema.prisma
│ ├── next.config.ts
│ ├── postcss.config.mjs
│ ├── .gitignore
│ ├── components.json
│ ├── tsconfig.json
│ ├── package.json
│ └── tailwind.config.ts
└── web
│ ├── postcss.config.mjs
│ ├── public
│ └── images
│ │ ├── logo_dark.png
│ │ └── logo_light.png
│ ├── src
│ ├── app
│ │ ├── fonts
│ │ │ ├── GeistVF.woff
│ │ │ └── GeistMonoVF.woff
│ │ ├── page.tsx
│ │ ├── c
│ │ │ ├── page.tsx
│ │ │ └── [id]
│ │ │ │ └── page.tsx
│ │ ├── api
│ │ │ ├── chat
│ │ │ │ └── route.ts
│ │ │ └── conversations
│ │ │ │ └── route.ts
│ │ ├── globals.css
│ │ └── layout.tsx
│ ├── components
│ │ ├── ui
│ │ │ ├── theme-provider.tsx
│ │ │ ├── theme-toggle.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── input.tsx
│ │ │ ├── alert.tsx
│ │ │ ├── button.tsx
│ │ │ ├── drawer.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── alert-dialog.tsx
│ │ │ └── select.tsx
│ │ ├── ChatSidebar.tsx
│ │ ├── ChatDrawer.tsx
│ │ ├── icons
│ │ │ └── MoonPhaseIcon.tsx
│ │ ├── SettingsDialog.tsx
│ │ └── ChatInterface.tsx
│ ├── contexts
│ │ └── SidebarContext.tsx
│ └── lib
│ │ ├── utils.ts
│ │ └── indexeddb.ts
│ ├── jsconfig.json
│ ├── components.json
│ ├── .gitignore
│ ├── tsconfig.json
│ ├── package.json
│ └── tailwind.config.ts
├── turbo.json
├── package.json
├── tsconfig.json
├── .gitignore
└── README.md
/packages/database/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 |
--------------------------------------------------------------------------------
/packages/ui/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 | *.db-journal
3 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'apps/*'
3 | - 'packages/*'
--------------------------------------------------------------------------------
/apps/local/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mainframecomputer/fullmoon-web/HEAD/apps/local/src/app/favicon.ico
--------------------------------------------------------------------------------
/apps/web/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/public/images/logo_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mainframecomputer/fullmoon-web/HEAD/apps/web/public/images/logo_dark.png
--------------------------------------------------------------------------------
/apps/web/src/app/fonts/GeistVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mainframecomputer/fullmoon-web/HEAD/apps/web/src/app/fonts/GeistVF.woff
--------------------------------------------------------------------------------
/apps/local/src/app/fonts/GeistVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mainframecomputer/fullmoon-web/HEAD/apps/local/src/app/fonts/GeistVF.woff
--------------------------------------------------------------------------------
/apps/web/public/images/logo_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mainframecomputer/fullmoon-web/HEAD/apps/web/public/images/logo_light.png
--------------------------------------------------------------------------------
/apps/local/src/app/fonts/GeistMonoVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mainframecomputer/fullmoon-web/HEAD/apps/local/src/app/fonts/GeistMonoVF.woff
--------------------------------------------------------------------------------
/apps/web/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["./src/*"]
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/apps/web/src/app/fonts/GeistMonoVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mainframecomputer/fullmoon-web/HEAD/apps/web/src/app/fonts/GeistMonoVF.woff
--------------------------------------------------------------------------------
/apps/local/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (e.g., Git)
3 | provider = "sqlite"
--------------------------------------------------------------------------------
/apps/local/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/apps/local/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/packages/database/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@fullmoon/database",
3 | "version": "0.1.0",
4 | "private": true,
5 | "main": "./index.ts",
6 | "types": "./index.ts",
7 | "dependencies": {
8 | "zod": "^3.23.8"
9 | },
10 | "devDependencies": {
11 | "typescript": "^5"
12 | }
13 | }
--------------------------------------------------------------------------------
/apps/local/src/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | const globalForPrisma = global as unknown as { prisma: PrismaClient };
4 |
5 | export const prisma = globalForPrisma.prisma || new PrismaClient();
6 |
7 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
8 |
--------------------------------------------------------------------------------
/apps/local/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import ChatSidebar from "@/components/ChatSidebar";
2 | import ChatInterface from "@/components/ChatInterface";
3 |
4 | export default function Chat() {
5 | return (
6 |
7 |
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "globalDependencies": ["**/.env.*local"],
4 | "pipeline": {
5 | "build": {
6 | "dependsOn": ["^build"],
7 | "outputs": [".next/**", "!.next/cache/**"]
8 | },
9 | "lint": {},
10 | "dev": {
11 | "cache": false,
12 | "persistent": true
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/apps/web/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { ChatInterface } from "@/components/ChatInterface";
2 | import { ChatSidebar } from "@/components/ChatSidebar";
3 |
4 | export default function Chat() {
5 | return (
6 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/apps/web/src/app/c/page.tsx:
--------------------------------------------------------------------------------
1 | import { ChatInterface } from "@/components/ChatInterface";
2 | import { ChatSidebar } from "@/components/ChatSidebar";
3 |
4 | export default function ConversationsPage() {
5 | return (
6 |
12 | );
13 | }
--------------------------------------------------------------------------------
/apps/web/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/apps/web/src/components/ui/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import {
5 | ThemeProvider as NextThemesProvider,
6 | type ThemeProviderProps as NextThemeProviderProps,
7 | } from "next-themes";
8 |
9 | export function ThemeProvider({ children, ...props }: NextThemeProviderProps) {
10 | return {children};
11 | }
12 |
--------------------------------------------------------------------------------
/apps/local/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 | import type { ThemeProviderProps } from "next-themes";
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return (
9 |
10 | {children}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/apps/web/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 | /.pnp
4 | .pnp.js
5 |
6 | # testing
7 | /coverage
8 |
9 | # next.js
10 | /.next/
11 | /out/
12 | /build
13 | .next
14 |
15 | # misc
16 | .DS_Store
17 | *.pem
18 |
19 | # debug
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 | .pnpm-debug.log*
24 |
25 | # local env files
26 | .env*.local
27 | .env
28 |
29 | # vercel
30 | .vercel
31 |
32 | # typescript
33 | *.tsbuildinfo
34 | next-env.d.ts
35 |
36 | # Turbo
37 | .turbo
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fullmoon-www",
3 | "private": true,
4 | "scripts": {
5 | "build": "turbo build",
6 | "dev": "turbo dev",
7 | "lint": "turbo lint",
8 | "clean": "turbo clean",
9 | "format": "prettier --write \"**/*.{ts,tsx,md}\""
10 | },
11 | "devDependencies": {
12 | "@turbo/gen": "^1.11.3",
13 | "eslint": "^8.56.0",
14 | "prettier": "^3.1.1",
15 | "turbo": "^1.11.3"
16 | },
17 | "packageManager": "pnpm@8.9.0",
18 | "workspaces": [
19 | "apps/*",
20 | "packages/*"
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/apps/local/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 | /.pnp
4 | .pnp.js
5 |
6 | # testing
7 | /coverage
8 |
9 | # next.js
10 | /.next/
11 | /out/
12 | /build
13 | .next
14 |
15 | # misc
16 | .DS_Store
17 | *.pem
18 |
19 | # debug
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 | .pnpm-debug.log*
24 |
25 | # local env files
26 | .env*.local
27 | .env
28 |
29 | # vercel
30 | .vercel
31 |
32 | # typescript
33 | *.tsbuildinfo
34 | next-env.d.ts
35 |
36 | # SQLite database files
37 | *.db
38 | *.db-journal
39 |
40 | # Turbo
41 | .turbo/*
--------------------------------------------------------------------------------
/apps/local/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/apps/local/prisma/migrations/20241204213722_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "Conversation" (
3 | "id" TEXT NOT NULL PRIMARY KEY,
4 | "title" TEXT NOT NULL,
5 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
6 | );
7 |
8 | -- CreateTable
9 | CREATE TABLE "Message" (
10 | "id" TEXT NOT NULL PRIMARY KEY,
11 | "content" TEXT NOT NULL,
12 | "role" TEXT NOT NULL,
13 | "conversationId" TEXT NOT NULL,
14 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
15 | CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
16 | );
17 |
--------------------------------------------------------------------------------
/apps/local/prisma/migrations/20250112071547_default_updated_at/migration.sql:
--------------------------------------------------------------------------------
1 | -- RedefineTables
2 | PRAGMA defer_foreign_keys=ON;
3 | PRAGMA foreign_keys=OFF;
4 | CREATE TABLE "new_Conversation" (
5 | "id" TEXT NOT NULL PRIMARY KEY,
6 | "title" TEXT NOT NULL,
7 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
8 | "updatedAt" DATETIME NOT NULL
9 | );
10 | INSERT INTO "new_Conversation" ("createdAt", "id", "title", "updatedAt") SELECT "createdAt", "id", "title", "updatedAt" FROM "Conversation";
11 | DROP TABLE "Conversation";
12 | ALTER TABLE "new_Conversation" RENAME TO "Conversation";
13 | PRAGMA foreign_keys=ON;
14 | PRAGMA defer_foreign_keys=OFF;
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["**/*.ts", "**/*.tsx"],
26 | "exclude": ["node_modules"]
27 | }
--------------------------------------------------------------------------------
/apps/local/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "plugins": [{ "name": "next" }],
5 | "paths": {
6 | "@/*": ["./src/*"]
7 | },
8 | "jsx": "preserve",
9 | "jsxImportSource": "react",
10 | "types": ["react", "react-dom", "node"],
11 | "isolatedModules": true,
12 | "noImplicitAny": false,
13 | "allowJs": true,
14 | "esModuleInterop": true,
15 | "skipLibCheck": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "incremental": true
20 | },
21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22 | "exclude": ["node_modules"]
23 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 |
32 | # env files (can opt-in for committing if needed)
33 | .env
34 | .env.local
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
43 | # prisma
44 | dev.db
45 |
46 | # virtual environment
47 | .venv
48 | *.db-journal
49 |
50 | .turbo
51 |
--------------------------------------------------------------------------------
/apps/local/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | })
20 | Textarea.displayName = "Textarea"
21 |
22 | export { Textarea }
23 |
--------------------------------------------------------------------------------
/packages/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@fullmoon/ui",
3 | "version": "0.1.0",
4 | "private": true,
5 | "main": "./index.tsx",
6 | "types": "./index.tsx",
7 | "dependencies": {
8 | "@radix-ui/react-alert-dialog": "^1.1.4",
9 | "@radix-ui/react-dialog": "^1.1.4",
10 | "@radix-ui/react-select": "^2.1.4",
11 | "@radix-ui/react-slot": "^1.1.0",
12 | "class-variance-authority": "^0.7.1",
13 | "clsx": "^2.1.1",
14 | "lucide-react": "^0.464.0",
15 | "next-themes": "^0.4.3",
16 | "tailwind-merge": "^2.5.5",
17 | "tailwindcss-animate": "^1.0.7"
18 | },
19 | "devDependencies": {
20 | "@types/react": "^18",
21 | "typescript": "^5"
22 | },
23 | "peerDependencies": {
24 | "react": "19.0.0-rc-66855b96-20241106"
25 | }
26 | }
--------------------------------------------------------------------------------
/apps/web/src/components/ui/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { Moon, Sun } from "lucide-react";
5 | import { useTheme } from "next-themes";
6 |
7 | import { Button } from "@/components/ui/button";
8 |
9 | export function ThemeToggle() {
10 | const { setTheme, theme } = useTheme();
11 |
12 | return (
13 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | type TextareaProps = React.TextareaHTMLAttributes;
6 |
7 | const Textarea = React.forwardRef(
8 | ({ className, ...props }, ref) => {
9 | return (
10 |
18 | );
19 | }
20 | );
21 | Textarea.displayName = "Textarea";
22 |
23 | export { Textarea };
24 |
--------------------------------------------------------------------------------
/apps/local/prisma/migrations/20250110054210_add_cascade_delete/migration.sql:
--------------------------------------------------------------------------------
1 | -- RedefineTables
2 | PRAGMA defer_foreign_keys=ON;
3 | PRAGMA foreign_keys=OFF;
4 | CREATE TABLE "new_Message" (
5 | "id" TEXT NOT NULL PRIMARY KEY,
6 | "content" TEXT NOT NULL,
7 | "role" TEXT NOT NULL,
8 | "conversationId" TEXT NOT NULL,
9 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
10 | CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation" ("id") ON DELETE CASCADE ON UPDATE CASCADE
11 | );
12 | INSERT INTO "new_Message" ("content", "conversationId", "createdAt", "id", "role") SELECT "content", "conversationId", "createdAt", "id", "role" FROM "Message";
13 | DROP TABLE "Message";
14 | ALTER TABLE "new_Message" RENAME TO "Message";
15 | PRAGMA foreign_keys=ON;
16 | PRAGMA defer_foreign_keys=OFF;
17 |
--------------------------------------------------------------------------------
/apps/local/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/apps/local/src/components/ui/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { Moon, Sun } from "lucide-react";
2 | import { useTheme } from "next-themes";
3 |
4 | export function ThemeToggle() {
5 | const { setTheme, theme } = useTheme();
6 |
7 | return (
8 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | type InputProps = React.InputHTMLAttributes;
6 |
7 | const Input = React.forwardRef(
8 | ({ className, type, ...props }, ref) => {
9 | return (
10 |
19 | );
20 | }
21 | );
22 | Input.displayName = "Input";
23 |
24 | export { Input };
25 |
--------------------------------------------------------------------------------
/apps/local/prisma/migrations/20250112071445_adding_updated_at/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - Added the required column `updatedAt` to the `Conversation` table without a default value. This is not possible if the table is not empty.
5 |
6 | */
7 | -- RedefineTables
8 | PRAGMA defer_foreign_keys=ON;
9 | PRAGMA foreign_keys=OFF;
10 | CREATE TABLE "new_Conversation" (
11 | "id" TEXT NOT NULL PRIMARY KEY,
12 | "title" TEXT NOT NULL,
13 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
14 | "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
15 | );
16 | INSERT INTO "new_Conversation" ("createdAt", "id", "title", "updatedAt")
17 | SELECT "createdAt", "id", "title", "createdAt" FROM "Conversation";
18 | DROP TABLE "Conversation";
19 | ALTER TABLE "new_Conversation" RENAME TO "Conversation";
20 | PRAGMA foreign_keys=ON;
21 | PRAGMA defer_foreign_keys=OFF;
22 |
23 | -- CreateIndex
24 | CREATE INDEX "Message_conversationId_idx" ON "Message"("conversationId");
25 |
--------------------------------------------------------------------------------
/apps/local/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | }
10 |
11 | datasource db {
12 | provider = "sqlite"
13 | url = "file:./dev.db"
14 | }
15 |
16 | model Conversation {
17 | id String @id @default(uuid())
18 | title String
19 | createdAt DateTime @default(now())
20 | updatedAt DateTime @updatedAt
21 | messages Message[]
22 | }
23 |
24 | model Message {
25 | id String @id @default(uuid())
26 | content String
27 | role String
28 | conversationId String
29 | createdAt DateTime @default(now())
30 | conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
31 |
32 | @@index([conversationId])
33 | }
34 |
--------------------------------------------------------------------------------
/apps/local/src/contexts/SidebarContext.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { createContext, useContext, useState } from "react";
4 | import type { ReactNode } from "react";
5 |
6 | interface SidebarContextType {
7 | isSidebarOpen: boolean;
8 | setIsSidebarOpen: (isOpen: boolean) => void;
9 | toggleSidebar: () => void;
10 | }
11 |
12 | const SidebarContext = createContext(undefined);
13 |
14 | export function SidebarProvider({ children }: { children: ReactNode }) {
15 | const [isSidebarOpen, setIsSidebarOpen] = useState(false);
16 |
17 | const toggleSidebar = () => setIsSidebarOpen((prev) => !prev);
18 |
19 | return (
20 |
23 | {children}
24 |
25 | );
26 | }
27 |
28 | export function useSidebar() {
29 | const context = useContext(SidebarContext);
30 | if (context === undefined) {
31 | throw new Error("useSidebar must be used within a SidebarProvider");
32 | }
33 | return context;
34 | }
35 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/chat/route.ts:
--------------------------------------------------------------------------------
1 | import { createOpenAI } from "@ai-sdk/openai";
2 | import { streamText } from "ai";
3 |
4 | export async function POST(req: Request) {
5 | const { messages, conversationId, customEndpointSettings } = await req.json();
6 | console.log("Conversation ID:", conversationId);
7 | const userMessage = messages[messages.length - 1];
8 | console.log("User Message:", userMessage);
9 | console.log("Custom Endpoint Settings:", customEndpointSettings);
10 |
11 | const baseURL = customEndpointSettings?.endpoint || process.env.LLM_BASE_URL;
12 |
13 | const openaiConfig = {
14 | baseURL,
15 | apiKey: customEndpointSettings?.apiKey,
16 | };
17 |
18 | const openai = createOpenAI(openaiConfig);
19 |
20 | try {
21 | const result = streamText({
22 | model: openai(customEndpointSettings?.modelName || "llama3.2:1b"),
23 | messages,
24 | maxSteps: 5,
25 | });
26 | return result.toDataStreamResponse();
27 | } catch (error) {
28 | console.error("Error:", error);
29 | return new Response("Error", { status: 500 });
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/apps/web/src/contexts/SidebarContext.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type * as React from "react";
4 | import { createContext, useContext, useState } from "react";
5 |
6 | interface SidebarContextType {
7 | isSidebarOpen: boolean;
8 | toggleSidebar: () => void;
9 | setIsSidebarOpen: (isOpen: boolean) => void;
10 | }
11 |
12 | const SidebarContext = createContext(undefined);
13 |
14 | export function SidebarProvider({ children }: { children: React.ReactNode }) {
15 | const [isSidebarOpen, setIsSidebarOpen] = useState(false);
16 |
17 | const toggleSidebar = () => {
18 | setIsSidebarOpen(!isSidebarOpen);
19 | };
20 |
21 | return (
22 |
25 | {children}
26 |
27 | );
28 | }
29 |
30 | export function useSidebar() {
31 | const context = useContext(SidebarContext);
32 | if (context === undefined) {
33 | throw new Error("useSidebar must be used within a SidebarProvider");
34 | }
35 | return context;
36 | }
37 |
--------------------------------------------------------------------------------
/packages/database/index.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | export const MessageSchema = z.object({
4 | id: z.string(),
5 | content: z.string(),
6 | role: z.enum(['user', 'assistant']),
7 | conversationId: z.string(),
8 | createdAt: z.date(),
9 | });
10 |
11 | export const ConversationSchema = z.object({
12 | id: z.string(),
13 | title: z.string(),
14 | createdAt: z.date(),
15 | updatedAt: z.date(),
16 | });
17 |
18 | export type Message = z.infer;
19 | export type Conversation = z.infer;
20 |
21 | export interface DatabaseAdapter {
22 | // Conversation methods
23 | createConversation(conversation: Omit): Promise;
24 | getConversation(id: string): Promise;
25 | listConversations(): Promise;
26 | updateConversation(id: string, data: Partial): Promise;
27 | deleteConversation(id: string): Promise;
28 |
29 | // Message methods
30 | createMessage(message: Omit): Promise;
31 | getMessages(conversationId: string): Promise;
32 | deleteMessage(id: string): Promise;
33 | }
--------------------------------------------------------------------------------
/apps/local/src/app/api/chat/route.ts:
--------------------------------------------------------------------------------
1 | import { createOpenAI } from "@ai-sdk/openai";
2 | import { streamText } from "ai";
3 | import { prisma } from "@/lib/prisma";
4 |
5 | const openai = createOpenAI({
6 | baseURL: process.env.LLM_BASE_URL,
7 | });
8 |
9 | export async function POST(req: Request) {
10 | const { messages, conversationId } = await req.json();
11 | console.log("Conversation ID:", conversationId);
12 | const userMessage = messages[messages.length - 1];
13 | console.log("User Message:", userMessage);
14 |
15 | if (conversationId) {
16 | await prisma.message.create({
17 | data: {
18 | content: userMessage.content,
19 | role: "user",
20 | conversationId,
21 | },
22 | });
23 | }
24 |
25 | const result = streamText({
26 | model: openai("mlx-community/Llama-3.2-3B-Instruct-4bit"),
27 | messages,
28 | maxSteps: 5,
29 | async onFinish({ text }) {
30 | if (conversationId) {
31 | await prisma.message.create({
32 | data: {
33 | content: text,
34 | role: "assistant",
35 | conversationId,
36 | },
37 | });
38 | }
39 | },
40 | });
41 |
42 | return result.toDataStreamResponse();
43 | }
44 |
--------------------------------------------------------------------------------
/apps/local/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "local",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@ai-sdk/openai": "^1.0.6",
13 | "@fullmoon/database": "workspace:*",
14 | "@fullmoon/ui": "workspace:*",
15 | "@prisma/client": "^6.0.1",
16 | "@radix-ui/react-alert-dialog": "^1.1.4",
17 | "@radix-ui/react-dialog": "^1.1.4",
18 | "@radix-ui/react-select": "^2.1.4",
19 | "@radix-ui/react-slot": "^1.1.0",
20 | "ai": "^4.0.11",
21 | "class-variance-authority": "^0.7.1",
22 | "clsx": "^2.1.1",
23 | "lucide-react": "^0.464.0",
24 | "next": "15.0.3",
25 | "next-themes": "^0.4.3",
26 | "react": "19.0.0-rc-66855b96-20241106",
27 | "react-dom": "19.0.0-rc-66855b96-20241106",
28 | "tailwind-merge": "^2.5.5",
29 | "tailwindcss-animate": "^1.0.7",
30 | "zod": "^3.23.8"
31 | },
32 | "devDependencies": {
33 | "@types/node": "^20",
34 | "@types/react": "^18",
35 | "@types/react-dom": "^18",
36 | "eslint": "^8",
37 | "eslint-config-next": "15.0.3",
38 | "postcss": "^8",
39 | "prisma": "^6.0.1",
40 | "tailwindcss": "^3.4.1",
41 | "typescript": "^5"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/apps/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@ai-sdk/openai": "^1.0.6",
13 | "@fullmoon/database": "workspace:*",
14 | "@fullmoon/ui": "workspace:*",
15 | "@radix-ui/react-alert-dialog": "^1.1.4",
16 | "@radix-ui/react-dialog": "^1.1.4",
17 | "@radix-ui/react-icons": "^1.3.2",
18 | "@radix-ui/react-select": "^2.1.4",
19 | "@radix-ui/react-slot": "^1.1.0",
20 | "ai": "^4.0.11",
21 | "class-variance-authority": "^0.7.1",
22 | "clsx": "^2.1.1",
23 | "idb": "^8.0.0",
24 | "lucide-react": "^0.464.0",
25 | "next": "15.0.3",
26 | "next-themes": "^0.4.3",
27 | "react": "19.0.0-rc-66855b96-20241106",
28 | "react-dom": "19.0.0-rc-66855b96-20241106",
29 | "react-pdftotext": "^1.3.4",
30 | "tailwind-merge": "^2.5.5",
31 | "tailwindcss-animate": "^1.0.7",
32 | "uuid": "^9.0.1",
33 | "vaul": "^1.1.2",
34 | "zod": "^3.23.8"
35 | },
36 | "devDependencies": {
37 | "@types/node": "^20",
38 | "@types/react": "^18",
39 | "@types/react-dom": "^18",
40 | "@types/uuid": "^9.0.7",
41 | "autoprefixer": "^10.0.1",
42 | "eslint": "^8",
43 | "eslint-config-next": "15.0.3",
44 | "postcss": "^8",
45 | "tailwindcss": "^3.4.1",
46 | "typescript": "^5"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/apps/local/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export function getMoonPhase(): string {
9 | // Get current date
10 | const currentDate = new Date();
11 |
12 | // Define base date (known new moon date)
13 | const baseDate = new Date(2000, 0, 6); // Note: months are 0-based in JavaScript
14 |
15 | // Calculate days since base date
16 | const daysSinceBaseDate =
17 | (currentDate.getTime() - baseDate.getTime()) / (1000 * 60 * 60 * 24);
18 |
19 | // Moon phase repeats approximately every 29.53 days
20 | const moonCycleLength = 29.53;
21 | const daysIntoCycle = daysSinceBaseDate % moonCycleLength;
22 |
23 | // Determine the phase based on how far into the cycle we are
24 | if (daysIntoCycle < 1.8457) {
25 | return "new";
26 | }
27 | if (daysIntoCycle < 5.536) {
28 | return "waxing-crescent";
29 | }
30 | if (daysIntoCycle < 9.228) {
31 | return "first-quarter";
32 | }
33 | if (daysIntoCycle < 12.919) {
34 | return "waxing-gibbous";
35 | }
36 | if (daysIntoCycle < 16.61) {
37 | return "full";
38 | }
39 | if (daysIntoCycle < 20.302) {
40 | return "waning-gibbous";
41 | }
42 | if (daysIntoCycle < 23.993) {
43 | return "last-quarter";
44 | }
45 | if (daysIntoCycle < 27.684) {
46 | return "waning-crescent";
47 | }
48 | return "new";
49 | }
50 |
--------------------------------------------------------------------------------
/apps/local/src/app/c/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { redirect } from "next/navigation";
3 | import { prisma } from "@/lib/prisma";
4 | import ChatSidebar from "@/components/ChatSidebar";
5 | import ChatInterface from "@/components/ChatInterface";
6 | import type { Message } from "ai";
7 |
8 | interface Conversation {
9 | id: string;
10 | title: string;
11 | messages: Message[];
12 | }
13 |
14 | // Client wrapper component
15 | const ClientWrapper = ({ conversation }: { conversation: Conversation }) => {
16 | "use client";
17 |
18 | return (
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | // @ts-ignore
27 | export default async function ConversationPage({ params }) {
28 | const { id } = await params; // https://nextjs.org/docs/messages/sync-dynamic-apis
29 | const dbConversation = await prisma.conversation.findUnique({
30 | where: { id },
31 | include: {
32 | messages: {
33 | orderBy: { createdAt: "asc" },
34 | },
35 | },
36 | });
37 |
38 | if (!dbConversation) {
39 | redirect("/");
40 | }
41 |
42 | // Map database messages to AI Message type
43 | const conversation: Conversation = {
44 | id: dbConversation.id,
45 | title: dbConversation.title,
46 | messages: dbConversation.messages.map((msg) => ({
47 | id: msg.id,
48 | content: msg.content,
49 | role: msg.role as Message["role"],
50 | })),
51 | };
52 |
53 | return ;
54 | }
55 |
--------------------------------------------------------------------------------
/apps/web/src/app/c/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import { useParams } from "next/navigation";
5 | import { ChatInterface } from "@/components/ChatInterface";
6 | import { ChatSidebar } from "@/components/ChatSidebar";
7 | import { IndexedDBAdapter } from "@/lib/indexeddb";
8 | import type { Conversation } from "@fullmoon/database";
9 | import type { Message as AiMessage } from "ai";
10 | import { redirect } from "next/navigation";
11 |
12 | const db = new IndexedDBAdapter();
13 |
14 | interface ConversationWithMessages extends Conversation {
15 | messages: AiMessage[];
16 | }
17 |
18 | export default function ConversationPage() {
19 | const params = useParams();
20 | const conversationId = params.id as string;
21 | const [conversation, setConversation] = useState<
22 | ConversationWithMessages | undefined
23 | >();
24 |
25 | useEffect(() => {
26 | if (!conversationId) return;
27 | const fetchConversation = async () => {
28 | const conv = await db.getConversation(conversationId);
29 | if (conv) {
30 | const messages = await db.getMessages(conversationId);
31 | // convert messages to AI Message type
32 | const aiMessages: AiMessage[] = messages.map((msg) => ({
33 | id: msg.id,
34 | content: msg.content,
35 | role: msg.role,
36 | }));
37 | setConversation({ ...conv, messages: aiMessages });
38 | } else {
39 | redirect("/");
40 | }
41 | };
42 | fetchConversation();
43 | }, [conversationId]);
44 |
45 | return (
46 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/apps/web/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 240 10% 3.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 240 10% 3.9%;
15 |
16 | --primary: 240 5.9% 10%;
17 | --primary-foreground: 0 0% 98%;
18 |
19 | --secondary: 240 4.8% 95.9%;
20 | --secondary-foreground: 240 5.9% 10%;
21 |
22 | --muted: 240 4.8% 95.9%;
23 | --muted-foreground: 240 3.8% 46.1%;
24 |
25 | --accent: 240 4.8% 95.9%;
26 | --accent-foreground: 240 5.9% 10%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 0 0% 98%;
30 |
31 | --border: 240 5.9% 90%;
32 | --input: 240 5.9% 90%;
33 | --ring: 240 5.9% 10%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 240 10% 3.9%;
40 | --foreground: 0 0% 98%;
41 |
42 | --card: 240 10% 3.9%;
43 | --card-foreground: 0 0% 98%;
44 |
45 | --popover: 240 10% 3.9%;
46 | --popover-foreground: 0 0% 98%;
47 |
48 | --primary: 0 0% 98%;
49 | --primary-foreground: 240 5.9% 10%;
50 |
51 | --secondary: 240 3.7% 15.9%;
52 | --secondary-foreground: 0 0% 98%;
53 |
54 | --muted: 240 3.7% 15.9%;
55 | --muted-foreground: 240 5% 64.9%;
56 |
57 | --accent: 240 3.7% 15.9%;
58 | --accent-foreground: 0 0% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 0 0% 98%;
62 |
63 | --border: 240 3.7% 15.9%;
64 | --input: 240 3.7% 15.9%;
65 | --ring: 240 4.9% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/apps/local/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import localFont from "next/font/local";
3 | import "./globals.css";
4 | import { ThemeProvider } from "@/components/theme-provider";
5 | import { SidebarProvider } from "@/contexts/SidebarContext";
6 |
7 | const geistSans = localFont({
8 | src: "./fonts/GeistVF.woff",
9 | variable: "--font-geist-sans",
10 | weight: "100 900",
11 | });
12 | const geistMono = localFont({
13 | src: "./fonts/GeistMonoVF.woff",
14 | variable: "--font-geist-mono",
15 | weight: "100 900",
16 | });
17 |
18 | export const metadata: Metadata = {
19 | title: "fullmoon: local intelligence",
20 | description: "chat with private and local large language models",
21 | metadataBase: new URL("https://fullmoon.app"),
22 | openGraph: {
23 | title: "fullmoon: local intelligence",
24 | description: "chat with private and local large language models",
25 | url: "https://fullmoon.app",
26 | type: "website",
27 | images: ["https://fullmoon.app/images/og.png"],
28 | },
29 | twitter: {
30 | card: "summary_large_image",
31 | title: "fullmoon: local intelligence",
32 | description: "chat with private and local large language models",
33 | images: ["https://fullmoon.app/images/og.png"],
34 | },
35 | };
36 |
37 | export default function RootLayout({
38 | children,
39 | }: Readonly<{
40 | children: React.ReactNode;
41 | }>) {
42 | return (
43 |
44 |
47 |
53 | {children}
54 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/apps/web/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import type { ReactNode } from "react";
3 | import localFont from "next/font/local";
4 | import "./globals.css";
5 | import { ThemeProvider } from "@/components/ui/theme-provider";
6 | import { SidebarProvider } from "@/contexts/SidebarContext";
7 |
8 | const geistSans = localFont({
9 | src: "./fonts/GeistVF.woff",
10 | variable: "--font-geist-sans",
11 | weight: "100 900",
12 | });
13 |
14 | const geistMono = localFont({
15 | src: "./fonts/GeistMonoVF.woff",
16 | variable: "--font-geist-mono",
17 | weight: "100 900",
18 | });
19 |
20 | export const metadata: Metadata = {
21 | title: "fullmoon: local intelligence",
22 | description: "chat with private and local large language models",
23 | metadataBase: new URL("https://fullmoon.app"),
24 | openGraph: {
25 | title: "fullmoon: local intelligence",
26 | description: "chat with private and local large language models",
27 | url: "https://fullmoon.app",
28 | type: "website",
29 | images: ["https://fullmoon.app/images/og.png"],
30 | },
31 | twitter: {
32 | card: "summary_large_image",
33 | title: "fullmoon: local intelligence",
34 | description: "chat with private and local large language models",
35 | images: ["https://fullmoon.app/images/og.png"],
36 | },
37 | };
38 |
39 | export default function RootLayout({ children }: { children: ReactNode }) {
40 | return (
41 |
42 |
45 |
51 | {children}
52 |
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/apps/local/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | darkMode: ["class"],
5 | content: [
6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | colors: {
13 | background: 'hsl(var(--background))',
14 | foreground: 'hsl(var(--foreground))',
15 | card: {
16 | DEFAULT: 'hsl(var(--card))',
17 | foreground: 'hsl(var(--card-foreground))'
18 | },
19 | popover: {
20 | DEFAULT: 'hsl(var(--popover))',
21 | foreground: 'hsl(var(--popover-foreground))'
22 | },
23 | primary: {
24 | DEFAULT: 'hsl(var(--primary))',
25 | foreground: 'hsl(var(--primary-foreground))'
26 | },
27 | secondary: {
28 | DEFAULT: 'hsl(var(--secondary))',
29 | foreground: 'hsl(var(--secondary-foreground))'
30 | },
31 | muted: {
32 | DEFAULT: 'hsl(var(--muted))',
33 | foreground: 'hsl(var(--muted-foreground))'
34 | },
35 | accent: {
36 | DEFAULT: 'hsl(var(--accent))',
37 | foreground: 'hsl(var(--accent-foreground))'
38 | },
39 | destructive: {
40 | DEFAULT: 'hsl(var(--destructive))',
41 | foreground: 'hsl(var(--destructive-foreground))'
42 | },
43 | border: 'hsl(var(--border))',
44 | input: 'hsl(var(--input))',
45 | ring: 'hsl(var(--ring))',
46 | chart: {
47 | '1': 'hsl(var(--chart-1))',
48 | '2': 'hsl(var(--chart-2))',
49 | '3': 'hsl(var(--chart-3))',
50 | '4': 'hsl(var(--chart-4))',
51 | '5': 'hsl(var(--chart-5))'
52 | }
53 | },
54 | borderRadius: {
55 | lg: 'var(--radius)',
56 | md: 'calc(var(--radius) - 2px)',
57 | sm: 'calc(var(--radius) - 4px)'
58 | }
59 | }
60 | },
61 | plugins: [require("tailwindcss-animate")],
62 | } satisfies Config;
63 |
--------------------------------------------------------------------------------
/apps/local/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | font-family: Arial, Helvetica, sans-serif;
7 | }
8 |
9 | @layer base {
10 | :root {
11 | --background: 0 0% 100%;
12 | --foreground: 0 0% 3.9%;
13 | --card: 0 0% 100%;
14 | --card-foreground: 0 0% 3.9%;
15 | --popover: 0 0% 100%;
16 | --popover-foreground: 0 0% 3.9%;
17 | --primary: 0 0% 9%;
18 | --primary-foreground: 0 0% 98%;
19 | --secondary: 0 0% 96.1%;
20 | --secondary-foreground: 0 0% 9%;
21 | --muted: 0 0% 96.1%;
22 | --muted-foreground: 0 0% 45.1%;
23 | --accent: 0 0% 96.1%;
24 | --accent-foreground: 0 0% 9%;
25 | --destructive: 0 84.2% 60.2%;
26 | --destructive-foreground: 0 0% 98%;
27 | --border: 0 0% 89.8%;
28 | --input: 0 0% 89.8%;
29 | --ring: 0 0% 3.9%;
30 | --chart-1: 12 76% 61%;
31 | --chart-2: 173 58% 39%;
32 | --chart-3: 197 37% 24%;
33 | --chart-4: 43 74% 66%;
34 | --chart-5: 27 87% 67%;
35 | --radius: 0.5rem;
36 | }
37 | .dark {
38 | --background: 0 0% 3.9%;
39 | --foreground: 0 0% 98%;
40 | --card: 0 0% 3.9%;
41 | --card-foreground: 0 0% 98%;
42 | --popover: 0 0% 3.9%;
43 | --popover-foreground: 0 0% 98%;
44 | --primary: 0 0% 98%;
45 | --primary-foreground: 0 0% 9%;
46 | --secondary: 0 0% 14.9%;
47 | --secondary-foreground: 0 0% 98%;
48 | --muted: 0 0% 14.9%;
49 | --muted-foreground: 0 0% 63.9%;
50 | --accent: 0 0% 14.9%;
51 | --accent-foreground: 0 0% 98%;
52 | --destructive: 0 62.8% 30.6%;
53 | --destructive-foreground: 0 0% 98%;
54 | --border: 0 0% 14.9%;
55 | --input: 0 0% 14.9%;
56 | --ring: 0 0% 83.1%;
57 | --chart-1: 220 70% 50%;
58 | --chart-2: 160 60% 45%;
59 | --chart-3: 30 80% 55%;
60 | --chart-4: 280 65% 60%;
61 | --chart-5: 340 75% 55%;
62 | }
63 | }
64 |
65 | @layer base {
66 | * {
67 | @apply border-border;
68 | }
69 | body {
70 | @apply bg-background text-foreground;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/apps/local/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/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-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/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-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button";
46 | return (
47 |
52 | );
53 | }
54 | );
55 | Button.displayName = "Button";
56 |
57 | export { Button, buttonVariants };
58 |
--------------------------------------------------------------------------------
/apps/web/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { MOON_PHASES } from "@/components/icons/MoonPhaseIcon";
2 | import { type ClassValue, clsx } from "clsx";
3 | import { twMerge } from "tailwind-merge";
4 |
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs));
7 | }
8 |
9 | export function getMoonPhase(): string {
10 | // Get current date
11 | const currentDate = new Date();
12 |
13 | // Define base date (known new moon date)
14 | const baseDate = new Date(2000, 0, 6); // Note: months are 0-based in JavaScript
15 |
16 | // Calculate days since base date
17 | const daysSinceBaseDate =
18 | (currentDate.getTime() - baseDate.getTime()) / (1000 * 60 * 60 * 24);
19 |
20 | // Moon phase repeats approximately every 29.53 days
21 | const moonCycleLength = 29.53;
22 | const daysIntoCycle = daysSinceBaseDate % moonCycleLength;
23 |
24 | // Determine the phase based on how far into the cycle we are
25 | if (daysIntoCycle < 1.8457) {
26 | return "new";
27 | }
28 | if (daysIntoCycle < 5.536) {
29 | return "waxing-crescent";
30 | }
31 | if (daysIntoCycle < 9.228) {
32 | return "first-quarter";
33 | }
34 | if (daysIntoCycle < 12.919) {
35 | return "waxing-gibbous";
36 | }
37 | if (daysIntoCycle < 16.61) {
38 | return "full";
39 | }
40 | if (daysIntoCycle < 20.302) {
41 | return "waning-gibbous";
42 | }
43 | if (daysIntoCycle < 23.993) {
44 | return "last-quarter";
45 | }
46 | if (daysIntoCycle < 27.684) {
47 | return "waning-crescent";
48 | }
49 | return "new";
50 | }
51 |
52 | export const getCurrentMoonPhase = () => {
53 | const phase = getMoonPhase();
54 | switch (phase) {
55 | case "new":
56 | return MOON_PHASES.NEW;
57 | case "waxing-crescent":
58 | return MOON_PHASES.WAXING_CRESCENT;
59 | case "first-quarter":
60 | return MOON_PHASES.FIRST_QUARTER;
61 | case "waxing-gibbous":
62 | return MOON_PHASES.WAXING_GIBBOUS;
63 | case "full":
64 | return MOON_PHASES.FULL;
65 | case "waning-gibbous":
66 | return MOON_PHASES.WANING_GIBBOUS;
67 | case "last-quarter":
68 | return MOON_PHASES.LAST_QUARTER;
69 | case "waning-crescent":
70 | return MOON_PHASES.WANING_CRESCENT;
71 | default:
72 | return MOON_PHASES.NEW;
73 | }
74 | };
75 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/conversations/route.ts:
--------------------------------------------------------------------------------
1 | import { IndexedDBAdapter } from "@/lib/indexeddb";
2 | import { NextResponse } from "next/server";
3 |
4 | const db = new IndexedDBAdapter();
5 |
6 | export async function GET(request: Request) {
7 | const { searchParams } = new URL(request.url);
8 | const id = searchParams.get("id");
9 |
10 | if (id) {
11 | // Fetch a single conversation
12 | const conversation = await db.getConversation(id);
13 | if (!conversation) {
14 | return NextResponse.json({ error: "Conversation not found" }, { status: 404 });
15 | }
16 |
17 | // Fetch messages for the conversation
18 | const messages = await db.getMessages(id);
19 | return NextResponse.json({ ...conversation, messages });
20 | }
21 |
22 | // Fetch all conversations
23 | const conversations = await db.listConversations();
24 | return NextResponse.json(conversations);
25 | }
26 |
27 | export async function POST(request: Request) {
28 | const { message } = await request.json();
29 | const title = message.content.trim().slice(0, 80);
30 |
31 | const conversation = await db.createConversation({
32 | title,
33 | createdAt: new Date(),
34 | updatedAt: new Date(),
35 | });
36 |
37 | return NextResponse.json(conversation);
38 | }
39 |
40 | export async function DELETE(req: Request) {
41 | try {
42 | const { searchParams } = new URL(req.url);
43 | const id = searchParams.get("id");
44 | const deleteAll = searchParams.get("deleteAll");
45 |
46 | if (deleteAll === "true") {
47 | // Delete all conversations
48 | const conversations = await db.listConversations();
49 | await Promise.all(conversations.map(conv => db.deleteConversation(conv.id)));
50 | return NextResponse.json({ success: true });
51 | }
52 |
53 | if (!id) {
54 | return NextResponse.json(
55 | { error: "Conversation ID is required" },
56 | { status: 400 }
57 | );
58 | }
59 |
60 | // Delete single conversation
61 | await db.deleteConversation(id);
62 | return NextResponse.json({ success: true });
63 | } catch (error) {
64 | console.error("Delete error:", error);
65 | return NextResponse.json(
66 | { error: "Failed to delete conversation" },
67 | { status: 500 }
68 | );
69 | }
70 | }
--------------------------------------------------------------------------------
/apps/local/src/app/api/conversations/route.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from "@/lib/prisma";
2 | import { NextResponse } from "next/server";
3 |
4 | export async function GET(request: Request) {
5 | const { searchParams } = new URL(request.url);
6 | const id = searchParams.get("id");
7 |
8 | if (id) {
9 | // Fetch a single conversation with all messages
10 | const conversation = await prisma.conversation.findUnique({
11 | where: { id },
12 | include: {
13 | messages: {
14 | orderBy: { createdAt: "asc" },
15 | },
16 | },
17 | });
18 |
19 | return conversation
20 | ? NextResponse.json(conversation)
21 | : NextResponse.json({ error: "Conversation not found" }, { status: 404 });
22 | }
23 | // Fetch all conversations without messages for efficiency
24 | const conversations = await prisma.conversation.findMany({
25 | orderBy: { createdAt: "desc" },
26 | select: {
27 | id: true,
28 | title: true,
29 | createdAt: true,
30 | },
31 | });
32 |
33 | return NextResponse.json(conversations);
34 | }
35 |
36 | export async function POST(request: Request) {
37 | const { message } = await request.json();
38 | const title = message.content.trim().slice(0, 80);
39 |
40 | const conversation = await prisma.conversation.create({
41 | data: {
42 | title,
43 | },
44 | });
45 |
46 | return NextResponse.json(conversation);
47 | }
48 |
49 | export async function DELETE(req: Request) {
50 | try {
51 | const { searchParams } = new URL(req.url);
52 | const id = searchParams.get("id");
53 | const deleteAll = searchParams.get("deleteAll");
54 |
55 | if (deleteAll === "true") {
56 | // Delete all conversations (cascade delete will handle messages)
57 | await prisma.conversation.deleteMany();
58 | return NextResponse.json({ success: true });
59 | }
60 |
61 | if (!id) {
62 | return NextResponse.json(
63 | { error: "Conversation ID is required" },
64 | { status: 400 }
65 | );
66 | }
67 |
68 | // Delete single conversation - messages will be automatically deleted due to onDelete: Cascade
69 | await prisma.conversation.delete({
70 | where: { id },
71 | });
72 |
73 | return NextResponse.json({ success: true });
74 | } catch (error) {
75 | console.error("Delete error:", error);
76 | return NextResponse.json(
77 | { error: "Failed to delete conversation" },
78 | { status: 500 }
79 | );
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/apps/web/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | primary: {
26 | DEFAULT: "hsl(var(--primary))",
27 | foreground: "hsl(var(--primary-foreground))",
28 | },
29 | secondary: {
30 | DEFAULT: "hsl(var(--secondary))",
31 | foreground: "hsl(var(--secondary-foreground))",
32 | },
33 | destructive: {
34 | DEFAULT: "hsl(var(--destructive))",
35 | foreground: "hsl(var(--destructive-foreground))",
36 | },
37 | muted: {
38 | DEFAULT: "hsl(var(--muted))",
39 | foreground: "hsl(var(--muted-foreground))",
40 | },
41 | accent: {
42 | DEFAULT: "hsl(var(--accent))",
43 | foreground: "hsl(var(--accent-foreground))",
44 | },
45 | popover: {
46 | DEFAULT: "hsl(var(--popover))",
47 | foreground: "hsl(var(--popover-foreground))",
48 | },
49 | card: {
50 | DEFAULT: "hsl(var(--card))",
51 | foreground: "hsl(var(--card-foreground))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | "accordion-down": {
61 | from: { height: 0 },
62 | to: { height: "var(--radix-accordion-content-height)" },
63 | },
64 | "accordion-up": {
65 | from: { height: "var(--radix-accordion-content-height)" },
66 | to: { height: 0 },
67 | },
68 | "zoom-in": {
69 | "0%": { transform: "scale(0)" },
70 | "100%": { transform: "scale(1)" },
71 | },
72 | },
73 | animation: {
74 | "accordion-down": "accordion-down 0.2s ease-out",
75 | "accordion-up": "accordion-up 0.2s ease-out",
76 | "zoom-in": "zoom-in 0.3s ease-out",
77 | },
78 | },
79 | },
80 | plugins: [require("tailwindcss-animate")],
81 | };
82 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # fullmoon web
2 |
3 | ### chat with private and local large language models.
4 |
5 | ## quick start: using fullmoon with your local llm
6 |
7 | ### step 1: download a local model
8 |
9 | you have two main options for getting local models:
10 |
11 | #### option a: using ollama
12 |
13 | ```bash
14 | # Install Ollama from https://ollama.ai
15 | # Then pull any model:
16 | ollama pull llama3.2:1b # Llama 3.2
17 | ollama pull mistral # Mistral 7B
18 |
19 | ```
20 |
21 | #### option b: download from hugging face
22 |
23 | 1. visit [Hugging Face](https://huggingface.co/models)
24 | 2. search for compatible models (e.g., Llama 3)
25 | 3. download using Git LFS:
26 |
27 | ```bash
28 | # Example for Mistral 7B
29 | git lfs install
30 | git clone https://huggingface.co/mistralai/Mistral-7B-v0.1
31 | ```
32 |
33 | ### step 2: run an openai-compatible server
34 |
35 | choose one of these servers:
36 |
37 | - [ollama](https://ollama.ai/)
38 |
39 | ```bash
40 | ollama serve
41 | ```
42 |
43 | - [mlx omni server](https://github.com/ml-explore/mlx-examples/tree/main/llms/mlx-omni) (for mac with apple silicon)
44 |
45 | ```bash
46 | pip install mlx-omni-server
47 | mlx-omni-server
48 | ```
49 |
50 | - [litellm](https://github.com/BerriAI/litellm)
51 |
52 | ### step 3: create a public endpoint
53 |
54 | make your local server accessible using [ngrok](https://ngrok.com/) or [localtunnel](https://localtunnel.me):
55 |
56 | ```bash
57 | # For Ollama
58 | ngrok http 11434 --host-header="localhost:11434"
59 |
60 | # For MLX Omni Server
61 | ngrok http 10240
62 | ```
63 |
64 | ### step 4: configure fullmoon
65 |
66 | 1. go to [web.fullmoon.app](https://web.fullmoon.app)
67 | 2. open settings
68 | 3. enter your endpoint details:
69 | - endpoint URL: `https://your-ngrok-url.ngrok.io/v1`
70 | - model name: Same as the model you downloaded (e.g., `llama2`, `mistral`)
71 |
72 | ## development guide
73 |
74 | a monorepo containing two versions of the fullmoon web app
75 |
76 | 1. a local version using SQLite for storage
77 | 2. a web version using IndexedDB for client-side storage live on https://web.fullmoon.app
78 |
79 | ### project structure
80 |
81 | ```
82 | apps/
83 | ├── local/ # SQLite-based version
84 | └── web/ # IndexedDB-based version
85 | packages/
86 | ├── database/ # Shared database interface
87 | └── ui/ # Shared UI components
88 | ```
89 |
90 | ### prerequisites
91 |
92 | - Node.js 18+
93 | - pnpm 8+
94 |
95 | ### installation
96 |
97 | ```bash
98 | pnpm install
99 | ```
100 |
101 | ### running locally
102 |
103 | For local version (sqlite):
104 |
105 | ```bash
106 | # Setup database
107 | npx prisma migrate dev
108 |
109 | # Start development server
110 | pnpm dev --filter local
111 | ```
112 |
113 | For web version (IndexedDB):
114 |
115 | ```bash
116 | pnpm dev --filter web
117 | ```
118 |
119 | ### building
120 |
121 | ```bash
122 | # Build all
123 | pnpm build
124 |
125 | # Build specific app
126 | pnpm build --filter local
127 | # or
128 | pnpm build --filter web
129 | ```
130 |
131 | ## license
132 |
133 | MIT
134 |
--------------------------------------------------------------------------------
/apps/web/src/components/ChatSidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect, useCallback } from "react";
4 | import Link from "next/link";
5 | import { Menu, Plus, Trash2 } from "lucide-react";
6 | import { useSidebar } from "@/contexts/SidebarContext";
7 | import { usePathname, useRouter } from "next/navigation";
8 | import { IndexedDBAdapter } from "@/lib/indexeddb";
9 |
10 | const db = new IndexedDBAdapter();
11 |
12 | export function ChatSidebar() {
13 | const { isSidebarOpen, setIsSidebarOpen } = useSidebar();
14 | const [conversations, setConversations] = useState<
15 | Array<{ id: string; createdAt: Date; title: string }>
16 | >([]);
17 | const pathname = usePathname();
18 | const router = useRouter();
19 |
20 | const fetchConversations = useCallback(async () => {
21 | const convs = await db.listConversations();
22 | setConversations(convs);
23 | }, []);
24 |
25 | useEffect(() => {
26 | fetchConversations();
27 | }, [fetchConversations]);
28 |
29 | const handleDelete = async (id: string, e: React.MouseEvent) => {
30 | e.preventDefault();
31 | try {
32 | await db.deleteConversation(id);
33 | // If we're currently viewing this conversation, navigate to home
34 | if (pathname === `/c/${id}`) {
35 | router.replace("/");
36 | }
37 | await fetchConversations();
38 | } catch (error) {
39 | console.error("Failed to delete conversation:", error);
40 | }
41 | };
42 |
43 | return (
44 |
49 |
50 |
51 |
52 |
59 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | {conversations.map((conversation) => (
71 |
76 |
77 | {conversation.title}
78 |
79 | {new Date(conversation.createdAt).toLocaleDateString()}
80 |
81 |
82 |
89 |
90 | ))}
91 |
92 |
93 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Drawer as DrawerPrimitive } from "vaul"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Drawer = ({
9 | shouldScaleBackground = true,
10 | ...props
11 | }: React.ComponentProps) => (
12 |
16 | )
17 | Drawer.displayName = "Drawer"
18 |
19 | const DrawerTrigger = DrawerPrimitive.Trigger
20 |
21 | const DrawerPortal = DrawerPrimitive.Portal
22 |
23 | const DrawerClose = DrawerPrimitive.Close
24 |
25 | const DrawerOverlay = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
34 | ))
35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
36 |
37 | const DrawerContent = React.forwardRef<
38 | React.ElementRef,
39 | React.ComponentPropsWithoutRef
40 | >(({ className, children, ...props }, ref) => (
41 |
42 |
43 |
51 |
52 | {children}
53 |
54 |
55 | ))
56 | DrawerContent.displayName = "DrawerContent"
57 |
58 | const DrawerHeader = ({
59 | className,
60 | ...props
61 | }: React.HTMLAttributes) => (
62 |
66 | )
67 | DrawerHeader.displayName = "DrawerHeader"
68 |
69 | const DrawerFooter = ({
70 | className,
71 | ...props
72 | }: React.HTMLAttributes) => (
73 |
77 | )
78 | DrawerFooter.displayName = "DrawerFooter"
79 |
80 | const DrawerTitle = React.forwardRef<
81 | React.ElementRef,
82 | React.ComponentPropsWithoutRef
83 | >(({ className, ...props }, ref) => (
84 |
92 | ))
93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName
94 |
95 | const DrawerDescription = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, ...props }, ref) => (
99 |
104 | ))
105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName
106 |
107 | export {
108 | Drawer,
109 | DrawerPortal,
110 | DrawerOverlay,
111 | DrawerTrigger,
112 | DrawerClose,
113 | DrawerContent,
114 | DrawerHeader,
115 | DrawerFooter,
116 | DrawerTitle,
117 | DrawerDescription,
118 | }
119 |
--------------------------------------------------------------------------------
/apps/local/src/components/ChatSidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect, useCallback } from "react";
4 | import Link from "next/link";
5 | import { Menu, Plus, Trash2 } from "lucide-react";
6 | import { useSidebar } from "@/contexts/SidebarContext";
7 | import { usePathname, useRouter } from "next/navigation";
8 |
9 | export default function ChatSidebar() {
10 | const { isSidebarOpen, setIsSidebarOpen } = useSidebar();
11 | const [conversations, setConversations] = useState<
12 | Array<{ id: string; createdAt: Date; title: string }>
13 | >([]);
14 | const pathname = usePathname();
15 | const router = useRouter();
16 |
17 | const fetchConversations = useCallback(async () => {
18 | const response = await fetch("/api/conversations");
19 | const data = await response.json();
20 | setConversations(data);
21 | }, []);
22 |
23 | useEffect(() => {
24 | fetchConversations();
25 | }, [fetchConversations]);
26 |
27 | const handleDelete = async (id: string, e: React.MouseEvent) => {
28 | e.preventDefault();
29 | try {
30 | const response = await fetch(`/api/conversations?id=${id}`, {
31 | method: "DELETE",
32 | });
33 |
34 | if (response.ok) {
35 | // If we're currently viewing this conversation, navigate to home
36 | if (pathname === `/c/${id}`) {
37 | router.replace("/");
38 | }
39 | await fetchConversations();
40 | }
41 | } catch (error) {
42 | console.error("Failed to delete conversation:", error);
43 | }
44 | };
45 |
46 | return (
47 |
52 |
53 |
54 |
55 |
62 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | {conversations.map((conversation) => (
74 |
79 |
80 | {conversation.title}
81 |
82 | {new Date(conversation.createdAt).toLocaleDateString()}
83 |
84 |
85 |
92 |
93 | ))}
94 |
95 |
96 |
97 |
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/apps/web/src/components/ChatDrawer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useCallback, useEffect, useState } from "react";
4 | import Link from "next/link";
5 | import { Plus, Trash2, XIcon } from "lucide-react";
6 | import { usePathname, useRouter } from "next/navigation";
7 | import { IndexedDBAdapter } from "@/lib/indexeddb";
8 | import { Drawer, DrawerContent } from "@/components/ui/drawer";
9 |
10 | const db = new IndexedDBAdapter();
11 |
12 | interface ChatDrawerProps {
13 | open: boolean;
14 | onOpenChange: (open: boolean) => void;
15 | }
16 |
17 | export function ChatDrawer({ open, onOpenChange }: ChatDrawerProps) {
18 | const pathname = usePathname();
19 | const router = useRouter();
20 | const [conversations, setConversations] = useState<
21 | Array<{ id: string; createdAt: Date; title: string }>
22 | >([]);
23 |
24 | const fetchConversations = useCallback(async () => {
25 | const convs = await db.listConversations();
26 | setConversations(convs);
27 | }, []);
28 |
29 | useEffect(() => {
30 | fetchConversations();
31 | }, [fetchConversations]);
32 |
33 | const handleDelete = async (id: string, e: React.MouseEvent) => {
34 | e.preventDefault();
35 | try {
36 | await db.deleteConversation(id);
37 | // If we're currently viewing this conversation, navigate to home
38 | if (pathname === `/c/${id}`) {
39 | router.replace("/");
40 | }
41 | await fetchConversations();
42 | } catch (error) {
43 | console.error("Failed to delete conversation:", error);
44 | }
45 | };
46 |
47 | const handleNavigate = useCallback(() => {
48 | onOpenChange(false);
49 | }, [onOpenChange]);
50 |
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
58 |
65 |
chats
66 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | {conversations.map((conversation, index) => (
78 |
88 |
89 | {conversation.title}
90 |
91 | {new Date(conversation.createdAt).toLocaleDateString()}
92 |
93 |
94 |
101 |
102 | ))}
103 | {conversations.length === 0 && (
104 |
105 | No conversations yet
106 |
107 | )}
108 |
109 |
110 |
111 |
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/apps/local/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DialogPrimitive from "@radix-ui/react-dialog";
5 | import { X } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = DialogPrimitive.Portal;
14 |
15 | const DialogClose = DialogPrimitive.Close;
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ));
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ));
54 | DialogContent.displayName = DialogPrimitive.Content.displayName;
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | );
68 | DialogHeader.displayName = "DialogHeader";
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | );
82 | DialogFooter.displayName = "DialogFooter";
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ));
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogTrigger,
116 | DialogClose,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | };
123 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DialogPrimitive from "@radix-ui/react-dialog";
5 | import { Cross2Icon } from "@radix-ui/react-icons";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = DialogPrimitive.Portal;
14 |
15 | const DialogClose = DialogPrimitive.Close;
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ));
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ));
54 | DialogContent.displayName = DialogPrimitive.Content.displayName;
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | );
68 | DialogHeader.displayName = "DialogHeader";
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | );
82 | DialogFooter.displayName = "DialogFooter";
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ));
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogTrigger,
116 | DialogClose,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | };
123 |
--------------------------------------------------------------------------------
/apps/local/src/components/icons/MoonPhaseIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const MOON_PHASES = {
4 | NEW: "new",
5 | WAXING_CRESCENT: "waxing-crescent",
6 | FIRST_QUARTER: "first-quarter",
7 | WAXING_GIBBOUS: "waxing-gibbous",
8 | FULL: "full",
9 | WANING_GIBBOUS: "waning-gibbous",
10 | LAST_QUARTER: "last-quarter",
11 | WANING_CRESCENT: "waning-crescent",
12 | };
13 |
14 | const PATHS = {
15 | [MOON_PHASES.NEW]:
16 | "M14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386C28.8727 6.5186 22.3527 -0.0014 14.4327 -0.0014C6.5127 -0.0014 0.0027 6.5186 0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886Z",
17 | [MOON_PHASES.WAXING_CRESCENT]:
18 | "M0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386C28.8727 6.5186 22.3527 -0.0014 14.4327 -0.0014C6.5127 -0.0014 0.0027 6.5186 0.0027 14.4386ZM25.2827 14.4386C25.2927 20.4286 20.5127 25.2886 14.5327 25.2886C14.3027 25.2886 14.0727 25.2786 13.8327 25.2486C17.0227 22.7886 18.8627 18.9886 18.8627 14.4486C18.8627 9.8986 17.0227 6.0886 13.8227 3.6386C14.0727 3.6086 14.3027 3.5986 14.5427 3.5986C20.5127 3.5986 25.2827 8.4786 25.2827 14.4386Z",
19 | [MOON_PHASES.FIRST_QUARTER]:
20 | "M14.4327 28.8776C22.3627 28.8776 28.8727 22.3676 28.8727 14.4376C28.8727 6.5176 22.3527 -0.0024 14.4327 -0.0024C6.5127 -0.0024 0.0027 6.5176 0.0027 14.4376C0.0027 22.3676 6.5227 28.8776 14.4327 28.8776ZM14.4327 25.2776V3.5976C20.4427 3.5976 25.2827 8.4376 25.2827 14.4376C25.2927 20.4576 20.4527 25.2776 14.4327 25.2776Z",
21 | [MOON_PHASES.WAXING_GIBBOUS]:
22 | "M0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386C28.8727 6.5186 22.3527 -0.0014 14.4327 -0.0014C6.5127 -0.0014 0.0027 6.5186 0.0027 14.4386ZM9.9627 14.4486C9.9627 9.8286 11.7627 6.0286 14.9427 3.6486C20.8327 3.9886 25.2827 8.5986 25.2827 14.4386C25.2927 20.2886 20.8327 24.9086 14.9027 25.2386C11.7427 22.8886 9.9627 19.0986 9.9627 14.4486Z",
23 | [MOON_PHASES.FULL]:
24 | "M14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386C28.8727 6.5186 22.3527 -0.0014 14.4327 -0.0014C6.5127 -0.0014 0.0027 6.5186 0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886ZM14.4327 25.2886C8.4327 25.2886 3.6127 20.4486 3.6127 14.4386C3.6127 8.4386 8.4227 3.5986 14.4327 3.5986C20.4427 3.5986 25.2827 8.4386 25.2827 14.4386C25.2827 20.4486 20.4527 25.2886 14.4327 25.2886Z",
25 | [MOON_PHASES.WANING_GIBBOUS]:
26 | "M28.8727 14.4386C28.8727 6.5186 22.3627 -0.0014 14.4527 -0.0014C6.5227 -0.0014 0.0027 6.5186 0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386ZM18.9227 14.4486C18.9227 19.0986 17.1327 22.8886 13.9727 25.2386C8.0527 24.9086 3.5827 20.2886 3.5927 14.4386C3.6127 8.5986 8.0527 3.9886 13.9427 3.6486C17.1127 6.0286 18.9227 9.8286 18.9227 14.4486Z",
27 | [MOON_PHASES.LAST_QUARTER]:
28 | "M14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386C28.8727 6.5186 22.3627 -0.0014 14.4327 -0.0014C6.5227 -0.0014 0.0027 6.5186 0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886ZM14.4327 25.2886C8.4327 25.2886 3.5827 20.4486 3.5927 14.4386C3.6127 8.4386 8.4327 3.5986 14.4327 3.5986Z",
29 | [MOON_PHASES.WANING_CRESCENT]:
30 | "M28.8727 14.4386C28.8727 6.5186 22.3627 -0.0014 14.4527 -0.0014C6.5227 -0.0014 0.0027 6.5186 0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386ZM3.5927 14.4386C3.6127 8.4786 8.3827 3.5986 14.3327 3.5986C14.5727 3.5986 14.8227 3.6086 15.0527 3.6386C11.8527 6.0886 10.0127 9.8986 10.0127 14.4486C10.0127 18.9886 11.8527 22.7886 15.0427 25.2486C14.8027 25.2786 14.5727 25.2886 14.3427 25.2886C8.3827 25.2886 3.5827 20.4286 3.5927 14.4386Z",
31 | };
32 |
33 | const MoonPhaseIcon = ({
34 | phase = MOON_PHASES.NEW,
35 | size = 24,
36 | color = "currentColor",
37 | className = "",
38 | }) => {
39 | // Calculate scaling factor based on original geometry
40 | const originalWidth = 28.875;
41 | const originalHeight = 28.888671875;
42 | const scale = size / Math.max(originalWidth, originalHeight);
43 |
44 | return (
45 |
55 | );
56 | };
57 |
58 | // Export the component and the phases enum
59 | export { MOON_PHASES };
60 | export default MoonPhaseIcon;
61 |
--------------------------------------------------------------------------------
/apps/web/src/components/icons/MoonPhaseIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const MOON_PHASES = {
4 | NEW: "new",
5 | WAXING_CRESCENT: "waxing-crescent",
6 | FIRST_QUARTER: "first-quarter",
7 | WAXING_GIBBOUS: "waxing-gibbous",
8 | FULL: "full",
9 | WANING_GIBBOUS: "waning-gibbous",
10 | LAST_QUARTER: "last-quarter",
11 | WANING_CRESCENT: "waning-crescent",
12 | };
13 |
14 | const PATHS = {
15 | [MOON_PHASES.NEW]:
16 | "M14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386C28.8727 6.5186 22.3527 -0.0014 14.4327 -0.0014C6.5127 -0.0014 0.0027 6.5186 0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886Z",
17 | [MOON_PHASES.WAXING_CRESCENT]:
18 | "M0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386C28.8727 6.5186 22.3527 -0.0014 14.4327 -0.0014C6.5127 -0.0014 0.0027 6.5186 0.0027 14.4386ZM25.2827 14.4386C25.2927 20.4286 20.5127 25.2886 14.5327 25.2886C14.3027 25.2886 14.0727 25.2786 13.8327 25.2486C17.0227 22.7886 18.8627 18.9886 18.8627 14.4486C18.8627 9.8986 17.0227 6.0886 13.8227 3.6386C14.0727 3.6086 14.3027 3.5986 14.5427 3.5986C20.5127 3.5986 25.2827 8.4786 25.2827 14.4386Z",
19 | [MOON_PHASES.FIRST_QUARTER]:
20 | "M14.4327 28.8776C22.3627 28.8776 28.8727 22.3676 28.8727 14.4376C28.8727 6.5176 22.3527 -0.0024 14.4327 -0.0024C6.5127 -0.0024 0.0027 6.5176 0.0027 14.4376C0.0027 22.3676 6.5227 28.8776 14.4327 28.8776ZM14.4327 25.2776V3.5976C20.4427 3.5976 25.2827 8.4376 25.2827 14.4376C25.2927 20.4576 20.4527 25.2776 14.4327 25.2776Z",
21 | [MOON_PHASES.WAXING_GIBBOUS]:
22 | "M0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386C28.8727 6.5186 22.3527 -0.0014 14.4327 -0.0014C6.5127 -0.0014 0.0027 6.5186 0.0027 14.4386ZM9.9627 14.4486C9.9627 9.8286 11.7627 6.0286 14.9427 3.6486C20.8327 3.9886 25.2827 8.5986 25.2827 14.4386C25.2927 20.2886 20.8327 24.9086 14.9027 25.2386C11.7427 22.8886 9.9627 19.0986 9.9627 14.4486Z",
23 | [MOON_PHASES.FULL]:
24 | "M14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386C28.8727 6.5186 22.3527 -0.0014 14.4327 -0.0014C6.5127 -0.0014 0.0027 6.5186 0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886ZM14.4327 25.2886C8.4327 25.2886 3.6127 20.4486 3.6127 14.4386C3.6127 8.4386 8.4227 3.5986 14.4327 3.5986C20.4427 3.5986 25.2827 8.4386 25.2827 14.4386C25.2827 20.4486 20.4527 25.2886 14.4327 25.2886Z",
25 | [MOON_PHASES.WANING_GIBBOUS]:
26 | "M28.8727 14.4386C28.8727 6.5186 22.3627 -0.0014 14.4527 -0.0014C6.5227 -0.0014 0.0027 6.5186 0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386ZM18.9227 14.4486C18.9227 19.0986 17.1327 22.8886 13.9727 25.2386C8.0527 24.9086 3.5827 20.2886 3.5927 14.4386C3.6127 8.5986 8.0527 3.9886 13.9427 3.6486C17.1127 6.0286 18.9227 9.8286 18.9227 14.4486Z",
27 | [MOON_PHASES.LAST_QUARTER]:
28 | "M14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386C28.8727 6.5186 22.3627 -0.0014 14.4327 -0.0014C6.5227 -0.0014 0.0027 6.5186 0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886ZM14.4327 25.2886C8.4327 25.2886 3.5827 20.4486 3.5927 14.4386C3.6127 8.4386 8.4327 3.5986 14.4327 3.5986Z",
29 | [MOON_PHASES.WANING_CRESCENT]:
30 | "M28.8727 14.4386C28.8727 6.5186 22.3627 -0.0014 14.4527 -0.0014C6.5227 -0.0014 0.0027 6.5186 0.0027 14.4386C0.0027 22.3686 6.5227 28.8886 14.4327 28.8886C22.3627 28.8886 28.8727 22.3686 28.8727 14.4386ZM3.5927 14.4386C3.6127 8.4786 8.3827 3.5986 14.3327 3.5986C14.5727 3.5986 14.8227 3.6086 15.0527 3.6386C11.8527 6.0886 10.0127 9.8986 10.0127 14.4486C10.0127 18.9886 11.8527 22.7886 15.0427 25.2486C14.8027 25.2786 14.5727 25.2886 14.3427 25.2886C8.3827 25.2886 3.5827 20.4286 3.5927 14.4386Z",
31 | };
32 |
33 | const MoonPhaseIcon = ({
34 | phase = MOON_PHASES.NEW,
35 | size = 24,
36 | color = "currentColor",
37 | className = "",
38 | }) => {
39 | // Calculate scaling factor based on original geometry
40 | const originalWidth = 28.875;
41 | const originalHeight = 28.888671875;
42 | const scale = size / Math.max(originalWidth, originalHeight);
43 |
44 | return (
45 |
55 | );
56 | };
57 |
58 | // Export the component and the phases enum
59 | export { MOON_PHASES };
60 | export default MoonPhaseIcon;
61 |
--------------------------------------------------------------------------------
/apps/local/src/components/SettingsDialog.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Dialog,
3 | DialogContent,
4 | DialogHeader,
5 | DialogTitle,
6 | DialogFooter,
7 | DialogClose,
8 | } from "@/components/ui/dialog";
9 | import {
10 | Select,
11 | SelectContent,
12 | SelectItem,
13 | SelectTrigger,
14 | SelectValue,
15 | } from "@/components/ui/select";
16 | import { Button } from "@/components/ui/button";
17 | import { useTheme } from "next-themes";
18 | import {
19 | AlertDialog,
20 | AlertDialogAction,
21 | AlertDialogCancel,
22 | AlertDialogContent,
23 | AlertDialogDescription,
24 | AlertDialogFooter,
25 | AlertDialogHeader,
26 | AlertDialogTitle,
27 | } from "@/components/ui/alert-dialog";
28 | import { useState } from "react";
29 | import { useRouter } from "next/navigation";
30 |
31 | interface SettingsDialogProps {
32 | isOpen: boolean;
33 | onOpenChange: (open: boolean) => void;
34 | }
35 |
36 | export default function SettingsDialog({
37 | isOpen,
38 | onOpenChange,
39 | }: SettingsDialogProps): JSX.Element {
40 | const { theme, setTheme } = useTheme();
41 | const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
42 | const router = useRouter();
43 |
44 | const handleDeleteChats = async () => {
45 | try {
46 | const response = await fetch("/api/conversations?deleteAll=true", {
47 | method: "DELETE",
48 | });
49 |
50 | if (response.ok) {
51 | setShowDeleteConfirm(false);
52 | router.replace("/");
53 | } else {
54 | console.error("Failed to delete conversations");
55 | }
56 | } catch (error) {
57 | console.error("Error deleting conversations:", error);
58 | }
59 | };
60 |
61 | return (
62 | <>
63 |
111 |
112 |
113 |
114 |
115 | are you sure?
116 | are you sure?
117 |
118 |
119 | cancel
120 |
124 | delete
125 |
126 |
127 |
128 |
129 | >
130 | );
131 | }
132 |
--------------------------------------------------------------------------------
/apps/local/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ))
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | )
60 | AlertDialogHeader.displayName = "AlertDialogHeader"
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | )
74 | AlertDialogFooter.displayName = "AlertDialogFooter"
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ))
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ))
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ))
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | }
142 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
5 |
6 | import { cn } from "@/lib/utils";
7 | import { buttonVariants } from "@/components/ui/button";
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root;
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal;
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ));
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | );
60 | AlertDialogHeader.displayName = "AlertDialogHeader";
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | );
74 | AlertDialogFooter.displayName = "AlertDialogFooter";
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ));
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ));
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName;
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ));
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | };
142 |
--------------------------------------------------------------------------------
/apps/web/src/lib/indexeddb.ts:
--------------------------------------------------------------------------------
1 | import { openDB, type DBSchema } from "idb";
2 | import type {
3 | DatabaseAdapter,
4 | Message,
5 | Conversation,
6 | } from "@fullmoon/database";
7 | import { v4 as uuidv4 } from "uuid";
8 |
9 | interface FullmoonDB extends DBSchema {
10 | conversations: {
11 | key: string;
12 | value: Conversation;
13 | indexes: {
14 | "by-updated": Date;
15 | "by-created": Date;
16 | };
17 | };
18 | messages: {
19 | key: string;
20 | value: Message;
21 | indexes: {
22 | "by-conversation": string;
23 | "by-created": Date;
24 | };
25 | };
26 | settings: {
27 | key: string;
28 | value: {
29 | id: string;
30 | customEndpoint?: string;
31 | modelName?: string;
32 | apiKey?: string;
33 | };
34 | };
35 | }
36 |
37 | const DB_NAME = "fullmoon-db";
38 | const DB_VERSION = 5;
39 |
40 | async function getDB() {
41 | return openDB(DB_NAME, DB_VERSION, {
42 | upgrade(db) {
43 | // Create or update conversations store
44 | if (!db.objectStoreNames.contains("conversations")) {
45 | const store = db.createObjectStore("conversations", {
46 | keyPath: "id",
47 | });
48 | store.createIndex("by-updated", "updatedAt");
49 | store.createIndex("by-created", "createdAt");
50 | }
51 |
52 | // Create or update messages store
53 | if (!db.objectStoreNames.contains("messages")) {
54 | const store = db.createObjectStore("messages", {
55 | keyPath: "id",
56 | });
57 | store.createIndex("by-conversation", "conversationId");
58 | store.createIndex("by-created", "createdAt");
59 | }
60 |
61 | // Create settings store if it doesn't exist
62 | if (!db.objectStoreNames.contains("settings")) {
63 | db.createObjectStore("settings", {
64 | keyPath: "id",
65 | });
66 | }
67 | },
68 | });
69 | }
70 |
71 | export class IndexedDBAdapter implements DatabaseAdapter {
72 | async createConversation(
73 | conversation: Omit
74 | ): Promise {
75 | const db = await getDB();
76 | const newConversation: Conversation = {
77 | id: uuidv4(),
78 | ...conversation,
79 | };
80 | await db.put("conversations", newConversation);
81 | return newConversation;
82 | }
83 |
84 | async getConversation(id: string): Promise {
85 | const db = await getDB();
86 | const conversation = await db.get("conversations", id);
87 | return conversation || null;
88 | }
89 |
90 | async listConversations(): Promise {
91 | const db = await getDB();
92 | const conversations = await db.getAllFromIndex(
93 | "conversations",
94 | "by-created"
95 | );
96 | return conversations.reverse();
97 | }
98 |
99 | async updateConversation(
100 | id: string,
101 | data: Partial
102 | ): Promise {
103 | const db = await getDB();
104 | const conversation = await db.get("conversations", id);
105 | if (!conversation) {
106 | throw new Error(`Conversation ${id} not found`);
107 | }
108 | const updatedConversation = {
109 | ...conversation,
110 | ...data,
111 | updatedAt: new Date(),
112 | };
113 | await db.put("conversations", updatedConversation);
114 | return updatedConversation;
115 | }
116 |
117 | async deleteConversation(id: string): Promise {
118 | const db = await getDB();
119 | await db.delete("conversations", id);
120 |
121 | // Delete all messages in the conversation
122 | const messages = await this.getMessages(id);
123 | await Promise.all(messages.map((msg) => this.deleteMessage(msg.id)));
124 | }
125 |
126 | async createMessage(message: Omit): Promise {
127 | const db = await getDB();
128 | const newMessage: Message = {
129 | ...message,
130 | id: uuidv4(),
131 | };
132 | await db.add("messages", newMessage);
133 | return newMessage;
134 | }
135 |
136 | async getMessages(conversationId: string): Promise {
137 | const db = await getDB();
138 | const messages = await db.getAllFromIndex(
139 | "messages",
140 | "by-conversation",
141 | conversationId
142 | );
143 | return messages.sort(
144 | (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
145 | );
146 | }
147 |
148 | async deleteMessage(id: string): Promise {
149 | const db = await getDB();
150 | await db.delete("messages", id);
151 | }
152 |
153 | async getCustomEndpoint(): Promise<{
154 | endpoint?: string;
155 | modelName?: string;
156 | apiKey?: string;
157 | }> {
158 | const db = await getDB();
159 | const settings = await db.get("settings", "customEndpoint");
160 | return {
161 | endpoint: settings?.customEndpoint,
162 | modelName: settings?.modelName,
163 | apiKey: settings?.apiKey,
164 | };
165 | }
166 |
167 | async setCustomEndpoint(
168 | endpoint: string | undefined,
169 | modelName: string | undefined,
170 | apiKey: string | undefined
171 | ): Promise {
172 | const db = await getDB();
173 | await db.put("settings", {
174 | id: "customEndpoint",
175 | customEndpoint: endpoint,
176 | modelName: modelName,
177 | apiKey: apiKey,
178 | });
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/apps/local/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 | {children}
132 |
133 | ))
134 | SelectItem.displayName = SelectPrimitive.Item.displayName
135 |
136 | const SelectSeparator = React.forwardRef<
137 | React.ElementRef,
138 | React.ComponentPropsWithoutRef
139 | >(({ className, ...props }, ref) => (
140 |
145 | ))
146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
147 |
148 | export {
149 | Select,
150 | SelectGroup,
151 | SelectValue,
152 | SelectTrigger,
153 | SelectContent,
154 | SelectLabel,
155 | SelectItem,
156 | SelectSeparator,
157 | SelectScrollUpButton,
158 | SelectScrollDownButton,
159 | }
160 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SelectPrimitive from "@radix-ui/react-select";
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Select = SelectPrimitive.Root;
10 |
11 | const SelectGroup = SelectPrimitive.Group;
12 |
13 | const SelectValue = SelectPrimitive.Value;
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ));
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ));
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ));
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName;
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ));
100 | SelectContent.displayName = SelectPrimitive.Content.displayName;
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ));
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 | {children}
132 |
133 | ));
134 | SelectItem.displayName = SelectPrimitive.Item.displayName;
135 |
136 | const SelectSeparator = React.forwardRef<
137 | React.ElementRef,
138 | React.ComponentPropsWithoutRef
139 | >(({ className, ...props }, ref) => (
140 |
145 | ));
146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
147 |
148 | export {
149 | Select,
150 | SelectGroup,
151 | SelectValue,
152 | SelectTrigger,
153 | SelectContent,
154 | SelectLabel,
155 | SelectItem,
156 | SelectSeparator,
157 | SelectScrollUpButton,
158 | SelectScrollDownButton,
159 | };
160 |
--------------------------------------------------------------------------------
/apps/local/src/components/ChatInterface.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useRef, useCallback, useEffect } from "react";
4 | import { useChat } from "ai/react";
5 | import {
6 | Menu,
7 | Paperclip,
8 | Globe,
9 | ArrowUp,
10 | Plus,
11 | HammerIcon,
12 | Cog,
13 | } from "lucide-react";
14 | import { Textarea } from "@/components/ui/textarea";
15 | import { Button } from "@/components/ui/button";
16 | import { useRouter } from "next/navigation";
17 | import Link from "next/link";
18 | import type { Message } from "ai";
19 | import { useSidebar } from "@/contexts/SidebarContext";
20 | import MoonPhaseIcon, { MOON_PHASES } from "@/components/icons/MoonPhaseIcon";
21 | import { getMoonPhase } from "@/lib/utils";
22 | import SettingsDialog from "@/components/SettingsDialog";
23 |
24 | interface Conversation {
25 | id: string;
26 | title: string;
27 | messages: Message[];
28 | }
29 |
30 | interface ChatInterfaceProps {
31 | convo?: Conversation;
32 | }
33 |
34 | export default function ChatInterface({
35 | convo,
36 | }: ChatInterfaceProps): JSX.Element {
37 | const [isSettingsOpen, setIsSettingsOpen] = useState(false);
38 | const { isSidebarOpen, toggleSidebar } = useSidebar();
39 | const router = useRouter();
40 | const [conversationId, setConversationId] = useState(
41 | convo?.id || null
42 | );
43 | const [conversation] = useState(convo || null);
44 |
45 | const messagesEndRef = useRef(null);
46 |
47 | const { messages, input, handleInputChange, handleSubmit, isLoading } =
48 | useChat({
49 | initialMessages: conversation?.messages || [],
50 | body: { conversationId },
51 | id: conversationId || "new",
52 | });
53 |
54 | const scrollToBottom = useCallback(() => {
55 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
56 | }, []);
57 |
58 | useEffect(() => {
59 | if (messages.length > 0) {
60 | scrollToBottom();
61 | }
62 | }, [messages, scrollToBottom]);
63 |
64 | useEffect(() => {
65 | if (conversationId) {
66 | console.log("fetching Conversation ID:", conversationId);
67 | // fetchConversation();
68 | if (input.trim()) {
69 | const event = new Event(
70 | "submit"
71 | ) as unknown as React.FormEvent;
72 | handleSubmit(event);
73 | }
74 | }
75 | // eslint-disable-next-line react-hooks/exhaustive-deps
76 | }, [conversationId]);
77 |
78 | // const fetchConversation = async () => {
79 | // const response = await fetch(`/api/conversations?id=${conversationId}`);
80 | // if (response.ok) {
81 | // const data = await response.json();
82 | // setConversation(data);
83 | // }
84 | // };
85 |
86 | const getCurrentMoonPhase = () => {
87 | const phase = getMoonPhase();
88 | switch (phase) {
89 | case "new":
90 | return MOON_PHASES.NEW;
91 | case "waxing-crescent":
92 | return MOON_PHASES.WAXING_CRESCENT;
93 | case "first-quarter":
94 | return MOON_PHASES.FIRST_QUARTER;
95 | case "waxing-gibbous":
96 | return MOON_PHASES.WAXING_GIBBOUS;
97 | case "full":
98 | return MOON_PHASES.FULL;
99 | case "waning-gibbous":
100 | return MOON_PHASES.WANING_GIBBOUS;
101 | case "last-quarter":
102 | return MOON_PHASES.LAST_QUARTER;
103 | case "waning-crescent":
104 | return MOON_PHASES.WANING_CRESCENT;
105 | default:
106 | return MOON_PHASES.NEW;
107 | }
108 | };
109 |
110 | const onSubmit = async (e: React.FormEvent) => {
111 | e.preventDefault();
112 | if (!input.trim()) return;
113 |
114 | if (!conversationId) {
115 | try {
116 | const response = await fetch("/api/conversations", {
117 | method: "POST",
118 | headers: { "Content-Type": "application/json" },
119 | body: JSON.stringify({
120 | title: input.substring(0, 100),
121 | message: { content: input, role: "user" },
122 | }),
123 | });
124 |
125 | if (response.ok) {
126 | const newConversation = await response.json();
127 | setConversationId(newConversation.id);
128 | router.push(`/c/${newConversation.id}`);
129 | return;
130 | }
131 | } catch (error) {
132 | console.error("Failed to create conversation:", error);
133 | }
134 | }
135 | handleSubmit(e);
136 | };
137 |
138 | return (
139 |
144 |
145 |
150 |
{conversation?.title || "chat"}
151 |
159 |
160 |
161 |
162 |
163 |
170 |
171 |
172 |
173 |
174 |
175 |
176 | {messages.length === 0 ? (
177 |
178 |
183 | {/*
184 | Start a conversation...
185 |
*/}
186 |
187 | ) : (
188 |
189 | {messages.map((message, index) => (
190 |
196 |
203 |
204 | {message.content}
205 | {isLoading &&
206 | index === messages.length - 1 &&
207 | message.role === "assistant" &&
208 | "🌕"}
209 |
210 |
211 |
212 | ))}
213 |
214 | )}
215 |
216 |
217 |
218 |
278 |
282 |
283 | );
284 | }
285 |
--------------------------------------------------------------------------------
/apps/web/src/components/SettingsDialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "@/components/ui/dialog";
10 | import {
11 | Select,
12 | SelectContent,
13 | SelectItem,
14 | SelectTrigger,
15 | SelectValue,
16 | } from "@/components/ui/select";
17 | import { Button } from "@/components/ui/button";
18 | import { useTheme } from "next-themes";
19 | import {
20 | AlertDialog,
21 | AlertDialogAction,
22 | AlertDialogCancel,
23 | AlertDialogContent,
24 | AlertDialogDescription,
25 | AlertDialogFooter,
26 | AlertDialogHeader,
27 | AlertDialogTitle,
28 | } from "@/components/ui/alert-dialog";
29 | import { useState, useEffect } from "react";
30 | import { useRouter } from "next/navigation";
31 | import { IndexedDBAdapter } from "@/lib/indexeddb";
32 | import { Input } from "@/components/ui/input";
33 | import MoonPhaseIcon from "./icons/MoonPhaseIcon";
34 | import { getCurrentMoonPhase } from "@/lib/utils";
35 | import { Check, ChevronRight, ChevronLeft, MoveUpRight } from "lucide-react";
36 | import { useMemo } from "react";
37 | // const db = new IndexedDBAdapter();
38 |
39 | interface SettingsDialogProps {
40 | open: boolean;
41 | onOpenChange: (open: boolean) => void;
42 | onSettingsChange?: () => void;
43 | }
44 |
45 | export default function SettingsDialog({
46 | open,
47 | onOpenChange,
48 | onSettingsChange,
49 | }: SettingsDialogProps): JSX.Element {
50 | const { theme, setTheme } = useTheme();
51 | const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
52 | const [customEndpoint, setCustomEndpoint] = useState("");
53 | const [customModelName, setCustomModelName] = useState("");
54 | const [customApiKey, setCustomApiKey] = useState("");
55 | const [saveSuccess, setSaveSuccess] = useState(false);
56 | const [currentView, setCurrentView] = useState<
57 | "main" | "appearance" | "endpoint" | "chats" | "credits"
58 | >("main");
59 | const router = useRouter();
60 |
61 | const db = useMemo(() => new IndexedDBAdapter(), []);
62 |
63 | // Reset view when dialog closes
64 | useEffect(() => {
65 | let mounted = true;
66 |
67 | if (open) {
68 | db.getCustomEndpoint().then((settings) => {
69 | if (mounted) {
70 | console.log("Loading settings:", settings);
71 | setCustomEndpoint(settings.endpoint || "");
72 | setCustomModelName(settings.modelName || "");
73 | setCustomApiKey(settings.apiKey || "");
74 | }
75 | });
76 | } else {
77 | setCurrentView("main");
78 | }
79 |
80 | return () => {
81 | mounted = false;
82 | };
83 | }, [open]);
84 |
85 | const handleDeleteChats = async () => {
86 | try {
87 | const conversations = await db.listConversations();
88 | await Promise.all(
89 | conversations.map((conv) => db.deleteConversation(conv.id))
90 | );
91 | setShowDeleteConfirm(false);
92 | router.push("/");
93 | } catch (error) {
94 | console.error("Error deleting conversations:", error);
95 | }
96 | };
97 |
98 | const handleSaveEndpoint = async () => {
99 | try {
100 | setSaveSuccess(true);
101 | console.log("Saving settings:", {
102 | endpoint: customEndpoint || undefined,
103 | modelName: customModelName || undefined,
104 | apiKey: customApiKey || undefined,
105 | });
106 | await db.setCustomEndpoint(
107 | customEndpoint || undefined,
108 | customModelName || undefined,
109 | customApiKey || undefined
110 | );
111 | onSettingsChange?.();
112 | setTimeout(() => {
113 | setSaveSuccess(false);
114 | }, 2000);
115 | } catch (error) {
116 | console.error("Error saving endpoint settings:", error);
117 | }
118 | };
119 |
120 | const renderMainView = () => (
121 | <>
122 |
123 | settings
124 |
125 |
126 | {/* First group */}
127 |
128 |
136 |
144 |
152 |
153 |
154 | {/* Credits section */}
155 |
156 |
164 |
165 |
166 |
177 |
178 |
179 |
184 |
185 |
version 0.1.0
186 |
187 |
Made by
188 |

193 |

198 |
Mainframe
199 |
200 |
201 |
202 | >
203 | );
204 |
205 | const renderAppearanceView = () => (
206 | <>
207 |
208 |
209 |
217 | appearance
218 |
219 |
220 |
221 |
222 |
223 |
theme
224 |
234 |
235 |
236 |
237 | >
238 | );
239 |
240 | const renderEndpointView = () => (
241 | <>
242 |
243 |
244 |
252 | model endpoint
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 | endpoint URL
261 |
262 |
setCustomEndpoint(e.target.value)}
267 | className="h-6 px-0 border-0 shadow-none focus-visible:ring-0 bg-transparent placeholder:text-muted-foreground/50 text-sm"
268 | />
269 |
270 |
271 |
272 | model name
273 |
274 |
setCustomModelName(e.target.value)}
279 | className="h-6 px-0 border-0 shadow-none focus-visible:ring-0 bg-transparent placeholder:text-muted-foreground/50 text-sm"
280 | />
281 |
282 |
283 |
284 | api key (optional)
285 |
286 |
setCustomApiKey(e.target.value)}
291 | className="h-6 px-0 border-0 shadow-none focus-visible:ring-0 bg-transparent placeholder:text-muted-foreground/50 text-sm"
292 | />
293 |
294 |
295 |
296 |
307 |
308 | >
309 | );
310 |
311 | const renderChatsView = () => (
312 | <>
313 |
314 |
315 |
323 | chats
324 |
325 |
326 |
327 |
334 |
335 | >
336 | );
337 |
338 | const renderCreditsView = () => (
339 | <>
340 |
341 |
342 |
350 | credits
351 |
352 |
353 |
375 | >
376 | );
377 |
378 | return (
379 | <>
380 |
389 |
390 |
391 |
392 |
393 | are you sure?
394 |
395 | this will permanently delete all your conversations.
396 |
397 |
398 |
399 | cancel
400 |
401 | delete all
402 |
403 |
404 |
405 |
406 | >
407 | );
408 | }
409 |
--------------------------------------------------------------------------------
/apps/web/src/components/ChatInterface.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useRef, useCallback, useEffect } from "react";
4 | import { useChat } from "ai/react";
5 | import {
6 | Menu,
7 | Paperclip,
8 | ArrowUp,
9 | Plus,
10 | Cog,
11 | MessageCircleWarning,
12 | } from "lucide-react";
13 | import { Textarea } from "@/components/ui/textarea";
14 | import { Button } from "@/components/ui/button";
15 | import { useRouter } from "next/navigation";
16 | import Link from "next/link";
17 | import type { Conversation } from "@fullmoon/database";
18 | import { useSidebar } from "@/contexts/SidebarContext";
19 | import MoonPhaseIcon from "@/components/icons/MoonPhaseIcon";
20 | import { getCurrentMoonPhase } from "@/lib/utils";
21 | import SettingsDialog from "@/components/SettingsDialog";
22 | import { IndexedDBAdapter } from "@/lib/indexeddb";
23 | import type { Message as AiMessage } from "ai";
24 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
25 | import readPDFText from "react-pdftotext";
26 | import { ChatDrawer } from "@/components/ChatDrawer";
27 |
28 | const db = new IndexedDBAdapter();
29 |
30 | interface ConversationWithMessages extends Conversation {
31 | messages: AiMessage[];
32 | }
33 |
34 | interface ChatInterfaceProps {
35 | convo?: ConversationWithMessages;
36 | }
37 |
38 | const MAX_FILE_SIZE = 100 * 1024; // 100KB
39 | const ALLOWED_FILE_TYPES = [
40 | "text/plain",
41 | "text/markdown",
42 | "application/json",
43 | "text/csv",
44 | "text/html",
45 | "text/javascript",
46 | "text/typescript",
47 | "text/css",
48 | "application/pdf",
49 | ];
50 |
51 | export function ChatInterface({ convo }: ChatInterfaceProps) {
52 | const [isSettingsOpen, setIsSettingsOpen] = useState(false);
53 | const [showSettingsAlert, setShowSettingsAlert] = useState(false);
54 | const { isSidebarOpen, toggleSidebar } = useSidebar();
55 | const router = useRouter();
56 | const [conversationId, setConversationId] = useState(
57 | convo?.id || null
58 | );
59 | const [, setConversation] = useState(
60 | convo || null
61 | );
62 | const [attachedFileName, setAttachedFileName] = useState(null);
63 |
64 | const [customEndpointSettings, setCustomEndpointSettings] = useState<
65 | | {
66 | endpoint?: string;
67 | modelName?: string;
68 | apiKey?: string;
69 | }
70 | | undefined
71 | >(undefined);
72 |
73 | useEffect(() => {
74 | db.getCustomEndpoint().then((endpointSettings) => {
75 | setCustomEndpointSettings(endpointSettings);
76 | setShowSettingsAlert(
77 | !endpointSettings?.endpoint || !endpointSettings?.modelName
78 | );
79 | });
80 | }, []);
81 |
82 | useEffect(() => {
83 | setShowSettingsAlert(
84 | !customEndpointSettings?.endpoint || !customEndpointSettings?.modelName
85 | );
86 | }, [customEndpointSettings]);
87 |
88 | const messagesEndRef = useRef(null);
89 | const fileInputRef = useRef(null);
90 |
91 | // Update conversationId when convo changes
92 | useEffect(() => {
93 | if (convo?.id) {
94 | setConversationId(convo.id);
95 | setConversation(convo);
96 | }
97 | }, [convo]);
98 |
99 | const { messages, input, handleInputChange, handleSubmit, isLoading } =
100 | useChat({
101 | initialMessages: convo?.messages || [],
102 | body: {
103 | conversationId,
104 | customEndpointSettings,
105 | },
106 | id: conversationId || "new",
107 | onFinish: async (message) => {
108 | if (!conversationId) return;
109 | try {
110 | await db.createMessage({
111 | content: message.content,
112 | role: "assistant",
113 | conversationId: conversationId,
114 | createdAt: new Date(),
115 | });
116 | } catch (error) {
117 | console.error("Failed to save assistant message:", error);
118 | }
119 | },
120 | });
121 |
122 | const handleFileUpload = async (e: React.ChangeEvent) => {
123 | const file = e.target.files?.[0];
124 | if (!file) return;
125 |
126 | // Validate file size
127 | if (file.size > MAX_FILE_SIZE) {
128 | alert("File size must be less than 100KB");
129 | return;
130 | }
131 |
132 | // Validate file type
133 | if (!ALLOWED_FILE_TYPES.includes(file.type)) {
134 | alert("Invalid file type. Only text and PDF files are allowed.");
135 | return;
136 | }
137 |
138 | try {
139 | let content: string;
140 | if (file.type === "application/pdf") {
141 | content = await readPDFText(file);
142 | } else {
143 | content = await file.text();
144 | }
145 |
146 | // Append the file content to the input
147 | const fileContent = input
148 | ? `${input}\n\nFile: ${file.name}\n\`\`\`\n${content}\n\`\`\``
149 | : `File: ${file.name}\n\`\`\`\n${content}\n\`\`\``;
150 | handleInputChange({
151 | target: { value: fileContent },
152 | } as React.ChangeEvent);
153 |
154 | setAttachedFileName(file.name);
155 | } catch (error) {
156 | console.error("Error reading file:", error);
157 | alert("Error reading file. Please try again.");
158 | }
159 |
160 | // Clear the file input
161 | if (fileInputRef.current) {
162 | fileInputRef.current.value = "";
163 | }
164 | };
165 |
166 | const clearAttachment = () => {
167 | setAttachedFileName(null);
168 | handleInputChange({
169 | target: { value: "" },
170 | } as React.ChangeEvent);
171 | if (fileInputRef.current) {
172 | fileInputRef.current.value = "";
173 | }
174 | };
175 |
176 | const scrollToBottom = useCallback(() => {
177 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
178 | }, []);
179 |
180 | useEffect(() => {
181 | if (messages.length > 0) {
182 | scrollToBottom();
183 | }
184 | }, [messages, scrollToBottom]);
185 |
186 | useEffect(() => {
187 | if (conversationId) {
188 | if (input.trim()) {
189 | const event = new Event(
190 | "submit"
191 | ) as unknown as React.FormEvent;
192 | handleSubmit(event);
193 | clearAttachment();
194 | }
195 | }
196 | // We intentionally omit input and handleSubmit from deps
197 | // because we only want this to run when conversationId changes
198 | // eslint-disable-next-line react-hooks/exhaustive-deps
199 | }, [conversationId]);
200 |
201 | const onSubmit = async (e: React.FormEvent) => {
202 | e.preventDefault();
203 | if (!input.trim() || isLoading) return;
204 | const userMessage = input.trim();
205 |
206 | if (!conversationId) {
207 | try {
208 | const newConversation = await db.createConversation({
209 | title: userMessage.slice(0, 40),
210 | createdAt: new Date(),
211 | updatedAt: new Date(),
212 | });
213 | // save user message
214 | await db.createMessage({
215 | content: userMessage,
216 | role: "user",
217 | conversationId: newConversation.id,
218 | createdAt: new Date(),
219 | });
220 | setConversationId(newConversation.id);
221 | router.replace(`/c/${newConversation.id}`);
222 | return;
223 | } catch (error) {
224 | console.error("Failed to create conversation:", error);
225 | }
226 | } else {
227 | try {
228 | // Save user message before sending to API
229 | await db.createMessage({
230 | content: userMessage,
231 | role: "user",
232 | conversationId: conversationId,
233 | createdAt: new Date(),
234 | });
235 | handleSubmit(e);
236 | clearAttachment();
237 | } catch (error) {
238 | console.error("Failed to save user message:", error);
239 | }
240 | }
241 | };
242 |
243 | const handleOpenChange = useCallback((open: boolean) => {
244 | setIsSettingsOpen(open);
245 | }, []);
246 |
247 | const refreshEndpointSettings = useCallback(async () => {
248 | const endpointSettings = await db.getCustomEndpoint();
249 | setCustomEndpointSettings(endpointSettings);
250 | }, []);
251 |
252 | const [isDrawerOpen, setIsDrawerOpen] = useState(false);
253 |
254 | const handleMenuClick = () => {
255 | if (window.innerWidth < 640) {
256 | // sm breakpoint
257 | setIsDrawerOpen(true);
258 | } else {
259 | toggleSidebar();
260 | }
261 | };
262 |
263 | return (
264 |
269 |
270 |
275 |
282 |
283 | {convo?.title || "chat"}
284 |
285 |
293 |
294 |
295 |
296 |
297 |
304 |
305 |
306 |
307 |
308 |
309 |
310 | {messages.length === 0 ? (
311 |
312 |
313 |
318 |
319 | {showSettingsAlert && (
320 |
321 |
322 | setup required
323 |
324 | please configure your API endpoint and model in settings to
325 | start chatting.
326 |
327 |
334 |
335 |
336 | )}
337 |
338 | ) : (
339 |
340 | {messages.map((message, index) => (
341 |
347 |
354 |
355 | {message.content}
356 | {isLoading &&
357 | index === messages.length - 1 &&
358 | message.role === "assistant" &&
359 | "🌕"}
360 |
361 |
362 |
363 | ))}
364 |
365 | )}
366 |
367 |
368 |
369 |
370 |
442 |
443 |
444 |
449 |
450 |
451 |
452 | );
453 | }
454 |
--------------------------------------------------------------------------------