diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..db51cb63 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,152 @@ +# CLAUDE.md - AI Coding Agent Context + +This file provides context for AI coding agents working on the Rowboat monorepo. + +## Quick Reference Commands + +```bash +# Electron App (apps/x) +cd apps/x && pnpm install # Install dependencies +cd apps/x && npm run deps # Build workspace packages (shared → core → preload) +cd apps/x && npm run dev # Development mode (builds deps, runs app) +cd apps/x && npm run lint # Lint check +cd apps/x/apps/main && npm run package # Production build (.app) +cd apps/x/apps/main && npm run make # Create DMG distributable +``` + +## Monorepo Structure + +``` +rowboat/ +├── apps/ +│ ├── x/ # Electron desktop app (focus of this doc) +│ ├── rowboat/ # Next.js web dashboard +│ ├── rowboatx/ # Next.js frontend +│ ├── cli/ # CLI tool +│ ├── python-sdk/ # Python SDK +│ └── docs/ # Documentation site +├── CLAUDE.md # This file +└── README.md # User-facing readme +``` + +## Electron App Architecture (`apps/x`) + +The Electron app is a **nested pnpm workspace** with its own package management. + +``` +apps/x/ +├── package.json # Workspace root, dev scripts +├── pnpm-workspace.yaml # Defines workspace packages +├── pnpm-lock.yaml # Lockfile +├── apps/ +│ ├── main/ # Electron main process +│ │ ├── src/ # Main process source +│ │ ├── forge.config.cjs # Electron Forge config +│ │ └── bundle.mjs # esbuild bundler +│ ├── renderer/ # React UI (Vite) +│ │ ├── src/ # React components +│ │ └── vite.config.ts +│ └── preload/ # Electron preload scripts +│ └── src/ +└── packages/ + ├── shared/ # @x/shared - Types, utilities, validators + └── core/ # @x/core - Business logic, AI, OAuth, MCP +``` + +### Build Order (Dependencies) + +``` +shared (no deps) + ↓ +core (depends on shared) + ↓ +preload (depends on shared) + ↓ +renderer (depends on shared) +main (depends on shared, core) +``` + +**The `npm run deps` command builds:** shared → core → preload + +### Key Entry Points + +| Component | Entry | Output | +|-----------|-------|--------| +| main | `apps/main/src/main.ts` | `.package/dist/main.cjs` | +| renderer | `apps/renderer/src/main.tsx` | `apps/renderer/dist/` | +| preload | `apps/preload/src/preload.ts` | `apps/preload/dist/preload.js` | + +## Build System + +- **Package manager:** pnpm (required for `workspace:*` protocol) +- **Main bundler:** esbuild (bundles to single CommonJS file) +- **Renderer bundler:** Vite +- **Packaging:** Electron Forge +- **TypeScript:** ES2022 target + +### Why esbuild bundling? + +pnpm uses symlinks for workspace packages. Electron Forge's dependency walker can't follow these symlinks. esbuild bundles everything into a single file, eliminating the need for node_modules in the packaged app. + +## Key Files Reference + +| Purpose | File | +|---------|------| +| Electron main entry | `apps/x/apps/main/src/main.ts` | +| React app entry | `apps/x/apps/renderer/src/main.tsx` | +| Forge config (packaging) | `apps/x/apps/main/forge.config.cjs` | +| Main process bundler | `apps/x/apps/main/bundle.mjs` | +| Vite config | `apps/x/apps/renderer/vite.config.ts` | +| Shared types | `apps/x/packages/shared/src/` | +| Core business logic | `apps/x/packages/core/src/` | +| Workspace config | `apps/x/pnpm-workspace.yaml` | +| Root scripts | `apps/x/package.json` | + +## Common Tasks + +### LLM configuration (single provider) +- Config file: `~/.rowboat/config/models.json` +- Schema: `{ provider: { flavor, apiKey?, baseURL?, headers? }, model: string }` +- Models catalog cache: `~/.rowboat/config/models.dev.json` (OpenAI/Anthropic/Google only) + +### Add a new shared type +1. Edit `apps/x/packages/shared/src/` +2. Run `cd apps/x && npm run deps` to rebuild + +### Modify main process +1. Edit `apps/x/apps/main/src/` +2. Restart dev server (main doesn't hot-reload) + +### Modify renderer (React UI) +1. Edit `apps/x/apps/renderer/src/` +2. Changes hot-reload automatically in dev mode + +### Add a new dependency to main +1. `cd apps/x/apps/main && pnpm add ` +2. Import in source - esbuild will bundle it + +### Verify compilation +```bash +cd apps/x && npm run deps && npm run lint +``` + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| Desktop | Electron 39.x | +| UI | React 19, Vite 7 | +| Styling | TailwindCSS, Radix UI | +| State | React hooks | +| AI | Vercel AI SDK, OpenAI/Anthropic/Google/OpenRouter providers, Vercel AI Gateway, Ollama, models.dev catalog | +| IPC | Electron contextBridge | +| Build | TypeScript 5.9, esbuild, Electron Forge | + +## Environment Variables (for packaging) + +For production builds with code signing: +- `APPLE_ID` - Apple Developer ID +- `APPLE_PASSWORD` - App-specific password +- `APPLE_TEAM_ID` - Team ID + +Not required for local development. diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index 8309e3e6..b0b890c0 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -4,6 +4,16 @@ import { URL } from 'url'; const OAUTH_CALLBACK_PATH = '/oauth/callback'; const DEFAULT_PORT = 8080; +/** Escape HTML special characters to prevent XSS */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + export interface AuthServerResult { server: Server; port: number; @@ -15,7 +25,7 @@ export interface AuthServerResult { */ export function createAuthServer( port: number = DEFAULT_PORT, - onCallback: (code: string, state: string) => void + onCallback: (code: string, state: string) => void | Promise ): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => { @@ -46,7 +56,7 @@ export function createAuthServer(

Authorization Failed

-

Error: ${error}

+

Error: ${escapeHtml(error)}

You can close this window.

@@ -55,48 +65,28 @@ export function createAuthServer( return; } - if (code && state) { - onCallback(code, state); - - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(` - - - - Authorization Successful - - - -

Authorization Successful

-

You can close this window.

- - - - `); - } else { - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end(` - - - - OAuth Error - - - -

Invalid Request

-

Missing code or state parameter.

-

You can close this window.

- - - - `); - } + // Handle callback - either traditional OAuth with code/state or Composio-style notification + // Composio callbacks may not have code/state, just a notification that the flow completed + onCallback(code || '', state || ''); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + + + Authorization Successful + + + +

Authorization Successful

+

You can close this window.

+ + + + `); } else { res.writeHead(404); res.end('Not Found'); diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts new file mode 100644 index 00000000..e5b25d1a --- /dev/null +++ b/apps/x/apps/main/src/composio-handler.ts @@ -0,0 +1,296 @@ +import { shell, BrowserWindow } from 'electron'; +import { createAuthServer } from './auth-server.js'; +import * as composioClient from '@x/core/dist/composio/client.js'; +import { composioAccountsRepo } from '@x/core/dist/composio/repo.js'; +import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js'; + +const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; + +// Store active OAuth flows +const activeFlows = new Map(); + +/** + * Emit Composio connection event to all renderer windows + */ +export function emitComposioEvent(event: { toolkitSlug: string; success: boolean; error?: string }): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('composio:didConnect', event); + } + } +} + +/** + * Check if Composio is configured with an API key + */ +export function isConfigured(): { configured: boolean } { + return { configured: composioClient.isConfigured() }; +} + +/** + * Set the Composio API key + */ +export function setApiKey(apiKey: string): { success: boolean; error?: string } { + try { + composioClient.setApiKey(apiKey); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set API key', + }; + } +} + +/** + * Initiate OAuth connection for a toolkit + */ +export async function initiateConnection(toolkitSlug: string): Promise<{ + success: boolean; + redirectUrl?: string; + connectedAccountId?: string; + error?: string; +}> { + try { + console.log(`[Composio] Initiating connection for ${toolkitSlug}...`); + + // Check if already connected + if (composioAccountsRepo.isConnected(toolkitSlug)) { + return { success: true }; + } + + // Get toolkit to check auth schemes + const toolkit = await composioClient.getToolkit(toolkitSlug); + + // Check for managed OAuth2 + if (!toolkit.composio_managed_auth_schemes.includes('OAUTH2')) { + return { + success: false, + error: `Toolkit ${toolkitSlug} does not support managed OAuth2`, + }; + } + + // Find or create managed OAuth2 auth config + const authConfigs = await composioClient.listAuthConfigs(toolkitSlug, null, true); + let authConfigId: string; + + const managedOauth2 = authConfigs.items.find( + cfg => cfg.auth_scheme === 'OAUTH2' && cfg.is_composio_managed + ); + + if (managedOauth2) { + authConfigId = managedOauth2.id; + } else { + // Create new managed auth config + const created = await composioClient.createAuthConfig({ + toolkit: { slug: toolkitSlug }, + auth_config: { + type: 'use_composio_managed_auth', + name: `rowboat-${toolkitSlug}`, + }, + }); + authConfigId = created.auth_config.id; + } + + // Create connected account with callback URL + const callbackUrl = REDIRECT_URI; + const response = await composioClient.createConnectedAccount({ + auth_config: { id: authConfigId }, + connection: { + user_id: 'rowboat-user', + callback_url: callbackUrl, + }, + }); + + const connectedAccountId = response.id; + + // Safely extract redirectUrl with type checking + const connectionVal = response.connectionData?.val; + const redirectUrl = typeof connectionVal === 'object' && connectionVal !== null && 'redirectUrl' in connectionVal + ? String((connectionVal as Record).redirectUrl) + : undefined; + + if (!redirectUrl) { + return { + success: false, + error: 'No redirect URL received from Composio', + }; + } + + // Store flow state + const flowKey = `${toolkitSlug}-${Date.now()}`; + activeFlows.set(flowKey, { + toolkitSlug, + connectedAccountId, + authConfigId, + }); + + // Save initial account state + const account: LocalConnectedAccount = { + id: connectedAccountId, + authConfigId, + status: 'INITIATED', + toolkitSlug, + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + }; + composioAccountsRepo.saveAccount(account); + + // Set up callback server + let cleanupTimeout: NodeJS.Timeout; + const { server } = await createAuthServer(8081, async (_code, _state) => { + // OAuth callback received - sync the account status + try { + const accountStatus = await composioClient.getConnectedAccount(connectedAccountId); + composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status); + + if (accountStatus.status === 'ACTIVE') { + emitComposioEvent({ toolkitSlug, success: true }); + } else { + emitComposioEvent({ + toolkitSlug, + success: false, + error: `Connection status: ${accountStatus.status}`, + }); + } + } catch (error) { + console.error('[Composio] Failed to sync account status:', error); + emitComposioEvent({ + toolkitSlug, + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + activeFlows.delete(flowKey); + server.close(); + clearTimeout(cleanupTimeout); + } + }); + + // Timeout for abandoned flows (5 minutes) + cleanupTimeout = setTimeout(() => { + if (activeFlows.has(flowKey)) { + console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`); + activeFlows.delete(flowKey); + server.close(); + emitComposioEvent({ + toolkitSlug, + success: false, + error: 'OAuth flow timed out', + }); + } + }, 5 * 60 * 1000); + + // Open browser for OAuth + shell.openExternal(redirectUrl); + + return { + success: true, + redirectUrl, + connectedAccountId, + }; + } catch (error) { + console.error('[Composio] Connection initiation failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * Get connection status for a toolkit + */ +export async function getConnectionStatus(toolkitSlug: string): Promise<{ + isConnected: boolean; + status?: string; +}> { + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (!account) { + return { isConnected: false }; + } + return { + isConnected: account.status === 'ACTIVE', + status: account.status, + }; +} + +/** + * Sync connection status with Composio API + */ +export async function syncConnection( + toolkitSlug: string, + connectedAccountId: string +): Promise<{ status: string }> { + try { + const accountStatus = await composioClient.getConnectedAccount(connectedAccountId); + composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status); + return { status: accountStatus.status }; + } catch (error) { + console.error('[Composio] Failed to sync connection:', error); + return { status: 'FAILED' }; + } +} + +/** + * Disconnect a toolkit + */ +export async function disconnect(toolkitSlug: string): Promise<{ success: boolean }> { + try { + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (account) { + // Delete from Composio + await composioClient.deleteConnectedAccount(account.id); + // Delete local record + composioAccountsRepo.deleteAccount(toolkitSlug); + } + return { success: true }; + } catch (error) { + console.error('[Composio] Disconnect failed:', error); + // Still delete local record even if API call fails + composioAccountsRepo.deleteAccount(toolkitSlug); + return { success: true }; + } +} + +/** + * List connected toolkits + */ +export function listConnected(): { toolkits: string[] } { + return { toolkits: composioAccountsRepo.getConnectedToolkits() }; +} + +/** + * Execute a Composio action + */ +export async function executeAction( + actionSlug: string, + toolkitSlug: string, + input: Record +): Promise<{ success: boolean; data: unknown; error?: string }> { + try { + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (!account || account.status !== 'ACTIVE') { + return { + success: false, + data: null, + error: `Toolkit ${toolkitSlug} is not connected`, + }; + } + + const result = await composioClient.executeAction(actionSlug, account.id, input); + return result; + } catch (error) { + console.error('[Composio] Action execution failed:', error); + return { + success: false, + data: null, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 01644e90..5a7a7bd9 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -15,11 +15,15 @@ import { bus } from '@x/core/dist/runs/bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; import z from 'zod'; -import { RunEvent } from 'packages/shared/dist/runs.js'; +import { RunEvent } from '@x/shared/dist/runs.js'; import container from '@x/core/dist/di/container.js'; +import { listOnboardingModels } from '@x/core/dist/models/models-dev.js'; +import { testModelConnection } from '@x/core/dist/models/models.js'; +import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; +import * as composioHandler from './composio-handler.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -295,7 +299,7 @@ export function setupIpcHandlers() { return { success: true }; }, 'runs:stop': async (_event, args) => { - await runsCore.stop(args.runId); + await runsCore.stop(args.runId, args.force); return { success: true }; }, 'runs:fetch': async (_event, args) => { @@ -304,8 +308,19 @@ export function setupIpcHandlers() { 'runs:list': async (_event, args) => { return runsCore.listRuns(args.cursor); }, + 'models:list': async () => { + return await listOnboardingModels(); + }, + 'models:test': async (_event, args) => { + return await testModelConnection(args.provider, args.model); + }, + 'models:saveConfig': async (_event, args) => { + const repo = container.resolve('modelConfigRepo'); + await repo.setConfig(args); + return { success: true }; + }, 'oauth:connect': async (_event, args) => { - return await connectProvider(args.provider); + return await connectProvider(args.provider, args.clientId); }, 'oauth:disconnect': async (_event, args) => { return await disconnectProvider(args.provider); @@ -344,5 +359,30 @@ export function setupIpcHandlers() { markOnboardingComplete(); return { success: true }; }, + // Composio integration handlers + 'composio:is-configured': async () => { + return composioHandler.isConfigured(); + }, + 'composio:set-api-key': async (_event, args) => { + return composioHandler.setApiKey(args.apiKey); + }, + 'composio:initiate-connection': async (_event, args) => { + return composioHandler.initiateConnection(args.toolkitSlug); + }, + 'composio:get-connection-status': async (_event, args) => { + return composioHandler.getConnectionStatus(args.toolkitSlug); + }, + 'composio:sync-connection': async (_event, args) => { + return composioHandler.syncConnection(args.toolkitSlug, args.connectedAccountId); + }, + 'composio:disconnect': async (_event, args) => { + return composioHandler.disconnect(args.toolkitSlug); + }, + 'composio:list-connected': async () => { + return composioHandler.listConnected(); + }, + 'composio:execute-action': async (_event, args) => { + return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); + }, }); -} \ No newline at end of file +} diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 7ae6ed46..6ddab7bc 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -10,6 +10,7 @@ import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js"; +import { initConfigs } from "@x/core/dist/config/initConfigs.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -96,7 +97,7 @@ function createWindow() { } } -app.whenReady().then(() => { +app.whenReady().then(async () => { // Register custom protocol before creating window (for production builds) if (app.isPackaged) { registerAppProtocol(); @@ -113,6 +114,9 @@ app.whenReady().then(() => { }); } + // Initialize all config files before UI can access them + await initConfigs(); + setupIpcHandlers(); createWindow(); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 3e694daa..3586aaac 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -1,8 +1,15 @@ import { shell } from 'electron'; +import type { Server } from 'http'; import { createAuthServer } from './auth-server.js'; import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; import type { Configuration } from '@x/core/dist/auth/oauth-client.js'; import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js'; +import { + clearProviderClientIdOverride, + getProviderClientIdOverride, + hasProviderClientIdOverride, + setProviderClientIdOverride, +} from '@x/core/dist/auth/provider-client-id.js'; import container from '@x/core/dist/di/container.js'; import { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js'; @@ -14,12 +21,48 @@ import { emitOAuthEvent } from './ipc.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; // Store active OAuth flows (state -> { codeVerifier, provider, config }) -const activeFlows = new Map(); +// Module-level state for tracking the active OAuth flow +interface ActiveOAuthFlow { + provider: string; + state: string; + server: Server; + cleanupTimeout: NodeJS.Timeout; +} + +let activeFlow: ActiveOAuthFlow | null = null; + +/** + * Cancel any active OAuth flow, cleaning up resources + */ +function cancelActiveFlow(reason: string = 'cancelled'): void { + if (!activeFlow) { + return; + } + + console.log(`[OAuth] Cancelling active flow for ${activeFlow.provider}: ${reason}`); + + clearTimeout(activeFlow.cleanupTimeout); + activeFlow.server.close(); + activeFlows.delete(activeFlow.state); + + // Only emit event for user-visible cancellations + if (reason !== 'new_flow_started') { + emitOAuthEvent({ + provider: activeFlow.provider, + success: false, + error: `OAuth flow ${reason}` + }); + } + + activeFlow = null; +} + /** * Get OAuth repository from DI container */ @@ -39,14 +82,25 @@ function getClientRegistrationRepo(): IClientRegistrationRepo { */ async function getProviderConfiguration(provider: string): Promise { const config = getProviderConfig(provider); + const resolveClientId = (): string => { + const override = getProviderClientIdOverride(provider); + if (override) { + return override; + } + if (config.client.mode === 'static' && config.client.clientId) { + return config.client.clientId; + } + throw new Error(`${provider} client ID not configured. Please provide a client ID.`); + }; if (config.discovery.mode === 'issuer') { if (config.client.mode === 'static') { // Discover endpoints, use static client ID console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`); + const clientId = resolveClientId(); return await oauthClient.discoverConfiguration( config.discovery.issuer, - config.client.clientId + clientId ); } else { // DCR mode - check for existing registration or register new @@ -83,10 +137,11 @@ async function getProviderConfiguration(provider: string): Promise { +export async function connectProvider(provider: string, clientId?: string): Promise<{ success: boolean; error?: string }> { try { console.log(`[OAuth] Starting connection flow for ${provider}...`); + + // Cancel any existing flow before starting a new one + cancelActiveFlow('new_flow_started'); + const oauthRepo = getOAuthRepo(); const providerConfig = getProviderConfig(provider); + if (provider === 'google') { + const trimmedClientId = clientId?.trim(); + if (!trimmedClientId) { + return { success: false, error: 'Google client ID is required to connect.' }; + } + setProviderClientIdOverride(provider, trimmedClientId); + } + // Get or create OAuth configuration const config = await getProviderConfiguration(provider); @@ -122,9 +189,6 @@ export async function connectProvider(provider: string): Promise<{ success: bool state, }); - // Declare timeout variable (will be set after server is created) - let cleanupTimeout: NodeJS.Timeout; - // Create callback server const { server } = await createAuthServer(8080, async (code, receivedState) => { // Validate state @@ -140,7 +204,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool try { // Build callback URL for token exchange const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`); - + // Exchange code for tokens console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); const tokens = await oauthClient.exchangeCodeForTokens( @@ -172,21 +236,30 @@ export async function connectProvider(provider: string): Promise<{ success: bool } finally { // Clean up activeFlows.delete(state); - server.close(); - clearTimeout(cleanupTimeout); + if (activeFlow && activeFlow.state === state) { + clearTimeout(activeFlow.cleanupTimeout); + activeFlow.server.close(); + activeFlow = null; + } } }); - // Set timeout to clean up abandoned flows (5 minutes) + // Set timeout to clean up abandoned flows (2 minutes) // This prevents memory leaks if user never completes the OAuth flow - cleanupTimeout = setTimeout(() => { - if (activeFlows.has(state)) { + const cleanupTimeout = setTimeout(() => { + if (activeFlow?.state === state) { console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); - activeFlows.delete(state); - server.close(); - emitOAuthEvent({ provider, success: false, error: 'OAuth flow timed out' }); + cancelActiveFlow('timed_out'); } - }, 5 * 60 * 1000); // 5 minutes + }, 2 * 60 * 1000); // 2 minutes + + // Store complete flow state for cleanup + activeFlow = { + provider, + state, + server, + cleanupTimeout, + }; // Open in system browser (shares cookies/sessions with user's regular browser) shell.openExternal(authUrl.toString()); @@ -209,6 +282,9 @@ export async function disconnectProvider(provider: string): Promise<{ success: b try { const oauthRepo = getOAuthRepo(); await oauthRepo.clearTokens(provider); + if (provider === 'google') { + clearProviderClientIdOverride(provider); + } return { success: true }; } catch (error) { console.error('OAuth disconnect failed:', error); @@ -222,6 +298,9 @@ export async function disconnectProvider(provider: string): Promise<{ success: b export async function isConnected(provider: string): Promise<{ isConnected: boolean }> { try { const oauthRepo = getOAuthRepo(); + if (provider === 'google' && !hasProviderClientIdOverride(provider)) { + return { isConnected: false }; + } const connected = await oauthRepo.isConnected(provider); return { isConnected: connected }; } catch (error) { @@ -278,7 +357,10 @@ export async function getConnectedProviders(): Promise<{ providers: string[] }> try { const oauthRepo = getOAuthRepo(); const providers = await oauthRepo.getConnectedProviders(); - return { providers }; + const filteredProviders = providers.filter((provider) => + provider === 'google' ? hasProviderClientIdOverride(provider) : true + ); + return { providers: filteredProviders }; } catch (error) { console.error('Get connected providers failed:', error); return { providers: [] }; diff --git a/apps/x/apps/preload/src/preload.ts b/apps/x/apps/preload/src/preload.ts index 1def7bdd..7d7d53e4 100644 --- a/apps/x/apps/preload/src/preload.ts +++ b/apps/x/apps/preload/src/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from 'electron'; +import { contextBridge, ipcRenderer, webUtils } from 'electron'; import { ipc as ipcShared } from '@x/shared'; type InvokeChannels = ipcShared.InvokeChannels; @@ -51,4 +51,8 @@ const ipc = { }, }; -contextBridge.exposeInMainWorld('ipc', ipc); \ No newline at end of file +contextBridge.exposeInMainWorld('ipc', ipc); + +contextBridge.exposeInMainWorld('electronUtils', { + getPathForFile: (file: File) => webUtils.getPathForFile(file), +}); \ No newline at end of file diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index d1a6f1fe..25ab8031 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen } from 'lucide-react'; +import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen, Square } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatInputBar } from './components/chat-button'; @@ -279,7 +279,9 @@ const collectFilePaths = (nodes: TreeNode[]): string[] => // Inner component that uses the controller to access mentions interface ChatInputInnerProps { onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onStop?: () => void isProcessing: boolean + isStopping?: boolean presetMessage?: string onPresetMessageConsumed?: () => void runId?: string | null @@ -287,7 +289,9 @@ interface ChatInputInnerProps { function ChatInputInner({ onSubmit, + onStop, isProcessing, + isStopping, presetMessage, onPresetMessageConsumed, runId, @@ -318,6 +322,37 @@ function ChatInputInner({ } }, [handleSubmit]) + useEffect(() => { + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault() + } + } + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault() + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + const paths = Array.from(e.dataTransfer.files) + .map((f) => window.electronUtils?.getPathForFile(f)) + .filter(Boolean) + if (paths.length > 0) { + const currentText = controller.textInput.value + const pathText = paths.join(' ') + controller.textInput.setInput( + currentText ? `${currentText} ${pathText}` : pathText + ) + } + } + } + document.addEventListener("dragover", onDragOver) + document.addEventListener("drop", onDrop) + return () => { + document.removeEventListener("dragover", onDragOver) + document.removeEventListener("drop", onDrop) + } + }, [controller]) + return (
- + {isProcessing ? ( + + ) : ( + + )}
) } @@ -350,7 +405,9 @@ interface ChatInputWithMentionsProps { recentFiles: string[] visibleFiles: string[] onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onStop?: () => void isProcessing: boolean + isStopping?: boolean presetMessage?: string onPresetMessageConsumed?: () => void runId?: string | null @@ -361,7 +418,9 @@ function ChatInputWithMentions({ recentFiles, visibleFiles, onSubmit, + onStop, isProcessing, + isStopping, presetMessage, onPresetMessageConsumed, runId, @@ -370,7 +429,9 @@ function ChatInputWithMentions({ (null) const runIdRef = useRef(null) const [isProcessing, setIsProcessing] = useState(false) + const [isStopping, setIsStopping] = useState(false) + const [stopClickedAt, setStopClickedAt] = useState(null) const [agentId] = useState('copilot') const [presetMessage, setPresetMessage] = useState(undefined) @@ -758,6 +821,8 @@ function App() { case 'run-processing-end': setIsProcessing(false) + setIsStopping(false) + setStopClickedAt(null) break case 'start': @@ -936,8 +1001,32 @@ function App() { break } + case 'run-stopped': + setIsProcessing(false) + setIsStopping(false) + setStopClickedAt(null) + // Clear pending requests since they've been aborted + setPendingPermissionRequests(new Map()) + setPendingAskHumanRequests(new Map()) + // Flush any streaming content as a message + setCurrentAssistantMessage(currentMsg => { + if (currentMsg) { + setConversation(prev => [...prev, { + id: `assistant-stopped-${Date.now()}`, + role: 'assistant', + content: currentMsg, + timestamp: Date.now(), + }]) + } + return '' + }) + setCurrentReasoning('') + break + case 'error': setIsProcessing(false) + setIsStopping(false) + setStopClickedAt(null) console.error('Run error:', event.error) break } @@ -1009,6 +1098,21 @@ function App() { } } + const handleStop = useCallback(async () => { + if (!runId) return + const now = Date.now() + const isForce = isStopping && stopClickedAt !== null && (now - stopClickedAt) < 2000 + + setStopClickedAt(now) + setIsStopping(true) + + try { + await window.ipc.invoke('runs:stop', { runId, force: isForce }) + } catch (error) { + console.error('Failed to stop run:', error) + } + }, [runId, isStopping, stopClickedAt]) + const handlePermissionResponse = useCallback(async (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => { if (!runId) return @@ -1337,6 +1441,29 @@ function App() { }, }), [tree, selectedPath, workspaceRoot, collectDirPaths]) + // Handler for when a voice note is created/updated + const handleVoiceNoteCreated = useCallback(async (notePath: string) => { + // Refresh the tree to show the new file/folder + const newTree = await loadDirectory() + setTree(newTree) + + // Expand parent directories to show the file + const parts = notePath.split('/') + const parentPaths: string[] = [] + for (let i = 1; i < parts.length; i++) { + parentPaths.push(parts.slice(0, i).join('/')) + } + setExpandedPaths(prev => { + const newSet = new Set(prev) + parentPaths.forEach(p => newSet.add(p)) + return newSet + }) + + // Select the file to show it in the editor + setIsGraphOpen(false) + setSelectedPath(notePath) + }, [loadDirectory]) + const ensureWikiFile = useCallback(async (wikiPath: string) => { const resolvedPath = toKnowledgePath(wikiPath) if (!resolvedPath) return null @@ -1562,7 +1689,7 @@ function App() { return ( - +
{/* Icon sidebar - always visible, fixed position */} @@ -1583,6 +1710,7 @@ function App() { expandedPaths={expandedPaths} onSelectFile={toggleExpand} knowledgeActions={knowledgeActions} + onVoiceNoteCreated={handleVoiceNoteCreated} runs={runs} currentRunId={runId} tasksActions={{ @@ -1779,7 +1907,9 @@ function App() { recentFiles={recentWikiFiles} visibleFiles={visibleKnowledgeFiles} onSubmit={handlePromptSubmit} + onStop={handleStop} isProcessing={isProcessing} + isStopping={isStopping} presetMessage={presetMessage} onPresetMessageConsumed={() => setPresetMessage(undefined)} runId={runId} @@ -1801,6 +1931,8 @@ function App() { currentAssistantMessage={currentAssistantMessage} currentReasoning={currentReasoning} isProcessing={isProcessing} + isStopping={isStopping} + onStop={handleStop} message={message} onMessageChange={setMessage} onSubmit={handlePromptSubmit} diff --git a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx index aa380f57..35baf6c4 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -13,7 +13,6 @@ export const Conversation = ({ className, ...props }: ConversationProps) => ( diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 4cddfdf0..4c97d581 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowUp, Expand, Plus } from 'lucide-react' +import { ArrowUp, Expand, LoaderIcon, Plus, Square } from 'lucide-react' import type { ToolUIPart } from 'ai' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -115,6 +115,8 @@ interface ChatSidebarProps { currentAssistantMessage: string currentReasoning: string isProcessing: boolean + isStopping?: boolean + onStop?: () => void message: string onMessageChange: (message: string) => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void @@ -139,6 +141,8 @@ export function ChatSidebar({ currentAssistantMessage, currentReasoning, isProcessing, + isStopping, + onStop, message, onMessageChange, onSubmit, @@ -595,19 +599,39 @@ export function ChatSidebar({ style={{ fieldSizing: 'content' } as React.CSSProperties} />
- + {isProcessing ? ( + + ) : ( + + )} {knowledgeFiles.length > 0 && ( void + onSubmit: (apiKey: string) => void + isSubmitting?: boolean +} + +export function ComposioApiKeyModal({ + open, + onOpenChange, + onSubmit, + isSubmitting = false, +}: ComposioApiKeyModalProps) { + const [apiKey, setApiKey] = useState("") + + useEffect(() => { + if (!open) { + setApiKey("") + } + }, [open]) + + const trimmedApiKey = apiKey.trim() + const isValid = trimmedApiKey.length > 0 + + const handleSubmit = () => { + if (!isValid || isSubmitting) return + onSubmit(trimmedApiKey) + } + + return ( + + + + Enter Composio API Key + + Get your API key from{" "} + + app.composio.dev/settings + + + +
+ + setApiKey(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + autoFocus + /> +
+
+ + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index ced6064f..7033a2ac 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail } from "lucide-react" +import { Loader2, Mic, Mail, MessageSquare } from "lucide-react" import { Popover, @@ -17,6 +17,9 @@ import { import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { Separator } from "@/components/ui/separator" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" +import { GoogleClientIdModal } from "@/components/google-client-id-modal" +import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store" import { toast } from "sonner" interface ProviderState { @@ -35,11 +38,18 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const [providers, setProviders] = useState([]) const [providersLoading, setProvidersLoading] = useState(true) const [providerStates, setProviderStates] = useState>({}) + const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) // Granola state const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) + // Composio/Slack state + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [slackConnected, setSlackConnected] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackConnecting, setSlackConnecting] = useState(false) + // Load available providers on mount useEffect(() => { async function loadProviders() { @@ -86,11 +96,89 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } }, []) + // Load Slack connection status + const refreshSlackStatus = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) + setSlackConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Slack status:', error) + setSlackConnected(false) + } finally { + setSlackLoading(false) + } + }, []) + + // Connect to Slack via Composio + const startSlackConnect = useCallback(async () => { + try { + setSlackConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Slack') + setSlackConnecting(false) + } + // Success will be handled by composio:didConnect event + } catch (error) { + console.error('Failed to connect to Slack:', error) + toast.error('Failed to connect to Slack') + setSlackConnecting(false) + } + }, []) + + // Handle Slack connect button click + const handleConnectSlack = useCallback(async () => { + // Check if Composio is configured + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyOpen(true) + return + } + await startSlackConnect() + }, [startSlackConnect]) + + // Handle Composio API key submission + const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { + try { + await window.ipc.invoke('composio:set-api-key', { apiKey }) + setComposioApiKeyOpen(false) + toast.success('Composio API key saved') + // Now start the Slack connection + await startSlackConnect() + } catch (error) { + console.error('Failed to save Composio API key:', error) + toast.error('Failed to save API key') + } + }, [startSlackConnect]) + + // Disconnect from Slack + const handleDisconnectSlack = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'slack' }) + if (result.success) { + setSlackConnected(false) + toast.success('Disconnected from Slack') + } else { + toast.error('Failed to disconnect from Slack') + } + } catch (error) { + console.error('Failed to disconnect from Slack:', error) + toast.error('Failed to disconnect from Slack') + } finally { + setSlackLoading(false) + } + }, []) + // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { // Refresh Granola refreshGranolaConfig() + // Refresh Slack status + refreshSlackStatus() + // Refresh OAuth providers if (providers.length === 0) return @@ -117,7 +205,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) ) setProviderStates(newStates) - }, [providers, refreshGranolaConfig]) + }, [providers, refreshGranolaConfig, refreshSlackStatus]) // Refresh statuses when popover opens or providers list changes useEffect(() => { @@ -161,15 +249,34 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) return cleanup }, [refreshAllStatuses]) - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { + // Listen for Composio connection events + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + + if (toolkitSlug === 'slack') { + setSlackConnected(success) + setSlackConnecting(false) + + if (success) { + toast.success('Connected to Slack') + } else { + toast.error(error || 'Failed to connect to Slack') + } + } + }) + + return cleanup + }, []) + + const startConnect = useCallback(async (provider: string, clientId?: string) => { setProviderStates(prev => ({ ...prev, [provider]: { ...prev[provider], isConnecting: true } })) try { - const result = await window.ipc.invoke('oauth:connect', { provider }) + const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) if (result.success) { // OAuth flow started - keep isConnecting state, wait for event @@ -192,6 +299,27 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } }, []) + // Connect to a provider + const handleConnect = useCallback(async (provider: string) => { + if (provider === 'google') { + const existingClientId = getGoogleClientId() + if (!existingClientId) { + setGoogleClientIdOpen(true) + return + } + await startConnect(provider, existingClientId) + return + } + + await startConnect(provider) + }, [startConnect]) + + const handleGoogleClientIdSubmit = useCallback((clientId: string) => { + setGoogleClientId(clientId) + setGoogleClientIdOpen(false) + startConnect('google', clientId) + }, [startConnect]) + // Disconnect from a provider const handleDisconnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ @@ -203,6 +331,9 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const result = await window.ipc.invoke('oauth:disconnect', { provider }) if (result.success) { + if (provider === 'google') { + clearGoogleClientId() + } const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) toast.success(`Disconnected from ${displayName}`) setProviderStates(prev => ({ @@ -289,6 +420,13 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } return ( + <> + {tooltip ? ( @@ -368,10 +506,71 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) {/* Fireflies */} {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} + + + + {/* Team Communication Section - Slack */} +
+ Team Communication +
+ + {/* Slack */} +
+
+
+ +
+
+ Slack + {slackLoading ? ( + Checking... + ) : ( + + Send messages and view channels + + )} +
+
+
+ {slackLoading ? ( + + ) : slackConnected ? ( + + ) : ( + + )} +
+
)}
+ + ) } diff --git a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx new file mode 100644 index 00000000..5727d506 --- /dev/null +++ b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx @@ -0,0 +1,85 @@ +"use client" + +import { useEffect, useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +interface GoogleClientIdModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (clientId: string) => void + isSubmitting?: boolean +} + +export function GoogleClientIdModal({ + open, + onOpenChange, + onSubmit, + isSubmitting = false, +}: GoogleClientIdModalProps) { + const [clientId, setClientId] = useState("") + + useEffect(() => { + if (!open) { + setClientId("") + } + }, [open]) + + const trimmedClientId = clientId.trim() + const isValid = trimmedClientId.length > 0 + + const handleSubmit = () => { + if (!isValid || isSubmitting) return + onSubmit(trimmedClientId) + } + + return ( + + + + Enter Google Client ID + + This app does not store the client ID. You will be prompted each session. + + +
+ + setClientId(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + autoFocus + /> +
+
+ + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 48119934..1f664f10 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail, CheckCircle2, Sailboat } from "lucide-react" +import { Loader2, Mic, Mail, CheckCircle2, Sailboat, MessageSquare } from "lucide-react" import { Dialog, @@ -13,7 +13,18 @@ import { } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { cn } from "@/lib/utils" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" +import { GoogleClientIdModal } from "@/components/google-client-id-modal" +import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store" import { toast } from "sonner" interface ProviderState { @@ -27,20 +38,75 @@ interface OnboardingModalProps { onComplete: () => void } -type Step = 0 | 1 | 2 +type Step = 0 | 1 | 2 | 3 + +type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" + +interface LlmModelOption { + id: string + name?: string + release_date?: string +} export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [currentStep, setCurrentStep] = useState(0) + // LLM setup state + const [llmProvider, setLlmProvider] = useState("openai") + const [modelsCatalog, setModelsCatalog] = useState>({}) + const [modelsLoading, setModelsLoading] = useState(false) + const [modelsError, setModelsError] = useState(null) + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", model: "" }, + anthropic: { apiKey: "", baseURL: "", model: "" }, + google: { apiKey: "", baseURL: "", model: "" }, + openrouter: { apiKey: "", baseURL: "", model: "" }, + aigateway: { apiKey: "", baseURL: "", model: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" }, + }) + const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ + status: "idle", + }) + const [savingLlmConfig, setSavingLlmConfig] = useState(false) + // OAuth provider states const [providers, setProviders] = useState([]) const [providersLoading, setProvidersLoading] = useState(true) const [providerStates, setProviderStates] = useState>({}) + const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) // Granola state const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) + // Composio/Slack state + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [slackConnected, setSlackConnected] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackConnecting, setSlackConnecting] = useState(false) + + const updateProviderConfig = useCallback( + (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => { + setProviderConfigs(prev => ({ + ...prev, + [provider]: { ...prev[provider], ...updates }, + })) + setTestState({ status: "idle" }) + }, + [] + ) + + const activeConfig = providerConfigs[llmProvider] + const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" + const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" + const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway" + const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible" + const canTest = + activeConfig.model.trim().length > 0 && + (!requiresApiKey || activeConfig.apiKey.trim().length > 0) && + (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) + // Track connected providers for the completion step const connectedProviders = Object.entries(providerStates) .filter(([, state]) => state.isConnected) @@ -65,6 +131,48 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { loadProviders() }, [open]) + // Load LLM models catalog on open + useEffect(() => { + if (!open) return + + async function loadModels() { + try { + setModelsLoading(true) + setModelsError(null) + const result = await window.ipc.invoke("models:list", null) + const catalog: Record = {} + for (const provider of result.providers || []) { + catalog[provider.id] = provider.models || [] + } + setModelsCatalog(catalog) + } catch (error) { + console.error("Failed to load models catalog:", error) + setModelsError("Failed to load models list") + setModelsCatalog({}) + } finally { + setModelsLoading(false) + } + } + + loadModels() + }, [open]) + + // Initialize default models from catalog + useEffect(() => { + if (Object.keys(modelsCatalog).length === 0) return + setProviderConfigs(prev => { + const next = { ...prev } + const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"] + for (const provider of cloudProviders) { + const models = modelsCatalog[provider] + if (models?.length && !next[provider].model) { + next[provider] = { ...next[provider], model: models[0]?.id || "" } + } + } + return next + }) + }, [modelsCatalog]) + // Load Granola config const refreshGranolaConfig = useCallback(async () => { try { @@ -94,11 +202,133 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) + // Load Slack connection status + const refreshSlackStatus = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) + setSlackConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Slack status:', error) + setSlackConnected(false) + } finally { + setSlackLoading(false) + } + }, []) + + // Start Slack connection + const startSlackConnect = useCallback(async () => { + try { + setSlackConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Slack') + setSlackConnecting(false) + } + // Success will be handled by composio:didConnect event + } catch (error) { + console.error('Failed to connect to Slack:', error) + toast.error('Failed to connect to Slack') + setSlackConnecting(false) + } + }, []) + + // Connect to Slack via Composio (checks if configured first) + const handleConnectSlack = useCallback(async () => { + // Check if Composio is configured + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyOpen(true) + return + } + await startSlackConnect() + }, [startSlackConnect]) + + // Handle Composio API key submission + const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { + try { + await window.ipc.invoke('composio:set-api-key', { apiKey }) + setComposioApiKeyOpen(false) + toast.success('Composio API key saved') + // Now start the Slack connection + await startSlackConnect() + } catch (error) { + console.error('Failed to save Composio API key:', error) + toast.error('Failed to save API key') + } + }, [startSlackConnect]) + + const handleNext = () => { + if (currentStep < 3) { + setCurrentStep((prev) => (prev + 1) as Step) + } + } + + const handleComplete = () => { + onComplete() + } + + const handleTestConnection = useCallback(async () => { + if (!canTest) return + setTestState({ status: "testing" }) + try { + const apiKey = activeConfig.apiKey.trim() || undefined + const baseURL = activeConfig.baseURL.trim() || undefined + const model = activeConfig.model.trim() + const result = await window.ipc.invoke("models:test", { + provider: { + flavor: llmProvider, + apiKey, + baseURL, + }, + model, + }) + if (result.success) { + setTestState({ status: "success" }) + toast.success("Connection successful") + } else { + setTestState({ status: "error", error: result.error }) + toast.error(result.error || "Connection test failed") + } + } catch (error) { + console.error("Connection test failed:", error) + setTestState({ status: "error", error: "Connection test failed" }) + toast.error("Connection test failed") + } + }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider]) + + const handleSaveLlmConfig = useCallback(async () => { + if (testState.status !== "success") return + setSavingLlmConfig(true) + try { + const apiKey = activeConfig.apiKey.trim() || undefined + const baseURL = activeConfig.baseURL.trim() || undefined + const model = activeConfig.model.trim() + await window.ipc.invoke("models:saveConfig", { + provider: { + flavor: llmProvider, + apiKey, + baseURL, + }, + model, + }) + setSavingLlmConfig(false) + handleNext() + } catch (error) { + console.error("Failed to save LLM config:", error) + toast.error("Failed to save LLM settings") + setSavingLlmConfig(false) + } + }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, handleNext, llmProvider, testState.status]) + // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { // Refresh Granola refreshGranolaConfig() + // Refresh Slack status + refreshSlackStatus() + // Refresh OAuth providers if (providers.length === 0) return @@ -125,7 +355,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) setProviderStates(newStates) - }, [providers, refreshGranolaConfig]) + }, [providers, refreshGranolaConfig, refreshSlackStatus]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -159,15 +389,34 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return cleanup }, []) - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { + // Listen for Composio connection events + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + + if (toolkitSlug === 'slack') { + setSlackConnected(success) + setSlackConnecting(false) + + if (success) { + toast.success('Connected to Slack') + } else { + toast.error(error || 'Failed to connect to Slack') + } + } + }) + + return cleanup + }, []) + + const startConnect = useCallback(async (provider: string, clientId?: string) => { setProviderStates(prev => ({ ...prev, [provider]: { ...prev[provider], isConnecting: true } })) try { - const result = await window.ipc.invoke('oauth:connect', { provider }) + const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) if (!result.success) { toast.error(result.error || `Failed to connect to ${provider}`) @@ -186,20 +435,31 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) - const handleNext = () => { - if (currentStep < 2) { - setCurrentStep((prev) => (prev + 1) as Step) + // Connect to a provider + const handleConnect = useCallback(async (provider: string) => { + if (provider === 'google') { + const existingClientId = getGoogleClientId() + if (!existingClientId) { + setGoogleClientIdOpen(true) + return + } + await startConnect(provider, existingClientId) + return } - } - const handleComplete = () => { - onComplete() - } + await startConnect(provider) + }, [startConnect]) + + const handleGoogleClientIdSubmit = useCallback((clientId: string) => { + setGoogleClientId(clientId) + setGoogleClientIdOpen(false) + startConnect('google', clientId) + }, [startConnect]) // Step indicator component const StepIndicator = () => (
- {[0, 1, 2].map((step) => ( + {[0, 1, 2, 3].map((step) => (
) + // Render Slack row + const renderSlackRow = () => ( +
+
+
+ +
+
+ Slack + {slackLoading ? ( + Checking... + ) : ( + + Send messages and view channels + + )} +
+
+
+ {slackLoading ? ( + + ) : slackConnected ? ( +
+ + Connected +
+ ) : ( + + )} +
+
+ ) + // Step 0: Welcome const WelcomeStep = () => (
@@ -323,7 +627,156 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) - // Step 1: Connect Accounts + // Step 1: LLM Setup + const LlmSetupStep = () => { + const providerOptions: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ + { id: "openai", name: "OpenAI", description: "Use your OpenAI API key" }, + { id: "anthropic", name: "Anthropic", description: "Use your Anthropic API key" }, + { id: "google", name: "Google", description: "Use your Google AI Studio key" }, + { id: "openrouter", name: "OpenRouter", description: "Access multiple models with one key" }, + { id: "aigateway", name: "AI Gateway (Vercel)", description: "Use Vercel's AI Gateway" }, + { id: "ollama", name: "Ollama (Local)", description: "Run a local model via Ollama" }, + { id: "openai-compatible", name: "OpenAI-Compatible", description: "Local or hosted OpenAI-compatible API" }, + ] + + const modelsForProvider = modelsCatalog[llmProvider] || [] + const showModelInput = isLocalProvider || modelsForProvider.length === 0 + + return ( +
+ + Choose your model + + Select your provider and model to power Rowboat’s AI. + + + +
+
+ Provider +
+ {providerOptions.map((provider) => ( + + ))} +
+
+ +
+ Model + {modelsLoading ? ( +
+ + Loading models... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { model: e.target.value })} + placeholder="Enter model ID" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ + {requiresApiKey && ( +
+ API Key + updateProviderConfig(llmProvider, { apiKey: e.target.value })} + placeholder="Paste your API key" + /> +
+ )} + + {showBaseURL && ( +
+ Base URL + updateProviderConfig(llmProvider, { baseURL: e.target.value })} + placeholder={ + llmProvider === "ollama" + ? "http://localhost:11434" + : llmProvider === "openai-compatible" + ? "http://localhost:1234/v1" + : "https://ai-gateway.vercel.sh/v1" + } + /> +
+ )} +
+ +
+ + {testState.status === "success" && ( + Connected + )} + {testState.status === "error" && ( + + {testState.error || "Test failed"} + + )} +
+ +
+ +
+
+ ) + } + + // Step 2: Connect Accounts const AccountConnectionStep = () => (
@@ -358,6 +811,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { {renderGranolaRow()} {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')}
+ + {/* Team Communication Section */} +
+
+ Team Communication +
+ {renderSlackRow()} +
)}
@@ -373,9 +834,9 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) - // Step 2: Completion + // Step 3: Completion const CompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected return (
@@ -416,6 +877,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { Granola (Local meeting notes)
)} + {slackConnected && ( +
+ + Slack (Team communication) +
+ )} @@ -429,6 +896,19 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } return ( + <> + + {}}> {currentStep === 0 && } - {currentStep === 1 && } - {currentStep === 2 && } + {currentStep === 1 && } + {currentStep === 2 && } + {currentStep === 3 && } + ) } diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 8820dfed..af0d1896 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -12,8 +12,10 @@ import { Folder, FolderPlus, MessageSquare, + Mic, Network, Pencil, + Square, SquarePen, Trash2, } from "lucide-react" @@ -88,6 +90,7 @@ type SidebarContentPanelProps = { expandedPaths: Set onSelectFile: (path: string, kind: "file" | "dir") => void knowledgeActions: KnowledgeActions + onVoiceNoteCreated?: (path: string) => void runs?: RunListItem[] currentRunId?: string | null tasksActions?: TasksActions @@ -95,7 +98,7 @@ type SidebarContentPanelProps = { const sectionTitles = { knowledge: "Knowledge", - tasks: "Tasks", + tasks: "Chats", } export function SidebarContentPanel({ @@ -104,6 +107,7 @@ export function SidebarContentPanel({ expandedPaths, onSelectFile, knowledgeActions, + onVoiceNoteCreated, runs = [], currentRunId, tasksActions, @@ -126,6 +130,7 @@ export function SidebarContentPanel({ expandedPaths={expandedPaths} onSelectFile={onSelectFile} actions={knowledgeActions} + onVoiceNoteCreated={onVoiceNoteCreated} /> )} {activeSection === "tasks" && ( @@ -141,6 +146,227 @@ export function SidebarContentPanel({ ) } +async function transcribeWithDeepgram(audioBlob: Blob): Promise { + try { + const configResult = await window.ipc.invoke('workspace:readFile', { + path: 'config/deepgram.json', + encoding: 'utf8', + }) + const { apiKey } = JSON.parse(configResult.data) as { apiKey: string } + if (!apiKey) throw new Error('No apiKey in deepgram.json') + + const response = await fetch( + 'https://api.deepgram.com/v1/listen?model=nova-2&smart_format=true', + { + method: 'POST', + headers: { + Authorization: `Token ${apiKey}`, + 'Content-Type': audioBlob.type, + }, + body: audioBlob, + }, + ) + + if (!response.ok) throw new Error(`Deepgram API error: ${response.status}`) + const result = await response.json() + return result.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? null + } catch (err) { + console.error('Deepgram transcription failed:', err) + return null + } +} + +// Voice Note Recording Button +function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => void }) { + const [isRecording, setIsRecording] = React.useState(false) + const mediaRecorderRef = React.useRef(null) + const chunksRef = React.useRef([]) + const notePathRef = React.useRef(null) + const timestampRef = React.useRef(null) + const relativePathRef = React.useRef(null) + + const startRecording = async () => { + try { + // Generate timestamp and paths immediately + const now = new Date() + const timestamp = now.toISOString().replace(/[:.]/g, '-') + const dateStr = now.toISOString().split('T')[0] // YYYY-MM-DD + const noteName = `voice-memo-${timestamp}` + const notePath = `knowledge/Voice Memos/${dateStr}/${noteName}.md` + + timestampRef.current = timestamp + notePathRef.current = notePath + // Relative path for linking (from knowledge/ root, without .md extension) + const relativePath = `Voice Memos/${dateStr}/${noteName}` + relativePathRef.current = relativePath + + // Create the note immediately with a "Recording..." placeholder + await window.ipc.invoke('workspace:mkdir', { + path: `knowledge/Voice Memos/${dateStr}`, + recursive: true, + }) + + const initialContent = `# Voice Memo + +**Type:** voice memo +**Recorded:** ${now.toLocaleString()} +**Path:** ${relativePath} + +## Transcript + +*Recording in progress...* +` + await window.ipc.invoke('workspace:writeFile', { + path: notePath, + data: initialContent, + opts: { encoding: 'utf8' }, + }) + + // Select the note so the user can see it + onNoteCreated?.(notePath) + + // Start actual recording + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const mimeType = MediaRecorder.isTypeSupported('audio/mp4') + ? 'audio/mp4' + : 'audio/webm' + const recorder = new MediaRecorder(stream, { mimeType }) + chunksRef.current = [] + + recorder.ondataavailable = (e) => { + if (e.data.size > 0) chunksRef.current.push(e.data) + } + + recorder.onstop = async () => { + stream.getTracks().forEach((t) => t.stop()) + const blob = new Blob(chunksRef.current, { type: mimeType }) + const ext = mimeType === 'audio/mp4' ? 'm4a' : 'webm' + const audioFilename = `voice-memo-${timestampRef.current}.${ext}` + + // Save audio file to voice_memos folder (for backup/reference) + try { + await window.ipc.invoke('workspace:mkdir', { + path: 'voice_memos', + recursive: true, + }) + + const arrayBuffer = await blob.arrayBuffer() + const base64 = btoa( + new Uint8Array(arrayBuffer).reduce( + (data, byte) => data + String.fromCharCode(byte), + '', + ), + ) + + await window.ipc.invoke('workspace:writeFile', { + path: `voice_memos/${audioFilename}`, + data: base64, + opts: { encoding: 'base64' }, + }) + } catch { + console.error('Failed to save audio file') + } + + // Update note to show transcribing status + const currentNotePath = notePathRef.current + const currentRelativePath = relativePathRef.current + if (currentNotePath && currentRelativePath) { + const transcribingContent = `# Voice Memo + +**Type:** voice memo +**Recorded:** ${new Date().toLocaleString()} +**Path:** ${currentRelativePath} + +## Transcript + +*Transcribing...* +` + await window.ipc.invoke('workspace:writeFile', { + path: currentNotePath, + data: transcribingContent, + opts: { encoding: 'utf8' }, + }) + } + + // Transcribe and update the note with the transcript + const transcript = await transcribeWithDeepgram(blob) + if (currentNotePath && currentRelativePath) { + const finalContent = transcript + ? `# Voice Memo + +**Type:** voice memo +**Recorded:** ${new Date().toLocaleString()} +**Path:** ${currentRelativePath} + +## Transcript + +${transcript} +` + : `# Voice Memo + +**Type:** voice memo +**Recorded:** ${new Date().toLocaleString()} +**Path:** ${currentRelativePath} + +## Transcript + +*Transcription failed. Please try again.* +` + await window.ipc.invoke('workspace:writeFile', { + path: currentNotePath, + data: finalContent, + opts: { encoding: 'utf8' }, + }) + + // Re-select to trigger refresh + onNoteCreated?.(currentNotePath) + + if (transcript) { + toast('Voice note transcribed', 'success') + } else { + toast('Transcription failed', 'error') + } + } + } + + recorder.start() + mediaRecorderRef.current = recorder + setIsRecording(true) + toast('Recording started', 'success') + } catch { + toast('Could not access microphone', 'error') + } + } + + const stopRecording = () => { + if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { + mediaRecorderRef.current.stop() + } + mediaRecorderRef.current = null + setIsRecording(false) + } + + return ( + + + + + + {isRecording ? 'Stop Recording' : 'New Voice Note'} + + + ) +} + // Knowledge Section function KnowledgeSection({ tree, @@ -148,15 +374,17 @@ function KnowledgeSection({ expandedPaths, onSelectFile, actions, + onVoiceNoteCreated, }: { tree: TreeNode[] selectedPath: string | null expandedPaths: Set onSelectFile: (path: string, kind: "file" | "dir") => void actions: KnowledgeActions + onVoiceNoteCreated?: (path: string) => void }) { const isExpanded = expandedPaths.size > 0 - + const quickActions = [ { icon: FilePlus, label: "New Note", action: () => actions.createNote() }, { icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() }, @@ -181,6 +409,7 @@ function KnowledgeSection({ {action.label} ))} +