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
+
+ {description}
+ ;
+};
+const CopyToClipboard = ({
+ text
+}) => {
+ const [copied, setCopied] = useState(false);
+ const handleCopy = () => {
+ navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+ return
+ {copied ? "Copied!" : "Copy"}
+ ;
+};
+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 */}
+
+
+ {/* 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.
+
+
+
+
+
+
+
+
+ Your browser does not support the video tag.
+
+
+
+
+
+
+
+ {/* Installation */}
+
+
+
+ Installation Guide
+
+
+
+
+
+
Quick Install
+
+
+ {installCommand}
+
+
+
+
+
Manual Setup
+
+
+
+ 1
+
+
+
Install Dependencies
+
+ Ensure you have the necessary dependencies installed:
+
+
+
+ {"sudo apt install python3-pip portaudio19-dev python3-pyaudio git"}
+
+
+
+
+
+
+
+
+ 2
+
+
+
Clone Repository
+
+ Clone the Vocalinux repository to your local machine:
+
+
+
+ {"git clone https://github.com/jatinkrmalik/vocalinux.git\ncd vocalinux"}
+
+
+
+
+
+
+
+
+ 3
+
+
+
Install Python Requirements
+
+ Install the required Python packages:
+
+
+
+ {"pip install -r requirements.txt"}
+
+
+
+
+
+
+
+
+ 4
+
+
+
Install Vocalinux
+
+ Install Vocalinux on your system:
+
+
+
+ {"python cli.py install"}
+
+
+
+
+
+
+
+
+ 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"}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 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 */}
+
+
+ {/* 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 */}
+
+
+
+
+
+
+
Vocalinux
+
+
+ Open-source voice dictation for Linux that respects your privacy.
+
+
+
+
+
+
+
+
+
+
+
+ Β© {new Date().getFullYear()} Vocalinux. Open-source software released under the MIT License.
+
+
+
+
+ ;
+}
\ 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 */}
+
+
+
+ {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"
+ ]
+}