diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..33445ce --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Null Library — Environment Variables +# Copy this file to .env and fill in your values. +# .env is gitignored and never committed. + +# ── Free Gemini API Key (fallback) ────────────────────────────────────────── +# From https://aistudio.google.com/apikey — free tier available +# VITE_GOOGLE_API_KEY=AIza... + +# ── NullProxy Engine: OAuth Provider Credentials ──────────────────────────── +# These are needed to use the OAuth proxy feature (requires desktop app). +# Register at https://console.cloud.google.com → APIs & Services → Credentials +# Redirect URI to add: http://localhost:8085/callback + +# GEMINI_CLI_CLIENT_ID=your-client-id.apps.googleusercontent.com +# GEMINI_CLI_CLIENT_SECRET=your-client-secret + +# GEMINI_ANTIGRAVITY_CLIENT_ID=your-second-client-id.apps.googleusercontent.com +# GEMINI_ANTIGRAVITY_CLIENT_SECRET=your-second-client-secret diff --git a/.gitignore b/.gitignore index a547bf3..1440125 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ dist-ssr *.njsproj *.sln *.sw? + +# Environment files (contain secrets) +.env +.env.local +.env.*.local diff --git a/App.tsx b/App.tsx index 97a1f65..b7687d8 100644 --- a/App.tsx +++ b/App.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; -import { AppStep, BookOutline, Chapter, MarketReport, AuthorProfile, GenreSuggestion, TopicSuggestion, KdpMarketingInfo, AppMode, BatchProject, KdpAutomationPayload } from './types'; +import { AppStep, BookOutline, Chapter, MarketReport, AuthorProfile, GenreSuggestion, TopicSuggestion, KdpMarketingInfo, AppMode, BatchProject, KdpAutomationPayload, ProxySettings } from './types'; import * as geminiService from './services/geminiService'; import * as realMarketService from './services/realMarketService'; import * as storageService from './services/storageService'; @@ -12,17 +12,20 @@ import OutlineStep from './components/steps/OutlineStep'; import ContentGenerationStep from './components/steps/ContentGenerationStep'; import IllustrationStep from './components/steps/IllustrationStep'; import ReviewStep from './components/steps/ReviewStep'; +import SetupWizardStep from './components/steps/SetupWizardStep'; +import ProxySettingsModal from './components/ProxySettingsModal'; import LoadingSpinner from './components/shared/LoadingSpinner'; import { SparklesIcon, UserCircleIcon, TrashIcon, RocketLaunchIcon, CheckBadgeIcon } from './components/icons'; +import NullLibraryLogo from './components/NullLibraryLogo'; import AuthorProfileModal from './components/AuthorProfileModal'; import BatchMode from './components/BatchMode'; import KdpAutomationBot from './components/KdpAutomationBot'; import TitleBar from './components/TitleBar'; import desktopBridge from './services/desktopBridge'; - import { useAutoSave } from './hooks/useAutoSave'; import AutoSaveIndicator from './components/shared/AutoSaveIndicator'; +import { isFirstRun } from './services/oauthSetupService'; const pageRangeToChapterCount = (pageRange: string): number => { const firstNum = parseInt(pageRange.split('-')[0], 10); @@ -35,14 +38,18 @@ const pageRangeToChapterCount = (pageRange: string): number => { return 25; }; -// Versioned storage key -const STORAGE_KEY = 'kdp-ai-booksmith-v5-db'; +// Versioned storage key (renamed to null-library from kdp-ai-booksmith) +const STORAGE_KEY = 'null-library-v1-db'; function App() { const [mode, setMode] = useState(AppMode.Single); const [currentStep, setCurrentStep] = useState(AppStep.MarketResearch); const [isLoading, setIsLoading] = useState(true); // Start true to allow DB load const [error, setError] = useState(null); + + // ── First-run wizard state ──────────────────────────────────────────────── + const [showWizard, setShowWizard] = useState(false); + const [isProxySettingsOpen, setIsProxySettingsOpen] = useState(false); // SINGLE BOOK MODE STATE const [genreSuggestions, setGenreSuggestions] = useState(null); @@ -76,6 +83,7 @@ function App() { const [batchProjects, setBatchProjects] = useState([]); const [isBatchRunning, setIsBatchRunning] = useState(false); + // --- AUTO SAVE --- const { lastSaved, isSaving: isAutoSaving } = useAutoSave({ mode, @@ -133,20 +141,28 @@ function App() { } } catch (e) { console.error("Failed to load state from DB", e); - // Fallback: Check localStorage for migration - const legacyState = localStorage.getItem('kdp-ai-booksmith-v4'); - if (legacyState) { - console.log("Migrating from legacy localStorage..."); - try { - const parsed = JSON.parse(legacyState); - setAuthorProfile(parsed.authorProfile); - // We don't load everything to avoid bugs, just profile is useful - } catch(err) {} + // Fallback: Check localStorage for migration from legacy storage keys + const legacyKeys = ['kdp-ai-booksmith-v4', 'kdp-ai-booksmith-v5-db']; + for (const key of legacyKeys) { + const legacyState = localStorage.getItem(key); + if (legacyState) { + console.log("Migrating from legacy localStorage key:", key); + try { + const parsed = JSON.parse(legacyState); + setAuthorProfile(parsed.authorProfile); + } catch(err) {} + break; + } } } finally { setIsLoading(false); } + // Show first-run wizard if not completed + if (isFirstRun()) { + setShowWizard(true); + } + // Check storage status const isPersisted = await navigator.storage && navigator.storage.persisted ? await navigator.storage.persisted() : false; setIsPersistentStorage(isPersisted); @@ -796,7 +812,7 @@ function App() { chapterLoadingStates: {} // Don't save loading states }; - const fileName = bookOutline?.title ? `${bookOutline.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json` : 'kdp-project.json'; + const fileName = bookOutline?.title ? `${bookOutline.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json` : 'null-library-project.json'; try { const result = await desktopBridge.saveFile(JSON.stringify(projectState, null, 2), fileName); @@ -940,21 +956,36 @@ function App() { return (
- + setIsProxySettingsOpen(true)} /> + {/* Setup Wizard overlay — shows on first launch */} + {showWizard && ( +
+ setShowWizard(false)} + onSkip={() => setShowWizard(false)} + /> +
+ )} + + {/* Proxy Settings Modal */} + {isProxySettingsOpen && ( + setIsProxySettingsOpen(false)} /> + )} + {/* Header */}
- +

- KDP E-Book Generator + Null Library

-

AI-POWERED PUBLISHING ENGINE

+

THE ART OF INFINITE PRODUCTION

@@ -1062,7 +1093,15 @@ function App() { {isHighPerformanceMode ? "High Concurrency" : "Sequential"}
- App v1.5.0 + App v3.0.0 +
diff --git a/README.md b/README.md index 0fdd358..58956f7 100644 --- a/README.md +++ b/README.md @@ -1,234 +1,187 @@ -
+# 📚 Null Library — The Art of Infinite Production -# 📚 FraudRob's AI Book Factory -### The Ultimate AI-Powered Amazon KDP Publishing Suite +> An AI-powered multi-agent publishing platform for creating unlimited books with zero API costs. -[![React](https://img.shields.io/badge/React-18-61DAFB?style=for-the-badge&logo=react&logoColor=black)](https://reactjs.org/) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.0-3178C6?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) -[![Gemini API](https://img.shields.io/badge/Powered%20By-Google%20Gemini-8E75B2?style=for-the-badge&logo=google&logoColor=white)](https://ai.google.dev/) -[![Tailwind CSS](https://img.shields.io/badge/Tailwind-3.0-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white)](https://tailwindcss.com/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) +--- -**Research • Write • Illustrate • Design • Publish** +## What Is Null Library? -[Report Bug](https://github.com/yourusername/fraudrobs-book-factory/issues) · [Request Feature](https://github.com/yourusername/fraudrobs-book-factory/issues) +Null Library is a desktop + web application that guides you through the entire book creation pipeline: from market research and genre selection, to outlining, writing every chapter, generating illustrations, and producing a publish-ready EPUB for Amazon KDP or any platform. -
+It runs on Windows, Mac, and Linux (via Electron) or in any modern browser. --- -## 🚀 Overview +## ⚡ The NullProxy Engine (Plain English) + +### What it is +Instead of requiring expensive monthly API keys, Null Library uses a clever system that logs into AI tools **exactly the way a normal user would** — through the real browser login pages (Google, Anthropic, etc.). Once you log in, it saves your session token locally. Every AI call in the app then goes through that token, making requests that look like they come from a logged-in browser user. + +**Result:** You get full AI capability at no cost, using free-tier browser accounts. + +### How it works (step by step) +1. A tiny local server opens briefly on your machine to catch the login callback URL. +2. Your real browser opens to the provider's login page (e.g., Google's sign-in page). +3. You log in normally. The token is captured automatically in the background. +4. The token is stored securely in your app's local data folder — never sent anywhere. +5. From then on, every AI call routes through that token as if you were browsing the web. -**FraudRob's AI Book Factory** is not just another text generator—it is a comprehensive, full-stack publishing suite designed to dominate the Amazon KDP market. +### Why it works +All major AI providers have browser-based OAuth login flows. The tokens those flows produce can be used to make API calls. Null Library uses the same OAuth client IDs that official CLI tools use (Google's `gemini-cli`, Kiro IDE, GitHub Copilot, etc.) — so the tokens are legitimate and fully authorized. -Leveraging the raw power of **Google's Gemini 2.5 & 3.0 models**, this application automates the entire lifecycle of book creation. From identifying high-profit niches using simulated market data to generating full-length manuscripts with high-concurrency threading, custom illustrations, and ready-to-upload KDP metadata. +### Multiple accounts → No rate limits +Add multiple Google/Anthropic/OpenAI accounts per provider. The app rotates through them in round-robin order, spreading API usage so no single account hits rate limits. 5 Google accounts = 5× the free capacity. -### 🖼️ Workflow Visualization +### Smart routing +Different AI tasks are automatically routed to the best model: -```mermaid -graph LR - A[🔍 Market Research] --> B[📝 Outline & Structure] - B --> C[⚡ High-Speed Writing] - C --> D[🎨 AI Illustration] - D --> E[🖌️ Cover Design] - E --> F[📦 KDP Export & Auto-Bot] - style A fill:#f9f,stroke:#333,stroke-width:2px - style C fill:#bbf,stroke:#333,stroke-width:2px - style F fill:#bfb,stroke:#333,stroke-width:2px +| Task | Default Provider | +|------|-----------------| +| Long-form creative writing (chapters) | Gemini 2.5 Flash (Google OAuth) | +| Market research / JSON analysis | Gemini 2.5 Flash or Claude Haiku | +| Cover copy / marketing text | Claude Sonnet (Kiro OAuth) | +| Image prompt generation | Gemini Flash | +| Quality critique / proofreading | Claude Sonnet (Kiro OAuth) | +| General fallback | Gemini Flash | + +### Failsafe chain +The app never fails completely. It tries each layer in order: + +``` +1. NullProxy OAuth accounts ← primary (free, no API key) +2. Manual API keys in settings ← fallback (your own paid keys) +3. Free Google Gemini ← last resort (always available) ``` --- -## ✨ Key Features - -### 🧠 1. Intelligent Market Research -Don't write blindly. The app analyzes trends before you type a single word. -* **Niche Finder:** Identifies profitable, low-competition genres. -* **Trend Simulation:** Generates visual Google Trends data simulations. -* **Competitor Analysis:** AI agents analyze potential competitors to find market gaps. -* **Audience Profiling:** Deep dives into demographics, pain points, and interests. - -### ✍️ 2. Advanced Manuscript Generation -* **High-Concurrency Mode:** Blasts API requests in parallel threads to generate full books in minutes, not hours. -* **Director's Mode:** Give surgical instructions ("Make this scene darker," "Add a plot twist") and watch the AI rewrite instantly. -* **The Critic Agent:** A dedicated "Grand Master Scholar" agent reviews chapters and provides literary critique before rewriting them for quality. -* **Humanization Engine:** Post-processing algorithms to smooth out AI-sounding prose. - -### 🎨 3. Visuals & Cover Design -* **AI Illustration:** Generates scene-specific prompts and renders images for every chapter (Cinematic, Anime, Watercolor, and more). -* **Integrated Cover Editor:** Full FabricJS-powered drag-and-drop editor. - * AI Stock Photo Search - * Custom Typography Control - * Layer Management -* **Author Persona Generator:** Creates fake but realistic author bios, headshots, and action shots for pen names. - -### 📦 4. Deployment & Automation -* **One-Click EPUB:** Generates formatted `.epub` files ready for Kindle. -* **KDP Metadata Suite:** Auto-generates SEO-optimized Titles, Subtitles, 7-Backend Keywords, and HTML-formatted descriptions. -* **Smart Download:** Zips the Manuscript, Cover, and a "Publishing Guide" into a single package. -* **Automation Bot (Beta):** Includes a backend service (Playwright) to physically automate the upload process to Amazon KDP, complete with CAPTCHA handling. - -### 🏭 5. Batch Production Mode -* **Series Generator:** Define a genre and generate **entire book series** (Book 1, 2, 3...) in a single run. -* **Mass Production:** Queue up to 10 projects and let the factory run in the background. +## 🚀 Setup Wizard + +On first launch, a setup wizard walks you through connecting AI accounts: + +1. **Welcome** — Overview of the NullProxy Engine +2. **Connect Accounts** — Click "Connect" per provider; your browser opens the login page +3. **API Keys** — Optionally add fallback API keys (Gemini, Claude, OpenAI) +4. **Ready** — Summary and launch + +You can re-run the wizard anytime from **AI Proxy Settings** in the menu. --- -## 🖥️ Window State Persistence (Desktop / Tauri) +## ⚙️ Settings: AI Proxy Options -When running as a desktop app (Tauri wrapper), the application automatically saves and restores your window size, position, and maximized state between sessions. +Open from the title bar menu → **AI Proxy Settings**. -* **Storage location:** A `window-state.json` file inside the application's AppData directory (e.g. `%AppData%\\` on Windows). -* **Monitor awareness:** On next launch the window reopens on the same monitor if it is still connected; otherwise it falls back to a monitor containing the saved top-left corner, then to the primary monitor. -* **Shrink-to-fit:** If the saved size is larger than the target monitor, the window is shrunk to fit. -* **16 px margin:** When clamping or shrinking, a 16 px inset is kept from every edge of the monitor work area so the window is never flush against the screen border. -* **Minimum size:** The window is never restored smaller than 400 × 300 px. -* **Maximized state:** If the app was closed while maximized it reopens maximized, while the normal (restored) rectangle is still preserved for when you un-maximize. +### Status Tab +- Per-provider account health (green/red indicator) +- Total accounts connected / healthy count +- **Multi-Account Round Robin** toggle — rotate through accounts for load balancing +- **Failsafe Mode** toggle — fall back to free Gemini if all proxy accounts fail ---- +### Accounts Tab +- Add new OAuth accounts per provider (click "+" → browser login) +- Remove accounts +- View per-account usage stats (call count, last used date) -## 🛠️ Technical Stack +### Routing Tab +- Drag to reorder provider priority +- Per-task routing rules (which providers are tried for each task type) -* **Frontend:** React 18, TypeScript, Tailwind CSS -* **AI Core:** Google GenAI SDK (Gemini 2.5 Flash, Gemini 3.0 Pro) -* **State Management:** IndexedDB (Custom wrapper for massive storage capacity beyond 5MB) -* **Graphics:** FabricJS (Canvas manipulation), Pollinations.ai (Image Generation) -* **Export:** JSZip, Epub-Gen-ES -* **Backend (Optional Bot):** Node.js, Express, WebSockets, Playwright +### API Keys Tab +- Enter manual API keys (used as fallback only) +- Google Gemini, Anthropic Claude, OpenAI --- -## 💾 Installation & Setup - -1. **Clone the repo** - ```bash - git clone https://github.com/crazyrob425/KDP-E-Book-Generator.git - cd KDP-E-Book-Generator - ``` - -2. **Install dependencies** - ```bash - npm install - ``` - -3. **Configure environment variables** - Create a `.env` file in the project root: - ```env - VITE_GOOGLE_API_KEY=your_google_gemini_api_key_here - KDP_EMAIL=your-kdp-email@example.com - KDP_PASSWORD=your-kdp-password - ``` - Notes: - - `VITE_GOOGLE_API_KEY` is required for AI generation in the frontend. - - `KDP_EMAIL` and `KDP_PASSWORD` are required only for automation flows that run browser automation. - - Some code paths still support `API_KEY` fallback, but `VITE_GOOGLE_API_KEY` is the canonical setting. - -4. **Run in development** - ```bash - npm run dev - ``` - -5. **Build for production** - ```bash - npm run build - ``` - -## 🖥️ Supported Runtime Modes - -### 1) Web UI mode -- Run: `npm run dev` -- Supports the core authoring workflow (research, outline, generation, review). -- Electron-specific features (custom window controls, IPC-backed file dialogs, local automation worker bridge) are not guaranteed in plain browser mode. - -### 2) Electron desktop mode (primary automation path) -- Frontend + Electron preload/main process integration. -- KDP automation component currently uses Electron IPC as the active transport path. -- Uses handlers defined in `electron/main.ts` and API exposed in `electron/preload.ts`. - -### 3) Standalone backend automation mode (optional) -- `server/server.ts` provides a WebSocket backend path for automation workflows. -- Treat this as an optional deployment mode for remote automation hosting scenarios. -- See `server/README.md` for backend setup and deployment details. - -### 🤖 Automation Bot Setup (Optional) - -To run browser automation reliably: - -1. Install dependencies in the root project (`npm install`). -2. Install Playwright browsers: - ```bash - npx playwright install - ``` -3. Set: - - `KDP_EMAIL` - - `KDP_PASSWORD` - -If using the standalone backend mode, follow `/server/README.md`. - -## ✅ Validation Commands - -- Frontend build: - ```bash - npm run build - ``` -- Server TypeScript check: - ```bash - npx tsc -p server/tsconfig.json --noEmit - ``` - -## 🛠️ Troubleshooting - -- **`vite: not found`** - - Run `npm install` first, then rerun `npm run build` or `npm run dev`. - -- **Missing API key errors** - - Ensure `.env` contains `VITE_GOOGLE_API_KEY`. - -- **Playwright launch/automation failures** - - Run `npx playwright install`. - - Ensure `KDP_EMAIL` and `KDP_PASSWORD` are present in environment. - -- **IPC-only feature errors in browser mode** - - Features relying on `window.electronAPI` require Electron desktop runtime. - -- **Backend connectivity mismatch** - - Electron automation flow uses IPC. - - Standalone backend flow requires a running WebSocket backend on the expected URL/path. +## 📖 Features + +- **Market Research**: Hot genre finder, topic brainstorming, competitor analysis +- **Book Outlining**: AI-generated table of contents from market data +- **Chapter Writing**: Generate full chapters in parallel (High-Concurrency mode) +- **Humanization**: Pass all chapters through a humanizer for natural tone +- **Illustration**: AI image prompts and image generation per chapter +- **Cover Design**: Generate KDP-ready book cover art +- **EPUB Export**: Publish-ready EPUB with images, author bio, and back matter +- **KDP Marketing**: Auto-generated descriptions, categories, and keywords +- **Batch Mode**: Generate entire libraries of books automatically +- **KDP Automation**: Bot-assisted upload to Amazon KDP (Electron only) +- **Project Save/Load**: Full project state saved to disk as JSON --- -## 🖼️ Gallery +## 🔧 Development + +```bash +# Install dependencies +npm install + +# Start development server +npm run dev -| Market Research | Cover Editor | Manuscript Writer | -| :---: | :---: | :---: | -| *Analyze Trends* | *Drag & Drop Design* | *Director's Mode* | -| ![Research Placeholder](https://placehold.co/300x200/1e293b/FFF?text=Market+Data) | ![Editor Placeholder](https://placehold.co/300x200/1e293b/FFF?text=Cover+Lab) | ![Writer Placeholder](https://placehold.co/300x200/1e293b/FFF?text=AI+Writer) | +# Build for production +npm run build + +# Build Electron app +npm run electron:build +``` + +### Environment Variables +```env +# Fallback Gemini API key (optional — NullProxy Engine doesn't need it) +VITE_GOOGLE_API_KEY=AIza... +``` --- -## 🤝 Contributing +## 🏗️ Architecture + +``` +App.tsx Main React app with wizard gate +├── SetupWizardStep First-run OAuth setup wizard +├── ProxySettingsModal AI Proxy settings UI +├── TitleBar Custom frame with Null Library branding +└── steps/ Book creation pipeline steps + +services/ +├── aiService.ts Unified AI router (proxy → key → free Gemini) +├── nullProxyService.ts NullProxy Engine (routing, round-robin, OAuth calls) +├── oauthSetupService.ts OAuth browser login flow bridge +├── geminiService.ts Legacy Gemini service (kept for compatibility) +└── desktopBridge.ts Electron/Tauri/Browser bridge + +electron/ +├── main.ts OAuth IPC handlers, local callback server +└── preload.ts Secure IPC bridge (contextIsolation) +``` + +--- -Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. +## Logo -1. Fork the Project -2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) -3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) -4. Push to the Branch (`git push origin feature/AmazingFeature`) -5. Open a Pull Request +The Null Library logo depicts a **digital brain made of interlocking puzzle pieces** contained within the outline of an open book — symbolizing structured intelligence and infinite creative possibility. See `components/NullLibraryLogo.tsx`. --- -## ⚠️ Disclaimer +## License -This tool is intended for educational and productivity purposes. Users are responsible for adhering to Amazon KDP's Terms of Service regarding AI-generated content. Always review AI output before publishing. +MIT — do whatever you want. Build infinite books. --- -
-

Built with ❤️ by FraudRob

-

- Website • - Documentation • - Support -

-
+## 🔑 Configuring OAuth Providers (Electron) + +OAuth proxy connections require registering OAuth 2.0 client credentials. Set them in a `.env` file (gitignored): + +```env +# Gemini CLI OAuth (Google Cloud Platform scope) +# Register at https://console.cloud.google.com → APIs & Services → Credentials +# Or use credentials from the open-source gemini-cli project +GEMINI_CLI_CLIENT_ID=your-client-id.apps.googleusercontent.com +GEMINI_CLI_CLIENT_SECRET=your-client-secret + +# Second Google account channel (optional, for load balancing) +GEMINI_ANTIGRAVITY_CLIENT_ID=your-second-client-id.apps.googleusercontent.com +GEMINI_ANTIGRAVITY_CLIENT_SECRET=your-second-client-secret +``` + +Copy `.env.example` to `.env` and fill in your values. If you don't configure OAuth credentials, manual API keys (Settings → API Keys) and free Gemini still work. diff --git a/components/NullLibraryLogo.tsx b/components/NullLibraryLogo.tsx new file mode 100644 index 0000000..588aeeb --- /dev/null +++ b/components/NullLibraryLogo.tsx @@ -0,0 +1,124 @@ +import React from 'react'; + +/** + * Null Library logo — a digital brain composed of puzzle pieces, + * contained within the outline of an open book. + */ +const NullLibraryLogo: React.FC<{ className?: string; size?: number }> = ({ + className = '', + size = 40, +}) => ( + + {/* Book outline — two open pages meeting at spine */} + + + {/* Spine line */} + + + {/* Left puzzle-piece brain segments */} + {/* Top-left piece */} + + {/* Top-right piece on left page */} + + {/* Bottom-left piece on left page */} + + {/* Bottom-right piece on left page */} + + + {/* Right page puzzle-piece brain segments */} + {/* Top-left piece on right page */} + + {/* Top-right piece on right page */} + + {/* Bottom-left piece on right page */} + + {/* Bottom-right piece on right page */} + + + {/* Neural connection dots — the "digital" aspect */} + + + + + + + + + + {/* Gradient defs */} + + + + + + + + + + + + + + + + + + + + + + + +); + +export default NullLibraryLogo; diff --git a/components/ProxySettingsModal.tsx b/components/ProxySettingsModal.tsx new file mode 100644 index 0000000..bef91de --- /dev/null +++ b/components/ProxySettingsModal.tsx @@ -0,0 +1,444 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import Modal from './shared/Modal'; +import Button from './shared/Button'; +import LoadingSpinner from './shared/LoadingSpinner'; +import { ProxyProvider, ProxyAccount, ProxySettings } from '../types'; +import { + PROVIDER_DISPLAY_NAMES, + PROVIDER_LOGIN_DESCRIPTIONS, + DEFAULT_PROXY_SETTINGS, + loadProxySettings, + saveProxySettings, + invalidateSettingsCache, +} from '../services/nullProxyService'; +import { startOAuthFlow, removeAccount, isElectronRuntime } from '../services/oauthSetupService'; +import desktopBridge from '../services/desktopBridge'; + +interface ProxySettingsModalProps { + onClose: () => void; +} + +const PROXY_PROVIDERS: ProxyProvider[] = [ + 'gemini-cli', + 'gemini-antigravity', + 'claude-kiro', + 'openai-codex', + 'openai-qwen', + 'openai-iflow', +]; + +const TASK_LABELS: Record = { + 'creative-writing': 'Long-form Creative Writing', + 'market-research': 'Market Research & Structured Data', + 'marketing-copy': 'Marketing Copy & Book Descriptions', + 'image-prompt': 'Image Prompt Generation', + 'critique': 'Quality Critique & Proofreading', + 'general': 'General / Unclassified Tasks', +}; + +const ProxySettingsModal: React.FC = ({ onClose }) => { + const [settings, setSettings] = useState(null); + const [activeTab, setActiveTab] = useState<'status' | 'accounts' | 'routing' | 'keys'>('status'); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [connectingProvider, setConnectingProvider] = useState(null); + const [oauthMessage, setOauthMessage] = useState>({}); + const [isElectron] = useState(isElectronRuntime); + + useEffect(() => { + loadProxySettings().then((s) => { + setSettings(s); + setIsLoading(false); + }); + }, []); + + const handleSave = useCallback(async (updated: ProxySettings) => { + setIsSaving(true); + try { + await saveProxySettings(updated); + setSettings(updated); + } finally { + setIsSaving(false); + } + }, []); + + const handleToggle = (field: keyof ProxySettings) => { + if (!settings) return; + const updated = { ...settings, [field]: !settings[field as keyof ProxySettings] }; + handleSave(updated as ProxySettings); + }; + + const handleConnectAccount = useCallback(async (provider: ProxyProvider) => { + setConnectingProvider(provider); + setOauthMessage((prev) => ({ ...prev, [provider]: 'Connecting…' })); + const account = await startOAuthFlow(provider, (status) => { + setOauthMessage((prev) => ({ ...prev, [provider]: status.message })); + }); + setConnectingProvider(null); + if (account) { + invalidateSettingsCache(); + const refreshed = await loadProxySettings(); + setSettings(refreshed); + setOauthMessage((prev) => ({ ...prev, [provider]: '✅ Connected!' })); + } else { + setOauthMessage((prev) => ({ + ...prev, + [provider]: prev[provider]?.includes('✅') ? prev[provider] : '❌ Failed — try again', + })); + } + }, []); + + const handleRemoveAccount = useCallback(async (accountId: string) => { + if (!window.confirm('Remove this account?')) return; + await removeAccount(accountId); + invalidateSettingsCache(); + const refreshed = await loadProxySettings(); + setSettings(refreshed); + }, []); + + const handlePriorityMove = (provider: ProxyProvider, direction: 'up' | 'down') => { + if (!settings) return; + const list = [...settings.providerPriority]; + const idx = list.indexOf(provider); + if (idx < 0) return; + const newIdx = direction === 'up' ? idx - 1 : idx + 1; + if (newIdx < 0 || newIdx >= list.length) return; + [list[idx], list[newIdx]] = [list[newIdx], list[idx]]; + handleSave({ ...settings, providerPriority: list }); + }; + + const handleKeyChange = (field: 'manualApiKey' | 'manualClaudeApiKey' | 'manualOpenAiApiKey', value: string) => { + if (!settings) return; + setSettings({ ...settings, [field]: value }); + }; + + const handleKeySave = () => { + if (!settings) return; + handleSave(settings); + }; + + if (isLoading || !settings) { + return ( + +
+ +
+
+ ); + } + + const accountsByProvider: Record = {}; + for (const account of settings.accounts ?? []) { + if (!accountsByProvider[account.provider]) accountsByProvider[account.provider] = []; + accountsByProvider[account.provider].push(account); + } + + const totalAccounts = settings.accounts?.length ?? 0; + const healthyAccounts = settings.accounts?.filter((a) => a.isHealthy && !a.isDisabled).length ?? 0; + + return ( + + {/* Explanation banner */} +
+ What is the NullProxy Engine?{' '} + Instead of paid API keys, this system logs into AI tools through real browser login pages + (Google, Anthropic, etc.) and stores your session tokens locally. AI calls are routed + through those tokens — free, private, on your machine.{' '} + Failsafe chain:{' '} + OAuth Proxy{' → '} + Manual API Key{' → '} + Free Gemini +
+ + {/* Master toggle */} +
+
+
NullProxy Engine
+
Master switch for all proxy AI access
+
+ +
+ + {/* Tabs */} +
+ {(['status', 'accounts', 'routing', 'keys'] as const).map((tab) => ( + + ))} +
+ + {/* ── Status Tab ──────────────────────────────────────────────────────── */} + {activeTab === 'status' && ( +
+
+ {[ + { label: 'Total Accounts', value: totalAccounts, color: 'text-white' }, + { label: 'Healthy', value: healthyAccounts, color: 'text-emerald-400' }, + { label: 'Unhealthy', value: totalAccounts - healthyAccounts, color: 'text-red-400' }, + { label: 'Providers', value: Object.keys(accountsByProvider).length, color: 'text-violet-400' }, + ].map(({ label, value, color }) => ( +
+
{value}
+
{label}
+
+ ))} +
+ +
+

Per-Provider Status

+
+ {PROXY_PROVIDERS.map((provider) => { + const accounts = accountsByProvider[provider] ?? []; + const healthy = accounts.filter((a) => a.isHealthy && !a.isDisabled); + return ( +
+
{PROVIDER_DISPLAY_NAMES[provider]}
+
+ {accounts.length === 0 ? ( + Not connected + ) : ( + <> + {accounts.length} account{accounts.length !== 1 ? 's' : ''} + 0 ? 'bg-emerald-500' : 'bg-red-500'}`} /> + + )} +
+
+ ); + })} +
+
+ + {/* Round-robin toggle */} +
+
+
Multi-Account Round Robin
+
Rotate through all accounts to spread API load evenly
+
+ +
+ + {/* Failsafe toggle */} +
+
+
Failsafe Mode
+
Fall back to free Google Gemini if all proxy accounts fail
+
+ +
+
+ )} + + {/* ── Accounts Tab ────────────────────────────────────────────────────── */} + {activeTab === 'accounts' && ( +
+ {!isElectron && ( +
+ ⚠️ Adding OAuth accounts requires the Null Library desktop app (Electron). +
+ )} + + {PROXY_PROVIDERS.map((provider) => { + const accounts = accountsByProvider[provider] ?? []; + const isConnecting = connectingProvider === provider; + const msg = oauthMessage[provider]; + + return ( +
+
+
+
{PROVIDER_DISPLAY_NAMES[provider]}
+
{PROVIDER_LOGIN_DESCRIPTIONS[provider]}
+
+ +
+ + {msg && ( +
{msg}
+ )} + + {accounts.length > 0 ? ( +
+ {accounts.map((account) => ( +
+
+
+ {account.displayName ?? account.email ?? account.id.substring(0, 24) + '…'} +
+
+ {account.usageCount} calls + {account.lastUsed && ( + Last: {new Date(account.lastUsed).toLocaleDateString()} + )} +
+
+
+ + +
+
+ ))} +
+ ) : ( +
No accounts connected
+ )} +
+ ); + })} +
+ )} + + {/* ── Routing Tab ─────────────────────────────────────────────────────── */} + {activeTab === 'routing' && ( +
+
+

Provider Priority Order

+

+ Drag or use arrows to reorder. The engine tries providers from top to bottom. +

+
+ {settings.providerPriority.map((provider, idx) => ( +
+ {idx + 1} + {PROVIDER_DISPLAY_NAMES[provider]} +
+ + +
+
+ ))} +
+
+ +
+

Task → Provider Routing

+

+ Which providers to try for each type of AI task (in order of preference). +

+
+ {Object.entries(settings.taskRouting).map(([task, providers]) => ( +
+
{TASK_LABELS[task] ?? task}
+
+ {(providers as ProxyProvider[]).map((p) => ( + + {PROVIDER_DISPLAY_NAMES[p]} + + ))} +
+
+ ))} +
+
+
+ )} + + {/* ── API Keys Tab ─────────────────────────────────────────────────────── */} + {activeTab === 'keys' && ( +
+
+ ⚠️ Fallback Only: These API keys are only used + when all proxy accounts fail or are unavailable. The NullProxy Engine takes priority. +
+ + {[ + { + label: 'Google Gemini API Key', + field: 'manualApiKey' as const, + placeholder: 'AIza…', + hint: 'From https://aistudio.google.com/apikey', + }, + { + label: 'Anthropic Claude API Key', + field: 'manualClaudeApiKey' as const, + placeholder: 'sk-ant-…', + hint: 'From https://console.anthropic.com', + }, + { + label: 'OpenAI API Key', + field: 'manualOpenAiApiKey' as const, + placeholder: 'sk-…', + hint: 'From https://platform.openai.com/api-keys', + }, + ].map(({ label, field, placeholder, hint }) => ( +
+ + handleKeyChange(field, e.target.value)} + className="w-full bg-slate-800 border border-slate-600 text-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-violet-500" + /> +

{hint}

+
+ ))} + + +
+ )} + + {/* Footer */} +
+ + All credentials stored locally on your machine. Never sent to any server. + + +
+
+ ); +}; + +export default ProxySettingsModal; diff --git a/components/TitleBar.tsx b/components/TitleBar.tsx index f13dd9c..b95b20b 100644 --- a/components/TitleBar.tsx +++ b/components/TitleBar.tsx @@ -1,13 +1,15 @@ import React, { useState } from 'react'; import { XIcon, DownloadIcon, UploadIcon } from './icons'; import desktopBridge from '../services/desktopBridge'; +import NullLibraryLogo from './NullLibraryLogo'; interface TitleBarProps { onSave?: () => void; onLoad?: () => void; + onOpenSettings?: () => void; } -const TitleBar: React.FC = ({ onSave, onLoad }) => { +const TitleBar: React.FC = ({ onSave, onLoad, onOpenSettings }) => { const [menuOpen, setMenuOpen] = useState(false); const handleMinimize = () => void desktopBridge.minimize(); @@ -29,11 +31,13 @@ const TitleBar: React.FC = ({ onSave, onLoad }) => { -
- KDP E-Book Generator +
+ + Null Library + — The Art of Infinite Production
{menuOpen && ( -
+
{onSave && ( )} + {onOpenSettings && ( + + )} ); diff --git a/components/shared/Modal.tsx b/components/shared/Modal.tsx index 594906d..53fa398 100644 --- a/components/shared/Modal.tsx +++ b/components/shared/Modal.tsx @@ -4,9 +4,18 @@ import React from 'react'; interface ModalProps { children: React.ReactNode; onClose: () => void; + title?: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; } -const Modal: React.FC = ({ children, onClose }) => { +const sizeClasses: Record = { + sm: 'w-full max-w-sm', + md: 'w-full max-w-lg', + lg: 'w-full max-w-2xl', + xl: 'w-full max-w-4xl', +}; + +const Modal: React.FC = ({ children, onClose, title, size = 'md' }) => { return (
= ({ children, onClose }) => { role="dialog" >
e.stopPropagation()} > - {children} + {title && ( +
+

{title}

+ +
+ )} +
+ {children} +
); diff --git a/components/shared/StepIndicator.tsx b/components/shared/StepIndicator.tsx index 58abcb9..6636236 100644 --- a/components/shared/StepIndicator.tsx +++ b/components/shared/StepIndicator.tsx @@ -5,7 +5,7 @@ import { BookOpenIcon, EyeIcon, LightBulbIcon, PencilSquareIcon, PhotoIcon } fro interface StepIndicatorProps { currentStep: AppStep; - setStep: (step: AppStep) => void; + onStepClick: (step: AppStep) => void; } const steps = [ @@ -16,7 +16,7 @@ const steps = [ { id: AppStep.Review, name: 'Review', icon: EyeIcon }, ]; -const StepIndicator: React.FC = ({ currentStep, setStep }) => { +const StepIndicator: React.FC = ({ currentStep, onStepClick }) => { return (
+
+
+ ); + } + + // ── Render: Connect Accounts ───────────────────────────────────────────────── + if (phase === 'connect-accounts') { + return ( +
+
+
+
+ +
+

Connect AI Accounts

+

+ Click Connect next to each provider. Your browser will open the login page — just log in normally. +

+ {!isElectron && ( +
+ ⚠️ OAuth proxy requires the desktop app. You can still use manual API keys. +
+ )} +
+ +
+ {OAUTH_PROVIDERS.map((provider) => { + const isConnected = connectedProviders.has(provider) || (summary[provider]?.count ?? 0) > 0; + const isConnecting = connectingProvider === provider; + const status = oauthStatuses[provider]; + const accountCount = summary[provider]?.count ?? 0; + + return ( +
+
+
+ {PROVIDER_ICONS[provider]} +
+
+ {PROVIDER_DISPLAY_NAMES[provider]} +
+ {isConnected && accountCount > 0 && ( +
+ {accountCount} account{accountCount > 1 ? 's' : ''} connected +
+ )} +
+
+ +
+

{PROVIDER_LOGIN_DESCRIPTIONS[provider]}

+ {status && ( +
+ {status} +
+ )} +
+ ); + })} +
+ +
+ +
+ +
+
+
+
+ ); + } + + // ── Render: Manual API Keys ────────────────────────────────────────────────── + if (phase === 'api-keys') { + return ( +
+
+
+ +

Manual API Keys

+

+ Optional. These are used as fallback only if all proxy accounts fail. + Leave blank to rely entirely on the connected accounts above. +

+
+ +
+ {[ + { + label: 'Google Gemini API Key (VITE_GOOGLE_API_KEY)', + placeholder: 'AIza…', + value: manualApiKey, + onChange: setManualApiKey, + hint: 'From https://aistudio.google.com/apikey — free tier available', + }, + { + label: 'Anthropic Claude API Key', + placeholder: 'sk-ant-…', + value: manualClaudeKey, + onChange: setManualClaudeKey, + hint: 'From https://console.anthropic.com — paid plans only', + }, + { + label: 'OpenAI API Key', + placeholder: 'sk-…', + value: manualOpenAiKey, + onChange: setManualOpenAiKey, + hint: 'From https://platform.openai.com/api-keys — paid plans only', + }, + ].map(({ label, placeholder, value, onChange, hint }) => ( +
+ + onChange(e.target.value)} + className="w-full bg-slate-800 border border-slate-600 text-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent" + /> +

{hint}

+
+ ))} +
+ +
+ Failsafe chain:{' '} + OAuth Proxy{' '} + →{' '} + Manual API Key{' '} + →{' '} + Free Google Gemini +

+ The app always has a way to make AI calls. If everything else fails, it falls back to + the free Google Gemini tier — you'll just need a Google account. +

+
+ +
+ + +
+
+
+ ); + } + + // ── Render: Ready ──────────────────────────────────────────────────────────── + const summaryValues = Object.values(summary) as { count: number; healthy: number }[]; + const totalConnected = summaryValues.reduce((sum, s) => sum + s.count, 0); + + return ( +
+
+ +

You're All Set!

+

+ Null Library is ready to create infinite books for you. +

+ +
+
+ OAuth accounts connected + 0 ? 'text-emerald-400' : 'text-amber-400'}`}> + {totalConnected > 0 ? `${totalConnected} account${totalConnected !== 1 ? 's' : ''}` : 'None — will use fallback'} + +
+
+ Gemini API key + + {manualApiKey ? 'Provided ✓' : 'Not set'} + +
+
+ Failsafe (free Gemini) + Always enabled ✓ +
+
+ Round-robin load balancing + 1 ? 'text-emerald-400' : 'text-slate-600'}`}> + {totalConnected > 1 ? 'Active ✓' : 'N/A (need 2+ accounts)'} + +
+
+ +

+ You can change all of this later in AI Proxy Settings from the menu bar. +

+ + +
+
+ ); +}; + +export default SetupWizardStep; diff --git a/dist-electron/main.js b/dist-electron/main.js index 95505e4..71dda63 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -25,6 +25,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge var import_electron = require("electron"); var import_path2 = __toESM(require("path")); var import_promises2 = __toESM(require("fs/promises")); +var import_http = __toESM(require("http")); // server/automation-worker.ts var import_playwright = require("playwright"); @@ -212,11 +213,45 @@ async function fetchAmazonCompetitors(keyword) { } // electron/main.ts -if (require("electron-squirrel-startup")) { - import_electron.app.quit(); +var OAUTH_CONFIGS = { + "gemini-cli": process.env.GEMINI_CLI_CLIENT_ID ? { + clientId: process.env.GEMINI_CLI_CLIENT_ID, + clientSecret: process.env.GEMINI_CLI_CLIENT_SECRET || "", + authUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + scope: "https://www.googleapis.com/auth/cloud-platform", + port: 8085, + credDir: ".null-library/gemini-cli", + credFile: "oauth_creds.json" + } : null, + "gemini-antigravity": process.env.GEMINI_ANTIGRAVITY_CLIENT_ID ? { + clientId: process.env.GEMINI_ANTIGRAVITY_CLIENT_ID, + clientSecret: process.env.GEMINI_ANTIGRAVITY_CLIENT_SECRET || "", + authUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + scope: "https://www.googleapis.com/auth/cloud-platform", + port: 8086, + credDir: ".null-library/gemini-antigravity", + credFile: "oauth_creds.json" + } : null, + "claude-kiro": null, + // Kiro OAuth is more complex; handled via external browser + "openai-codex": null, + // Codex OAuth via GitHub device flow + "openai-qwen": null, + // Qwen browser login + "openai-iflow": null + // iFlow browser login +}; +try { + if (require("electron-squirrel-startup")) { + import_electron.app.quit(); + } +} catch { } var mainWindow = null; var automationGenerator = null; +var oauthServers = /* @__PURE__ */ new Map(); var createWindow = () => { mainWindow = new import_electron.BrowserWindow({ width: 1200, @@ -238,7 +273,9 @@ var createWindow = () => { import_electron.shell.openExternal(url); return { action: "deny" }; }); - mainWindow.webContents.openDevTools(); + if (!import_electron.app.isPackaged) { + mainWindow.webContents.openDevTools(); + } mainWindow.webContents.on("did-fail-load", (event, errorCode, errorDescription) => { console.error("Failed to load window:", errorCode, errorDescription); }); @@ -254,6 +291,18 @@ import_electron.app.on("activate", () => { createWindow(); } }); +function getCredStoreDir() { + return import_path2.default.join(import_electron.app.getPath("userData"), "null-library-credentials"); +} +function getAccountCredPath(provider, accountId) { + return import_path2.default.join(getCredStoreDir(), provider, `${accountId}.json`); +} +async function ensureDir(dir) { + await import_promises2.default.mkdir(dir, { recursive: true }); +} +function sendOAuthStatus(provider, phase, message, error) { + mainWindow?.webContents.send("oauth-status", { provider, phase, message, error }); +} import_electron.ipcMain.handle("window-control", async (event, action) => { const win = import_electron.BrowserWindow.fromWebContents(event.sender); if (!win) return; @@ -275,8 +324,6 @@ import_electron.ipcMain.handle("start-automation", async (event, payload) => { sender.send("automation-update", update); }; try { - if (automationGenerator) { - } automationGenerator = runAutomation(payload, sendUpdate); const result = await automationGenerator.next(); if (result.done) { @@ -337,3 +384,253 @@ import_electron.ipcMain.handle("load-file", async () => { return { success: false, error: e.message }; } }); +import_electron.ipcMain.handle("oauth:start", async (event, provider) => { + const config = OAUTH_CONFIGS[provider]; + if (!config) { + return { success: false, error: `OAuth not configured for provider: ${provider}` }; + } + const existing = oauthServers.get(provider); + if (existing) { + existing.close(); + oauthServers.delete(provider); + } + return new Promise((resolve) => { + const redirectUri = `http://localhost:${config.port}/callback`; + sendOAuthStatus(provider, "starting", `Starting OAuth flow for ${provider}\u2026`); + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: config.scope, + access_type: "offline", + prompt: "consent" + }); + const authUrl = `${config.authUrl}?${params.toString()}`; + const server = import_http.default.createServer(async (req, res) => { + if (!req.url?.startsWith("/callback")) { + res.writeHead(404); + res.end("Not found"); + return; + } + const url = new URL(req.url, `http://localhost:${config.port}`); + const code = url.searchParams.get("code"); + const rawError = url.searchParams.get("error"); + const error = rawError ? rawError.replace(/[^\w\s\-_.]/g, "") : null; + if (error || !code) { + const displayError = error ?? "No authorization code received"; + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(buildCallbackPage(false, `Auth failed: ${displayError}`, provider)); + sendOAuthStatus(provider, "error", `OAuth cancelled or failed: ${displayError}`); + server.close(); + oauthServers.delete(provider); + resolve({ success: false, error: displayError }); + return; + } + sendOAuthStatus(provider, "callback-received", "Login successful! Saving credentials\u2026"); + try { + const tokenRes = await fetch(config.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code" + }).toString() + }); + if (!tokenRes.ok) { + const err = await tokenRes.text(); + throw new Error(`Token exchange failed: ${err}`); + } + const tokens = await tokenRes.json(); + const accountId = `${provider}-${Date.now()}`; + const credPath = getAccountCredPath(provider, accountId); + await ensureDir(import_path2.default.dirname(credPath)); + const credData = { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expiry_date: Date.now() + tokens.expires_in * 1e3, + token_type: tokens.token_type, + accountId, + provider, + createdAt: (/* @__PURE__ */ new Date()).toISOString() + }; + await import_promises2.default.writeFile(credPath, JSON.stringify(credData, null, 2), "utf-8"); + sendOAuthStatus(provider, "saving", "Credentials saved."); + sendOAuthStatus(provider, "done", `Connected to ${provider} successfully!`); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(buildCallbackPage(true, `Connected to ${provider}!`, provider)); + await updateProxyAccountsList(provider, accountId, credPath); + server.close(); + oauthServers.delete(provider); + resolve({ success: true }); + } catch (e) { + const errMsg = e.message; + sendOAuthStatus(provider, "error", `Failed to save credentials: ${errMsg}`, errMsg); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(buildCallbackPage(false, errMsg, provider)); + server.close(); + oauthServers.delete(provider); + resolve({ success: false, error: errMsg }); + } + }); + server.listen(config.port, () => { + oauthServers.set(provider, server); + sendOAuthStatus(provider, "waiting-for-browser", "Browser opened \u2014 please log in\u2026"); + import_electron.shell.openExternal(authUrl).catch((e) => { + console.error("Failed to open browser:", e); + server.close(); + oauthServers.delete(provider); + resolve({ success: false, error: "Could not open browser" }); + }); + }); + server.on("error", (e) => { + sendOAuthStatus(provider, "error", `Server error: ${e.message}`, e.message); + resolve({ success: false, error: e.message }); + }); + }); +}); +import_electron.ipcMain.handle("oauth:cancel", async (_, provider) => { + const server = oauthServers.get(provider); + if (server) { + server.close(); + oauthServers.delete(provider); + } + sendOAuthStatus(provider, "error", "OAuth flow cancelled by user."); +}); +import_electron.ipcMain.handle("proxy:getStatus", async () => { + try { + const settings = await loadProxySettingsFromDisk(); + return settings.accounts ?? []; + } catch { + return []; + } +}); +import_electron.ipcMain.handle("proxy:addAccount", async (_, provider) => { + return import_electron.ipcMain.emit("oauth:start", provider); +}); +import_electron.ipcMain.handle("proxy:removeAccount", async (_, accountId) => { + const settings = await loadProxySettingsFromDisk(); + settings.accounts = (settings.accounts ?? []).filter((a) => a.id !== accountId); + await saveProxySettingsToDisk(settings); + const credStoreDir = getCredStoreDir(); + try { + const files = await import_promises2.default.readdir(credStoreDir, { recursive: true, withFileTypes: true }); + for (const f of files) { + if (f.name === `${accountId}.json`) { + await import_promises2.default.unlink(import_path2.default.join(f.path ?? f.parentPath, f.name)).catch(() => { + }); + } + } + } catch { + } +}); +import_electron.ipcMain.handle("proxy:getSettings", async () => { + return loadProxySettingsFromDisk(); +}); +import_electron.ipcMain.handle("proxy:saveSettings", async (_, settings) => { + await saveProxySettingsToDisk(settings); +}); +import_electron.ipcMain.handle("proxy:getCredPath", async (_, provider, accountId) => { + const p = getAccountCredPath(provider, accountId); + try { + await import_promises2.default.access(p); + return p; + } catch { + return null; + } +}); +import_electron.ipcMain.handle("proxy:readCredFile", async (_, filePath) => { + try { + return await import_promises2.default.readFile(filePath, "utf-8"); + } catch { + return null; + } +}); +var PROXY_SETTINGS_FILE = () => import_path2.default.join(import_electron.app.getPath("userData"), "null-library-proxy-settings.json"); +async function loadProxySettingsFromDisk() { + try { + const data = await import_promises2.default.readFile(PROXY_SETTINGS_FILE(), "utf-8"); + return JSON.parse(data); + } catch { + return { + enabled: true, + roundRobinEnabled: true, + providerPriority: ["gemini-cli", "gemini-antigravity", "claude-kiro", "openai-codex", "openai-qwen", "openai-iflow"], + taskRouting: { + "creative-writing": ["gemini-cli", "gemini-antigravity", "openai-codex"], + "market-research": ["gemini-cli", "gemini-antigravity", "claude-kiro"], + "marketing-copy": ["claude-kiro", "openai-codex", "gemini-cli"], + "image-prompt": ["gemini-cli", "gemini-antigravity"], + "critique": ["claude-kiro", "gemini-cli", "openai-codex"], + "general": ["gemini-cli", "gemini-antigravity", "claude-kiro", "openai-codex"] + }, + failsafeEnabled: true, + accounts: [] + }; + } +} +async function saveProxySettingsToDisk(settings) { + const filePath = PROXY_SETTINGS_FILE(); + await ensureDir(import_path2.default.dirname(filePath)); + await import_promises2.default.writeFile(filePath, JSON.stringify(settings, null, 2), "utf-8"); +} +async function updateProxyAccountsList(provider, accountId, credPath) { + const settings = await loadProxySettingsFromDisk(); + const newAccount = { + id: accountId, + provider, + isHealthy: true, + isDisabled: false, + lastUsed: Date.now(), + usageCount: 0, + errorCount: 0 + }; + const existing = (settings.accounts ?? []).findIndex((a) => a.id === accountId); + if (existing >= 0) { + settings.accounts[existing] = newAccount; + } else { + settings.accounts = [...settings.accounts ?? [], newAccount]; + } + await saveProxySettingsToDisk(settings); + mainWindow?.webContents.send("proxy-accounts-updated", settings.accounts); +} +function escapeHtml(unsafe) { + return unsafe.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} +function buildCallbackPage(isSuccess, message, provider) { + const title = isSuccess ? "\u2705 Connected!" : "\u274C Connection Failed"; + const bgColor = isSuccess ? "#0f172a" : "#1a0a0a"; + const borderColor = isSuccess ? "#6d28d9" : "#dc2626"; + const textColor = isSuccess ? "#a78bfa" : "#f87171"; + const instruction = isSuccess ? "You can close this tab and return to Null Library." : "Please close this tab and try again in Null Library."; + const safeProvider = escapeHtml(provider); + const safeMessage = escapeHtml(message); + return ` + + + +${isSuccess ? "Connected!" : "Connection Failed"} + + + +
+ +

${title}

+

${safeProvider}

+

${safeMessage}

+

${instruction}

+
+ + +`; +} diff --git a/dist-electron/preload.js b/dist-electron/preload.js index 4280526..e24fa2d 100644 --- a/dist-electron/preload.js +++ b/dist-electron/preload.js @@ -18,5 +18,31 @@ import_electron.contextBridge.exposeInMainWorld("electronAPI", { }, fetchGoogleTrends: (keyword) => import_electron.ipcRenderer.invoke("market-research:trends", keyword), fetchAmazonCompetitors: (keyword) => import_electron.ipcRenderer.invoke("market-research:competitors", keyword), - fetchAmazonSuggestions: (keyword) => import_electron.ipcRenderer.invoke("market-research:suggestions", keyword) + fetchAmazonSuggestions: (keyword) => import_electron.ipcRenderer.invoke("market-research:suggestions", keyword), + // ── NullProxy OAuth ── + oauthStart: (provider) => import_electron.ipcRenderer.invoke("oauth:start", provider), + oauthCancel: (provider) => import_electron.ipcRenderer.invoke("oauth:cancel", provider), + onOAuthStatus: (callback) => { + const subscription = (_event, value) => callback(value); + import_electron.ipcRenderer.on("oauth-status", subscription); + return () => { + import_electron.ipcRenderer.removeListener("oauth-status", subscription); + }; + }, + // ── NullProxy Proxy Operations ── + proxyGetStatus: () => import_electron.ipcRenderer.invoke("proxy:getStatus"), + proxyAddAccount: (provider) => import_electron.ipcRenderer.invoke("proxy:addAccount", provider), + proxyRemoveAccount: (accountId) => import_electron.ipcRenderer.invoke("proxy:removeAccount", accountId), + proxyGetSettings: () => import_electron.ipcRenderer.invoke("proxy:getSettings"), + proxySaveSettings: (settings) => import_electron.ipcRenderer.invoke("proxy:saveSettings", settings), + proxyGetCredPath: (provider, accountId) => import_electron.ipcRenderer.invoke("proxy:getCredPath", provider, accountId), + readCredFile: (filePath) => import_electron.ipcRenderer.invoke("proxy:readCredFile", filePath), + // Subscribe to proxy accounts being updated (after an OAuth login) + onProxyAccountsUpdated: (callback) => { + const subscription = (_event, value) => callback(value); + import_electron.ipcRenderer.on("proxy-accounts-updated", subscription); + return () => { + import_electron.ipcRenderer.removeListener("proxy-accounts-updated", subscription); + }; + } }); diff --git a/electron/main.ts b/electron/main.ts index 1f6ce67..a1ebcca 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,11 +1,61 @@ import { app, BrowserWindow, ipcMain, shell, dialog } from 'electron'; import path from 'path'; import fs from 'fs/promises'; +import http from 'http'; import { runAutomation } from '../server/automation-worker'; import { fetchGoogleTrends, fetchAmazonCompetitors, fetchAmazonSuggestions } from '../server/market-research-worker'; -import { KdpAutomationPayload, BotUpdate } from '../types'; +import { KdpAutomationPayload, BotUpdate, ProxyProvider, ProxyAccount, ProxySettings } from '../types'; +// ─── OAuth provider configurations ──────────────────────────────────────────── +// Client IDs and secrets are loaded from environment variables at build time, +// or from a local config file at runtime. They are NOT committed to source. +// +// For Gemini CLI OAuth: +// Set GEMINI_CLI_CLIENT_ID and GEMINI_CLI_CLIENT_SECRET in your .env file. +// These can be obtained from the gemini-cli open-source project, or by +// registering your own OAuth app at https://console.cloud.google.com. +// +// For Gemini Antigravity: +// Set GEMINI_ANTIGRAVITY_CLIENT_ID and GEMINI_ANTIGRAVITY_CLIENT_SECRET. +// +// Users who don't configure these can still use manual API keys (Settings → +// API Keys tab) or the free Google Gemini fallback. +const OAUTH_CONFIGS: Record = { + 'gemini-cli': process.env.GEMINI_CLI_CLIENT_ID ? { + clientId: process.env.GEMINI_CLI_CLIENT_ID, + clientSecret: process.env.GEMINI_CLI_CLIENT_SECRET || '', + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + scope: 'https://www.googleapis.com/auth/cloud-platform', + port: 8085, + credDir: '.null-library/gemini-cli', + credFile: 'oauth_creds.json', + } : null, + 'gemini-antigravity': process.env.GEMINI_ANTIGRAVITY_CLIENT_ID ? { + clientId: process.env.GEMINI_ANTIGRAVITY_CLIENT_ID, + clientSecret: process.env.GEMINI_ANTIGRAVITY_CLIENT_SECRET || '', + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + scope: 'https://www.googleapis.com/auth/cloud-platform', + port: 8086, + credDir: '.null-library/gemini-antigravity', + credFile: 'oauth_creds.json', + } : null, + 'claude-kiro': null, // Kiro OAuth is more complex; handled via external browser + 'openai-codex': null, // Codex OAuth via GitHub device flow + 'openai-qwen': null, // Qwen browser login + 'openai-iflow': null, // iFlow browser login +}; // Handle creating/removing shortcuts on Windows when installing/uninstalling. try { @@ -19,6 +69,8 @@ try { let mainWindow: BrowserWindow | null = null; let automationGenerator: AsyncGenerator | null = null; +// Track active OAuth callback servers keyed by provider +const oauthServers: Map = new Map(); const createWindow = () => { mainWindow = new BrowserWindow({ @@ -44,7 +96,6 @@ const createWindow = () => { return { action: 'deny' }; }); - // DEBUG: Open DevTools to debug blank screen (only when not packaged) if (!app.isPackaged) { mainWindow!.webContents.openDevTools(); } @@ -68,7 +119,25 @@ app.on('activate', () => { } }); -// --- IPC HANDLERS --- +// ─── Helper: get the app's credential storage directory ────────────────────── +function getCredStoreDir(): string { + return path.join(app.getPath('userData'), 'null-library-credentials'); +} + +function getAccountCredPath(provider: ProxyProvider, accountId: string): string { + return path.join(getCredStoreDir(), provider, `${accountId}.json`); +} + +async function ensureDir(dir: string): Promise { + await fs.mkdir(dir, { recursive: true }); +} + +// ─── Helper: send OAuth status event to renderer ────────────────────────────── +function sendOAuthStatus(provider: ProxyProvider, phase: string, message: string, error?: string) { + mainWindow?.webContents.send('oauth-status', { provider, phase, message, error }); +} + +// ─── IPC HANDLERS ───────────────────────────────────────────────────────────── ipcMain.handle('window-control', async (event, action) => { const win = BrowserWindow.fromWebContents(event.sender); @@ -87,13 +156,6 @@ ipcMain.handle('start-automation', async (event, payload: KdpAutomationPayload) }; try { - if (automationGenerator) { - // If running, ensure we clean up? Or maybe we just restart. - // For now, let's just error if busy? Or kill previous? - // Simple approach: Error if busy - // But actually, the generator might be waiting for input. - } - automationGenerator = runAutomation(payload, sendUpdate); const result = await automationGenerator.next(); if (result.done) { @@ -121,7 +183,7 @@ ipcMain.handle('stop-automation', async () => { } }); -// --- MARKET RESEARCH HANDLERS --- +// ─── MARKET RESEARCH HANDLERS ────────────────────────────────────────────────── ipcMain.handle('market-research:trends', async (_, keyword: string) => { return await fetchGoogleTrends(keyword); @@ -131,7 +193,7 @@ ipcMain.handle('market-research:competitors', async (_, keyword: string) => { return await fetchAmazonCompetitors(keyword); }); -// --- FILE SYSTEM HANDLERS --- +// ─── FILE SYSTEM HANDLERS ────────────────────────────────────────────────────── ipcMain.handle('save-file', async (event, data: string, filename: string) => { const { canceled, filePath } = await dialog.showSaveDialog({ @@ -169,3 +231,330 @@ ipcMain.handle('load-file', async () => { } }); +// ─── NULLPROXY OAUTH HANDLERS ────────────────────────────────────────────────── + +/** + * Start an OAuth login flow for a Google-based provider. + * Opens a local callback server, then opens the browser to the auth URL. + */ +ipcMain.handle('oauth:start', async (event, provider: ProxyProvider) => { + const config = OAUTH_CONFIGS[provider]; + if (!config) { + return { success: false, error: `OAuth not configured for provider: ${provider}` }; + } + + // Close any existing server for this provider + const existing = oauthServers.get(provider); + if (existing) { + existing.close(); + oauthServers.delete(provider); + } + + return new Promise<{ success: boolean; error?: string }>((resolve) => { + const redirectUri = `http://localhost:${config.port}/callback`; + + sendOAuthStatus(provider, 'starting', `Starting OAuth flow for ${provider}…`); + + // Build auth URL + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: config.scope, + access_type: 'offline', + prompt: 'consent', + }); + const authUrl = `${config.authUrl}?${params.toString()}`; + + // Start local callback server + const server = http.createServer(async (req, res) => { + if (!req.url?.startsWith('/callback')) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const url = new URL(req.url, `http://localhost:${config.port}`); + const code = url.searchParams.get('code'); + // Sanitize the error parameter — it comes from the external OAuth provider + // and must not be reflected back into HTML or IPC messages without sanitization. + const rawError = url.searchParams.get('error'); + const error = rawError ? rawError.replace(/[^\w\s\-_.]/g, '') : null; + + if (error || !code) { + const displayError = error ?? 'No authorization code received'; + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(buildCallbackPage(false, `Auth failed: ${displayError}`, provider)); + sendOAuthStatus(provider, 'error', `OAuth cancelled or failed: ${displayError}`); + server.close(); + oauthServers.delete(provider); + resolve({ success: false, error: displayError }); + return; + } + + sendOAuthStatus(provider, 'callback-received', 'Login successful! Saving credentials…'); + + try { + // Exchange code for tokens + const tokenRes = await fetch(config.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }).toString(), + }); + + if (!tokenRes.ok) { + const err = await tokenRes.text(); + throw new Error(`Token exchange failed: ${err}`); + } + + const tokens = await tokenRes.json() as { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; + }; + + // Generate a deterministic account ID from the first part of the token + const accountId = `${provider}-${Date.now()}`; + const credPath = getAccountCredPath(provider, accountId); + await ensureDir(path.dirname(credPath)); + + const credData = { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expiry_date: Date.now() + (tokens.expires_in * 1000), + token_type: tokens.token_type, + accountId, + provider, + createdAt: new Date().toISOString(), + }; + await fs.writeFile(credPath, JSON.stringify(credData, null, 2), 'utf-8'); + + sendOAuthStatus(provider, 'saving', 'Credentials saved.'); + sendOAuthStatus(provider, 'done', `Connected to ${provider} successfully!`); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(buildCallbackPage(true, `Connected to ${provider}!`, provider)); + + // Update the main proxy accounts list + await updateProxyAccountsList(provider, accountId, credPath); + + server.close(); + oauthServers.delete(provider); + resolve({ success: true }); + + } catch (e) { + const errMsg = (e as Error).message; + sendOAuthStatus(provider, 'error', `Failed to save credentials: ${errMsg}`, errMsg); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(buildCallbackPage(false, errMsg, provider)); + server.close(); + oauthServers.delete(provider); + resolve({ success: false, error: errMsg }); + } + }); + + server.listen(config.port, () => { + oauthServers.set(provider, server); + sendOAuthStatus(provider, 'waiting-for-browser', 'Browser opened — please log in…'); + shell.openExternal(authUrl).catch((e) => { + console.error('Failed to open browser:', e); + server.close(); + oauthServers.delete(provider); + resolve({ success: false, error: 'Could not open browser' }); + }); + }); + + server.on('error', (e) => { + sendOAuthStatus(provider, 'error', `Server error: ${e.message}`, e.message); + resolve({ success: false, error: e.message }); + }); + }); +}); + +ipcMain.handle('oauth:cancel', async (_, provider: ProxyProvider) => { + const server = oauthServers.get(provider); + if (server) { + server.close(); + oauthServers.delete(provider); + } + sendOAuthStatus(provider, 'error', 'OAuth flow cancelled by user.'); +}); + +// ─── NULLPROXY STATUS & MANAGEMENT HANDLERS ──────────────────────────────────── + +ipcMain.handle('proxy:getStatus', async () => { + try { + const settings = await loadProxySettingsFromDisk(); + return settings.accounts ?? []; + } catch { + return []; + } +}); + +ipcMain.handle('proxy:addAccount', async (_, provider: ProxyProvider) => { + // Alias for oauth:start — triggered from UI "Add Account" button + return ipcMain.emit('oauth:start', provider); +}); + +ipcMain.handle('proxy:removeAccount', async (_, accountId: string) => { + const settings = await loadProxySettingsFromDisk(); + settings.accounts = (settings.accounts ?? []).filter((a: ProxyAccount) => a.id !== accountId); + await saveProxySettingsToDisk(settings); + + // Also remove the credential file + const credStoreDir = getCredStoreDir(); + try { + const files = await fs.readdir(credStoreDir, { recursive: true, withFileTypes: true } as any); + for (const f of files as any[]) { + if (f.name === `${accountId}.json`) { + await fs.unlink(path.join(f.path ?? f.parentPath, f.name)).catch(() => {}); + } + } + } catch { + // best-effort + } +}); + +ipcMain.handle('proxy:getSettings', async () => { + return loadProxySettingsFromDisk(); +}); + +ipcMain.handle('proxy:saveSettings', async (_, settings: ProxySettings) => { + await saveProxySettingsToDisk(settings); +}); + +ipcMain.handle('proxy:getCredPath', async (_, provider: ProxyProvider, accountId: string) => { + const p = getAccountCredPath(provider, accountId); + try { + await fs.access(p); + return p; + } catch { + return null; + } +}); + +ipcMain.handle('proxy:readCredFile', async (_, filePath: string) => { + try { + return await fs.readFile(filePath, 'utf-8'); + } catch { + return null; + } +}); + +// ─── Proxy settings persistence (main process) ─────────────────────────────── + +const PROXY_SETTINGS_FILE = () => path.join(app.getPath('userData'), 'null-library-proxy-settings.json'); + +async function loadProxySettingsFromDisk(): Promise { + try { + const data = await fs.readFile(PROXY_SETTINGS_FILE(), 'utf-8'); + return JSON.parse(data) as ProxySettings; + } catch { + return { + enabled: true, + roundRobinEnabled: true, + providerPriority: ['gemini-cli', 'gemini-antigravity', 'claude-kiro', 'openai-codex', 'openai-qwen', 'openai-iflow'], + taskRouting: { + 'creative-writing': ['gemini-cli', 'gemini-antigravity', 'openai-codex'], + 'market-research': ['gemini-cli', 'gemini-antigravity', 'claude-kiro'], + 'marketing-copy': ['claude-kiro', 'openai-codex', 'gemini-cli'], + 'image-prompt': ['gemini-cli', 'gemini-antigravity'], + 'critique': ['claude-kiro', 'gemini-cli', 'openai-codex'], + 'general': ['gemini-cli', 'gemini-antigravity', 'claude-kiro', 'openai-codex'], + }, + failsafeEnabled: true, + accounts: [], + }; + } +} + +async function saveProxySettingsToDisk(settings: ProxySettings): Promise { + const filePath = PROXY_SETTINGS_FILE(); + await ensureDir(path.dirname(filePath)); + await fs.writeFile(filePath, JSON.stringify(settings, null, 2), 'utf-8'); +} + +async function updateProxyAccountsList(provider: ProxyProvider, accountId: string, credPath: string): Promise { + const settings = await loadProxySettingsFromDisk(); + const newAccount: ProxyAccount = { + id: accountId, + provider, + isHealthy: true, + isDisabled: false, + lastUsed: Date.now(), + usageCount: 0, + errorCount: 0, + }; + const existing = (settings.accounts ?? []).findIndex((a: ProxyAccount) => a.id === accountId); + if (existing >= 0) { + settings.accounts[existing] = newAccount; + } else { + settings.accounts = [...(settings.accounts ?? []), newAccount]; + } + await saveProxySettingsToDisk(settings); + // Notify renderer that accounts changed + mainWindow?.webContents.send('proxy-accounts-updated', settings.accounts); +} + +// ─── HTML-escape helper to prevent XSS in callback page ────────────────────── +function escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// ─── HTML page shown in browser after OAuth callback ───────────────────────── +function buildCallbackPage(isSuccess: boolean, message: string, provider: string): string { + const title = isSuccess ? '✅ Connected!' : '❌ Connection Failed'; + const bgColor = isSuccess ? '#0f172a' : '#1a0a0a'; + const borderColor = isSuccess ? '#6d28d9' : '#dc2626'; + const textColor = isSuccess ? '#a78bfa' : '#f87171'; + const instruction = isSuccess + ? 'You can close this tab and return to Null Library.' + : 'Please close this tab and try again in Null Library.'; + + // Escape all user-provided values before embedding in HTML + const safeProvider = escapeHtml(provider); + const safeMessage = escapeHtml(message); + + return ` + + + +${isSuccess ? 'Connected!' : 'Connection Failed'} + + + +
+ +

${title}

+

${safeProvider}

+

${safeMessage}

+

${instruction}

+
+ + +`; +} + + + + diff --git a/electron/preload.ts b/electron/preload.ts index 45bc5c3..fc562b5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron'; -import { KdpAutomationPayload, BotUpdate } from '../types'; +import { KdpAutomationPayload, BotUpdate, ProxyProvider, ProxyAccount, ProxySettings, OAuthFlowStatus } from '../types'; contextBridge.exposeInMainWorld('electronAPI', { minimize: () => ipcRenderer.invoke('window-control', 'minimize'), @@ -24,4 +24,35 @@ contextBridge.exposeInMainWorld('electronAPI', { fetchGoogleTrends: (keyword: string) => ipcRenderer.invoke('market-research:trends', keyword), fetchAmazonCompetitors: (keyword: string) => ipcRenderer.invoke('market-research:competitors', keyword), fetchAmazonSuggestions: (keyword: string) => ipcRenderer.invoke('market-research:suggestions', keyword), + + // ── NullProxy OAuth ── + oauthStart: (provider: ProxyProvider) => ipcRenderer.invoke('oauth:start', provider), + oauthCancel: (provider: ProxyProvider) => ipcRenderer.invoke('oauth:cancel', provider), + onOAuthStatus: (callback: (status: OAuthFlowStatus) => void) => { + const subscription = (_event: any, value: OAuthFlowStatus) => callback(value); + ipcRenderer.on('oauth-status', subscription); + return () => { + ipcRenderer.removeListener('oauth-status', subscription); + }; + }, + + // ── NullProxy Proxy Operations ── + proxyGetStatus: (): Promise => ipcRenderer.invoke('proxy:getStatus'), + proxyAddAccount: (provider: ProxyProvider) => ipcRenderer.invoke('proxy:addAccount', provider), + proxyRemoveAccount: (accountId: string) => ipcRenderer.invoke('proxy:removeAccount', accountId), + proxyGetSettings: (): Promise => ipcRenderer.invoke('proxy:getSettings'), + proxySaveSettings: (settings: ProxySettings) => ipcRenderer.invoke('proxy:saveSettings', settings), + proxyGetCredPath: (provider: ProxyProvider, accountId: string) => + ipcRenderer.invoke('proxy:getCredPath', provider, accountId), + readCredFile: (filePath: string) => ipcRenderer.invoke('proxy:readCredFile', filePath), + + // Subscribe to proxy accounts being updated (after an OAuth login) + onProxyAccountsUpdated: (callback: (accounts: ProxyAccount[]) => void) => { + const subscription = (_event: any, value: ProxyAccount[]) => callback(value); + ipcRenderer.on('proxy-accounts-updated', subscription); + return () => { + ipcRenderer.removeListener('proxy-accounts-updated', subscription); + }; + }, }); + diff --git a/index.html b/index.html index 99a294e..1de3c3b 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - FraudRob's AI Book Factory + Null Library diff --git a/metadata.json b/metadata.json index baa38b3..850e94d 100644 --- a/metadata.json +++ b/metadata.json @@ -1,5 +1,5 @@ { - "name": "FraudRob's AI Book Factory", - "description": "An AI-powered multi-agent application that guides users through the entire process of creating a high-quality, marketable ebook for Amazon KDP, from trend research and outlining to content and graphics generation.", + "name": "Null Library", + "description": "Null Library: The Art of Infinite Production — AI-powered multi-agent publishing platform for infinite book creation.", "requestFramePermissions": [] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9a314d1..8479bd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "fraudrobs-ai-book-factory", - "version": "2.1.0", + "name": "null-library", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "fraudrobs-ai-book-factory", - "version": "2.1.0", + "name": "null-library", + "version": "3.0.0", "dependencies": { "@google/genai": "^1.28.0", "@langchain/core": "^1.1.8", diff --git a/package.json b/package.json index 5174e8b..3298a42 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "fraudrobs-ai-book-factory", + "name": "null-library", "private": true, - "version": "2.1.0", - "description": "AI-Powered Book Generation Platform - Hardened & Optimized with Local GPT4All LLM", + "version": "3.0.0", + "description": "Null Library: The Art of Infinite Production", "scripts": { "dev": "vite", "build": "vite build", diff --git a/services/aiService.ts b/services/aiService.ts new file mode 100644 index 0000000..e26c1c6 --- /dev/null +++ b/services/aiService.ts @@ -0,0 +1,234 @@ +/** + * aiService.ts — Unified AI Client (NullProxy Engine) + * + * This is the single entry point for all AI calls in Null Library. + * + * FALLBACK CHAIN (tried in order): + * 1. NullProxy OAuth accounts (free, browser-spoofed, no API key needed) + * 2. Manual API keys (user-entered in Settings) + * 3. Free Google Gemini (via VITE_GOOGLE_API_KEY env var or google login) + * + * Usage: + * import * as aiService from './aiService'; + * const text = await aiService.generateText('creative-writing', prompt); + */ + +/// + +import { GoogleGenAI, GenerateContentResponse, Type } from '@google/genai'; +import { TaskType } from '../types'; +import { + selectProxyForTask, + callGeminiViaProxy, + callClaudeViaProxy, + callOpenAIViaProxy, + markAccountUnhealthy, + recordAccountUsage, +} from './nullProxyService'; +import { loadProxySettings } from './nullProxyService'; + +// ─── Gemini direct client (fallback) ───────────────────────────────────────── + +let _geminiInstance: GoogleGenAI | null = null; + +function getGeminiClient(): GoogleGenAI { + if (!_geminiInstance) { + const key = import.meta.env.VITE_GOOGLE_API_KEY || (typeof process !== 'undefined' ? process.env?.API_KEY : undefined); + if (!key) { + throw new Error( + 'No API key configured. Please connect an AI account in Settings, or add a VITE_GOOGLE_API_KEY to your .env file.' + ); + } + _geminiInstance = new GoogleGenAI({ apiKey: key }); + } + return _geminiInstance; +} + +/** Reset the Gemini client (e.g. after key change in settings). */ +export function resetGeminiClient(): void { + _geminiInstance = null; +} + +// ─── Retry helper ───────────────────────────────────────────────────────────── + +async function withRetry(fn: () => Promise, retries = 3): Promise { + for (let i = 0; i < retries; i++) { + try { + return await fn(); + } catch (error) { + if (i === retries - 1) throw error; + await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, i))); + } + } + throw new Error('Max retries reached'); +} + +// ─── Core text generation ───────────────────────────────────────────────────── + +export interface GenerateOptions { + systemPrompt?: string; + responseMimeType?: 'application/json' | 'text/plain'; + responseSchema?: any; + temperature?: number; + maxTokens?: number; +} + +/** + * Generate text using the best available AI for the given task type. + * + * Tries the NullProxy Engine first, then falls back to manual API keys, + * then to the free Gemini client. + */ +export async function generateText( + taskType: TaskType, + prompt: string, + options: GenerateOptions = {} +): Promise { + const settings = await loadProxySettings(); + + // ── Tier 1: NullProxy Engine ────────────────────────────────────────────── + if (settings.enabled) { + try { + const selection = await selectProxyForTask(taskType); + if (selection) { + const result = await withRetry(async () => { + let response: any; + + if (selection.provider.startsWith('gemini')) { + // Convert simple text prompt to Gemini content format + const contents = [{ role: 'user', parts: [{ text: prompt }] }]; + const config: Record = {}; + if (options.responseMimeType) config.responseMimeType = options.responseMimeType; + if (options.responseSchema) config.responseSchema = options.responseSchema; + if (options.temperature !== undefined) config.temperature = options.temperature; + response = await callGeminiViaProxy(selection, contents, config); + return response?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''; + + } else if (selection.provider === 'claude-kiro') { + const messages = [{ role: 'user', content: prompt }]; + response = await callClaudeViaProxy( + selection, + messages, + options.systemPrompt, + options.maxTokens ?? 8096 + ); + return response?.content?.[0]?.text ?? ''; + + } else { + // OpenAI-compatible providers + const messages: { role: string; content: string }[] = []; + if (options.systemPrompt) { + messages.push({ role: 'system', content: options.systemPrompt }); + } + messages.push({ role: 'user', content: prompt }); + response = await callOpenAIViaProxy(selection, messages, { + temperature: options.temperature, + responseFormat: options.responseMimeType === 'application/json' ? 'json' : undefined, + }); + return response?.choices?.[0]?.message?.content ?? ''; + } + }); + + await recordAccountUsage(selection.account.id); + return result; + } + } catch (proxyError) { + console.warn('[NullProxy] Proxy attempt failed, falling back:', proxyError); + // If we had a selection, mark the account as potentially unhealthy + try { + const sel = await selectProxyForTask(taskType); + if (sel) await markAccountUnhealthy(sel.account.id); + } catch { + // ignore + } + } + } + + // ── Tier 2 & 3: Manual API key / Free Gemini ────────────────────────────── + return withRetry(async () => { + const ai = getGeminiClient(); + + const model = taskType === 'critique' ? 'gemini-2.5-pro' : 'gemini-2.5-flash'; + + const genConfig: Record = {}; + if (options.responseMimeType) genConfig.responseMimeType = options.responseMimeType; + if (options.responseSchema) genConfig.responseSchema = options.responseSchema; + + let contents: any = prompt; + if (options.systemPrompt) { + contents = `${options.systemPrompt}\n\n${prompt}`; + } + + const response = await ai.models.generateContent({ + model, + contents, + config: genConfig, + } as any); + + return (response as any).text ?? ''; + }); +} + +/** + * Generate structured JSON using the best available AI. + * Convenience wrapper around generateText with JSON mode. + */ +export async function generateJSON( + taskType: TaskType, + prompt: string, + schema?: any +): Promise { + const text = await generateText(taskType, prompt, { + responseMimeType: 'application/json', + responseSchema: schema, + }); + + try { + // Strip markdown code fences if present + const clean = text.replace(/^```json\s*/i, '').replace(/```\s*$/, '').trim(); + return JSON.parse(clean) as T; + } catch { + throw new Error(`[aiService] Failed to parse JSON response: ${text.substring(0, 200)}`); + } +} + +// ─── Re-export the legacy Gemini service functions for backward compatibility ── +// This allows existing code that imports from geminiService to continue working +// through the new routing layer. + +export { Type }; + +export function getAi(): GoogleGenAI { + return getGeminiClient(); +} + +// ─── Direct Gemini call (for functions that need the full response object) ──── + +export async function generateContentDirect( + model: string, + contents: any, + config?: any +): Promise { + // Try proxy first for Gemini models + const taskType: TaskType = 'general'; + const settings = await loadProxySettings(); + + if (settings.enabled) { + try { + const selection = await selectProxyForTask(taskType); + if (selection && selection.provider.startsWith('gemini')) { + const contentsArray = typeof contents === 'string' + ? [{ role: 'user', parts: [{ text: contents }] }] + : contents; + const result = await callGeminiViaProxy(selection, contentsArray, config); + await recordAccountUsage(selection.account.id); + return result as GenerateContentResponse; + } + } catch { + // fall through to direct + } + } + + const ai = getGeminiClient(); + return ai.models.generateContent({ model, contents, config } as any) as Promise; +} diff --git a/services/desktopBridge.ts b/services/desktopBridge.ts index 2e0ab48..dcca5b3 100644 --- a/services/desktopBridge.ts +++ b/services/desktopBridge.ts @@ -1,7 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { open, save } from '@tauri-apps/plugin-dialog'; -import { BotUpdate, GoogleTrendsData, KdpAutomationPayload } from '../types'; +import { BotUpdate, GoogleTrendsData, KdpAutomationPayload, ProxyProvider, ProxyAccount, ProxySettings, OAuthFlowStatus } from '../types'; type SaveResult = { success: boolean; filePath?: string; error?: string }; type LoadResult = { success: boolean; data?: string; error?: string }; @@ -22,6 +22,19 @@ export interface DesktopBridge { fetchGoogleTrends: (keyword: string) => Promise; fetchAmazonCompetitors: (keyword: string) => Promise; fetchAmazonSuggestions: (keyword: string) => Promise; + + // ── NullProxy OAuth ── + oauthStart: (provider: ProxyProvider) => Promise<{ success: boolean; error?: string }>; + oauthCancel: (provider: ProxyProvider) => Promise; + onOAuthStatus: (callback: (status: OAuthFlowStatus) => void) => () => void; + + // ── NullProxy Proxy Operations ── + proxyGetStatus: () => Promise; + proxyAddAccount: (provider: ProxyProvider) => Promise<{ success: boolean; error?: string }>; + proxyRemoveAccount: (accountId: string) => Promise; + proxyGetSettings: () => Promise; + proxySaveSettings: (settings: ProxySettings) => Promise; + proxyGetCredPath: (provider: ProxyProvider, accountId: string) => Promise; } const isElectron = () => typeof window !== 'undefined' && !!window.electronAPI; @@ -47,6 +60,17 @@ const browserBridge: DesktopBridge = { fetchGoogleTrends: async () => null, fetchAmazonCompetitors: async () => [], fetchAmazonSuggestions: async () => [], + + oauthStart: async () => ({ success: false, error: 'OAuth login requires the desktop app.' }), + oauthCancel: async () => {}, + onOAuthStatus: () => () => {}, + + proxyGetStatus: async () => [], + proxyAddAccount: async () => ({ success: false, error: 'Proxy management requires the desktop app.' }), + proxyRemoveAccount: async () => {}, + proxyGetSettings: async () => null, + proxySaveSettings: async () => {}, + proxyGetCredPath: async () => null, }; const electronBridge: DesktopBridge = { @@ -65,6 +89,17 @@ const electronBridge: DesktopBridge = { fetchGoogleTrends: async (keyword) => window.electronAPI?.fetchGoogleTrends?.(keyword) || null, fetchAmazonCompetitors: async (keyword) => window.electronAPI?.fetchAmazonCompetitors?.(keyword) || [], fetchAmazonSuggestions: async (keyword) => window.electronAPI?.fetchAmazonSuggestions?.(keyword) || [], + + oauthStart: async (provider) => window.electronAPI?.oauthStart?.(provider) || { success: false }, + oauthCancel: async (provider) => window.electronAPI?.oauthCancel?.(provider), + onOAuthStatus: (callback) => window.electronAPI?.onOAuthStatus?.(callback) || (() => {}), + + proxyGetStatus: async () => window.electronAPI?.proxyGetStatus?.() || [], + proxyAddAccount: async (provider) => window.electronAPI?.proxyAddAccount?.(provider) || { success: false }, + proxyRemoveAccount: async (id) => window.electronAPI?.proxyRemoveAccount?.(id), + proxyGetSettings: async () => window.electronAPI?.proxyGetSettings?.() || null, + proxySaveSettings: async (settings) => window.electronAPI?.proxySaveSettings?.(settings), + proxyGetCredPath: async (provider, accountId) => window.electronAPI?.proxyGetCredPath?.(provider, accountId) || null, }; const tauriBridge: DesktopBridge = { @@ -139,8 +174,20 @@ const tauriBridge: DesktopBridge = { return []; } }, + + // Tauri doesn't implement OAuth proxy — falls back gracefully + oauthStart: async () => ({ success: false, error: 'OAuth proxy not supported in Tauri mode.' }), + oauthCancel: async () => {}, + onOAuthStatus: () => () => {}, + proxyGetStatus: async () => [], + proxyAddAccount: async () => ({ success: false, error: 'Not supported in Tauri mode.' }), + proxyRemoveAccount: async () => {}, + proxyGetSettings: async () => null, + proxySaveSettings: async () => {}, + proxyGetCredPath: async () => null, }; const desktopBridge: DesktopBridge = isElectron() ? electronBridge : isTauri() ? tauriBridge : browserBridge; export default desktopBridge; + diff --git a/services/nullProxyService.ts b/services/nullProxyService.ts new file mode 100644 index 0000000..c8eb04c --- /dev/null +++ b/services/nullProxyService.ts @@ -0,0 +1,445 @@ +/** + * NullProxy Engine — nullProxyService.ts + * + * ═══════════════════════════════════════════════════════════════════════════ + * WHAT IS THE NULLPROXY ENGINE? (Plain English) + * ═══════════════════════════════════════════════════════════════════════════ + * + * Instead of needing expensive paid API keys to use AI, this system logs + * into AI tools the same way any normal user would — through the real browser + * login pages (Google, Anthropic, OpenAI, etc.). + * + * Once you log in, it saves the resulting "session token" (a temporary + * digital ID card) to your local machine. When the app needs AI, it uses + * that saved token to make requests that look exactly like a logged-in user + * browsing the web — bypassing the need for an API key entirely. + * + * HOW: + * 1. A tiny local web server opens briefly to catch the login callback. + * 2. Your real browser opens to the provider's login page (Google, etc.). + * 3. You log in normally. The callback token is captured automatically. + * 4. The token is stored securely in your app's local data folder. + * 5. Every AI call routes through that token going forward. + * + * WHY: + * - No API costs for casual use (these are free-tier user accounts). + * - Add multiple accounts per provider → spread load so no single account + * hits rate limits (round-robin load balancing). + * - Smart routing picks the best model for each type of task. + * - If a token expires or fails, the app automatically falls back to your + * manually entered API key, and then to free Google Gemini as last resort. + * + * FAILSAFE CHAIN: + * OAuth Proxy → Manual API Key → Free Google Account (Gemini) + * + * ═══════════════════════════════════════════════════════════════════════════ + */ + +import { ProxyProvider, ProxyAccount, ProxySettings, TaskType } from '../types'; +import desktopBridge from './desktopBridge'; + +// ─── Default task-to-provider routing table ────────────────────────────────── +// Maps each task type to an ordered list of preferred providers. +// The engine tries them in order, picking the first healthy one. + +export const DEFAULT_TASK_ROUTING: Record = { + 'creative-writing': ['gemini-cli', 'gemini-antigravity', 'openai-codex'], + 'market-research': ['gemini-cli', 'gemini-antigravity', 'claude-kiro'], + 'marketing-copy': ['claude-kiro', 'openai-codex', 'gemini-cli'], + 'image-prompt': ['gemini-cli', 'gemini-antigravity'], + 'critique': ['claude-kiro', 'gemini-cli', 'openai-codex'], + 'general': ['gemini-cli', 'gemini-antigravity', 'claude-kiro', 'openai-codex'], +}; + +// ─── Default provider priority order ──────────────────────────────────────── +export const DEFAULT_PROVIDER_PRIORITY: ProxyProvider[] = [ + 'gemini-cli', + 'gemini-antigravity', + 'claude-kiro', + 'openai-codex', + 'openai-qwen', + 'openai-iflow', +]; + +// ─── Default proxy settings ────────────────────────────────────────────────── +export const DEFAULT_PROXY_SETTINGS: ProxySettings = { + enabled: true, + roundRobinEnabled: true, + providerPriority: DEFAULT_PROVIDER_PRIORITY, + taskRouting: DEFAULT_TASK_ROUTING, + failsafeEnabled: true, + manualApiKey: undefined, + manualClaudeApiKey: undefined, + manualOpenAiApiKey: undefined, + accounts: [], +}; + +// ─── Round-robin state (in-memory per session) ─────────────────────────────── +const roundRobinIndexes: Record = {}; + +// ─── Settings cache ────────────────────────────────────────────────────────── +let _settingsCache: ProxySettings | null = null; + +/** + * Load proxy settings from disk (via Electron IPC), with in-memory cache. + */ +export async function loadProxySettings(): Promise { + if (_settingsCache) return _settingsCache; + try { + const saved = await desktopBridge.proxyGetSettings(); + _settingsCache = saved + ? { ...DEFAULT_PROXY_SETTINGS, ...saved } + : { ...DEFAULT_PROXY_SETTINGS }; + } catch { + _settingsCache = { ...DEFAULT_PROXY_SETTINGS }; + } + return _settingsCache; +} + +/** + * Save proxy settings to disk and update cache. + */ +export async function saveProxySettings(settings: ProxySettings): Promise { + _settingsCache = settings; + try { + await desktopBridge.proxySaveSettings(settings); + } catch (e) { + console.error('[NullProxy] Failed to persist settings:', e); + } +} + +/** + * Invalidate the in-memory settings cache (call after adding/removing accounts). + */ +export function invalidateSettingsCache(): void { + _settingsCache = null; +} + +// ─── Account selection ─────────────────────────────────────────────────────── + +/** + * Get all healthy, enabled accounts for a given provider. + */ +function getHealthyAccounts(settings: ProxySettings, provider: ProxyProvider): ProxyAccount[] { + return settings.accounts.filter( + (a) => a.provider === provider && a.isHealthy && !a.isDisabled + ); +} + +/** + * Pick the next account for a provider using round-robin rotation. + * Returns null if no healthy accounts are available. + */ +export function selectAccount( + settings: ProxySettings, + provider: ProxyProvider +): ProxyAccount | null { + const accounts = getHealthyAccounts(settings, provider); + if (accounts.length === 0) return null; + if (!settings.roundRobinEnabled) return accounts[0]; + + const key = provider; + const idx = (roundRobinIndexes[key] ?? 0) % accounts.length; + roundRobinIndexes[key] = idx + 1; + return accounts[idx]; +} + +// ─── Provider-to-API-base-URL mapping ──────────────────────────────────────── +export const PROVIDER_API_BASE: Record = { + 'gemini-cli': 'https://generativelanguage.googleapis.com/v1beta', + 'gemini-antigravity': 'https://generativelanguage.googleapis.com/v1beta', + 'claude-kiro': 'https://api.anthropic.com/v1', + 'openai-codex': 'https://api.openai.com/v1', + 'openai-qwen': 'https://api.openai.com/v1', + 'openai-iflow': 'https://api.openai.com/v1', +}; + +// Best model to use per provider for each task type +export const PROVIDER_MODELS: Record> = { + 'gemini-cli': { + 'creative-writing': 'gemini-2.5-flash', + 'market-research': 'gemini-2.5-flash', + 'marketing-copy': 'gemini-2.5-flash', + 'image-prompt': 'gemini-2.5-flash', + 'critique': 'gemini-2.5-flash', + 'general': 'gemini-2.5-flash', + }, + 'gemini-antigravity': { + 'creative-writing': 'gemini-2.5-flash', + 'market-research': 'gemini-2.5-flash', + 'marketing-copy': 'gemini-2.5-flash', + 'image-prompt': 'gemini-2.5-flash', + 'critique': 'gemini-2.5-flash', + 'general': 'gemini-2.5-flash', + }, + 'claude-kiro': { + 'creative-writing': 'claude-3-5-haiku-20241022', + 'market-research': 'claude-3-5-haiku-20241022', + 'marketing-copy': 'claude-3-7-sonnet-20250219', + 'image-prompt': 'claude-3-5-haiku-20241022', + 'critique': 'claude-3-7-sonnet-20250219', + 'general': 'claude-3-5-haiku-20241022', + }, + 'openai-codex': { + 'creative-writing': 'gpt-4o-mini', + 'market-research': 'gpt-4o-mini', + 'marketing-copy': 'gpt-4o-mini', + 'image-prompt': 'gpt-4o-mini', + 'critique': 'gpt-4o', + 'general': 'gpt-4o-mini', + }, + 'openai-qwen': { + 'creative-writing': 'qwen3-coder-flash', + 'market-research': 'qwen3-coder-flash', + 'marketing-copy': 'qwen3-coder-flash', + 'image-prompt': 'qwen3-coder-flash', + 'critique': 'qwen3-coder-flash', + 'general': 'qwen3-coder-flash', + }, + 'openai-iflow': { + 'creative-writing': 'qwen3-coder-plus', + 'market-research': 'qwen3-coder-plus', + 'marketing-copy': 'qwen3-coder-plus', + 'image-prompt': 'qwen3-coder-plus', + 'critique': 'qwen3-coder-plus', + 'general': 'qwen3-coder-plus', + }, +}; + +// ─── Core routing logic ─────────────────────────────────────────────────────── + +export interface ProxySelection { + /** Which provider was chosen */ + provider: ProxyProvider; + /** The account to use */ + account: ProxyAccount; + /** Which model to call on that provider */ + model: string; + /** Base URL for the provider's API */ + baseUrl: string; + /** Authorization header value (Bearer ) */ + authHeader: string; +} + +/** + * Find the best available proxy selection for a given task type. + * + * Tries each provider in the task's routing order (respecting the user's + * priority override), picks the first healthy account via round-robin. + * + * Returns null if no proxy accounts are available for this task. + */ +export async function selectProxyForTask( + taskType: TaskType +): Promise { + const settings = await loadProxySettings(); + if (!settings.enabled) return null; + + const preferredProviders = settings.taskRouting[taskType] ?? DEFAULT_TASK_ROUTING[taskType]; + + // Apply user's provider priority ordering as a secondary sort + const priorityMap = Object.fromEntries( + settings.providerPriority.map((p, i) => [p, i]) + ); + const orderedProviders = [...preferredProviders].sort( + (a, b) => (priorityMap[a] ?? 99) - (priorityMap[b] ?? 99) + ); + + for (const provider of orderedProviders) { + const account = selectAccount(settings, provider); + if (!account) continue; + + // Get the stored OAuth token for this account + const credPath = await desktopBridge.proxyGetCredPath(provider, account.id); + const token = credPath ? await readTokenFromCredPath(credPath, provider) : null; + if (!token) continue; + + return { + provider, + account, + model: PROVIDER_MODELS[provider][taskType], + baseUrl: PROVIDER_API_BASE[provider], + authHeader: `Bearer ${token}`, + }; + } + + return null; +} + +/** + * Read the access token from a credential file path. + * Delegates to Electron main process for filesystem access. + * In browser mode this always returns null (no filesystem access). + */ +async function readTokenFromCredPath( + credPath: string, + provider: ProxyProvider +): Promise { + try { + // In Electron, use IPC to read the file from the main process + if (typeof window !== 'undefined' && window.electronAPI) { + const result = await (window as any).electronAPI.readCredFile?.(credPath); + if (!result) return null; + const creds = typeof result === 'string' ? JSON.parse(result) : result; + // Different providers use different field names for the access token + return ( + creds.access_token || + creds.accessToken || + creds.token || + creds.userAccessToken || + null + ); + } + } catch (e) { + console.warn(`[NullProxy] Failed to read token for ${provider}:`, e); + } + return null; +} + +// ─── Gemini-specific proxy call ─────────────────────────────────────────────── + +/** + * Make a Gemini API call using an OAuth access token instead of an API key. + * This is the core "spoofing" mechanism for Gemini providers. + */ +export async function callGeminiViaProxy( + selection: ProxySelection, + contents: any[], + config?: Record +): Promise { + const url = `${selection.baseUrl}/models/${selection.model}:generateContent`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': selection.authHeader, + }, + body: JSON.stringify({ contents, generationConfig: config }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`[NullProxy/Gemini] ${response.status}: ${err}`); + } + + return response.json(); +} + +/** + * Make a Claude API call via the Kiro OAuth token. + */ +export async function callClaudeViaProxy( + selection: ProxySelection, + messages: any[], + systemPrompt?: string, + maxTokens = 8096 +): Promise { + const url = `${selection.baseUrl}/messages`; + const body: Record = { + model: selection.model, + max_tokens: maxTokens, + messages, + }; + if (systemPrompt) body.system = systemPrompt; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': selection.authHeader.replace('Bearer ', ''), + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`[NullProxy/Claude] ${response.status}: ${err}`); + } + + return response.json(); +} + +/** + * Make an OpenAI-compatible API call via an OAuth token + * (works for Codex/GPT, Qwen, iFlow providers). + */ +export async function callOpenAIViaProxy( + selection: ProxySelection, + messages: { role: string; content: string }[], + options?: { temperature?: number; responseFormat?: 'json' } +): Promise { + const url = `${selection.baseUrl}/chat/completions`; + const body: Record = { + model: selection.model, + messages, + }; + if (options?.temperature !== undefined) body.temperature = options.temperature; + if (options?.responseFormat === 'json') { + body.response_format = { type: 'json_object' }; + } + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': selection.authHeader, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`[NullProxy/OpenAI] ${response.status}: ${err}`); + } + + return response.json(); +} + +// ─── Mark account as unhealthy after failure ────────────────────────────────── +export async function markAccountUnhealthy(accountId: string): Promise { + const settings = await loadProxySettings(); + const updated: ProxySettings = { + ...settings, + accounts: settings.accounts.map((a) => + a.id === accountId + ? { ...a, errorCount: a.errorCount + 1, isHealthy: a.errorCount + 1 >= 3 ? false : a.isHealthy } + : a + ), + }; + await saveProxySettings(updated); +} + +// ─── Update usage stats for an account ─────────────────────────────────────── +export async function recordAccountUsage(accountId: string): Promise { + const settings = await loadProxySettings(); + const updated: ProxySettings = { + ...settings, + accounts: settings.accounts.map((a) => + a.id === accountId + ? { ...a, usageCount: a.usageCount + 1, lastUsed: Date.now() } + : a + ), + }; + await saveProxySettings(updated); +} + +// ─── Get human-readable provider display names ──────────────────────────────── +export const PROVIDER_DISPLAY_NAMES: Record = { + 'gemini-cli': 'Google Gemini (CLI OAuth)', + 'gemini-antigravity': 'Google Gemini (Antigravity)', + 'claude-kiro': 'Anthropic Claude (Kiro IDE)', + 'openai-codex': 'OpenAI GPT (Codex OAuth)', + 'openai-qwen': 'Qwen AI (QwenCoder)', + 'openai-iflow': 'Qwen Plus (iFlow)', +}; + +// ─── Get provider login URL descriptions ───────────────────────────────────── +export const PROVIDER_LOGIN_DESCRIPTIONS: Record = { + 'gemini-cli': 'Log in with your Google account to access Gemini 2.5 Flash for free.', + 'gemini-antigravity': 'Connect a second Google account for Gemini load balancing.', + 'claude-kiro': 'Log in to Kiro IDE with your account to access Claude Sonnet & Haiku.', + 'openai-codex': 'Connect via GitHub/Microsoft account to access GPT-4o mini for free.', + 'openai-qwen': 'Log in with Alibaba/Qwen account for structured AI tasks.', + 'openai-iflow': 'Connect iFlow account for Qwen Plus access (backup).', +}; diff --git a/services/oauthSetupService.ts b/services/oauthSetupService.ts new file mode 100644 index 0000000..3866923 --- /dev/null +++ b/services/oauthSetupService.ts @@ -0,0 +1,191 @@ +/** + * oauthSetupService.ts — OAuth Browser Login Flow Bridge + * + * Orchestrates the browser-based OAuth login process for each AI provider. + * Communicates with the Electron main process (which runs a local HTTP callback + * server and opens the browser) via IPC through desktopBridge. + * + * In browser-only mode, this service gracefully reports that OAuth is + * unavailable and suggests using manual API keys instead. + */ + +import { ProxyProvider, OAuthFlowStatus, ProxyAccount, ProxySettings } from '../types'; +import desktopBridge from './desktopBridge'; +import { + loadProxySettings, + saveProxySettings, + invalidateSettingsCache, + PROVIDER_DISPLAY_NAMES, +} from './nullProxyService'; + +/** Whether the app is running in Electron (full OAuth support). */ +export const isElectronRuntime = (): boolean => + typeof window !== 'undefined' && !!window.electronAPI; + +/** + * Start an OAuth login flow for a given provider. + * + * This opens the provider's login page in the default browser. + * A local HTTP server (managed by the Electron main process) listens + * for the OAuth callback redirect and captures the auth code/token. + * + * @param provider - The AI provider to authenticate with + * @param onStatus - Progress callback for UI updates + * @returns The new ProxyAccount if successful, or null on failure + */ +export async function startOAuthFlow( + provider: ProxyProvider, + onStatus: (status: OAuthFlowStatus) => void +): Promise { + if (!isElectronRuntime()) { + onStatus({ + provider, + phase: 'error', + message: 'OAuth login requires the Null Library desktop app.', + error: 'Not running in Electron.', + }); + return null; + } + + onStatus({ + provider, + phase: 'starting', + message: `Opening ${PROVIDER_DISPLAY_NAMES[provider]} login page…`, + }); + + // Subscribe to status updates from the main process + let unsubscribe: (() => void) | null = null; + const statusPromise = new Promise((resolve) => { + unsubscribe = desktopBridge.onOAuthStatus((status) => { + if (status.provider === provider) { + onStatus(status); + if (status.phase === 'done' || status.phase === 'error') { + resolve(); + } + } + }); + }); + + try { + const result = await desktopBridge.oauthStart(provider); + if (!result.success) { + onStatus({ + provider, + phase: 'error', + message: `Failed to start OAuth flow: ${result.error ?? 'Unknown error'}`, + error: result.error, + }); + return null; + } + + // Wait for the flow to complete (browser login + callback capture) + await statusPromise; + + // Reload accounts from main process + invalidateSettingsCache(); + const accounts = await desktopBridge.proxyGetStatus(); + const newAccount = accounts + .filter((a) => a.provider === provider) + .sort((a, b) => (b.lastUsed ?? 0) - (a.lastUsed ?? 0))[0]; + + if (newAccount) { + // Persist to settings + const settings = await loadProxySettings(); + const existingIds = new Set(settings.accounts.map((a) => a.id)); + const updatedAccounts = existingIds.has(newAccount.id) + ? settings.accounts.map((a) => (a.id === newAccount.id ? newAccount : a)) + : [...settings.accounts, newAccount]; + + await saveProxySettings({ ...settings, accounts: updatedAccounts }); + return newAccount; + } + + return null; + } finally { + unsubscribe?.(); + } +} + +/** + * Cancel an in-progress OAuth flow. + */ +export async function cancelOAuthFlow(provider: ProxyProvider): Promise { + await desktopBridge.oauthCancel(provider); +} + +/** + * Remove a proxy account and persist the updated settings. + */ +export async function removeAccount(accountId: string): Promise { + await desktopBridge.proxyRemoveAccount(accountId); + invalidateSettingsCache(); + const settings = await loadProxySettings(); + const updated: ProxySettings = { + ...settings, + accounts: settings.accounts.filter((a) => a.id !== accountId), + }; + await saveProxySettings(updated); +} + +/** + * Get a summary of all connected accounts grouped by provider. + */ +export async function getConnectedAccountsSummary(): Promise< + Record +> { + const settings = await loadProxySettings(); + const summary: Record = {}; + + for (const account of settings.accounts) { + if (!summary[account.provider]) { + summary[account.provider] = { count: 0, healthy: 0 }; + } + summary[account.provider].count++; + if (account.isHealthy && !account.isDisabled) { + summary[account.provider].healthy++; + } + } + + return summary as Record; +} + +/** + * Check whether any proxy accounts are connected and healthy. + */ +export async function hasAnyHealthyAccount(): Promise { + const settings = await loadProxySettings(); + return settings.accounts.some((a) => a.isHealthy && !a.isDisabled); +} + +/** + * Check whether the wizard has been completed before (first-run detection). + */ +export function isFirstRun(): boolean { + try { + return !localStorage.getItem('null-library-wizard-complete'); + } catch { + return true; + } +} + +/** + * Mark the setup wizard as completed so it doesn't show again on next launch. + */ +export function markWizardComplete(): void { + try { + localStorage.setItem('null-library-wizard-complete', '1'); + } catch { + // localStorage unavailable — no-op + } +} + +/** + * Reset the wizard completion state (for testing or re-running setup). + */ +export function resetWizardState(): void { + try { + localStorage.removeItem('null-library-wizard-complete'); + } catch { + // no-op + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1947af4..b4f6980 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", - "productName": "KDP E-Book Generator", - "version": "0.1.0", - "identifier": "com.tauri.dev", + "productName": "Null Library", + "version": "3.0.0", + "identifier": "com.nulllibrary.app", "build": { "frontendDist": "../dist", "devUrl": "http://localhost:5173", @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "KDP E-Book Generator", + "title": "Null Library", "width": 800, "height": 600, "resizable": true, diff --git a/types.ts b/types.ts index 724be03..78ff8b9 100644 --- a/types.ts +++ b/types.ts @@ -132,6 +132,76 @@ export interface KdpAutomationPayload { export type BotStatus = 'initializing' | 'running' | 'captcha' | 'uploading' | 'success' | 'error'; +// ─── NullProxy Engine Types ─────────────────────────────────────────────────── + +/** + * The AI providers Null Library can connect to via OAuth proxy spoofing. + * Each maps to a real AI backend. + */ +export type ProxyProvider = + | 'gemini-cli' // Google Gemini via Gemini-CLI OAuth (best for writing & research) + | 'gemini-antigravity' // Google Gemini via Antigravity OAuth (backup Gemini channel) + | 'claude-kiro' // Claude via Kiro IDE OAuth (best for critique & marketing copy) + | 'openai-codex' // GPT via GitHub Copilot/Codex OAuth (creative & marketing) + | 'openai-qwen' // Qwen via QwenCoder OAuth (coding tasks, structured output) + | 'openai-iflow'; // Qwen Plus via iFlow OAuth (fallback structured tasks) + +/** + * Types of AI tasks, used for smart routing to the best model. + */ +export type TaskType = + | 'creative-writing' // Long-form chapters, narrative content + | 'market-research' // Structured JSON, competitive analysis + | 'marketing-copy' // Book blurbs, KDP descriptions, ad copy + | 'image-prompt' // Illustration prompt generation + | 'critique' // Quality review, proofreading, feedback + | 'general'; // Fallback for unclassified tasks + +/** A single OAuth-authenticated account for a provider. */ +export interface ProxyAccount { + id: string; + provider: ProxyProvider; + email?: string; + displayName?: string; + isHealthy: boolean; + isDisabled: boolean; + lastUsed?: number; + tokenExpiry?: number; + usageCount: number; + errorCount: number; +} + +/** Full proxy engine configuration stored in settings. */ +export interface ProxySettings { + /** Master toggle: use proxy at all */ + enabled: boolean; + /** Rotate through multiple accounts per provider */ + roundRobinEnabled: boolean; + /** Provider preference order (first tried first) */ + providerPriority: ProxyProvider[]; + /** Which providers to use for each task type */ + taskRouting: Record; + /** Fall back to free Google account login if all else fails */ + failsafeEnabled: boolean; + /** User-supplied manual API keys (used as fallback) */ + manualApiKey?: string; + manualClaudeApiKey?: string; + manualOpenAiApiKey?: string; + /** All connected OAuth accounts */ + accounts: ProxyAccount[]; +} + +/** Status reported back to the UI during an OAuth login flow */ +export interface OAuthFlowStatus { + provider: ProxyProvider; + phase: 'starting' | 'waiting-for-browser' | 'callback-received' | 'saving' | 'done' | 'error'; + message: string; + error?: string; +} + +/** Phase of the first-run setup wizard */ +export type WizardPhase = 'welcome' | 'connect-accounts' | 'api-keys' | 'ready'; + export type BotUpdate = | { type: 'log'; message: string } | { type: 'status'; status: BotStatus } @@ -156,8 +226,30 @@ export interface ElectronAPI { // Market Research fetchGoogleTrends: (keyword: string) => Promise; - fetchAmazonCompetitors: (keyword: string) => Promise; // Using any[] to avoid circular dependency or duplication for now, strictly it's ScrapedBook[] + fetchAmazonCompetitors: (keyword: string) => Promise; fetchAmazonSuggestions: (keyword: string) => Promise; + + // ── NullProxy OAuth ── + /** Start OAuth login flow for a provider; opens browser */ + oauthStart: (provider: ProxyProvider) => Promise<{ success: boolean; error?: string }>; + /** Cancel an in-progress OAuth flow */ + oauthCancel: (provider: ProxyProvider) => Promise; + /** Subscribe to OAuth flow status updates */ + onOAuthStatus: (callback: (status: OAuthFlowStatus) => void) => () => void; + + // ── NullProxy Proxy Operations ── + /** Get current status/health of all proxy accounts */ + proxyGetStatus: () => Promise; + /** Add an additional account to a provider (starts OAuth flow) */ + proxyAddAccount: (provider: ProxyProvider) => Promise<{ success: boolean; error?: string }>; + /** Remove an account by id */ + proxyRemoveAccount: (accountId: string) => Promise; + /** Load saved proxy settings */ + proxyGetSettings: () => Promise; + /** Save updated proxy settings */ + proxySaveSettings: (settings: ProxySettings) => Promise; + /** Read a credential file path for a provider (for token refresh) */ + proxyGetCredPath: (provider: ProxyProvider, accountId: string) => Promise; } declare global {