From ec68dd90f028e2909a4603f6999219f2013089fc Mon Sep 17 00:00:00 2001 From: jatinkrmalik Date: Sun, 25 May 2025 21:11:17 -0500 Subject: [PATCH] Add Next.js website with GitHub Pages deployment - Added complete Next.js single-page website for Vocalinux - Configured static export with proper GitHub Pages deployment - Added GitHub Actions workflow for automated deployment to gh-pages branch - Configured custom domain (vocalinux.com) with CNAME file - Added comprehensive build script with npm support - Responsive design with dark/light theme toggle - Modern UI components with Tailwind CSS and shadcn/ui - Proper SEO metadata and accessibility features --- .github/workflows/deploy.yml | 46 ++ activate-vocalinux.sh | 5 - build-website.sh | 37 + web/Dockerfile | 39 + web/README.md | 130 ++++ web/components.json | 21 + web/next-env.d.ts | 5 + web/next.config.js | 48 ++ web/package.json | 72 ++ web/postcss.config.js | 5 + web/prettier.config.js | 4 + web/src/app/layout.tsx | 87 +++ web/src/app/page.tsx | 951 +++++++++++++++++++++++ web/src/components/dictation-overlay.tsx | 109 +++ web/src/components/icons.tsx | 11 + web/src/components/logo.tsx | 18 + web/src/components/theme-provider.tsx | 23 + web/src/components/theme-toggle.tsx | 29 + web/src/components/ui/button.tsx | 31 + web/src/hooks/use-double-press.ts | 60 ++ web/src/lib/api/util.ts | 594 ++++++++++++++ web/src/lib/utils.ts | 6 + web/src/styles/globals.css | 92 +++ web/src/types/tailwind.d.ts | 4 + web/tailwind.config.ts | 111 +++ web/tsconfig.json | 59 ++ 26 files changed, 2592 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/deploy.yml delete mode 100755 activate-vocalinux.sh create mode 100755 build-website.sh create mode 100644 web/Dockerfile create mode 100644 web/README.md create mode 100644 web/components.json create mode 100644 web/next-env.d.ts create mode 100644 web/next.config.js create mode 100644 web/package.json create mode 100644 web/postcss.config.js create mode 100644 web/prettier.config.js create mode 100644 web/src/app/layout.tsx create mode 100644 web/src/app/page.tsx create mode 100644 web/src/components/dictation-overlay.tsx create mode 100644 web/src/components/icons.tsx create mode 100644 web/src/components/logo.tsx create mode 100644 web/src/components/theme-provider.tsx create mode 100644 web/src/components/theme-toggle.tsx create mode 100644 web/src/components/ui/button.tsx create mode 100644 web/src/hooks/use-double-press.ts create mode 100644 web/src/lib/api/util.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/styles/globals.css create mode 100644 web/src/types/tailwind.d.ts create mode 100644 web/tailwind.config.ts create mode 100644 web/tsconfig.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0351672 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,46 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [ main, master ] + paths: [ 'web/**' ] + workflow_dispatch: + +permissions: + contents: write + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' + + - name: Install dependencies + working-directory: ./web + run: npm ci + + - name: Build website + working-directory: ./web + run: npm run deploy + + - name: Deploy to gh-pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./web/out + publish_branch: gh-pages + cname: vocalinux.com diff --git a/activate-vocalinux.sh b/activate-vocalinux.sh deleted file mode 100755 index 23b86b9..0000000 --- a/activate-vocalinux.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -# This script activates the Vocalinux virtual environment -source "$(dirname "$(realpath "$0")")/venv/bin/activate" -echo "Vocalinux virtual environment activated." -echo "To start the application, run: vocalinux" diff --git a/build-website.sh b/build-website.sh new file mode 100755 index 0000000..1ed95f8 --- /dev/null +++ b/build-website.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Build script for static website deployment +echo "πŸš€ Building Vocalinux website for static deployment..." + +# Navigate to web directory +cd "$(dirname "$0")/web" || exit 1 + +# Install dependencies +echo "πŸ“¦ Installing dependencies with npm..." +npm install + +# Build the static site +echo "πŸ”¨ Building static site..." +npm run deploy + +# Fix asset paths for relative deployment +echo "πŸ”§ Fixing asset paths..." +cd "$(dirname "$0")" || exit 1 +./fix-paths.sh + +# Check if build was successful +if [ -d "out" ]; then + echo "βœ… Build successful! Static files are in web/out/" + echo "πŸ“ Contents of out directory:" + ls -la out/ + echo "" + echo "🌐 You can now deploy the 'web/out' directory to any static hosting service" + echo "πŸ“‹ For GitHub Pages:" + echo " 1. Push your changes to GitHub" + echo " 2. Go to Settings > Pages in your GitHub repository" + echo " 3. Select 'GitHub Actions' as the source" + echo " 4. The workflow will automatically deploy your site" +else + echo "❌ Build failed! Please check the error messages above." + exit 1 +fi diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..9148769 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,39 @@ +# Base image for development dependencies +FROM oven/bun:1 AS base + +# No need for pnpm installation since we'll use bun + +# Install common dependencies that are likely to be used in Next.js projects +FROM base AS deps +WORKDIR /app + +# Copy only package files for efficient caching +COPY . . + +# Install dependencies +RUN bun install + +# Install git and configure it +RUN apt-get update -y && apt-get install -y git && \ + cd /app && \ + git init && \ + git config --system --add safe.directory /app && \ + git config --system user.name "creatr-agent" && \ + git config --system user.email "creatr-agent@getcreatr.com" && \ + git add . && \ + git commit -m "Initial Commit" + +# Create a separate stage for exporting +FROM alpine:latest AS export-stage + +# Create tar archive of the source code (excluding node_modules) +COPY --from=deps /app /source +RUN cd /source && \ + tar -czf /source.tar.gz --exclude=node_modules . + +# Copy node_modules to a separate location for volume mounting +COPY --from=deps /app/node_modules /deps/node_modules + +# Clean up the source directory and extract the tar +RUN rm -rf /source/* && \ + mv /source.tar.gz /source/ \ No newline at end of file diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..f9579a3 --- /dev/null +++ b/web/README.md @@ -0,0 +1,130 @@ +# Vocalinux Website + +This is the official website for Vocalinux, a voice-to-text application for Linux systems. The website is built with Next.js and can be deployed as a static site to GitHub Pages. + +## πŸš€ Quick Start + +### Development +```bash +npm install +npm run dev +``` + +### Building for Production +```bash +npm install +npm run build +``` + +The static files will be generated in the `out/` directory. + +## πŸ“¦ Deployment to GitHub Pages + +### Automatic Deployment (Recommended) + +1. **Push your changes** to the `main` or `master` branch +2. **GitHub Actions will automatically build and deploy** your site to the `gh-pages` branch +3. **Custom Domain**: The site is configured for `vocalinux.com` +4. **The site will be available** at https://vocalinux.com + +### Manual Deployment + +If you prefer to deploy manually: + +1. **Build the static site:** + ```bash + cd web + npm install + npm run deploy + ``` + +2. **The build includes:** + - Static HTML, CSS, and JavaScript files in `out/` + - `.nojekyll` file for GitHub Pages compatibility + - `CNAME` file pointing to `vocalinux.com` + +### Local Testing + +To test the built site locally: +```bash +cd web/out +python3 -m http.server 3000 +``` + +Then visit `http://localhost:3000` in your browser. + +## πŸ› οΈ Build Scripts + +- `npm run dev` - Start development server +- `npm run build` - Build static site for production +- `npm run deploy` - Build and prepare for GitHub Pages (adds .nojekyll and CNAME) +- `npm run lint` - Run ESLint +- `npm run typecheck` - Run TypeScript type checking + +## πŸ“ Project Structure + +``` +web/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ app/ +β”‚ β”‚ β”œβ”€β”€ layout.tsx # Root layout +β”‚ β”‚ └── page.tsx # Homepage +β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ β”œβ”€β”€ dictation-overlay.tsx +β”‚ β”‚ β”œβ”€β”€ theme-toggle.tsx +β”‚ β”‚ └── ui/ # Reusable UI components +β”‚ β”œβ”€β”€ hooks/ +β”‚ └── lib/ +β”œβ”€β”€ public/ # Static assets +β”œβ”€β”€ out/ # Generated static files (after build) +└── package.json +``` + +## 🎨 Features + +- **Responsive Design** - Works on desktop and mobile +- **Dark/Light Theme** - Automatic theme switching +- **Modern UI** - Built with Tailwind CSS and Framer Motion +- **SEO Optimized** - Proper meta tags and structure +- **Fast Loading** - Optimized static generation + +## πŸ”§ Configuration + +### Custom Domain + +If you want to use a custom domain: + +1. Update the `deploy` script in `package.json` to include your domain: + ```json + "deploy": "next build && touch out/.nojekyll && echo 'yourdomain.com' > out/CNAME" + ``` + +2. Configure your domain's DNS to point to GitHub Pages + +### Base Path + +If deploying to a subdirectory, update `next.config.js`: +```javascript +const config = { + basePath: '/your-subdirectory', + // ... other config +} +``` + +## πŸ› Troubleshooting + +### Build Issues + +- **"Module not found"**: Run `npm install` to ensure all dependencies are installed +- **TypeScript errors**: Run `npm run typecheck` to see detailed type errors +- **ESLint errors**: Run `npm run lint:fix` to automatically fix common issues + +### Deployment Issues + +- **404 on GitHub Pages**: Ensure `.nojekyll` file exists in the `out/` directory +- **Assets not loading**: Check that `basePath` is correctly configured in `next.config.js` +- **Blank page**: Check browser console for JavaScript errors + +## πŸ“ License + +This project is licensed under the same license as the main Vocalinux project. diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..7feb265 --- /dev/null +++ b/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/styles/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/web/next-env.d.ts b/web/next-env.d.ts new file mode 100644 index 0000000..1b3be08 --- /dev/null +++ b/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/next.config.js b/web/next.config.js new file mode 100644 index 0000000..34889f8 --- /dev/null +++ b/web/next.config.js @@ -0,0 +1,48 @@ +// @ts-ignore +/** @type {import("next").NextConfig} */ +const config = { + output: 'export', + trailingSlash: true, + // Configuration for custom domain deployment + distDir: 'out', + images: { + unoptimized: true, + remotePatterns: [ + { + protocol: 'https', + hostname: '*', + pathname: '**', + }, + ], + }, + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, + productionBrowserSourceMaps: true, + webpack: (config, { isServer }) => { + config.stats = "verbose"; + config.devtool = 'source-map' + // Enhanced node polyfills for postgres and other modules + if (!isServer) { + config.resolve.fallback = { + ...config.resolve.fallback, + // File system - never needed in browser + fs: false, + net: false, + tls: false, + crypto: false, + stream: false, + 'perf_hooks': false, + + // Definitely not needed in browser + child_process: false, + dns: false, + }; + } + return config; + }, +}; +export default config; diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..da1efe5 --- /dev/null +++ b/web/package.json @@ -0,0 +1,72 @@ +{ + "name": "creatr", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "next build", + "build:static": "next build", + "check": "next lint && tsc --noEmit", + "dev": "next dev", + "lint": "eslint", + "lint:fix": "eslint . --fix", + "preview": "next build && next start", + "start": "next start", + "typecheck": "tsc --noEmit", + "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", + "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", + "test": "jest", + "test:types": "tsc --noEmit --pretty", + "test:all": "npm run test && npm run test:types", + "deploy": "next build && touch out/.nojekyll && echo 'vocalinux.com' > out/CNAME", + "db:generate": "npx drizzle-kit generate && npx drizzle-kit migrate && git add . && git commit -m 'chore: update migration'" + }, + "dependencies": { + "@jest/globals": "^29.7.0", + "@types/eslint": "^8.56.10", + "@types/jest": "^29.5.14", + "@types/node": "^20.14.10", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.1.0", + "@typescript-eslint/parser": "^8.1.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "error-stack-parser": "^2.1.4", + "eslint": "^8.57.0", + "eslint-config-next": "^15.0.1", + "eslint-import-resolver-node": "^0.3.9", + "eslint-import-resolver-typescript": "^3.7.0", + "framer-motion": "^12.9.4", + "geist": "^1.4.1", + "lucide-react": "^0.507.0", + "next": "^15.0.1", + "next-themes": "^0.4.6", + "postcss": "^8.4.39", + "postcss-syntax": "^0.36.2", + "prettier": "^3.3.2", + "prettier-plugin-tailwindcss": "^0.6.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-intersection-observer": "^9.16.0", + "react-syntax-highlighter": "^15.6.1", + "recharts": "^2.15.0", + "source-map": "^0.7.4", + "stylelint": "^16.13.0", + "stylelint-config-standard": "^37.0.0", + "stylelint-config-tailwindcss": "^0.0.7", + "tailwind-merge": "^3.2.0", + "tailwindcss": "^3.4.3", + "tailwindcss-animate": "^1.0.7", + "ts-jest": "^29.2.5", + "typescript": "^5.5.3", + "zustand": "^5.0.2", + "creatr-devtools": "latest" + }, + "overrides": { + "drizzle-orm": "0.41.0-29155cc" + }, + "devDependencies": { + "eslint-plugin-creatr": "latest" + } +} \ No newline at end of file diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..01bf743 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + tailwindcss: {}, + }, +}; diff --git a/web/prettier.config.js b/web/prettier.config.js new file mode 100644 index 0000000..da332bd --- /dev/null +++ b/web/prettier.config.js @@ -0,0 +1,4 @@ +/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */ +export default { + plugins: ["prettier-plugin-tailwindcss"], +}; diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx new file mode 100644 index 0000000..a36bdfd --- /dev/null +++ b/web/src/app/layout.tsx @@ -0,0 +1,87 @@ +import "@/styles/globals.css"; +import React from "react"; +import { GeistSans } from "geist/font/sans"; +import { type Metadata } from "next"; +import { ThemeProvider } from "@/components/theme-provider"; +import { DevtoolsProvider } from 'creatr-devtools'; +export const viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1 +}; +export const metadata: Metadata = { + title: { + default: "Vocalinux - Open-Source Voice Dictation for Linux", + template: "%s | Vocalinux" + }, + description: "Open-source offline voice dictation for Linux using Whisper models. Privacy-focused with no cloud dependencies.", + applicationName: "Vocalinux", + keywords: ["voice dictation", "linux", "speech recognition", "whisper", "offline", "privacy", "x11", "wayland", "open-source"], + authors: [{ + name: "Vocalinux Team" + }], + creator: "Vocalinux Team", + publisher: "Vocalinux Team", + icons: { + icon: [{ + url: "/favicon-16x16.png", + sizes: "16x16", + type: "image/png" + }, { + url: "/favicon-32x32.png", + sizes: "32x32", + type: "image/png" + }, { + url: "/favicon.ico", + sizes: "48x48", + type: "image/x-icon" + }], + apple: [{ + url: "/apple-touch-icon.png", + sizes: "180x180", + type: "image/png" + }] + }, + manifest: "/site.webmanifest", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Vocalinux" + }, + formatDetection: { + telephone: false + }, + openGraph: { + type: "website", + locale: "en_US", + url: "https://github.com/jatinkrmalik/vocalinux", + siteName: "Vocalinux", + title: "Vocalinux - Open-Source Voice Dictation for Linux", + description: "Open-source offline voice dictation for Linux using Whisper models. Works with X11 and Wayland.", + images: [{ + url: "https://picsum.photos/200", + width: 1280, + height: 640, + alt: "Vocalinux Open-Source Voice Dictation" + }] + }, + twitter: { + card: "summary_large_image", + title: "Vocalinux - Open-Source Voice Dictation for Linux", + description: "Open-source offline voice dictation for Linux using Whisper models. Works with X11 and Wayland.", + images: ["https://picsum.photos/200"] + } +}; +export default function RootLayout({ + children +}: Readonly<{ + children: React.ReactNode; +}>) { + return + + + {children} + + + ; +} \ No newline at end of file diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx new file mode 100644 index 0000000..1809e2b --- /dev/null +++ b/web/src/app/page.tsx @@ -0,0 +1,951 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { motion } from "framer-motion"; +import { useDoublePress } from "@/hooks/use-double-press"; +import { DictationOverlay } from "@/components/dictation-overlay"; +import { Mic, Terminal, Lock, Zap, Laptop, Keyboard, Settings, Code, Github, BookOpen, MessageSquare, Edit, Star, Download, ExternalLink, ChevronRight, CheckCircle2, Menu, X } from "lucide-react"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { VocalinuxLogo } from "@/components/icons"; +import SyntaxHighlighter from "react-syntax-highlighter"; +import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs"; +import { useInView } from "react-intersection-observer"; +const installCommand = `# Install Vocalinux +git clone https://github.com/jatinkrmalik/vocalinux.git +cd vocalinux +pip install -r requirements.txt +python cli.py install`; +const FeatureCard = ({ + icon, + title, + description +}) => { + const [ref, inView] = useInView({ + triggerOnce: true, + threshold: 0.1 + }); + return +
+
+ {icon} +
+

{title}

+
+

{description}

+
; +}; +const CopyToClipboard = ({ + text +}) => { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return ; +}; +const FadeInSection = ({ + children, + delay = 0 +}) => { + const [ref, inView] = useInView({ + triggerOnce: true, + threshold: 0.1, + rootMargin: "0px 0px -50px 0px" + }); + return + {children} + ; +}; +export default function HomePage() { + const [stars, setStars] = useState(0); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const { + isActive: isDictating, + resetActive: resetDictation + } = useDoublePress({ + key: "Control", + onDoublePress: () => { + // When double Ctrl press is detected, the overlay will show automatically + console.log("Double Ctrl press detected!"); + } + }); + useEffect(() => { + // Set a placeholder value for stars + setStars(1247); + }, []); + return
+ {/* Dictation Overlay */} + + {/* Header with Theme Toggle */} +
+ + + {/* Mobile menu */} + + + +
+ + {/* Hero Section */} +
+
+
+
+
+ +
+
+ + Open-Source Voice Dictation for Linux +
+
+

+ Linux Voice Dictation with{" "} + Vocalinux +

+

+ Open-source offline voice dictation for Linux with universal application support. + Privacy-focused speech-to-text with no cloud dependencies. +

+ +
+
+
+ +
+
+ {/* Clean space with gradient background */} +
+ + {/* Double Ctrl Key Visualization */} +
+ {/* First Ctrl Key - Already Pressed */} + +
+ Ctrl +
+ {/* Press indicator */} + + + + {/* Second Ctrl Key - Being Pressed */} + +
+ Ctrl +
+ {/* Press indicator */} + + + + {/* Tap Indicators - Circular ripples */} +
+ + +
+
+ + {/* Double-Press Indicator */} + +
+
+
+
+
+
+
+
+
+
+
+ + {/* Problem Statement */} +
+
+ +

+ Voice Dictation on Linux Finally Solved +

+
+
+

The Linux Voice Gap

+

+ While macOS and Windows offer built-in voice dictation capabilities, Linux users have been left without a comprehensive, privacy-focused solutionβ€”until now. +

+
    + {["Cloud services compromise privacy and require constant internet", "Existing solutions lack system-wide integration", "Poor performance with specialized Linux terminology", "Complicated setup processes for casual users"].map((item, i) =>
  • +
    + +
    + {item} +
  • )} +
+
+
+

The Vocalinux Solution

+

+ Vocalinux bridges this gap with a comprehensive voice dictation system built specifically for the Linux ecosystem. +

+
    + {["100% offline processing protects your privacy", "Universal application support across Linux environments", "Simple, global activation with keyboard shortcut", "Works with both X11 and Wayland display servers"].map((item, i) =>
  • +
    + +
    + {item} +
  • )} +
+
+
+
+
+
+ + {/* Features */} +
+
+ +
+

Powerful Features

+

+ Vocalinux combines privacy, performance, and flexibility to provide a seamless voice dictation experience on Linux. +

+
+
+ +
+ } title="Offline & Private" description="Runs completely offline using Whisper models - your voice data never leaves your computer." /> + } title="Universal Support" description="Works with all Linux applications - from terminals to browsers to office suites." /> + } title="Blazing Fast" description="Uses optimized local models for real-time transcription with minimal latency." /> + } title="Global Shortcut" description="Activate from anywhere with a keyboard shortcut (default: Ctrl+Alt+Shift+V)." /> + } title="X11 & Wayland" description="Compatible with both X11 and Wayland display servers out of the box." /> + } title="Configurable" description="Choose model size, language, activation method, and other settings via config file." /> +
+
+
+ + {/* How It Works */} +
+
+ +

How Vocalinux Works

+
+ +
+ {[{ + step: "01", + title: "Installation", + description: "Install Vocalinux with our simple setup script that handles dependencies and configuration automatically.", + icon: + }, { + step: "02", + title: "Activation", + description: "Double-tap the Ctrl key (or your custom shortcut) to start dictating in any application.", + icon: + }, { + step: "03", + title: "Dictation", + description: "Speak naturally, including voice commands for punctuation and formatting.", + icon: + }].map((item, index) => +
+
+
+ {item.icon} +
+ {item.step} +
+

{item.title}

+

{item.description}

+
+
)} +
+
+
+ + {/* Demo */} +
+
+ +
+

See Vocalinux in Action

+

+ Watch how Vocalinux transforms the way you interact with your Linux system. +

+
+
+ + +
+ +
+
+ + + +
+
+
+
+
+
+ + {/* Installation */} +
+
+ +

Installation Guide

+
+ + +
+
+

Quick Install

+
+ + {installCommand} + + +
+ +

Manual Setup

+
    +
  1. +
    + 1 +
    +
    +

    Install Dependencies

    +

    + Ensure you have the necessary dependencies installed: +

    +
    + + {"sudo apt install python3-pip portaudio19-dev python3-pyaudio git"} + + +
    +
    +
  2. + +
  3. +
    + 2 +
    +
    +

    Clone Repository

    +

    + Clone the Vocalinux repository to your local machine: +

    +
    + + {"git clone https://github.com/jatinkrmalik/vocalinux.git\ncd vocalinux"} + + +
    +
    +
  4. + +
  5. +
    + 3 +
    +
    +

    Install Python Requirements

    +

    + Install the required Python packages: +

    +
    + + {"pip install -r requirements.txt"} + + +
    +
    +
  6. + +
  7. +
    + 4 +
    +
    +

    Install Vocalinux

    +

    + Install Vocalinux on your system: +

    +
    + + {"python cli.py install"} + + +
    +
    +
  8. + +
  9. +
    + 5 +
    +
    +

    Start Using Vocalinux

    +

    + Launch Vocalinux and begin dictating with the default keyboard shortcut (Ctrl+Alt+Shift+V) or customize it in the config file. +

    +
    + + {"python cli.py start"} + + +
    +
    +
  10. +
+
+
+
+
+
+ + {/* Voice Commands */} +
+
+ +

Voice Commands

+

+ Control your dictation experience with these built-in voice commands. +

+
+ +
+ +
+

+ + Text Formatting +

+
    + {[{ + command: "new line", + description: "Inserts a line break" + }, { + command: "new paragraph", + description: "Inserts two line breaks" + }, { + command: "period", + description: "Inserts a period (.)" + }, { + command: "question mark", + description: "Inserts a question mark (?)" + }, { + command: "exclamation mark", + description: "Inserts an exclamation mark (!)" + }, { + command: "comma", + description: "Inserts a comma (,)" + }, { + command: "colon", + description: "Inserts a colon (:)" + }, { + command: "semicolon", + description: "Inserts a semicolon (;)" + }, { + command: "hyphen", + description: "Inserts a hyphen (-)" + }].map((item, i) =>
  • + {item.command} + {item.description} +
  • )} +
+
+
+ + +
+

+ + Editing Commands +

+
    + {[{ + command: "backspace", + description: "Deletes the previous character" + }, { + command: "delete that", + description: "Deletes the last phrase" + }, { + command: "clear all", + description: "Clears all text" + }, { + command: "select all", + description: "Selects all text" + }, { + command: "copy", + description: "Copies selected text" + }, { + command: "paste", + description: "Pastes from clipboard" + }, { + command: "undo", + description: "Undoes last action" + }, { + command: "redo", + description: "Redoes last action" + }, { + command: "tab", + description: "Inserts a tab character" + }].map((item, i) =>
  • + {item.command} + {item.description} +
  • )} +
+
+
+
+
+
+ + {/* Customization Options */} +
+
+ +

Customization Options

+
+ +
+ +
+
+

Whisper Models

+

+ Choose from different Whisper model sizes depending on your hardware and accuracy needs. +

+
+
Tiny (75MB)
+
Base (142MB)
+
Small (466MB)
+
Medium (1.5GB)
+
Large (3GB)
+
+
+ +
+

Model Size & Language

+

+ Balance between accuracy and resource usage with different model sizes. +

+
+
+ Small (150MB) + Lower resource usage +
+
+ Medium (450MB) + Balanced performance +
+
+ Large (1.2GB) + Highest accuracy +
+
+
+ +
+

Custom Activation

+

+ Configure your own keyboard shortcuts or even voice activation phrases. +

+
+
+
+ + +
+
+
+
+
+
+
+
+ vocalinux-config.yaml +
+
+ + {"# Vocalinux Configuration File\n\n# Set this to 'true' if you want the app to start automatically\nauto_start: false\n\n# Default global shortcut to start/stop dictation \n# Uses the same format as the keyboard library\nshortcut: 'ctrl+alt+shift+v'\n\n# Model settings\nmodel:\n # Options: tiny, base, small, medium, large\n size: 'tiny'\n \n # Language code (determines the model downloaded)\n language: 'en'\n\n# Thread settings\nthreads: 2\n\n# Audio settings\naudio:\n # Pause detection to automatically stop recording after silence\n pause_detection: true\n # Seconds of silence to wait before stopping\n pause_threshold: 2.0\n \n# Notification settings\nnotifications: true"} + +
+
+
+
+
+ + {/* Community & Contribution */} +
+
+ +

Join the Community

+

+ Vocalinux is community-driven and welcomes contributors of all skill levels. +

+
+ +
+ +
+
+ +
+

Contribute Code

+

+ Help improve Vocalinux by contributing code, fixing bugs, or adding new features through pull requests. +

+ + View GitHub Repository + +
+
+ + +
+
+ +
+

Improve Documentation

+

+ Help make Vocalinux more accessible by improving documentation, writing tutorials, or creating guides. +

+ + View Documentation + +
+
+ + +
+
+ +
+

Join Discussions

+

+ Participate in discussions, share your experiences, report bugs, and suggest new features. +

+ + Join Community + +
+
+
+ + +
+
+ + {stars} GitHub Stars +
+

Support the Project

+ +
+
+
+
+ + {/* FAQ */} +
+
+ +

Frequently Asked Questions

+
+ +
+ {[{ + question: "Is Vocalinux really 100% offline?", + answer: "Yes, Vocalinux processes all voice recognition locally on your machine. Your voice data never leaves your computer, ensuring complete privacy and security." + }, { + question: "Which Linux distributions are supported?", + answer: "Vocalinux is designed to work with most major Linux distributions including Ubuntu, Fedora, Debian, Arch Linux, Linux Mint, and more. It's compatible with both X11 and Wayland display servers." + }, { + question: "How accurate is the speech recognition?", + answer: "Recognition accuracy depends on several factors including microphone quality, background noise, and the model size selected. With a good microphone and the large model, accuracy can exceed 95% for most users speaking clearly." + }, { + question: "What are the system requirements?", + answer: "Minimum requirements: 4GB RAM, dual-core CPU, and 500MB disk space for the small model. For optimal performance with the large model, we recommend 8GB RAM, a quad-core CPU, and 2GB of disk space." + }, { + question: "Can I use Vocalinux in languages other than English?", + answer: "Yes, Vocalinux supports multiple languages depending on the recognition engine selected. VOSK and Whisper engines support dozens of languages with varying levels of accuracy." + }, { + question: "How can I customize Vocalinux for my needs?", + answer: "Vocalinux can be customized through its configuration file. You can change the recognition engine, model size, activation method, keyboard shortcuts, and add custom voice commands." + }].map((item, i) => +
+

{item.question}

+

{item.answer}

+
+
)} +
+
+
+ + {/* Footer */} + +
; +} \ No newline at end of file diff --git a/web/src/components/dictation-overlay.tsx b/web/src/components/dictation-overlay.tsx new file mode 100644 index 0000000..30c04ae --- /dev/null +++ b/web/src/components/dictation-overlay.tsx @@ -0,0 +1,109 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Mic } from "lucide-react"; +interface DictationOverlayProps { + isActive: boolean; + onAnimationComplete?: () => void; +} +export function DictationOverlay({ + isActive, + onAnimationComplete +}: DictationOverlayProps) { + const [text, setText] = useState(""); + const fullText = "Hello world"; + + // Handle text typing animation + useEffect(() => { + if (!isActive) { + setText(""); + return; + } + let currentIndex = 0; + const interval = setInterval(() => { + if (currentIndex < fullText.length) { + setText(fullText.substring(0, currentIndex + 1)); + currentIndex++; + } else { + clearInterval(interval); + // Wait a bit before triggering the fade out + setTimeout(() => { + if (onAnimationComplete) onAnimationComplete(); + }, 800); + } + }, 100); // Speed of typing + + return () => clearInterval(interval); + }, [isActive, onAnimationComplete]); + return + {isActive &&
+
+ {/* Microphone Icon */} + + + + + + {/* Ripple effect */} + + + + {/* Text Box */} + +
+
+ Transcribing... +
+
+ {text} + +
+
+
+
} +
; +} \ No newline at end of file diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx new file mode 100644 index 0000000..2ed46df --- /dev/null +++ b/web/src/components/icons.tsx @@ -0,0 +1,11 @@ +"use client"; + +import React from "react"; +export const VocalinuxLogo: React.FC> = props => { + return + + + + + ; +}; \ No newline at end of file diff --git a/web/src/components/logo.tsx b/web/src/components/logo.tsx new file mode 100644 index 0000000..7e6ac56 --- /dev/null +++ b/web/src/components/logo.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { VocalinuxLogo } from "./icons"; +import { cn } from "@/lib/utils"; +interface LogoProps { + className?: string; + iconClassName?: string; + textClassName?: string; +} +export function Logo({ + className, + iconClassName, + textClassName +}: LogoProps) { + return
+ + Vocalinux +
; +} \ No newline at end of file diff --git a/web/src/components/theme-provider.tsx b/web/src/components/theme-provider.tsx new file mode 100644 index 0000000..4177f82 --- /dev/null +++ b/web/src/components/theme-provider.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import React from "react"; +type Attribute = "class" | "data-theme" | "data-mode"; + +// Define the props type with correct attribute type +interface ThemeProviderProps { + children: React.ReactNode; + attribute?: Attribute | Attribute[]; + defaultTheme?: string; + enableSystem?: boolean; + disableTransitionOnChange?: boolean; + storageKey?: string; + forcedTheme?: string; + themes?: string[]; +} +export function ThemeProvider({ + children, + ...props +}: ThemeProviderProps) { + return {children}; +} \ No newline at end of file diff --git a/web/src/components/theme-toggle.tsx b/web/src/components/theme-toggle.tsx new file mode 100644 index 0000000..5ab7e90 --- /dev/null +++ b/web/src/components/theme-toggle.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { motion } from "framer-motion"; +export function ThemeToggle() { + const [mounted, setMounted] = useState(false); + const { + theme, + setTheme + } = useTheme(); + + // Wait for component to be mounted to avoid hydration mismatch + useEffect(() => { + setMounted(true); + }, []); + if (!mounted) { + return
+ Loading theme toggle +
; + } + return setTheme(theme === "dark" ? "light" : "dark")} className="w-8 h-8 sm:w-9 sm:h-9 bg-muted hover:bg-muted/80 rounded-md flex items-center justify-center transition-all" aria-label="Toggle theme" data-unique-id="5f422091-838a-431f-9e75-917f703ab13c" data-file-name="components/theme-toggle.tsx" data-dynamic-text="true"> + {theme === "dark" ? : } + Toggle theme + ; +} \ No newline at end of file diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx new file mode 100644 index 0000000..378aadf --- /dev/null +++ b/web/src/components/ui/button.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'default' | 'outline' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg'; + className?: string; + asChild?: boolean; + children?: React.ReactNode; +} +const Button = React.forwardRef(({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}, ref) => { + const Comp = asChild ? "span" : "button"; + return ; +}); +Button.displayName = "Button"; +export { Button }; \ No newline at end of file diff --git a/web/src/hooks/use-double-press.ts b/web/src/hooks/use-double-press.ts new file mode 100644 index 0000000..36e1d7c --- /dev/null +++ b/web/src/hooks/use-double-press.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +interface UseDoublePressOptions { + key: string; + delay?: number; + onDoublePress?: () => void; +} + +export function useDoublePress({ + key, + delay = 400, + onDoublePress, +}: UseDoublePressOptions) { + const [lastPressed, setLastPressed] = useState(null); + const [isActive, setIsActive] = useState(false); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + // Only respond to the specified key + if (event.key.toLowerCase() !== key.toLowerCase()) { + return; + } + + const now = Date.now(); + + // First press or too much time has passed + if (lastPressed === null || now - lastPressed > delay) { + setLastPressed(now); + return; + } + + // Double press detected within the delay timeframe + if (now - lastPressed <= delay) { + setIsActive(true); + if (onDoublePress) onDoublePress(); + setLastPressed(null); + } + }, + [key, delay, lastPressed, onDoublePress] + ); + + // Reset the active state + const resetActive = useCallback(() => { + setIsActive(false); + }, []); + + useEffect(() => { + // Add event listener for key press + window.addEventListener("keydown", handleKeyDown); + + // Cleanup + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [handleKeyDown]); + + return { isActive, resetActive }; +} diff --git a/web/src/lib/api/util.ts b/web/src/lib/api/util.ts new file mode 100644 index 0000000..9af3590 --- /dev/null +++ b/web/src/lib/api/util.ts @@ -0,0 +1,594 @@ +export class AIServiceError extends Error { + statusCode: number; + provider: string; + + constructor(message: string, statusCode: number, provider: string) { + super(message); + this.name = 'AIServiceError'; + this.statusCode = statusCode; + this.provider = provider; + } +} + +export type TextGenerationResult = { + text: string; + usage: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; + finishReason?: 'stop' | 'length' | 'content_filter'; +}; + +// Vision-enabled Text Types +export type ContentItem = + | { type: 'text'; text: string } + | { type: 'image_url'; image_url: { url: string } }; + +export type Message = { + role: 'user' | 'assistant' | 'system'; + content: ContentItem[]; +}; + +// We reuse TextGenerationResult since the output format is the same + + +const CONFIG: { + webId: string; + availableProviders: { + text: string[]; + visionEnabled: string[]; + }; + } = { + webId: "6818365e6da47c0013cf7afd", + availableProviders: { + "text": [ + "azure-gpt-4o" + ], + "visionEnabled": [ + "azure-gpt-4o" + ] +} + }; + +const API_KEYS = {}; + +// ===== TEXT GENERATION MODULE ===== +export interface TextProviderImplementation { + generate(prompt: string): Promise; + generateStream(prompt: string, onChunk: (text: string) => void): Promise; +} + +export interface VisionTextProviderImplementation extends TextProviderImplementation { + generateWithImages(prompt: string, imageUrls: string[]): Promise; + generateWithImagesStream(prompt: string, imageUrls: string[], onChunk: (text: string) => void): Promise; +} + +/** + * Generate text using a specific provider + * @param prompt - The prompt to generate text from + * @param provider - The provider to use + * @returns A promise that resolves to the generated text + */ +export async function generateText( + prompt: string, + provider: string +): Promise { + if (!prompt) throw new Error('Prompt is required'); + if (!provider) throw new Error('Provider is required'); + + if (!CONFIG.availableProviders.text.includes(provider)) { + throw new Error(`Provider ${provider} is not available`); + } + + try { + return await textProviderImplementations[provider].generate(prompt); + } catch (error) { + if (error instanceof AIServiceError) { + throw error; + } else { + throw new AIServiceError( + `Error generating text: ${error instanceof Error ? error.message : String(error)}`, + 500, + provider + ); + } + } +} + + +/** + * Generate text using a specific provider and stream the results + * @param prompt - The prompt to generate text from + * @param onChunk - A callback function that receives a chunk of text, can be used to update a UI or display the text in real-time + * @param provider - The provider to use + * @returns A promise that resolves to the generated text + */ +export async function generateTextStream( + prompt: string, + onChunk: (text: string) => void, + provider: string +): Promise { + if (!prompt) throw new Error('Prompt is required'); + if (!onChunk) throw new Error('onChunk callback is required'); + if (!provider) throw new Error('Provider is required'); + + if (!CONFIG.availableProviders.text.includes(provider)) { + throw new Error(`Provider ${provider} is not available`); + } + + try { + return await textProviderImplementations[provider].generateStream(prompt, onChunk); + } catch (error) { + if (error instanceof AIServiceError) { + throw error; + } else { + throw new AIServiceError( + `Error streaming text: ${error instanceof Error ? error.message : String(error)}`, + 500, + provider + ); + } + } +} + +/** + * Get all available text providers + * @returns An array of all available text providers + */ +export function getTextProviders(): string[] { + return CONFIG.availableProviders.text; +} + +/** + * Get all available vision-enabled providers + * @returns An array of all available vision-enabled providers + */ +export function getVisionEnabledProviders(): string[] { + return CONFIG.availableProviders.visionEnabled; +} + +/** + * Generate text using a specific provider and provide images + * @param prompt - The prompt to generate text from + * @param imageUrls - An array of image URLs + * @param provider - The provider to use + * @returns A promise that resolves to the generated text + */ +export async function generateTextWithImages( + prompt: string, + imageUrls: string[], + provider: string +): Promise { + if (!prompt) throw new Error('Prompt is required'); + if (!imageUrls || !Array.isArray(imageUrls)) throw new Error('Image URLs must be an array'); + if (!provider) throw new Error('Provider is required'); + + if (!CONFIG.availableProviders.visionEnabled.includes(provider)) { + throw new Error(`Provider ${provider} does not support image inputs`); + } + + try { + const implementation = textProviderImplementations[provider] as VisionTextProviderImplementation; + return await implementation.generateWithImages(prompt, imageUrls); + } catch (error) { + if (error instanceof AIServiceError) { + throw error; + } else { + throw new AIServiceError( + `Error generating text with images: ${error instanceof Error ? error.message : String(error)}`, + 500, + provider + ); + } + } +} + +/** + * Generate text using a specific provider and provide images and stream the results + * @param prompt - The prompt to generate text from + * @param imageUrls - An array of image URLs + * @param onChunk - A callback function that receives a chunk of text, can be used to update a UI or display the text in real-time + * @param provider - The provider to use + * @returns A promise that resolves to the generated text + */ +export async function generateTextWithImagesStream( + prompt: string, + imageUrls: string[], + onChunk: (text: string) => void, + provider: string +): Promise { + if (!prompt) throw new Error('Prompt is required'); + if (!imageUrls || !Array.isArray(imageUrls)) throw new Error('Image URLs must be an array'); + if (!onChunk) throw new Error('onChunk callback is required'); + if (!provider) throw new Error('Provider is required'); + + if (!CONFIG.availableProviders.visionEnabled.includes(provider)) { + throw new Error(`Provider ${provider} does not support image inputs`); + } + + try { + const implementation = textProviderImplementations[provider] as VisionTextProviderImplementation; + return await implementation.generateWithImagesStream(prompt, imageUrls, onChunk); + } catch (error) { + if (error instanceof AIServiceError) { + throw error; + } else { + throw new AIServiceError( + `Error streaming text with images: ${error instanceof Error ? error.message : String(error)}`, + 500, + provider + ); + } + } +} + +// Shared implementation for all LiteLLM text providers +/** + * Generate text using a specific provider + * @param prompt - The prompt to generate text from + * @param integrationId - The integration ID to use + * @param model - The model to use + * @returns A promise that resolves to the generated text + */ +async function proxyGenerate(prompt: string, integrationId: string, model: string): Promise { + const requestBody = { + webId: CONFIG.webId, + integrationId, + prompt, + stream: false, + model + }; + + try { + const response = await fetch('https://proxy.getcreatr.com/v1/proxy/llm', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new AIServiceError( + `Proxy error: ${response.status} ${response.statusText}`, + response.status, + model + ); + } + + const data = await response.json(); + console.log(data); + + return { + text: data.data.choices[0].message.content, + usage: { + promptTokens: data.data.usage.prompt_tokens, + completionTokens: data.data.usage.completion_tokens, + totalTokens: data.data.usage.total_tokens + }, + finishReason: data.data.choices[0].finish_reason + }; + } catch (error) { + if (error instanceof AIServiceError) throw error; + throw new Error(`Failed to call proxy: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Generate text using a specific provider and stream the results + * @param prompt - The prompt to generate text from + * @param onChunk - A callback function that receives a chunk of text, can be used to update a UI or display the text in real-time + * @param integrationId - The integration ID to use + * @param model - The model to use + * @returns A promise that resolves to the generated text + */ +async function proxyGenerateStream(prompt: string, onChunk: (text: string) => void, integrationId: string, model: string): Promise { + const requestBody = { + webId: CONFIG.webId, + integrationId, + prompt, + stream: true, + model + }; + + let fullText = ''; + let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + let finishReason; + + try { + const response = await fetch('https://proxy.getcreatr.com/v1/proxy/llm/stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new AIServiceError( + `Proxy streaming error: ${response.status} ${response.statusText}`, + response.status, + model + ); + } + + if (!response.body) throw new Error('Response body is null or undefined'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let isStreamActive = true; + + while (isStreamActive) { + const { done, value } = await reader.read(); + + if (done) { + isStreamActive = false; + continue; + } + + buffer += decoder.decode(value, { stream: true }); + + const events = buffer.split('\n\n'); + buffer = events.pop() || ''; + + for (const event of events) { + if (event.startsWith('data: ')) { + const data = event.slice(6).trim(); + + if (data === '[DONE]') { + isStreamActive = false; + continue; + } + + try { + const parsed = JSON.parse(data); + + const chunkContent = parsed.choices[0]?.delta?.content || ''; + if (chunkContent) { + fullText += chunkContent; + onChunk(chunkContent); + } + + if (parsed.choices[0]?.finish_reason) { + finishReason = parsed.choices[0].finish_reason; + } + + // Update usage information from the final chunk + if (parsed.usage) { + usage = { + promptTokens: parsed.usage.prompt_tokens, + completionTokens: parsed.usage.completion_tokens, + totalTokens: parsed.usage.total_tokens + }; + } + } catch (e) { + console.error('Error parsing stream chunk:', e); + } + } + } + } + + return { text: fullText, usage, finishReason }; + } catch (error) { + if (error instanceof AIServiceError) throw error; + throw new Error(`Failed to stream from proxy: ${error instanceof Error ? error.message : String(error)}`); + } +} + +// Shared implementation for vision-enabled text providers +/** + * Generate text using a specific provider and provide images + * @param prompt - The prompt to generate text from + * @param imageUrls - An array of image URLs + * @param integrationId - The integration ID to use + * @param model - The model to use + * @returns A promise that resolves to the generated text + */ +async function proxyGenerateWithImages(prompt: string, imageUrls: string[], integrationId: string, model: string): Promise { + // Build content array with text and images for each message + const content: ContentItem[] = [ + { type: 'text', text: prompt } + ]; + + // Add each image URL to the content array + for (const url of imageUrls) { + content.push({ + type: 'image_url', + image_url: { + url: url + } + }); + } + + // Create the request payload exactly matching the expected format + const requestBody = { + model, + webId: CONFIG.webId, + integrationId, + messages: [ + { + role: 'user', + content + } + ], + stream: false + }; + + try { + const response = await fetch('https://proxy.getcreatr.com/v1/proxy/llm', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new AIServiceError( + `Vision proxy error: ${response.status} ${response.statusText}`, + response.status, + model + ); + } + + const data = await response.json(); + console.log(data); + + return { + text: data.data.choices[0].message.content, + usage: { + promptTokens: data.data.usage.prompt_tokens, + completionTokens: data.data.usage.completion_tokens, + totalTokens: data.data.usage.total_tokens + }, + finishReason: data.data.choices[0].finish_reason + }; + } catch (error) { + if (error instanceof AIServiceError) throw error; + throw new Error(`Failed to call vision proxy: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Generate text using a specific provider and provide images and stream the results + * @param prompt - The prompt to generate text from + * @param imageUrls - An array of image URLs + * @param onChunk - A callback function that receives a chunk of text, can be used to update a UI or display the text in real-time + * @param integrationId - The integration ID to use + * @param model - The model to use + * @returns A promise that resolves to the generated text + */ +async function proxyGenerateWithImagesStream(prompt: string, imageUrls: string[], onChunk: (text: string) => void, integrationId: string, model: string): Promise { + // Build content array with text and images + const content: ContentItem[] = [ + { type: 'text', text: prompt } + ]; + + // Add each image URL to the content array + for (const url of imageUrls) { + content.push({ + type: 'image_url', + image_url: { + url: url + } + }); + } + + // Create the request payload exactly matching the expected format + const requestBody = { + model, + webId: CONFIG.webId, + integrationId, + messages: [ + { + role: 'user', + content + } + ], + stream: true + }; + + let fullText = ''; + let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + let finishReason; + + try { + const response = await fetch('https://proxy.getcreatr.com/v1/proxy/llm/stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new AIServiceError( + `Vision proxy streaming error: ${response.status} ${response.statusText}`, + response.status, + model + ); + } + + if (!response.body) throw new Error('Response body is null or undefined'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let isStreamActive = true; + + while (isStreamActive) { + const { done, value } = await reader.read(); + + if (done) { + isStreamActive = false; + continue; + } + + buffer += decoder.decode(value, { stream: true }); + + const events = buffer.split('\n\n'); + buffer = events.pop() || ''; + + for (const event of events) { + if (event.startsWith('data: ')) { + const data = event.slice(6).trim(); + + if (data === '[DONE]') { + isStreamActive = false; + continue; + } + + try { + const parsed = JSON.parse(data); + + const chunkContent = parsed.choices[0]?.delta?.content || ''; + if (chunkContent) { + fullText += chunkContent; + onChunk(chunkContent); + } + + if (parsed.choices[0]?.finish_reason) { + finishReason = parsed.choices[0].finish_reason; + } + + // Update usage information from the final chunk + if (parsed.usage) { + usage = { + promptTokens: parsed.usage.prompt_tokens, + completionTokens: parsed.usage.completion_tokens, + totalTokens: parsed.usage.total_tokens + }; + } + } catch (e) { + console.error('Error parsing stream chunk:', e); + } + } + } + } + + return { text: fullText, usage, finishReason }; + } catch (error) { + if (error instanceof AIServiceError) throw error; + throw new Error(`Failed to stream from vision proxy: ${error instanceof Error ? error.message : String(error)}`); + } +} + +// Text provider implementations +const textProviderImplementations: Record = {}; + +// Vision-enabled text provider: azure-gpt-4o +textProviderImplementations["azure-gpt-4o"] = { + async generate(prompt: string): Promise { + return proxyGenerate(prompt, "67e52e6e8e1618ee9ba4117f", "azure-gpt-4o"); + }, + async generateStream(prompt: string, onChunk: (text: string) => void): Promise { + return proxyGenerateStream(prompt, onChunk, "67e52e6e8e1618ee9ba4117f", "azure-gpt-4o"); + }, + async generateWithImages(prompt: string, imageUrls: string[]): Promise { + return proxyGenerateWithImages(prompt, imageUrls, "67e52e6e8e1618ee9ba4117f", "azure-gpt-4o"); + }, + async generateWithImagesStream(prompt: string, imageUrls: string[], onChunk: (text: string) => void): Promise { + return proxyGenerateWithImagesStream(prompt, imageUrls, onChunk, "67e52e6e8e1618ee9ba4117f", "azure-gpt-4o"); + } +}; + diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css new file mode 100644 index 0000000..d47d129 --- /dev/null +++ b/web/src/styles/globals.css @@ -0,0 +1,92 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: #FFFFFF; + --foreground: #09090B; + --card: #FFFFFF; + --card-foreground: #09090B; + --popover: #FFFFFF; + --popover-foreground: #09090B; + --primary: #3CB371; + --primary-foreground: #FFFFFF; + --secondary: #F4F4F5; + --secondary-foreground: #18181B; + --muted: #F4F4F5; + --muted-foreground: #71717A; + --accent: #F4F4F5; + --accent-foreground: #18181B; + --destructive: #EF4444; + --destructive-foreground: #FAFAFA; + --border: #E4E4E7; + --input: #E4E4E7; + --ring: #3CB371; + --chart-1: #3CB371; + --chart-2: #2DD4BF; + --chart-3: #2F3F4A; + --chart-4: #D9B64E; + --chart-5: #E67E33; + --radius: 0.5rem; + + --sidebar-background: #FAFAFA; + --sidebar-foreground: #3F3F46; + --sidebar-primary: #3CB371; + --sidebar-primary-foreground: #FAFAFA; + --sidebar-accent: #F4F4F5; + --sidebar-accent-foreground: #18181B; + --sidebar-border: #E5E7EB; + --sidebar-ring: #3CB371; + } + + .dark { + --background: #09090B; + --foreground: #FAFAFA; + --card: #09090B; + --card-foreground: #FAFAFA; + --popover: #09090B; + --popover-foreground: #FAFAFA; + --primary: #50C878; + --primary-foreground: #FFFFFF; + --secondary: #27272A; + --secondary-foreground: #FAFAFA; + --muted: #27272A; + --muted-foreground: #A1A1AA; + --accent: #27272A; + --accent-foreground: #FAFAFA; + --destructive: #7F1D1D; + --destructive-foreground: #FAFAFA; + --border: #27272A; + --input: #27272A; + --ring: #50C878; + --chart-1: #50C878; + --chart-2: #2DD4BF; + --chart-3: #FB923C; + --chart-4: #C084FC; + --chart-5: #F87171; + + --sidebar-background: #18181B; + --sidebar-foreground: #F4F4F5; + --sidebar-primary: #50C878; + --sidebar-primary-foreground: #FFFFFF; + --sidebar-accent: #27272A; + --sidebar-accent-foreground: #F4F4F5; + --sidebar-border: #27272A; + --sidebar-ring: #50C878; + } +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} + +nextjs-portal { + display: none; +} diff --git a/web/src/types/tailwind.d.ts b/web/src/types/tailwind.d.ts new file mode 100644 index 0000000..cb9889c --- /dev/null +++ b/web/src/types/tailwind.d.ts @@ -0,0 +1,4 @@ +declare module 'tailwindcss/lib/util/flattenColorPalette' { + function flattenColorPalette(colors: object): { [key: string]: string }; + export = flattenColorPalette; +} \ No newline at end of file diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts new file mode 100644 index 0000000..a83debd --- /dev/null +++ b/web/tailwind.config.ts @@ -0,0 +1,111 @@ +import { type Config } from "tailwindcss"; +import defaultTheme from "tailwindcss/defaultTheme"; +import animate from "tailwindcss-animate"; +import flattenColorPalette from "tailwindcss/lib/util/flattenColorPalette"; + +export default { + darkMode: ["class"], + content: ["./src/**/*.tsx"], + theme: { + extend: { + fontFamily: { + sans: [ + 'var(--font-geist-sans)', + ...defaultTheme.fontFamily.sans + ] + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + colors: { + background: 'var(--background)', + foreground: 'var(--foreground)', + card: { + DEFAULT: 'var(--card)', + foreground: 'var(--card-foreground)' + }, + popover: { + DEFAULT: 'var(--popover)', + foreground: 'var(--popover-foreground)' + }, + primary: { + DEFAULT: 'var(--primary)', + foreground: 'var(--primary-foreground)' + }, + secondary: { + DEFAULT: 'var(--secondary)', + foreground: 'var(--secondary-foreground)' + }, + muted: { + DEFAULT: 'var(--muted)', + foreground: 'var(--muted-foreground)' + }, + accent: { + DEFAULT: 'var(--accent)', + foreground: 'var(--accent-foreground)' + }, + destructive: { + DEFAULT: 'var(--destructive)', + foreground: 'var(--destructive-foreground)' + }, + border: 'var(--border)', + input: 'var(--input)', + ring: 'var(--ring)', + chart: { + '1': 'var(--chart-1)', + '2': 'var(--chart-2)', + '3': 'var(--chart-3)', + '4': 'var(--chart-4)', + '5': 'var(--chart-5)' + }, + sidebar: { + DEFAULT: 'var(--sidebar-background)', + foreground: 'var(--sidebar-foreground)', + primary: 'var(--sidebar-primary)', + 'primary-foreground': 'var(--sidebar-primary-foreground)', + accent: 'var(--sidebar-accent)', + 'accent-foreground': 'var(--sidebar-accent-foreground)', + border: 'var(--sidebar-border)', + ring: 'var(--sidebar-ring)' + } + }, + keyframes: { + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out' + } + } + }, + plugins: [animate, addVariablesForColors], +} satisfies Config; + + +function addVariablesForColors({ addBase, theme }: any) { + let allColors = flattenColorPalette(theme("colors")); + let newVars = Object.fromEntries( + Object.entries(allColors).map(([key, val]) => [`--${key}`, val]) + ); + + addBase({ + ":root": newVars, + }); +} \ No newline at end of file diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..f13895a --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,59 @@ +{ + "compilerOptions": { + /* Base Options: */ + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "strictNullChecks": false, + "noImplicitAny": false, + /* Strictness */ + "strict": true, + "checkJs": true, + /* Bundled projects */ + "lib": [ + "dom", + "dom.iterable", + "ES2022" + ], + "noEmit": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ], + "incremental": true, + /* Path Aliases */ + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "**/*.cjs", + "**/*.js", + "**/*.ts", + "**/*.tsx", + ".eslintrc.cjs", + ".next/types/**/*.ts", + "next-env.d.ts", + "src/styles/global.css", + "out/types/**/*.ts" + ], + "exclude": [ + "node_modules/*", + ".next", + "out", + "build", + "dist", + "supabase" + ] +}