├── server ├── data │ ├── uploads │ │ └── .gitkeep │ └── .gitkeep ├── src │ ├── lib │ │ ├── httpStatus.js │ │ ├── constants.js │ │ ├── security.js │ │ ├── imageGeneration.js │ │ └── encryption.js │ ├── routes │ │ └── settings.js │ ├── middleware │ │ └── auth.js │ └── init.js ├── .env.example └── package.json ├── frontend ├── public │ ├── sounds │ │ ├── .gitkeep │ │ └── light-on.mp3 │ ├── img │ │ └── logo.svg │ └── themes │ │ ├── dracula.json │ │ ├── gruvbox.json │ │ ├── kanagawa.json │ │ ├── nord.json │ │ ├── one-dark.json │ │ ├── monokai.json │ │ ├── rosepine.json │ │ ├── default.json │ │ ├── everforest.json │ │ ├── material.json │ │ ├── night-owl.json │ │ ├── tokyo-night.json │ │ ├── catppuccin.json │ │ ├── solarized.json │ │ └── synthwave84.json ├── src │ ├── styles │ │ ├── index.css │ │ └── custom.css │ ├── main.jsx │ ├── pages │ │ ├── public │ │ │ └── Login.jsx │ │ └── authenticated │ │ │ └── Chat.jsx │ ├── components │ │ ├── ui │ │ │ ├── link.jsx │ │ │ ├── textarea.jsx │ │ │ ├── ErrorBanner.jsx │ │ │ ├── ToolbarGroup.jsx │ │ │ ├── text.jsx │ │ │ ├── ThemeToggle.jsx │ │ │ ├── dropdown.jsx │ │ │ ├── ProviderLogo.jsx │ │ │ ├── Avatar.jsx │ │ │ ├── Radio.jsx │ │ │ ├── Modal.jsx │ │ │ ├── ProviderBadge.jsx │ │ │ └── Switch.jsx │ │ ├── layout │ │ │ ├── MainLayout.jsx │ │ │ ├── SidebarToolbar.jsx │ │ │ └── IndexRouteGuard.jsx │ │ ├── chat │ │ │ ├── VoiceStatusIndicator.jsx │ │ │ └── MessageAttachment.jsx │ │ ├── admin │ │ │ ├── DeleteUserModal.jsx │ │ │ ├── EditUserRoleModal.jsx │ │ │ ├── EditProviderModal.jsx │ │ │ └── ResetPasswordModal.jsx │ │ └── settings │ │ │ └── FontSelector.jsx │ ├── utils │ │ ├── message │ │ │ └── messageUtils.js │ │ ├── voice │ │ │ ├── browserSupport.js │ │ │ └── errorHandler.js │ │ └── importConversation.js │ ├── lib │ │ ├── clsx.js │ │ ├── utils.js │ │ ├── adminOnboarding.js │ │ ├── providerUtils.js │ │ ├── authClient.js │ │ ├── adminClient.js │ │ ├── messageUtils.js │ │ ├── search.js │ │ ├── providersClient.js │ │ └── errorHandler.js │ ├── hooks │ │ ├── useIsMobile.js │ │ ├── useReturnToChat.js │ │ ├── queryKeys.js │ │ ├── useFileDragDrop.js │ │ ├── useChatPersistence.js │ │ ├── useImageGeneration.js │ │ ├── useChatVoice.js │ │ ├── voice │ │ │ ├── useVoiceSelection.js │ │ │ ├── useTextToSpeech.js │ │ │ └── useSpeechRecognition.js │ │ ├── useChatActions.js │ │ ├── useSidebarState.js │ │ ├── useKeyboardShortcuts.js │ │ └── useChat.js │ ├── constants │ │ └── voiceStateConfig.js │ ├── App.jsx │ └── state │ │ ├── useUiState.js │ │ ├── useAuthState.js │ │ └── useAppSettings.js ├── jsconfig.json ├── package.json ├── vite.config.js └── index.html ├── models.png ├── themes.png ├── connections.png ├── faster-chat.png ├── focus-mode.png ├── white-label.png ├── railway.json ├── packages └── shared │ ├── package.json │ └── src │ ├── constants │ ├── database.js │ ├── imageGeneration.js │ ├── folders.js │ ├── voice.js │ ├── shortcuts.js │ ├── files.js │ ├── import.js │ ├── prompts.js │ ├── ui.js │ ├── settings.js │ └── config.js │ ├── types │ ├── database.js │ ├── chat.js │ └── models.js │ ├── index.js │ ├── data │ └── languages.js │ └── utils │ └── formatters.js ├── scripts └── reset-db.sh ├── .github ├── ISSUE_TEMPLATE │ ├── doc_update.yml │ ├── tech_debt.yml │ ├── security_report.yml │ ├── improvement.yml │ ├── rfc.yml │ ├── config.yml │ ├── bug_report.yml │ ├── feature_request.yml │ └── provider_integration.yml ├── workflows │ ├── labeler.yml │ └── ci.yml ├── CODEOWNERS ├── labeler.yml └── pull_request_template.md ├── release.yml ├── faster-chat.code-workspace ├── .prettierignore ├── render.yaml ├── .prettierrc ├── CHANGELOG.md ├── docker-compose.caddy.yml ├── .dockerignore ├── fly.toml ├── LICENSE ├── docker-compose.yml ├── .gitignore ├── package.json ├── Caddyfile ├── Dockerfile └── oneclick-deploy-notes.md /server/data/uploads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/sounds/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /models.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1337hero/faster-chat/HEAD/models.png -------------------------------------------------------------------------------- /themes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1337hero/faster-chat/HEAD/themes.png -------------------------------------------------------------------------------- /connections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1337hero/faster-chat/HEAD/connections.png -------------------------------------------------------------------------------- /faster-chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1337hero/faster-chat/HEAD/faster-chat.png -------------------------------------------------------------------------------- /focus-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1337hero/faster-chat/HEAD/focus-mode.png -------------------------------------------------------------------------------- /frontend/src/styles/index.css: -------------------------------------------------------------------------------- 1 | @import "./tailwind.css"; 2 | @import "./custom.css"; 3 | -------------------------------------------------------------------------------- /white-label.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1337hero/faster-chat/HEAD/white-label.png -------------------------------------------------------------------------------- /frontend/public/sounds/light-on.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1337hero/faster-chat/HEAD/frontend/public/sounds/light-on.mp3 -------------------------------------------------------------------------------- /server/data/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the data directory is tracked by git 2 | # Database files (*.db, *.db-shm, *.db-wal) are gitignored but the directory must exist 3 | -------------------------------------------------------------------------------- /frontend/src/main.jsx: -------------------------------------------------------------------------------- 1 | import { render } from "preact"; 2 | import App from "./App"; 3 | import "./styles/index.css"; 4 | 5 | const root = document.getElementById("app"); 6 | if (root) { 7 | render(, root); 8 | } 9 | -------------------------------------------------------------------------------- /railway.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://railway.app/railway.schema.json", 3 | "build": { 4 | "builder": "DOCKERFILE", 5 | "dockerfilePath": "Dockerfile" 6 | }, 7 | "deploy": { 8 | "restartPolicyType": "ON_FAILURE", 9 | "restartPolicyMaxRetries": 10 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/src/lib/httpStatus.js: -------------------------------------------------------------------------------- 1 | export const HTTP_STATUS = { 2 | OK: 200, 3 | CREATED: 201, 4 | NO_CONTENT: 204, 5 | BAD_REQUEST: 400, 6 | UNAUTHORIZED: 401, 7 | FORBIDDEN: 403, 8 | NOT_FOUND: 404, 9 | CONFLICT: 409, 10 | TOO_MANY_REQUESTS: 429, 11 | INTERNAL_SERVER_ERROR: 500, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@faster-chat/shared", 3 | "version": "0.2.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "main": "./src/index.js", 7 | "exports": { 8 | ".": "./src/index.js" 9 | }, 10 | "scripts": {}, 11 | "dependencies": { 12 | "zod": "^4.1.12" 13 | }, 14 | "devDependencies": {} 15 | } 16 | -------------------------------------------------------------------------------- /packages/shared/src/constants/database.js: -------------------------------------------------------------------------------- 1 | export const DB_CONSTANTS = { 2 | CACHE_SIZE_PAGES: -64000, 3 | MMAP_SIZE_BYTES: 30 * 1024 * 1024 * 1024, 4 | DEFAULT_SESSION_EXPIRY_MS: 7 * 24 * 60 * 60 * 1000, 5 | SESSION_ID_BYTES: 32, 6 | SESSION_CLEANUP_INTERVAL_MS: 60 * 60 * 1000, 7 | MILLISECONDS_PER_HOUR: 60 * 60 * 1000, 8 | MILLISECONDS_PER_DAY: 24 * 60 * 60 * 1000, 9 | }; 10 | -------------------------------------------------------------------------------- /scripts/reset-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Reset database for testing user onboarding flow 3 | 4 | echo "🗑️ Deleting database..." 5 | rm -f server/data/chat.db 6 | 7 | echo "✅ Database deleted!" 8 | echo "" 9 | echo "Next steps:" 10 | echo "1. Start server: bun run dev" 11 | echo "2. Go to http://localhost:3000" 12 | echo "3. First user you create = admin" 13 | echo "4. Test the API hookup flow" 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/doc_update.yml: -------------------------------------------------------------------------------- 1 | name: "📚 Documentation Update" 2 | description: "Fix or add docs/readme content" 3 | title: "[DOC] " 4 | labels: [documentation] 5 | body: 6 | - type: textarea 7 | id: location 8 | attributes: 9 | label: Affected Doc / URL 10 | - type: textarea 11 | id: change 12 | attributes: 13 | label: What needs to change? -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/tech_debt.yml: -------------------------------------------------------------------------------- 1 | name: "💸 Tech‑Debt / Refactor" 2 | description: "Code health improvements that deliver no direct user value" 3 | title: "[DEBT] " 4 | labels: [tech‑debt] 5 | body: 6 | - type: textarea 7 | id: rationale 8 | attributes: 9 | label: Why now? 10 | - type: textarea 11 | id: approach 12 | attributes: 13 | label: Proposed Approach -------------------------------------------------------------------------------- /server/src/lib/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rate limiting configuration 3 | */ 4 | export const RATE_LIMIT = { 5 | WINDOW_MS: 15 * 60 * 1000, // 15 minutes 6 | MAX_ATTEMPTS: 5, 7 | }; 8 | 9 | /** 10 | * Authentication configuration 11 | */ 12 | export const AUTH = { 13 | TRUST_PROXY: process.env.TRUST_PROXY === "true", 14 | REGISTRATION_LOCK_MESSAGE: "Registration disabled. Ask an administrator to create an account.", 15 | }; 16 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Auto Label PRs 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | 7 | permissions: 8 | contents: read 9 | pull-requests: write 10 | 11 | jobs: 12 | label: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/labeler@v5 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | configuration-path: .github/labeler.yml 19 | -------------------------------------------------------------------------------- /release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Create GitHub Release 19 | uses: softprops/action-gh-release@v2 20 | with: 21 | generate_release_notes: true 22 | -------------------------------------------------------------------------------- /server/src/lib/security.js: -------------------------------------------------------------------------------- 1 | import { hash, verify } from "@node-rs/argon2"; 2 | 3 | const PASSWORD_HASH_CONFIG = { 4 | memoryCost: 19456, 5 | timeCost: 2, 6 | outputLen: 32, 7 | parallelism: 1, 8 | }; 9 | 10 | export async function hashPassword(password) { 11 | return hash(password, PASSWORD_HASH_CONFIG); 12 | } 13 | 14 | export async function verifyPassword(hashedPassword, password) { 15 | return verify(hashedPassword, password); 16 | } 17 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Default owner for everything 2 | * @1337hero 3 | 4 | # Frontend-specific 5 | /frontend/ @1337hero 6 | 7 | # Backend-specific 8 | /server/ @1337hero 9 | 10 | # Documentation requires approval 11 | /docs/ @1337hero 12 | /README.md @1337hero 13 | /CLAUDE.md @1337hero 14 | /AGENTS.md @1337hero 15 | 16 | # GitHub configuration 17 | /.github/ @1337hero 18 | 19 | # Docker and deployment 20 | /Dockerfile @1337hero 21 | /docker-compose.yml @1337hero 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/security_report.yml: -------------------------------------------------------------------------------- 1 | name: "🚨 Security Issue" 2 | description: "Report a vulnerability confidentially" 3 | assignees: security‑team 4 | labels: [security] 5 | private: true 6 | body: 7 | - type: textarea 8 | id: summary 9 | attributes: 10 | label: Summary of the vulnerability 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: exploit 15 | attributes: 16 | label: Proof‑of‑Concept / Exploit steps -------------------------------------------------------------------------------- /faster-chat.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "files.exclude": { 9 | "**/node_modules": true, 10 | ".claude": true, 11 | ".cluade": true 12 | }, 13 | "search.exclude": { 14 | "**/node_modules": true, 15 | ".claude": true, 16 | ".cluade": true 17 | }, 18 | "files.watcherExclude": { 19 | "**/node_modules": true 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/pages/public/Login.jsx: -------------------------------------------------------------------------------- 1 | import { Navigate } from "@tanstack/react-router"; 2 | import AuthPage from "@/components/auth/AuthPage"; 3 | import { useAuthState } from "@/state/useAuthState"; 4 | 5 | const Login = () => { 6 | const user = useAuthState((state) => state.user); 7 | 8 | // If already logged in, redirect to home 9 | if (user) { 10 | return ; 11 | } 12 | 13 | return ; 14 | }; 15 | 16 | export default Login; 17 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules 3 | **/node_modules 4 | 5 | # Build output & caches 6 | dist 7 | **/dist 8 | build 9 | **/build 10 | .next 11 | .turbo 12 | .vercel 13 | coverage 14 | 15 | # Static/public assets and backups 16 | public 17 | egress-map 18 | _backup 19 | 20 | # Package manager + log files 21 | *.log 22 | yarn.lock 23 | yarn.error.log 24 | pnpm-lock.yaml 25 | package-lock.json 26 | bun.lock 27 | bun.lockb 28 | 29 | # Repo metadata 30 | .github 31 | -------------------------------------------------------------------------------- /packages/shared/src/types/database.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} Chat 3 | * @property {string} id 4 | * @property {string} [title] 5 | * @property {Date} created_at 6 | * @property {Date} updated_at 7 | */ 8 | 9 | /** 10 | * @typedef {Object} StoredMessage 11 | * @property {string} id 12 | * @property {string} chatId 13 | * @property {string} content 14 | * @property {("user" | "assistant")} role 15 | * @property {Date} created_at 16 | * @property {boolean} [isPartial] 17 | */ 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/improvement.yml: -------------------------------------------------------------------------------- 1 | name: "🛠 Improvement" 2 | description: "Minor UI/UX or perf adjustment — not a full feature" 3 | title: "[IMPR] " 4 | labels: [improvement] 5 | body: 6 | - type: textarea 7 | id: context 8 | attributes: 9 | label: Context & Motivation 10 | - type: textarea 11 | id: proposal 12 | attributes: 13 | label: Proposed Change 14 | - type: textarea 15 | id: ac 16 | attributes: 17 | label: Acceptance Criteria -------------------------------------------------------------------------------- /frontend/src/components/ui/link.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Link component wrapper using Headless UI DataInteractive 3 | * This ensures proper handling of client-side router integration 4 | */ 5 | 6 | import * as Headless from "@headlessui/react"; 7 | import { forwardRef } from "@preact/compat"; 8 | 9 | export const Link = forwardRef(function Link(props, ref) { 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/utils/message/messageUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Message content extraction utilities 3 | */ 4 | 5 | export const extractTextContent = (message) => { 6 | if (!message?.parts) return ""; 7 | 8 | return message.parts 9 | .filter((part) => part.type === "text" && part.text) 10 | .map((part) => part.text) 11 | .join(""); 12 | }; 13 | 14 | export const hasTextContent = (message) => { 15 | return message?.parts?.some((part) => part.type === "text" && part.text?.trim()); 16 | }; 17 | -------------------------------------------------------------------------------- /packages/shared/src/types/chat.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} PersistentChatOptions 3 | * @property {string} [id] 4 | * @property {string} model 5 | */ 6 | 7 | /** 8 | * @typedef {Object} ChatBody 9 | * @property {string} model 10 | * @property {string} systemPromptId 11 | * @property {ChatMessage[]} messages 12 | */ 13 | 14 | /** 15 | * @typedef {Object} ChatMessage 16 | * @property {string} id 17 | * @property {string} content 18 | * @property {("user" | "assistant" | "system")} role 19 | */ 20 | -------------------------------------------------------------------------------- /frontend/public/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | # Server port for Hono API 2 | # Default: 8787 (avoids common dev port conflicts) 3 | # Docker: Set via APP_PORT in docker-compose or .env 4 | PORT=8787 5 | 6 | # Security / encryption 7 | # (REQUIRED - generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") 8 | API_KEY_ENCRYPTION_KEY= 9 | 10 | # Set to true if behind a proxy like Nginx 11 | TRUST_PROXY=false 12 | 13 | # Database 14 | DATABASE_URL=sqlite://./data/chat.db 15 | 16 | # Application URL (for CORS in production) 17 | APP_URL=http://localhost:3000 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/rfc.yml: -------------------------------------------------------------------------------- 1 | name: "📄 RFC" 2 | description: "Propose a significant technical or process change" 3 | labels: [rfc, needs discussion] 4 | body: 5 | - type: textarea 6 | id: problem 7 | attributes: 8 | label: Problem Statement 9 | - type: textarea 10 | id: proposal 11 | attributes: 12 | label: Proposed Solution 13 | - type: textarea 14 | id: alternatives 15 | attributes: 16 | label: Alternatives Considered 17 | - type: textarea 18 | id: impact 19 | attributes: 20 | label: Impact / Migration Strategy -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💬 Discussions 4 | url: https://github.com/1337hero/faster-next-chat/discussions 5 | about: Ask questions, share ideas, or show off your setup 6 | - name: 📖 Documentation 7 | url: https://github.com/1337hero/faster-next-chat#readme 8 | about: Read the README, CLAUDE.md (architecture), and AGENTS.md (coding principles) 9 | - name: 🗺️ Roadmap 10 | url: https://github.com/1337hero/faster-next-chat#roadmap 11 | about: Check what's already planned before requesting features 12 | -------------------------------------------------------------------------------- /packages/shared/src/constants/imageGeneration.js: -------------------------------------------------------------------------------- 1 | export const IMAGE_GENERATION = { 2 | ASPECT_RATIOS: ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"], 3 | DEFAULT_ASPECT_RATIO: "9:16", 4 | MAX_IMAGES: 4, 5 | DEFAULT_NUM_IMAGES: 1, 6 | DOWNLOAD_TIMEOUT_MS: 30000, 7 | GENERATED_DIR: "generated", 8 | SAFETY_TOLERANCE: 5, 9 | }; 10 | 11 | // Phase 1: Single hardcoded model 12 | // Phase 2: This will be fetched from database based on admin configuration 13 | export const IMAGE_MODELS = { 14 | DEFAULT: "black-forest-labs/flux-1.1-pro", 15 | DISPLAY_NAME: "Flux 1.1 Pro", 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "skipLibCheck": true, 5 | "jsx": "react-jsx", 6 | "jsxImportSource": "preact", 7 | "paths": { 8 | "@/*": ["./src/*"], 9 | "src/*": ["./src/*"], 10 | "react": ["./node_modules/@preact/compat"], 11 | "react/jsx-runtime": ["./node_modules/@preact/compat/jsx-runtime"], 12 | "react-dom": ["./node_modules/@preact/compat"], 13 | "react-dom/*": ["./node_modules/@preact/compat/*"] 14 | } 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules", "**/node_modules/*", "dist"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/shared/src/types/models.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {("claude-sonnet-4-5" | "claude-haiku-4-5" | "claude-opus-4-1" | "gpt-5-mini" | "gpt-5-nano" | "gpt-5.1-flagship" | "gpt-4o" | "ollama-gemma3-12b" | "ollama-llama" | "ollama-deepseek-r1-14b" | "ollama-qwen3-coder-30b" | "ollama-gpt-oss-20b")} ModelId 3 | */ 4 | 5 | /** 6 | * @typedef {("anthropic" | "openai" | "ollama")} Provider 7 | */ 8 | 9 | /** 10 | * @typedef {Object} ModelConfig 11 | * @property {string} name 12 | * @property {number} contextWindow 13 | * @property {Provider} provider 14 | * @property {string} [modelId] - Optional override for API calls 15 | */ 16 | -------------------------------------------------------------------------------- /frontend/src/lib/clsx.js: -------------------------------------------------------------------------------- 1 | export function clsx(...classes) { 2 | function toVal(mix) { 3 | if (typeof mix === "string" || typeof mix === "number") { 4 | return mix.toString(); 5 | } 6 | 7 | if (!mix) return ""; 8 | 9 | if (Array.isArray(mix)) { 10 | return mix.map(toVal).filter(Boolean).join(" "); 11 | } 12 | 13 | if (typeof mix === "object") { 14 | return Object.entries(mix) 15 | .filter(([_, value]) => value) 16 | .map(([key]) => key) 17 | .join(" "); 18 | } 19 | 20 | return ""; 21 | } 22 | 23 | return classes.map(toVal).filter(Boolean).join(" "); 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/ui/textarea.jsx: -------------------------------------------------------------------------------- 1 | import * as Headless from "@headlessui/react"; 2 | import { clsx } from "@/lib/clsx"; 3 | import { forwardRef } from "@preact/compat"; 4 | 5 | export const Textarea = forwardRef(function Textarea( 6 | { className, resizable = true, ...props }, 7 | ref 8 | ) { 9 | return ( 10 | 19 | ); 20 | }); 21 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: faster-chat 4 | runtime: docker 5 | plan: free 6 | dockerfilePath: ./Dockerfile 7 | dockerContext: . 8 | region: oregon 9 | healthCheckPath: /api/models 10 | envVars: 11 | - key: NODE_ENV 12 | value: production 13 | - key: PORT 14 | value: 8787 15 | - key: DATABASE_URL 16 | value: sqlite:///app/server/data/chat.db 17 | - key: API_KEY_ENCRYPTION_KEY 18 | generateValue: true 19 | - key: APP_URL 20 | sync: false 21 | disk: 22 | name: chat-data 23 | mountPath: /app/server/data 24 | sizeGB: 1 25 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": true, 4 | "bracketSpacing": true, 5 | "endOfLine": "lf", 6 | "htmlWhitespaceSensitivity": "css", 7 | "insertPragma": false, 8 | "printWidth": 100, 9 | "proseWrap": "preserve", 10 | "quoteProps": "as-needed", 11 | "requirePragma": false, 12 | "semi": true, 13 | "singleQuote": false, 14 | "jsxSingleQuote": false, 15 | "tabWidth": 2, 16 | "trailingComma": "es5", 17 | "useTabs": false, 18 | "overrides": [ 19 | { 20 | "files": "*.css", 21 | "options": { 22 | "parser": "css" 23 | } 24 | } 25 | ], 26 | "plugins": ["prettier-plugin-tailwindcss"] 27 | } -------------------------------------------------------------------------------- /frontend/src/lib/utils.js: -------------------------------------------------------------------------------- 1 | export function safeHydration(props) { 2 | if (typeof window === "undefined") { 3 | return props; 4 | } 5 | 6 | const cleanProps = { ...props }; 7 | const browserExtensionAttrs = ["data-lt-installed", "inmaintabuse", "cz-shortcut-listen"]; 8 | 9 | browserExtensionAttrs.forEach((attr) => { 10 | delete cleanProps[attr]; 11 | }); 12 | 13 | return cleanProps; 14 | } 15 | 16 | export function formatDate(date) { 17 | const normalized = date instanceof Date ? date : new Date(date); 18 | return new Intl.DateTimeFormat("en-US", { 19 | month: "short", 20 | day: "numeric", 21 | hour: "numeric", 22 | minute: "numeric", 23 | }).format(normalized); 24 | } 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.0 — Preact/Hono refactor, lighter footprint 4 | - Migrated from Next.js to a Preact SPA (Vite, TanStack Router/Query) with Hono API at `/api/chat`. 5 | - Dropped TypeScript to keep iteration loops fast and avoid compiler friction; favor runtime schemas where needed. 6 | - Aligned package versions across the monorepo to `0.2.0`. 7 | - Kept local-first persistence via Dexie with AI SDK streaming transport; preserved `_backup/` for historical Next.js code. 8 | - Added `server/.env.example` to standardize provider/env configuration. 9 | 10 | ## 0.1.x — Next.js era 11 | - Initial Next.js implementation with server-rendered pages and the early chat UI. Kept only for reference in `_backup/`. 12 | -------------------------------------------------------------------------------- /frontend/src/utils/voice/browserSupport.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the browser supports voice features (speech recognition + synthesis) 3 | */ 4 | export const checkVoiceSupport = () => { 5 | if (typeof window === "undefined") return false; 6 | const hasSpeechRecognition = !!(window.SpeechRecognition || window.webkitSpeechRecognition); 7 | const hasSpeechSynthesis = !!window.speechSynthesis; 8 | return hasSpeechRecognition && hasSpeechSynthesis; 9 | }; 10 | 11 | /** 12 | * Get the SpeechRecognition constructor (handles vendor prefixes) 13 | */ 14 | export const getSpeechRecognition = () => { 15 | if (typeof window === "undefined") return null; 16 | return window.SpeechRecognition || window.webkitSpeechRecognition; 17 | }; 18 | -------------------------------------------------------------------------------- /docker-compose.caddy.yml: -------------------------------------------------------------------------------- 1 | # Optional Caddy reverse proxy for HTTPS 2 | # Usage: docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d 3 | 4 | services: 5 | faster-chat: 6 | # Remove direct port exposure when using Caddy 7 | ports: [] 8 | expose: 9 | - "8787" 10 | 11 | caddy: 12 | image: caddy:2-alpine 13 | restart: unless-stopped 14 | ports: 15 | - "80:80" 16 | - "443:443" 17 | - "443:443/udp" # HTTP/3 support 18 | volumes: 19 | - ./Caddyfile:/etc/caddy/Caddyfile:ro 20 | - caddy-data:/data 21 | - caddy-config:/config 22 | depends_on: 23 | - faster-chat 24 | networks: 25 | - default 26 | 27 | volumes: 28 | caddy-data: 29 | caddy-config: 30 | -------------------------------------------------------------------------------- /frontend/src/hooks/useIsMobile.js: -------------------------------------------------------------------------------- 1 | import { UI_CONSTANTS } from "@faster-chat/shared"; 2 | import { useEffect, useState } from "preact/hooks"; 3 | 4 | export function useIsMobile() { 5 | const [isMobile, setIsMobile] = useState(() => { 6 | if (typeof window === "undefined") return false; 7 | return window.innerWidth < UI_CONSTANTS.BREAKPOINT_MD; 8 | }); 9 | 10 | useEffect(() => { 11 | if (typeof window === "undefined") return; 12 | 13 | function handleResize() { 14 | setIsMobile(window.innerWidth < UI_CONSTANTS.BREAKPOINT_MD); 15 | } 16 | 17 | window.addEventListener("resize", handleResize); 18 | return () => window.removeEventListener("resize", handleResize); 19 | }, []); 20 | 21 | return isMobile; 22 | } 23 | -------------------------------------------------------------------------------- /packages/shared/src/constants/folders.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Folder constants shared between frontend and backend 3 | */ 4 | 5 | export const FOLDER_CONSTANTS = { 6 | MAX_NAME_LENGTH: 50, 7 | DEFAULT_COLOR: "#6b7280", 8 | DEFAULT_POSITION: 0, 9 | ICON_BG_OPACITY: "20", // hex opacity (12.5%) 10 | }; 11 | 12 | export const FOLDER_COLORS = [ 13 | "#6b7280", // gray 14 | "#ef4444", // red 15 | "#f97316", // orange 16 | "#eab308", // yellow 17 | "#22c55e", // green 18 | "#14b8a6", // teal 19 | "#3b82f6", // blue 20 | "#8b5cf6", // purple 21 | "#ec4899", // pink 22 | ]; 23 | 24 | export const FOLDER_VALIDATION = { 25 | HEX_COLOR_REGEX: /^#[0-9A-Fa-f]{6}$/, 26 | HEX_COLOR_ERROR: "Color must be a valid hex color (e.g., #FF5733)", 27 | }; 28 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | frontend/node_modules 4 | server/node_modules 5 | packages/*/node_modules 6 | 7 | # Build outputs 8 | frontend/dist 9 | server/dist 10 | dist 11 | 12 | # Database files 13 | server/data/*.db 14 | server/data/*.db-* 15 | *.db 16 | *.db-shm 17 | *.db-wal 18 | 19 | # Git and version control 20 | .git 21 | .gitignore 22 | .gitattributes 23 | 24 | # Development files 25 | *.log 26 | tmp 27 | .env.local 28 | .env.*.local 29 | Screenshot* 30 | *.md 31 | !README.md 32 | .vscode 33 | .idea 34 | 35 | # Docker files (don't copy into image) 36 | Dockerfile 37 | .dockerignore 38 | docker-compose.yml 39 | docker-compose.*.yml 40 | 41 | # Documentation 42 | docs/ 43 | CLAUDE.md 44 | AGENTS.md 45 | 46 | # Scripts 47 | reset-db.sh 48 | -------------------------------------------------------------------------------- /packages/shared/src/constants/voice.js: -------------------------------------------------------------------------------- 1 | export const VOICE_CONSTANTS = { 2 | // Error and notification display 3 | ERROR_DISPLAY_DURATION_MS: 5000, 4 | 5 | // Speech recognition timing 6 | RECOGNITION_RESTART_DELAY_MS: 100, 7 | MIN_TRANSCRIPT_LENGTH: 2, 8 | 9 | // Text-to-speech timing 10 | TTS_COOLDOWN_DELAY_MS: 1000, 11 | 12 | // Text processing 13 | SENTENCE_SPLIT_PATTERN: /[^.!?]+[.!?]+/g, 14 | 15 | // Defaults 16 | DEFAULT_LANGUAGE: 'en-US', 17 | DEFAULT_LANGUAGE_PREFIX: 'en', 18 | 19 | // LocalStorage keys 20 | STORAGE_KEY_LANGUAGE: 'selectedLanguage', 21 | STORAGE_KEY_VOICE: 'selectedVoice', 22 | }; 23 | 24 | export const CHAT_STATES = { 25 | IDLE: 'idle', 26 | LISTENING: 'listening', 27 | PROCESSING: 'processing', 28 | SPEAKING: 'speaking', 29 | COOLDOWN: 'cooldown', 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/src/hooks/useReturnToChat.js: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "@tanstack/react-router"; 2 | import { useChatsQuery, useCreateChatMutation } from "./useChatsQuery"; 3 | 4 | export function useReturnToChat() { 5 | const { data: chats } = useChatsQuery(); 6 | const createChatMutation = useCreateChatMutation(); 7 | const navigate = useNavigate(); 8 | 9 | const returnToChat = async () => { 10 | const existingChat = chats?.[0]; 11 | 12 | if (existingChat) { 13 | navigate({ to: "/chat/$chatId", params: { chatId: existingChat.id } }); 14 | return; 15 | } 16 | 17 | const newChat = await createChatMutation.mutateAsync({}); 18 | navigate({ to: "/chat/$chatId", params: { chatId: newChat.id } }); 19 | }; 20 | 21 | return { 22 | returnToChat, 23 | isReturning: createChatMutation.isPending, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /packages/shared/src/index.js: -------------------------------------------------------------------------------- 1 | // Types 2 | export * from "./types/chat.js"; 3 | export * from "./types/models.js"; 4 | export * from "./types/database.js"; 5 | 6 | // Constants 7 | export * from "./constants/prompts.js"; 8 | export * from "./constants/ui.js"; 9 | export * from "./constants/files.js"; 10 | export * from "./constants/voice.js"; 11 | export * from "./constants/database.js"; 12 | export * from "./constants/providers.js"; 13 | export * from "./constants/config.js"; 14 | export * from "./constants/settings.js"; 15 | export * from "./constants/shortcuts.js"; 16 | export * from "./constants/imageGeneration.js"; 17 | export * from "./constants/import.js"; 18 | export * from "./constants/folders.js"; 19 | 20 | // Data 21 | export * from "./data/languages.js"; 22 | 23 | // Utilities 24 | export * from "./utils/formatters.js"; 25 | export * from "./utils/providerValidation.js"; 26 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file 2 | # See https://fly.io/docs/reference/configuration/ for information 3 | 4 | app = "faster-chat" 5 | primary_region = "sjc" 6 | 7 | [build] 8 | dockerfile = "Dockerfile" 9 | 10 | [env] 11 | NODE_ENV = "production" 12 | PORT = "8787" 13 | DATABASE_URL = "sqlite:///app/server/data/chat.db" 14 | 15 | [http_service] 16 | internal_port = 8787 17 | force_https = true 18 | auto_stop_machines = "stop" 19 | auto_start_machines = true 20 | min_machines_running = 0 21 | processes = ["app"] 22 | 23 | [[http_service.checks]] 24 | interval = "30s" 25 | timeout = "5s" 26 | grace_period = "10s" 27 | method = "GET" 28 | path = "/api/models" 29 | 30 | [mounts] 31 | source = "chat_data" 32 | destination = "/app/server/data" 33 | initial_size = "1gb" 34 | 35 | [[vm]] 36 | memory = "512mb" 37 | cpu_kind = "shared" 38 | cpus = 1 39 | -------------------------------------------------------------------------------- /frontend/src/lib/adminOnboarding.js: -------------------------------------------------------------------------------- 1 | const ADMIN_ONBOARDING_FLAG = "fc_admin_connections_onboarding_seen"; 2 | 3 | export const hasSeenAdminConnectionsOnboarding = (userId) => { 4 | if (!userId) return false; 5 | 6 | const raw = localStorage.getItem(ADMIN_ONBOARDING_FLAG); 7 | if (!raw) return false; 8 | 9 | try { 10 | const parsed = JSON.parse(raw); 11 | return Boolean(parsed?.[userId]); 12 | } catch { 13 | return false; 14 | } 15 | }; 16 | 17 | export const markAdminConnectionsOnboardingSeen = (userId) => { 18 | if (!userId) return; 19 | 20 | const raw = localStorage.getItem(ADMIN_ONBOARDING_FLAG); 21 | 22 | try { 23 | const parsed = raw ? JSON.parse(raw) : {}; 24 | parsed[userId] = true; 25 | localStorage.setItem(ADMIN_ONBOARDING_FLAG, JSON.stringify(parsed)); 26 | } catch { 27 | localStorage.setItem(ADMIN_ONBOARDING_FLAG, JSON.stringify({ [userId]: true })); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /frontend/src/hooks/queryKeys.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Query key factories for TanStack Query 3 | * Centralized here to avoid circular imports between hooks 4 | * Each factory includes userId to prevent cache bleed between users 5 | */ 6 | 7 | export const chatKeys = { 8 | all: (userId) => ["chats", userId], 9 | list: (userId) => [...chatKeys.all(userId), "list"], 10 | details: (userId) => [...chatKeys.all(userId), "detail"], 11 | detail: (userId, id) => [...chatKeys.details(userId), id], 12 | messages: (userId, chatId) => [...chatKeys.detail(userId, chatId), "messages"], 13 | }; 14 | 15 | export const folderKeys = { 16 | all: (userId) => ["folders", userId], 17 | list: (userId) => [...folderKeys.all(userId), "list"], 18 | details: (userId) => [...folderKeys.all(userId), "detail"], 19 | detail: (userId, folderId) => [...folderKeys.details(userId), folderId], 20 | chats: (userId, folderId) => [...folderKeys.detail(userId, folderId), "chats"], 21 | }; 22 | -------------------------------------------------------------------------------- /packages/shared/src/constants/shortcuts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Keyboard Shortcuts 3 | * 4 | * Single source of truth for all keyboard shortcuts. 5 | * Used by both the implementation (useKeyboardShortcuts) and UI display (Settings). 6 | */ 7 | 8 | export const KEYBOARD_SHORTCUTS = [ 9 | { 10 | id: "focusSearch", 11 | keys: ["Ctrl", "K"], 12 | label: "Search chats", 13 | check: (e) => e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "k", 14 | }, 15 | { 16 | id: "newChat", 17 | keys: ["Ctrl", "Shift", "O"], 18 | label: "New chat", 19 | check: (e) => e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "o", 20 | }, 21 | { 22 | id: "toggleSidebar", 23 | keys: ["Ctrl", "B"], 24 | label: "Toggle sidebar", 25 | check: (e) => e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "b", 26 | }, 27 | ]; 28 | 29 | // Helper to find shortcut by ID 30 | export const getShortcut = (id) => KEYBOARD_SHORTCUTS.find((s) => s.id === id); 31 | -------------------------------------------------------------------------------- /frontend/src/constants/voiceStateConfig.js: -------------------------------------------------------------------------------- 1 | import { Mic, Loader2, Volume2 } from "lucide-preact"; 2 | import { CHAT_STATES } from "@faster-chat/shared"; 3 | 4 | export const VOICE_STATE_CONFIG = { 5 | [CHAT_STATES.LISTENING]: { 6 | icon: Mic, 7 | text: "Listening...", 8 | color: "text-theme-green", 9 | bgColor: "bg-theme-green/10", 10 | animate: true, 11 | }, 12 | [CHAT_STATES.PROCESSING]: { 13 | icon: Loader2, 14 | text: "Processing...", 15 | color: "text-theme-blue", 16 | bgColor: "bg-theme-blue/10", 17 | animate: true, 18 | spin: true, 19 | }, 20 | [CHAT_STATES.SPEAKING]: { 21 | icon: Volume2, 22 | text: "Speaking...", 23 | color: "text-theme-mauve", 24 | bgColor: "bg-theme-mauve/10", 25 | animate: true, 26 | }, 27 | [CHAT_STATES.COOLDOWN]: { 28 | icon: Loader2, 29 | text: "Ready...", 30 | color: "text-theme-overlay", 31 | bgColor: "bg-theme-overlay/10", 32 | animate: false, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/lib/providerUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the logo URL for a provider from models.dev 3 | * @param {string} providerId - The provider identifier (e.g., 'anthropic', 'openai') 4 | * @returns {string} The URL to the provider's logo SVG 5 | */ 6 | export function getProviderLogoUrl(providerId) { 7 | return `https://models.dev/logos/${providerId}.svg`; 8 | } 9 | 10 | // RGB tuples keyed by provider id for tinted logo backgrounds 11 | export const providerBrandColors = { 12 | openai: [116, 170, 156], 13 | anthropic: [222, 115, 86], 14 | google: [66, 133, 244], 15 | mistral: [65, 33, 144], 16 | xai: [160, 103, 232], 17 | perplexity: [48, 85, 245], 18 | }; 19 | 20 | export function getProviderBranding(providerId) { 21 | const brandRgb = providerBrandColors[providerId]; 22 | 23 | if (!brandRgb) { 24 | return { className: "", style: undefined }; 25 | } 26 | 27 | return { 28 | className: "", 29 | style: { 30 | backgroundColor: `rgba(${brandRgb.join(",")}, 1)`, 31 | }, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/shared/src/data/languages.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Language Names Map 3 | * 4 | * Maps language codes to human-readable language names for voice selection 5 | */ 6 | export const LANGUAGE_NAMES = { 7 | 'en-US': 'English (US)', 8 | 'en-GB': 'English (UK)', 9 | 'es-ES': 'Spanish (Spain)', 10 | 'es-MX': 'Spanish (Mexico)', 11 | 'fr-FR': 'French', 12 | 'de-DE': 'German', 13 | 'it-IT': 'Italian', 14 | 'pt-BR': 'Portuguese (Brazil)', 15 | 'pt-PT': 'Portuguese (Portugal)', 16 | 'ru-RU': 'Russian', 17 | 'ja-JP': 'Japanese', 18 | 'ko-KR': 'Korean', 19 | 'zh-CN': 'Chinese (Simplified)', 20 | 'zh-TW': 'Chinese (Traditional)', 21 | 'ar-SA': 'Arabic', 22 | 'hi-IN': 'Hindi', 23 | 'nl-NL': 'Dutch', 24 | 'pl-PL': 'Polish', 25 | 'sv-SE': 'Swedish', 26 | 'tr-TR': 'Turkish', 27 | }; 28 | 29 | /** 30 | * Get human-readable language name from language code 31 | * Falls back to the language code itself if not found 32 | */ 33 | export const getLanguageName = (langCode) => LANGUAGE_NAMES[langCode] || langCode; 34 | -------------------------------------------------------------------------------- /frontend/src/components/layout/MainLayout.jsx: -------------------------------------------------------------------------------- 1 | import { useUiState } from "@/state/useUiState"; 2 | import { useRouterState } from "@tanstack/react-router"; 3 | 4 | const MainLayout = ({ sidebar, children }) => { 5 | const sidebarCollapsed = useUiState((state) => state.sidebarCollapsed); 6 | const pathname = useRouterState({ select: (state) => state.location.pathname }); 7 | const forceExpanded = pathname === "/admin" || pathname === "/settings"; 8 | const effectiveCollapsed = forceExpanded ? false : sidebarCollapsed; 9 | 10 | return ( 11 |
12 | {/* Sidebar */} 13 | {sidebar} 14 | 15 | {/* Main Content - shifts left when sidebar collapses */} 16 |
20 | {children} 21 |
22 |
23 | ); 24 | }; 25 | 26 | export default MainLayout; 27 | -------------------------------------------------------------------------------- /frontend/src/components/ui/ErrorBanner.jsx: -------------------------------------------------------------------------------- 1 | const ErrorBanner = ({ message, className = "" }) => { 2 | if (!message) return null; 3 | 4 | const getMessageText = (value) => { 5 | if (!value) return ""; 6 | if (typeof value === "string") return value; 7 | if (value instanceof Error && typeof value.message === "string") return value.message; 8 | if (typeof value === "object") { 9 | if (typeof value.message === "string") return value.message; 10 | if (typeof value.error === "string") return value.error; 11 | if (value.error && typeof value.error.message === "string") return value.error.message; 12 | } 13 | 14 | try { 15 | return String(value); 16 | } catch { 17 | return "An unexpected error occurred."; 18 | } 19 | }; 20 | 21 | const text = getMessageText(message); 22 | if (!text) return null; 23 | 24 | return ( 25 |
26 | {text} 27 |
28 | ); 29 | }; 30 | 31 | export default ErrorBanner; 32 | -------------------------------------------------------------------------------- /frontend/src/components/ui/ToolbarGroup.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * ToolbarGroup - Shared backdrop pill for grouping icon buttons 3 | * 4 | * Provides consistent visual styling for toolbar button clusters. 5 | * Children should be icon buttons with the toolbar button styling. 6 | */ 7 | export const ToolbarGroup = ({ children, className = "" }) => ( 8 |
9 |
10 | {children} 11 |
12 | ); 13 | 14 | /** 15 | * ToolbarButton - Consistent icon button styling for use within ToolbarGroup 16 | */ 17 | export const ToolbarButton = ({ onClick, title, children, className = "" }) => ( 18 | 24 | ); 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 1337 Hero, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/src/utils/voice/errorHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Voice error handling utilities 3 | */ 4 | 5 | const IGNORED_ERRORS = ["aborted", "no-speech"]; 6 | 7 | export const ERROR_TYPES = { 8 | RECOGNITION: "recognition", 9 | SYNTHESIS: "synthesis", 10 | MICROPHONE: "microphone", 11 | BROWSER_SUPPORT: "browser_support", 12 | }; 13 | 14 | export const ERROR_MESSAGES = { 15 | MICROPHONE_ERROR: (error) => `Microphone error: ${error}`, 16 | MICROPHONE_START_FAILED: "Failed to start microphone", 17 | BROWSER_NOT_SUPPORTED: "Voice not supported in this browser", 18 | RECOGNITION_RESTART_FAILED: "Failed to restart speech recognition", 19 | }; 20 | 21 | export const shouldReportError = (error) => { 22 | return !IGNORED_ERRORS.includes(error); 23 | }; 24 | 25 | export const handleVoiceError = (error, type, callback) => { 26 | console.error(`[Voice ${type}]`, error); 27 | 28 | if (callback && shouldReportError(error)) { 29 | const message = 30 | type === ERROR_TYPES.RECOGNITION || type === ERROR_TYPES.MICROPHONE 31 | ? ERROR_MESSAGES.MICROPHONE_ERROR(error) 32 | : error; 33 | callback(message); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /packages/shared/src/constants/files.js: -------------------------------------------------------------------------------- 1 | export const FILE_CONSTANTS = { 2 | MAX_FILE_SIZE_BYTES: 10 * 1024 * 1024, 3 | BYTES_PER_KB: 1024, 4 | MAX_FILENAME_LENGTH: 255, 5 | DEFAULT_FILENAME: "unnamed_file", 6 | ERROR_DISPLAY_DURATION_MS: 5000, 7 | 8 | MIME_IMAGE_JPEG: "image/jpeg", 9 | MIME_IMAGE_PNG: "image/png", 10 | MIME_IMAGE_GIF: "image/gif", 11 | MIME_IMAGE_WEBP: "image/webp", 12 | MIME_IMAGE_SVG: "image/svg+xml", 13 | 14 | MIME_PDF: "application/pdf", 15 | MIME_TEXT_PLAIN: "text/plain", 16 | MIME_TEXT_MARKDOWN: "text/markdown", 17 | MIME_TEXT_CSV: "text/csv", 18 | 19 | MIME_DOCX: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 20 | MIME_XLSX: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 21 | MIME_PPTX: "application/vnd.openxmlformats-officedocument.presentationml.presentation", 22 | 23 | MIME_JSON: "application/json", 24 | MIME_JAVASCRIPT: "application/javascript", 25 | MIME_HTML: "text/html", 26 | MIME_CSS: "text/css", 27 | MIME_XML: "application/xml", 28 | 29 | MIME_DEFAULT: "application/octet-stream", 30 | 31 | SIZE_UNITS: ["Bytes", "KB", "MB", "GB"], 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/src/components/ui/text.jsx: -------------------------------------------------------------------------------- 1 | import { clsx } from "@/lib/clsx"; 2 | import { Link } from "./link"; 3 | 4 | export const Text = ({ className, ...props }) => { 5 | return ( 6 |

11 | ); 12 | }; 13 | 14 | export const TextLink = ({ className, ...props }) => { 15 | return ( 16 | 23 | ); 24 | }; 25 | 26 | export const Strong = ({ className, ...props }) => { 27 | return ; 28 | }; 29 | 30 | export const Code = ({ className, ...props }) => { 31 | return ( 32 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | faster-chat: 3 | container_name: faster-chat 4 | image: faster-chat:latest 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | restart: unless-stopped 9 | env_file: 10 | - server/.env 11 | environment: 12 | - NODE_ENV=production 13 | - DATABASE_URL=${DATABASE_URL:-sqlite:///app/server/data/chat.db} 14 | - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} 15 | ports: 16 | - "1337:3001" 17 | extra_hosts: 18 | - host.docker.internal:host-gateway 19 | volumes: 20 | - chat-data:/app/server/data 21 | deploy: 22 | resources: 23 | limits: 24 | cpus: "1.0" 25 | memory: 512M 26 | reservations: 27 | cpus: "0.5" 28 | memory: 256M 29 | healthcheck: 30 | test: 31 | [ 32 | "CMD", 33 | "node", 34 | "-e", 35 | "require('http').get('http://localhost:8080/api/models', (r) => process.exit(r.statusCode === 200 ? 0 : 1))", 36 | ] 37 | interval: 30s 38 | timeout: 3s 39 | retries: 3 40 | start_period: 10s 41 | 42 | volumes: 43 | chat-data: 44 | -------------------------------------------------------------------------------- /frontend/src/hooks/useFileDragDrop.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from "preact/hooks"; 2 | 3 | /** 4 | * Hook for file drag & drop functionality 5 | * Returns state and handlers for a drop zone 6 | */ 7 | export const useFileDragDrop = (onFileSelect) => { 8 | const [dragActive, setDragActive] = useState(false); 9 | const fileInputRef = useRef(null); 10 | 11 | const handleDrag = (e) => { 12 | e.preventDefault(); 13 | e.stopPropagation(); 14 | setDragActive(e.type === "dragenter" || e.type === "dragover"); 15 | }; 16 | 17 | const handleDrop = async (e) => { 18 | e.preventDefault(); 19 | e.stopPropagation(); 20 | setDragActive(false); 21 | 22 | const file = e.dataTransfer.files?.[0]; 23 | if (file) { 24 | await onFileSelect(file); 25 | } 26 | }; 27 | 28 | const handleFileInput = async (e) => { 29 | const file = e.target.files?.[0]; 30 | if (file) { 31 | await onFileSelect(file); 32 | } 33 | }; 34 | 35 | const openFilePicker = () => fileInputRef.current?.click(); 36 | 37 | return { 38 | dragActive, 39 | fileInputRef, 40 | handleDrag, 41 | handleDrop, 42 | handleFileInput, 43 | openFilePicker, 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /packages/shared/src/utils/formatters.js: -------------------------------------------------------------------------------- 1 | import { FILE_CONSTANTS } from "../constants/files.js"; 2 | 3 | export function formatFileSize(bytes) { 4 | if (bytes === 0) return "0 Bytes"; 5 | 6 | const k = FILE_CONSTANTS.BYTES_PER_KB; 7 | const sizes = FILE_CONSTANTS.SIZE_UNITS; 8 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 9 | 10 | return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; 11 | } 12 | 13 | /** 14 | * Format a price value for display 15 | * @param {number|null|undefined} price - Price in dollars per 1M tokens 16 | * @returns {string} Formatted price string 17 | */ 18 | export function formatPrice(price) { 19 | if (!price) return "Free"; 20 | return `$${price.toFixed(2)}`; 21 | } 22 | 23 | /** 24 | * Format a context window size for display 25 | * @param {number|null|undefined} tokens - Number of tokens 26 | * @returns {string} Formatted context window string (e.g., "128K", "1.5M") 27 | */ 28 | export function formatContextWindow(tokens) { 29 | if (!tokens) return "Unknown"; 30 | if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; 31 | if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}K`; 32 | return tokens.toString(); 33 | } 34 | -------------------------------------------------------------------------------- /packages/shared/src/constants/import.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Import Constants 3 | * 4 | * Configuration for conversation import features. 5 | */ 6 | 7 | export const IMPORT_CONSTANTS = { 8 | // File Size Limits 9 | MAX_FILE_SIZE_BYTES: 50 * 1024 * 1024, // 50MB 10 | MAX_FILE_SIZE_MB: 50, 11 | 12 | // Supported Formats 13 | SUPPORTED_EXTENSION: '.json', 14 | 15 | // Import Sources 16 | SOURCES: { 17 | CHATGPT: 'chatgpt', 18 | }, 19 | 20 | // API Endpoints 21 | ENDPOINTS: { 22 | VALIDATE: '/api/import/validate', 23 | CHATGPT: '/api/import/chatgpt', 24 | }, 25 | 26 | // Preview Settings 27 | PREVIEW_LENGTH: 100, // Characters to show in message preview 28 | }; 29 | 30 | // Valid message roles for imported conversations 31 | export const IMPORT_VALID_ROLES = new Set(['user', 'assistant', 'system']); 32 | 33 | // Default role when import doesn't specify 34 | export const IMPORT_DEFAULT_ROLE = 'user'; 35 | 36 | // Time conversion (ChatGPT uses Unix seconds, we use milliseconds) 37 | export const MS_PER_SECOND = 1000; 38 | 39 | /** 40 | * Convert Unix timestamp (seconds) to milliseconds 41 | */ 42 | export const unixToMs = (unixTimestamp) => 43 | unixTimestamp ? unixTimestamp * MS_PER_SECOND : Date.now(); 44 | -------------------------------------------------------------------------------- /frontend/src/components/layout/SidebarToolbar.jsx: -------------------------------------------------------------------------------- 1 | import { ToolbarButton, ToolbarGroup } from "@/components/ui/ToolbarGroup"; 2 | import { useUiState } from "@/state/useUiState"; 3 | import { PanelLeft, Plus, Search } from "lucide-preact"; 4 | 5 | const SidebarToolbar = ({ onNewChat, onSearch }) => { 6 | const sidebarCollapsed = useUiState((state) => state.sidebarCollapsed); 7 | const toggleSidebarCollapse = useUiState((state) => state.toggleSidebarCollapse); 8 | 9 | // When sidebar open: negative margin pulls toolbar behind sidebar 10 | // When collapsed: sits normally in the flex layout 11 | return ( 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default SidebarToolbar; 32 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@faster-chat/server", 3 | "version": "0.2.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "init": "bun run src/init.js", 8 | "dev": "bun run src/init.js && bun run src/index.js", 9 | "build": "echo 'Build complete'", 10 | "start": "bun run src/init.js && bun run src/index.js" 11 | }, 12 | "dependencies": { 13 | "@ai-sdk/amazon-bedrock": "^3.0.56", 14 | "@ai-sdk/anthropic": "^2.0.45", 15 | "@ai-sdk/azure": "^2.0.73", 16 | "@ai-sdk/cerebras": "^1.0.31", 17 | "@ai-sdk/cohere": "^2.0.19", 18 | "@ai-sdk/deepseek": "^1.0.29", 19 | "@ai-sdk/fireworks": "^1.0.28", 20 | "@ai-sdk/google": "^2.0.40", 21 | "@ai-sdk/google-vertex": "^3.0.72", 22 | "@ai-sdk/groq": "^2.0.31", 23 | "@ai-sdk/mistral": "^2.0.24", 24 | "@ai-sdk/openai": "^2.0.69", 25 | "@ai-sdk/xai": "^2.0.35", 26 | "@faster-chat/shared": "workspace:*", 27 | "@hono/node-server": "^1.14.3", 28 | "@node-rs/argon2": "^2.0.2", 29 | "ai": "^5.0.97", 30 | "dotenv": "^16.4.7", 31 | "hono": "^4.7.12", 32 | "ollama-ai-provider": "^1.2.0", 33 | "replicate": "^1.4.0", 34 | "zod": "^4.1.12" 35 | }, 36 | "devDependencies": { 37 | "@types/node": "^20.19.25" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | format-check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Bun 16 | uses: oven-sh/setup-bun@v1 17 | with: 18 | bun-version: latest 19 | 20 | - name: Install dependencies 21 | run: bun install 22 | 23 | - name: Check formatting 24 | run: bun run format:check 25 | 26 | build-frontend: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: Setup Bun 32 | uses: oven-sh/setup-bun@v1 33 | with: 34 | bun-version: latest 35 | 36 | - name: Install dependencies 37 | run: bun install 38 | 39 | - name: Build frontend 40 | run: bun run build:frontend 41 | 42 | build-server: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - name: Setup Bun 48 | uses: oven-sh/setup-bun@v1 49 | with: 50 | bun-version: latest 51 | 52 | - name: Install dependencies 53 | run: bun install 54 | 55 | - name: Build server 56 | run: bun run build:server 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #1337Hero's Overkill Git Ignore! 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 | # Monorepo packages 17 | /frontend/node_modules 18 | /server/node_modules 19 | /packages/shared/node_modules 20 | 21 | # production 22 | /build 23 | dist 24 | dist-ssr 25 | *.local 26 | 27 | # Logs 28 | logs 29 | *.log 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | pnpm-debug.log* 34 | lerna-debug.log* 35 | 36 | # do not commit any .env files to git 37 | .env 38 | !.env.sample 39 | .env.backup 40 | 41 | # Database (ignore all .db files everywhere) 42 | *.db 43 | *.db-shm 44 | *.db-wal 45 | 46 | # File uploads (keep directory structure but ignore uploaded files) 47 | /server/data/uploads/* 48 | !/server/data/uploads/.gitkeep 49 | 50 | #cache 51 | .eslintcache 52 | 53 | # vercel 54 | .vercel 55 | 56 | # typescript 57 | *.tsbuildinfo 58 | next-env.d.ts 59 | 60 | #misc 61 | /_backup 62 | /.idea 63 | /docs 64 | 65 | # Editor directories and files 66 | ._* 67 | .vscode/* 68 | !.vscode/extensions.json 69 | .DS_Store 70 | .Spotlight-V100 71 | .Trashes 72 | *.suo 73 | *.ntvs* 74 | *.njsproj 75 | *.sln 76 | *.sw? 77 | 78 | #AI Agents 79 | .claude 80 | CLAUDE.md 81 | GEMINI.md -------------------------------------------------------------------------------- /packages/shared/src/constants/prompts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} SystemPrompt 3 | * @property {string} id 4 | * @property {string} name 5 | * @property {string} content 6 | * @property {string} description 7 | */ 8 | 9 | /** 10 | * @type {SystemPrompt[]} 11 | */ 12 | export const systemPrompts = [ 13 | { 14 | id: "default", 15 | name: "Default", 16 | content: "You are a highly capable, thoughtful, and precise assistant. Your goal is to deeply understand the user's intent, ask clarifying questions when needed, think step-by-step through complex problems, provide clear and accurate answers, and proactively anticipate helpful follow-up information. Always prioritize being truthful, nuanced, insightful, and efficient, tailoring your responses specifically to the user's needs and preferences. Try to match the user's vibe, tone, and generally how they are speaking.", 17 | description: "A helpful assistant", 18 | }, 19 | ]; 20 | 21 | /** 22 | * Get a system prompt by ID, returns default if not found 23 | * @param {string} id 24 | * @returns {SystemPrompt} 25 | */ 26 | export const getSystemPrompt = (id) => { 27 | const prompt = systemPrompts.find((p) => p.id === id); 28 | if (!prompt) { 29 | return systemPrompts[0]; // Return default prompt if ID not found 30 | } 31 | return prompt; 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/src/hooks/useChatPersistence.js: -------------------------------------------------------------------------------- 1 | import { useChatQuery, useMessagesQuery, useCreateMessageMutation } from "./useChatsQuery"; 2 | 3 | export function useChatPersistence(chatId) { 4 | const { data: chat, isLoading: isChatLoading, isError: isChatError } = useChatQuery(chatId); 5 | const { data: messages } = useMessagesQuery(chatId); 6 | const createMessageMutation = useCreateMessageMutation(); 7 | 8 | async function saveUserMessage( 9 | { id, content, fileIds = [], createdAt, model = null }, 10 | currentChatId 11 | ) { 12 | const message = { 13 | id, 14 | role: "user", 15 | content, 16 | fileIds, 17 | createdAt, 18 | model, 19 | }; 20 | 21 | return createMessageMutation.mutateAsync({ chatId: currentChatId, message }); 22 | } 23 | 24 | async function saveAssistantMessage({ id, content, model = null, createdAt }, currentChatId) { 25 | const message = { 26 | id, 27 | role: "assistant", 28 | content, 29 | model, 30 | createdAt, 31 | }; 32 | 33 | return createMessageMutation.mutateAsync({ chatId: currentChatId, message }); 34 | } 35 | 36 | return { 37 | chat, 38 | messages: messages ?? [], 39 | isChatLoading, 40 | isChatError, 41 | saveUserMessage, 42 | saveAssistantMessage, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/lib/authClient.js: -------------------------------------------------------------------------------- 1 | const API_BASE = import.meta.env.DEV ? "http://localhost:3001" : ""; 2 | 3 | async function authFetch(endpoint, options = {}) { 4 | const response = await fetch(`${API_BASE}/api/auth${endpoint}`, { 5 | ...options, 6 | headers: { 7 | "Content-Type": "application/json", 8 | ...options.headers, 9 | }, 10 | credentials: "include", 11 | }); 12 | 13 | const data = await response.json(); 14 | 15 | if (!response.ok) { 16 | throw new Error(data.error || "Request failed"); 17 | } 18 | 19 | return data; 20 | } 21 | 22 | export const authClient = { 23 | async register(username, password) { 24 | return authFetch("/register", { 25 | method: "POST", 26 | body: JSON.stringify({ username, password }), 27 | }); 28 | }, 29 | 30 | async login(username, password) { 31 | return authFetch("/login", { 32 | method: "POST", 33 | body: JSON.stringify({ username, password }), 34 | }); 35 | }, 36 | 37 | async logout() { 38 | return authFetch("/logout", { 39 | method: "POST", 40 | }); 41 | }, 42 | 43 | async getSession() { 44 | try { 45 | return await authFetch("/session", { 46 | method: "GET", 47 | }); 48 | } catch (error) { 49 | return { user: null }; 50 | } 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /packages/shared/src/constants/ui.js: -------------------------------------------------------------------------------- 1 | /** 2 | * UI Constants 3 | * 4 | * Central location for all magic numbers used throughout the UI. 5 | * Following DHH's principle: No magic numbers - name everything. 6 | */ 7 | 8 | export const UI_CONSTANTS = { 9 | // Input Area 10 | INPUT_MAX_HEIGHT: 240, 11 | 12 | // Chat Interface 13 | MESSAGE_LIST_PADDING_BOTTOM: 180, 14 | MESSAGE_LIST_PADDING_TOP: 16, 15 | CHAT_CONTAINER_MAX_WIDTH: "48rem", // max-w-3xl in Tailwind 16 | 17 | // Chat Management 18 | CHAT_TITLE_MAX_LENGTH: 50, 19 | CHAT_TITLE_ELLIPSIS: "...", 20 | 21 | // Sidebar 22 | SIDEBAR_WIDTH_MOBILE_PERCENT: "90%", 23 | SIDEBAR_WIDTH_DESKTOP_COLLAPSED: 80, 24 | SIDEBAR_WIDTH_DESKTOP_EXPANDED: 320, 25 | SIDEBAR_FOCUS_DELAY_MS: 100, 26 | 27 | // Breakpoints (matches Tailwind defaults) 28 | BREAKPOINT_MD: 768, 29 | BREAKPOINT_SM: 640, 30 | 31 | // Scrollbar 32 | SCROLLBAR_WIDTH: "0.25rem", 33 | 34 | // Feedback Durations 35 | SUCCESS_MESSAGE_DURATION_MS: 2000, 36 | COPY_FEEDBACK_DURATION_MS: 1500, 37 | 38 | // Form Inputs 39 | APP_NAME_MAX_LENGTH: 50, 40 | 41 | // Icon Picker 42 | ICON_PICKER_COLUMNS: 10, 43 | }; 44 | 45 | // Semantic icon sizes for lucide-preact (in pixels) 46 | export const ICON_SIZE = { 47 | XS: 12, 48 | SM: 14, 49 | MD: 16, 50 | LG: 18, 51 | XL: 20, 52 | XXL: 24, 53 | }; 54 | -------------------------------------------------------------------------------- /frontend/src/components/ui/ThemeToggle.jsx: -------------------------------------------------------------------------------- 1 | import { useThemeStore } from "@/state/useThemeStore"; 2 | import { Moon, Sun } from "lucide-preact"; 3 | import { useRef } from "preact/hooks"; 4 | 5 | const CLICK_SOUND_PATH = "/sounds/light-on.mp3"; 6 | const CLICK_SOUND_VOLUME = 0.25; 7 | 8 | export const ThemeToggle = () => { 9 | const mode = useThemeStore((state) => state.mode); 10 | const toggleMode = useThemeStore((state) => state.toggleMode); 11 | const clickSoundRef = useRef(null); 12 | 13 | const handleToggle = () => { 14 | toggleMode(); 15 | 16 | // Lazy-load and play sound 17 | if (!clickSoundRef.current) { 18 | const audio = new Audio(CLICK_SOUND_PATH); 19 | audio.volume = CLICK_SOUND_VOLUME; 20 | clickSoundRef.current = audio; 21 | } 22 | 23 | const audio = clickSoundRef.current; 24 | if (audio) { 25 | audio.currentTime = 0; 26 | audio.play().catch(() => {}); 27 | } 28 | }; 29 | 30 | return ( 31 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@faster-chat/frontend", 3 | "version": "0.2.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@ai-sdk/react": "^2.0.97", 13 | "@faster-chat/shared": "workspace:*", 14 | "@headlessui/react": "^2.2.0", 15 | "@preact/compat": "^18.3.1", 16 | "@preact/preset-vite": "^2.10.1", 17 | "@tanstack/react-query": "^5.64.2", 18 | "@tanstack/react-router": "^1.98.0", 19 | "@tanstack/react-virtual": "^3.13.2", 20 | "ai": "^5.0.97", 21 | "clsx": "^2.1.1", 22 | "katex": "^0.16.25", 23 | "lucide-preact": "^0.554.0", 24 | "nanoid": "^5.0.0", 25 | "preact": "^10.25.5", 26 | "react-markdown": "^9.0.1", 27 | "rehype-katex": "^7.0.1", 28 | "remark-gfm": "^4.0.1", 29 | "remark-math": "^6.0.0", 30 | "shiki": "^3.19.0", 31 | "sonner": "^2.0.7", 32 | "tailwind-merge": "^3.4.0", 33 | "tw-animate-css": "^1.4.0", 34 | "zustand": "^5.0.3" 35 | }, 36 | "devDependencies": { 37 | "@preact/preset-vite": "^2.10.2", 38 | "@tailwindcss/vite": "^4.1.17", 39 | "tailwindcss": "^4.1.17", 40 | "autoprefixer": "^10.4.22", 41 | "prettier": "^3.6.2", 42 | "prettier-plugin-tailwindcss": "^0.6.14", 43 | "vite": "^5.4.16" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 | import { RouterProvider } from "@tanstack/react-router"; 3 | import { Toaster } from "sonner"; 4 | import { useEffect } from "preact/hooks"; 5 | import { router } from "./router"; 6 | import { useThemeStore } from "./state/useThemeStore"; 7 | import { useAppSettings } from "./state/useAppSettings"; 8 | 9 | export const queryClient = new QueryClient({ 10 | defaultOptions: { 11 | queries: { 12 | staleTime: 1000 * 60 * 5, 13 | gcTime: 1000 * 60 * 10, 14 | }, 15 | }, 16 | }); 17 | 18 | const App = () => { 19 | const initializeTheme = useThemeStore((state) => state.initializeTheme); 20 | const mode = useThemeStore((state) => state.mode); 21 | const fetchSettings = useAppSettings((state) => state.fetchSettings); 22 | 23 | // Initialize theme and app settings on app mount 24 | useEffect(() => { 25 | initializeTheme(); 26 | fetchSettings(); 27 | }, []); 28 | 29 | return ( 30 | 31 | 32 | 39 | 40 | ); 41 | }; 42 | 43 | export default App; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "faster-chat-monorepo", 3 | "version": "0.2.0", 4 | "private": true, 5 | "type": "module", 6 | "workspaces": [ 7 | "frontend", 8 | "server", 9 | "packages/*" 10 | ], 11 | "scripts": { 12 | "dev": "concurrently \"bun run dev:frontend\" \"bun run dev:server\"", 13 | "dev:frontend": "cd frontend && bun run dev", 14 | "dev:server": "cd server && bun run dev", 15 | "build": "bun run build:frontend && bun run build:server", 16 | "build:frontend": "cd frontend && bun run build", 17 | "build:server": "cd server && bun run build", 18 | "start": "cd server && bun run start", 19 | "clean": "rm -rf frontend/dist server/dist node_modules frontend/node_modules server/node_modules packages/*/node_modules", 20 | "install:all": "bun install", 21 | "format": "prettier --write \"frontend/src/**/*.{js,jsx,ts,tsx,css,json}\" \"server/src/**/*.{js,jsx,ts,tsx,json}\"", 22 | "format:check": "prettier --check \"frontend/src/**/*.{js,jsx,ts,tsx,css,json}\" \"server/src/**/*.{js,jsx,ts,tsx,json}\"" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/1337hero/faster-next-chat" 27 | }, 28 | "license": "MIT", 29 | "author": "1337 Hero, LLC", 30 | "devDependencies": { 31 | "concurrently": "^9.2.1", 32 | "prettier": "^3.7.4", 33 | "prettier-plugin-tailwindcss": "^0.7.2" 34 | }, 35 | "dependencies": { 36 | "fuzzysort": "^3.1.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | # Caddy configuration for Faster Chat 2 | # Automatic HTTPS with Let's Encrypt 3 | 4 | # Production: Replace with your actual domain 5 | # chat.yourdomain.com { 6 | # reverse_proxy faster-chat:8787 7 | # 8 | # # Security headers 9 | # header { 10 | # Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 11 | # X-Content-Type-Options "nosniff" 12 | # X-Frame-Options "DENY" 13 | # Referrer-Policy "strict-origin-when-cross-origin" 14 | # Permissions-Policy "geolocation=(), microphone=(), camera=()" 15 | # } 16 | # 17 | # # Enable compression 18 | # encode gzip zstd 19 | # 20 | # # Access logs (optional) 21 | # log { 22 | # output file /data/access.log 23 | # } 24 | # } 25 | 26 | # Local HTTPS with self-signed certificate 27 | chat.local { 28 | tls internal # Caddy generates self-signed cert 29 | reverse_proxy faster-chat:8787 30 | 31 | encode gzip zstd 32 | 33 | # Security headers 34 | header { 35 | Strict-Transport-Security "max-age=31536000" 36 | X-Content-Type-Options "nosniff" 37 | } 38 | } 39 | 40 | # Also keep HTTP on port 80 41 | :80 { 42 | reverse_proxy faster-chat:8787 43 | encode gzip zstd 44 | } 45 | 46 | # Alternative: Use a custom local domain (requires /etc/hosts entry) 47 | # chat.local { 48 | # tls internal # Self-signed cert for local development 49 | # reverse_proxy faster-chat:8787 50 | # } 51 | -------------------------------------------------------------------------------- /frontend/src/components/ui/dropdown.jsx: -------------------------------------------------------------------------------- 1 | import * as Headless from "@headlessui/react"; 2 | import { clsx } from "@/lib/clsx"; 3 | import { Link } from "./link"; 4 | 5 | export const Dropdown = (props) => ; 6 | 7 | export const DropdownButton = ({ as = "button", className, ...props }) => { 8 | return ( 9 | 17 | ); 18 | }; 19 | 20 | export const DropdownMenu = ({ anchor = "bottom", className, ...props }) => { 21 | return ( 22 | 28 | ); 29 | }; 30 | 31 | export const DropdownItem = ({ className, ...props }) => { 32 | const classes = clsx( 33 | className, 34 | // Base styles 35 | "block px-4 py-2 text-sm text-theme-text hover:bg-theme-surface-strong" 36 | ); 37 | 38 | return "href" in props ? ( 39 | 40 | ) : ( 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /frontend/src/lib/adminClient.js: -------------------------------------------------------------------------------- 1 | const API_BASE = import.meta.env.DEV ? "http://localhost:3001" : ""; 2 | 3 | class AdminClient { 4 | async _fetch(endpoint, options = {}) { 5 | const response = await fetch(`${API_BASE}${endpoint}`, { 6 | ...options, 7 | credentials: "include", 8 | headers: { 9 | "Content-Type": "application/json", 10 | ...options.headers, 11 | }, 12 | }); 13 | 14 | const data = await response.json(); 15 | 16 | if (!response.ok) { 17 | throw new Error(data.error || "Request failed"); 18 | } 19 | 20 | return data; 21 | } 22 | 23 | async getUsers() { 24 | return this._fetch("/api/admin/users"); 25 | } 26 | 27 | async createUser(username, password, role = "member") { 28 | return this._fetch("/api/admin/users", { 29 | method: "POST", 30 | body: JSON.stringify({ username, password, role }), 31 | }); 32 | } 33 | 34 | async updateUserRole(userId, role) { 35 | return this._fetch(`/api/admin/users/${userId}/role`, { 36 | method: "PUT", 37 | body: JSON.stringify({ role }), 38 | }); 39 | } 40 | 41 | async resetUserPassword(userId, password) { 42 | return this._fetch(`/api/admin/users/${userId}/password`, { 43 | method: "PUT", 44 | body: JSON.stringify({ password }), 45 | }); 46 | } 47 | 48 | async deleteUser(userId) { 49 | return this._fetch(`/api/admin/users/${userId}`, { 50 | method: "DELETE", 51 | }); 52 | } 53 | } 54 | 55 | export const adminClient = new AdminClient(); 56 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | # Auto-labeling configuration for PRs based on file paths 2 | 3 | 'frontend': 4 | - changed-files: 5 | - any-glob-to-any-file: 'frontend/**/*' 6 | 7 | 'backend': 8 | - changed-files: 9 | - any-glob-to-any-file: 'server/**/*' 10 | 11 | 'shared': 12 | - changed-files: 13 | - any-glob-to-any-file: 'packages/shared/**/*' 14 | 15 | 'documentation': 16 | - changed-files: 17 | - any-glob-to-any-file: 18 | - 'docs/**/*' 19 | - '*.md' 20 | - 'CLAUDE.md' 21 | - 'AGENTS.md' 22 | 23 | 'components': 24 | - changed-files: 25 | - any-glob-to-any-file: 'frontend/src/components/**/*' 26 | 27 | 'database': 28 | - changed-files: 29 | - any-glob-to-any-file: 30 | - 'frontend/src/lib/db.js' 31 | - 'server/src/lib/db.js' 32 | - '**/migrations/**/*' 33 | 34 | 'dependencies': 35 | - changed-files: 36 | - any-glob-to-any-file: 37 | - 'package.json' 38 | - 'frontend/package.json' 39 | - 'server/package.json' 40 | - 'bun.lockb' 41 | 42 | 'docker': 43 | - changed-files: 44 | - any-glob-to-any-file: 45 | - 'Dockerfile' 46 | - 'docker-compose.yml' 47 | - '.dockerignore' 48 | 49 | 'github-actions': 50 | - changed-files: 51 | - any-glob-to-any-file: '.github/workflows/**/*' 52 | 53 | 'config': 54 | - changed-files: 55 | - any-glob-to-any-file: 56 | - '*.config.js' 57 | - '*.config.mjs' 58 | - 'frontend/*.config.js' 59 | - 'server/*.config.js' 60 | - 'tailwind.config.js' 61 | - 'vite.config.js' 62 | -------------------------------------------------------------------------------- /frontend/src/components/ui/ProviderLogo.jsx: -------------------------------------------------------------------------------- 1 | import { getProviderLogoUrl, getProviderBranding } from "@/lib/providerUtils"; 2 | 3 | const SIZES = { 4 | xs: "h-4 w-4", 5 | sm: "h-5 w-5", 6 | md: "h-6 w-6", 7 | lg: "h-8 w-8", 8 | }; 9 | 10 | const ICON_SIZES = { 11 | xs: "h-2.5 w-2.5", 12 | sm: "h-3 w-3", 13 | md: "h-4 w-4", 14 | lg: "h-5 w-5", 15 | }; 16 | 17 | /** 18 | * ProviderLogo - Display a provider's logo with branded background 19 | * @param {string} providerId - The provider identifier (e.g., 'anthropic', 'openai') 20 | * @param {string} displayName - Display name for alt text 21 | * @param {"xs"|"sm"|"md"|"lg"} size - Size variant (default: "md") 22 | * @param {string} className - Additional classes for the container 23 | */ 24 | const ProviderLogo = ({ providerId, displayName, size = "md", className = "" }) => { 25 | const branding = getProviderBranding(providerId); 26 | const logoUrl = getProviderLogoUrl(providerId); 27 | 28 | return ( 29 |

34 | {`${displayName { 39 | e.target.parentElement.style.display = "none"; 40 | }} 41 | /> 42 |
43 | ); 44 | }; 45 | 46 | export default ProviderLogo; 47 | -------------------------------------------------------------------------------- /frontend/src/components/ui/Avatar.jsx: -------------------------------------------------------------------------------- 1 | import { clsx } from "clsx"; 2 | 3 | const borderVariants = { 4 | none: "border-0", 5 | visible: "border-2 border-theme-lavender", 6 | }; 7 | 8 | const backgroundVariants = { 9 | strong: "bg-theme-canvas-strong", 10 | subtle: "bg-theme-canvas-alt", 11 | base: "bg-theme-canvas", 12 | }; 13 | 14 | const sizeVariants = { 15 | small: "h-9 w-9 text-md", 16 | medium: "h-12 w-12 text-xl", 17 | large: "h-16 w-16 text-2xl", 18 | }; 19 | 20 | export const Avatar = ({ 21 | src, 22 | name, 23 | border = "visible", 24 | background = "subtle", 25 | size = "medium", 26 | ...props 27 | }) => { 28 | let initial = null; 29 | let last = null; 30 | 31 | if (name) { 32 | const split = name.split(" "); 33 | initial = split[0]?.slice(0, 1); 34 | 35 | if (split.length > 1) { 36 | last = split[split.length - 1]?.slice(0, 1); 37 | } 38 | } 39 | 40 | return ( 41 | 49 | {src ? ( 50 | 54 | ) : ( 55 | 56 | {initial?.toUpperCase()} 57 | {last?.toUpperCase()} 58 | 59 | )} 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /frontend/src/components/chat/VoiceStatusIndicator.jsx: -------------------------------------------------------------------------------- 1 | import { Settings } from "lucide-preact"; 2 | import { VOICE_STATE_CONFIG } from "@/constants/voiceStateConfig"; 3 | 4 | /** 5 | * Voice Status Indicator 6 | * 7 | * Shows current voice conversation state with visual feedback. 8 | * Clickable to open voice settings modal. 9 | */ 10 | const VoiceStatusIndicator = ({ voiceControls, onOpenSettings }) => { 11 | if (!voiceControls.isActive) return null; 12 | 13 | const stateInfo = VOICE_STATE_CONFIG[voiceControls.currentState]; 14 | if (!stateInfo) return null; 15 | 16 | const Icon = stateInfo.icon; 17 | 18 | return ( 19 | 39 | ); 40 | }; 41 | 42 | export default VoiceStatusIndicator; 43 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import preact from "@preact/preset-vite"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import path from "path"; 4 | import { fileURLToPath } from "url"; 5 | import { defineConfig } from "vite"; 6 | 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 | 9 | export default defineConfig({ 10 | plugins: [ 11 | preact(), 12 | tailwindcss({ 13 | config: { 14 | theme: { 15 | extend: { 16 | fontFamily: { 17 | mono: ["JetBrains Mono", "monospace"], 18 | sans: ["Sora", "sans-serif"], 19 | }, 20 | transitionTimingFunction: { 21 | // T3 Chat's snappy easing - fast start, quick settle 22 | snappy: "cubic-bezier(.2, .4, .1, .95)", 23 | }, 24 | }, 25 | }, 26 | }, 27 | }), 28 | ], 29 | 30 | // Custom CSS Configuration 31 | css: { 32 | devSourcemap: true, 33 | }, 34 | 35 | // Custom Alias and Extensions 36 | resolve: { 37 | alias: { 38 | "@": path.resolve(__dirname, "./src"), 39 | // Map React to Preact for compatibility 40 | react: "@preact/compat", 41 | "react-dom": "@preact/compat", 42 | "react/jsx-runtime": "@preact/compat/jsx-runtime", 43 | }, 44 | extensions: [".js", ".jsx"], 45 | }, 46 | 47 | // Server Configuration 48 | server: { 49 | port: 3000, 50 | proxy: { 51 | "/api": { 52 | target: "http://localhost:3001", 53 | changeOrigin: true, 54 | }, 55 | }, 56 | }, 57 | optimizeDeps: { 58 | include: ["@preact/compat"], 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /frontend/src/state/useUiState.js: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | 4 | export const useUiState = create( 5 | persist( 6 | (set) => ({ 7 | sidebarOpen: true, 8 | sidebarCollapsed: false, 9 | preferredModel: "claude-sonnet-4-5", 10 | preferredImageModel: null, 11 | theme: "dark", 12 | autoScroll: true, 13 | imageMode: false, 14 | 15 | setSidebarOpen: (isOpen) => set({ sidebarOpen: isOpen }), 16 | toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), 17 | setSidebarCollapsed: (isCollapsed) => set({ sidebarCollapsed: isCollapsed }), 18 | toggleSidebarCollapse: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })), 19 | setPreferredModel: (modelId) => set({ preferredModel: modelId }), 20 | setPreferredImageModel: (modelId) => set({ preferredImageModel: modelId }), 21 | toggleTheme: () => set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })), 22 | setAutoScroll: (enabled) => set({ autoScroll: enabled }), 23 | setImageMode: (enabled) => set({ imageMode: enabled }), 24 | toggleImageMode: () => set((state) => ({ imageMode: !state.imageMode })), 25 | }), 26 | { 27 | name: "ui-state", 28 | partialize: (state) => ({ 29 | sidebarOpen: state.sidebarOpen, 30 | sidebarCollapsed: state.sidebarCollapsed, 31 | preferredModel: state.preferredModel, 32 | preferredImageModel: state.preferredImageModel, 33 | theme: state.theme, 34 | autoScroll: state.autoScroll, 35 | }), 36 | } 37 | ) 38 | ); 39 | -------------------------------------------------------------------------------- /packages/shared/src/constants/settings.js: -------------------------------------------------------------------------------- 1 | /** 2 | * App Settings Constants 3 | * 4 | * Centralized definition of app settings defaults and logo icons. 5 | * Used across frontend (CustomizeTab, Sidebar) and backend (validation). 6 | */ 7 | 8 | // Logo icon names in display order 9 | export const LOGO_ICON_NAMES = [ 10 | "Zap", 11 | "Rocket", 12 | "Sparkles", 13 | "Bot", 14 | "Cpu", 15 | "Terminal", 16 | "Code", 17 | "Braces", 18 | "MessageSquare", 19 | "MessagesSquare", 20 | "Send", 21 | "Mail", 22 | "AtSign", 23 | "Circle", 24 | "Square", 25 | "Triangle", 26 | "Hexagon", 27 | "Star", 28 | "Heart", 29 | "Diamond", 30 | "Flame", 31 | "Sun", 32 | "Moon", 33 | "Cloud", 34 | "Leaf", 35 | "Mountain", 36 | "Ghost", 37 | "Smile", 38 | "Trophy", 39 | "ChessKnight", 40 | "ChessQueen", 41 | "Atom", 42 | "BadgeDollarSign", 43 | "Bookmark", 44 | "Cat", 45 | "Dog", 46 | "Fish", 47 | "Coffee", 48 | "Pizza", 49 | "IceCream", 50 | "Gem", 51 | "Command", 52 | "Hash", 53 | "Flag", 54 | "Pin", 55 | "Home", 56 | "Library", 57 | "Cherry", 58 | "Sprout", 59 | "Sword" 60 | ]; 61 | 62 | // Default app settings 63 | export const DEFAULT_APP_SETTINGS = { 64 | appName: "Faster Chat", 65 | logoIcon: "Zap", 66 | }; 67 | 68 | /** 69 | * Normalize settings with defaults 70 | * @param {Object} settings - Raw settings from API or DB 71 | * @returns {Object} Settings with defaults applied 72 | */ 73 | export const normalizeAppSettings = (settings = {}) => ({ 74 | appName: settings.appName || DEFAULT_APP_SETTINGS.appName, 75 | logoIcon: settings.logoIcon || DEFAULT_APP_SETTINGS.logoIcon, 76 | }); 77 | -------------------------------------------------------------------------------- /frontend/src/hooks/useImageGeneration.js: -------------------------------------------------------------------------------- 1 | import { IMAGE_GENERATION } from "@faster-chat/shared"; 2 | import { useMutation, useQuery } from "@tanstack/react-query"; 3 | 4 | const API_BASE = import.meta.env.DEV ? "http://localhost:3001" : ""; 5 | 6 | async function generateImage({ 7 | prompt, 8 | aspectRatio = IMAGE_GENERATION.DEFAULT_ASPECT_RATIO, 9 | chatId, 10 | model, 11 | }) { 12 | const response = await fetch(`${API_BASE}/api/images/generate`, { 13 | method: "POST", 14 | credentials: "include", 15 | headers: { "Content-Type": "application/json" }, 16 | body: JSON.stringify({ prompt, aspectRatio, chatId, modelId: model }), 17 | }); 18 | 19 | const data = await response.json(); 20 | 21 | if (!response.ok) { 22 | throw new Error(data.error || "Image generation failed"); 23 | } 24 | 25 | return data; 26 | } 27 | 28 | async function fetchImageStatus() { 29 | const response = await fetch(`${API_BASE}/api/images/status`, { 30 | credentials: "include", 31 | }); 32 | return response.json(); 33 | } 34 | 35 | export function useImageGeneration({ onSuccess, onError } = {}) { 36 | const mutation = useMutation({ 37 | mutationFn: generateImage, 38 | onSuccess, 39 | onError, 40 | }); 41 | 42 | return { 43 | generate: mutation.mutate, 44 | generateAsync: mutation.mutateAsync, 45 | isGenerating: mutation.isPending, 46 | error: mutation.error, 47 | data: mutation.data, 48 | reset: mutation.reset, 49 | }; 50 | } 51 | 52 | export function useImageStatus() { 53 | return useQuery({ 54 | queryKey: ["image-status"], 55 | queryFn: fetchImageStatus, 56 | staleTime: 5 * 60 * 1000, 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /frontend/src/components/ui/Radio.jsx: -------------------------------------------------------------------------------- 1 | import { useId } from "preact/hooks"; 2 | import { clsx } from "clsx"; 3 | 4 | export const Radio = ({ color = "blue", label, disabled = false, ...props }) => { 5 | const id = useId(); 6 | 7 | return ( 8 |
9 | 40 | 41 | {label && ( 42 | 45 | )} 46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /frontend/src/hooks/useChatVoice.js: -------------------------------------------------------------------------------- 1 | import { useVoice } from "@/hooks/useVoice"; 2 | import { showErrorToast } from "@/lib/errorHandler"; 3 | import { extractTextContent, hasTextContent } from "@/utils/message/messageUtils"; 4 | import { useLayoutEffect, useRef } from "preact/hooks"; 5 | 6 | const shouldSpeakMessage = (message, lastSpokenId) => { 7 | if (!message) return false; 8 | const isAssistantMessage = message.role === "assistant"; 9 | const notAlreadySpoken = lastSpokenId !== message.id; 10 | return isAssistantMessage && hasTextContent(message) && notAlreadySpoken; 11 | }; 12 | 13 | export function useChatVoice({ messages, isLoading, setInput, submitMessage }) { 14 | const lastSpokenMessageRef = useRef(null); 15 | 16 | const voice = useVoice({ 17 | onSpeechResult: async (transcript) => { 18 | setInput(transcript); 19 | await submitMessage({ content: transcript }); 20 | }, 21 | onError: (error) => { 22 | console.error("Voice error:", error); 23 | showErrorToast(error); 24 | }, 25 | }); 26 | 27 | useLayoutEffect(() => { 28 | if (!voice.isActive || messages.length === 0 || isLoading) return; 29 | 30 | const lastMessage = messages[messages.length - 1]; 31 | if (!shouldSpeakMessage(lastMessage, lastSpokenMessageRef.current)) return; 32 | 33 | const content = extractTextContent(lastMessage); 34 | lastSpokenMessageRef.current = lastMessage.id; 35 | 36 | // Check processing state but don't depend on it - we care about new messages, not state changes 37 | if (voice.isProcessing) { 38 | voice.completeProcessing(); 39 | } 40 | 41 | voice.speakStream(content); 42 | }, [messages, voice.isActive, isLoading]); 43 | 44 | return { voice }; 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/hooks/voice/useVoiceSelection.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "preact/hooks"; 2 | import { VOICE_CONSTANTS } from "@faster-chat/shared"; 3 | 4 | const selectDefaultVoice = (voices, savedVoiceName) => { 5 | const savedVoice = voices.find((v) => v.name === savedVoiceName); 6 | if (savedVoice) return savedVoice; 7 | 8 | const englishVoice = voices.find((v) => 9 | v.lang.startsWith(VOICE_CONSTANTS.DEFAULT_LANGUAGE_PREFIX) 10 | ); 11 | if (englishVoice) return englishVoice; 12 | 13 | return voices[0]; // First available as fallback 14 | }; 15 | 16 | export function useVoiceSelection() { 17 | const [availableVoices, setAvailableVoices] = useState([]); 18 | const [selectedVoice, setSelectedVoice] = useState(null); 19 | 20 | useEffect(() => { 21 | const loadVoices = () => { 22 | const voices = window.speechSynthesis.getVoices(); 23 | if (voices.length === 0) return; 24 | 25 | setAvailableVoices(voices); 26 | 27 | const savedVoiceName = localStorage.getItem(VOICE_CONSTANTS.STORAGE_KEY_VOICE); 28 | setSelectedVoice(selectDefaultVoice(voices, savedVoiceName)); 29 | }; 30 | 31 | if (window.speechSynthesis.onvoiceschanged !== undefined) { 32 | window.speechSynthesis.onvoiceschanged = loadVoices; 33 | } 34 | loadVoices(); 35 | 36 | return () => { 37 | window.speechSynthesis.onvoiceschanged = null; 38 | }; 39 | }, []); 40 | 41 | const changeVoice = (voice) => { 42 | setSelectedVoice(voice); 43 | localStorage.setItem(VOICE_CONSTANTS.STORAGE_KEY_VOICE, voice.name); 44 | localStorage.setItem(VOICE_CONSTANTS.STORAGE_KEY_LANGUAGE, voice.lang); 45 | }; 46 | 47 | return { 48 | availableVoices, 49 | selectedVoice, 50 | changeVoice, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/hooks/useChatActions.js: -------------------------------------------------------------------------------- 1 | import { toast } from "sonner"; 2 | import { 3 | usePinChatMutation, 4 | useUnpinChatMutation, 5 | useArchiveChatMutation, 6 | useDeleteChatMutation, 7 | useUpdateChatMutation, 8 | } from "@/hooks/useChatsQuery"; 9 | 10 | export const useChatActions = () => { 11 | const pinChatMutation = usePinChatMutation(); 12 | const unpinChatMutation = useUnpinChatMutation(); 13 | const archiveChatMutation = useArchiveChatMutation(); 14 | const deleteChatMutation = useDeleteChatMutation(); 15 | const updateChatMutation = useUpdateChatMutation(); 16 | 17 | const handlePin = async (chatId) => { 18 | await pinChatMutation.mutateAsync(chatId); 19 | toast.success("Chat pinned"); 20 | }; 21 | 22 | const handleUnpin = async (chatId) => { 23 | await unpinChatMutation.mutateAsync(chatId); 24 | toast.success("Chat unpinned"); 25 | }; 26 | 27 | const handleArchive = async (chatId) => { 28 | await archiveChatMutation.mutateAsync(chatId); 29 | toast.success("Chat archived"); 30 | }; 31 | 32 | const handleDelete = async (chatId) => { 33 | if (!confirm("Delete this chat?")) return; 34 | await deleteChatMutation.mutateAsync(chatId); 35 | toast.success("Chat deleted"); 36 | }; 37 | 38 | const handleRename = async (chatId, chats) => { 39 | const chat = chats?.find((c) => c.id === chatId); 40 | if (!chat) return; 41 | 42 | const newName = prompt("Rename chat:", chat.title || ""); 43 | if (newName !== null && newName.trim() && newName.trim() !== chat.title) { 44 | await updateChatMutation.mutateAsync({ chatId, updates: { title: newName.trim() } }); 45 | toast.success("Chat renamed"); 46 | } 47 | }; 48 | 49 | return { 50 | handlePin, 51 | handleUnpin, 52 | handleArchive, 53 | handleDelete, 54 | handleRename, 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /frontend/src/components/ui/Modal.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "preact/hooks"; 2 | 3 | const Modal = ({ isOpen, onClose, title, children }) => { 4 | useEffect(() => { 5 | if (!isOpen) return; 6 | 7 | const handleEscape = (e) => { 8 | if (e.key === "Escape") onClose(); 9 | }; 10 | 11 | document.addEventListener("keydown", handleEscape); 12 | // Prevent body scroll when modal is open 13 | document.body.style.overflow = "hidden"; 14 | 15 | return () => { 16 | document.removeEventListener("keydown", handleEscape); 17 | document.body.style.overflow = ""; 18 | }; 19 | }, [isOpen]); 20 | 21 | if (!isOpen) return null; 22 | 23 | return ( 24 |
25 | {/* Backdrop */} 26 |
27 | 28 | {/* Modal */} 29 |
30 | {/* Header */} 31 |
32 |

{title}

33 | 45 |
46 | 47 | {/* Content */} 48 |
{children}
49 |
50 |
51 | ); 52 | }; 53 | 54 | export default Modal; 55 | -------------------------------------------------------------------------------- /frontend/src/lib/messageUtils.js: -------------------------------------------------------------------------------- 1 | import { MESSAGE_CONSTANTS } from "@faster-chat/shared"; 2 | 3 | /** 4 | * Get timestamp from a message, handling both camelCase and snake_case formats 5 | * @param {object} message - Message object 6 | * @param {*} defaultValue - Default value if no timestamp found (default: Date.now()) 7 | * @returns {number} Timestamp value 8 | */ 9 | export function getMessageTimestamp(message, defaultValue = Date.now()) { 10 | return message.createdAt ?? message.created_at ?? defaultValue; 11 | } 12 | 13 | export function ensureTimestamp(message, timestampsRef) { 14 | const existing = getMessageTimestamp(message, null); 15 | if (existing) { 16 | if (message.id && timestampsRef) { 17 | timestampsRef.current.set(message.id, existing); 18 | } 19 | return { ...message, createdAt: existing }; 20 | } 21 | 22 | if (message.id && timestampsRef?.current?.has(message.id)) { 23 | return { ...message, createdAt: timestampsRef.current.get(message.id) }; 24 | } 25 | 26 | const now = Date.now(); 27 | if (message.id && timestampsRef) { 28 | timestampsRef.current.set(message.id, now); 29 | } 30 | return { ...message, createdAt: now }; 31 | } 32 | 33 | export function deduplicateMessages(messages) { 34 | const seenIds = new Set(); 35 | const seenContentWindow = new Map(); 36 | 37 | return messages.filter((msg) => { 38 | const content = msg.parts?.map((p) => p.text).join("") || ""; 39 | const timestamp = getMessageTimestamp(msg); 40 | const bucket = Math.floor(timestamp / MESSAGE_CONSTANTS.DEDUPLICATION_WINDOW_MS); 41 | const contentKey = `${msg.role}:${content}:${bucket}`; 42 | 43 | if (msg.id && seenIds.has(msg.id)) return false; 44 | if (seenContentWindow.has(contentKey)) return false; 45 | 46 | if (msg.id) seenIds.add(msg.id); 47 | seenContentWindow.set(contentKey, true); 48 | return true; 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.7 2 | 3 | # Builder stage - use Bun (Alpine) to install and build 4 | FROM oven/bun:1.3-alpine AS builder 5 | WORKDIR /app 6 | 7 | COPY . . 8 | 9 | # Install dependencies and build frontend 10 | RUN bun install --frozen-lockfile && bun run build:frontend 11 | 12 | # Dependencies stage - install only production server deps 13 | FROM oven/bun:1.3-alpine AS deps 14 | WORKDIR /app 15 | ENV NODE_ENV=production 16 | 17 | COPY --from=builder /app/package.json ./package.json 18 | COPY --from=builder /app/bun.lock ./bun.lock 19 | COPY --from=builder /app/server ./server 20 | COPY --from=builder /app/packages ./packages 21 | COPY --from=builder /app/frontend/package.json ./frontend/package.json 22 | 23 | # Install only server + shared workspace production dependencies 24 | RUN bun install --frozen-lockfile --production --filter @faster-chat/server --filter @faster-chat/shared --no-save \ 25 | && rm -rf /root/.bun/install/cache 26 | 27 | # Production image - lean Bun runtime on Alpine 28 | FROM oven/bun:1.3-alpine AS runner 29 | WORKDIR /app 30 | 31 | ENV NODE_ENV=production 32 | ENV PORT=1337 33 | 34 | # Copy built application 35 | COPY --from=deps /app/package.json ./package.json 36 | COPY --from=deps /app/bun.lock ./bun.lock 37 | COPY --from=deps /app/node_modules ./node_modules 38 | COPY --from=builder /app/packages ./packages 39 | COPY --from=builder /app/server ./server 40 | COPY --from=builder /app/frontend/dist ./frontend/dist 41 | 42 | # No native module rebuild needed - bun:sqlite is built-in! 🎉 43 | 44 | # Create data directory for SQLite database 45 | RUN mkdir -p /app/server/data 46 | 47 | # Persist SQLite data outside the container filesystem 48 | VOLUME /app/server/data 49 | 50 | EXPOSE 1337 51 | 52 | # Set working directory to server folder 53 | WORKDIR /app/server 54 | 55 | # Run with Bun runtime (native SQLite support) 56 | # Init script generates encryption key on first run 57 | CMD ["sh", "-c", "bun run src/init.js && bun run src/index.js"] 58 | -------------------------------------------------------------------------------- /server/src/routes/settings.js: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { z } from "zod"; 3 | import { dbUtils } from "../lib/db.js"; 4 | import { ensureSession, requireRole } from "../middleware/auth.js"; 5 | import { HTTP_STATUS } from "../lib/httpStatus.js"; 6 | import { LOGO_ICON_NAMES, normalizeAppSettings, UI_CONSTANTS } from "@faster-chat/shared"; 7 | 8 | export const settingsRouter = new Hono(); 9 | 10 | // Validation schema for updating settings 11 | const UpdateSettingsSchema = z.object({ 12 | appName: z.string().min(1).max(UI_CONSTANTS.APP_NAME_MAX_LENGTH).optional(), 13 | logoIcon: z.enum(LOGO_ICON_NAMES).optional(), 14 | }); 15 | 16 | /** 17 | * GET /api/settings 18 | * Get all public settings (no auth required) 19 | */ 20 | settingsRouter.get("/", async (c) => { 21 | try { 22 | const settings = dbUtils.getAllSettings(); 23 | return c.json(normalizeAppSettings(settings)); 24 | } catch (error) { 25 | console.error("Get settings error:", error); 26 | return c.json({ error: "Failed to get settings" }, HTTP_STATUS.INTERNAL_SERVER_ERROR); 27 | } 28 | }); 29 | 30 | /** 31 | * PUT /api/settings 32 | * Update settings (admin only) 33 | */ 34 | settingsRouter.put("/", ensureSession, requireRole("admin"), async (c) => { 35 | try { 36 | const body = await c.req.json(); 37 | const updates = UpdateSettingsSchema.parse(body); 38 | 39 | if (Object.keys(updates).length === 0) { 40 | return c.json({ error: "No valid settings to update" }, HTTP_STATUS.BAD_REQUEST); 41 | } 42 | 43 | dbUtils.setSettings(updates); 44 | 45 | const settings = dbUtils.getAllSettings(); 46 | return c.json(normalizeAppSettings(settings)); 47 | } catch (error) { 48 | if (error instanceof z.ZodError) { 49 | return c.json({ error: "Invalid input", details: error.errors }, HTTP_STATUS.BAD_REQUEST); 50 | } 51 | console.error("Update settings error:", error); 52 | return c.json({ error: "Failed to update settings" }, HTTP_STATUS.INTERNAL_SERVER_ERROR); 53 | } 54 | }); 55 | -------------------------------------------------------------------------------- /frontend/public/themes/dracula.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dracula", 3 | "name": "Dracula", 4 | "author": "Zeno Rocha", 5 | "colors": { 6 | "light": { 7 | "background": "#f8f8f2", 8 | "foreground": "#282a36", 9 | "primary": "#bd93f9", 10 | "secondary": "#e9e9e6", 11 | "accent": "#ff79c6", 12 | "muted": "#a0a0a0", 13 | "border": "#d4d4d0", 14 | "surface": "#f0f0ec", 15 | "surface-strong": "#e4e4e0", 16 | "surface-stronger": "#d4d4d0", 17 | "overlay": "#a0a0a0", 18 | "overlay-strong": "#888888", 19 | "text": "#282a36", 20 | "text-muted": "#44475a", 21 | "text-subtle": "#383a4a", 22 | "red": "#ff5555", 23 | "green": "#50fa7b", 24 | "yellow": "#f1fa8c", 25 | "blue": "#6272a4", 26 | "pink": "#ff79c6", 27 | "teal": "#8be9fd", 28 | "mauve": "#bd93f9", 29 | "peach": "#ffb86c", 30 | "sky": "#8be9fd", 31 | "lavender": "#bd93f9", 32 | "canvas": "#f8f8f2", 33 | "canvas-alt": "#f0f0ec", 34 | "canvas-strong": "#e4e4e0" 35 | }, 36 | "dark": { 37 | "background": "#282a36", 38 | "foreground": "#f8f8f2", 39 | "primary": "#bd93f9", 40 | "secondary": "#44475a", 41 | "accent": "#ff79c6", 42 | "muted": "#6272a4", 43 | "border": "#44475a", 44 | "surface": "#343746", 45 | "surface-strong": "#44475a", 46 | "surface-stronger": "#545770", 47 | "overlay": "#6272a4", 48 | "overlay-strong": "#7c8aae", 49 | "text": "#f8f8f2", 50 | "text-muted": "#c0c0c0", 51 | "text-subtle": "#d8d8d8", 52 | "red": "#ff5555", 53 | "green": "#50fa7b", 54 | "yellow": "#f1fa8c", 55 | "blue": "#6272a4", 56 | "pink": "#ff79c6", 57 | "teal": "#8be9fd", 58 | "mauve": "#bd93f9", 59 | "peach": "#ffb86c", 60 | "sky": "#8be9fd", 61 | "lavender": "#bd93f9", 62 | "canvas": "#282a36", 63 | "canvas-alt": "#1e1f29", 64 | "canvas-strong": "#191a21" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/gruvbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "gruvbox", 3 | "name": "Gruvbox", 4 | "author": "morhetz", 5 | "colors": { 6 | "light": { 7 | "background": "#fbf1c7", 8 | "foreground": "#3c3836", 9 | "primary": "#076678", 10 | "secondary": "#ebdbb2", 11 | "accent": "#b57614", 12 | "muted": "#7c6f64", 13 | "border": "#d5c4a1", 14 | "surface": "#ebdbb2", 15 | "surface-strong": "#d5c4a1", 16 | "surface-stronger": "#bdae93", 17 | "overlay": "#bdae93", 18 | "overlay-strong": "#a89984", 19 | "text": "#3c3836", 20 | "text-muted": "#504945", 21 | "text-subtle": "#665c54", 22 | "red": "#9d0006", 23 | "green": "#79740e", 24 | "yellow": "#b57614", 25 | "blue": "#076678", 26 | "pink": "#8f3f71", 27 | "teal": "#427b58", 28 | "mauve": "#8f3f71", 29 | "peach": "#af3a03", 30 | "sky": "#427b58", 31 | "lavender": "#076678", 32 | "canvas": "#fbf1c7", 33 | "canvas-alt": "#ebdbb2", 34 | "canvas-strong": "#ebdbb2" 35 | }, 36 | "dark": { 37 | "background": "#282828", 38 | "foreground": "#fbf1c7", 39 | "primary": "#458588", 40 | "secondary": "#3c3836", 41 | "accent": "#d79921", 42 | "muted": "#928374", 43 | "border": "#504945", 44 | "surface": "#3c3836", 45 | "surface-strong": "#504945", 46 | "surface-stronger": "#665c54", 47 | "overlay": "#665c54", 48 | "overlay-strong": "#7c6f64", 49 | "text": "#fbf1c7", 50 | "text-muted": "#ebdbb2", 51 | "text-subtle": "#d5c4a1", 52 | "red": "#cc241d", 53 | "green": "#98971a", 54 | "yellow": "#d79921", 55 | "blue": "#458588", 56 | "pink": "#b16286", 57 | "teal": "#689d6a", 58 | "mauve": "#b16286", 59 | "peach": "#d65d0e", 60 | "sky": "#689d6a", 61 | "lavender": "#b16286", 62 | "canvas": "#282828", 63 | "canvas-alt": "#282828", 64 | "canvas-strong": "#282828" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/kanagawa.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "kanagawa", 3 | "name": "Kanagawa", 4 | "author": "rebelot", 5 | "colors": { 6 | "light": { 7 | "background": "#dcd7ba", 8 | "foreground": "#545464", 9 | "primary": "#6a9589", 10 | "secondary": "#d8d2b7", 11 | "accent": "#836f9f", 12 | "muted": "#ada493", 13 | "border": "#d8d2b7", 14 | "surface": "#d8d2b7", 15 | "surface-strong": "#cecab4", 16 | "surface-stronger": "#c4c0ae", 17 | "overlay": "#ada493", 18 | "overlay-strong": "#9d9483", 19 | "text": "#545464", 20 | "text-muted": "#646474", 21 | "text-subtle": "#444454", 22 | "red": "#c54752", 23 | "green": "#6a9589", 24 | "yellow": "#a06e5c", 25 | "blue": "#5a7797", 26 | "pink": "#836f9f", 27 | "teal": "#6a9589", 28 | "mauve": "#836f9f", 29 | "peach": "#a06e5c", 30 | "sky": "#5a7797", 31 | "lavender": "#836f9f", 32 | "canvas": "#dcd7ba", 33 | "canvas-alt": "#dcd7ba", 34 | "canvas-strong": "#dcd7ba" 35 | }, 36 | "dark": { 37 | "background": "#1f1f28", 38 | "foreground": "#dcd7ba", 39 | "primary": "#7e9cd8", 40 | "secondary": "#282832", 41 | "accent": "#957fb8", 42 | "muted": "#727169", 43 | "border": "#282832", 44 | "surface": "#282832", 45 | "surface-strong": "#3f3f4c", 46 | "surface-stronger": "#505064", 47 | "overlay": "#727169", 48 | "overlay-strong": "#9e9b90", 49 | "text": "#dcd7ba", 50 | "text-muted": "#c8c093", 51 | "text-subtle": "#b2a988", 52 | "red": "#c34043", 53 | "green": "#76946a", 54 | "yellow": "#c0a36e", 55 | "blue": "#7e9cd8", 56 | "pink": "#957fb8", 57 | "teal": "#6a9589", 58 | "mauve": "#957fb8", 59 | "peach": "#c0a36e", 60 | "sky": "#7e9cd8", 61 | "lavender": "#957fb8", 62 | "canvas": "#1f1f28", 63 | "canvas-alt": "#1f1f28", 64 | "canvas-strong": "#1f1f28" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/nord.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "nord", 3 | "name": "Nord", 4 | "author": "Arctic Ice Studio", 5 | "colors": { 6 | "light": { 7 | "background": "#eceff4", 8 | "foreground": "#2e3440", 9 | "primary": "#5e81ac", 10 | "secondary": "#d8dee9", 11 | "accent": "#88c0d0", 12 | "muted": "#7b88a1", 13 | "border": "#d8dee9", 14 | "surface": "#e5e9f0", 15 | "surface-strong": "#d8dee9", 16 | "surface-stronger": "#c9d1dc", 17 | "overlay": "#9da5b4", 18 | "overlay-strong": "#8892a3", 19 | "text": "#2e3440", 20 | "text-muted": "#434c5e", 21 | "text-subtle": "#3b4252", 22 | "red": "#bf616a", 23 | "green": "#a3be8c", 24 | "yellow": "#ebcb8b", 25 | "blue": "#5e81ac", 26 | "pink": "#b48ead", 27 | "teal": "#8fbcbb", 28 | "mauve": "#b48ead", 29 | "peach": "#d08770", 30 | "sky": "#88c0d0", 31 | "lavender": "#81a1c1", 32 | "canvas": "#eceff4", 33 | "canvas-alt": "#e5e9f0", 34 | "canvas-strong": "#d8dee9" 35 | }, 36 | "dark": { 37 | "background": "#2e3440", 38 | "foreground": "#eceff4", 39 | "primary": "#88c0d0", 40 | "secondary": "#3b4252", 41 | "accent": "#81a1c1", 42 | "muted": "#616e88", 43 | "border": "#434c5e", 44 | "surface": "#3b4252", 45 | "surface-strong": "#434c5e", 46 | "surface-stronger": "#4c566a", 47 | "overlay": "#616e88", 48 | "overlay-strong": "#7b88a1", 49 | "text": "#eceff4", 50 | "text-muted": "#d8dee9", 51 | "text-subtle": "#e5e9f0", 52 | "red": "#bf616a", 53 | "green": "#a3be8c", 54 | "yellow": "#ebcb8b", 55 | "blue": "#81a1c1", 56 | "pink": "#b48ead", 57 | "teal": "#8fbcbb", 58 | "mauve": "#b48ead", 59 | "peach": "#d08770", 60 | "sky": "#88c0d0", 61 | "lavender": "#5e81ac", 62 | "canvas": "#2e3440", 63 | "canvas-alt": "#292e39", 64 | "canvas-strong": "#242933" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/one-dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "one-dark", 3 | "name": "One Dark", 4 | "author": "Atom", 5 | "colors": { 6 | "light": { 7 | "background": "#fafafa", 8 | "foreground": "#383a42", 9 | "primary": "#4078f2", 10 | "secondary": "#e5e5e6", 11 | "accent": "#a626a4", 12 | "muted": "#a0a1a7", 13 | "border": "#d4d4d4", 14 | "surface": "#f0f0f0", 15 | "surface-strong": "#e5e5e6", 16 | "surface-stronger": "#d4d4d4", 17 | "overlay": "#a0a1a7", 18 | "overlay-strong": "#8c8d91", 19 | "text": "#383a42", 20 | "text-muted": "#4f5156", 21 | "text-subtle": "#44464d", 22 | "red": "#e45649", 23 | "green": "#50a14f", 24 | "yellow": "#c18401", 25 | "blue": "#4078f2", 26 | "pink": "#a626a4", 27 | "teal": "#0184bc", 28 | "mauve": "#a626a4", 29 | "peach": "#d19a66", 30 | "sky": "#0997b3", 31 | "lavender": "#4078f2", 32 | "canvas": "#fafafa", 33 | "canvas-alt": "#f0f0f0", 34 | "canvas-strong": "#e5e5e6" 35 | }, 36 | "dark": { 37 | "background": "#282c34", 38 | "foreground": "#abb2bf", 39 | "primary": "#61afef", 40 | "secondary": "#3e4452", 41 | "accent": "#c678dd", 42 | "muted": "#5c6370", 43 | "border": "#3e4452", 44 | "surface": "#31353f", 45 | "surface-strong": "#3e4452", 46 | "surface-stronger": "#4b5263", 47 | "overlay": "#5c6370", 48 | "overlay-strong": "#6b7280", 49 | "text": "#abb2bf", 50 | "text-muted": "#8b929e", 51 | "text-subtle": "#9da5b4", 52 | "red": "#e06c75", 53 | "green": "#98c379", 54 | "yellow": "#e5c07b", 55 | "blue": "#61afef", 56 | "pink": "#c678dd", 57 | "teal": "#56b6c2", 58 | "mauve": "#c678dd", 59 | "peach": "#d19a66", 60 | "sky": "#56b6c2", 61 | "lavender": "#61afef", 62 | "canvas": "#282c34", 63 | "canvas-alt": "#21252b", 64 | "canvas-strong": "#1b1f23" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/monokai.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "monokai", 3 | "name": "Monokai", 4 | "author": "Wimer Hazenberg", 5 | "colors": { 6 | "light": { 7 | "background": "#fdfcfc", 8 | "foreground": "#383a42", 9 | "primary": "#526fff", 10 | "secondary": "#eaeaeb", 11 | "accent": "#e4005c", 12 | "muted": "#75715e", 13 | "border": "#eaeaeb", 14 | "surface": "#eaeaeb", 15 | "surface-strong": "#dcdbdd", 16 | "surface-stronger": "#d0d1d2", 17 | "overlay": "#75715e", 18 | "overlay-strong": "#5f5d4e", 19 | "text": "#383a42", 20 | "text-muted": "#4b4d55", 21 | "text-subtle": "#272822", 22 | "red": "#f92672", 23 | "green": "#94c42d", 24 | "yellow": "#f2d427", 25 | "blue": "#526fff", 26 | "pink": "#f92672", 27 | "teal": "#66d9ef", 28 | "mauve": "#9e62f0", 29 | "peach": "#f0901e", 30 | "sky": "#66d9ef", 31 | "lavender": "#9e62f0", 32 | "canvas": "#fdfcfc", 33 | "canvas-alt": "#fdfcfc", 34 | "canvas-strong": "#fdfcfc" 35 | }, 36 | "dark": { 37 | "background": "#272822", 38 | "foreground": "#F8F8F2", 39 | "primary": "#66D9EF", 40 | "secondary": "#3E3D32", 41 | "accent": "#F92672", 42 | "muted": "#75715E", 43 | "border": "#3E3D32", 44 | "surface": "#3E3D32", 45 | "surface-strong": "#49483E", 46 | "surface-stronger": "#75715E", 47 | "overlay": "#75715E", 48 | "overlay-strong": "#94907E", 49 | "text": "#F8F8F2", 50 | "text-muted": "#E6E6E6", 51 | "text-subtle": "#CCCCCC", 52 | "red": "#F92672", 53 | "green": "#A6E22E", 54 | "yellow": "#E6DB74", 55 | "blue": "#66D9EF", 56 | "pink": "#F92672", 57 | "teal": "#66D9EF", 58 | "mauve": "#AE81FF", 59 | "peach": "#FD971F", 60 | "sky": "#66D9EF", 61 | "lavender": "#AE81FF", 62 | "canvas": "#272822", 63 | "canvas-alt": "#272822", 64 | "canvas-strong": "#272822" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/rosepine.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "rosepine", 3 | "name": "Rosé Pine", 4 | "author": "Rosé Pine", 5 | "colors": { 6 | "light": { 7 | "background": "#faf4ed", 8 | "foreground": "#575279", 9 | "primary": "#907aa9", 10 | "secondary": "#f2e9e1", 11 | "accent": "#d7827e", 12 | "muted": "#9893a5", 13 | "border": "#dfdad9", 14 | "surface": "#f2e9e1", 15 | "surface-strong": "#ebe5df", 16 | "surface-stronger": "#dfdad9", 17 | "overlay": "#9893a5", 18 | "overlay-strong": "#797593", 19 | "text": "#575279", 20 | "text-muted": "#6e6a86", 21 | "text-subtle": "#625f7c", 22 | "red": "#b4637a", 23 | "green": "#286983", 24 | "yellow": "#ea9d34", 25 | "blue": "#56949f", 26 | "pink": "#d7827e", 27 | "teal": "#286983", 28 | "mauve": "#907aa9", 29 | "peach": "#ea9d34", 30 | "sky": "#56949f", 31 | "lavender": "#907aa9", 32 | "canvas": "#faf4ed", 33 | "canvas-alt": "#f4ede8", 34 | "canvas-strong": "#f2e9e1" 35 | }, 36 | "dark": { 37 | "background": "#191724", 38 | "foreground": "#e0def4", 39 | "primary": "#c4a7e7", 40 | "secondary": "#26233a", 41 | "accent": "#ebbcba", 42 | "muted": "#6e6a86", 43 | "border": "#403d52", 44 | "surface": "#1f1d2e", 45 | "surface-strong": "#26233a", 46 | "surface-stronger": "#403d52", 47 | "overlay": "#524f67", 48 | "overlay-strong": "#6e6a86", 49 | "text": "#e0def4", 50 | "text-muted": "#908caa", 51 | "text-subtle": "#a6a2bb", 52 | "red": "#eb6f92", 53 | "green": "#31748f", 54 | "yellow": "#f6c177", 55 | "blue": "#9ccfd8", 56 | "pink": "#ebbcba", 57 | "teal": "#31748f", 58 | "mauve": "#c4a7e7", 59 | "peach": "#f6c177", 60 | "sky": "#9ccfd8", 61 | "lavender": "#c4a7e7", 62 | "canvas": "#191724", 63 | "canvas-alt": "#1a1726", 64 | "canvas-strong": "#11111b" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "default", 3 | "name": "Default", 4 | "author": "Faster Chat", 5 | "colors": { 6 | "light": { 7 | "background": "#F9FAFB", 8 | "foreground": "#111827", 9 | "primary": "#4F46E5", 10 | "secondary": "#F3F4F6", 11 | "accent": "#6366F1", 12 | "muted": "#9CA3AF", 13 | "border": "#E5E7EB", 14 | "surface": "#F3F4F6", 15 | "surface-strong": "#E5E7EB", 16 | "surface-stronger": "#D1D5DB", 17 | "overlay": "#9CA3AF", 18 | "overlay-strong": "#6B7280", 19 | "text": "#111827", 20 | "text-muted": "#6B7280", 21 | "text-subtle": "#4B5563", 22 | "red": "#DC2626", 23 | "green": "#16A34A", 24 | "yellow": "#CA8A04", 25 | "blue": "#2563EB", 26 | "pink": "#DB2777", 27 | "teal": "#0D9488", 28 | "mauve": "#7C3AED", 29 | "peach": "#EA580C", 30 | "sky": "#0284C7", 31 | "lavender": "#818CF8", 32 | "canvas": "#F9FAFB", 33 | "canvas-alt": "#FFFFFF", 34 | "canvas-strong": "#F3F4F6" 35 | }, 36 | "dark": { 37 | "background": "#09090b", 38 | "foreground": "#FAFAFA", 39 | "primary": "#6366F1", 40 | "secondary": "#18181b", 41 | "accent": "#818CF8", 42 | "muted": "#52525B", 43 | "border": "#27272a", 44 | "surface": "#18181b", 45 | "surface-strong": "#121215", 46 | "surface-stronger": "#1E1B4B", 47 | "overlay": "#27272a", 48 | "overlay-strong": "#3f3f46", 49 | "text": "#FAFAFA", 50 | "text-muted": "#A1A1AA", 51 | "text-subtle": "#D4D4D8", 52 | "red": "#F87171", 53 | "green": "#4ADE80", 54 | "yellow": "#FACC15", 55 | "blue": "#60A5FA", 56 | "pink": "#F472B6", 57 | "teal": "#2DD4BF", 58 | "mauve": "#A78BFA", 59 | "peach": "#FB923C", 60 | "sky": "#38BDF8", 61 | "lavender": "#A5B4FC", 62 | "canvas": "#121215", 63 | "canvas-alt": "#18181b", 64 | "canvas-strong": "#09090b" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/public/themes/everforest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "everforest", 3 | "name": "Everforest", 4 | "author": "Sainnhe", 5 | "colors": { 6 | "light": { 7 | "background": "#fdf6e3", 8 | "foreground": "#5c6a72", 9 | "primary": "#7fbbb3", 10 | "secondary": "#f4eeda", 11 | "accent": "#8da101", 12 | "muted": "#939f86", 13 | "border": "#f4eeda", 14 | "surface": "#f4eeda", 15 | "surface-strong": "#e8deca", 16 | "surface-stronger": "#ddd3c1", 17 | "overlay": "#939f86", 18 | "overlay-strong": "#7c8670", 19 | "text": "#5c6a72", 20 | "text-muted": "#4a555b", 21 | "text-subtle": "#3a4246", 22 | "red": "#f85552", 23 | "green": "#8da101", 24 | "yellow": "#dfa000", 25 | "blue": "#3a94c5", 26 | "pink": "#df69ba", 27 | "teal": "#35a77c", 28 | "mauve": "#df69ba", 29 | "peach": "#f57d26", 30 | "sky": "#35a77c", 31 | "lavender": "#3a94c5", 32 | "canvas": "#fdf6e3", 33 | "canvas-alt": "#fdf6e3", 34 | "canvas-strong": "#fdf6e3" 35 | }, 36 | "dark": { 37 | "background": "#2f383e", 38 | "foreground": "#d3c6aa", 39 | "primary": "#7fbbb3", 40 | "secondary": "#3a464c", 41 | "accent": "#a7c080", 42 | "muted": "#5c6a72", 43 | "border": "#4b565c", 44 | "surface": "#3a464c", 45 | "surface-strong": "#4b565c", 46 | "surface-stronger": "#5c6a72", 47 | "overlay": "#6b7b83", 48 | "overlay-strong": "#7fbbb3", 49 | "text": "#d3c6aa", 50 | "text-muted": "#b3a995", 51 | "text-subtle": "#a39a88", 52 | "red": "#e67e80", 53 | "green": "#a7c080", 54 | "yellow": "#dbbc7f", 55 | "blue": "#7fbbb3", 56 | "pink": "#d699b6", 57 | "teal": "#83c092", 58 | "mauve": "#d699b6", 59 | "peach": "#e69875", 60 | "sky": "#83c092", 61 | "lavender": "#d699b6", 62 | "canvas": "#2f383e", 63 | "canvas-alt": "#2f383e", 64 | "canvas-strong": "#2f383e" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/material.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "material", 3 | "name": "Material", 4 | "author": "Mattia Astorino", 5 | "colors": { 6 | "light": { 7 | "background": "#FAFAFA", 8 | "foreground": "#90A4AE", 9 | "primary": "#91B859", 10 | "secondary": "#F4F4F4", 11 | "accent": "#E53935", 12 | "muted": "#B0BEC5", 13 | "border": "#F4F4F4", 14 | "surface": "#F4F4F4", 15 | "surface-strong": "#E0E0E0", 16 | "surface-stronger": "#CCCCCC", 17 | "overlay": "#B0BEC5", 18 | "overlay-strong": "#90A4AE", 19 | "text": "#263238", 20 | "text-muted": "#546E7A", 21 | "text-subtle": "#37474F", 22 | "red": "#E53935", 23 | "green": "#91B859", 24 | "yellow": "#F6BF26", 25 | "blue": "#6182B8", 26 | "pink": "#E53935", 27 | "teal": "#39ADB5", 28 | "mauve": "#6182B8", 29 | "peach": "#F76D47", 30 | "sky": "#39ADB5", 31 | "lavender": "#6182B8", 32 | "canvas": "#FAFAFA", 33 | "canvas-alt": "#FAFAFA", 34 | "canvas-strong": "#FAFAFA" 35 | }, 36 | "dark": { 37 | "background": "#212121", 38 | "foreground": "#EEFFFF", 39 | "primary": "#80CBC4", 40 | "secondary": "#262626", 41 | "accent": "#F07178", 42 | "muted": "#546E7A", 43 | "border": "#262626", 44 | "surface": "#262626", 45 | "surface-strong": "#323232", 46 | "surface-stronger": "#424242", 47 | "overlay": "#546E7A", 48 | "overlay-strong": "#708D9A", 49 | "text": "#EEFFFF", 50 | "text-muted": "#B0BEC5", 51 | "text-subtle": "#90A4AE", 52 | "red": "#F07178", 53 | "green": "#C3E88D", 54 | "yellow": "#FFCB6B", 55 | "blue": "#82AAFF", 56 | "pink": "#C792EA", 57 | "teal": "#80CBC4", 58 | "mauve": "#C792EA", 59 | "peach": "#FFCB6B", 60 | "sky": "#89DDFF", 61 | "lavender": "#C792EA", 62 | "canvas": "#212121", 63 | "canvas-alt": "#212121", 64 | "canvas-strong": "#212121" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/night-owl.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "night-owl", 3 | "name": "Night Owl", 4 | "author": "Sarah Drasner", 5 | "colors": { 6 | "light": { 7 | "background": "#fbfbfb", 8 | "foreground": "#403f53", 9 | "primary": "#4876d6", 10 | "secondary": "#e6e6e6", 11 | "accent": "#c565d9", 12 | "muted": "#9392a6", 13 | "border": "#d9d9d9", 14 | "surface": "#e6e6e6", 15 | "surface-strong": "#d9d9d9", 16 | "surface-stronger": "#cccccc", 17 | "overlay": "#9392a6", 18 | "overlay-strong": "#7e7d91", 19 | "text": "#403f53", 20 | "text-muted": "#5f5f7f", 21 | "text-subtle": "#33334d", 22 | "red": "#d44f4f", 23 | "green": "#0c965e", 24 | "yellow": "#dd9a00", 25 | "blue": "#4876d6", 26 | "pink": "#c565d9", 27 | "teal": "#008299", 28 | "mauve": "#c565d9", 29 | "peach": "#d95829", 30 | "sky": "#008299", 31 | "lavender": "#4876d6", 32 | "canvas": "#fbfbfb", 33 | "canvas-alt": "#f0f0f0", 34 | "canvas-strong": "#e6e6e6" 35 | }, 36 | "dark": { 37 | "background": "#011627", 38 | "foreground": "#d6deeb", 39 | "primary": "#82aaff", 40 | "secondary": "#0b2942", 41 | "accent": "#c792ea", 42 | "muted": "#637777", 43 | "border": "#122d42", 44 | "surface": "#0b2942", 45 | "surface-strong": "#122d42", 46 | "surface-stronger": "#1d3b53", 47 | "overlay": "#3d5067", 48 | "overlay-strong": "#5f6f81", 49 | "text": "#d6deeb", 50 | "text-muted": "#a6b2c4", 51 | "text-subtle": "#8c9aaf", 52 | "red": "#ef5350", 53 | "green": "#22da6e", 54 | "yellow": "#addb67", 55 | "blue": "#82aaff", 56 | "pink": "#c792ea", 57 | "teal": "#21c7a8", 58 | "mauve": "#c792ea", 59 | "peach": "#f78c6c", 60 | "sky": "#21c7a8", 61 | "lavender": "#c792ea", 62 | "canvas": "#011627", 63 | "canvas-alt": "#011627", 64 | "canvas-strong": "#011627" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/tokyo-night.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "tokyo-night", 3 | "name": "Tokyo Night", 4 | "author": "enkia", 5 | "colors": { 6 | "light": { 7 | "background": "#d5d6db", 8 | "foreground": "#343b58", 9 | "primary": "#7aa2f7", 10 | "secondary": "#c4c8d6", 11 | "accent": "#bb9af7", 12 | "muted": "#717ba5", 13 | "border": "#b4b8c6", 14 | "surface": "#cbccd5", 15 | "surface-strong": "#c0c1ca", 16 | "surface-stronger": "#b4b5be", 17 | "overlay": "#9699a3", 18 | "overlay-strong": "#8c8fa1", 19 | "text": "#343b58", 20 | "text-muted": "#4c5373", 21 | "text-subtle": "#3d4466", 22 | "red": "#8c4351", 23 | "green": "#485e30", 24 | "yellow": "#8f5e15", 25 | "blue": "#34548a", 26 | "pink": "#7847bd", 27 | "teal": "#166775", 28 | "mauve": "#5a4a78", 29 | "peach": "#965027", 30 | "sky": "#0f4b6e", 31 | "lavender": "#7aa2f7", 32 | "canvas": "#d5d6db", 33 | "canvas-alt": "#cbccd1", 34 | "canvas-strong": "#c0c1c6" 35 | }, 36 | "dark": { 37 | "background": "#1a1b26", 38 | "foreground": "#c0caf5", 39 | "primary": "#7aa2f7", 40 | "secondary": "#24283b", 41 | "accent": "#bb9af7", 42 | "muted": "#565f89", 43 | "border": "#3b4261", 44 | "surface": "#24283b", 45 | "surface-strong": "#2f3549", 46 | "surface-stronger": "#3b4261", 47 | "overlay": "#545c7e", 48 | "overlay-strong": "#6b7394", 49 | "text": "#c0caf5", 50 | "text-muted": "#9aa5ce", 51 | "text-subtle": "#a9b1d6", 52 | "red": "#f7768e", 53 | "green": "#9ece6a", 54 | "yellow": "#e0af68", 55 | "blue": "#7aa2f7", 56 | "pink": "#bb9af7", 57 | "teal": "#73daca", 58 | "mauve": "#9d7cd8", 59 | "peach": "#ff9e64", 60 | "sky": "#7dcfff", 61 | "lavender": "#b4f9f8", 62 | "canvas": "#1a1b26", 63 | "canvas-alt": "#16161e", 64 | "canvas-strong": "#13131a" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/src/state/useAuthState.js: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { authClient } from "../lib/authClient.js"; 3 | import { queryClient } from "../App.jsx"; 4 | 5 | export const useAuthState = create((set, get) => ({ 6 | user: null, 7 | isLoading: true, 8 | error: null, 9 | 10 | setUser: (user) => set({ user, error: null }), 11 | setError: (error) => set({ error }), 12 | clearError: () => set({ error: null }), 13 | 14 | checkSession: async () => { 15 | set({ isLoading: true, error: null }); 16 | try { 17 | const data = await authClient.getSession(); 18 | set({ user: data.user, isLoading: false }); 19 | return data.user; 20 | } catch (error) { 21 | set({ user: null, isLoading: false, error: error.message }); 22 | return null; 23 | } 24 | }, 25 | 26 | login: async (username, password) => { 27 | set({ isLoading: true, error: null }); 28 | try { 29 | const data = await authClient.login(username, password); 30 | set({ user: data.user, isLoading: false }); 31 | return data.user; 32 | } catch (error) { 33 | set({ user: null, isLoading: false, error: error.message }); 34 | throw error; 35 | } 36 | }, 37 | 38 | register: async (username, password) => { 39 | set({ isLoading: true, error: null }); 40 | try { 41 | const data = await authClient.register(username, password); 42 | set({ user: data.user, isLoading: false }); 43 | return data.user; 44 | } catch (error) { 45 | set({ user: null, isLoading: false, error: error.message }); 46 | throw error; 47 | } 48 | }, 49 | 50 | logout: async () => { 51 | set({ isLoading: true, error: null }); 52 | try { 53 | await authClient.logout(); 54 | // Clear all cached data to prevent cache bleed between users 55 | queryClient.clear(); 56 | set({ user: null, isLoading: false }); 57 | } catch (error) { 58 | set({ isLoading: false, error: error.message }); 59 | throw error; 60 | } 61 | }, 62 | })); 63 | -------------------------------------------------------------------------------- /frontend/public/themes/catppuccin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "catppuccin", 3 | "name": "Catppuccin", 4 | "author": "Catppuccin Team", 5 | "colors": { 6 | "light": { 7 | "background": "#eff1f5", 8 | "foreground": "#4c4f69", 9 | "primary": "#8839ef", 10 | "secondary": "#ccd0da", 11 | "accent": "#ea76cb", 12 | "muted": "#9ca0b0", 13 | "border": "#acb0be", 14 | "surface": "#ccd0da", 15 | "surface-strong": "#bcc0cc", 16 | "surface-stronger": "#acb0be", 17 | "overlay": "#9ca0b0", 18 | "overlay-strong": "#8c8fa1", 19 | "text": "#4c4f69", 20 | "text-muted": "#6c6f85", 21 | "text-subtle": "#5c5f77", 22 | "red": "#d20f39", 23 | "green": "#40a02b", 24 | "yellow": "#df8e1d", 25 | "blue": "#1e66f5", 26 | "pink": "#ea76cb", 27 | "teal": "#179299", 28 | "mauve": "#8839ef", 29 | "peach": "#fe640b", 30 | "sky": "#04a5e5", 31 | "lavender": "#7287fd", 32 | "canvas": "#eff1f5", 33 | "canvas-alt": "#e6e9ef", 34 | "canvas-strong": "#dce0e8" 35 | }, 36 | "dark": { 37 | "background": "#24273a", 38 | "foreground": "#cad3f5", 39 | "primary": "#c6a0f6", 40 | "secondary": "#363a4f", 41 | "accent": "#f5bde6", 42 | "muted": "#6e738d", 43 | "border": "#5b6078", 44 | "surface": "#363a4f", 45 | "surface-strong": "#494d64", 46 | "surface-stronger": "#5b6078", 47 | "overlay": "#6e738d", 48 | "overlay-strong": "#8087a2", 49 | "text": "#cad3f5", 50 | "text-muted": "#a5adcb", 51 | "text-subtle": "#b8c0e0", 52 | "red": "#ed8796", 53 | "green": "#a6da95", 54 | "yellow": "#eed49f", 55 | "blue": "#8aadf4", 56 | "pink": "#f5bde6", 57 | "teal": "#8bd5ca", 58 | "mauve": "#c6a0f6", 59 | "peach": "#f5a97f", 60 | "sky": "#91d7e3", 61 | "lavender": "#b7bdf8", 62 | "canvas": "#24273a", 63 | "canvas-alt": "#1e2030", 64 | "canvas-strong": "#181926" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/solarized.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "solarized", 3 | "name": "Solarized", 4 | "author": "Ethan Schoonover", 5 | "colors": { 6 | "light": { 7 | "background": "#fdf6e3", 8 | "foreground": "#657b83", 9 | "primary": "#268bd2", 10 | "secondary": "#eee8d5", 11 | "accent": "#b58900", 12 | "muted": "#93a1a1", 13 | "border": "#eee8d5", 14 | "surface": "#eee8d5", 15 | "surface-strong": "#93a1a1", 16 | "surface-stronger": "#839496", 17 | "overlay": "#93a1a1", 18 | "overlay-strong": "#839496", 19 | "text": "#657b83", 20 | "text-muted": "#586e75", 21 | "text-subtle": "#073642", 22 | "red": "#dc322f", 23 | "green": "#859900", 24 | "yellow": "#b58900", 25 | "blue": "#268bd2", 26 | "pink": "#d33682", 27 | "teal": "#2aa198", 28 | "mauve": "#6c71c4", 29 | "peach": "#cb4b16", 30 | "sky": "#2aa198", 31 | "lavender": "#6c71c4", 32 | "canvas": "#fdf6e3", 33 | "canvas-alt": "#eee8d5", 34 | "canvas-strong": "#eee8d5" 35 | }, 36 | "dark": { 37 | "background": "#002b36", 38 | "foreground": "#839496", 39 | "primary": "#268bd2", 40 | "secondary": "#073642", 41 | "accent": "#b58900", 42 | "muted": "#586e75", 43 | "border": "#073642", 44 | "surface": "#073642", 45 | "surface-strong": "#586e75", 46 | "surface-stronger": "#657b83", 47 | "overlay": "#586e75", 48 | "overlay-strong": "#657b83", 49 | "text": "#839496", 50 | "text-muted": "#93a1a1", 51 | "text-subtle": "#eee8d5", 52 | "red": "#dc322f", 53 | "green": "#859900", 54 | "yellow": "#b58900", 55 | "blue": "#268bd2", 56 | "pink": "#d33682", 57 | "teal": "#2aa198", 58 | "mauve": "#6c71c4", 59 | "peach": "#cb4b16", 60 | "sky": "#2aa198", 61 | "lavender": "#6c71c4", 62 | "canvas": "#002b36", 63 | "canvas-alt": "#002b36", 64 | "canvas-strong": "#002b36" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/public/themes/synthwave84.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "synthwave84", 3 | "name": "Synthwave '84", 4 | "author": "Robb Owen", 5 | "colors": { 6 | "light": { 7 | "background": "#2a2139", 8 | "foreground": "#ffffff", 9 | "primary": "#ff79c6", 10 | "secondary": "#382f4d", 11 | "accent": "#fede5d", 12 | "muted": "#69578e", 13 | "border": "#382f4d", 14 | "surface": "#382f4d", 15 | "surface-strong": "#4a3e68", 16 | "surface-stronger": "#5d4d82", 17 | "overlay": "#69578e", 18 | "overlay-strong": "#8270b4", 19 | "text": "#ffffff", 20 | "text-muted": "#e0e0e0", 21 | "text-subtle": "#c0c0c0", 22 | "red": "#ff5555", 23 | "green": "#50fa7b", 24 | "yellow": "#fede5d", 25 | "blue": "#bd93f9", 26 | "pink": "#ff79c6", 27 | "teal": "#2de2e6", 28 | "mauve": "#bd93f9", 29 | "peach": "#f97e72", 30 | "sky": "#2de2e6", 31 | "lavender": "#bd93f9", 32 | "canvas": "#2a2139", 33 | "canvas-alt": "#2a2139", 34 | "canvas-strong": "#2a2139" 35 | }, 36 | "dark": { 37 | "background": "#2a2139", 38 | "foreground": "#ffffff", 39 | "primary": "#ff79c6", 40 | "secondary": "#382f4d", 41 | "accent": "#fede5d", 42 | "muted": "#69578e", 43 | "border": "#382f4d", 44 | "surface": "#382f4d", 45 | "surface-strong": "#4a3e68", 46 | "surface-stronger": "#5d4d82", 47 | "overlay": "#69578e", 48 | "overlay-strong": "#8270b4", 49 | "text": "#ffffff", 50 | "text-muted": "#e0e0e0", 51 | "text-subtle": "#c0c0c0", 52 | "red": "#ff5555", 53 | "green": "#50fa7b", 54 | "yellow": "#fede5d", 55 | "blue": "#bd93f9", 56 | "pink": "#ff79c6", 57 | "teal": "#2de2e6", 58 | "mauve": "#bd93f9", 59 | "peach": "#f97e72", 60 | "sky": "#2de2e6", 61 | "lavender": "#bd93f9", 62 | "canvas": "#2a2139", 63 | "canvas-alt": "#2a2139", 64 | "canvas-strong": "#2a2139" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/src/state/useAppSettings.js: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { DEFAULT_APP_SETTINGS, normalizeAppSettings } from "@faster-chat/shared"; 3 | 4 | export const useAppSettings = create((set, get) => ({ 5 | appName: DEFAULT_APP_SETTINGS.appName, 6 | logoIcon: DEFAULT_APP_SETTINGS.logoIcon, 7 | isLoaded: false, 8 | isFetching: false, 9 | isSaving: false, 10 | error: null, 11 | 12 | fetchSettings: async () => { 13 | if (get().isFetching) return; 14 | 15 | set({ isFetching: true, error: null }); 16 | try { 17 | const response = await fetch("/api/settings"); 18 | if (!response.ok) throw new Error("Failed to fetch settings"); 19 | 20 | const data = await response.json(); 21 | set({ 22 | ...normalizeAppSettings(data), 23 | isLoaded: true, 24 | isFetching: false, 25 | }); 26 | } catch (error) { 27 | console.error("Failed to fetch app settings:", error); 28 | set({ 29 | error: error.message, 30 | isLoaded: true, 31 | isFetching: false, 32 | }); 33 | } 34 | }, 35 | 36 | updateSettings: async (updates) => { 37 | set({ isSaving: true, error: null }); 38 | try { 39 | const response = await fetch("/api/settings", { 40 | method: "PUT", 41 | headers: { "Content-Type": "application/json" }, 42 | credentials: "include", 43 | body: JSON.stringify(updates), 44 | }); 45 | 46 | if (!response.ok) { 47 | const error = await response.json(); 48 | throw new Error(error.error || "Failed to update settings"); 49 | } 50 | 51 | const data = await response.json(); 52 | set({ 53 | ...normalizeAppSettings(data), 54 | isLoaded: true, 55 | isSaving: false, 56 | }); 57 | return { success: true }; 58 | } catch (error) { 59 | console.error("Failed to update app settings:", error); 60 | set({ error: error.message, isSaving: false }); 61 | return { success: false, error: error.message }; 62 | } 63 | }, 64 | })); 65 | -------------------------------------------------------------------------------- /server/src/middleware/auth.js: -------------------------------------------------------------------------------- 1 | import { getCookie } from "hono/cookie"; 2 | import { dbUtils } from "../lib/db.js"; 3 | 4 | const COOKIE_NAME = "session"; 5 | 6 | /** 7 | * Middleware to ensure a valid session exists 8 | * Attaches user info to context: c.get('user') 9 | */ 10 | export async function ensureSession(c, next) { 11 | const sessionId = getCookie(c, COOKIE_NAME); 12 | 13 | if (!sessionId) { 14 | return c.json({ error: "Unauthorized" }, 401); 15 | } 16 | 17 | const session = dbUtils.getSession(sessionId); 18 | 19 | if (!session) { 20 | return c.json({ error: "Session expired" }, 401); 21 | } 22 | 23 | // Attach user info to context 24 | c.set("user", { 25 | id: session.user_id, 26 | username: session.username, 27 | role: session.role, 28 | }); 29 | 30 | await next(); 31 | } 32 | 33 | /** 34 | * Middleware to require a specific role 35 | * Must be used after ensureSession 36 | * @param {...string} allowedRoles - Roles that are allowed to access this route 37 | */ 38 | export function requireRole(...allowedRoles) { 39 | return async (c, next) => { 40 | const user = c.get("user"); 41 | 42 | if (!user) { 43 | return c.json({ error: "Unauthorized" }, 401); 44 | } 45 | 46 | if (!allowedRoles.includes(user.role)) { 47 | return c.json({ error: "Forbidden: insufficient permissions" }, 403); 48 | } 49 | 50 | await next(); 51 | }; 52 | } 53 | 54 | /** 55 | * Optional auth middleware - doesn't fail if no session, just sets user to null 56 | * Useful for routes that work both authenticated and unauthenticated 57 | */ 58 | export async function optionalAuth(c, next) { 59 | const sessionId = getCookie(c, COOKIE_NAME); 60 | 61 | if (sessionId) { 62 | const session = dbUtils.getSession(sessionId); 63 | 64 | if (session) { 65 | c.set("user", { 66 | id: session.user_id, 67 | username: session.username, 68 | role: session.role, 69 | }); 70 | } 71 | } 72 | 73 | // Set user to null if not authenticated 74 | if (!c.get("user")) { 75 | c.set("user", null); 76 | } 77 | 78 | await next(); 79 | } 80 | -------------------------------------------------------------------------------- /frontend/src/components/layout/IndexRouteGuard.jsx: -------------------------------------------------------------------------------- 1 | import { useAuthState } from "@/state/useAuthState"; 2 | import { 3 | hasSeenAdminConnectionsOnboarding, 4 | markAdminConnectionsOnboardingSeen, 5 | } from "@/lib/adminOnboarding"; 6 | import { useChatsQuery, useCreateChatMutation } from "@/hooks/useChatsQuery"; 7 | import { useNavigate } from "@tanstack/react-router"; 8 | import { useEffect, useRef } from "preact/hooks"; 9 | 10 | export function IndexRouteGuard() { 11 | const navigate = useNavigate(); 12 | const { user } = useAuthState(); 13 | const { data: chats, isLoading } = useChatsQuery(); 14 | const createChatMutation = useCreateChatMutation(); 15 | const hasStartedNavigation = useRef(false); 16 | 17 | useEffect(() => { 18 | if (!user || isLoading || hasStartedNavigation.current) return; 19 | hasStartedNavigation.current = true; 20 | 21 | const hasSeenOnboarding = hasSeenAdminConnectionsOnboarding(user.id); 22 | 23 | // Admin onboarding: redirect to connections if no chats and hasn't seen it 24 | if (user.role === "admin" && (!chats || chats.length === 0) && !hasSeenOnboarding) { 25 | markAdminConnectionsOnboardingSeen(user.id); 26 | navigate({ 27 | to: "/admin", 28 | search: { tab: "connections" }, 29 | replace: true, 30 | }); 31 | return; 32 | } 33 | 34 | // Navigate to existing chat or create new one 35 | if (chats && chats.length > 0) { 36 | navigate({ 37 | to: "/chat/$chatId", 38 | params: { chatId: chats[0].id }, 39 | replace: true, 40 | }); 41 | } else { 42 | createChatMutation.mutate( 43 | {}, 44 | { 45 | onSuccess: (newChat) => { 46 | navigate({ 47 | to: "/chat/$chatId", 48 | params: { chatId: newChat.id }, 49 | replace: true, 50 | }); 51 | }, 52 | } 53 | ); 54 | } 55 | }, [navigate, user, chats, isLoading, createChatMutation]); 56 | 57 | return ( 58 |
59 |
{isLoading ? "Loading..." : "Redirecting..."}
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /packages/shared/src/constants/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Centralized Configuration Constants 3 | * All magic numbers and configuration values in one place 4 | */ 5 | 6 | /** API Timeout Values (in milliseconds) */ 7 | export const TIMEOUTS = { 8 | MODELS_DEV_FETCH: 10000, // 10 seconds - Fetching provider list from models.dev 9 | OLLAMA_FETCH: 5000, // 5 seconds - Fetching models from local Ollama 10 | PROVIDER_API_CALL: 30000, // 30 seconds - Standard API call timeout 11 | }; 12 | 13 | /** Cache Duration Values (in milliseconds) */ 14 | export const CACHE_DURATIONS = { 15 | MODELS_DEV: 60 * 60 * 1000, // 1 hour - Cache models.dev database 16 | PROVIDER_LIST: 60 * 60 * 1000, // 1 hour - Cache available providers list 17 | IMAGE_MODELS: 5 * 60 * 1000, // 5 minutes - Cache image model list 18 | }; 19 | 20 | /** Provider Default Configuration Values */ 21 | export const PROVIDER_DEFAULTS = { 22 | GOOGLE_VERTEX_LOCATION: "us-central1", 23 | AWS_REGION: "us-east-1", 24 | OLLAMA_BASE_URL: 25 | typeof process !== "undefined" && process.env.OLLAMA_BASE_URL 26 | ? `${process.env.OLLAMA_BASE_URL}/v1` 27 | : "http://localhost:11434/v1", 28 | }; 29 | 30 | /** Message Processing Constants */ 31 | export const MESSAGE_CONSTANTS = { 32 | /** Maximum messages to keep in history for context */ 33 | MAX_HISTORY: 64, 34 | /** Time window for message deduplication (in milliseconds) */ 35 | DEDUPLICATION_WINDOW_MS: 5000, 36 | /** Threshold for considering messages "similar" in timestamp sorting */ 37 | TIMESTAMP_SIMILARITY_MS: 5000, 38 | }; 39 | 40 | /** AI Completion Constants */ 41 | export const COMPLETION_CONSTANTS = { 42 | /** Maximum tokens for completion responses */ 43 | MAX_TOKENS: 4096, 44 | }; 45 | 46 | /** AI Model Feature Detection */ 47 | export const MODEL_FEATURES = { 48 | /** 49 | * Check if model supports prompt caching 50 | * @param {string} modelId - The model identifier 51 | * @returns {boolean} True if model supports caching 52 | */ 53 | SUPPORTS_PROMPT_CACHING: (modelId) => modelId.includes("claude"), 54 | 55 | /** Number of recent messages to include in cache */ 56 | CACHE_LAST_N_MESSAGES: 2, 57 | 58 | /** Cache type for prompt caching */ 59 | CACHE_TYPE: "ephemeral", 60 | }; 61 | -------------------------------------------------------------------------------- /frontend/src/components/ui/ProviderBadge.jsx: -------------------------------------------------------------------------------- 1 | import { MapPin, BadgeCheck, Users } from "lucide-preact"; 2 | import { categorizeProvider } from "@faster-chat/shared"; 3 | 4 | const BADGE_CONFIGS = { 5 | "openai-compatible": { 6 | colors: "bg-theme-green/10 text-theme-green", 7 | Icon: MapPin, 8 | label: "OpenAI Compatible", 9 | }, 10 | local: { 11 | colors: "bg-theme-green/10 text-theme-green", 12 | Icon: MapPin, 13 | label: "Local", 14 | }, 15 | official: { 16 | colors: "bg-theme-blue/10 text-theme-blue", 17 | Icon: BadgeCheck, 18 | label: "Official", 19 | }, 20 | community: { 21 | colors: "bg-theme-mauve/10 text-theme-mauve", 22 | Icon: Users, 23 | label: "Community", 24 | }, 25 | }; 26 | 27 | /** 28 | * Resolve the badge type from provider data 29 | */ 30 | function resolveBadgeType(provider) { 31 | // Use explicit type/category from provider data 32 | if (provider.type) return provider.type; 33 | if (provider.category) return provider.category; 34 | 35 | // Fall back to ID-based categorization 36 | return categorizeProvider(provider.id || provider.name || ""); 37 | } 38 | 39 | /** 40 | * ProviderBadge - Display a badge indicating provider type 41 | * @param {object} provider - Provider object with type, category, id, or name 42 | * @param {boolean} showLabel - Whether to show the label text (default: true) 43 | * @param {string} labelOverride - Optional label to override the default 44 | */ 45 | const ProviderBadge = ({ provider, showLabel = true, labelOverride }) => { 46 | const badgeType = resolveBadgeType(provider); 47 | const config = BADGE_CONFIGS[badgeType] || BADGE_CONFIGS.community; 48 | const { colors, Icon } = config; 49 | 50 | // Determine label: "Native SDK" for official with .type, otherwise default 51 | const label = 52 | labelOverride ?? 53 | (provider.id === "openrouter" 54 | ? "OpenRouter API" 55 | : badgeType === "official" && provider.type 56 | ? "Native SDK" 57 | : config.label); 58 | 59 | return ( 60 | 62 | 63 | {showLabel && label} 64 | 65 | ); 66 | }; 67 | 68 | export default ProviderBadge; 69 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Faster Chat 8 | 9 | 10 | 64 |
65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /frontend/src/components/admin/DeleteUserModal.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "preact/hooks"; 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 | import { adminClient } from "@/lib/adminClient"; 4 | import { Button } from "@/components/ui/button"; 5 | import Modal from "@/components/ui/Modal"; 6 | 7 | const DeleteUserModal = ({ user, isOpen, onClose }) => { 8 | const [error, setError] = useState(""); 9 | 10 | const queryClient = useQueryClient(); 11 | 12 | const deleteMutation = useMutation({ 13 | mutationFn: () => adminClient.deleteUser(user.id), 14 | onSuccess: () => { 15 | queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); 16 | onClose(); 17 | setError(""); 18 | }, 19 | onError: (error) => { 20 | setError(error.message); 21 | }, 22 | }); 23 | 24 | const handleDelete = () => { 25 | setError(""); 26 | deleteMutation.mutate(); 27 | }; 28 | 29 | return ( 30 | 31 |
32 |

33 | Are you sure you want to delete {user?.username}? This action cannot be 34 | undone. 35 |

36 | 37 |
38 |

Warning:

39 |
    40 |
  • All user data will be permanently deleted
  • 41 |
  • All active sessions will be terminated
  • 42 |
  • This action cannot be reversed
  • 43 |
44 |
45 | 46 | {error && ( 47 |
{error}
48 | )} 49 | 50 |
51 | 54 | 61 |
62 |
63 |
64 | ); 65 | }; 66 | 67 | export default DeleteUserModal; 68 | -------------------------------------------------------------------------------- /frontend/src/hooks/useSidebarState.js: -------------------------------------------------------------------------------- 1 | import { useChatsQuery, useDeleteChatMutation, useCreateChatMutation } from "./useChatsQuery"; 2 | import { useIsMobile } from "./useIsMobile"; 3 | import { useUiState } from "@/state/useUiState"; 4 | import { useNavigate, useRouterState } from "@tanstack/react-router"; 5 | import { toast } from "sonner"; 6 | 7 | export function useSidebarState() { 8 | const navigate = useNavigate(); 9 | const pathname = useRouterState({ select: (state) => state.location.pathname }); 10 | const { data: chats } = useChatsQuery(); 11 | const deleteChatMutation = useDeleteChatMutation(); 12 | const createChatMutation = useCreateChatMutation(); 13 | const isSidebarOpen = useUiState((state) => state.sidebarOpen); 14 | const setIsSidebarOpen = useUiState((state) => state.setSidebarOpen); 15 | const toggleSidebar = useUiState((state) => state.toggleSidebar); 16 | const isMobile = useIsMobile(); 17 | 18 | function navigateToChat(chatId, replace = false) { 19 | navigate({ to: "/chat/$chatId", params: { chatId }, replace }); 20 | } 21 | 22 | async function handleDeleteChat(e, chatId) { 23 | e.preventDefault(); 24 | e.stopPropagation(); 25 | 26 | await deleteChatMutation.mutateAsync(chatId); 27 | toast.success("Chat deleted"); 28 | 29 | if (pathname === `/chat/${chatId}`) { 30 | const remainingChats = chats?.filter((c) => c.id !== chatId) ?? []; 31 | const nextChat = remainingChats[0]; 32 | 33 | if (nextChat) { 34 | navigateToChat(nextChat.id, true); 35 | } else { 36 | const newChat = await createChatMutation.mutateAsync({}); 37 | navigateToChat(newChat.id, true); 38 | } 39 | } 40 | } 41 | 42 | async function handleNewChat() { 43 | const newChat = await createChatMutation.mutateAsync({}); 44 | navigateToChat(newChat.id); 45 | if (isMobile) setIsSidebarOpen(false); 46 | } 47 | 48 | function handleLinkClick() { 49 | if (isMobile) setIsSidebarOpen(false); 50 | } 51 | 52 | function handleSelectChat(chatId, replace = false) { 53 | navigateToChat(chatId, replace); 54 | handleLinkClick(); 55 | } 56 | 57 | return { 58 | chats, 59 | isSidebarOpen, 60 | isMobile, 61 | pathname, 62 | handleDeleteChat, 63 | handleNewChat, 64 | handleSelectChat, 65 | toggleSidebar, 66 | setIsSidebarOpen, 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /server/src/lib/imageGeneration.js: -------------------------------------------------------------------------------- 1 | import { IMAGE_GENERATION, IMAGE_MODELS } from "@faster-chat/shared"; 2 | import Replicate from "replicate"; 3 | 4 | /** 5 | * Create a Replicate client with the provided API key 6 | */ 7 | export function createReplicateClient(apiKey) { 8 | return new Replicate({ auth: apiKey }); 9 | } 10 | 11 | /** 12 | * Generate an image using Replicate's Flux model 13 | * @param {Replicate} client - Replicate client instance 14 | * @param {object} options - Generation options 15 | * @param {string} options.prompt - The image prompt 16 | * @param {string} [options.aspectRatio] - Aspect ratio (e.g., "1:1", "16:9") 17 | * @returns {Promise} Array of image URLs 18 | */ 19 | export async function generateImage(client, options) { 20 | const { prompt, aspectRatio = IMAGE_GENERATION.DEFAULT_ASPECT_RATIO } = options; 21 | 22 | const output = await client.run(IMAGE_MODELS.DEFAULT, { 23 | input: { 24 | prompt, 25 | aspect_ratio: aspectRatio, 26 | output_format: "webp", 27 | output_quality: 90, 28 | safety_tolerance: 6, 29 | prompt_upsampling: true, 30 | }, 31 | }); 32 | 33 | // Flux schnell returns an array of FileOutput objects or URLs 34 | // Handle both cases 35 | if (Array.isArray(output)) { 36 | return output.map((item) => (typeof item === "string" ? item : item.url?.())); 37 | } 38 | 39 | // Single output case 40 | return [typeof output === "string" ? output : output.url?.()]; 41 | } 42 | 43 | /** 44 | * Download an image from a URL and return as buffer 45 | * @param {string} url - Image URL to download 46 | * @returns {Promise<{buffer: Buffer, mimeType: string}>} 47 | */ 48 | export async function downloadImage(url) { 49 | const controller = new AbortController(); 50 | const timeout = setTimeout(() => controller.abort(), IMAGE_GENERATION.DOWNLOAD_TIMEOUT_MS); 51 | 52 | try { 53 | const response = await fetch(url, { signal: controller.signal }); 54 | 55 | if (!response.ok) { 56 | throw new Error(`Failed to download image: ${response.status} ${response.statusText}`); 57 | } 58 | 59 | const arrayBuffer = await response.arrayBuffer(); 60 | const buffer = Buffer.from(arrayBuffer); 61 | const mimeType = response.headers.get("content-type") || "image/webp"; 62 | 63 | return { buffer, mimeType }; 64 | } finally { 65 | clearTimeout(timeout); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/lib/search.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fuzzy search utilities using fuzzysort v3. 3 | * Lightweight wrapper for consistent search behavior across the app. 4 | */ 5 | 6 | import fuzzysort from "fuzzysort"; 7 | 8 | // Default search options 9 | const DEFAULT_OPTIONS = { 10 | threshold: -10000, // Allow loose matches 11 | limit: 50, 12 | }; 13 | 14 | /** 15 | * Search items by a single key. 16 | * Returns items sorted by match score (best first). 17 | * 18 | * @param {string} query - Search query 19 | * @param {Array} items - Items to search 20 | * @param {string} key - Object key to search on 21 | * @returns {Array} Matched items (original objects, not fuzzysort results) 22 | */ 23 | export function searchByKey(query, items, key) { 24 | if (!query?.trim()) return items; 25 | 26 | const results = fuzzysort.go(query, items, { 27 | ...DEFAULT_OPTIONS, 28 | key, 29 | }); 30 | 31 | return results.map((result) => result.obj); 32 | } 33 | 34 | /** 35 | * Search items by multiple keys. 36 | * Searches all specified keys and returns best matches. 37 | * 38 | * @param {string} query - Search query 39 | * @param {Array} items - Items to search 40 | * @param {string[]} keys - Object keys to search on 41 | * @returns {Array} Matched items (original objects) 42 | */ 43 | export function searchByKeys(query, items, keys) { 44 | if (!query?.trim()) return items; 45 | 46 | const results = fuzzysort.go(query, items, { 47 | ...DEFAULT_OPTIONS, 48 | keys, 49 | }); 50 | 51 | return results.map((result) => result.obj); 52 | } 53 | 54 | /** 55 | * Search with highlighted results. 56 | * Returns results with both original objects and highlighted text. 57 | * 58 | * @param {string} query - Search query 59 | * @param {Array} items - Items to search 60 | * @param {string} key - Object key to search on 61 | * @returns {Array<{item: object, highlighted: string|null}>} 62 | */ 63 | export function searchWithHighlights(query, items, key) { 64 | if (!query?.trim()) { 65 | return items.map((item) => ({ item, highlighted: null })); 66 | } 67 | 68 | const results = fuzzysort.go(query, items, { 69 | ...DEFAULT_OPTIONS, 70 | key, 71 | }); 72 | 73 | // v3 API: result[0] contains the match for single-key search 74 | return results.map((result) => ({ 75 | item: result.obj, 76 | highlighted: result[0]?.highlight("", "") ?? null, 77 | })); 78 | } 79 | -------------------------------------------------------------------------------- /frontend/src/hooks/useKeyboardShortcuts.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "@preact/compat"; 2 | import { useNavigate } from "@tanstack/react-router"; 3 | import { useUiState } from "@/state/useUiState"; 4 | import { useCreateChatMutation } from "./useChatsQuery"; 5 | import { useIsMobile } from "./useIsMobile"; 6 | import { getShortcut } from "@faster-chat/shared"; 7 | 8 | /** 9 | * Global keyboard shortcuts hook. 10 | * Shortcut definitions live in @faster-chat/shared/constants/shortcuts.js 11 | */ 12 | export function useKeyboardShortcuts() { 13 | const navigate = useNavigate(); 14 | const createChatMutation = useCreateChatMutation(); 15 | const isMobile = useIsMobile(); 16 | 17 | const toggleSidebar = useUiState((state) => state.toggleSidebar); 18 | const toggleSidebarCollapse = useUiState((state) => state.toggleSidebarCollapse); 19 | const setSidebarOpen = useUiState((state) => state.setSidebarOpen); 20 | const sidebarCollapsed = useUiState((state) => state.sidebarCollapsed); 21 | 22 | useEffect(() => { 23 | const handleKeyDown = (e) => { 24 | // Toggle sidebar - Ctrl+B 25 | if (getShortcut("toggleSidebar").check(e)) { 26 | e.preventDefault(); 27 | isMobile ? toggleSidebar() : toggleSidebarCollapse(); 28 | return; 29 | } 30 | 31 | // New chat - Ctrl+Shift+O 32 | if (getShortcut("newChat").check(e)) { 33 | e.preventDefault(); 34 | createChatMutation.mutateAsync({}).then((newChat) => { 35 | navigate({ to: "/chat/$chatId", params: { chatId: newChat.id } }); 36 | if (!isMobile && sidebarCollapsed) { 37 | toggleSidebarCollapse(); 38 | } 39 | }); 40 | return; 41 | } 42 | 43 | // Focus search - Ctrl+K 44 | if (getShortcut("focusSearch").check(e)) { 45 | e.preventDefault(); 46 | if (isMobile) { 47 | setSidebarOpen(true); 48 | } else if (sidebarCollapsed) { 49 | toggleSidebarCollapse(); 50 | } 51 | window.dispatchEvent(new CustomEvent("focus-sidebar-search")); 52 | return; 53 | } 54 | }; 55 | 56 | window.addEventListener("keydown", handleKeyDown); 57 | return () => window.removeEventListener("keydown", handleKeyDown); 58 | }, [ 59 | isMobile, 60 | sidebarCollapsed, 61 | toggleSidebar, 62 | toggleSidebarCollapse, 63 | setSidebarOpen, 64 | navigate, 65 | createChatMutation, 66 | ]); 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/utils/importConversation.js: -------------------------------------------------------------------------------- 1 | import { IMPORT_CONSTANTS } from "@faster-chat/shared"; 2 | 3 | /** 4 | * Validate import file format and structure via backend 5 | */ 6 | export async function validateImportFile(file) { 7 | // Check file extension 8 | if (!file.name.endsWith(IMPORT_CONSTANTS.SUPPORTED_EXTENSION)) { 9 | return { 10 | valid: false, 11 | error: `Only ${IMPORT_CONSTANTS.SUPPORTED_EXTENSION} files are supported.`, 12 | }; 13 | } 14 | 15 | // Check file size 16 | if (file.size > IMPORT_CONSTANTS.MAX_FILE_SIZE_BYTES) { 17 | return { 18 | valid: false, 19 | error: `File is too large. Maximum size is ${IMPORT_CONSTANTS.MAX_FILE_SIZE_MB}MB.`, 20 | }; 21 | } 22 | 23 | // Parse JSON 24 | let data; 25 | try { 26 | const text = await file.text(); 27 | data = JSON.parse(text); 28 | } catch { 29 | return { 30 | valid: false, 31 | error: "Invalid JSON file. Please check the file format.", 32 | }; 33 | } 34 | 35 | // Validate structure via backend 36 | try { 37 | const response = await fetch(IMPORT_CONSTANTS.ENDPOINTS.VALIDATE, { 38 | method: "POST", 39 | headers: { "Content-Type": "application/json" }, 40 | credentials: "include", 41 | body: JSON.stringify({ data }), 42 | }); 43 | 44 | const result = await response.json(); 45 | 46 | if (!response.ok) { 47 | return { 48 | valid: false, 49 | error: result.error || "Failed to validate import file", 50 | }; 51 | } 52 | 53 | if (!result.valid) { 54 | return { 55 | valid: false, 56 | error: result.errors?.join(", ") || "Invalid import format", 57 | }; 58 | } 59 | 60 | return { 61 | valid: true, 62 | data, 63 | preview: result.preview, 64 | stats: result.stats, 65 | }; 66 | } catch (error) { 67 | return { 68 | valid: false, 69 | error: "Failed to validate file: " + error.message, 70 | }; 71 | } 72 | } 73 | 74 | /** 75 | * Import ChatGPT conversations via backend 76 | */ 77 | export async function importChatGPTConversations(data) { 78 | const response = await fetch(IMPORT_CONSTANTS.ENDPOINTS.CHATGPT, { 79 | method: "POST", 80 | headers: { "Content-Type": "application/json" }, 81 | credentials: "include", 82 | body: JSON.stringify({ data }), 83 | }); 84 | 85 | const result = await response.json(); 86 | 87 | if (!response.ok) { 88 | throw new Error(result.error || "Import failed"); 89 | } 90 | 91 | return result; 92 | } 93 | -------------------------------------------------------------------------------- /frontend/src/components/admin/EditUserRoleModal.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "preact/hooks"; 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 | import { adminClient } from "@/lib/adminClient"; 4 | import { Button } from "@/components/ui/button"; 5 | import Modal from "@/components/ui/Modal"; 6 | 7 | const EditUserRoleModal = ({ user, isOpen, onClose }) => { 8 | const [role, setRole] = useState(user?.role || "member"); 9 | const [error, setError] = useState(""); 10 | 11 | const queryClient = useQueryClient(); 12 | 13 | const updateMutation = useMutation({ 14 | mutationFn: () => adminClient.updateUserRole(user.id, role), 15 | onSuccess: () => { 16 | queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); 17 | onClose(); 18 | setError(""); 19 | }, 20 | onError: (error) => { 21 | setError(error.message); 22 | }, 23 | }); 24 | 25 | const handleSubmit = (e) => { 26 | e.preventDefault(); 27 | setError(""); 28 | updateMutation.mutate(); 29 | }; 30 | 31 | return ( 32 | 33 |
34 |
35 | 36 | 44 |

45 | Changing role will invalidate all active sessions for this user. 46 |

47 |
48 | 49 | {error && ( 50 |
{error}
51 | )} 52 | 53 |
54 | 57 | 60 |
61 |
62 |
63 | ); 64 | }; 65 | 66 | export default EditUserRoleModal; 67 | -------------------------------------------------------------------------------- /frontend/src/hooks/voice/useTextToSpeech.js: -------------------------------------------------------------------------------- 1 | import { useRef } from "preact/hooks"; 2 | import { VOICE_CONSTANTS, CHAT_STATES } from "@faster-chat/shared"; 3 | 4 | const splitIntoSentences = (text) => text.match(VOICE_CONSTANTS.SENTENCE_SPLIT_PATTERN) || [text]; 5 | 6 | const enqueueCleanedSentences = (sentences, queueRef) => { 7 | sentences.forEach((sentence) => { 8 | const trimmed = sentence.trim(); 9 | if (trimmed) queueRef.current.push(trimmed); 10 | }); 11 | }; 12 | 13 | const isSpeakingState = (stateRef) => stateRef.current === CHAT_STATES.SPEAKING; 14 | 15 | export function useTextToSpeech({ selectedVoice, onSpeakStart, onSpeakEnd, currentStateRef }) { 16 | const ttsQueueRef = useRef([]); 17 | const cooldownTimerRef = useRef(null); 18 | 19 | const speak = (text) => { 20 | if (!text?.trim()) return; 21 | 22 | const utterance = new SpeechSynthesisUtterance(text); 23 | 24 | if (selectedVoice) { 25 | utterance.voice = selectedVoice; 26 | utterance.lang = selectedVoice.lang; 27 | } 28 | 29 | utterance.onstart = () => { 30 | if (onSpeakStart) onSpeakStart(); 31 | }; 32 | 33 | utterance.onend = () => { 34 | if (ttsQueueRef.current.length > 0) { 35 | const nextSentence = ttsQueueRef.current.shift(); 36 | speak(nextSentence); 37 | return; 38 | } 39 | 40 | if (onSpeakEnd) onSpeakEnd(); 41 | 42 | if (cooldownTimerRef.current) { 43 | clearTimeout(cooldownTimerRef.current); 44 | } 45 | 46 | cooldownTimerRef.current = setTimeout(() => { 47 | if (currentStateRef.current === CHAT_STATES.COOLDOWN && onSpeakEnd) { 48 | onSpeakEnd(true); 49 | } 50 | }, VOICE_CONSTANTS.TTS_COOLDOWN_DELAY_MS); 51 | }; 52 | 53 | window.speechSynthesis.speak(utterance); 54 | }; 55 | 56 | const speakStream = (text) => { 57 | if (!text) return; 58 | 59 | const sentences = splitIntoSentences(text); 60 | enqueueCleanedSentences(sentences, ttsQueueRef); 61 | 62 | if (!isSpeakingState(currentStateRef) && ttsQueueRef.current.length > 0) { 63 | const nextSentence = ttsQueueRef.current.shift(); 64 | speak(nextSentence); 65 | } 66 | }; 67 | 68 | const cancelAll = () => { 69 | if (cooldownTimerRef.current) { 70 | clearTimeout(cooldownTimerRef.current); 71 | cooldownTimerRef.current = null; 72 | } 73 | window.speechSynthesis.cancel(); 74 | ttsQueueRef.current = []; 75 | }; 76 | 77 | return { 78 | speak, 79 | speakStream, 80 | cancelAll, 81 | isSpeaking: () => isSpeakingState(currentStateRef), 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /frontend/src/components/ui/Switch.jsx: -------------------------------------------------------------------------------- 1 | import { useId } from "preact/hooks"; 2 | import { clsx } from "clsx"; 3 | 4 | export const Switch = ({ color = "blue", value, label, onChange, disabled, ...props }) => { 5 | const id = useId(); 6 | 7 | const onClickEvent = (event) => { 8 | event.preventDefault(); 9 | if (disabled) return; 10 | if (onChange && typeof onChange === "function") { 11 | onChange(!value); 12 | } 13 | }; 14 | 15 | const onKeyEvent = (event) => { 16 | if (event.code !== "Space" && event.code !== "Enter") { 17 | return; 18 | } 19 | 20 | event.preventDefault(); 21 | if (disabled) return; 22 | if (onChange && typeof onChange === "function") { 23 | onChange(!value); 24 | } 25 | }; 26 | 27 | return ( 28 |
35 |
64 | 71 |
72 | 73 | {label && ( 74 | 77 | )} 78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug Report" 2 | description: "Report a reproducible problem" 3 | title: "[BUG] " 4 | labels: [bug, needs triage] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: "Thanks for helping us improve! Fill out every required field." 9 | 10 | - type: dropdown 11 | id: deployment 12 | attributes: 13 | label: Deployment Type 14 | description: How are you running Faster Chat? 15 | options: 16 | - Development (bun run dev) 17 | - Docker (self-hosted) 18 | - Other (please specify in Environment) 19 | validations: 20 | required: true 21 | 22 | - type: dropdown 23 | id: area 24 | attributes: 25 | label: Feature Area 26 | description: Which part of the app is affected? 27 | options: 28 | - Chat Interface (messaging, streaming) 29 | - Admin Panel (providers, models, users) 30 | - Authentication (login, registration) 31 | - File Attachments 32 | - Offline Mode / Ollama 33 | - Other (specify in description) 34 | validations: 35 | required: true 36 | 37 | - type: input 38 | id: env 39 | attributes: 40 | label: Environment 41 | description: "Browser, OS, AI provider (if relevant)" 42 | placeholder: "Chrome 126 – macOS 14.5 – using Ollama with llama3.2" 43 | validations: 44 | required: true 45 | 46 | - type: textarea 47 | id: steps 48 | attributes: 49 | label: Steps to Reproduce 50 | placeholder: | 51 | 1. Go to chat interface 52 | 2. Select Claude 3.5 Sonnet model 53 | 3. Type a message with @ file attachment 54 | 4. Click send 55 | 5. Expected stream to start, but saw error... 56 | validations: 57 | required: true 58 | 59 | - type: textarea 60 | id: expected 61 | attributes: 62 | label: Expected Result 63 | validations: 64 | required: true 65 | 66 | - type: textarea 67 | id: actual 68 | attributes: 69 | label: Actual Result 70 | validations: 71 | required: true 72 | 73 | - type: textarea 74 | id: logs 75 | attributes: 76 | label: Screenshots / Console logs / Server logs 77 | description: "Drag‑n‑drop images or paste stack traces. Check browser console (F12) and server logs if running Docker." 78 | 79 | - type: checkboxes 80 | id: checklist 81 | attributes: 82 | label: Checklist 83 | options: 84 | - label: I checked the browser console for errors (F12) 85 | - label: I checked server logs (if using Docker) 86 | - label: I searched existing issues for duplicates 87 | - label: This happens consistently (not a one-time glitch) 88 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | 4 | **Linked Issue(s):** # 5 | 6 | --- 7 | 8 | ### Type of Change 9 | - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) 10 | - [ ] ✨ New feature (non-breaking change which adds functionality) 11 | - [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] 📝 Documentation update 13 | - [ ] 🎨 UI/UX improvement 14 | - [ ] ⚡ Performance improvement 15 | - [ ] 🔧 Refactoring (no functional changes) 16 | 17 | --- 18 | 19 | ### Coding Principles Checklist (see `AGENTS.md`) 20 | - [ ] **Avoided `useEffect`** unless absolutely necessary (syncing with external systems only) 21 | - [ ] **Did NOT use `useCallback`** (no function memoization) 22 | - [ ] **Minimized `useState`** - derived state as expressions instead of new state variables 23 | - [ ] **TanStack Query for server state** - no duplication in useState/Zustand 24 | - [ ] Followed "delete aggressively" - removed unused code 25 | - [ ] No over-engineering - kept it simple and focused 26 | - [ ] Small, focused components (one responsibility each) 27 | 28 | --- 29 | 30 | ### Testing Checklist 31 | - [ ] Tested in **development mode** (`bun run dev`) 32 | - [ ] Tested in **production build** (`bun run build && bun run start` or Docker) 33 | - [ ] Tested in these browsers: 34 | - [ ] Chrome/Edge 35 | - [ ] Firefox 36 | - [ ] Safari (if macOS available) 37 | - [ ] Tested with: 38 | - [ ] Cloud provider (OpenAI/Anthropic/etc.) 39 | - [ ] Local provider (Ollama) - if applicable 40 | - [ ] No console errors or warnings 41 | - [ ] No TypeScript-style errors (we use JSDoc, but code should be clean) 42 | 43 | --- 44 | 45 | ### Code Quality 46 | - [ ] Followed existing code patterns and file organization 47 | - [ ] Updated documentation (`CLAUDE.md`, `README.md`, or inline comments) if needed 48 | - [ ] No `TODO` comments left in code 49 | - [ ] No hardcoded values - used constants from `packages/shared/src/constants/` 50 | - [ ] Proper error handling at system boundaries (user input, API calls) 51 | 52 | --- 53 | 54 | ### Screenshots / GIF (if UI change) 55 | 56 | 57 | --- 58 | 59 | ### Database Changes (if applicable) 60 | - [ ] Updated frontend schema version in `frontend/src/lib/db.js` 61 | - [ ] Updated backend schema in `server/src/lib/db.js` 62 | - [ ] Tested migration from previous version 63 | - [ ] Documented breaking changes in PR description 64 | 65 | --- 66 | 67 | ### Deployment Notes 68 | 69 | 70 | --- 71 | 72 | ### Notes for Reviewers 73 | 74 | -------------------------------------------------------------------------------- /frontend/src/hooks/useChat.js: -------------------------------------------------------------------------------- 1 | import { extractTextContent } from "@/utils/message/messageUtils"; 2 | import { useState } from "preact/hooks"; 3 | import { useChatPersistence } from "./useChatPersistence"; 4 | import { useChatStream } from "./useChatStream"; 5 | 6 | export function useChat({ id: chatId, model }) { 7 | const [input, setInput] = useState(""); 8 | 9 | const { 10 | chat, 11 | messages: persistedMessages, 12 | isChatLoading, 13 | isChatError, 14 | saveUserMessage, 15 | saveAssistantMessage, 16 | } = useChatPersistence(chatId); 17 | 18 | const stream = useChatStream({ 19 | chatId, 20 | model, 21 | persistedMessages, 22 | onMessageComplete: async ({ id, content, createdAt }) => { 23 | if (chatId) { 24 | await saveAssistantMessage({ id, content, model, createdAt }, chatId); 25 | } 26 | }, 27 | }); 28 | 29 | // Imperative API for submitting messages (used by voice and form) 30 | async function submitMessage({ content, fileIds = [] }) { 31 | const trimmedContent = content.trim(); 32 | if (!trimmedContent && fileIds.length === 0) return; 33 | if (!chatId) return; // Route should ensure chatId exists 34 | 35 | const messageId = crypto.randomUUID(); 36 | const createdAt = Date.now(); 37 | setInput(""); 38 | 39 | try { 40 | await saveUserMessage( 41 | { id: messageId, content: trimmedContent, fileIds, createdAt, model }, 42 | chatId 43 | ); 44 | await stream.send({ id: messageId, content: trimmedContent, fileIds, createdAt }); 45 | } catch (err) { 46 | console.error("Failed to send message", err); 47 | } 48 | } 49 | 50 | // Form handler wraps imperative API 51 | function handleSubmit(e, fileIds = []) { 52 | e.preventDefault(); 53 | submitMessage({ content: input, fileIds }); 54 | } 55 | 56 | function handleInputChange(e) { 57 | setInput(e.target.value); 58 | } 59 | 60 | const isLoading = (chatId && isChatLoading) || stream.isStreaming; 61 | 62 | async function regenerateResponse() { 63 | const messages = stream.messages; 64 | if (!messages || messages.length === 0) return; 65 | 66 | const lastUserMessage = messages.findLast((msg) => msg.role === "user"); 67 | if (!lastUserMessage) return; 68 | 69 | const content = extractTextContent(lastUserMessage); 70 | if (!content.trim()) return; 71 | 72 | const fileIds = lastUserMessage.fileIds || []; 73 | await submitMessage({ content, fileIds }); 74 | } 75 | 76 | return { 77 | messages: stream.messages, 78 | input, 79 | setInput, 80 | handleInputChange, 81 | handleSubmit, 82 | submitMessage, 83 | isLoading, 84 | isChatError, 85 | error: stream.error, 86 | currentChat: chat, 87 | stop: stream.stop, 88 | regenerateResponse: stream.isStreaming ? undefined : regenerateResponse, 89 | status: stream.status, 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /server/src/lib/encryption.js: -------------------------------------------------------------------------------- 1 | import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; 2 | import { config } from "dotenv"; 3 | 4 | config(); 5 | 6 | // Encryption key from environment (must be 32 bytes for AES-256) 7 | const ENCRYPTION_KEY = process.env.API_KEY_ENCRYPTION_KEY; 8 | 9 | if (!ENCRYPTION_KEY) { 10 | throw new Error( 11 | "API_KEY_ENCRYPTION_KEY is not set. Generate a 32-byte hex key and set it in the environment before starting the server." 12 | ); 13 | } 14 | 15 | const ALGORITHM = "aes-256-gcm"; 16 | 17 | /** 18 | * Encrypt an API key 19 | * @param {string} apiKey - The API key to encrypt 20 | * @returns {{ encryptedKey: string, iv: string, authTag: string }} 21 | */ 22 | export function encryptApiKey(apiKey) { 23 | if (!apiKey) { 24 | throw new Error("API key is required"); 25 | } 26 | 27 | const key = Buffer.from(process.env.API_KEY_ENCRYPTION_KEY, "hex"); 28 | const iv = randomBytes(16); 29 | const cipher = createCipheriv(ALGORITHM, key, iv); 30 | 31 | let encrypted = cipher.update(apiKey, "utf8", "hex"); 32 | encrypted += cipher.final("hex"); 33 | 34 | const authTag = cipher.getAuthTag(); 35 | 36 | return { 37 | encryptedKey: encrypted, 38 | iv: iv.toString("hex"), 39 | authTag: authTag.toString("hex"), 40 | }; 41 | } 42 | 43 | /** 44 | * Decrypt an API key 45 | * @param {string} encryptedKey - The encrypted API key 46 | * @param {string} iv - The initialization vector 47 | * @param {string} authTag - The authentication tag 48 | * @returns {string} - The decrypted API key 49 | */ 50 | export function decryptApiKey(encryptedKey, iv, authTag) { 51 | if (!encryptedKey || !iv || !authTag) { 52 | throw new Error("Encrypted key, IV, and auth tag are required"); 53 | } 54 | 55 | try { 56 | const key = Buffer.from(process.env.API_KEY_ENCRYPTION_KEY, "hex"); 57 | const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, "hex")); 58 | 59 | decipher.setAuthTag(Buffer.from(authTag, "hex")); 60 | 61 | let decrypted = decipher.update(encryptedKey, "hex", "utf8"); 62 | decrypted += decipher.final("utf8"); 63 | 64 | return decrypted; 65 | } catch (error) { 66 | throw new Error("Failed to decrypt API key: " + error.message); 67 | } 68 | } 69 | 70 | /** 71 | * Mask an API key for display (show first 7 chars + last 4) 72 | * @param {string} apiKey - The API key to mask 73 | * @returns {string} - Masked API key (e.g., "sk-proj...abc123") 74 | */ 75 | export function maskApiKey(apiKey) { 76 | if (!apiKey || apiKey.length < 12) { 77 | return "***"; 78 | } 79 | 80 | const start = apiKey.substring(0, 7); 81 | const end = apiKey.substring(apiKey.length - 4); 82 | return `${start}...${end}`; 83 | } 84 | 85 | /** 86 | * Generate a 32-byte encryption key (for setup) 87 | * Use this to generate the API_KEY_ENCRYPTION_KEY 88 | */ 89 | export function generateEncryptionKey() { 90 | return randomBytes(32).toString("hex"); 91 | } 92 | -------------------------------------------------------------------------------- /oneclick-deploy-notes.md: -------------------------------------------------------------------------------- 1 | ✅ Created Config Files 2 | 3 | 1. fly.toml - Fly.io configuration 4 | 2. railway.json - Railway configuration 5 | 3. render.yaml - Render configuration 6 | 7 | 🚀 One-Click Deploy Buttons 8 | 9 | Add these to your landing page or README: 10 | 11 | HTML Buttons (for landing page) 12 | 13 | 14 |
15 | Deploy on Fly 16 | 17 | 18 | 19 | 20 | Deploy on Railway 21 | 22 | 23 | 24 | 25 | Deploy to Render 26 | 27 | 28 | Markdown Buttons (for README) 29 | 30 | [![Deploy on 31 | Fly.io](https://fly.io/button.svg)](https://fly.io/launch?template=https://github.com/YOUR_USERNAME/faster-chat) 32 | 33 | [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/faster-chat) 34 | 35 | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://git 36 | hub.com/YOUR_USERNAME/faster-chat) 37 | 38 | 📋 How It Works 39 | 40 | When users click these buttons: 41 | 42 | 1. Fly.io: 43 | 44 | - Reads fly.toml 45 | - Prompts for environment variables (API_KEY_ENCRYPTION_KEY, APP_URL) 46 | - Creates a persistent volume for SQLite 47 | - Builds and deploys your Docker image 48 | 2. Railway: 49 | 50 | - Reads railway.json 51 | - Auto-detects Dockerfile 52 | - Prompts for environment variables 53 | - Deploys with auto-scaling 54 | 3. Render: 55 | 56 | - Reads render.yaml 57 | - Auto-generates API_KEY_ENCRYPTION_KEY 58 | - Creates persistent disk for SQLite 59 | - Free tier available! 60 | 61 | 🔑 What Users Need to Configure 62 | 63 | After clicking deploy, users will need to set: 64 | - API_KEY_ENCRYPTION_KEY - 32-byte hex string (you can provide a generator in docs) 65 | - APP_URL - Their deployed URL (e.g., https://my-chat.fly.dev) 66 | 67 | 💡 Pro Tips 68 | 69 | For your landing page, you could create a slick section like: 70 | 71 |
72 |

Deploy Your Own Instance

73 |

One-click deploy to your preferred platform:

74 |
75 | 76 |
77 |
78 | 79 | To set up Railway template: 80 | 1. Go to railway.app/new 81 | 2. Select your GitHub repo 82 | 3. Click "Deploy" 83 | 4. Once deployed, click "Share Template" to get your template URL 84 | 85 | Want me to help you add these deploy buttons to your landing page, or create a setup guide for users? 86 | 87 | -------------------------------------------------------------------------------- /frontend/src/styles/custom.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | /* Themed Switch Component */ 3 | .switch-pseudo::before, 4 | .switch-pseudo::after { 5 | @apply absolute block h-3 w-3 transition-all duration-300; 6 | content: ""; 7 | } 8 | 9 | .switch-pseudo::before { 10 | @apply opacity-0; 11 | left: 0.3rem; 12 | background-color: currentColor; 13 | mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 12 12' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='1px'%3E%3Cpath d='M1.4,5.582,5.764,9.945,14.4,1.4' /%3E%3C/svg%3E"); 14 | transform: translateX(-1ch); 15 | } 16 | 17 | .switch-pseudo.active::before { 18 | @apply opacity-100; 19 | transform: translateX(0); 20 | } 21 | 22 | .switch-pseudo::after { 23 | @apply bg-theme-surface-stronger opacity-100; 24 | right: 0.3rem; 25 | mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'%3E%3Cpath d='M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z'/%3E%3C/svg%3E"); 26 | transform: translateX(0); 27 | } 28 | 29 | .switch-pseudo.active::after { 30 | @apply opacity-0; 31 | transform: translateX(1ch); 32 | } 33 | 34 | /* Themed Radio Component */ 35 | .radio-after::after { 36 | @apply block h-2 w-2 rounded-full opacity-0 transition-all delay-75 duration-300; 37 | content: ""; 38 | background-color: var(--radio-color, currentColor); 39 | transform: scale(0.3); 40 | } 41 | 42 | .radio-after:checked::after { 43 | @apply opacity-100; 44 | transform: scale(1); 45 | } 46 | 47 | /* KaTeX Math Rendering Styles */ 48 | .katex { 49 | font-size: 1.1em; 50 | } 51 | 52 | .katex-display { 53 | margin: 1em 0; 54 | overflow-x: auto; 55 | overflow-y: hidden; 56 | } 57 | 58 | .katex .katex-html { 59 | color: var(--theme-text); 60 | } 61 | 62 | .markdown-block .katex { 63 | display: inline-block; 64 | margin: 0 0.2em; 65 | } 66 | 67 | .markdown-block .katex-display { 68 | padding: 1em; 69 | background: var(--theme-surface); 70 | border-radius: 8px; 71 | border: 1px solid var(--theme-border); 72 | } 73 | 74 | .markdown-block + .markdown-block { 75 | margin-top: 0.5em; 76 | } 77 | 78 | .markdown-block ul, 79 | .markdown-block ol { 80 | margin: 0.5em 0; 81 | padding-left: 1.5em; 82 | } 83 | 84 | .markdown-block li { 85 | margin: 0.25em 0; 86 | } 87 | 88 | .markdown-block h1, 89 | .markdown-block h2, 90 | .markdown-block h3, 91 | .markdown-block h4, 92 | .markdown-block h5, 93 | .markdown-block h6 { 94 | margin-top: 1em; 95 | margin-bottom: 0.5em; 96 | } 97 | 98 | .markdown-block blockquote { 99 | border-left: 3px solid var(--theme-primary); 100 | padding-left: 1em; 101 | margin: 1em 0; 102 | color: var(--theme-text-muted); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /server/src/init.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | /** 3 | * Initialization script for Faster Chat 4 | * 5 | * This script runs before the server starts to: 6 | * - Generate encryption key if missing 7 | * - Ensure required directories exist 8 | * - Validate environment setup 9 | */ 10 | 11 | import { randomBytes } from "crypto"; 12 | import { existsSync, writeFileSync, mkdirSync } from "fs"; 13 | import { join } from "path"; 14 | import { cwd } from "process"; 15 | 16 | // Get paths relative to current working directory (should be /app/server in Docker or server/ locally) 17 | const serverRoot = cwd(); 18 | const envPath = join(serverRoot, ".env"); 19 | const dataDir = join(serverRoot, "data"); 20 | const uploadsDir = join(dataDir, "uploads"); 21 | 22 | console.log("🚀 Initializing Faster Chat...\n"); 23 | 24 | // 1. Ensure data directories exist 25 | if (!existsSync(dataDir)) { 26 | mkdirSync(dataDir, { recursive: true }); 27 | console.log("✅ Created data directory"); 28 | } 29 | 30 | if (!existsSync(uploadsDir)) { 31 | mkdirSync(uploadsDir, { recursive: true }); 32 | console.log("✅ Created uploads directory"); 33 | } 34 | 35 | // 2. Generate encryption key if missing 36 | let keyGenerated = false; 37 | 38 | if (!existsSync(envPath)) { 39 | console.log("🔑 No .env file found, creating one..."); 40 | 41 | const encryptionKey = randomBytes(32).toString("hex"); 42 | const envContent = `# Auto-generated encryption key for API keys storage 43 | # DO NOT commit this file to version control! 44 | # DO NOT lose this key - you won't be able to decrypt stored API keys! 45 | 46 | API_KEY_ENCRYPTION_KEY=${encryptionKey} 47 | `; 48 | 49 | writeFileSync(envPath, envContent, { mode: 0o600 }); // Secure file permissions 50 | console.log("✅ Generated new encryption key in server/.env"); 51 | keyGenerated = true; 52 | } else { 53 | // Check if encryption key exists in .env 54 | const envContent = Bun.file(envPath).text(); 55 | const hasKey = (await envContent).includes("API_KEY_ENCRYPTION_KEY="); 56 | 57 | if (!hasKey) { 58 | console.log("⚠️ .env exists but missing API_KEY_ENCRYPTION_KEY"); 59 | console.log("🔑 Appending encryption key to existing .env..."); 60 | 61 | const encryptionKey = randomBytes(32).toString("hex"); 62 | const envAppend = `\n# Auto-generated encryption key\nAPI_KEY_ENCRYPTION_KEY=${encryptionKey}\n`; 63 | 64 | writeFileSync(envPath, (await envContent) + envAppend, { mode: 0o600 }); 65 | console.log("✅ Added encryption key to server/.env"); 66 | keyGenerated = true; 67 | } else { 68 | console.log("✅ Encryption key already configured"); 69 | } 70 | } 71 | 72 | // 3. Security warning if key was just generated 73 | if (keyGenerated) { 74 | console.log("\n⚠️ IMPORTANT SECURITY NOTICE:"); 75 | console.log(" • Backup your server/.env file - you'll need it to decrypt API keys"); 76 | console.log(" • Never commit this file to git (already in .gitignore)"); 77 | console.log(" • If you lose this key, you'll need to re-add all provider API keys\n"); 78 | } 79 | 80 | console.log("✨ Initialization complete!\n"); 81 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "✨ Feature Request" 2 | description: "Propose a new capability or enhancement" 3 | title: "[FEAT] " 4 | labels: [feature, product] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | **Before submitting:** Check the [Roadmap](https://github.com/1337hero/faster-next-chat#roadmap) to see if this feature is already planned. 10 | 11 | - type: checkboxes 12 | id: preflight 13 | attributes: 14 | label: Pre-flight Checklist 15 | options: 16 | - label: I checked the Roadmap and this isn't already planned 17 | required: true 18 | - label: I searched existing feature requests for duplicates 19 | required: true 20 | 21 | - type: textarea 22 | id: problem 23 | attributes: 24 | label: Problem / Goal 25 | description: "What user pain are we solving? Why does this matter?" 26 | validations: 27 | required: true 28 | 29 | - type: dropdown 30 | id: category 31 | attributes: 32 | label: Feature Category 33 | description: "Which area does this feature belong to?" 34 | options: 35 | - Chat Interface (messaging, streaming, UX) 36 | - Admin Panel (providers, models, users) 37 | - AI Capabilities (tools, vision, search, RAG) 38 | - Settings & Preferences 39 | - Authentication & Security 40 | - Deployment & Infrastructure 41 | - Documentation 42 | - Other 43 | validations: 44 | required: true 45 | 46 | - type: textarea 47 | id: scope 48 | attributes: 49 | label: Scope (in / out) 50 | placeholder: | 51 | **In‑scope** 52 | - User can attach images to chat messages 53 | - Images display inline with preview 54 | - Support PNG, JPG, WebP 55 | 56 | **Out‑of‑scope** 57 | - Video attachments (separate feature) 58 | - Image editing tools 59 | 60 | - type: textarea 61 | id: stories 62 | attributes: 63 | label: User Stories 64 | placeholder: | 65 | 1. As a user, I want to attach images to my messages so that I can ask vision models about them. 66 | 2. As a user, I want to see image previews so that I know what I attached. 67 | 68 | - type: textarea 69 | id: ac 70 | attributes: 71 | label: Acceptance Criteria 72 | description: "How will we know this is done? Use Given/When/Then format if helpful." 73 | placeholder: | 74 | - [ ] User can click attach button and select image files 75 | - [ ] Selected images show preview before sending 76 | - [ ] Images are sent to vision-capable models 77 | - [ ] Images persist in chat history 78 | validations: 79 | required: true 80 | 81 | - type: input 82 | id: figma 83 | attributes: 84 | label: Design / Mockup link (if any) 85 | placeholder: "Figma, screenshot, or sketch URL" 86 | 87 | - type: textarea 88 | id: alternatives 89 | attributes: 90 | label: Alternatives Considered 91 | description: "Did you consider other approaches? Why is this the best solution?" 92 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/provider_integration.yml: -------------------------------------------------------------------------------- 1 | name: "🔌 Provider Integration Request" 2 | description: "Request support for a new AI provider" 3 | title: "[PROVIDER] " 4 | labels: [provider, feature] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Request integration for a new AI provider. We use [models.dev](https://models.dev) for auto-discovery when possible. 10 | 11 | - type: input 12 | id: provider_name 13 | attributes: 14 | label: Provider Name 15 | description: "Official name of the AI provider" 16 | placeholder: "e.g., Cohere, Together AI, Replicate" 17 | validations: 18 | required: true 19 | 20 | - type: input 21 | id: provider_url 22 | attributes: 23 | label: Provider Website / API Docs 24 | placeholder: "https://..." 25 | validations: 26 | required: true 27 | 28 | - type: dropdown 29 | id: compatibility 30 | attributes: 31 | label: API Compatibility 32 | description: "Is the provider OpenAI-compatible?" 33 | options: 34 | - OpenAI-compatible (drop-in replacement) 35 | - Anthropic-compatible 36 | - Custom API (needs adapter) 37 | - Not sure 38 | validations: 39 | required: true 40 | 41 | - type: input 42 | id: base_url 43 | attributes: 44 | label: Base URL / API Endpoint 45 | placeholder: "https://api.provider.com/v1" 46 | 47 | - type: dropdown 48 | id: models_dev 49 | attributes: 50 | label: Is this provider in models.dev? 51 | description: "Check https://models.dev/api.json to see if it's already listed" 52 | options: 53 | - "Yes - already in models.dev" 54 | - "No - needs to be added" 55 | - "Not sure" 56 | validations: 57 | required: true 58 | 59 | - type: textarea 60 | id: models 61 | attributes: 62 | label: Popular Models to Support 63 | description: "List the key models from this provider" 64 | placeholder: | 65 | - command-r-plus 66 | - command-r 67 | - embed-english-v3.0 68 | 69 | - type: textarea 70 | id: auth 71 | attributes: 72 | label: Authentication Method 73 | description: "How does this provider handle API keys?" 74 | placeholder: | 75 | - API key in Authorization header 76 | - Bearer token 77 | - Custom header 78 | - etc. 79 | 80 | - type: textarea 81 | id: special_features 82 | attributes: 83 | label: Special Features / Considerations 84 | description: "Anything unique about this provider? Rate limits, special capabilities, etc." 85 | placeholder: | 86 | - Supports vision models 87 | - Requires OAuth instead of API key 88 | - Has aggressive rate limiting 89 | - Offers free tier 90 | 91 | - type: checkboxes 92 | id: checklist 93 | attributes: 94 | label: Checklist 95 | options: 96 | - label: I checked the Roadmap to see if this is already planned 97 | - label: I searched existing issues for this provider 98 | - label: I'm willing to help test this integration 99 | -------------------------------------------------------------------------------- /frontend/src/lib/providersClient.js: -------------------------------------------------------------------------------- 1 | const API_BASE = import.meta.env.DEV ? "http://localhost:3001" : ""; 2 | 3 | class ProvidersClient { 4 | async _fetch(endpoint, options = {}) { 5 | const response = await fetch(`${API_BASE}${endpoint}`, { 6 | ...options, 7 | credentials: "include", 8 | headers: { 9 | "Content-Type": "application/json", 10 | ...options.headers, 11 | }, 12 | }); 13 | 14 | const data = await response.json(); 15 | 16 | if (!response.ok) { 17 | const error = new Error(data.error || "Request failed"); 18 | error.response = data; 19 | error.status = response.status; 20 | console.error("API Error:", { 21 | endpoint, 22 | status: response.status, 23 | error: data.error, 24 | details: data.details, 25 | }); 26 | throw error; 27 | } 28 | 29 | return data; 30 | } 31 | 32 | async getProviders() { 33 | return this._fetch("/api/admin/providers"); 34 | } 35 | 36 | async getAvailableProviders() { 37 | return this._fetch("/api/admin/providers/available"); 38 | } 39 | 40 | async createProvider(name, displayName, providerType, baseUrl, apiKey) { 41 | return this._fetch("/api/admin/providers", { 42 | method: "POST", 43 | body: JSON.stringify({ name, displayName, providerType, baseUrl, apiKey }), 44 | }); 45 | } 46 | 47 | async updateProvider(providerId, updates) { 48 | return this._fetch(`/api/admin/providers/${providerId}`, { 49 | method: "PUT", 50 | body: JSON.stringify(updates), 51 | }); 52 | } 53 | 54 | async refreshModels(providerId) { 55 | return this._fetch(`/api/admin/providers/${providerId}/refresh-models`, { 56 | method: "POST", 57 | }); 58 | } 59 | 60 | async deleteProvider(providerId) { 61 | return this._fetch(`/api/admin/providers/${providerId}`, { 62 | method: "DELETE", 63 | }); 64 | } 65 | 66 | async setAllModelsEnabled(providerId, enabled) { 67 | return this._fetch(`/api/admin/providers/${providerId}/models/enable`, { 68 | method: "POST", 69 | body: JSON.stringify({ enabled }), 70 | }); 71 | } 72 | 73 | async getAllModels() { 74 | return this._fetch("/api/admin/models"); 75 | } 76 | 77 | async getEnabledModels() { 78 | return this._fetch("/api/models"); 79 | } 80 | 81 | async getEnabledModelsByType(modelType) { 82 | const params = modelType ? `?type=${modelType}` : ""; 83 | return this._fetch(`/api/models${params}`); 84 | } 85 | 86 | async updateModel(modelId, updates) { 87 | return this._fetch(`/api/admin/models/${modelId}`, { 88 | method: "PUT", 89 | body: JSON.stringify(updates), 90 | }); 91 | } 92 | 93 | async setDefaultModel(modelId) { 94 | return this._fetch(`/api/admin/models/${modelId}/default`, { 95 | method: "PUT", 96 | }); 97 | } 98 | 99 | async deleteModel(modelId) { 100 | return this._fetch(`/api/admin/models/${modelId}`, { 101 | method: "DELETE", 102 | }); 103 | } 104 | } 105 | 106 | export const providersClient = new ProvidersClient(); 107 | -------------------------------------------------------------------------------- /frontend/src/hooks/voice/useSpeechRecognition.js: -------------------------------------------------------------------------------- 1 | import { useRef } from "preact/hooks"; 2 | import { VOICE_CONSTANTS, CHAT_STATES } from "@faster-chat/shared"; 3 | import { getSpeechRecognition } from "@/utils/voice/browserSupport"; 4 | import { handleVoiceError, ERROR_TYPES, ERROR_MESSAGES } from "@/utils/voice/errorHandler"; 5 | 6 | const createRecognitionInstance = (language) => { 7 | const SpeechRecognition = getSpeechRecognition(); 8 | const recognition = new SpeechRecognition(); 9 | recognition.continuous = true; 10 | recognition.interimResults = true; 11 | recognition.lang = language || VOICE_CONSTANTS.DEFAULT_LANGUAGE; 12 | return recognition; 13 | }; 14 | 15 | const parseRecognitionResults = (event) => { 16 | let interimTranscript = ""; 17 | let finalTranscript = ""; 18 | 19 | for (let i = event.resultIndex; i < event.results.length; i++) { 20 | const transcript = event.results[i][0].transcript; 21 | if (event.results[i].isFinal) { 22 | finalTranscript += transcript; 23 | } else { 24 | interimTranscript += transcript; 25 | } 26 | } 27 | 28 | return { interim: interimTranscript, final: finalTranscript }; 29 | }; 30 | 31 | const shouldRestartRecognition = (currentStateRef, recognitionRef) => { 32 | return currentStateRef.current === CHAT_STATES.LISTENING && recognitionRef.current; 33 | }; 34 | 35 | export function useSpeechRecognition({ onResult, onError, language, currentStateRef }) { 36 | const recognitionRef = useRef(null); 37 | 38 | const initRecognition = () => { 39 | if (recognitionRef.current) return recognitionRef.current; 40 | 41 | const recognition = createRecognitionInstance(language); 42 | 43 | recognition.onresult = (event) => { 44 | const transcripts = parseRecognitionResults(event); 45 | if (onResult) onResult(transcripts); 46 | }; 47 | 48 | recognition.onerror = (event) => { 49 | handleVoiceError(event.error, ERROR_TYPES.RECOGNITION, onError); 50 | }; 51 | 52 | recognition.onend = () => { 53 | if (shouldRestartRecognition(currentStateRef, recognitionRef)) { 54 | setTimeout(() => { 55 | if (shouldRestartRecognition(currentStateRef, recognitionRef)) { 56 | try { 57 | recognitionRef.current.start(); 58 | } catch (err) { 59 | console.error("[useSpeechRecognition] Failed to restart:", err); 60 | } 61 | } 62 | }, VOICE_CONSTANTS.RECOGNITION_RESTART_DELAY_MS); 63 | } 64 | }; 65 | 66 | recognitionRef.current = recognition; 67 | return recognition; 68 | }; 69 | 70 | const start = () => { 71 | const recognition = initRecognition(); 72 | try { 73 | recognition.start(); 74 | } catch (err) { 75 | console.error("[useSpeechRecognition] Failed to start:", err); 76 | if (onError) onError(ERROR_MESSAGES.MICROPHONE_START_FAILED); 77 | } 78 | }; 79 | 80 | const stop = () => { 81 | if (recognitionRef.current) { 82 | recognitionRef.current.stop(); 83 | } 84 | }; 85 | 86 | const updateLanguage = (lang) => { 87 | if (recognitionRef.current) { 88 | recognitionRef.current.lang = lang; 89 | } 90 | }; 91 | 92 | return { 93 | start, 94 | stop, 95 | updateLanguage, 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /frontend/src/lib/errorHandler.js: -------------------------------------------------------------------------------- 1 | import { toast } from "sonner"; 2 | 3 | /** 4 | * Extract error message from various error formats 5 | */ 6 | export function extractErrorMessage(error) { 7 | if (!error) return "An unexpected error occurred."; 8 | if (typeof error === "string") return error; 9 | if (error instanceof Error) return error.message; 10 | if (typeof error === "object") { 11 | if (typeof error.message === "string") return error.message; 12 | if (typeof error.error === "string") return error.error; 13 | if (error.error && typeof error.error.message === "string") return error.error.message; 14 | } 15 | 16 | try { 17 | return String(error); 18 | } catch { 19 | return "An unexpected error occurred."; 20 | } 21 | } 22 | 23 | /** 24 | * Categorize error type for better UX 25 | */ 26 | function categorizeError(message) { 27 | if (!message) return "error"; 28 | 29 | const msg = message.toLowerCase(); 30 | if (msg.includes("network") || msg.includes("offline") || msg.includes("connection")) { 31 | return "network"; 32 | } 33 | if (msg.includes("unauthorized") || msg.includes("forbidden") || msg.includes("auth")) { 34 | return "auth"; 35 | } 36 | if (msg.includes("validation") || msg.includes("invalid")) { 37 | return "validation"; 38 | } 39 | if (msg.includes("not found") || msg.includes("404")) { 40 | return "notfound"; 41 | } 42 | if (msg.includes("timeout")) { 43 | return "timeout"; 44 | } 45 | return "error"; 46 | } 47 | 48 | /** 49 | * Show enhanced error toast with smart categorization and copy action 50 | * @param {string|Error|object} error - The error to display 51 | * @param {number} duration - Toast duration in ms (default: 4000) 52 | */ 53 | export function showErrorToast(error, duration = 4000) { 54 | const message = extractErrorMessage(error); 55 | const category = categorizeError(message); 56 | 57 | const copyAction = { 58 | label: "Copy", 59 | onClick: () => navigator.clipboard.writeText(message), 60 | }; 61 | 62 | switch (category) { 63 | case "network": 64 | toast.error("Connection Error", { 65 | description: "Check your internet connection or verify the server is running.", 66 | duration, 67 | action: copyAction, 68 | }); 69 | break; 70 | 71 | case "timeout": 72 | toast.error("Request Timeout", { 73 | description: "The request took too long. Try again.", 74 | duration, 75 | action: copyAction, 76 | }); 77 | break; 78 | 79 | case "auth": 80 | toast.error("Authentication Error", { 81 | description: "Your session may have expired. Please log in again.", 82 | duration, 83 | }); 84 | break; 85 | 86 | case "validation": 87 | toast.warning("Invalid Input", { 88 | description: message, 89 | duration, 90 | }); 91 | break; 92 | 93 | case "notfound": 94 | toast.error("Not Found", { 95 | description: message, 96 | duration, 97 | }); 98 | break; 99 | 100 | default: 101 | toast.error("Error", { 102 | description: message, 103 | duration, 104 | action: copyAction, 105 | }); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /frontend/src/components/chat/MessageAttachment.jsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { Download, File, Sparkles } from "lucide-preact"; 3 | 4 | const API_BASE = import.meta.env.DEV ? "http://localhost:3001" : ""; 5 | 6 | export default function MessageAttachment({ fileId }) { 7 | const { 8 | data: fileMetadata, 9 | isLoading, 10 | error, 11 | } = useQuery({ 12 | queryKey: ["file", fileId], 13 | queryFn: async () => { 14 | const response = await fetch(`${API_BASE}/api/files/${fileId}`, { 15 | credentials: "include", 16 | }); 17 | if (!response.ok) throw new Error("Failed to load file"); 18 | return response.json(); 19 | }, 20 | }); 21 | 22 | const handleDownload = () => { 23 | window.open(`${API_BASE}/api/files/${fileId}/content`, "_blank"); 24 | }; 25 | 26 | if (isLoading) { 27 | return ( 28 |
29 | 30 | Loading... 31 |
32 | ); 33 | } 34 | 35 | if (error || !fileMetadata) { 36 | return ( 37 |
38 | 39 | File unavailable 40 |
41 | ); 42 | } 43 | 44 | const isImage = fileMetadata.mimeType?.startsWith("image/"); 45 | const isGenerated = fileMetadata.meta?.type === "generated"; 46 | 47 | // Render images inline 48 | if (isImage) { 49 | return ( 50 |
51 | {fileMetadata.meta?.prompt 57 | {/* Overlay with download button and generated badge */} 58 |
59 | {isGenerated && ( 60 | 61 | 62 | Generated 63 | 64 | )} 65 | 71 |
72 |
73 | ); 74 | } 75 | 76 | // Non-image files: show download button 77 | return ( 78 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /frontend/src/pages/authenticated/Chat.jsx: -------------------------------------------------------------------------------- 1 | import ChatInterface from "@/components/chat/ChatInterface"; 2 | import ErrorBanner from "@/components/ui/ErrorBanner"; 3 | import { useChatQuery, useCreateChatMutation } from "@/hooks/useChatsQuery"; 4 | import { useAppSettings } from "@/state/useAppSettings"; 5 | import { useNavigate } from "@tanstack/react-router"; 6 | import { useLayoutEffect, useRef } from "preact/hooks"; 7 | 8 | const Chat = ({ chatId }) => { 9 | const navigate = useNavigate(); 10 | const { data: chat, isLoading, isError, error } = useChatQuery(chatId); 11 | const createChatMutation = useCreateChatMutation(); 12 | const appName = useAppSettings((state) => state.appName); 13 | 14 | // Update document title when chat changes 15 | useLayoutEffect(() => { 16 | const chatTitle = chat?.title || "New Chat"; 17 | document.title = `${chatTitle} | ${appName}`; 18 | 19 | return () => { 20 | document.title = appName; 21 | }; 22 | }, [chat?.title, appName]); 23 | const hasAttemptedRedirect = useRef(false); 24 | 25 | // Auto-redirect to new chat if current chat is missing/deleted 26 | if (isError && !hasAttemptedRedirect.current && !createChatMutation.isPending) { 27 | hasAttemptedRedirect.current = true; 28 | createChatMutation.mutate( 29 | {}, 30 | { 31 | onSuccess: (newChat) => { 32 | navigate({ 33 | to: "/chat/$chatId", 34 | params: { chatId: newChat.id }, 35 | replace: true, 36 | }); 37 | }, 38 | } 39 | ); 40 | } 41 | 42 | const handleCreateNewChat = () => { 43 | createChatMutation.mutate( 44 | {}, 45 | { 46 | onSuccess: (newChat) => { 47 | navigate({ 48 | to: "/chat/$chatId", 49 | params: { chatId: newChat.id }, 50 | replace: true, 51 | }); 52 | }, 53 | } 54 | ); 55 | }; 56 | 57 | if (isLoading || createChatMutation.isPending) { 58 | return ( 59 |
60 |
61 | {createChatMutation.isPending ? "Redirecting to a new chat..." : "Loading chat..."} 62 |
63 |
64 | ); 65 | } 66 | 67 | if (isError) { 68 | return ( 69 |
70 | 75 |
76 | 82 | 88 |
89 |
90 | ); 91 | } 92 | 93 | return ; 94 | }; 95 | 96 | export default Chat; 97 | -------------------------------------------------------------------------------- /frontend/src/components/settings/FontSelector.jsx: -------------------------------------------------------------------------------- 1 | import { useThemeStore, FONT_PRESETS, FONT_SIZE_PRESETS } from "@/state/useThemeStore"; 2 | import { Check } from "lucide-preact"; 3 | 4 | // Font card component - shows preview in actual font 5 | const FontCard = ({ font, isSelected, onSelect }) => { 6 | return ( 7 | 34 | ); 35 | }; 36 | 37 | // Font size toggle 38 | const FontSizeToggle = ({ currentSize, setSize }) => { 39 | return ( 40 |
41 | {FONT_SIZE_PRESETS.map(({ id, name }) => ( 42 | 52 | ))} 53 |
54 | ); 55 | }; 56 | 57 | export const FontSelector = () => { 58 | const chatFont = useThemeStore((state) => state.chatFont); 59 | const chatFontSize = useThemeStore((state) => state.chatFontSize); 60 | const setChatFont = useThemeStore((state) => state.setChatFont); 61 | const setChatFontSize = useThemeStore((state) => state.setChatFontSize); 62 | 63 | return ( 64 |
65 | {/* Font Family */} 66 |
67 | 68 |
69 | {FONT_PRESETS.map((font) => ( 70 | 76 | ))} 77 |
78 |
79 | 80 | {/* Font Size */} 81 |
82 | 85 | 86 |
87 |
88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /frontend/src/components/admin/EditProviderModal.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "preact/hooks"; 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 | import { providersClient } from "@/lib/providersClient"; 4 | import { Button } from "@/components/ui/button"; 5 | import Modal from "@/components/ui/Modal"; 6 | 7 | const EditProviderModal = ({ provider, onClose }) => { 8 | const queryClient = useQueryClient(); 9 | const [apiKey, setApiKey] = useState(""); 10 | const [baseUrl, setBaseUrl] = useState(provider.base_url || ""); 11 | const [error, setError] = useState(""); 12 | 13 | const updateMutation = useMutation({ 14 | mutationFn: () => 15 | providersClient.updateProvider(provider.id, { 16 | apiKey: apiKey || undefined, 17 | baseUrl: baseUrl || null, 18 | }), 19 | onSuccess: () => { 20 | queryClient.invalidateQueries({ queryKey: ["admin", "providers"] }); 21 | queryClient.invalidateQueries({ queryKey: ["admin", "models"] }); 22 | onClose(); 23 | setApiKey(""); 24 | setError(""); 25 | }, 26 | onError: (err) => { 27 | const message = err?.message || "Failed to update provider"; 28 | setError(message); 29 | }, 30 | }); 31 | 32 | const handleSubmit = (e) => { 33 | e.preventDefault(); 34 | setError(""); 35 | if (!apiKey && baseUrl === provider.base_url) { 36 | setError("Enter a new API key or update the base URL."); 37 | return; 38 | } 39 | updateMutation.mutate(); 40 | }; 41 | 42 | return ( 43 | 44 |
45 |
46 | 47 | setApiKey(e.target.value)} 51 | placeholder="New API key" 52 | autoComplete="new-password" 53 | className="border-theme-surface-strong bg-theme-canvas text-theme-text focus:border-theme-blue mt-1 w-full rounded-lg border px-4 py-2 focus:outline-none" 54 | /> 55 |

56 | Current key: {provider.masked_key || "Not set"} 57 |

58 |
59 | 60 |
61 | 62 | setBaseUrl(e.target.value)} 66 | placeholder="https://api.example.com" 67 | className="border-theme-surface-strong bg-theme-canvas text-theme-text focus:border-theme-blue mt-1 w-full rounded-lg border px-4 py-2 focus:outline-none" 68 | /> 69 |
70 | 71 | {error && ( 72 |
{error}
73 | )} 74 | 75 |
76 | 79 | 82 |
83 |
84 |
85 | ); 86 | }; 87 | 88 | export default EditProviderModal; 89 | -------------------------------------------------------------------------------- /frontend/src/components/admin/ResetPasswordModal.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "preact/hooks"; 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 | import { adminClient } from "@/lib/adminClient"; 4 | import { Button } from "@/components/ui/button"; 5 | import Modal from "@/components/ui/Modal"; 6 | 7 | const ResetPasswordModal = ({ user, isOpen, onClose }) => { 8 | const [password, setPassword] = useState(""); 9 | const [confirmPassword, setConfirmPassword] = useState(""); 10 | const [error, setError] = useState(""); 11 | 12 | const queryClient = useQueryClient(); 13 | 14 | const resetMutation = useMutation({ 15 | mutationFn: () => adminClient.resetUserPassword(user.id, password), 16 | onSuccess: () => { 17 | queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); 18 | onClose(); 19 | setPassword(""); 20 | setConfirmPassword(""); 21 | setError(""); 22 | }, 23 | onError: (error) => { 24 | setError(error.message); 25 | }, 26 | }); 27 | 28 | const handleSubmit = (e) => { 29 | e.preventDefault(); 30 | setError(""); 31 | 32 | if (!password || !confirmPassword) { 33 | setError("Both password fields are required"); 34 | return; 35 | } 36 | 37 | if (password.length < 8) { 38 | setError("Password must be at least 8 characters"); 39 | return; 40 | } 41 | 42 | if (password !== confirmPassword) { 43 | setError("Passwords do not match"); 44 | return; 45 | } 46 | 47 | resetMutation.mutate(); 48 | }; 49 | 50 | return ( 51 | 52 |
53 |
54 | 55 | setPassword(e.target.value)} 59 | className="border-theme-surface-strong bg-theme-canvas text-theme-text focus:border-theme-blue mt-1 w-full rounded-lg border px-4 py-2 focus:outline-none" 60 | placeholder="Minimum 8 characters" 61 | /> 62 |
63 | 64 |
65 | 66 | setConfirmPassword(e.target.value)} 70 | className="border-theme-surface-strong bg-theme-canvas text-theme-text focus:border-theme-blue mt-1 w-full rounded-lg border px-4 py-2 focus:outline-none" 71 | placeholder="Re-enter password" 72 | /> 73 |

74 | Resetting password will invalidate all active sessions for this user. 75 |

76 |
77 | 78 | {error && ( 79 |
{error}
80 | )} 81 | 82 |
83 | 86 | 89 |
90 |
91 |
92 | ); 93 | }; 94 | 95 | export default ResetPasswordModal; 96 | --------------------------------------------------------------------------------