mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 03:42:38 +02:00
Compare commits
68 commits
d8b6cef3f5
...
c68d0d203b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c68d0d203b | ||
|
|
71c848006c | ||
|
|
290dc54d1e | ||
|
|
c2177a0aa4 | ||
|
|
e0aaa9a27e | ||
|
|
bc929b6c1b | ||
|
|
48edd98fe0 | ||
|
|
0b9698b0da | ||
|
|
1dd622449e | ||
|
|
79be8fbf42 | ||
|
|
5e53afb670 | ||
|
|
54af172cbc | ||
|
|
7a425acfdf | ||
|
|
3f81b771b2 | ||
|
|
903fecc5f5 | ||
|
|
0e362cc763 | ||
|
|
f7e6f783ba | ||
|
|
1c5e5afda8 | ||
|
|
61e92783b2 | ||
|
|
86cc2aaf73 | ||
|
|
30e1785fe2 | ||
|
|
07d34471f5 | ||
|
|
678e645bbc | ||
|
|
eb34873c32 | ||
|
|
f8e7c17be5 | ||
|
|
a4febb09c0 | ||
|
|
96ab91f8cb | ||
|
|
fa07a75358 | ||
|
|
2d62824030 | ||
|
|
333ccb096b | ||
|
|
29ee4b5930 | ||
|
|
1686c8f878 | ||
|
|
db7c843d33 | ||
|
|
0094cfa397 | ||
|
|
e0e4f7165c | ||
|
|
02c0fd487f | ||
|
|
8151769891 | ||
|
|
d191c00e4d | ||
|
|
c41586b85d | ||
|
|
2190e793a6 | ||
|
|
c03882f43f | ||
|
|
484231b224 | ||
|
|
e408f859d6 | ||
|
|
9e28c47f30 | ||
|
|
c0138af3ab | ||
|
|
7966501a79 | ||
|
|
1c63ee571c | ||
|
|
2e8a3580c2 | ||
|
|
63a1d4952b | ||
|
|
758a2779f4 | ||
|
|
de8b3291e4 | ||
|
|
affc9956f4 | ||
|
|
91030a5fca | ||
|
|
a10e97110d | ||
|
|
47ecc31988 | ||
|
|
128f433e5c | ||
|
|
b066aa2b24 | ||
|
|
86818e7d21 | ||
|
|
65e2b3b868 | ||
|
|
09395a72c7 | ||
|
|
970bb75faa | ||
|
|
7e15c1231d | ||
|
|
16b8975b00 | ||
|
|
dd361cb6bf | ||
|
|
17bb625ab9 | ||
|
|
d2bb11f104 | ||
|
|
983a4c578f | ||
|
|
d8d96634d0 |
123 changed files with 14995 additions and 1869 deletions
11
apps/x/.claude/launch.json
Normal file
11
apps/x/.claude/launch.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "renderer-dev",
|
||||
"runtimeExecutable": "/Users/tusharmagar/Rowboat/rowboat-V2/apps/x/apps/renderer/node_modules/.bin/vite",
|
||||
"runtimeArgs": ["--port", "5173"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
12
apps/x/apps/main/entitlements.plist
Normal file
12
apps/x/apps/main/entitlements.plist
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.screen-capture</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -11,8 +11,15 @@ module.exports = {
|
|||
icon: './icons/icon', // .icns extension added automatically
|
||||
appBundleId: 'com.rowboat.app',
|
||||
appCategoryType: 'public.app-category.productivity',
|
||||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
||||
},
|
||||
osxSign: {
|
||||
batchCodesignCalls: true,
|
||||
optionsForFile: () => ({
|
||||
entitlements: path.join(__dirname, 'entitlements.plist'),
|
||||
'entitlements-inherit': path.join(__dirname, 'entitlements.plist'),
|
||||
}),
|
||||
},
|
||||
osxNotarize: {
|
||||
appleId: process.env.APPLE_ID,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export interface AuthServerResult {
|
|||
*/
|
||||
export function createAuthServer(
|
||||
port: number = DEFAULT_PORT,
|
||||
onCallback: (code: string, state: string) => void | Promise<void>
|
||||
onCallback: (params: Record<string, string>) => void | Promise<void>
|
||||
): Promise<AuthServerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
|
|
@ -67,7 +67,7 @@ export function createAuthServer(
|
|||
|
||||
// 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 || '');
|
||||
onCallback(Object.fromEntries(url.searchParams.entries()));
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
|
|
|
|||
|
|
@ -2,15 +2,21 @@ 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';
|
||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { CURATED_TOOLKIT_SLUGS } from '@x/shared/dist/composio.js';
|
||||
import type { LocalConnectedAccount, Toolkit } from '@x/core/dist/composio/types.js';
|
||||
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
|
||||
|
||||
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
|
||||
|
||||
// Store active OAuth flows
|
||||
// Store active OAuth flows (keyed by toolkitSlug to prevent concurrent flows for the same toolkit)
|
||||
const activeFlows = new Map<string, {
|
||||
toolkitSlug: string;
|
||||
connectedAccountId: string;
|
||||
authConfigId: string;
|
||||
server: import('http').Server;
|
||||
timeout: NodeJS.Timeout;
|
||||
}>();
|
||||
|
||||
/**
|
||||
|
|
@ -28,8 +34,8 @@ export function emitComposioEvent(event: { toolkitSlug: string; success: boolean
|
|||
/**
|
||||
* Check if Composio is configured with an API key
|
||||
*/
|
||||
export function isConfigured(): { configured: boolean } {
|
||||
return { configured: composioClient.isConfigured() };
|
||||
export async function isConfigured(): Promise<{ configured: boolean }> {
|
||||
return { configured: await composioClient.isConfigured() };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -68,7 +74,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
const toolkit = await composioClient.getToolkit(toolkitSlug);
|
||||
|
||||
// Check for managed OAuth2
|
||||
if (!toolkit.composio_managed_auth_schemes.includes('OAUTH2')) {
|
||||
if (!toolkit.composio_managed_auth_schemes?.includes('OAUTH2')) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Toolkit ${toolkitSlug} does not support managed OAuth2`,
|
||||
|
|
@ -122,13 +128,14 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
};
|
||||
}
|
||||
|
||||
// Store flow state
|
||||
const flowKey = `${toolkitSlug}-${Date.now()}`;
|
||||
activeFlows.set(flowKey, {
|
||||
toolkitSlug,
|
||||
connectedAccountId,
|
||||
authConfigId,
|
||||
});
|
||||
// Abort any existing flow for this toolkit before starting a new one
|
||||
const existingFlow = activeFlows.get(toolkitSlug);
|
||||
if (existingFlow) {
|
||||
console.log(`[Composio] Aborting existing flow for ${toolkitSlug}`);
|
||||
clearTimeout(existingFlow.timeout);
|
||||
existingFlow.server.close();
|
||||
activeFlows.delete(toolkitSlug);
|
||||
}
|
||||
|
||||
// Save initial account state
|
||||
const account: LocalConnectedAccount = {
|
||||
|
|
@ -142,15 +149,27 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
composioAccountsRepo.saveAccount(account);
|
||||
|
||||
// Set up callback server
|
||||
let cleanupTimeout: NodeJS.Timeout;
|
||||
const { server } = await createAuthServer(8081, async (_code, _state) => {
|
||||
const timeoutRef: { current: NodeJS.Timeout | null } = { current: null };
|
||||
let callbackHandled = false;
|
||||
const { server } = await createAuthServer(8081, async () => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
if (callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
// OAuth callback received - sync the account status
|
||||
try {
|
||||
const accountStatus = await composioClient.getConnectedAccount(connectedAccountId);
|
||||
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
|
||||
|
||||
if (accountStatus.status === 'ACTIVE') {
|
||||
// Invalidate instructions cache so the copilot knows about the new connection
|
||||
invalidateCopilotInstructionsCache();
|
||||
emitComposioEvent({ toolkitSlug, success: true });
|
||||
if (toolkitSlug === 'gmail') {
|
||||
triggerGmailSync();
|
||||
}
|
||||
if (toolkitSlug === 'googlecalendar') {
|
||||
triggerCalendarSync();
|
||||
}
|
||||
} else {
|
||||
emitComposioEvent({
|
||||
toolkitSlug,
|
||||
|
|
@ -166,17 +185,17 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
activeFlows.delete(flowKey);
|
||||
activeFlows.delete(toolkitSlug);
|
||||
server.close();
|
||||
clearTimeout(cleanupTimeout);
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout for abandoned flows (5 minutes)
|
||||
cleanupTimeout = setTimeout(() => {
|
||||
if (activeFlows.has(flowKey)) {
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
if (activeFlows.has(toolkitSlug)) {
|
||||
console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`);
|
||||
activeFlows.delete(flowKey);
|
||||
activeFlows.delete(toolkitSlug);
|
||||
server.close();
|
||||
emitComposioEvent({
|
||||
toolkitSlug,
|
||||
|
|
@ -185,6 +204,16 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
});
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
timeoutRef.current = cleanupTimeout;
|
||||
|
||||
// Store flow state (keyed by toolkit to prevent concurrent flows)
|
||||
activeFlows.set(toolkitSlug, {
|
||||
toolkitSlug,
|
||||
connectedAccountId,
|
||||
authConfigId,
|
||||
server,
|
||||
timeout: cleanupTimeout,
|
||||
});
|
||||
|
||||
// Open browser for OAuth
|
||||
shell.openExternal(redirectUrl);
|
||||
|
|
@ -244,18 +273,16 @@ export async function disconnect(toolkitSlug: string): Promise<{ success: boolea
|
|||
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
|
||||
} finally {
|
||||
// Always clean up local state, even if the API call fails
|
||||
composioAccountsRepo.deleteAccount(toolkitSlug);
|
||||
return { success: true };
|
||||
invalidateCopilotInstructionsCache();
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -266,31 +293,38 @@ export function listConnected(): { toolkits: string[] } {
|
|||
}
|
||||
|
||||
/**
|
||||
* Execute a Composio action
|
||||
* Check if Composio should be used for Google services (Gmail, etc.)
|
||||
*/
|
||||
export async function executeAction(
|
||||
actionSlug: string,
|
||||
toolkitSlug: string,
|
||||
input: Record<string, unknown>
|
||||
): 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',
|
||||
};
|
||||
}
|
||||
export async function useComposioForGoogle(): Promise<{ enabled: boolean }> {
|
||||
return { enabled: await composioClient.useComposioForGoogle() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio should be used for Google Calendar
|
||||
*/
|
||||
export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean }> {
|
||||
return { enabled: await composioClient.useComposioForGoogleCalendar() };
|
||||
}
|
||||
|
||||
/**
|
||||
* List available Composio toolkits — filtered to curated list only.
|
||||
* Return type matches the ZToolkit schema from core/composio/types.ts.
|
||||
*/
|
||||
export async function listToolkits() {
|
||||
// Paginate through all API pages to collect every curated toolkit
|
||||
const allItems: Toolkit[] = [];
|
||||
let cursor: string | null = null;
|
||||
const maxPages = 10; // safety limit
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const result = await composioClient.listToolkits(cursor);
|
||||
allItems.push(...result.items);
|
||||
cursor = result.next_cursor;
|
||||
if (!cursor) break;
|
||||
}
|
||||
const filtered = allItems.filter(item => CURATED_TOOLKIT_SLUGS.has(item.slug));
|
||||
return {
|
||||
items: filtered,
|
||||
nextCursor: null as string | null,
|
||||
totalItems: filtered.length,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ipcMain, BrowserWindow, shell, dialog } from 'electron';
|
||||
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
|
@ -39,7 +39,11 @@ import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.
|
|||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||
import { search } from '@x/core/dist/search/search.js';
|
||||
import { versionHistory, voice } from '@x/core';
|
||||
import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||
import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
||||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
|
||||
/**
|
||||
* Convert markdown to a styled HTML document for PDF/DOCX export.
|
||||
|
|
@ -142,10 +146,10 @@ export function registerIpcHandlers(handlers: InvokeHandlers) {
|
|||
ipcMain.handle(channel, async (event, rawArgs) => {
|
||||
// Validate request payload
|
||||
const args = ipc.validateRequest(channel, rawArgs);
|
||||
|
||||
|
||||
// Call handler
|
||||
const result = await handler(event, args);
|
||||
|
||||
|
||||
// Validate response payload
|
||||
return ipc.validateResponse(channel, result);
|
||||
});
|
||||
|
|
@ -469,6 +473,21 @@ export function setupIpcHandlers() {
|
|||
const config = await repo.getClientFacingConfig();
|
||||
return { config };
|
||||
},
|
||||
'account:getRowboat': async () => {
|
||||
const signedIn = await isSignedIn();
|
||||
if (!signedIn) {
|
||||
return { signedIn: false, accessToken: null, config: null };
|
||||
}
|
||||
|
||||
const config = await getRowboatConfig();
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
return { signedIn: true, accessToken, config };
|
||||
} catch {
|
||||
return { signedIn: true, accessToken: null, config };
|
||||
}
|
||||
},
|
||||
'granola:getConfig': async () => {
|
||||
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
|
|
@ -540,8 +559,15 @@ export function setupIpcHandlers() {
|
|||
'composio:list-connected': async () => {
|
||||
return composioHandler.listConnected();
|
||||
},
|
||||
'composio:execute-action': async (_event, args) => {
|
||||
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
|
||||
// Composio Tools Library handlers
|
||||
'composio:list-toolkits': async () => {
|
||||
return composioHandler.listToolkits();
|
||||
},
|
||||
'composio:use-composio-for-google': async () => {
|
||||
return composioHandler.useComposioForGoogle();
|
||||
},
|
||||
'composio:use-composio-for-google-calendar': async () => {
|
||||
return composioHandler.useComposioForGoogleCalendar();
|
||||
},
|
||||
// Agent schedule handlers
|
||||
'agent-schedule:getConfig': async () => {
|
||||
|
|
@ -694,18 +720,44 @@ export function setupIpcHandlers() {
|
|||
|
||||
return { success: false, error: 'Unknown format' };
|
||||
},
|
||||
'meeting:checkScreenPermission': async () => {
|
||||
if (process.platform !== 'darwin') return { granted: true };
|
||||
const status = systemPreferences.getMediaAccessStatus('screen');
|
||||
console.log('[meeting] Screen recording permission status:', status);
|
||||
if (status === 'granted') return { granted: true };
|
||||
// Not granted — call desktopCapturer.getSources() to register the app
|
||||
// in the macOS Screen Recording list. On first call this shows the
|
||||
// native permission prompt (signed apps are remembered across restarts).
|
||||
try { await desktopCapturer.getSources({ types: ['screen'] }); } catch { /* ignore */ }
|
||||
// Re-check after the native prompt was dismissed
|
||||
const statusAfter = systemPreferences.getMediaAccessStatus('screen');
|
||||
console.log('[meeting] Screen recording permission status after prompt:', statusAfter);
|
||||
return { granted: statusAfter === 'granted' };
|
||||
},
|
||||
'meeting:openScreenRecordingSettings': async () => {
|
||||
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
|
||||
return { success: true };
|
||||
},
|
||||
'meeting:summarize': async (_event, args) => {
|
||||
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
|
||||
return { notes };
|
||||
},
|
||||
'inline-task:classifySchedule': async (_event, args) => {
|
||||
const schedule = await classifySchedule(args.instruction);
|
||||
return { schedule };
|
||||
},
|
||||
'inline-task:process': async (_event, args) => {
|
||||
return await processRowboatInstruction(args.instruction, args.noteContent, args.notePath);
|
||||
},
|
||||
'voice:getConfig': async () => {
|
||||
return voice.getVoiceConfig();
|
||||
},
|
||||
'voice:synthesize': async (_event, args) => {
|
||||
return voice.synthesizeSpeech(args.text);
|
||||
},
|
||||
'voice:getDeepgramToken': async () => {
|
||||
return voice.getDeepgramToken();
|
||||
// Billing handler
|
||||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { app, BrowserWindow, protocol, net, shell, session } from "electron";
|
||||
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session } from "electron";
|
||||
import path from "node:path";
|
||||
import {
|
||||
setupIpcHandlers,
|
||||
|
|
@ -21,8 +21,14 @@ import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.j
|
|||
import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
|
||||
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -30,6 +36,28 @@ const __dirname = dirname(__filename);
|
|||
// run this as early in the main process as possible
|
||||
if (started) app.quit();
|
||||
|
||||
// Fix PATH for packaged Electron apps on macOS/Linux.
|
||||
// Packaged apps inherit a minimal environment that doesn't include paths from
|
||||
// the user's shell profile (nvm, Homebrew, etc.). Spawn the user's login shell
|
||||
// to resolve the full PATH, using delimiters to safely extract it from any
|
||||
// surrounding shell output (motd, greeting messages, etc.).
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
const userShell = process.env.SHELL || '/bin/zsh';
|
||||
const delimiter = '__ROWBOAT_PATH__';
|
||||
const output = execSync(
|
||||
`${userShell} -lc 'echo -n "${delimiter}$PATH${delimiter}"'`,
|
||||
{ encoding: 'utf-8', timeout: 5000 },
|
||||
);
|
||||
const match = output.match(new RegExp(`${delimiter}(.+?)${delimiter}`));
|
||||
if (match?.[1]) {
|
||||
process.env.PATH = match[1];
|
||||
}
|
||||
} catch {
|
||||
// Silently fall back to the existing PATH if shell resolution fails
|
||||
}
|
||||
}
|
||||
|
||||
// Path resolution differs between development and production:
|
||||
const preloadPath = app.isPackaged
|
||||
? path.join(__dirname, "../preload/dist/preload.js")
|
||||
|
|
@ -92,17 +120,30 @@ function createWindow() {
|
|||
},
|
||||
});
|
||||
|
||||
// Grant microphone permission for voice mode
|
||||
// Grant microphone and display-capture permissions
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
if (permission === 'media') {
|
||||
if (permission === 'media' || permission === 'display-capture') {
|
||||
callback(true);
|
||||
} else {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-approve display media requests and route system audio as loopback.
|
||||
// Electron requires a video source in the callback even if we only want audio.
|
||||
// We pass the first available screen source; the renderer discards the video track.
|
||||
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
||||
const sources = await desktopCapturer.getSources({ types: ['screen'] });
|
||||
if (sources.length === 0) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
callback({ video: sources[0], audio: 'loopback' });
|
||||
});
|
||||
|
||||
// Show window when content is ready to prevent blank screen
|
||||
win.once("ready-to-show", () => {
|
||||
win.maximize();
|
||||
win.show();
|
||||
});
|
||||
|
||||
|
|
@ -147,6 +188,19 @@ app.whenReady().then(async () => {
|
|||
});
|
||||
}
|
||||
|
||||
// Ensure agent-slack CLI is available
|
||||
try {
|
||||
execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 });
|
||||
} catch {
|
||||
try {
|
||||
console.log('agent-slack not found, installing...');
|
||||
await execAsync('npm install -g agent-slack', { timeout: 60000 });
|
||||
console.log('agent-slack installed successfully');
|
||||
} catch (e) {
|
||||
console.error('Failed to install agent-slack:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize all config files before UI can access them
|
||||
await initConfigs();
|
||||
|
||||
|
|
@ -194,6 +248,12 @@ app.whenReady().then(async () => {
|
|||
// start background agent runner (scheduled agents)
|
||||
initAgentRunner();
|
||||
|
||||
// start agent notes learning service
|
||||
initAgentNotes();
|
||||
|
||||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
|
|||
* Get or create OAuth configuration for a provider
|
||||
*/
|
||||
async function getProviderConfiguration(provider: string, clientIdOverride?: string): Promise<Configuration> {
|
||||
const config = getProviderConfig(provider);
|
||||
const config = await getProviderConfig(provider);
|
||||
const resolveClientId = async (): Promise<string> => {
|
||||
if (config.client.mode === 'static' && config.client.clientId) {
|
||||
return config.client.clientId;
|
||||
|
|
@ -156,7 +156,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
cancelActiveFlow('new_flow_started');
|
||||
|
||||
const oauthRepo = getOAuthRepo();
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
const providerConfig = await getProviderConfig(provider);
|
||||
|
||||
if (provider === 'google') {
|
||||
if (!clientId) {
|
||||
|
|
@ -186,9 +186,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
});
|
||||
|
||||
// Create callback server
|
||||
const { server } = await createAuthServer(8080, async (code, receivedState) => {
|
||||
let callbackHandled = false;
|
||||
const { server } = await createAuthServer(8080, async (params: Record<string, string>) => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
if (callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
// Validate state
|
||||
if (receivedState !== state) {
|
||||
if (params.state !== state) {
|
||||
throw new Error('Invalid state parameter - possible CSRF attack');
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +203,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
|
||||
try {
|
||||
// Build callback URL for token exchange
|
||||
const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`);
|
||||
const callbackUrl = new URL(`${REDIRECT_URI}?${new URLSearchParams(params).toString()}`);
|
||||
|
||||
// Exchange code for tokens
|
||||
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
|
||||
|
|
@ -282,6 +286,8 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
|||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
await oauthRepo.delete(provider);
|
||||
// Notify renderer so sidebar, voice, and billing re-check state
|
||||
emitOAuthEvent({ provider, success: false });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('OAuth disconnect failed:', error);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.8.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^1.6.10",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
|
|||
|
|
@ -49,6 +49,15 @@
|
|||
color: #888;
|
||||
}
|
||||
|
||||
/* Onboarding dot grid background */
|
||||
.onboarding-dot-grid {
|
||||
background-image: radial-gradient(circle, oklch(0.5 0 0 / 0.08) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
.dark .onboarding-dot-grid {
|
||||
background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
|
|
@ -293,3 +302,56 @@
|
|||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Upgrade button: grainy gradient sweep on hover */
|
||||
.upgrade-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.upgrade-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(168, 85, 247, 0.35) 20%,
|
||||
rgba(236, 72, 153, 0.4) 40%,
|
||||
rgba(251, 146, 60, 0.35) 60%,
|
||||
rgba(168, 85, 247, 0.3) 80%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 100px 100px, 100% 100%;
|
||||
transform: translateX(-120%);
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.upgrade-btn:hover::before {
|
||||
animation: grain-sweep 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes grain-sweep {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
45% {
|
||||
opacity: 1;
|
||||
transform: translateX(120%);
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
transform: translateX(120%);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
|||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||
import './App.css'
|
||||
import z from 'zod';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor } from './components/markdown-editor';
|
||||
import { ChatSidebar } from './components/chat-sidebar';
|
||||
|
|
@ -20,7 +20,7 @@ import {
|
|||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ScrollPositionPreserver,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation';
|
||||
import {
|
||||
Message,
|
||||
|
|
@ -33,9 +33,11 @@ import {
|
|||
} from '@/components/ai-elements/prompt-input';
|
||||
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool';
|
||||
import { useSmoothedText } from './hooks/useSmoothedText';
|
||||
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
||||
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
||||
import { Suggestions } from '@/components/ai-elements/suggestions';
|
||||
|
|
@ -46,10 +48,12 @@ import {
|
|||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||
import { OnboardingModal } from '@/components/onboarding-modal'
|
||||
import { OnboardingModal } from '@/components/onboarding'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||
|
|
@ -58,12 +62,15 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over
|
|||
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
|
||||
import {
|
||||
type ChatMessage,
|
||||
type ChatViewportAnchorState,
|
||||
type ChatTabViewState,
|
||||
type ConversationItem,
|
||||
type ToolCall,
|
||||
createEmptyChatTabViewState,
|
||||
getWebSearchCardData,
|
||||
getAppActionCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
inferRunTitleFromMessage,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
|
|
@ -73,11 +80,15 @@ import {
|
|||
parseAttachedFiles,
|
||||
toToolState,
|
||||
} from '@/lib/chat-conversation'
|
||||
import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js'
|
||||
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
|
||||
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
|
||||
import { toast } from "sonner"
|
||||
import { useVoiceMode } from '@/hooks/useVoiceMode'
|
||||
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
|
||||
import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
|
||||
import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
|
||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||
type RunEventType = z.infer<typeof RunEvent>
|
||||
|
|
@ -90,6 +101,11 @@ interface TreeNode extends DirEntry {
|
|||
|
||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||
|
||||
function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) {
|
||||
const smoothText = useSmoothedText(text)
|
||||
return <MessageResponse components={components}>{smoothText}</MessageResponse>
|
||||
}
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 256
|
||||
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
|
||||
const graphPalette = [
|
||||
|
|
@ -258,10 +274,49 @@ const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageMod
|
|||
}
|
||||
}
|
||||
|
||||
// Sort nodes (dirs first, then alphabetically)
|
||||
// Sidebar folder ordering — listed folders appear in this order, unlisted ones follow alphabetically
|
||||
const FOLDER_ORDER = ['People', 'Organizations', 'Projects', 'Topics', 'Meetings', 'Agent Notes', 'Notes']
|
||||
|
||||
/**
|
||||
* Per-folder base view config: which columns to show and default sort.
|
||||
* Folders not listed here fall back to DEFAULT_BASE_CONFIG.
|
||||
*/
|
||||
const FOLDER_BASE_CONFIGS: Record<string, { visibleColumns: string[]; sort: { field: string; dir: 'asc' | 'desc' } }> = {
|
||||
'Agent Notes': {
|
||||
visibleColumns: ['name', 'folder', 'mtimeMs'],
|
||||
sort: { field: 'mtimeMs', dir: 'desc' },
|
||||
},
|
||||
People: {
|
||||
visibleColumns: ['name', 'relationship', 'organization', 'mtimeMs'],
|
||||
sort: { field: 'name', dir: 'asc' },
|
||||
},
|
||||
Organizations: {
|
||||
visibleColumns: ['name', 'relationship', 'mtimeMs'],
|
||||
sort: { field: 'name', dir: 'asc' },
|
||||
},
|
||||
Projects: {
|
||||
visibleColumns: ['name', 'status', 'topic', 'mtimeMs'],
|
||||
sort: { field: 'name', dir: 'asc' },
|
||||
},
|
||||
Topics: {
|
||||
visibleColumns: ['name', 'mtimeMs'],
|
||||
sort: { field: 'name', dir: 'asc' },
|
||||
},
|
||||
Meetings: {
|
||||
visibleColumns: ['name', 'topic', 'mtimeMs'],
|
||||
sort: { field: 'mtimeMs', dir: 'desc' },
|
||||
},
|
||||
}
|
||||
|
||||
// Sort nodes (dirs first, ordered folders by FOLDER_ORDER, then alphabetically)
|
||||
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
||||
const aOrder = FOLDER_ORDER.indexOf(a.name)
|
||||
const bOrder = FOLDER_ORDER.indexOf(b.name)
|
||||
if (aOrder !== -1 && bOrder !== -1) return aOrder - bOrder
|
||||
if (aOrder !== -1) return -1
|
||||
if (bOrder !== -1) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}).map(node => {
|
||||
if (node.children) {
|
||||
|
|
@ -271,6 +326,73 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Organize Meetings/ source folders into date-grouped subfolders.
|
||||
*
|
||||
* - rowboat: rowboat/2026-03-20/meeting-xxx.md → keeps date folders as-is
|
||||
* - granola: granola/2026/03/18/Title.md → collapses into "2026-03-18" folders
|
||||
* - Files directly under a source folder (no date subfolder) are grouped
|
||||
* by the date prefix in their filename (e.g. meeting-2026-03-17T...).
|
||||
*/
|
||||
function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
|
||||
return nodes.flatMap(node => {
|
||||
if (node.kind !== 'dir' || node.name !== 'Meetings') return [node]
|
||||
|
||||
const flattenedSourceChildren = (node.children ?? []).flatMap(sourceNode => {
|
||||
if (sourceNode.kind !== 'dir') return [sourceNode]
|
||||
|
||||
// Collect all files with their date group label
|
||||
const dateGroups = new Map<string, TreeNode[]>()
|
||||
|
||||
function collectFiles(n: TreeNode, dateParts: string[]) {
|
||||
for (const child of n.children ?? []) {
|
||||
if (child.kind === 'file') {
|
||||
const dateStr = dateParts.join('-')
|
||||
// If file is at root of source folder, try to extract date from filename
|
||||
const groupKey = dateStr || extractDateFromFilename(child.name) || 'other'
|
||||
const group = dateGroups.get(groupKey) ?? []
|
||||
group.push(child)
|
||||
dateGroups.set(groupKey, group)
|
||||
} else if (child.kind === 'dir') {
|
||||
collectFiles(child, [...dateParts, child.name])
|
||||
}
|
||||
}
|
||||
}
|
||||
collectFiles(sourceNode, [])
|
||||
|
||||
if (dateGroups.size === 0) return []
|
||||
|
||||
// Build date folder nodes, sorted reverse chronologically
|
||||
const dateFolderNodes: TreeNode[] = [...dateGroups.entries()]
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([dateKey, files]) => {
|
||||
// Sort files within each date group reverse chronologically
|
||||
files.sort((a, b) => b.name.localeCompare(a.name))
|
||||
return {
|
||||
name: dateKey,
|
||||
path: `${sourceNode.path}/${dateKey}`,
|
||||
kind: 'dir' as const,
|
||||
children: files,
|
||||
loaded: true,
|
||||
}
|
||||
})
|
||||
|
||||
return [{ ...sourceNode, children: dateFolderNodes }]
|
||||
})
|
||||
|
||||
// Hide Meetings folder entirely if no source folders have files
|
||||
if (flattenedSourceChildren.length === 0) return []
|
||||
|
||||
return [{ ...node, children: flattenedSourceChildren }]
|
||||
})
|
||||
}
|
||||
|
||||
/** Extract YYYY-MM-DD from filenames like "meeting-2026-03-17T05-01-47.md" */
|
||||
function extractDateFromFilename(name: string): string | null {
|
||||
const match = name.match(/(\d{4}-\d{2}-\d{2})/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
// Build tree structure from flat entries
|
||||
function buildTree(entries: DirEntry[]): TreeNode[] {
|
||||
const treeMap = new Map<string, TreeNode>()
|
||||
|
|
@ -332,6 +454,10 @@ function FixedSidebarToggle({
|
|||
canNavigateForward,
|
||||
onNewChat,
|
||||
onOpenSearch,
|
||||
meetingState,
|
||||
meetingSummarizing,
|
||||
meetingAvailable,
|
||||
onToggleMeeting,
|
||||
leftInsetPx,
|
||||
}: {
|
||||
onNavigateBack: () => void
|
||||
|
|
@ -340,6 +466,10 @@ function FixedSidebarToggle({
|
|||
canNavigateForward: boolean
|
||||
onNewChat: () => void
|
||||
onOpenSearch: () => void
|
||||
meetingState: MeetingTranscriptionState
|
||||
meetingSummarizing: boolean
|
||||
meetingAvailable: boolean
|
||||
onToggleMeeting: () => void
|
||||
leftInsetPx: number
|
||||
}) {
|
||||
const { toggleSidebar, state } = useSidebar()
|
||||
|
|
@ -375,6 +505,37 @@ function FixedSidebarToggle({
|
|||
>
|
||||
<SearchIcon className="size-5" />
|
||||
</button>
|
||||
{meetingAvailable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMeeting}
|
||||
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md transition-colors disabled:pointer-events-none",
|
||||
meetingSummarizing
|
||||
? "text-muted-foreground"
|
||||
: meetingState === 'recording'
|
||||
? "text-red-500 hover:bg-accent"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
>
|
||||
{meetingSummarizing || meetingState === 'connecting' ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : meetingState === 'recording' ? (
|
||||
<SquareIcon className="size-4 animate-pulse" />
|
||||
) : (
|
||||
<RadioIcon className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'connecting' ? 'Starting transcription...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Back / Forward navigation */}
|
||||
{isCollapsed && (
|
||||
<>
|
||||
|
|
@ -465,6 +626,8 @@ function App() {
|
|||
type ShortcutPane = 'left' | 'right'
|
||||
type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean }
|
||||
|
||||
useAnalyticsIdentity()
|
||||
|
||||
// File browser state (for Knowledge section)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string>('')
|
||||
|
|
@ -568,20 +731,38 @@ function App() {
|
|||
const voiceRef = useRef(voice)
|
||||
voiceRef.current = voice
|
||||
|
||||
// Check if voice is available on mount
|
||||
useEffect(() => {
|
||||
const handleToggleMeetingRef = useRef<(() => void) | undefined>(undefined)
|
||||
const meetingTranscription = useMeetingTranscription(() => {
|
||||
handleToggleMeetingRef.current?.()
|
||||
})
|
||||
|
||||
// Check if voice is available on mount and when OAuth state changes
|
||||
const refreshVoiceAvailability = useCallback(() => {
|
||||
Promise.all([
|
||||
window.ipc.invoke('voice:getConfig', null),
|
||||
window.ipc.invoke('oauth:getState', null),
|
||||
]).then(([config, oauthState]) => {
|
||||
const rowboatConnected = oauthState.config?.rowboat?.connected ?? false
|
||||
setVoiceAvailable(!!config.deepgram || rowboatConnected)
|
||||
const hasVoice = !!config.deepgram || rowboatConnected
|
||||
setVoiceAvailable(hasVoice)
|
||||
setTtsAvailable(!!config.elevenlabs || rowboatConnected)
|
||||
// Pre-cache auth details so mic click skips IPC round-trips
|
||||
if (hasVoice) {
|
||||
voice.warmup()
|
||||
}
|
||||
}).catch(() => {
|
||||
setVoiceAvailable(false)
|
||||
setTtsAvailable(false)
|
||||
})
|
||||
}, [])
|
||||
}, [voice.warmup])
|
||||
|
||||
useEffect(() => {
|
||||
refreshVoiceAvailability()
|
||||
const cleanup = window.ipc.on('oauth:didConnect', () => {
|
||||
refreshVoiceAvailability()
|
||||
})
|
||||
return cleanup
|
||||
}, [refreshVoiceAvailability])
|
||||
|
||||
const handleStartRecording = useCallback(() => {
|
||||
setIsRecording(true)
|
||||
|
|
@ -589,7 +770,7 @@ function App() {
|
|||
voice.start()
|
||||
}, [voice])
|
||||
|
||||
const handlePromptSubmitRef = useRef<((msg: { text: string }) => void) | null>(null)
|
||||
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise<void>) | null>(null)
|
||||
const pendingVoiceInputRef = useRef(false)
|
||||
|
||||
const handleSubmitRecording = useCallback(() => {
|
||||
|
|
@ -598,7 +779,7 @@ function App() {
|
|||
isRecordingRef.current = false
|
||||
if (text) {
|
||||
pendingVoiceInputRef.current = true
|
||||
handlePromptSubmitRef.current?.({ text })
|
||||
handlePromptSubmitRef.current?.({ text, files: [] })
|
||||
}
|
||||
}, [voice])
|
||||
|
||||
|
|
@ -624,6 +805,22 @@ function App() {
|
|||
isRecordingRef.current = false
|
||||
}, [voice])
|
||||
|
||||
// Enter to submit voice input, Escape to cancel
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isRecordingRef.current) return
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmitRecording()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleCancelRecording()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleSubmitRecording, handleCancelRecording])
|
||||
|
||||
// Helper to cancel recording from any navigation handler
|
||||
const cancelRecordingIfActive = useCallback(() => {
|
||||
if (isRecordingRef.current) {
|
||||
|
|
@ -649,6 +846,7 @@ function App() {
|
|||
const chatDraftsRef = useRef(new Map<string, string>())
|
||||
const chatScrollTopByTabRef = useRef(new Map<string, number>())
|
||||
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
|
||||
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({})
|
||||
const activeChatTabIdRef = useRef(activeChatTabId)
|
||||
activeChatTabIdRef.current = activeChatTabId
|
||||
const setChatDraftForTab = useCallback((tabId: string, text: string) => {
|
||||
|
|
@ -674,6 +872,18 @@ function App() {
|
|||
}
|
||||
})
|
||||
}, [])
|
||||
const setChatViewportAnchor = useCallback((tabId: string, messageId: string | null) => {
|
||||
setChatViewportAnchorByTab((prev) => {
|
||||
const prevForTab = prev[tabId]
|
||||
return {
|
||||
...prev,
|
||||
[tabId]: {
|
||||
messageId,
|
||||
requestKey: (prevForTab?.requestKey ?? 0) + 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
const getChatScrollContainer = useCallback((tabId: string): HTMLElement | null => {
|
||||
if (typeof document === 'undefined') return null
|
||||
const panel = document.querySelector<HTMLElement>(
|
||||
|
|
@ -775,6 +985,22 @@ function App() {
|
|||
})
|
||||
}, [chatTabs])
|
||||
|
||||
useEffect(() => {
|
||||
const tabIds = new Set(chatTabs.map((tab) => tab.id))
|
||||
setChatViewportAnchorByTab((prev) => {
|
||||
let changed = false
|
||||
const next: Record<string, ChatViewportAnchorState> = {}
|
||||
for (const [tabId, state] of Object.entries(prev)) {
|
||||
if (tabIds.has(tabId)) {
|
||||
next[tabId] = state
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [chatTabs])
|
||||
|
||||
// Workspace root for full paths
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
|
||||
|
||||
|
|
@ -928,7 +1154,7 @@ function App() {
|
|||
opts: { recursive: false, includeHidden: false, includeStats: true }
|
||||
}).catch(() => [] as DirEntry[]),
|
||||
])
|
||||
const knowledgeTree = buildTree(knowledgeResult)
|
||||
const knowledgeTree = flattenMeetingsTree(buildTree(knowledgeResult))
|
||||
const basesChildren: TreeNode[] = (basesResult as DirEntry[])
|
||||
.filter((e) => e.name.endsWith('.base'))
|
||||
.map((e) => ({ ...e, kind: 'file' as const }))
|
||||
|
|
@ -948,10 +1174,12 @@ function App() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Ensure bases/ directory exists on startup
|
||||
// Ensure bases/ and knowledge/Notes/ directories exist on startup
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('workspace:mkdir', { path: 'bases', recursive: true })
|
||||
.catch((err: unknown) => console.error('Failed to ensure bases directory:', err))
|
||||
window.ipc.invoke('workspace:mkdir', { path: 'knowledge/Notes', recursive: true })
|
||||
.catch((err: unknown) => console.error('Failed to ensure Notes directory:', err))
|
||||
}, [])
|
||||
|
||||
// Load initial tree
|
||||
|
|
@ -1673,7 +1901,7 @@ function App() {
|
|||
const inferredTitle = inferRunTitleFromMessage(msg.content)
|
||||
if (inferredTitle) {
|
||||
setRuns(prev => prev.map(run => (
|
||||
run.id === event.runId && run.title !== inferredTitle
|
||||
run.id === event.runId && !run.title
|
||||
? { ...run, title: inferredTitle }
|
||||
: run
|
||||
)))
|
||||
|
|
@ -1901,6 +2129,7 @@ function App() {
|
|||
) => {
|
||||
if (isProcessing) return
|
||||
|
||||
const submitTabId = activeChatTabIdRef.current
|
||||
const { text } = message
|
||||
const userMessage = text.trim()
|
||||
const hasAttachments = stagedAttachments.length > 0
|
||||
|
|
@ -1925,6 +2154,7 @@ function App() {
|
|||
attachments: displayAttachments,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
setChatViewportAnchor(submitTabId, userMessageId)
|
||||
|
||||
try {
|
||||
let currentRunId = runId
|
||||
|
|
@ -1937,9 +2167,10 @@ function App() {
|
|||
currentRunId = run.id
|
||||
newRunCreatedAt = run.createdAt
|
||||
setRunId(currentRunId)
|
||||
analytics.chatSessionCreated(currentRunId)
|
||||
// Update active chat tab's runId to the new run
|
||||
setChatTabs((prev) => prev.map((tab) => (
|
||||
tab.id === activeChatTabId
|
||||
tab.id === submitTabId
|
||||
? { ...tab, runId: currentRunId }
|
||||
: tab
|
||||
)))
|
||||
|
|
@ -1997,6 +2228,11 @@ function App() {
|
|||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||
searchEnabled: searchEnabled || undefined,
|
||||
})
|
||||
analytics.chatMessageSent({
|
||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||
searchEnabled: searchEnabled || undefined,
|
||||
})
|
||||
} else {
|
||||
// Legacy path: plain string with optional XML-formatted @mentions.
|
||||
let formattedMessage = userMessage
|
||||
|
|
@ -2028,6 +2264,11 @@ function App() {
|
|||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||
searchEnabled: searchEnabled || undefined,
|
||||
})
|
||||
analytics.chatMessageSent({
|
||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||
searchEnabled: searchEnabled || undefined,
|
||||
})
|
||||
|
||||
titleSource = formattedMessage
|
||||
}
|
||||
|
|
@ -2052,6 +2293,12 @@ function App() {
|
|||
}
|
||||
handlePromptSubmitRef.current = handlePromptSubmit
|
||||
|
||||
const handleComposioConnected = useCallback((toolkitSlug: string) => {
|
||||
// Auto-send a continuation message when a Composio toolkit connects
|
||||
const name = composioDisplayNames[toolkitSlug] || toolkitSlug
|
||||
handlePromptSubmitRef.current?.({ text: `${name} connected successfully.`, files: [] })
|
||||
}, [])
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!runId) return
|
||||
const now = Date.now()
|
||||
|
|
@ -2129,11 +2376,12 @@ function App() {
|
|||
setAllPermissionRequests(new Map())
|
||||
setPermissionResponses(new Map())
|
||||
setSelectedBackgroundTask(null)
|
||||
setChatViewportAnchor(activeChatTabIdRef.current, null)
|
||||
setChatViewStateByTab(prev => ({
|
||||
...prev,
|
||||
[activeChatTabIdRef.current]: createEmptyChatTabViewState(),
|
||||
}))
|
||||
}, [])
|
||||
}, [setChatViewportAnchor])
|
||||
|
||||
// Chat tab operations
|
||||
const applyChatTab = useCallback((tab: ChatTab) => {
|
||||
|
|
@ -2151,8 +2399,9 @@ function App() {
|
|||
setPendingAskHumanRequests(new Map())
|
||||
setAllPermissionRequests(new Map())
|
||||
setPermissionResponses(new Map())
|
||||
setChatViewportAnchor(tab.id, null)
|
||||
}
|
||||
}, [loadRun])
|
||||
}, [loadRun, setChatViewportAnchor])
|
||||
|
||||
const restoreChatTabState = useCallback((tabId: string, fallbackRunId: string | null): boolean => {
|
||||
const cached = chatViewStateByTabRef.current[tabId]
|
||||
|
|
@ -2992,6 +3241,31 @@ function App() {
|
|||
return
|
||||
}
|
||||
|
||||
// Top-level knowledge folders (except Notes) open as a bases view with folder filter
|
||||
const parts = path.split('/')
|
||||
if (parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes') {
|
||||
const folderName = parts[1]
|
||||
const folderCfg = FOLDER_BASE_CONFIGS[folderName]
|
||||
setBaseConfigByPath((prev) => ({
|
||||
...prev,
|
||||
[BASES_DEFAULT_TAB_PATH]: {
|
||||
...DEFAULT_BASE_CONFIG,
|
||||
name: folderName,
|
||||
filters: [{ category: 'folder', value: folderName }],
|
||||
...(folderCfg && {
|
||||
visibleColumns: folderCfg.visibleColumns,
|
||||
sort: folderCfg.sort,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||
return
|
||||
}
|
||||
|
||||
const newExpanded = new Set(expandedPaths)
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path)
|
||||
|
|
@ -3070,7 +3344,7 @@ function App() {
|
|||
}, [])
|
||||
|
||||
const knowledgeActions = React.useMemo(() => ({
|
||||
createNote: async (parentPath: string = 'knowledge') => {
|
||||
createNote: async (parentPath: string = 'knowledge/Notes') => {
|
||||
try {
|
||||
let index = 0
|
||||
let name = untitledBaseName
|
||||
|
|
@ -3093,7 +3367,7 @@ function App() {
|
|||
throw err
|
||||
}
|
||||
},
|
||||
createFolder: async (parentPath: string = 'knowledge') => {
|
||||
createFolder: async (parentPath: string = 'knowledge/Notes') => {
|
||||
try {
|
||||
await window.ipc.invoke('workspace:mkdir', {
|
||||
path: `${parentPath}/new-folder-${Date.now()}`,
|
||||
|
|
@ -3195,7 +3469,14 @@ function App() {
|
|||
},
|
||||
copyPath: (path: string) => {
|
||||
const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path
|
||||
navigator.clipboard.writeText(fullPath)
|
||||
navigator.clipboard.writeText(fullPath).catch(() => {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = fullPath
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
})
|
||||
},
|
||||
onOpenInNewTab: (path: string) => {
|
||||
openFileInNewTab(path)
|
||||
|
|
@ -3220,9 +3501,171 @@ function App() {
|
|||
return newSet
|
||||
})
|
||||
|
||||
// Select the file to show it in the editor
|
||||
// If tab already exists for this path (e.g. second call after transcription),
|
||||
// force a content reload instead of creating a duplicate tab.
|
||||
const existingTab = fileTabs.find(tab => tab.path === notePath)
|
||||
if (existingTab) {
|
||||
setActiveFileTabId(existingTab.id)
|
||||
// Read fresh content from disk and update the editor
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' })
|
||||
const { raw: fm, body } = splitFrontmatter(result.data)
|
||||
frontmatterByPathRef.current.set(notePath, fm)
|
||||
setFileContent(body)
|
||||
setEditorContent(body)
|
||||
editorContentRef.current = body
|
||||
editorPathRef.current = notePath
|
||||
initialContentRef.current = body
|
||||
initialContentByPathRef.current.set(notePath, body)
|
||||
setEditorContentByPath(prev => ({ ...prev, [notePath]: body }))
|
||||
editorContentByPathRef.current.set(notePath, body)
|
||||
// Bump editor session to force TipTap to pick up the new content
|
||||
setEditorSessionByTabId(prev => ({
|
||||
...prev,
|
||||
[existingTab.id]: (prev[existingTab.id] ?? 0) + 1,
|
||||
}))
|
||||
} catch {
|
||||
// File read failed — ignore
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// First call — open the file in a tab
|
||||
navigateToFile(notePath)
|
||||
}, [loadDirectory, navigateToFile])
|
||||
}, [loadDirectory, navigateToFile, fileTabs])
|
||||
|
||||
const meetingNotePathRef = useRef<string | null>(null)
|
||||
const pendingCalendarEventRef = useRef<CalendarEventMeta | undefined>(undefined)
|
||||
const [meetingSummarizing, setMeetingSummarizing] = useState(false)
|
||||
const [showMeetingPermissions, setShowMeetingPermissions] = useState(false)
|
||||
|
||||
const [checkingPermission, setCheckingPermission] = useState(false)
|
||||
|
||||
const startMeetingNow = useCallback(async () => {
|
||||
const calEvent = pendingCalendarEventRef.current
|
||||
pendingCalendarEventRef.current = undefined
|
||||
const notePath = await meetingTranscription.start(calEvent)
|
||||
if (notePath) {
|
||||
meetingNotePathRef.current = notePath
|
||||
await handleVoiceNoteCreated(notePath)
|
||||
}
|
||||
}, [meetingTranscription, handleVoiceNoteCreated])
|
||||
|
||||
const handleCheckPermissionAndRetry = useCallback(async () => {
|
||||
setCheckingPermission(true)
|
||||
try {
|
||||
const { granted } = await window.ipc.invoke('meeting:checkScreenPermission', null)
|
||||
if (granted) {
|
||||
setShowMeetingPermissions(false)
|
||||
await startMeetingNow()
|
||||
}
|
||||
} finally {
|
||||
setCheckingPermission(false)
|
||||
}
|
||||
}, [startMeetingNow])
|
||||
|
||||
const handleOpenScreenRecordingSettings = useCallback(async () => {
|
||||
await window.ipc.invoke('meeting:openScreenRecordingSettings', null)
|
||||
}, [])
|
||||
|
||||
const handleToggleMeeting = useCallback(async () => {
|
||||
if (meetingTranscription.state === 'recording') {
|
||||
await meetingTranscription.stop()
|
||||
|
||||
// Read the final transcript and generate meeting notes via LLM
|
||||
const notePath = meetingNotePathRef.current
|
||||
if (notePath) {
|
||||
setMeetingSummarizing(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: notePath, encoding: 'utf8' })
|
||||
const fileContent = result.data
|
||||
if (fileContent && fileContent.trim()) {
|
||||
// Extract meeting start time and calendar event from frontmatter
|
||||
const dateMatch = fileContent.match(/^date:\s*"(.+)"$/m)
|
||||
const meetingStartTime = dateMatch?.[1]
|
||||
// If a calendar event was linked, pass it directly so the summarizer
|
||||
// skips scanning and uses this event for attendee/title info.
|
||||
const calEventMatch = fileContent.match(/^calendar_event:\s*'(.+)'$/m)
|
||||
const calendarEventJson = calEventMatch?.[1]?.replace(/''/g, "'")
|
||||
const { notes } = await window.ipc.invoke('meeting:summarize', { transcript: fileContent, meetingStartTime, calendarEventJson })
|
||||
if (notes) {
|
||||
// Prepend meeting notes above the existing transcript block
|
||||
const { raw: fm, body } = splitFrontmatter(fileContent)
|
||||
const fmTitleMatch = fileContent.match(/^title:\s*(.+)$/m)
|
||||
const noteTitle = fmTitleMatch?.[1]?.trim() || 'Meeting Notes'
|
||||
const cleanedNotes = notes.replace(/^#{1,2}\s+.+\n+/, '')
|
||||
// Extract the existing transcript block and preserve it as-is
|
||||
const transcriptBlockMatch = body.match(/(```transcript\n[\s\S]*?\n```)/)
|
||||
const transcriptBlock = transcriptBlockMatch?.[1] || ''
|
||||
const newBody = `# ${noteTitle}\n\n` + cleanedNotes + (transcriptBlock ? '\n\n' + transcriptBlock : '')
|
||||
const newContent = fm ? `${fm}\n${newBody}` : newBody
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
data: newContent,
|
||||
opts: { encoding: 'utf8' },
|
||||
})
|
||||
// Refresh the file view
|
||||
await handleVoiceNoteCreated(notePath)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[meeting] Failed to generate meeting notes:', err)
|
||||
}
|
||||
setMeetingSummarizing(false)
|
||||
meetingNotePathRef.current = null
|
||||
}
|
||||
} else if (meetingTranscription.state === 'idle') {
|
||||
// On macOS, check screen recording permission before starting
|
||||
if (isMac) {
|
||||
const result = await window.ipc.invoke('meeting:checkScreenPermission', null)
|
||||
console.log('[meeting] Permission check result:', result)
|
||||
if (!result.granted) {
|
||||
setShowMeetingPermissions(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
await startMeetingNow()
|
||||
}
|
||||
}, [meetingTranscription, handleVoiceNoteCreated, startMeetingNow])
|
||||
handleToggleMeetingRef.current = handleToggleMeeting
|
||||
|
||||
// Listen for calendar block "join meeting & take notes" events
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
// Read calendar event data set by the calendar block on window
|
||||
const pending = window.__pendingCalendarEvent
|
||||
window.__pendingCalendarEvent = undefined
|
||||
if (pending) {
|
||||
pendingCalendarEventRef.current = {
|
||||
summary: pending.summary,
|
||||
start: pending.start,
|
||||
end: pending.end,
|
||||
location: pending.location,
|
||||
htmlLink: pending.htmlLink,
|
||||
conferenceLink: pending.conferenceLink,
|
||||
source: pending.source,
|
||||
}
|
||||
}
|
||||
// Use the same toggle flow — it will pick up pendingCalendarEventRef
|
||||
handleToggleMeetingRef.current?.()
|
||||
}
|
||||
window.addEventListener('calendar-block:join-meeting', handler)
|
||||
return () => window.removeEventListener('calendar-block:join-meeting', handler)
|
||||
}, [])
|
||||
|
||||
// Email block: draft with assistant
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const pending = window.__pendingEmailDraft
|
||||
if (pending) {
|
||||
setPresetMessage(pending.prompt)
|
||||
setIsChatSidebarOpen(true)
|
||||
window.__pendingEmailDraft = undefined
|
||||
}
|
||||
}
|
||||
window.addEventListener('email-block:draft-with-assistant', handler)
|
||||
return () => window.removeEventListener('email-block:draft-with-assistant', handler)
|
||||
}, [])
|
||||
|
||||
const ensureWikiFile = useCallback(async (wikiPath: string) => {
|
||||
const resolvedPath = toKnowledgePath(wikiPath)
|
||||
|
|
@ -3276,12 +3719,17 @@ function App() {
|
|||
return
|
||||
}
|
||||
|
||||
const nodeSet = new Set(knowledgeFilePaths)
|
||||
const graphFilePaths = knowledgeFilePaths.filter((p) => {
|
||||
const normalized = stripKnowledgePrefix(p)
|
||||
return !normalized.toLowerCase().startsWith('meetings/')
|
||||
})
|
||||
|
||||
const nodeSet = new Set(graphFilePaths)
|
||||
const edges: GraphEdge[] = []
|
||||
const edgeKeys = new Set<string>()
|
||||
|
||||
const contents = await Promise.all(
|
||||
knowledgeFilePaths.map(async (path) => {
|
||||
graphFilePaths.map(async (path) => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path })
|
||||
return { path, data: result.data as string }
|
||||
|
|
@ -3340,7 +3788,7 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
const nodes = knowledgeFilePaths.map((path) => {
|
||||
const nodes = graphFilePaths.map((path) => {
|
||||
const degree = degreeMap.get(path) ?? 0
|
||||
const radius = 6 + Math.min(18, degree * 2)
|
||||
const { group, depth } = getNodeGroup(path)
|
||||
|
|
@ -3380,7 +3828,7 @@ function App() {
|
|||
if (item.role === 'user') {
|
||||
if (item.attachments && item.attachments.length > 0) {
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
|
|
@ -3392,7 +3840,7 @@ function App() {
|
|||
}
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent>
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
|
|
@ -3412,7 +3860,7 @@ function App() {
|
|||
)
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent>
|
||||
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
|
||||
</MessageContent>
|
||||
|
|
@ -3437,6 +3885,22 @@ function App() {
|
|||
/>
|
||||
)
|
||||
}
|
||||
const composioConnectData = getComposioConnectCardData(item)
|
||||
if (composioConnectData) {
|
||||
// Skip rendering if this is a duplicate "already connected" card
|
||||
if (composioConnectData.hidden) return null
|
||||
return (
|
||||
<ComposioConnectCard
|
||||
key={item.id}
|
||||
toolkitSlug={composioConnectData.toolkitSlug}
|
||||
toolkitDisplayName={composioConnectData.toolkitDisplayName}
|
||||
status={item.status}
|
||||
alreadyConnected={composioConnectData.alreadyConnected}
|
||||
onConnected={handleComposioConnected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const toolTitle = getToolDisplayName(item)
|
||||
const errorText = item.status === 'error' ? 'Tool error' : ''
|
||||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
|
|
@ -3447,15 +3911,12 @@ function App() {
|
|||
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
|
||||
>
|
||||
<ToolHeader
|
||||
title={item.name}
|
||||
title={toolTitle}
|
||||
type={`tool-${item.name}`}
|
||||
state={toToolState(item.status)}
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolInput input={input} />
|
||||
{output !== null ? (
|
||||
<ToolOutput output={output} errorText={errorText} />
|
||||
) : null}
|
||||
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
|
|
@ -3463,7 +3924,7 @@ function App() {
|
|||
|
||||
if (isErrorMessage(item)) {
|
||||
return (
|
||||
<Message key={item.id} from="assistant">
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
<MessageContent className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs">{item.message}</pre>
|
||||
</MessageContent>
|
||||
|
|
@ -3514,7 +3975,11 @@ function App() {
|
|||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<SidebarSectionProvider defaultSection="tasks">
|
||||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
||||
if (section === 'knowledge' && !selectedPath && !isGraphOpen) {
|
||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||
}
|
||||
}}>
|
||||
<div className="flex h-svh w-full overflow-hidden">
|
||||
{/* Content sidebar with SidebarProvider for collapse functionality */}
|
||||
<SidebarProvider
|
||||
|
|
@ -3736,6 +4201,11 @@ function App() {
|
|||
onSave={(name) => void handleBaseSave(name)}
|
||||
externalSearch={externalBaseSearch}
|
||||
onExternalSearchConsumed={() => setExternalBaseSearch(undefined)}
|
||||
actions={{
|
||||
rename: knowledgeActions.rename,
|
||||
remove: knowledgeActions.remove,
|
||||
copyPath: knowledgeActions.copyPath,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isGraphOpen ? (
|
||||
|
|
@ -3743,7 +4213,7 @@ function App() {
|
|||
<GraphView
|
||||
nodes={graphData.nodes}
|
||||
edges={graphData.edges}
|
||||
isLoading={graphStatus === 'loading'}
|
||||
isLoading={false}
|
||||
error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null}
|
||||
onSelectNode={(path) => {
|
||||
navigateToFile(path)
|
||||
|
|
@ -3775,6 +4245,7 @@ function App() {
|
|||
>
|
||||
<MarkdownEditor
|
||||
content={tabContent}
|
||||
notePath={tab.path}
|
||||
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
|
||||
onPrimaryHeadingCommit={() => {
|
||||
untitledRenameReadyPathsRef.current.add(tab.path)
|
||||
|
|
@ -3811,6 +4282,7 @@ function App() {
|
|||
const title = getBaseName(tab.path)
|
||||
try {
|
||||
await window.ipc.invoke('export:note', { markdown, format, title })
|
||||
analytics.noteExported(format)
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err)
|
||||
}
|
||||
|
|
@ -3899,8 +4371,11 @@ function App() {
|
|||
data-chat-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<ScrollPositionPreserver />
|
||||
<Conversation
|
||||
anchorMessageId={chatViewportAnchorByTab[tab.id]?.messageId}
|
||||
anchorRequestKey={chatViewportAnchorByTab[tab.id]?.requestKey}
|
||||
className="relative flex-1"
|
||||
>
|
||||
<ConversationContent className={tabConversationContentClassName}>
|
||||
{!tabHasConversation ? (
|
||||
<ConversationEmptyState className="h-auto">
|
||||
|
|
@ -3947,7 +4422,7 @@ function App() {
|
|||
{tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage.replace(/<\/?voice>/g, '')}</MessageResponse>
|
||||
<SmoothStreamingMessage text={tabState.currentAssistantMessage.replace(/<\/?voice>/g, '')} components={streamdownComponents} />
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
|
|
@ -3962,6 +4437,7 @@ function App() {
|
|||
</>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -4038,6 +4514,7 @@ function App() {
|
|||
conversation={conversation}
|
||||
currentAssistantMessage={currentAssistantMessage}
|
||||
chatTabStates={chatViewStateByTab}
|
||||
viewportAnchors={chatViewportAnchorByTab}
|
||||
isProcessing={isProcessing}
|
||||
isStopping={isStopping}
|
||||
onStop={handleStop}
|
||||
|
|
@ -4071,6 +4548,7 @@ function App() {
|
|||
ttsMode={ttsMode}
|
||||
onToggleTts={handleToggleTts}
|
||||
onTtsModeChange={handleTtsModeChange}
|
||||
onComposioConnected={handleComposioConnected}
|
||||
/>
|
||||
)}
|
||||
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
|
||||
|
|
@ -4081,6 +4559,10 @@ function App() {
|
|||
canNavigateForward={canNavigateForward}
|
||||
onNewChat={handleNewChatTab}
|
||||
onOpenSearch={() => setIsSearchOpen(true)}
|
||||
meetingState={meetingTranscription.state}
|
||||
meetingSummarizing={meetingSummarizing}
|
||||
meetingAvailable={voiceAvailable}
|
||||
onToggleMeeting={() => { void handleToggleMeeting() }}
|
||||
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
|
|
@ -4097,6 +4579,31 @@ function App() {
|
|||
open={showOnboarding}
|
||||
onComplete={handleOnboardingComplete}
|
||||
/>
|
||||
<Dialog open={showMeetingPermissions} onOpenChange={setShowMeetingPermissions}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Screen recording permission required</DialogTitle>
|
||||
<DialogDescription>
|
||||
Rowboat needs <strong>Screen Recording</strong> permission to capture meeting audio from other apps (Zoom, Meet, etc.). This feature won't work without it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>To enable this:</p>
|
||||
<ol className="list-decimal list-inside space-y-1.5">
|
||||
<li>Open <strong>System Settings</strong> → <strong>Privacy & Security</strong> → <strong>Screen Recording</strong></li>
|
||||
<li>Toggle on <strong>Rowboat</strong></li>
|
||||
<li>You may need to restart the app after granting permission</li>
|
||||
</ol>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowMeetingPermissions(false)}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => { void handleOpenScreenRecordingSettings() }}>Open System Settings</Button>
|
||||
<Button onClick={() => { void handleCheckPermissionAndRetry() }} disabled={checkingPermission}>
|
||||
{checkingPermission ? 'Checking...' : 'Check Again'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
Link2Icon,
|
||||
LoaderIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ComposioConnectCardProps {
|
||||
toolkitSlug: string;
|
||||
toolkitDisplayName: string;
|
||||
status: "pending" | "running" | "completed" | "error";
|
||||
alreadyConnected?: boolean;
|
||||
onConnected?: (toolkitSlug: string) => void;
|
||||
}
|
||||
|
||||
export function ComposioConnectCard({
|
||||
toolkitSlug,
|
||||
toolkitDisplayName,
|
||||
status,
|
||||
alreadyConnected,
|
||||
onConnected,
|
||||
}: ComposioConnectCardProps) {
|
||||
const [connectionState, setConnectionState] = useState<
|
||||
"idle" | "connecting" | "connected" | "error"
|
||||
>(alreadyConnected ? "connected" : "idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const didFireCallback = useRef(alreadyConnected ?? false);
|
||||
|
||||
// Listen for composio:didConnect events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on(
|
||||
"composio:didConnect",
|
||||
(event: { toolkitSlug: string; success: boolean; error?: string }) => {
|
||||
if (event.toolkitSlug !== toolkitSlug) return;
|
||||
if (event.success) {
|
||||
setConnectionState("connected");
|
||||
setErrorMessage(null);
|
||||
if (!didFireCallback.current) {
|
||||
didFireCallback.current = true;
|
||||
onConnected?.(toolkitSlug);
|
||||
}
|
||||
} else {
|
||||
setConnectionState("error");
|
||||
setErrorMessage(event.error || "Connection failed");
|
||||
}
|
||||
}
|
||||
);
|
||||
return cleanup;
|
||||
}, [toolkitSlug, onConnected]);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
setConnectionState("connecting");
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:initiate-connection", {
|
||||
toolkitSlug,
|
||||
});
|
||||
if (!result.success) {
|
||||
setConnectionState("error");
|
||||
setErrorMessage(result.error || "Failed to initiate connection");
|
||||
}
|
||||
} catch {
|
||||
setConnectionState("error");
|
||||
setErrorMessage("Failed to initiate connection");
|
||||
}
|
||||
}, [toolkitSlug]);
|
||||
|
||||
const isToolRunning = status === "pending" || status === "running";
|
||||
const displayName = toolkitDisplayName || toolkitSlug;
|
||||
|
||||
return (
|
||||
<div className="not-prose mb-4 flex items-center gap-3 rounded-lg border px-3 py-2.5">
|
||||
{/* Toolkit initial */}
|
||||
<div className="size-7 rounded bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Name & status */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{displayName}</span>
|
||||
{connectionState === "connected" && (
|
||||
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{connectionState === "error" && errorMessage && (
|
||||
<p className="text-xs text-destructive truncate">{errorMessage}</p>
|
||||
)}
|
||||
{connectionState === "idle" && isToolRunning && (
|
||||
<p className="text-xs text-muted-foreground">Waiting to connect...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action area */}
|
||||
{connectionState === "connected" ? (
|
||||
<CheckCircleIcon className="size-4 text-green-600 flex-shrink-0" />
|
||||
) : connectionState === "connecting" ? (
|
||||
<Button size="sm" disabled className="text-xs h-7 flex-shrink-0">
|
||||
<LoaderIcon className="size-3 animate-spin mr-1" />
|
||||
Connecting...
|
||||
</Button>
|
||||
) : connectionState === "error" ? (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<XCircleIcon className="size-3.5 text-destructive" />
|
||||
<Button size="sm" variant="outline" onClick={handleConnect} className="text-xs h-7">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : isToolRunning ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<Button size="sm" onClick={handleConnect} className="text-xs h-7 flex-shrink-0">
|
||||
<Link2Icon className="size-3 mr-1" />
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,163 +3,254 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import type { ComponentProps, ReactNode, RefObject } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
// Context to share scroll preservation state
|
||||
interface ScrollPreservationContextValue {
|
||||
registerScrollContainer: (container: HTMLElement | null) => void;
|
||||
markUserEngaged: () => void;
|
||||
resetEngagement: () => void;
|
||||
const BOTTOM_THRESHOLD_PX = 8;
|
||||
const MAX_ANCHOR_RETRIES = 6;
|
||||
|
||||
interface ConversationContextValue {
|
||||
contentRef: RefObject<HTMLDivElement | null>;
|
||||
isAtBottom: boolean;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
const ScrollPreservationContext = createContext<ScrollPreservationContextValue | null>(null);
|
||||
const ConversationContext = createContext<ConversationContextValue | null>(null);
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom> & {
|
||||
export type ConversationProps = ComponentProps<"div"> & {
|
||||
anchorMessageId?: string | null;
|
||||
anchorRequestKey?: number;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Conversation = ({ className, children, ...props }: ConversationProps) => {
|
||||
const [scrollContainer, setScrollContainer] = useState<HTMLElement | null>(null);
|
||||
const isUserEngagedRef = useRef(false);
|
||||
const savedScrollTopRef = useRef<number>(0);
|
||||
const lastScrollHeightRef = useRef<number>(0);
|
||||
export const Conversation = ({
|
||||
anchorMessageId = null,
|
||||
anchorRequestKey,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ConversationProps) => {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const spacerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
const contextValue: ScrollPreservationContextValue = {
|
||||
registerScrollContainer: (container) => {
|
||||
setScrollContainer(container);
|
||||
},
|
||||
markUserEngaged: () => {
|
||||
// Only save position on first engagement, not on repeated calls
|
||||
if (!isUserEngagedRef.current && scrollContainer) {
|
||||
savedScrollTopRef.current = scrollContainer.scrollTop;
|
||||
lastScrollHeightRef.current = scrollContainer.scrollHeight;
|
||||
const updateBottomState = useCallback(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD_PX);
|
||||
}, []);
|
||||
|
||||
const applyAnchorLayout = useCallback(
|
||||
(scrollToAnchor: boolean): boolean => {
|
||||
const container = scrollRef.current;
|
||||
const content = contentRef.current;
|
||||
const spacer = spacerRef.current;
|
||||
|
||||
if (!container || !content || !spacer) {
|
||||
return false;
|
||||
}
|
||||
isUserEngagedRef.current = true;
|
||||
},
|
||||
resetEngagement: () => {
|
||||
isUserEngagedRef.current = false;
|
||||
},
|
||||
};
|
||||
|
||||
// Watch for content changes and restore scroll position if user was engaged
|
||||
if (!anchorMessageId) {
|
||||
spacer.style.height = "0px";
|
||||
updateBottomState();
|
||||
return true;
|
||||
}
|
||||
|
||||
const anchor = content.querySelector<HTMLElement>(
|
||||
`[data-message-id="${anchorMessageId}"]`
|
||||
);
|
||||
|
||||
if (!anchor) {
|
||||
spacer.style.height = "0px";
|
||||
updateBottomState();
|
||||
return false;
|
||||
}
|
||||
|
||||
spacer.style.height = "0px";
|
||||
|
||||
const contentPaddingTop = Number.parseFloat(
|
||||
window.getComputedStyle(content).paddingTop || "0"
|
||||
);
|
||||
const anchorTop = anchor.offsetTop;
|
||||
const targetScrollTop = Math.max(0, anchorTop - contentPaddingTop);
|
||||
const requiredSlack = Math.max(
|
||||
0,
|
||||
targetScrollTop - (content.scrollHeight - container.clientHeight)
|
||||
);
|
||||
|
||||
spacer.style.height = `${Math.ceil(requiredSlack)}px`;
|
||||
|
||||
if (scrollToAnchor) {
|
||||
container.scrollTop = targetScrollTop;
|
||||
}
|
||||
|
||||
updateBottomState();
|
||||
return true;
|
||||
},
|
||||
[anchorMessageId, updateBottomState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainer) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
updateBottomState();
|
||||
};
|
||||
|
||||
handleScroll();
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [updateBottomState]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!container || !content) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const checkAndRestoreScroll = () => {
|
||||
if (!isUserEngagedRef.current) return;
|
||||
|
||||
const currentScrollTop = scrollContainer.scrollTop;
|
||||
const currentScrollHeight = scrollContainer.scrollHeight;
|
||||
const savedScrollTop = savedScrollTopRef.current;
|
||||
|
||||
// If scroll position jumped significantly (auto-scroll happened)
|
||||
// and scroll height also changed (content changed), restore position
|
||||
if (
|
||||
Math.abs(currentScrollTop - savedScrollTop) > 50 &&
|
||||
currentScrollHeight !== lastScrollHeightRef.current
|
||||
) {
|
||||
scrollContainer.scrollTop = savedScrollTop;
|
||||
const schedule = () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
|
||||
lastScrollHeightRef.current = currentScrollHeight;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
applyAnchorLayout(false);
|
||||
});
|
||||
};
|
||||
|
||||
// Use ResizeObserver to detect content changes
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(checkAndRestoreScroll);
|
||||
});
|
||||
|
||||
resizeObserver.observe(scrollContainer);
|
||||
const observer = new ResizeObserver(schedule);
|
||||
observer.observe(container);
|
||||
observer.observe(content);
|
||||
schedule();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
observer.disconnect();
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [scrollContainer]);
|
||||
}, [applyAnchorLayout]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (anchorRequestKey === undefined) return;
|
||||
|
||||
let attempts = 0;
|
||||
let rafId: number | null = null;
|
||||
|
||||
const tryAnchor = () => {
|
||||
if (applyAnchorLayout(true)) {
|
||||
return;
|
||||
}
|
||||
if (attempts >= MAX_ANCHOR_RETRIES) {
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
rafId = requestAnimationFrame(tryAnchor);
|
||||
};
|
||||
|
||||
tryAnchor();
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [anchorRequestKey, applyAnchorLayout]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
updateBottomState();
|
||||
}, [updateBottomState]);
|
||||
|
||||
const contextValue = useMemo<ConversationContextValue>(
|
||||
() => ({
|
||||
contentRef,
|
||||
isAtBottom,
|
||||
scrollRef,
|
||||
scrollToBottom,
|
||||
}),
|
||||
[isAtBottom, scrollToBottom]
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollPreservationContext.Provider value={contextValue}>
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
<ConversationContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("relative flex-1 overflow-hidden", className)}
|
||||
role="log"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StickToBottom>
|
||||
</ScrollPreservationContext.Provider>
|
||||
<div
|
||||
className="h-full w-full overflow-y-auto [scrollbar-gutter:stable]"
|
||||
ref={scrollRef}
|
||||
>
|
||||
{children}
|
||||
<div ref={spacerRef} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</ConversationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that tracks scroll engagement and preserves position.
|
||||
* Must be used inside Conversation component.
|
||||
*/
|
||||
export const ScrollPositionPreserver = () => {
|
||||
const { isAtBottom, scrollRef } = useStickToBottomContext();
|
||||
const preservationContext = useContext(ScrollPreservationContext);
|
||||
const containerFoundRef = useRef(false);
|
||||
const useConversationContext = () => {
|
||||
const context = useContext(ConversationContext);
|
||||
|
||||
// Find and register scroll container on mount
|
||||
useLayoutEffect(() => {
|
||||
if (containerFoundRef.current || !preservationContext) return;
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"Conversation components must be used within a Conversation component."
|
||||
);
|
||||
}
|
||||
|
||||
// Use the local StickToBottom scroll container for this conversation instance.
|
||||
const container = scrollRef.current;
|
||||
if (container) {
|
||||
preservationContext.registerScrollContainer(container);
|
||||
containerFoundRef.current = true;
|
||||
}
|
||||
}, [preservationContext, scrollRef]);
|
||||
|
||||
// Track engagement based on scroll position
|
||||
useEffect(() => {
|
||||
if (!preservationContext) return;
|
||||
|
||||
if (!isAtBottom) {
|
||||
// User is not at bottom - mark as engaged
|
||||
preservationContext.markUserEngaged();
|
||||
} else {
|
||||
// User is back at bottom - reset
|
||||
preservationContext.resetEngagement();
|
||||
}
|
||||
}, [isAtBottom, preservationContext]);
|
||||
|
||||
return null;
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
export type ConversationContentProps = ComponentProps<"div">;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}: ConversationContentProps) => {
|
||||
const { contentRef } = useConversationContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
ref={contentRef}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
icon?: ReactNode;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
children,
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
title = "No messages yet",
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
|
|
@ -183,13 +274,15 @@ export const ConversationEmptyState = ({
|
|||
</div>
|
||||
);
|
||||
|
||||
export const ScrollPositionPreserver = () => null;
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
const { isAtBottom, scrollToBottom } = useConversationContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
|
|
@ -199,16 +292,16 @@ export const ConversationScrollButton = ({
|
|||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
|
||||
"absolute bottom-6 left-[50%] z-10 h-12 w-12 translate-x-[-50%] rounded-full border border-border/70 bg-background/95 text-foreground shadow-lg backdrop-blur-sm transition hover:bg-background",
|
||||
className
|
||||
)}
|
||||
aria-label="Scroll to latest message"
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
<ArrowDownIcon className="size-6" strokeWidth={1.75} />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import {
|
|||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||
|
||||
const formatToolValue = (value: unknown) => {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
|
|
@ -37,7 +37,7 @@ const ToolCode = ({
|
|||
}) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"whitespace-pre-wrap text-xs font-mono",
|
||||
"whitespace-pre-wrap text-xs font-mono break-all",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -129,64 +129,90 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
|||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
/* ── Tabbed content (Parameters / Result) ────────────────────────── */
|
||||
|
||||
export type ToolTabbedContentProps = {
|
||||
input: ToolUIPart["input"];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md border bg-muted/50 p-4 text-foreground">
|
||||
<ToolCode code={formatToolValue(input ?? {})} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolUIPart["output"];
|
||||
errorText: ToolUIPart["errorText"];
|
||||
errorText?: ToolUIPart["errorText"];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
export const ToolTabbedContent = ({
|
||||
input,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
}: ToolTabbedContentProps) => {
|
||||
const [activeTab, setActiveTab] = useState<"parameters" | "result">("parameters");
|
||||
const hasOutput = output != null || !!errorText;
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = <ToolCode code={formatToolValue(output ?? null)} />;
|
||||
} else if (typeof output === "string") {
|
||||
Output = <ToolCode code={formatToolValue(output)} />;
|
||||
let OutputNode: ReactNode = null;
|
||||
if (errorText) {
|
||||
OutputNode = <ToolCode code={errorText} className="text-destructive" />;
|
||||
} else if (output != null) {
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
OutputNode = <ToolCode code={formatToolValue(output)} />;
|
||||
} else if (typeof output === "string") {
|
||||
OutputNode = <ToolCode code={output} />;
|
||||
} else {
|
||||
OutputNode = <div>{output as ReactNode}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 p-4", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto rounded-md border p-4 text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{errorText && (
|
||||
<div className="mb-2 font-sans text-xs text-destructive">
|
||||
{errorText}
|
||||
<div className="border-t">
|
||||
{/* Tabs */}
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
|
||||
activeTab === "parameters"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("parameters")}
|
||||
>
|
||||
Parameters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
|
||||
activeTab === "result"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("result")}
|
||||
>
|
||||
Result
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="p-3">
|
||||
{activeTab === "parameters" && (
|
||||
<div className="rounded-md border bg-muted/50 p-3 max-h-64 overflow-auto">
|
||||
<ToolCode code={formatToolValue(input ?? {})} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "result" && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border p-3 max-h-64 overflow-auto",
|
||||
errorText ? "bg-destructive/10" : "bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{hasOutput ? (
|
||||
<div className={cn(errorText && "text-destructive")}>
|
||||
{OutputNode}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">(pending...)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import * as React from 'react'
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save, Copy, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -91,6 +98,12 @@ type BasesViewProps = {
|
|||
externalSearch?: string
|
||||
/** Called after the external search has been consumed (applied to internal state). */
|
||||
onExternalSearchConsumed?: () => void
|
||||
/** Actions for context menu */
|
||||
actions?: {
|
||||
rename: (oldPath: string, newName: string, isDir: boolean) => Promise<void>
|
||||
remove: (path: string) => Promise<void>
|
||||
copyPath: (path: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
||||
|
|
@ -140,13 +153,15 @@ function getSortValue(note: NoteEntry, column: string): string | number {
|
|||
if (column === 'mtimeMs') return note.mtimeMs
|
||||
const v = note.fields[column]
|
||||
if (!v) return ''
|
||||
if (column === 'last_update' || column === 'first_met') {
|
||||
const s = Array.isArray(v) ? v[0] ?? '' : v
|
||||
const ms = Date.parse(s)
|
||||
return isNaN(ms) ? 0 : ms
|
||||
}
|
||||
return Array.isArray(v) ? v[0] ?? '' : v
|
||||
}
|
||||
|
||||
const isBuiltin = (col: string): col is BuiltinColumn =>
|
||||
(BUILTIN_COLUMNS as readonly string[]).includes(col)
|
||||
|
||||
export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave, externalSearch, onExternalSearchConsumed }: BasesViewProps) {
|
||||
export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave, externalSearch, onExternalSearchConsumed, actions }: BasesViewProps) {
|
||||
// Build notes instantly from tree
|
||||
const notes = useMemo<NoteEntry[]>(() => {
|
||||
return collectFiles(tree).map((f) => ({
|
||||
|
|
@ -655,22 +670,15 @@ export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaul
|
|||
</thead>
|
||||
<tbody>
|
||||
{pageNotes.map((note) => (
|
||||
<tr
|
||||
<NoteRow
|
||||
key={note.path}
|
||||
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onSelectNote(note.path)}
|
||||
>
|
||||
{visibleColumns.map((col) => (
|
||||
<td key={col} className="px-4 py-2 overflow-hidden">
|
||||
<CellRenderer
|
||||
note={note}
|
||||
column={col}
|
||||
filters={filters}
|
||||
toggleFilter={toggleFilter}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
note={note}
|
||||
visibleColumns={visibleColumns}
|
||||
filters={filters}
|
||||
toggleFilter={toggleFilter}
|
||||
onSelectNote={onSelectNote}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
{pageNotes.length === 0 && (
|
||||
<tr>
|
||||
|
|
@ -773,6 +781,17 @@ function CellRenderer({
|
|||
return <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(note.mtimeMs)}</span>
|
||||
}
|
||||
|
||||
// Date-like frontmatter columns — render like Last Modified
|
||||
if (column === 'last_update' || column === 'first_met') {
|
||||
const value = note.fields[column]
|
||||
if (!value || Array.isArray(value)) return null
|
||||
const ms = Date.parse(value)
|
||||
if (!isNaN(ms)) {
|
||||
return <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(ms)}</span>
|
||||
}
|
||||
return <span className="text-muted-foreground whitespace-nowrap truncate block">{value}</span>
|
||||
}
|
||||
|
||||
// Frontmatter column
|
||||
const value = note.fields[column]
|
||||
if (!value) return null
|
||||
|
|
@ -804,6 +823,116 @@ function CellRenderer({
|
|||
)
|
||||
}
|
||||
|
||||
function NoteRow({
|
||||
note,
|
||||
visibleColumns,
|
||||
filters,
|
||||
toggleFilter,
|
||||
onSelectNote,
|
||||
actions,
|
||||
}: {
|
||||
note: NoteEntry
|
||||
visibleColumns: string[]
|
||||
filters: ActiveFilter[]
|
||||
toggleFilter: (category: string, value: string) => void
|
||||
onSelectNote: (path: string) => void
|
||||
actions?: BasesViewProps['actions']
|
||||
}) {
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const isSubmittingRef = useRef(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) inputRef.current?.focus()
|
||||
}, [isRenaming])
|
||||
|
||||
const baseName = note.name
|
||||
const handleRenameSubmit = useCallback(async () => {
|
||||
if (isSubmittingRef.current) return
|
||||
const trimmed = newName.trim()
|
||||
if (!trimmed || trimmed === baseName) {
|
||||
setIsRenaming(false)
|
||||
return
|
||||
}
|
||||
isSubmittingRef.current = true
|
||||
try {
|
||||
await actions?.rename(note.path, trimmed, false)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setIsRenaming(false)
|
||||
isSubmittingRef.current = false
|
||||
}, [newName, baseName, actions, note.path])
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
actions?.copyPath(note.path)
|
||||
}, [actions, note.path])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
void actions?.remove(note.path)
|
||||
}, [actions, note.path])
|
||||
|
||||
const row = (
|
||||
<tr
|
||||
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onSelectNote(note.path)}
|
||||
>
|
||||
{visibleColumns.map((col) => (
|
||||
<td key={col} className="px-4 py-2 overflow-hidden">
|
||||
{col === 'name' && isRenaming ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onBlur={() => void handleRenameSubmit()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleRenameSubmit()
|
||||
if (e.key === 'Escape') setIsRenaming(false)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full bg-transparent text-sm font-medium outline-none ring-1 ring-ring rounded px-1"
|
||||
/>
|
||||
) : (
|
||||
<CellRenderer
|
||||
note={note}
|
||||
column={col}
|
||||
filters={filters}
|
||||
toggleFilter={toggleFilter}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
|
||||
if (!actions) return row
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
{row}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem onClick={handleCopyPath}>
|
||||
<Copy className="mr-2 size-4" />
|
||||
Copy Path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryBadge({
|
||||
category,
|
||||
value,
|
||||
|
|
|
|||
|
|
@ -66,10 +66,11 @@ const providerDisplayNames: Record<string, string> = {
|
|||
openrouter: 'OpenRouter',
|
||||
aigateway: 'AI Gateway',
|
||||
'openai-compatible': 'OpenAI-Compatible',
|
||||
rowboat: 'Rowboat',
|
||||
}
|
||||
|
||||
interface ConfiguredModel {
|
||||
flavor: string
|
||||
flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
|
||||
model: string
|
||||
apiKey?: string
|
||||
baseURL?: string
|
||||
|
|
@ -156,51 +157,103 @@ function ChatInputInner({
|
|||
const [activeModelKey, setActiveModelKey] = useState('')
|
||||
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
|
||||
// Load model config from disk (on mount and whenever tab becomes active)
|
||||
// Check Rowboat sign-in state
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||
setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
|
||||
}).catch(() => setIsRowboatConnected(false))
|
||||
}, [isActive])
|
||||
|
||||
// Update sign-in state when OAuth events fire
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', () => {
|
||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||
setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
|
||||
}).catch(() => setIsRowboatConnected(false))
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Load model config (gateway when signed in, local config when BYOK)
|
||||
const loadModelConfig = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const models: ConfiguredModel[] = []
|
||||
if (parsed?.providers) {
|
||||
for (const [flavor, entry] of Object.entries(parsed.providers)) {
|
||||
const e = entry as Record<string, unknown>
|
||||
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
|
||||
const singleModel = typeof e.model === 'string' ? e.model : ''
|
||||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||
for (const model of allModels) {
|
||||
if (model) {
|
||||
models.push({
|
||||
flavor,
|
||||
model,
|
||||
apiKey: (e.apiKey as string) || undefined,
|
||||
baseURL: (e.baseURL as string) || undefined,
|
||||
headers: (e.headers as Record<string, string>) || undefined,
|
||||
knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined,
|
||||
})
|
||||
if (isRowboatConnected) {
|
||||
// Fetch gateway models
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
const rowboatProvider = listResult.providers?.find(
|
||||
(p: { id: string }) => p.id === 'rowboat'
|
||||
)
|
||||
const models: ConfiguredModel[] = (rowboatProvider?.models || []).map(
|
||||
(m: { id: string }) => ({ flavor: 'rowboat', model: m.id })
|
||||
)
|
||||
|
||||
// Read current default from config
|
||||
let defaultModel = ''
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
defaultModel = parsed?.model || ''
|
||||
} catch { /* no config yet */ }
|
||||
|
||||
if (defaultModel) {
|
||||
models.sort((a, b) => {
|
||||
if (a.model === defaultModel) return -1
|
||||
if (b.model === defaultModel) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
setConfiguredModels(models)
|
||||
const activeKey = defaultModel
|
||||
? `rowboat/${defaultModel}`
|
||||
: models[0] ? `rowboat/${models[0].model}` : ''
|
||||
if (activeKey) setActiveModelKey(activeKey)
|
||||
} else {
|
||||
// BYOK: read from local models.json
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const models: ConfiguredModel[] = []
|
||||
if (parsed?.providers) {
|
||||
for (const [flavor, entry] of Object.entries(parsed.providers)) {
|
||||
const e = entry as Record<string, unknown>
|
||||
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
|
||||
const singleModel = typeof e.model === 'string' ? e.model : ''
|
||||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||
for (const model of allModels) {
|
||||
if (model) {
|
||||
models.push({
|
||||
flavor: flavor as ConfiguredModel['flavor'],
|
||||
model,
|
||||
apiKey: (e.apiKey as string) || undefined,
|
||||
baseURL: (e.baseURL as string) || undefined,
|
||||
headers: (e.headers as Record<string, string>) || undefined,
|
||||
knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const defaultKey = parsed?.provider?.flavor && parsed?.model
|
||||
? `${parsed.provider.flavor}/${parsed.model}`
|
||||
: ''
|
||||
models.sort((a, b) => {
|
||||
const aKey = `${a.flavor}/${a.model}`
|
||||
const bKey = `${b.flavor}/${b.model}`
|
||||
if (aKey === defaultKey) return -1
|
||||
if (bKey === defaultKey) return 1
|
||||
return 0
|
||||
})
|
||||
setConfiguredModels(models)
|
||||
if (defaultKey) {
|
||||
setActiveModelKey(defaultKey)
|
||||
const defaultKey = parsed?.provider?.flavor && parsed?.model
|
||||
? `${parsed.provider.flavor}/${parsed.model}`
|
||||
: ''
|
||||
models.sort((a, b) => {
|
||||
const aKey = `${a.flavor}/${a.model}`
|
||||
const bKey = `${b.flavor}/${b.model}`
|
||||
if (aKey === defaultKey) return -1
|
||||
if (bKey === defaultKey) return 1
|
||||
return 0
|
||||
})
|
||||
setConfiguredModels(models)
|
||||
if (defaultKey) {
|
||||
setActiveModelKey(defaultKey)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No config yet
|
||||
}
|
||||
}, [])
|
||||
}, [isRowboatConnected])
|
||||
|
||||
useEffect(() => {
|
||||
loadModelConfig()
|
||||
|
|
@ -213,47 +266,54 @@ function ChatInputInner({
|
|||
return () => window.removeEventListener('models-config-changed', handler)
|
||||
}, [loadModelConfig])
|
||||
|
||||
// Check search tool availability (brave or exa)
|
||||
// Check search tool availability (exa or signed-in via gateway)
|
||||
useEffect(() => {
|
||||
const checkSearch = async () => {
|
||||
if (isRowboatConnected) {
|
||||
setSearchAvailable(true)
|
||||
return
|
||||
}
|
||||
let available = false
|
||||
try {
|
||||
const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/brave-search.json' })
|
||||
const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' })
|
||||
const config = JSON.parse(raw.data)
|
||||
if (config.apiKey) available = true
|
||||
} catch { /* not configured */ }
|
||||
if (!available) {
|
||||
try {
|
||||
const raw = await window.ipc.invoke('workspace:readFile', { path: 'config/exa-search.json' })
|
||||
const config = JSON.parse(raw.data)
|
||||
if (config.apiKey) available = true
|
||||
} catch { /* not configured */ }
|
||||
}
|
||||
setSearchAvailable(available)
|
||||
}
|
||||
checkSearch()
|
||||
}, [isActive])
|
||||
}, [isActive, isRowboatConnected])
|
||||
|
||||
const handleModelChange = useCallback(async (key: string) => {
|
||||
const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key)
|
||||
if (!entry) return
|
||||
setActiveModelKey(key)
|
||||
// Collect all models for this provider so the full list is preserved
|
||||
const providerModels = configuredModels
|
||||
.filter((m) => m.flavor === entry.flavor)
|
||||
.map((m) => m.model)
|
||||
|
||||
try {
|
||||
await window.ipc.invoke('models:saveConfig', {
|
||||
provider: {
|
||||
flavor: entry.flavor,
|
||||
apiKey: entry.apiKey,
|
||||
baseURL: entry.baseURL,
|
||||
headers: entry.headers,
|
||||
},
|
||||
model: entry.model,
|
||||
models: providerModels,
|
||||
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||
})
|
||||
if (entry.flavor === 'rowboat') {
|
||||
// Gateway model — save with valid Zod flavor, no credentials
|
||||
await window.ipc.invoke('models:saveConfig', {
|
||||
provider: { flavor: 'openrouter' as const },
|
||||
model: entry.model,
|
||||
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||
})
|
||||
} else {
|
||||
// BYOK — preserve full provider config
|
||||
const providerModels = configuredModels
|
||||
.filter((m) => m.flavor === entry.flavor)
|
||||
.map((m) => m.model)
|
||||
await window.ipc.invoke('models:saveConfig', {
|
||||
provider: {
|
||||
flavor: entry.flavor,
|
||||
apiKey: entry.apiKey,
|
||||
baseURL: entry.baseURL,
|
||||
headers: entry.headers,
|
||||
},
|
||||
model: entry.model,
|
||||
models: providerModels,
|
||||
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to switch model')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ScrollPositionPreserver,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import {
|
||||
Message,
|
||||
|
|
@ -16,8 +16,9 @@ import {
|
|||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
|
||||
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||
import { Suggestions } from '@/components/ai-elements/suggestions'
|
||||
|
|
@ -29,11 +30,14 @@ import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-
|
|||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import {
|
||||
type ChatViewportAnchorState,
|
||||
type ChatTabViewState,
|
||||
type ConversationItem,
|
||||
type PermissionResponse,
|
||||
createEmptyChatTabViewState,
|
||||
getWebSearchCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
|
|
@ -87,6 +91,7 @@ interface ChatSidebarProps {
|
|||
conversation: ConversationItem[]
|
||||
currentAssistantMessage: string
|
||||
chatTabStates?: Record<string, ChatTabViewState>
|
||||
viewportAnchors?: Record<string, ChatViewportAnchorState>
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
onStop?: () => void
|
||||
|
|
@ -121,6 +126,7 @@ interface ChatSidebarProps {
|
|||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
onComposioConnected?: (toolkitSlug: string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
|
|
@ -138,6 +144,7 @@ export function ChatSidebar({
|
|||
conversation,
|
||||
currentAssistantMessage,
|
||||
chatTabStates = {},
|
||||
viewportAnchors = {},
|
||||
isProcessing,
|
||||
isStopping,
|
||||
onStop,
|
||||
|
|
@ -171,6 +178,7 @@ export function ChatSidebar({
|
|||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onComposioConnected,
|
||||
}: ChatSidebarProps) {
|
||||
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
|
|
@ -284,7 +292,7 @@ export function ChatSidebar({
|
|||
if (item.role === 'user') {
|
||||
if (item.attachments && item.attachments.length > 0) {
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
|
|
@ -296,7 +304,7 @@ export function ChatSidebar({
|
|||
}
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent>
|
||||
{files.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
|
|
@ -316,7 +324,7 @@ export function ChatSidebar({
|
|||
)
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent>
|
||||
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
|
||||
</MessageContent>
|
||||
|
|
@ -337,6 +345,21 @@ export function ChatSidebar({
|
|||
/>
|
||||
)
|
||||
}
|
||||
const composioConnectData = getComposioConnectCardData(item)
|
||||
if (composioConnectData) {
|
||||
if (composioConnectData.hidden) return null
|
||||
return (
|
||||
<ComposioConnectCard
|
||||
key={item.id}
|
||||
toolkitSlug={composioConnectData.toolkitSlug}
|
||||
toolkitDisplayName={composioConnectData.toolkitDisplayName}
|
||||
status={item.status}
|
||||
alreadyConnected={composioConnectData.alreadyConnected}
|
||||
onConnected={onComposioConnected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const toolTitle = getToolDisplayName(item)
|
||||
const errorText = item.status === 'error' ? 'Tool error' : ''
|
||||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
|
|
@ -346,10 +369,9 @@ export function ChatSidebar({
|
|||
open={isToolOpenForTab?.(tabId, item.id) ?? false}
|
||||
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
|
||||
>
|
||||
<ToolHeader title={item.name} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
<ToolInput input={input} />
|
||||
{output !== null ? <ToolOutput output={output} errorText={errorText} /> : null}
|
||||
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
|
|
@ -357,7 +379,7 @@ export function ChatSidebar({
|
|||
|
||||
if (isErrorMessage(item)) {
|
||||
return (
|
||||
<Message key={item.id} from="assistant">
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
<MessageContent className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs">{item.message}</pre>
|
||||
</MessageContent>
|
||||
|
|
@ -466,9 +488,12 @@ export function ChatSidebar({
|
|||
)}
|
||||
data-chat-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<ScrollPositionPreserver />
|
||||
>
|
||||
<Conversation
|
||||
anchorMessageId={viewportAnchors[tab.id]?.messageId}
|
||||
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
||||
className="relative flex-1"
|
||||
>
|
||||
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
|
||||
{!tabHasConversation ? (
|
||||
<ConversationEmptyState className="h-auto">
|
||||
|
|
@ -526,10 +551,11 @@ export function ChatSidebar({
|
|||
</Message>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { AlertTriangle, Loader2, Mic, Mail, MessageSquare, User } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { AlertTriangle, Loader2, Mic, Mail, Calendar, MessageSquare, User } from "lucide-react"
|
||||
|
||||
import {
|
||||
Popover,
|
||||
|
|
@ -18,367 +18,40 @@ import { Button } from "@/components/ui/button"
|
|||
import { Switch } from "@/components/ui/switch"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface ProviderState {
|
||||
isConnected: boolean
|
||||
isLoading: boolean
|
||||
isConnecting: boolean
|
||||
}
|
||||
|
||||
interface ProviderStatus {
|
||||
error?: string
|
||||
}
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
import { useConnectors } from "@/hooks/useConnectors"
|
||||
|
||||
interface ConnectorsPopoverProps {
|
||||
children: React.ReactNode
|
||||
tooltip?: string
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
mode?: "all" | "unconnected"
|
||||
}
|
||||
|
||||
export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenChange }: ConnectorsPopoverProps) {
|
||||
export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenChange, mode = "all" }: ConnectorsPopoverProps) {
|
||||
const [openInternal, setOpenInternal] = useState(false)
|
||||
const isControlled = typeof openProp === "boolean"
|
||||
const open = isControlled ? openProp : openInternal
|
||||
const setOpen = onOpenChange ?? setOpenInternal
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
|
||||
const [providerStatus, setProviderStatus] = useState<Record<string, ProviderStatus>>({})
|
||||
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
|
||||
const [googleClientIdDescription, setGoogleClientIdDescription] = useState<string | undefined>(undefined)
|
||||
|
||||
// Granola state
|
||||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||
const c = useConnectors(open)
|
||||
|
||||
// Slack state (agent-slack CLI)
|
||||
const [slackEnabled, setSlackEnabled] = useState(false)
|
||||
const [slackLoading, setSlackLoading] = useState(true)
|
||||
const [slackWorkspaces, setSlackWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
|
||||
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
|
||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Load available providers on mount
|
||||
useEffect(() => {
|
||||
async function loadProviders() {
|
||||
try {
|
||||
setProvidersLoading(true)
|
||||
const result = await window.ipc.invoke('oauth:list-providers', null)
|
||||
setProviders(result.providers || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to get available providers:', error)
|
||||
setProviders([])
|
||||
} finally {
|
||||
setProvidersLoading(false)
|
||||
}
|
||||
}
|
||||
loadProviders()
|
||||
}, [])
|
||||
|
||||
// Load Granola config
|
||||
const refreshGranolaConfig = useCallback(async () => {
|
||||
try {
|
||||
setGranolaLoading(true)
|
||||
const result = await window.ipc.invoke('granola:getConfig', null)
|
||||
setGranolaEnabled(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Granola config:', error)
|
||||
setGranolaEnabled(false)
|
||||
} finally {
|
||||
setGranolaLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update Granola config
|
||||
const handleGranolaToggle = useCallback(async (enabled: boolean) => {
|
||||
try {
|
||||
setGranolaLoading(true)
|
||||
await window.ipc.invoke('granola:setConfig', { enabled })
|
||||
setGranolaEnabled(enabled)
|
||||
toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to update Granola config:', error)
|
||||
toast.error('Failed to update Granola sync settings')
|
||||
} finally {
|
||||
setGranolaLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load Slack config
|
||||
const refreshSlackConfig = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('slack:getConfig', null)
|
||||
setSlackEnabled(result.enabled)
|
||||
setSlackWorkspaces(result.workspaces || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load Slack config:', error)
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Enable Slack: discover workspaces
|
||||
const handleSlackEnable = useCallback(async () => {
|
||||
setSlackDiscovering(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:listWorkspaces', null)
|
||||
if (result.error || result.workspaces.length === 0) {
|
||||
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
|
||||
setSlackAvailableWorkspaces([])
|
||||
setSlackPickerOpen(true)
|
||||
} else {
|
||||
setSlackAvailableWorkspaces(result.workspaces)
|
||||
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
|
||||
setSlackPickerOpen(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to discover Slack workspaces:', error)
|
||||
setSlackDiscoverError('Failed to discover Slack workspaces')
|
||||
setSlackPickerOpen(true)
|
||||
} finally {
|
||||
setSlackDiscovering(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save selected Slack workspaces
|
||||
const handleSlackSaveWorkspaces = useCallback(async () => {
|
||||
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected })
|
||||
setSlackEnabled(true)
|
||||
setSlackWorkspaces(selected)
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack enabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to save Slack config:', error)
|
||||
toast.error('Failed to save Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [slackAvailableWorkspaces, slackSelectedUrls])
|
||||
|
||||
// Disable Slack
|
||||
const handleSlackDisable = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] })
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack disabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to update Slack config:', error)
|
||||
toast.error('Failed to update Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
// Refresh Granola
|
||||
refreshGranolaConfig()
|
||||
|
||||
// Refresh Slack config
|
||||
refreshSlackConfig()
|
||||
|
||||
// Refresh OAuth providers
|
||||
if (providers.length === 0) return
|
||||
|
||||
const newStates: Record<string, ProviderState> = {}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:getState', null)
|
||||
const config = result.config || {}
|
||||
const statusMap: Record<string, ProviderStatus> = {}
|
||||
|
||||
for (const provider of providers) {
|
||||
const providerConfig = config[provider]
|
||||
newStates[provider] = {
|
||||
isConnected: providerConfig?.connected ?? false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
if (providerConfig?.error) {
|
||||
statusMap[provider] = { error: providerConfig.error }
|
||||
}
|
||||
}
|
||||
|
||||
setProviderStatus(statusMap)
|
||||
} catch (error) {
|
||||
console.error('Failed to check connection statuses:', error)
|
||||
for (const provider of providers) {
|
||||
newStates[provider] = {
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}
|
||||
setProviderStatus({})
|
||||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig])
|
||||
|
||||
// Refresh statuses when popover opens or providers list changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
refreshAllStatuses()
|
||||
}
|
||||
}, [open, providers, refreshAllStatuses])
|
||||
|
||||
// Listen for OAuth completion events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
const { provider, success, error } = event
|
||||
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: {
|
||||
isConnected: success,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}))
|
||||
|
||||
if (success) {
|
||||
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
// Show detailed message for Google and Fireflies (includes sync info)
|
||||
if (provider === 'google' || provider === 'fireflies-ai') {
|
||||
toast.success(`Connected to ${displayName}`, {
|
||||
description: 'Syncing your data in the background. This may take a few minutes before changes appear.',
|
||||
duration: 8000,
|
||||
})
|
||||
} else {
|
||||
toast.success(`Connected to ${displayName}`)
|
||||
}
|
||||
// Refresh status to ensure consistency
|
||||
refreshAllStatuses()
|
||||
} else {
|
||||
toast.error(error || `Failed to connect to ${provider}`)
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [refreshAllStatuses])
|
||||
|
||||
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, clientId })
|
||||
|
||||
if (result.success) {
|
||||
// OAuth flow started - keep isConnecting state, wait for event
|
||||
// Event listener will handle the actual completion
|
||||
} else {
|
||||
// Immediate failure (e.g., couldn't start flow)
|
||||
toast.error(result.error || `Failed to connect to ${provider}`)
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: false }
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error)
|
||||
toast.error(`Failed to connect to ${provider}`)
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: false }
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect to a provider
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
setGoogleClientIdDescription(undefined)
|
||||
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)
|
||||
setGoogleClientIdDescription(undefined)
|
||||
startConnect('google', clientId)
|
||||
}, [startConnect])
|
||||
|
||||
// Disconnect from a provider
|
||||
const handleDisconnect = useCallback(async (provider: string) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isLoading: true }
|
||||
}))
|
||||
|
||||
try {
|
||||
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 => ({
|
||||
...prev,
|
||||
[provider]: {
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
toast.error(`Failed to disconnect from ${provider}`)
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isLoading: false }
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect:', error)
|
||||
toast.error(`Failed to disconnect from ${provider}`)
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isLoading: false }
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const hasProviderError = Object.values(providerStatus).some(
|
||||
(status) => Boolean(status?.error)
|
||||
)
|
||||
const isUnconnectedMode = mode === "unconnected"
|
||||
|
||||
// Helper to render an OAuth provider row
|
||||
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
|
||||
const state = providerStates[provider] || {
|
||||
const state = c.providerStates[provider] || {
|
||||
isConnected: false,
|
||||
isLoading: true,
|
||||
isConnecting: false,
|
||||
}
|
||||
const needsReconnect = Boolean(providerStatus[provider]?.error)
|
||||
const needsReconnect = Boolean(c.providerStatus[provider]?.error)
|
||||
|
||||
// In unconnected mode, skip connected providers (unless they need reconnect)
|
||||
if (isUnconnectedMode && state.isConnected && !needsReconnect && !state.isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -409,13 +82,13 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
size="sm"
|
||||
onClick={() => {
|
||||
if (provider === 'google') {
|
||||
setGoogleClientIdDescription(
|
||||
c.setGoogleClientIdDescription(
|
||||
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
||||
)
|
||||
setGoogleClientIdOpen(true)
|
||||
c.setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
startConnect(provider)
|
||||
c.startConnect(provider)
|
||||
}}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
|
|
@ -425,23 +98,23 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(provider)}
|
||||
onClick={() => c.handleDisconnect(provider)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
{provider === 'rowboat' ? 'Log Out' : 'Disconnect'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleConnect(provider)}
|
||||
onClick={() => c.handleConnect(provider)}
|
||||
disabled={state.isConnecting}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{state.isConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
provider === 'rowboat' ? 'Log In' : 'Connect'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -450,19 +123,57 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
)
|
||||
}
|
||||
|
||||
// Check if Gmail is unconnected (for filtering in unconnected mode)
|
||||
const isGmailUnconnected = c.useComposioForGoogle ? !c.gmailConnected && !c.gmailLoading : true
|
||||
const isGoogleCalendarUnconnected = c.useComposioForGoogleCalendar ? !c.googleCalendarConnected && !c.googleCalendarLoading : true
|
||||
const isGranolaUnconnected = !c.granolaEnabled && !c.granolaLoading
|
||||
const isSlackUnconnected = !c.slackEnabled && !c.slackLoading
|
||||
|
||||
// For unconnected mode, check if there's anything to show
|
||||
const hasUnconnectedEmailCalendar = (() => {
|
||||
if (!isUnconnectedMode) return true
|
||||
if (c.useComposioForGoogle && isGmailUnconnected) return true
|
||||
if (c.useComposioForGoogleCalendar && isGoogleCalendarUnconnected) return true
|
||||
if (!c.useComposioForGoogle && c.providers.includes('google')) {
|
||||
const googleState = c.providerStates['google']
|
||||
if (!googleState?.isConnected || c.providerStatus['google']?.error) return true
|
||||
}
|
||||
return false
|
||||
})()
|
||||
|
||||
const hasUnconnectedMeetingNotes = (() => {
|
||||
if (!isUnconnectedMode) return true
|
||||
if (isGranolaUnconnected) return true
|
||||
if (c.providers.includes('fireflies-ai')) {
|
||||
const firefliesState = c.providerStates['fireflies-ai']
|
||||
if (!firefliesState?.isConnected || c.providerStatus['fireflies-ai']?.error) return true
|
||||
}
|
||||
return false
|
||||
})()
|
||||
|
||||
const hasUnconnectedSlack = !isUnconnectedMode || isSlackUnconnected
|
||||
|
||||
const isRowboatUnconnected = (() => {
|
||||
if (!c.providers.includes('rowboat')) return false
|
||||
const rowboatState = c.providerStates['rowboat']
|
||||
return !rowboatState?.isConnected || rowboatState?.isLoading
|
||||
})()
|
||||
|
||||
const allConnected = isUnconnectedMode && !isRowboatUnconnected && !hasUnconnectedEmailCalendar && !hasUnconnectedMeetingNotes && !hasUnconnectedSlack
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoogleClientIdModal
|
||||
open={googleClientIdOpen}
|
||||
open={c.googleClientIdOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setGoogleClientIdOpen(nextOpen)
|
||||
c.setGoogleClientIdOpen(nextOpen)
|
||||
if (!nextOpen) {
|
||||
setGoogleClientIdDescription(undefined)
|
||||
c.setGoogleClientIdDescription(undefined)
|
||||
}
|
||||
}}
|
||||
onSubmit={handleGoogleClientIdSubmit}
|
||||
isSubmitting={providerStates.google?.isConnecting ?? false}
|
||||
description={googleClientIdDescription}
|
||||
onSubmit={c.handleGoogleClientIdSubmit}
|
||||
isSubmitting={c.providerStates.google?.isConnecting ?? false}
|
||||
description={c.googleClientIdDescription}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{tooltip ? (
|
||||
|
|
@ -489,169 +200,296 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
>
|
||||
<div className="p-4 border-b">
|
||||
<h4 className="font-semibold text-sm flex items-center gap-1.5">
|
||||
Connected accounts
|
||||
{hasProviderError && (
|
||||
{isUnconnectedMode ? "Connect Accounts" : "Connected accounts"}
|
||||
{!isUnconnectedMode && c.hasProviderError && (
|
||||
<AlertTriangle className="size-3 text-amber-500/80 animate-pulse" />
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Connect accounts to sync data
|
||||
{isUnconnectedMode ? "Add new account connections" : "Connect accounts to sync data"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{providersLoading ? (
|
||||
{c.providersLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : allConnected ? (
|
||||
<div className="flex flex-col items-center py-6 px-4 gap-2">
|
||||
<p className="text-sm text-muted-foreground text-center">All accounts connected</p>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Manage your connections in Settings
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Rowboat Account */}
|
||||
{providers.includes('rowboat') && (
|
||||
{/* Rowboat Account - show in "all" mode always, or in "unconnected" mode only when not connected */}
|
||||
{c.providers.includes('rowboat') && (() => {
|
||||
const rowboatState = c.providerStates['rowboat']
|
||||
const isRowboatConnected = rowboatState?.isConnected && !rowboatState?.isLoading
|
||||
if (isUnconnectedMode && isRowboatConnected) return null
|
||||
return (
|
||||
<>
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Account</span>
|
||||
</div>
|
||||
{renderOAuthProvider('rowboat', 'Rowboat', <User className="size-4" />, 'Log in to your Rowboat account')}
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Email & Calendar Section */}
|
||||
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && hasUnconnectedEmailCalendar && (
|
||||
<>
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Account</span>
|
||||
</div>
|
||||
{renderOAuthProvider('rowboat', 'Rowboat', <User className="size-4" />, 'Connect your Rowboat account')}
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Email & Calendar Section - Google */}
|
||||
{providers.includes('google') && (
|
||||
<>
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Email & Calendar</span>
|
||||
</div>
|
||||
{renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')}
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Meeting Notes Section - Granola & Fireflies */}
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Meeting Notes</span>
|
||||
</div>
|
||||
|
||||
{/* Granola */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Mic className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Granola</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Local meeting notes
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Email & Calendar
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{granolaLoading && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{c.useComposioForGoogle ? (
|
||||
// In unconnected mode, only show if not connected
|
||||
(!isUnconnectedMode || isGmailUnconnected) ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Gmail</span>
|
||||
{c.gmailLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Sync emails
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{c.gmailLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : c.gmailConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={c.handleDisconnectGmail}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={c.handleConnectGmail}
|
||||
disabled={c.gmailConnecting}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{c.gmailConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
|
||||
)}
|
||||
<Switch
|
||||
checked={granolaEnabled}
|
||||
onCheckedChange={handleGranolaToggle}
|
||||
disabled={granolaLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fireflies */}
|
||||
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* Team Communication Section - Slack */}
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Team Communication</span>
|
||||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
<div className="rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
{c.useComposioForGoogleCalendar && (!isUnconnectedMode || isGoogleCalendarUnconnected) && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Calendar className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Google Calendar</span>
|
||||
{c.googleCalendarLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Sync calendar events
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{c.googleCalendarLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : c.googleCalendarConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={c.handleDisconnectGoogleCalendar}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={c.handleConnectGoogleCalendar}
|
||||
disabled={c.googleCalendarConnecting}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{c.googleCalendarConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{slackEnabled && slackWorkspaces.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{slackWorkspaces.map(w => w.name).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
)}
|
||||
)}
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Meeting Notes Section */}
|
||||
{hasUnconnectedMeetingNotes && (
|
||||
<>
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Meeting Notes</span>
|
||||
</div>
|
||||
|
||||
{/* Granola - show in unconnected mode only if not enabled */}
|
||||
{(!isUnconnectedMode || isGranolaUnconnected) && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<Mic className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Granola</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Local meeting notes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{c.granolaLoading && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
<Switch
|
||||
checked={c.granolaEnabled}
|
||||
onCheckedChange={c.handleGranolaToggle}
|
||||
disabled={c.granolaLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fireflies */}
|
||||
{c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
|
||||
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Team Communication Section */}
|
||||
{hasUnconnectedSlack && (
|
||||
<>
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Team Communication</span>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{(slackLoading || slackDiscovering) && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
{slackEnabled ? (
|
||||
<Switch
|
||||
checked={true}
|
||||
onCheckedChange={() => handleSlackDisable()}
|
||||
disabled={slackLoading}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSlackEnable}
|
||||
disabled={slackLoading || slackDiscovering}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
|
||||
<div className="rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{c.slackEnabled && c.slackWorkspaces.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{c.slackWorkspaces.map(w => w.name).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{(c.slackLoading || c.slackDiscovering) && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
{c.slackEnabled ? (
|
||||
<Switch
|
||||
checked={true}
|
||||
onCheckedChange={() => c.handleSlackDisable()}
|
||||
disabled={c.slackLoading}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={c.handleSlackEnable}
|
||||
disabled={c.slackLoading || c.slackDiscovering}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{c.slackPickerOpen && (
|
||||
<div className="mt-2 ml-11 space-y-2">
|
||||
{c.slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
{c.slackAvailableWorkspaces.map(w => (
|
||||
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c.slackSelectedUrls.has(w.url)}
|
||||
onChange={(e) => {
|
||||
c.setSlackSelectedUrls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (e.target.checked) next.add(w.url)
|
||||
else next.delete(w.url)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={c.handleSlackSaveWorkspaces}
|
||||
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{slackPickerOpen && (
|
||||
<div className="mt-2 ml-11 space-y-2">
|
||||
{slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
{slackAvailableWorkspaces.map(w => (
|
||||
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={slackSelectedUrls.has(w.url)}
|
||||
onChange={(e) => {
|
||||
setSlackSelectedUrls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (e.target.checked) next.add(w.url)
|
||||
else next.delete(w.url)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSlackSaveWorkspaces}
|
||||
disabled={slackSelectedUrls.size === 0 || slackLoading}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ComposioApiKeyModal
|
||||
open={c.composioApiKeyOpen}
|
||||
onOpenChange={c.setComposioApiKeyOpen}
|
||||
onSubmit={c.handleComposioApiKeySubmit}
|
||||
isSubmitting={c.gmailConnecting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro
|
|||
})
|
||||
}, [])
|
||||
|
||||
const commitField = useCallback((index: number) => {
|
||||
const commitField = useCallback((_index: number) => {
|
||||
setFields(prev => {
|
||||
commit(prev)
|
||||
return prev
|
||||
|
|
|
|||
|
|
@ -47,19 +47,37 @@ export function GoogleClientIdModal({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enter Google Client ID</DialogTitle>
|
||||
<DialogDescription>
|
||||
{description ?? "Enter the client ID for your Google OAuth app to continue."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="google-client-id">
|
||||
Client ID
|
||||
</label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Need help setting this up?{" "}
|
||||
<DialogContent className="w-[min(28rem,calc(100%-2rem))] max-w-md p-0 gap-0 overflow-hidden rounded-xl">
|
||||
<div className="p-6 pb-0">
|
||||
<DialogHeader className="space-y-1.5">
|
||||
<DialogTitle className="text-lg font-semibold">Google Client ID</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
{description ?? "Enter the client ID for your Google OAuth app to connect."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block" htmlFor="google-client-id">
|
||||
Client ID
|
||||
</label>
|
||||
<Input
|
||||
id="google-client-id"
|
||||
placeholder="xxxxxxxxxxxx-xxxx.apps.googleusercontent.com"
|
||||
value={clientId}
|
||||
onChange={(event) => setClientId(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Need help?{" "}
|
||||
<a
|
||||
className="text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
href={GOOGLE_CLIENT_ID_SETUP_GUIDE_URL}
|
||||
|
|
@ -68,31 +86,18 @@ export function GoogleClientIdModal({
|
|||
>
|
||||
Read the setup guide
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
<Input
|
||||
id="google-client-id"
|
||||
placeholder="xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com"
|
||||
value={clientId}
|
||||
onChange={(event) => setClientId(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t bg-muted/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2, Search, X } from 'lucide-react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
export type GraphNode = {
|
||||
|
|
@ -456,22 +456,13 @@ export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: Grap
|
|||
|
||||
return (
|
||||
<div ref={containerRef} className="graph-view relative h-full w-full">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/70 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Building graph…</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
{error ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && nodes.length === 0 ? (
|
||||
{!error && nodes.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
No notes found.
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,16 @@ import TaskList from '@tiptap/extension-task-list'
|
|||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
||||
import { TaskBlockExtension } from '@/extensions/task-block'
|
||||
import { ImageBlockExtension } from '@/extensions/image-block'
|
||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||
import { TableBlockExtension } from '@/extensions/table-block'
|
||||
import { CalendarBlockExtension } from '@/extensions/calendar-block'
|
||||
import { EmailBlockExtension } from '@/extensions/email-block'
|
||||
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Calendar, ChevronDown, ExternalLink } from 'lucide-react'
|
||||
|
||||
// Zero-width space used as invisible marker for blank lines
|
||||
const BLANK_LINE_MARKER = '\u200B'
|
||||
|
|
@ -101,41 +109,60 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
|||
const level = (node.attrs?.level as number) || 1
|
||||
const text = nodeToText(node)
|
||||
blocks.push('#'.repeat(level) + ' ' + text)
|
||||
} else if (node.type === 'bulletList' || node.type === 'orderedList') {
|
||||
// Handle lists - all items are part of one block
|
||||
const listLines: string[] = []
|
||||
const listItems = (node.content || []) as Array<{ content?: Array<unknown>; attrs?: Record<string, unknown> }>
|
||||
listItems.forEach((item, index) => {
|
||||
const prefix = node.type === 'orderedList' ? `${index + 1}. ` : '- '
|
||||
const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }>
|
||||
itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }, paraIndex: number) => {
|
||||
const text = nodeToText(para)
|
||||
if (paraIndex === 0) {
|
||||
listLines.push(prefix + text)
|
||||
} else if (node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList') {
|
||||
// Recursively serialize lists to handle nested bullets
|
||||
const serializeList = (
|
||||
listNode: { type?: string; content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> },
|
||||
indent: number
|
||||
): string[] => {
|
||||
const lines: string[] = []
|
||||
const items = (listNode.content || []) as Array<{ content?: Array<Record<string, unknown>>; attrs?: Record<string, unknown> }>
|
||||
items.forEach((item, index) => {
|
||||
const indentStr = ' '.repeat(indent)
|
||||
let prefix: string
|
||||
if (listNode.type === 'taskList') {
|
||||
const checked = item.attrs?.checked ? 'x' : ' '
|
||||
prefix = `- [${checked}] `
|
||||
} else if (listNode.type === 'orderedList') {
|
||||
prefix = `${index + 1}. `
|
||||
} else {
|
||||
listLines.push(' ' + text)
|
||||
prefix = '- '
|
||||
}
|
||||
const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }>
|
||||
let firstPara = true
|
||||
itemContent.forEach(child => {
|
||||
if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') {
|
||||
lines.push(...serializeList(child, indent + 1))
|
||||
} else {
|
||||
const text = nodeToText(child)
|
||||
if (firstPara) {
|
||||
lines.push(indentStr + prefix + text)
|
||||
firstPara = false
|
||||
} else {
|
||||
lines.push(indentStr + ' ' + text)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
blocks.push(listLines.join('\n'))
|
||||
} else if (node.type === 'taskList') {
|
||||
const listLines: string[] = []
|
||||
const listItems = (node.content || []) as Array<{ content?: Array<unknown>; attrs?: Record<string, unknown> }>
|
||||
listItems.forEach(item => {
|
||||
const checked = item.attrs?.checked ? 'x' : ' '
|
||||
const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }>
|
||||
itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }, paraIndex: number) => {
|
||||
const text = nodeToText(para)
|
||||
if (paraIndex === 0) {
|
||||
listLines.push(`- [${checked}] ${text}`)
|
||||
} else {
|
||||
listLines.push(' ' + text)
|
||||
}
|
||||
})
|
||||
})
|
||||
blocks.push(listLines.join('\n'))
|
||||
return lines
|
||||
}
|
||||
blocks.push(serializeList(node, 0).join('\n'))
|
||||
} else if (node.type === 'taskBlock') {
|
||||
blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'imageBlock') {
|
||||
blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'embedBlock') {
|
||||
blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'chartBlock') {
|
||||
blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'tableBlock') {
|
||||
blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'calendarBlock') {
|
||||
blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'emailBlock') {
|
||||
blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'transcriptBlock') {
|
||||
blocks.push('```transcript\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'codeBlock') {
|
||||
const lang = (node.attrs?.language as string) || ''
|
||||
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
||||
|
|
@ -206,6 +233,117 @@ type WikiLinkConfig = {
|
|||
onCreate: (path: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
// --- Meeting Event Banner ---
|
||||
|
||||
interface ParsedCalendarEvent {
|
||||
summary?: string
|
||||
start?: string
|
||||
end?: string
|
||||
location?: string
|
||||
htmlLink?: string
|
||||
conferenceLink?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
function parseCalendarEvent(raw: string | undefined): ParsedCalendarEvent | null {
|
||||
if (!raw) return null
|
||||
// Strip surrounding quotes if present (YAML single-quoted string)
|
||||
let json = raw
|
||||
if ((json.startsWith("'") && json.endsWith("'")) || (json.startsWith('"') && json.endsWith('"'))) {
|
||||
json = json.slice(1, -1)
|
||||
}
|
||||
// Unescape doubled single quotes from YAML
|
||||
json = json.replace(/''/g, "'")
|
||||
try {
|
||||
return JSON.parse(json) as ParsedCalendarEvent
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatEventTime(start?: string, end?: string): string {
|
||||
if (!start) return ''
|
||||
const s = new Date(start)
|
||||
const startStr = s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
const startTime = s.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
if (!end) return `${startStr} \u00b7 ${startTime}`
|
||||
const e = new Date(end)
|
||||
const endTime = e.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
return `${startStr} \u00b7 ${startTime} \u2013 ${endTime}`
|
||||
}
|
||||
|
||||
function formatEventDate(start?: string): string {
|
||||
if (!start) return ''
|
||||
const s = new Date(start)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
|
||||
if (s.toDateString() === today.toDateString()) return 'Today'
|
||||
if (s.toDateString() === yesterday.toDateString()) return 'Yesterday'
|
||||
if (s.toDateString() === tomorrow.toDateString()) return 'Tomorrow'
|
||||
return s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function MeetingEventBanner({ frontmatter }: { frontmatter: string | null | undefined }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
if (!frontmatter) return null
|
||||
const fields = extractAllFrontmatterValues(frontmatter)
|
||||
if (fields.type !== 'meeting') return null
|
||||
|
||||
const calStr = typeof fields.calendar_event === 'string' ? fields.calendar_event : undefined
|
||||
const cal = parseCalendarEvent(calStr)
|
||||
if (!cal) return null
|
||||
|
||||
return (
|
||||
<div className="meeting-event-banner" ref={ref}>
|
||||
<button
|
||||
className="meeting-event-pill"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Calendar size={13} />
|
||||
{formatEventDate(cal.start)}
|
||||
<ChevronDown size={12} className={`meeting-event-chevron ${open ? 'meeting-event-chevron-open' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="meeting-event-dropdown">
|
||||
<div className="meeting-event-dropdown-header">
|
||||
<span className="meeting-event-dropdown-dot" />
|
||||
<div className="meeting-event-dropdown-info">
|
||||
<div className="meeting-event-dropdown-title">{cal.summary || 'Meeting'}</div>
|
||||
<div className="meeting-event-dropdown-time">{formatEventTime(cal.start, cal.end)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{cal.htmlLink && (
|
||||
<button
|
||||
className="meeting-event-dropdown-link"
|
||||
onClick={() => window.open(cal.htmlLink, '_blank')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Open in Google Calendar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Editor ---
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
content: string
|
||||
onChange: (markdown: string) => void
|
||||
|
|
@ -220,6 +358,7 @@ interface MarkdownEditorProps {
|
|||
frontmatter?: string | null
|
||||
onFrontmatterChange?: (raw: string | null) => void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
notePath?: string
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -311,6 +450,7 @@ export function MarkdownEditor({
|
|||
frontmatter,
|
||||
onFrontmatterChange,
|
||||
onExport,
|
||||
notePath,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -429,6 +569,13 @@ export function MarkdownEditor({
|
|||
}),
|
||||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
ChartBlockExtension,
|
||||
TableBlockExtension,
|
||||
CalendarBlockExtension,
|
||||
EmailBlockExtension,
|
||||
TranscriptBlockExtension,
|
||||
WikiLink.configure({
|
||||
onCreate: wikiLinks?.onCreate
|
||||
? (path) => {
|
||||
|
|
@ -555,7 +702,7 @@ export function MarkdownEditor({
|
|||
|
||||
return false
|
||||
},
|
||||
handleClickOn: (_view, _pos, node, nodePos, event) => {
|
||||
handleClickOn: (_view, _pos, node, _nodePos, event) => {
|
||||
if (node.type.name === 'wikiLink') {
|
||||
event.preventDefault()
|
||||
wikiLinks?.onOpen?.(node.attrs.path)
|
||||
|
|
@ -912,24 +1059,17 @@ export function MarkdownEditor({
|
|||
}
|
||||
|
||||
if (activeRowboatMention) {
|
||||
// Classify schedule intent for new blocks
|
||||
const blockData: Record<string, unknown> = { instruction }
|
||||
try {
|
||||
const result = await window.ipc.invoke('inline-task:classifySchedule', { instruction })
|
||||
if (result.schedule) {
|
||||
const { label, ...rest } = result.schedule
|
||||
blockData.schedule = rest
|
||||
blockData['schedule-label'] = label
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RowboatAdd] Schedule classification failed:', error)
|
||||
}
|
||||
// Insert a temporary processing block
|
||||
const blockData: Record<string, unknown> = { instruction, processing: true }
|
||||
|
||||
const insertFrom = activeRowboatMention.range.from
|
||||
const insertTo = activeRowboatMention.range.to
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
{ from: activeRowboatMention.range.from, to: activeRowboatMention.range.to },
|
||||
{ from: insertFrom, to: insertTo },
|
||||
[
|
||||
{ type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } },
|
||||
{ type: 'paragraph' },
|
||||
|
|
@ -937,17 +1077,125 @@ export function MarkdownEditor({
|
|||
)
|
||||
.run()
|
||||
|
||||
// Mark note as live
|
||||
if (onFrontmatterChange) {
|
||||
const fields = extractAllFrontmatterValues(frontmatter ?? null)
|
||||
fields['live_note'] = 'true'
|
||||
onFrontmatterChange(buildFrontmatter(fields))
|
||||
}
|
||||
|
||||
setActiveRowboatMention(null)
|
||||
setRowboatAnchorTop(null)
|
||||
|
||||
// Get editor content for the agent
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const editorContent = (editor.storage as any).markdown?.getMarkdown?.() ?? ''
|
||||
|
||||
// Helper to find the processing block
|
||||
const findProcessingBlock = (): number | null => {
|
||||
let pos: number | null = null
|
||||
editor.state.doc.descendants((node, p) => {
|
||||
if (pos !== null) return false
|
||||
if (node.type.name === 'taskBlock') {
|
||||
try {
|
||||
const data = JSON.parse(node.attrs.data || '{}')
|
||||
if (data.instruction === instruction && data.processing === true) {
|
||||
pos = p
|
||||
return false
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
})
|
||||
return pos
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the copilot assistant for both one-time and recurring tasks
|
||||
const result = await window.ipc.invoke('inline-task:process', {
|
||||
instruction,
|
||||
noteContent: editorContent,
|
||||
notePath: notePath ?? '',
|
||||
})
|
||||
|
||||
const currentPos = findProcessingBlock()
|
||||
if (currentPos === null) return
|
||||
|
||||
const node = editor.state.doc.nodeAt(currentPos)
|
||||
if (!node) return
|
||||
|
||||
if (result.schedule) {
|
||||
// Recurring/scheduled task: update block with schedule, write target tags to disk
|
||||
const targetId = Math.random().toString(36).slice(2, 10)
|
||||
const updatedData: Record<string, unknown> = {
|
||||
instruction: result.instruction,
|
||||
schedule: result.schedule,
|
||||
'schedule-label': result.scheduleLabel,
|
||||
targetId,
|
||||
}
|
||||
const tr = editor.state.tr.setNodeMarkup(currentPos, undefined, {
|
||||
data: JSON.stringify(updatedData),
|
||||
})
|
||||
editor.view.dispatch(tr)
|
||||
|
||||
// Mark note as live
|
||||
if (onFrontmatterChange) {
|
||||
const fields = extractAllFrontmatterValues(frontmatter ?? null)
|
||||
fields['live_note'] = 'true'
|
||||
onFrontmatterChange(buildFrontmatter(fields))
|
||||
}
|
||||
|
||||
// Write target tags directly to the file on disk after a short delay
|
||||
// to let the editor save the updated content first
|
||||
if (notePath) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const file = await window.ipc.invoke('workspace:readFile', { path: notePath })
|
||||
const content = file.data
|
||||
const openTag = `<!--task-target:${targetId}-->`
|
||||
const closeTag = `<!--/task-target:${targetId}-->`
|
||||
|
||||
// Only add if not already present
|
||||
if (content.includes(openTag)) return
|
||||
|
||||
// Find the task block in the raw markdown and insert target tags after it
|
||||
const blockJson = JSON.stringify(updatedData)
|
||||
const blockStart = content.indexOf('```task\n' + blockJson)
|
||||
if (blockStart !== -1) {
|
||||
const blockEnd = content.indexOf('\n```', blockStart + 8)
|
||||
if (blockEnd !== -1) {
|
||||
const insertAt = blockEnd + 4 // after the closing ```
|
||||
const before = content.slice(0, insertAt)
|
||||
const after = content.slice(insertAt)
|
||||
const updated = before + '\n\n' + openTag + '\n' + closeTag + after
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
data: updated,
|
||||
opts: { encoding: 'utf8' },
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RowboatAdd] Failed to write target tags:', err)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
} else {
|
||||
// One-time task: remove the processing block, insert response in its place
|
||||
const insertPos = currentPos
|
||||
const deleteEnd = currentPos + node.nodeSize
|
||||
editor.chain().focus().deleteRange({ from: insertPos, to: deleteEnd }).run()
|
||||
|
||||
if (result.response) {
|
||||
editor.chain().insertContentAt(insertPos, result.response).run()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RowboatAdd] Processing failed:', error)
|
||||
|
||||
// Remove the processing block on error
|
||||
const currentPos = findProcessingBlock()
|
||||
if (currentPos !== null) {
|
||||
const node = editor.state.doc.nodeAt(currentPos)
|
||||
if (node) {
|
||||
editor.chain().focus().deleteRange({ from: currentPos, to: currentPos + node.nodeSize }).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange])
|
||||
}, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange, notePath])
|
||||
|
||||
const handleRowboatRemove = useCallback(() => {
|
||||
if (!editor || !rowboatBlockEdit) return
|
||||
|
|
@ -1082,6 +1330,7 @@ export function MarkdownEditor({
|
|||
editable={editable}
|
||||
/>
|
||||
)}
|
||||
<MeetingEventBanner frontmatter={frontmatter} />
|
||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||
<EditorContent editor={editor} />
|
||||
{wikiLinks ? (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Loader2, Mic, Mail, CheckCircle2, MessageSquare } from "lucide-react"
|
||||
import { Loader2, Mic, Mail, Calendar, CheckCircle2, ArrowLeft, MessageSquare } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -25,6 +25,7 @@ import { cn } from "@/lib/utils"
|
|||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { toast } from "sonner"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
|
||||
interface ProviderState {
|
||||
isConnected: boolean
|
||||
|
|
@ -37,7 +38,9 @@ interface OnboardingModalProps {
|
|||
onComplete: () => void
|
||||
}
|
||||
|
||||
type Step = 0 | 1 | 2
|
||||
type Step = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
type OnboardingPath = 'rowboat' | 'byok' | null
|
||||
|
||||
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
||||
|
||||
|
|
@ -49,6 +52,7 @@ interface LlmModelOption {
|
|||
|
||||
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState<Step>(0)
|
||||
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
|
||||
|
||||
// LLM setup state
|
||||
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("openai")
|
||||
|
|
@ -78,6 +82,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
||||
// Composio API key state
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
const [, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
||||
|
||||
// Slack state (agent-slack CLI)
|
||||
const [slackEnabled, setSlackEnabled] = useState(false)
|
||||
const [slackLoading, setSlackLoading] = useState(true)
|
||||
|
|
@ -88,6 +96,18 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Composio/Gmail state
|
||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(true)
|
||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||
|
||||
// Composio/Google Calendar state
|
||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
|
|
@ -115,7 +135,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
.filter(([, state]) => state.isConnected)
|
||||
.map(([provider]) => provider)
|
||||
|
||||
// Load available providers on mount
|
||||
// Load available providers and composio-for-google flag on mount
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
|
|
@ -131,7 +151,25 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
setProvidersLoading(false)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||
setUseComposioForGoogle(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google flag:', error)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleCalendarFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||
setUseComposioForGoogleCalendar(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||
}
|
||||
}
|
||||
loadProviders()
|
||||
loadComposioForGoogleFlag()
|
||||
loadComposioForGoogleCalendarFlag()
|
||||
}, [open])
|
||||
|
||||
// Load LLM models catalog on open
|
||||
|
|
@ -254,6 +292,101 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Load Gmail connection status
|
||||
const refreshGmailStatus = useCallback(async () => {
|
||||
try {
|
||||
setGmailLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' })
|
||||
setGmailConnected(result.isConnected)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Gmail status:', error)
|
||||
setGmailConnected(false)
|
||||
} finally {
|
||||
setGmailLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load Google Calendar connection status
|
||||
const refreshGoogleCalendarStatus = useCallback(async () => {
|
||||
try {
|
||||
setGoogleCalendarLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' })
|
||||
setGoogleCalendarConnected(result.isConnected)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Google Calendar status:', error)
|
||||
setGoogleCalendarConnected(false)
|
||||
} finally {
|
||||
setGoogleCalendarLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect to Gmail via Composio
|
||||
const startGmailConnect = useCallback(async () => {
|
||||
try {
|
||||
setGmailConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Gmail')
|
||||
setGmailConnecting(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Gmail:', error)
|
||||
toast.error('Failed to connect to Gmail')
|
||||
setGmailConnecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle Gmail connect button click
|
||||
const handleConnectGmail = useCallback(async () => {
|
||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configResult.configured) {
|
||||
setComposioApiKeyTarget('gmail')
|
||||
setComposioApiKeyOpen(true)
|
||||
return
|
||||
}
|
||||
await startGmailConnect()
|
||||
}, [startGmailConnect])
|
||||
|
||||
// Connect to Google Calendar via Composio
|
||||
const startGoogleCalendarConnect = useCallback(async () => {
|
||||
try {
|
||||
setGoogleCalendarConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Google Calendar')
|
||||
setGoogleCalendarConnecting(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Google Calendar:', error)
|
||||
toast.error('Failed to connect to Google Calendar')
|
||||
setGoogleCalendarConnecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle Google Calendar connect button click
|
||||
const handleConnectGoogleCalendar = useCallback(async () => {
|
||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configResult.configured) {
|
||||
setComposioApiKeyTarget('gmail')
|
||||
setComposioApiKeyOpen(true)
|
||||
return
|
||||
}
|
||||
await startGoogleCalendarConnect()
|
||||
}, [startGoogleCalendarConnect])
|
||||
|
||||
// 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')
|
||||
await startGmailConnect()
|
||||
} catch (error) {
|
||||
console.error('Failed to save Composio API key:', error)
|
||||
toast.error('Failed to save API key')
|
||||
}
|
||||
}, [startGmailConnect])
|
||||
|
||||
// Save selected Slack workspaces
|
||||
const handleSlackSaveWorkspaces = useCallback(async () => {
|
||||
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
||||
|
|
@ -290,11 +423,29 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}, [])
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 2) {
|
||||
if (currentStep < 4) {
|
||||
setCurrentStep((prev) => (prev + 1) as Step)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === 1) {
|
||||
// BYOK upsell → back to sign-in page
|
||||
setOnboardingPath(null)
|
||||
setCurrentStep(0 as Step)
|
||||
} else if (currentStep === 2) {
|
||||
// LLM setup → back to BYOK upsell
|
||||
setCurrentStep(1 as Step)
|
||||
} else if (currentStep === 3) {
|
||||
// Connect accounts → back depends on path
|
||||
if (onboardingPath === 'rowboat') {
|
||||
setCurrentStep(0 as Step)
|
||||
} else {
|
||||
setCurrentStep(2 as Step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
onComplete()
|
||||
}
|
||||
|
|
@ -341,6 +492,16 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
// Refresh Slack config
|
||||
refreshSlackConfig()
|
||||
|
||||
// Refresh Gmail Composio status if enabled
|
||||
if (useComposioForGoogle) {
|
||||
refreshGmailStatus()
|
||||
}
|
||||
|
||||
// Refresh Google Calendar Composio status if enabled
|
||||
if (useComposioForGoogleCalendar) {
|
||||
refreshGoogleCalendarStatus()
|
||||
}
|
||||
|
||||
// Refresh OAuth providers
|
||||
if (providers.length === 0) return
|
||||
|
||||
|
|
@ -368,7 +529,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig])
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
|
||||
|
||||
// Refresh statuses when modal opens or providers list changes
|
||||
useEffect(() => {
|
||||
|
|
@ -377,10 +538,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
}, [open, providers, refreshAllStatuses])
|
||||
|
||||
// Listen for OAuth completion events
|
||||
// Listen for OAuth completion events (state updates only — toasts handled by ConnectorsPopover)
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
const { provider, success, error } = event
|
||||
const { provider, success } = event
|
||||
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
|
|
@ -390,18 +551,44 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
isConnecting: false,
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
if (success) {
|
||||
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
toast.success(`Connected to ${displayName}`)
|
||||
} else {
|
||||
toast.error(error || `Failed to connect to ${provider}`)
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Auto-advance from Rowboat sign-in step when OAuth completes
|
||||
useEffect(() => {
|
||||
if (onboardingPath !== 'rowboat' || currentStep !== 0) return
|
||||
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (event.provider === 'rowboat' && event.success) {
|
||||
setCurrentStep(3 as Step)
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [onboardingPath, currentStep])
|
||||
|
||||
// Listen for Composio connection events (state updates only — toasts handled by ConnectorsPopover)
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||
const { toolkitSlug, success } = event
|
||||
|
||||
if (toolkitSlug === 'gmail') {
|
||||
setGmailConnected(success)
|
||||
setGmailConnecting(false)
|
||||
}
|
||||
|
||||
if (toolkitSlug === 'googlecalendar') {
|
||||
setGoogleCalendarConnected(success)
|
||||
setGoogleCalendarConnecting(false)
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
|
||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
|
|
@ -449,20 +636,30 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
startConnect('google', clientId)
|
||||
}, [startConnect])
|
||||
|
||||
// Step indicator
|
||||
const renderStepIndicator = () => (
|
||||
<div className="flex gap-2 justify-center mb-6">
|
||||
{[0, 1, 2].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full transition-colors",
|
||||
currentStep >= step ? "bg-primary" : "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
// Step indicator - dynamic based on path
|
||||
const renderStepIndicator = () => {
|
||||
// Rowboat path: Sign In (0), Connect (3), Done (4) = 3 dots
|
||||
// BYOK path: Sign In (0), Upsell (1), Model (2), Connect (3), Done (4) = 5 dots
|
||||
// Before path is chosen: show 3 dots (minimal)
|
||||
const rowboatSteps = [0, 3, 4]
|
||||
const byokSteps = [0, 1, 2, 3, 4]
|
||||
const steps = onboardingPath === 'byok' ? byokSteps : rowboatSteps
|
||||
const currentIndex = steps.indexOf(currentStep)
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 justify-center mb-6">
|
||||
{steps.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full transition-colors",
|
||||
currentIndex >= i ? "bg-primary" : "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to render an OAuth provider row
|
||||
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
|
||||
|
|
@ -544,6 +741,94 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</div>
|
||||
)
|
||||
|
||||
// Render Gmail Composio row
|
||||
const renderGmailRow = () => (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||
<Mail className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Gmail</span>
|
||||
{gmailLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Sync emails
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{gmailLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : gmailConnected ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConnectGmail}
|
||||
disabled={gmailConnecting}
|
||||
>
|
||||
{gmailConnecting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render Google Calendar Composio row
|
||||
const renderGoogleCalendarRow = () => (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||
<Calendar className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Google Calendar</span>
|
||||
{googleCalendarLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Sync calendar events
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{googleCalendarLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : googleCalendarConnected ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConnectGoogleCalendar}
|
||||
disabled={googleCalendarConnecting}
|
||||
>
|
||||
{googleCalendarConnecting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render Slack row
|
||||
const renderSlackRow = () => (
|
||||
<div className="rounded-md px-3 py-3 hover:bg-accent">
|
||||
|
|
@ -625,7 +910,123 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</div>
|
||||
)
|
||||
|
||||
// Step 0: LLM Setup
|
||||
// Step 0: Sign in to Rowboat (with BYOK option)
|
||||
const renderSignInStep = () => {
|
||||
const rowboatState = providerStates['rowboat'] || { isConnected: false, isLoading: false, isConnecting: false }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-3">
|
||||
<span className="text-lg font-medium text-muted-foreground">Your AI coworker, with memory</span>
|
||||
</div>
|
||||
<DialogHeader className="space-y-3 mb-8">
|
||||
<DialogTitle className="text-2xl">Sign in to Rowboat</DialogTitle>
|
||||
<DialogDescription className="text-base max-w-md mx-auto">
|
||||
Connect your Rowboat account for instant access to all models through our gateway — no API keys needed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{rowboatState.isConnected ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle2 className="size-5" />
|
||||
<span className="text-sm font-medium">Connected to Rowboat</span>
|
||||
</div>
|
||||
<Button onClick={() => setCurrentStep(3 as Step)} size="lg" className="w-full max-w-xs">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 w-full max-w-xs">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOnboardingPath('rowboat')
|
||||
startConnect('rowboat')
|
||||
}}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={rowboatState.isConnecting}
|
||||
>
|
||||
{rowboatState.isConnecting ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Waiting for sign in...</>
|
||||
) : (
|
||||
"Sign in with Rowboat"
|
||||
)}
|
||||
</Button>
|
||||
{rowboatState.isConnecting && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Complete sign in in your browser, then return here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full flex justify-end mt-8">
|
||||
<button
|
||||
onClick={() => {
|
||||
setOnboardingPath('byok')
|
||||
setCurrentStep(1 as Step)
|
||||
}}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Bring your own key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 1: BYOK upsell — explain benefits of Rowboat before continuing with BYOK
|
||||
const renderByokUpsellStep = () => (
|
||||
<div className="flex flex-col">
|
||||
<DialogHeader className="text-center mb-6">
|
||||
<DialogTitle className="text-2xl">Before you continue</DialogTitle>
|
||||
<DialogDescription className="text-base max-w-md mx-auto">
|
||||
With a Rowboat account, you get:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 mb-8">
|
||||
<div className="flex items-start gap-3 rounded-md border px-4 py-3">
|
||||
<CheckCircle2 className="size-5 text-green-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">Instant access to all models</div>
|
||||
<div className="text-xs text-muted-foreground">GPT, Claude, Gemini, and more — no separate API keys needed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-md border px-4 py-3">
|
||||
<CheckCircle2 className="size-5 text-green-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">Simplified billing</div>
|
||||
<div className="text-xs text-muted-foreground">One account for everything — no juggling multiple provider subscriptions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-md border px-4 py-3">
|
||||
<CheckCircle2 className="size-5 text-green-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">Automatic updates</div>
|
||||
<div className="text-xs text-muted-foreground">New models are available as soon as they launch, with no configuration changes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground text-center mb-6">
|
||||
By continuing, you'll set up your own API keys instead of using Rowboat's managed gateway.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={handleBack} className="gap-1">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNext}>
|
||||
I understand
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Step 2 (BYOK path): LLM Setup
|
||||
const renderLlmSetupStep = () => {
|
||||
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "Use your OpenAI API key" },
|
||||
|
|
@ -801,10 +1202,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 mt-4">
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<Button variant="ghost" onClick={handleBack} className="gap-1">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTestAndSaveLlmConfig}
|
||||
size="lg"
|
||||
disabled={!canTest || testState.status === "testing"}
|
||||
>
|
||||
{testState.status === "testing" ? (
|
||||
|
|
@ -818,7 +1222,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// Step 1: Connect Accounts
|
||||
// Step 3: Connect Accounts
|
||||
const renderAccountConnectionStep = () => (
|
||||
<div className="flex flex-col">
|
||||
<DialogHeader className="text-center mb-6">
|
||||
|
|
@ -835,13 +1239,19 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Email & Calendar Section */}
|
||||
{providers.includes('google') && (
|
||||
{/* Email / Email & Calendar Section */}
|
||||
{(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && (
|
||||
<div className="space-y-2">
|
||||
<div className="px-3">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email & Calendar</span>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{(useComposioForGoogle || useComposioForGoogleCalendar) ? 'Email & Calendar' : 'Email & Calendar'}
|
||||
</span>
|
||||
</div>
|
||||
{renderOAuthProvider('google', 'Google', <Mail className="size-5" />, 'Sync emails and calendar events')}
|
||||
{useComposioForGoogle
|
||||
? renderGmailRow()
|
||||
: renderOAuthProvider('google', 'Google', <Mail className="size-5" />, 'Sync emails and calendar events')
|
||||
}
|
||||
{useComposioForGoogleCalendar && renderGoogleCalendarRow()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -869,16 +1279,22 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
<Button onClick={handleNext} size="lg">
|
||||
Continue
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
|
||||
Skip for now
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={handleBack} className="gap-1">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Step 2: Completion
|
||||
// Step 4: Completion
|
||||
const renderCompletionStep = () => {
|
||||
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled
|
||||
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
|
|
@ -901,6 +1317,18 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<p className="text-sm font-medium mb-2">Connected accounts:</p>
|
||||
<div className="space-y-1">
|
||||
{gmailConnected && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
<span>Gmail (Email)</span>
|
||||
</div>
|
||||
)}
|
||||
{googleCalendarConnected && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
<span>Google Calendar</span>
|
||||
</div>
|
||||
)}
|
||||
{connectedProviders.includes('google') && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
|
|
@ -945,6 +1373,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
onSubmit={handleGoogleClientIdSubmit}
|
||||
isSubmitting={providerStates.google?.isConnecting ?? false}
|
||||
/>
|
||||
<ComposioApiKeyModal
|
||||
open={composioApiKeyOpen}
|
||||
onOpenChange={setComposioApiKeyOpen}
|
||||
onSubmit={handleComposioApiKeySubmit}
|
||||
isSubmitting={gmailConnecting}
|
||||
/>
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="w-[60vw] max-w-3xl max-h-[80vh] overflow-y-auto"
|
||||
|
|
@ -953,9 +1387,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{renderStepIndicator()}
|
||||
{currentStep === 0 && renderLlmSetupStep()}
|
||||
{currentStep === 1 && renderAccountConnectionStep()}
|
||||
{currentStep === 2 && renderCompletionStep()}
|
||||
{currentStep === 0 && renderSignInStep()}
|
||||
{currentStep === 1 && renderByokUpsellStep()}
|
||||
{currentStep === 2 && renderLlmSetupStep()}
|
||||
{currentStep === 3 && renderAccountConnectionStep()}
|
||||
{currentStep === 4 && renderCompletionStep()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
|
|
|||
83
apps/x/apps/renderer/src/components/onboarding/index.tsx
Normal file
83
apps/x/apps/renderer/src/components/onboarding/index.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from "@/components/ui/dialog"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
import { useOnboardingState } from "./use-onboarding-state"
|
||||
import { StepIndicator } from "./step-indicator"
|
||||
import { WelcomeStep } from "./steps/welcome-step"
|
||||
import { LlmSetupStep } from "./steps/llm-setup-step"
|
||||
import { ConnectAccountsStep } from "./steps/connect-accounts-step"
|
||||
import { CompletionStep } from "./steps/completion-step"
|
||||
|
||||
interface OnboardingModalProps {
|
||||
open: boolean
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||
const state = useOnboardingState(open, onComplete)
|
||||
|
||||
const stepContent = React.useMemo(() => {
|
||||
switch (state.currentStep) {
|
||||
case 0:
|
||||
return <WelcomeStep state={state} />
|
||||
case 1:
|
||||
return <LlmSetupStep state={state} />
|
||||
case 2:
|
||||
return <ConnectAccountsStep state={state} />
|
||||
case 3:
|
||||
return <CompletionStep state={state} />
|
||||
}
|
||||
}, [state.currentStep, state])
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoogleClientIdModal
|
||||
open={state.googleClientIdOpen}
|
||||
onOpenChange={state.setGoogleClientIdOpen}
|
||||
onSubmit={state.handleGoogleClientIdSubmit}
|
||||
isSubmitting={state.providerStates.google?.isConnecting ?? false}
|
||||
/>
|
||||
<ComposioApiKeyModal
|
||||
open={state.composioApiKeyOpen}
|
||||
onOpenChange={state.setComposioApiKeyOpen}
|
||||
onSubmit={state.handleComposioApiKeySubmit}
|
||||
isSubmitting={state.gmailConnecting}
|
||||
/>
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="w-[90vw] max-w-2xl max-h-[85vh] p-0 overflow-hidden"
|
||||
showCloseButton={false}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col h-full max-h-[85vh] overflow-y-auto p-8 md:p-10">
|
||||
<StepIndicator
|
||||
currentStep={state.currentStep}
|
||||
path={state.onboardingPath}
|
||||
/>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={state.currentStep}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
{stepContent}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface IconProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function OpenAIIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnthropicIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M17.304 3.541h-3.483l6.15 16.918h3.483zm-10.61 0L.545 20.459H4.15l1.278-3.554h6.539l1.278 3.554h3.604L10.698 3.541zm.49 10.537 2.065-5.728h.054l2.065 5.728z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoogleIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function OllamaIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-2-11a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm4 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-5.07 5.14a.5.5 0 0 1 .71-.07A4.97 4.97 0 0 0 12 15.5c.93 0 1.8-.26 2.53-.7a.5.5 0 1 1 .51.86A5.97 5.97 0 0 1 12 16.5a5.97 5.97 0 0 1-3.14-.88.5.5 0 0 1 .07-.48z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpenRouterIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M4 4h7v7H4zm9 0h7v7h-7zm-9 9h7v7H4zm9 0h7v7h-7z" opacity="0.8" />
|
||||
<path d="M6 6h3v3H6zm9 0h3v3h-3zM6 15h3v3H6zm9 0h3v3h-3z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function VercelIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M12 1L24 22H0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GmailIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
|
||||
<path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z" fill="#EA4335" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SlackIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" fill="#E01E5A" />
|
||||
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" fill="#36C5F0" />
|
||||
<path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.271 0a2.527 2.527 0 0 1-2.521 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.164 0a2.528 2.528 0 0 1 2.521 2.522v6.312z" fill="#2EB67D" />
|
||||
<path d="M15.164 18.956a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.164 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.271a2.527 2.527 0 0 1-2.521-2.521 2.527 2.527 0 0 1 2.521-2.521h6.314A2.528 2.528 0 0 1 24 15.164a2.528 2.528 0 0 1-2.522 2.521h-6.314z" fill="#ECB22E" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FirefliesIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<circle cx="12" cy="6" r="2" opacity="0.9" />
|
||||
<circle cx="7" cy="9" r="1.5" opacity="0.7" />
|
||||
<circle cx="17" cy="9" r="1.5" opacity="0.7" />
|
||||
<circle cx="5" cy="13" r="1" opacity="0.5" />
|
||||
<circle cx="19" cy="13" r="1" opacity="0.5" />
|
||||
<circle cx="8" cy="16" r="1.5" opacity="0.6" />
|
||||
<circle cx="16" cy="16" r="1.5" opacity="0.6" />
|
||||
<circle cx="12" cy="19" r="2" opacity="0.8" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GranolaIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M12 2a2 2 0 0 1 2 2v1h3a2 2 0 0 1 2 2v2h1a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-1v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-2H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h1V7a2 2 0 0 1 2-2h3V4a2 2 0 0 1 2-2zm0 2h-2v1h4V4h-2zm5 3H7v2h10V7zM4 11v6h16v-6H4zm3 10h10v-2H7v2z" opacity="0.85" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GenericApiIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 13h8v2H8v-2zm0 4h5v2H8v-2z" opacity="0.8" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import * as React from "react"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Step, OnboardingPath } from "./use-onboarding-state"
|
||||
|
||||
const ROWBOAT_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
const BYOK_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 1 as Step, label: "Model" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: Step
|
||||
path: OnboardingPath
|
||||
}
|
||||
|
||||
export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
||||
const steps = path === 'byok' ? BYOK_STEPS : ROWBOAT_STEPS
|
||||
const currentIndex = steps.findIndex(s => s.step === currentStep)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-8 px-4">
|
||||
{steps.map((s, i) => (
|
||||
<React.Fragment key={s.step}>
|
||||
{i > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-px flex-1 transition-colors duration-500",
|
||||
i <= currentIndex ? "bg-primary" : "bg-border"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"size-8 rounded-full flex items-center justify-center text-xs font-medium transition-all duration-300",
|
||||
i < currentIndex && "bg-primary text-primary-foreground",
|
||||
i === currentIndex && "bg-primary text-primary-foreground ring-4 ring-primary/20",
|
||||
i > currentIndex && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{i < currentIndex ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-medium transition-colors duration-300",
|
||||
i <= currentIndex ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { CheckCircle2 } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { OnboardingState } from "../use-onboarding-state"
|
||||
|
||||
interface CompletionStepProps {
|
||||
state: OnboardingState
|
||||
}
|
||||
|
||||
export function CompletionStep({ state }: CompletionStepProps) {
|
||||
const { connectedProviders, granolaEnabled, slackEnabled, gmailConnected, googleCalendarConnected, handleComplete } = state
|
||||
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center flex-1">
|
||||
{/* Animated checkmark */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.1 }}
|
||||
className="relative mb-8"
|
||||
>
|
||||
{/* Pulsing ring */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0.6 }}
|
||||
animate={{ scale: 1.5, opacity: 0 }}
|
||||
transition={{ duration: 1.2, repeat: 2, ease: "easeOut" }}
|
||||
className="absolute inset-0 rounded-full bg-green-500/20"
|
||||
/>
|
||||
<div className="relative size-20 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<CheckCircle2 className="size-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="text-3xl font-bold tracking-tight mb-3"
|
||||
>
|
||||
You're All Set!
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
className="text-base text-muted-foreground leading-relaxed max-w-sm mb-8"
|
||||
>
|
||||
{hasConnections ? (
|
||||
<>Give me 30 minutes to build your context graph. I can still help with other things on your computer.</>
|
||||
) : (
|
||||
<>You can connect your accounts anytime from the sidebar to start syncing data.</>
|
||||
)}
|
||||
</motion.p>
|
||||
|
||||
{/* Connected accounts summary */}
|
||||
{hasConnections && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.45 }}
|
||||
className="w-full max-w-sm rounded-xl border bg-muted/30 p-4 mb-8"
|
||||
>
|
||||
<p className="text-sm font-semibold mb-3 text-left">Connected</p>
|
||||
<div className="space-y-2">
|
||||
{gmailConnected && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Gmail (Email)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{googleCalendarConnected && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.52 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Google Calendar</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{connectedProviders.includes('google') && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Google (Email & Calendar)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{connectedProviders.includes('fireflies-ai') && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.55 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Fireflies (Meeting transcripts)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{granolaEnabled && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Granola (Local meeting notes)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{slackEnabled && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.65 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Slack (Team communication)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
size="lg"
|
||||
className="w-full max-w-xs h-12 text-base font-medium"
|
||||
>
|
||||
Start Using Rowboat
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
import { Loader2, CheckCircle2, ArrowLeft, Calendar } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { GmailIcon, SlackIcon, FirefliesIcon, GranolaIcon } from "../provider-icons"
|
||||
import type { OnboardingState, ProviderState } from "../use-onboarding-state"
|
||||
|
||||
interface ConnectAccountsStepProps {
|
||||
state: OnboardingState
|
||||
}
|
||||
|
||||
function ProviderCard({
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
iconBg,
|
||||
iconColor,
|
||||
providerState,
|
||||
onConnect,
|
||||
rightSlot,
|
||||
index,
|
||||
}: {
|
||||
name: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
iconBg: string
|
||||
iconColor: string
|
||||
providerState?: ProviderState
|
||||
onConnect?: () => void
|
||||
rightSlot?: React.ReactNode
|
||||
index: number
|
||||
}) {
|
||||
const isConnected = providerState?.isConnected ?? false
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.06 }}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4 rounded-xl border p-4 transition-colors",
|
||||
isConnected
|
||||
? "border-green-200 bg-green-50/50 dark:border-green-800/50 dark:bg-green-900/10"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", iconBg)}>
|
||||
<span className={iconColor}>{icon}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold">{name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{rightSlot ?? (
|
||||
providerState?.isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : isConnected ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span className="font-medium">Connected</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onConnect}
|
||||
disabled={providerState?.isConnecting}
|
||||
>
|
||||
{providerState?.isConnecting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
|
||||
const {
|
||||
providers, providersLoading, providerStates, handleConnect,
|
||||
granolaEnabled, granolaLoading, handleGranolaToggle,
|
||||
slackEnabled, slackLoading, slackWorkspaces, slackAvailableWorkspaces,
|
||||
slackSelectedUrls, setSlackSelectedUrls, slackPickerOpen,
|
||||
slackDiscovering, slackDiscoverError,
|
||||
handleSlackEnable, handleSlackSaveWorkspaces, handleSlackDisable,
|
||||
useComposioForGoogle, gmailConnected, gmailLoading, gmailConnecting, handleConnectGmail,
|
||||
useComposioForGoogleCalendar, googleCalendarConnected, googleCalendarLoading, googleCalendarConnecting, handleConnectGoogleCalendar,
|
||||
handleNext, handleBack,
|
||||
} = state
|
||||
|
||||
let cardIndex = 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
|
||||
Connect Your Accounts
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center leading-relaxed mb-8">
|
||||
Connect your accounts to give Rowboat context about your work. You can always add more later.
|
||||
</p>
|
||||
|
||||
{providersLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Email & Calendar */}
|
||||
{(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && (
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Email & Calendar
|
||||
</span>
|
||||
{useComposioForGoogle ? (
|
||||
<ProviderCard
|
||||
name="Gmail"
|
||||
description="Sync your email for context-aware assistance"
|
||||
icon={<GmailIcon />}
|
||||
iconBg="bg-red-500/10"
|
||||
iconColor="text-red-500"
|
||||
providerState={{ isConnected: gmailConnected, isLoading: gmailLoading, isConnecting: gmailConnecting }}
|
||||
onConnect={handleConnectGmail}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
) : (
|
||||
<ProviderCard
|
||||
name="Google"
|
||||
description="Rowboat uses your email and calendar to provide personalized, context-aware assistance"
|
||||
icon={<GmailIcon />}
|
||||
iconBg="bg-red-500/10"
|
||||
iconColor="text-red-500"
|
||||
providerState={providerStates['google']}
|
||||
onConnect={() => handleConnect('google')}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
)}
|
||||
{useComposioForGoogleCalendar && (
|
||||
<ProviderCard
|
||||
name="Google Calendar"
|
||||
description="Sync calendar events for scheduling awareness"
|
||||
icon={<Calendar className="size-5" />}
|
||||
iconBg="bg-blue-500/10"
|
||||
iconColor="text-blue-500"
|
||||
providerState={{ isConnected: googleCalendarConnected, isLoading: googleCalendarLoading, isConnecting: googleCalendarConnecting }}
|
||||
onConnect={handleConnectGoogleCalendar}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meeting Notes */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Meeting Notes
|
||||
</span>
|
||||
<ProviderCard
|
||||
name="Granola"
|
||||
description="Sync your local meeting notes for richer context"
|
||||
icon={<GranolaIcon />}
|
||||
iconBg="bg-purple-500/10"
|
||||
iconColor="text-purple-500"
|
||||
providerState={{ isConnected: granolaEnabled, isLoading: false, isConnecting: false }}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-2">
|
||||
{granolaLoading && <Loader2 className="size-3 animate-spin" />}
|
||||
<Switch
|
||||
checked={granolaEnabled}
|
||||
onCheckedChange={handleGranolaToggle}
|
||||
disabled={granolaLoading}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
{providers.includes('fireflies-ai') && (
|
||||
<ProviderCard
|
||||
name="Fireflies"
|
||||
description="Import AI-powered meeting transcripts automatically"
|
||||
icon={<FirefliesIcon />}
|
||||
iconBg="bg-amber-500/10"
|
||||
iconColor="text-amber-500"
|
||||
providerState={providerStates['fireflies-ai']}
|
||||
onConnect={() => handleConnect('fireflies-ai')}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Communication */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Team Communication
|
||||
</span>
|
||||
<div>
|
||||
<ProviderCard
|
||||
name="Slack"
|
||||
description={
|
||||
slackEnabled && slackWorkspaces.length > 0
|
||||
? slackWorkspaces.map(w => w.name).join(', ')
|
||||
: "Enable Rowboat to understand your team conversations and provide relevant context"
|
||||
}
|
||||
icon={<SlackIcon />}
|
||||
iconBg="bg-emerald-500/10"
|
||||
iconColor="text-emerald-500"
|
||||
providerState={{ isConnected: slackEnabled, isLoading: false, isConnecting: false }}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-2">
|
||||
{(slackLoading || slackDiscovering) && <Loader2 className="size-3 animate-spin" />}
|
||||
{slackEnabled ? (
|
||||
<Switch
|
||||
checked={true}
|
||||
onCheckedChange={() => handleSlackDisable()}
|
||||
disabled={slackLoading}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSlackEnable}
|
||||
disabled={slackLoading || slackDiscovering}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
{slackPickerOpen && (
|
||||
<div className="mt-2 ml-[3.25rem] space-y-2 pl-4 border-l-2 border-muted">
|
||||
{slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
{slackAvailableWorkspaces.map(w => (
|
||||
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={slackSelectedUrls.has(w.url)}
|
||||
onChange={(e) => {
|
||||
setSlackSelectedUrls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (e.target.checked) next.add(w.url)
|
||||
else next.delete(w.url)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSlackSaveWorkspaces}
|
||||
disabled={slackSelectedUrls.size === 0 || slackLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col gap-3 mt-8 pt-4 border-t">
|
||||
<Button onClick={handleNext} size="lg" className="h-12 text-base font-medium">
|
||||
Continue
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={handleBack} className="gap-1">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
OpenAIIcon,
|
||||
AnthropicIcon,
|
||||
GoogleIcon,
|
||||
OllamaIcon,
|
||||
OpenRouterIcon,
|
||||
VercelIcon,
|
||||
GenericApiIcon,
|
||||
} from "../provider-icons"
|
||||
import type { OnboardingState, LlmProviderFlavor } from "../use-onboarding-state"
|
||||
|
||||
interface LlmSetupStepProps {
|
||||
state: OnboardingState
|
||||
}
|
||||
|
||||
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "GPT models", color: "bg-green-500/10 text-green-600 dark:text-green-400", icon: <OpenAIIcon /> },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: <AnthropicIcon /> },
|
||||
{ id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: <GoogleIcon /> },
|
||||
{ id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: <OllamaIcon /> },
|
||||
]
|
||||
|
||||
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
|
||||
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", color: "bg-pink-500/10 text-pink-600 dark:text-pink-400", icon: <OpenRouterIcon /> },
|
||||
{ id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: <VercelIcon /> },
|
||||
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: <GenericApiIcon /> },
|
||||
]
|
||||
|
||||
export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
||||
const {
|
||||
llmProvider, setLlmProvider, modelsCatalog, modelsLoading, modelsError,
|
||||
activeConfig, testState, setTestState, showApiKey,
|
||||
showBaseURL, isLocalProvider, canTest, showMoreProviders, setShowMoreProviders,
|
||||
updateProviderConfig, handleTestAndSaveLlmConfig, handleBack,
|
||||
upsellDismissed, setUpsellDismissed, handleSwitchToRowboat,
|
||||
} = state
|
||||
|
||||
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
|
||||
const modelsForProvider = modelsCatalog[llmProvider] || []
|
||||
const showModelInput = isLocalProvider || modelsForProvider.length === 0
|
||||
|
||||
const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
|
||||
const isSelected = llmProvider === provider.id
|
||||
return (
|
||||
<motion.button
|
||||
key={provider.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
onClick={() => {
|
||||
setLlmProvider(provider.id)
|
||||
setTestState({ status: "idle" })
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-xl border-2 p-4 text-left transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-transparent bg-muted/50 hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", provider.color)}>
|
||||
{provider.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{provider.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{provider.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
|
||||
Choose your model
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center mb-6">
|
||||
Select a provider and configure your API key
|
||||
</p>
|
||||
|
||||
{/* Inline Rowboat upsell callout */}
|
||||
{!upsellDismissed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="rounded-xl bg-primary/5 border border-primary/20 p-4 mb-6 flex items-start gap-3"
|
||||
>
|
||||
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to all models — no API keys needed.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSwitchToRowboat}
|
||||
className="text-sm text-primary font-medium hover:underline mt-1 inline-block"
|
||||
>
|
||||
Sign in instead
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setUpsellDismissed(true)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Provider selection */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Provider</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{primaryProviders.map((p, i) => renderProviderCard(p, i))}
|
||||
</div>
|
||||
{(showMoreProviders || isMoreProvider) ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2 mt-2">
|
||||
{moreProviders.map((p, i) => renderProviderCard(p, i + 4))}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowMoreProviders(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
|
||||
>
|
||||
More providers...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="h-px bg-border my-4" />
|
||||
|
||||
{/* Model configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Model Configuration</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Assistant Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.model}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.model}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Knowledge Graph Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.knowledgeGraphModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showApiKey && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
API Key {!state.requiresApiKey && "(optional)"}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={activeConfig.apiKey}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { apiKey: e.target.value })}
|
||||
placeholder="Paste your API key"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBaseURL && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</label>
|
||||
<Input
|
||||
value={activeConfig.baseURL}
|
||||
onChange={(e) => 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"
|
||||
}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t">
|
||||
<Button variant="ghost" onClick={handleBack} className="gap-1">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{testState.status === "success" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
Connected
|
||||
</motion.div>
|
||||
)}
|
||||
{testState.status === "error" && (
|
||||
<span className="text-sm text-destructive max-w-[200px] truncate">
|
||||
{testState.error}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleTestAndSaveLlmConfig}
|
||||
disabled={!canTest || testState.status === "testing"}
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{testState.status === "testing" ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Testing...</>
|
||||
) : (
|
||||
"Test & Continue"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { Loader2, CheckCircle2 } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { OnboardingState } from "../use-onboarding-state"
|
||||
|
||||
interface WelcomeStepProps {
|
||||
state: OnboardingState
|
||||
}
|
||||
|
||||
export function WelcomeStep({ state }: WelcomeStepProps) {
|
||||
const rowboatState = state.providerStates['rowboat'] || { isConnected: false, isLoading: false, isConnecting: false }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center flex-1">
|
||||
{/* Logo with ambient glow */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative mb-8"
|
||||
>
|
||||
<div className="absolute inset-0 size-16 rounded-2xl bg-primary/10 blur-xl scale-[2.5]" />
|
||||
<img src="/logo-only.png" alt="Rowboat" className="relative size-16" />
|
||||
</motion.div>
|
||||
|
||||
{/* Tagline badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-6"
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
Your AI coworker, with memory
|
||||
</motion.div>
|
||||
|
||||
{/* Main heading */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-3xl font-bold tracking-tight mb-3"
|
||||
>
|
||||
Welcome to Rowboat
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-base text-muted-foreground leading-relaxed max-w-sm mb-10"
|
||||
>
|
||||
Rowboat connects to your work, builds a knowledge graph, and uses that context to help you get things done. Private and on your machine.
|
||||
</motion.p>
|
||||
|
||||
{/* Sign in / connected state */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
{rowboatState.isConnected ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="size-5" />
|
||||
<span className="text-sm font-medium">Connected to Rowboat</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
state.setOnboardingPath('rowboat')
|
||||
state.setCurrentStep(2)
|
||||
}}
|
||||
size="lg"
|
||||
className="w-full h-12 text-base font-medium"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
state.setOnboardingPath('rowboat')
|
||||
state.startConnect('rowboat')
|
||||
}}
|
||||
size="lg"
|
||||
className="w-full h-12 text-base font-medium"
|
||||
disabled={rowboatState.isConnecting}
|
||||
>
|
||||
{rowboatState.isConnecting ? (
|
||||
<><Loader2 className="size-5 animate-spin mr-2" />Waiting for sign in...</>
|
||||
) : (
|
||||
"Sign in with Rowboat"
|
||||
)}
|
||||
</Button>
|
||||
{rowboatState.isConnecting && (
|
||||
<p className="text-xs text-muted-foreground animate-pulse">
|
||||
Complete sign in in your browser, then return here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* BYOK link */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-8"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
state.setOnboardingPath('byok')
|
||||
state.setCurrentStep(1)
|
||||
}}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4 decoration-muted-foreground/30 hover:decoration-foreground/50"
|
||||
>
|
||||
I want to bring my own API key
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,720 @@
|
|||
import { useState, useEffect, useCallback } from "react"
|
||||
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export interface ProviderState {
|
||||
isConnected: boolean
|
||||
isLoading: boolean
|
||||
isConnecting: boolean
|
||||
}
|
||||
|
||||
export type Step = 0 | 1 | 2 | 3
|
||||
|
||||
export type OnboardingPath = 'rowboat' | 'byok' | null
|
||||
|
||||
export type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
||||
|
||||
export interface LlmModelOption {
|
||||
id: string
|
||||
name?: string
|
||||
release_date?: string
|
||||
}
|
||||
|
||||
export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||
const [currentStep, setCurrentStep] = useState<Step>(0)
|
||||
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
|
||||
|
||||
// LLM setup state
|
||||
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
})
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
||||
// OAuth provider states
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
|
||||
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
|
||||
|
||||
// Granola state
|
||||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||
|
||||
// Slack state (agent-slack CLI)
|
||||
const [slackEnabled, setSlackEnabled] = useState(false)
|
||||
const [slackLoading, setSlackLoading] = useState(true)
|
||||
const [slackWorkspaces, setSlackWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
|
||||
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
|
||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Inline upsell callout dismissed
|
||||
const [upsellDismissed, setUpsellDismissed] = useState(false)
|
||||
|
||||
// Composio/Gmail state (used when signed in with Rowboat account)
|
||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(true)
|
||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
||||
|
||||
// Composio/Google Calendar state
|
||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates },
|
||||
}))
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const activeConfig = providerConfigs[llmProvider]
|
||||
const showApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" || llmProvider === "openai-compatible"
|
||||
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)
|
||||
.map(([provider]) => provider)
|
||||
|
||||
// Load available providers and composio-for-google flag on mount
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
async function loadProviders() {
|
||||
try {
|
||||
setProvidersLoading(true)
|
||||
const result = await window.ipc.invoke('oauth:list-providers', null)
|
||||
setProviders(result.providers || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to get available providers:', error)
|
||||
setProviders([])
|
||||
} finally {
|
||||
setProvidersLoading(false)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||
setUseComposioForGoogle(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google flag:', error)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleCalendarFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||
setUseComposioForGoogleCalendar(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||
}
|
||||
}
|
||||
loadProviders()
|
||||
loadComposioForGoogleFlag()
|
||||
loadComposioForGoogleCalendarFlag()
|
||||
}, [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<string, LlmModelOption[]> = {}
|
||||
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])
|
||||
|
||||
// Preferred default models for each provider
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.2",
|
||||
anthropic: "claude-opus-4-6-20260202",
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const preferredModel = preferredDefaults[provider]
|
||||
const hasPreferred = preferredModel && models.some(m => m.id === preferredModel)
|
||||
next[provider] = { ...next[provider], model: hasPreferred ? preferredModel : (models[0]?.id || "") }
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [modelsCatalog])
|
||||
|
||||
// Load Granola config
|
||||
const refreshGranolaConfig = useCallback(async () => {
|
||||
try {
|
||||
setGranolaLoading(true)
|
||||
const result = await window.ipc.invoke('granola:getConfig', null)
|
||||
setGranolaEnabled(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Granola config:', error)
|
||||
setGranolaEnabled(false)
|
||||
} finally {
|
||||
setGranolaLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update Granola config
|
||||
const handleGranolaToggle = useCallback(async (enabled: boolean) => {
|
||||
try {
|
||||
setGranolaLoading(true)
|
||||
await window.ipc.invoke('granola:setConfig', { enabled })
|
||||
setGranolaEnabled(enabled)
|
||||
toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to update Granola config:', error)
|
||||
toast.error('Failed to update Granola sync settings')
|
||||
} finally {
|
||||
setGranolaLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load Slack config
|
||||
const refreshSlackConfig = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('slack:getConfig', null)
|
||||
setSlackEnabled(result.enabled)
|
||||
setSlackWorkspaces(result.workspaces || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load Slack config:', error)
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Enable Slack: discover workspaces
|
||||
const handleSlackEnable = useCallback(async () => {
|
||||
setSlackDiscovering(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:listWorkspaces', null)
|
||||
if (result.error || result.workspaces.length === 0) {
|
||||
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
|
||||
setSlackAvailableWorkspaces([])
|
||||
setSlackPickerOpen(true)
|
||||
} else {
|
||||
setSlackAvailableWorkspaces(result.workspaces)
|
||||
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
|
||||
setSlackPickerOpen(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to discover Slack workspaces:', error)
|
||||
setSlackDiscoverError('Failed to discover Slack workspaces')
|
||||
setSlackPickerOpen(true)
|
||||
} finally {
|
||||
setSlackDiscovering(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save selected Slack workspaces
|
||||
const handleSlackSaveWorkspaces = useCallback(async () => {
|
||||
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected })
|
||||
setSlackEnabled(true)
|
||||
setSlackWorkspaces(selected)
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack enabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to save Slack config:', error)
|
||||
toast.error('Failed to save Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [slackAvailableWorkspaces, slackSelectedUrls])
|
||||
|
||||
// Disable Slack
|
||||
const handleSlackDisable = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] })
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack disabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to update Slack config:', error)
|
||||
toast.error('Failed to update Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load Gmail connection status (Composio)
|
||||
const refreshGmailStatus = useCallback(async () => {
|
||||
try {
|
||||
setGmailLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' })
|
||||
setGmailConnected(result.isConnected)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Gmail status:', error)
|
||||
setGmailConnected(false)
|
||||
} finally {
|
||||
setGmailLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect to Gmail via Composio
|
||||
const startGmailConnect = useCallback(async () => {
|
||||
try {
|
||||
setGmailConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Gmail')
|
||||
setGmailConnecting(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Gmail:', error)
|
||||
toast.error('Failed to connect to Gmail')
|
||||
setGmailConnecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle Gmail connect button click (checks Composio config first)
|
||||
const handleConnectGmail = useCallback(async () => {
|
||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configResult.configured) {
|
||||
setComposioApiKeyTarget('gmail')
|
||||
setComposioApiKeyOpen(true)
|
||||
return
|
||||
}
|
||||
await startGmailConnect()
|
||||
}, [startGmailConnect])
|
||||
|
||||
// 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')
|
||||
await startGmailConnect()
|
||||
} catch (error) {
|
||||
console.error('Failed to save Composio API key:', error)
|
||||
toast.error('Failed to save API key')
|
||||
}
|
||||
}, [startGmailConnect])
|
||||
|
||||
// Load Google Calendar connection status (Composio)
|
||||
const refreshGoogleCalendarStatus = useCallback(async () => {
|
||||
try {
|
||||
setGoogleCalendarLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' })
|
||||
setGoogleCalendarConnected(result.isConnected)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Google Calendar status:', error)
|
||||
setGoogleCalendarConnected(false)
|
||||
} finally {
|
||||
setGoogleCalendarLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect to Google Calendar via Composio
|
||||
const startGoogleCalendarConnect = useCallback(async () => {
|
||||
try {
|
||||
setGoogleCalendarConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Google Calendar')
|
||||
setGoogleCalendarConnecting(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Google Calendar:', error)
|
||||
toast.error('Failed to connect to Google Calendar')
|
||||
setGoogleCalendarConnecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle Google Calendar connect button click
|
||||
const handleConnectGoogleCalendar = useCallback(async () => {
|
||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configResult.configured) {
|
||||
setComposioApiKeyTarget('gmail')
|
||||
setComposioApiKeyOpen(true)
|
||||
return
|
||||
}
|
||||
await startGoogleCalendarConnect()
|
||||
}, [startGoogleCalendarConnect])
|
||||
|
||||
// New step flow:
|
||||
// Rowboat path: 0 (welcome) → 2 (connect) → 3 (done)
|
||||
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (done)
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 0) {
|
||||
if (onboardingPath === 'byok') {
|
||||
setCurrentStep(1)
|
||||
} else {
|
||||
setCurrentStep(2)
|
||||
}
|
||||
} else if (currentStep === 1) {
|
||||
setCurrentStep(2)
|
||||
} else if (currentStep === 2) {
|
||||
setCurrentStep(3)
|
||||
}
|
||||
}, [currentStep, onboardingPath])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (currentStep === 1) {
|
||||
setCurrentStep(0)
|
||||
setOnboardingPath(null)
|
||||
} else if (currentStep === 2) {
|
||||
if (onboardingPath === 'rowboat') {
|
||||
setCurrentStep(0)
|
||||
} else {
|
||||
setCurrentStep(1)
|
||||
}
|
||||
}
|
||||
}, [currentStep, onboardingPath])
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
onComplete()
|
||||
}, [onComplete])
|
||||
|
||||
const handleTestAndSaveLlmConfig = 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 knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
apiKey,
|
||||
baseURL,
|
||||
},
|
||||
model,
|
||||
knowledgeGraphModel,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
setTestState({ status: "success" })
|
||||
await window.ipc.invoke("models:saveConfig", providerConfig)
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
handleNext()
|
||||
} 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, activeConfig.knowledgeGraphModel, canTest, llmProvider, handleNext])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
refreshGranolaConfig()
|
||||
refreshSlackConfig()
|
||||
|
||||
// Refresh Gmail Composio status if enabled
|
||||
if (useComposioForGoogle) {
|
||||
refreshGmailStatus()
|
||||
}
|
||||
|
||||
// Refresh Google Calendar Composio status if enabled
|
||||
if (useComposioForGoogleCalendar) {
|
||||
refreshGoogleCalendarStatus()
|
||||
}
|
||||
|
||||
if (providers.length === 0) return
|
||||
|
||||
const newStates: Record<string, ProviderState> = {}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:getState', null)
|
||||
const config = result.config || {}
|
||||
for (const provider of providers) {
|
||||
newStates[provider] = {
|
||||
isConnected: config[provider]?.connected ?? false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check connection status for providers:', error)
|
||||
for (const provider of providers) {
|
||||
newStates[provider] = {
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
|
||||
|
||||
// Refresh statuses when modal opens or providers list changes
|
||||
useEffect(() => {
|
||||
if (open && providers.length > 0) {
|
||||
refreshAllStatuses()
|
||||
}
|
||||
}, [open, providers, refreshAllStatuses])
|
||||
|
||||
// Listen for OAuth completion events (state updates only — toasts handled by ConnectorsPopover)
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
const { provider, success } = event
|
||||
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: {
|
||||
isConnected: success,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Auto-advance from Rowboat sign-in step when OAuth completes
|
||||
useEffect(() => {
|
||||
if (onboardingPath !== 'rowboat' || currentStep !== 0) return
|
||||
|
||||
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
|
||||
if (event.provider === 'rowboat' && event.success) {
|
||||
// Re-check composio flags now that the account is connected
|
||||
try {
|
||||
const [googleResult, calendarResult] = await Promise.all([
|
||||
window.ipc.invoke('composio:use-composio-for-google', null),
|
||||
window.ipc.invoke('composio:use-composio-for-google-calendar', null),
|
||||
])
|
||||
setUseComposioForGoogle(googleResult.enabled)
|
||||
setUseComposioForGoogleCalendar(calendarResult.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to re-check composio flags:', error)
|
||||
}
|
||||
setCurrentStep(2) // Go to Connect Accounts
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [onboardingPath, currentStep])
|
||||
|
||||
// Listen for Composio connection events (state updates only — toasts handled by ConnectorsPopover)
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||
const { toolkitSlug, success } = event
|
||||
|
||||
if (toolkitSlug === 'slack') {
|
||||
setSlackEnabled(success)
|
||||
}
|
||||
|
||||
if (toolkitSlug === 'gmail') {
|
||||
setGmailConnected(success)
|
||||
setGmailConnecting(false)
|
||||
}
|
||||
|
||||
if (toolkitSlug === 'googlecalendar') {
|
||||
setGoogleCalendarConnected(success)
|
||||
setGoogleCalendarConnecting(false)
|
||||
}
|
||||
})
|
||||
|
||||
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, clientId })
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error || `Failed to connect to ${provider}`)
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: false }
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error)
|
||||
toast.error(`Failed to connect to ${provider}`)
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: false }
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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])
|
||||
|
||||
// Switch to rowboat path from BYOK inline callout
|
||||
const handleSwitchToRowboat = useCallback(() => {
|
||||
setOnboardingPath('rowboat')
|
||||
setCurrentStep(0)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Step state
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
onboardingPath,
|
||||
setOnboardingPath,
|
||||
|
||||
// LLM state
|
||||
llmProvider,
|
||||
setLlmProvider,
|
||||
modelsCatalog,
|
||||
modelsLoading,
|
||||
modelsError,
|
||||
providerConfigs,
|
||||
activeConfig,
|
||||
testState,
|
||||
setTestState,
|
||||
showApiKey,
|
||||
requiresApiKey,
|
||||
requiresBaseURL,
|
||||
showBaseURL,
|
||||
isLocalProvider,
|
||||
canTest,
|
||||
showMoreProviders,
|
||||
setShowMoreProviders,
|
||||
updateProviderConfig,
|
||||
handleTestAndSaveLlmConfig,
|
||||
|
||||
// OAuth state
|
||||
providers,
|
||||
providersLoading,
|
||||
providerStates,
|
||||
googleClientIdOpen,
|
||||
setGoogleClientIdOpen,
|
||||
connectedProviders,
|
||||
handleConnect,
|
||||
handleGoogleClientIdSubmit,
|
||||
startConnect,
|
||||
|
||||
// Granola state
|
||||
granolaEnabled,
|
||||
granolaLoading,
|
||||
handleGranolaToggle,
|
||||
|
||||
// Slack state
|
||||
slackEnabled,
|
||||
slackLoading,
|
||||
slackWorkspaces,
|
||||
slackAvailableWorkspaces,
|
||||
slackSelectedUrls,
|
||||
setSlackSelectedUrls,
|
||||
slackPickerOpen,
|
||||
slackDiscovering,
|
||||
slackDiscoverError,
|
||||
handleSlackEnable,
|
||||
handleSlackSaveWorkspaces,
|
||||
handleSlackDisable,
|
||||
|
||||
// Upsell
|
||||
upsellDismissed,
|
||||
setUpsellDismissed,
|
||||
|
||||
// Composio/Gmail state
|
||||
useComposioForGoogle,
|
||||
gmailConnected,
|
||||
gmailLoading,
|
||||
gmailConnecting,
|
||||
composioApiKeyOpen,
|
||||
setComposioApiKeyOpen,
|
||||
composioApiKeyTarget,
|
||||
handleConnectGmail,
|
||||
handleComposioApiKeySubmit,
|
||||
|
||||
// Composio/Google Calendar state
|
||||
useComposioForGoogleCalendar,
|
||||
googleCalendarConnected,
|
||||
googleCalendarLoading,
|
||||
googleCalendarConnecting,
|
||||
handleConnectGoogleCalendar,
|
||||
|
||||
// Navigation
|
||||
handleNext,
|
||||
handleBack,
|
||||
handleComplete,
|
||||
handleSwitchToRowboat,
|
||||
}
|
||||
}
|
||||
|
||||
export type OnboardingState = ReturnType<typeof useOnboardingState>
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
|
||||
import {
|
||||
CommandDialog,
|
||||
|
|
@ -68,6 +70,8 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }:
|
|||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setResults(res.results)
|
||||
analytics.searchExecuted(types)
|
||||
posthog.people.set_once({ has_used_search: true })
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X } from "lucide-react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -22,8 +22,10 @@ import { Switch } from "@/components/ui/switch"
|
|||
import { cn } from "@/lib/utils"
|
||||
import { useTheme } from "@/contexts/theme-context"
|
||||
import { toast } from "sonner"
|
||||
import { AccountSettings } from "@/components/settings/account-settings"
|
||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
|
||||
type ConfigTab = "models" | "mcp" | "security" | "appearance" | "note-tagging"
|
||||
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -34,6 +36,18 @@ interface TabConfig {
|
|||
}
|
||||
|
||||
const tabs: TabConfig[] = [
|
||||
{
|
||||
id: "account",
|
||||
label: "Account",
|
||||
icon: User,
|
||||
description: "Manage your Rowboat account",
|
||||
},
|
||||
{
|
||||
id: "connected-accounts",
|
||||
label: "Connected Accounts",
|
||||
icon: Plug,
|
||||
description: "Manage connected services",
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
label: "Models",
|
||||
|
|
@ -61,6 +75,12 @@ const tabs: TabConfig[] = [
|
|||
icon: Palette,
|
||||
description: "Customize the look and feel",
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tools Library",
|
||||
icon: Wrench,
|
||||
description: "Browse and enable toolkits",
|
||||
},
|
||||
{
|
||||
id: "note-tagging",
|
||||
label: "Note Tagging",
|
||||
|
|
@ -693,6 +713,445 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Tools Library Settings ---
|
||||
|
||||
interface ToolkitInfo {
|
||||
slug: string
|
||||
name: string
|
||||
meta: { description: string; logo: string; tools_count: number; triggers_count: number }
|
||||
no_auth?: boolean
|
||||
auth_schemes?: string[]
|
||||
composio_managed_auth_schemes?: string[]
|
||||
}
|
||||
|
||||
function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: boolean; rowboatConnected: boolean }) {
|
||||
// API key state
|
||||
const [apiKeyConfigured, setApiKeyConfigured] = useState(false)
|
||||
const [apiKeyInput, setApiKeyInput] = useState("")
|
||||
const [apiKeySaving, setApiKeySaving] = useState(false)
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false)
|
||||
|
||||
// Toolkit browsing state
|
||||
const [toolkits, setToolkits] = useState<ToolkitInfo[]>([])
|
||||
const [toolkitsLoading, setToolkitsLoading] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// Connection state
|
||||
const [connectedToolkits, setConnectedToolkits] = useState<Set<string>>(new Set())
|
||||
const [connectingToolkit, setConnectingToolkit] = useState<string | null>(null)
|
||||
|
||||
// Check API key configuration
|
||||
const checkApiKey = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:is-configured", null)
|
||||
setApiKeyConfigured(result.configured)
|
||||
if (!result.configured) {
|
||||
setShowApiKeyInput(true)
|
||||
}
|
||||
} catch {
|
||||
setApiKeyConfigured(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load connected toolkits
|
||||
const loadConnected = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:list-connected", null)
|
||||
setConnectedToolkits(new Set(result.toolkits))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load toolkits
|
||||
const loadToolkits = useCallback(async () => {
|
||||
setToolkitsLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:list-toolkits", {})
|
||||
setToolkits(result.items)
|
||||
} catch {
|
||||
toast.error("Failed to load toolkits")
|
||||
} finally {
|
||||
setToolkitsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
checkApiKey()
|
||||
loadConnected()
|
||||
}, [dialogOpen, checkApiKey, loadConnected])
|
||||
|
||||
// Load toolkits when API key is configured
|
||||
useEffect(() => {
|
||||
if (dialogOpen && apiKeyConfigured) {
|
||||
loadToolkits()
|
||||
}
|
||||
}, [dialogOpen, apiKeyConfigured, loadToolkits])
|
||||
|
||||
// Listen for composio connection events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||
const { toolkitSlug, success, error } = event
|
||||
setConnectingToolkit(null)
|
||||
if (success) {
|
||||
setConnectedToolkits(prev => new Set([...prev, toolkitSlug]))
|
||||
toast.success(`Connected to ${toolkitSlug}`)
|
||||
} else {
|
||||
toast.error(error || `Failed to connect to ${toolkitSlug}`)
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Save API key
|
||||
const handleSaveApiKey = async () => {
|
||||
const trimmed = apiKeyInput.trim()
|
||||
if (!trimmed) return
|
||||
setApiKeySaving(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:set-api-key", { apiKey: trimmed })
|
||||
if (result.success) {
|
||||
setApiKeyConfigured(true)
|
||||
setShowApiKeyInput(false)
|
||||
setApiKeyInput("")
|
||||
toast.success("Composio API key saved")
|
||||
} else {
|
||||
toast.error(result.error || "Failed to save API key")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to save API key")
|
||||
} finally {
|
||||
setApiKeySaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect a toolkit
|
||||
const handleConnect = async (toolkitSlug: string) => {
|
||||
setConnectingToolkit(toolkitSlug)
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:initiate-connection", { toolkitSlug })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || "Failed to connect")
|
||||
setConnectingToolkit(null)
|
||||
}
|
||||
// Success will be handled by composio:didConnect event
|
||||
} catch {
|
||||
toast.error("Failed to connect")
|
||||
setConnectingToolkit(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect a toolkit
|
||||
const handleDisconnect = async (toolkitSlug: string) => {
|
||||
try {
|
||||
await window.ipc.invoke("composio:disconnect", { toolkitSlug })
|
||||
setConnectedToolkits(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(toolkitSlug)
|
||||
return next
|
||||
})
|
||||
toast.success(`Disconnected from ${toolkitSlug}`)
|
||||
} catch {
|
||||
toast.error("Failed to disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
// Filter toolkits by search
|
||||
const filteredToolkits = searchQuery.trim()
|
||||
? toolkits.filter(t =>
|
||||
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.meta.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: toolkits
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section A: API Key (only in BYOK mode) */}
|
||||
{!rowboatConnected && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Composio API Key</span>
|
||||
{apiKeyConfigured && !showApiKeyInput ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
API key configured
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowApiKeyInput(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter your Composio API key to browse and enable tool integrations.
|
||||
Get your key from{" "}
|
||||
<a
|
||||
href="https://app.composio.dev/settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
app.composio.dev/settings
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
placeholder="Paste your Composio API key"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveApiKey}
|
||||
disabled={!apiKeyInput.trim() || apiKeySaving}
|
||||
size="sm"
|
||||
>
|
||||
{apiKeySaving ? <Loader2 className="size-4 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
{apiKeyConfigured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setShowApiKeyInput(false); setApiKeyInput("") }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section B: Toolkit Browser (only when API key configured) */}
|
||||
{apiKeyConfigured && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Available Toolkits</span>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search toolkits..."
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolkitsLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading toolkits...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-[400px] overflow-y-auto pr-1">
|
||||
{filteredToolkits.map((toolkit) => {
|
||||
const isConnected = connectedToolkits.has(toolkit.slug)
|
||||
const isConnecting = connectingToolkit === toolkit.slug
|
||||
|
||||
return (
|
||||
<div key={toolkit.slug} className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{/* Logo */}
|
||||
{toolkit.meta.logo ? (
|
||||
<img
|
||||
src={toolkit.meta.logo}
|
||||
alt=""
|
||||
className="size-7 rounded object-contain shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded bg-muted flex items-center justify-center shrink-0">
|
||||
<Wrench className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name & description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{toolkit.name}</span>
|
||||
{isConnected && (
|
||||
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{toolkit.meta.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connect / Disconnect button */}
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(toolkit.slug)}
|
||||
className="text-xs h-7 shrink-0"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleConnect(toolkit.slug)}
|
||||
disabled={isConnecting}
|
||||
className="text-xs h-7 shrink-0"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Connecting...</>
|
||||
) : (
|
||||
<><Link2 className="size-3 mr-1" />Connect</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredToolkits.length === 0 && !toolkitsLoading && (
|
||||
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||
{searchQuery ? "No toolkits match your search" : "No toolkits available"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Rowboat Model Settings (when signed in via Rowboat) ---
|
||||
|
||||
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [gatewayModels, setGatewayModels] = useState<LlmModelOption[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState("")
|
||||
const [selectedKgModel, setSelectedKgModel] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Fetch gateway models
|
||||
const listResult = await window.ipc.invoke("models:list", null)
|
||||
const rowboatProvider = listResult.providers?.find((p: { id: string }) => p.id === "rowboat")
|
||||
const models = rowboatProvider?.models || []
|
||||
setGatewayModels(models)
|
||||
|
||||
// Read current selection from config
|
||||
try {
|
||||
const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
||||
const parsed = JSON.parse(configResult.data)
|
||||
if (parsed?.model) setSelectedModel(parsed.model)
|
||||
if (parsed?.knowledgeGraphModel) setSelectedKgModel(parsed.knowledgeGraphModel)
|
||||
} catch {
|
||||
// No config yet — pick first model as default
|
||||
if (models.length > 0) setSelectedModel(models[0].id)
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to load models")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [dialogOpen])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedModel) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("models:saveConfig", {
|
||||
provider: { flavor: "openrouter" as const },
|
||||
model: selectedModel,
|
||||
knowledgeGraphModel: selectedKgModel || undefined,
|
||||
})
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
toast.success("Model configuration saved")
|
||||
} catch {
|
||||
toast.error("Failed to save model configuration")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [selectedModel, selectedKgModel])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the models Rowboat uses. These are provided through your Rowboat account.
|
||||
</p>
|
||||
|
||||
{/* Assistant model */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Assistant model</label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{gatewayModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Knowledge graph model */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Knowledge graph model</label>
|
||||
<Select value={selectedKgModel || "__same__"} onValueChange={(v) => setSelectedKgModel(v === "__same__" ? "" : v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Same as assistant" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{gatewayModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<Button onClick={handleSave} disabled={!selectedModel || saving}>
|
||||
{saving ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Note Tagging Settings ---
|
||||
|
||||
interface TagDef {
|
||||
|
|
@ -709,7 +1168,7 @@ const NOTE_TAG_TYPE_ORDER = [
|
|||
]
|
||||
|
||||
const EMAIL_TAG_TYPE_ORDER = [
|
||||
"relationship", "topic", "email-type", "filter", "action", "status",
|
||||
"relationship", "topic", "email-type", "noise", "action", "status",
|
||||
]
|
||||
|
||||
const TAG_TYPE_LABELS: Record<string, string> = {
|
||||
|
|
@ -717,77 +1176,16 @@ const TAG_TYPE_LABELS: Record<string, string> = {
|
|||
"relationship-sub": "Relationship Sub-Tags",
|
||||
"topic": "Topic",
|
||||
"email-type": "Email Type",
|
||||
"filter": "Filter",
|
||||
"noise": "Noise",
|
||||
"action": "Action",
|
||||
"status": "Status",
|
||||
"source": "Source",
|
||||
}
|
||||
|
||||
const DEFAULT_TAGS: TagDef[] = [
|
||||
{ tag: "investor", type: "relationship", applicability: "both", noteEffect: "create", description: "Investors, VCs, or angels", example: "Following up on our meeting — we'd like to move forward with the Series A term sheet." },
|
||||
{ tag: "customer", type: "relationship", applicability: "both", noteEffect: "create", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" },
|
||||
{ tag: "prospect", type: "relationship", applicability: "both", noteEffect: "create", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." },
|
||||
{ tag: "partner", type: "relationship", applicability: "both", noteEffect: "create", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." },
|
||||
{ tag: "vendor", type: "relationship", applicability: "both", noteEffect: "create", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." },
|
||||
{ tag: "product", type: "relationship", applicability: "both", noteEffect: "skip", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." },
|
||||
{ tag: "candidate", type: "relationship", applicability: "both", noteEffect: "create", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." },
|
||||
{ tag: "team", type: "relationship", applicability: "both", noteEffect: "create", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." },
|
||||
{ tag: "advisor", type: "relationship", applicability: "both", noteEffect: "create", description: "Advisors, mentors, or board members", example: "I've reviewed the deck. Here are my thoughts on the GTM strategy." },
|
||||
{ tag: "personal", type: "relationship", applicability: "both", noteEffect: "create", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." },
|
||||
{ tag: "press", type: "relationship", applicability: "both", noteEffect: "create", description: "Journalists or media", example: "I'm writing a piece on AI agents. Would you be available for an interview?" },
|
||||
{ tag: "community", type: "relationship", applicability: "both", noteEffect: "create", description: "Users, peers, or open source contributors", example: "Love what you're building with Rowboat. Here's a bug I found..." },
|
||||
{ tag: "government", type: "relationship", applicability: "both", noteEffect: "create", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." },
|
||||
{ tag: "primary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Main contact or decision maker", example: "Sarah Chen — VP Engineering, your main point of contact at Acme." },
|
||||
{ tag: "secondary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Supporting contact, involved but not the lead", example: "David Kim — Engineer CC'd on customer emails." },
|
||||
{ tag: "executive-assistant", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "EA or admin handling scheduling and logistics", example: "Lisa — Sarah's EA who schedules all her meetings." },
|
||||
{ tag: "cc", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who's CC'd but not actively engaged", example: "Manager looped in for visibility on deal." },
|
||||
{ tag: "referred-by", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who made an introduction or referral", example: "David Park — Investor who intro'd you to Sarah." },
|
||||
{ tag: "former", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." },
|
||||
{ tag: "champion", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." },
|
||||
{ tag: "blocker", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." },
|
||||
{ tag: "sales", type: "topic", applicability: "both", noteEffect: "create", description: "Sales conversations, deals, and revenue", example: "Here's the pricing proposal we discussed. Let me know if you have questions." },
|
||||
{ tag: "support", type: "topic", applicability: "both", noteEffect: "create", description: "Help requests, issues, and customer support", example: "We're seeing an error when trying to export. Can you help?" },
|
||||
{ tag: "legal", type: "topic", applicability: "both", noteEffect: "create", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." },
|
||||
{ tag: "finance", type: "topic", applicability: "both", noteEffect: "create", description: "Money, invoices, payments, banking, and taxes", example: "Your invoice #1234 for $5,000 is attached. Payment due in 30 days." },
|
||||
{ tag: "hiring", type: "topic", applicability: "both", noteEffect: "create", description: "Recruiting, interviews, and employment", example: "We'd like to move forward with a final round interview. Are you available Thursday?" },
|
||||
{ tag: "fundraising", type: "topic", applicability: "both", noteEffect: "create", description: "Raising money and investor relations", example: "Thanks for sending the deck. We'd like to schedule a partner meeting." },
|
||||
{ tag: "travel", type: "topic", applicability: "both", noteEffect: "skip", description: "Flights, hotels, trips, and travel logistics", example: "Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123." },
|
||||
{ tag: "event", type: "topic", applicability: "both", noteEffect: "create", description: "Conferences, meetups, and gatherings", example: "You're invited to speak at TechCrunch Disrupt. Can you confirm your availability?" },
|
||||
{ tag: "shopping", type: "topic", applicability: "both", noteEffect: "skip", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." },
|
||||
{ tag: "health", type: "topic", applicability: "both", noteEffect: "skip", description: "Medical, wellness, and health-related matters", example: "Your appointment with Dr. Smith is confirmed for Monday at 2pm." },
|
||||
{ tag: "learning", type: "topic", applicability: "both", noteEffect: "skip", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." },
|
||||
{ tag: "research", type: "topic", applicability: "both", noteEffect: "create", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." },
|
||||
{ tag: "intro", type: "email-type", applicability: "both", noteEffect: "create", description: "Warm introduction from someone you know", example: "I'd like to introduce you to Sarah Chen, VP Engineering at Acme." },
|
||||
{ tag: "followup", type: "email-type", applicability: "both", noteEffect: "create", description: "Following up on a previous conversation", example: "Following up on our call last week. Have you had a chance to review the proposal?" },
|
||||
{ tag: "scheduling", type: "email-type", applicability: "email", noteEffect: "skip", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" },
|
||||
{ tag: "cold-outreach", type: "email-type", applicability: "email", noteEffect: "skip", description: "Unsolicited contact from someone you don't know", example: "Hi, I noticed your company is growing fast. I'd love to show you how we can help with..." },
|
||||
{ tag: "newsletter", type: "email-type", applicability: "email", noteEffect: "skip", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." },
|
||||
{ tag: "notification", type: "email-type", applicability: "email", noteEffect: "skip", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." },
|
||||
{ tag: "spam", type: "filter", applicability: "email", noteEffect: "skip", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." },
|
||||
{ tag: "promotion", type: "filter", applicability: "email", noteEffect: "skip", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" },
|
||||
{ tag: "social", type: "filter", applicability: "email", noteEffect: "skip", description: "Social media notifications", example: "John Smith commented on your post." },
|
||||
{ tag: "forums", type: "filter", applicability: "email", noteEffect: "skip", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" },
|
||||
{ tag: "action-required", type: "action", applicability: "both", noteEffect: "create", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" },
|
||||
{ tag: "fyi", type: "action", applicability: "email", noteEffect: "skip", description: "Informational only, no action needed", example: "Just wanted to let you know the deal closed. Thanks for your help!" },
|
||||
{ tag: "urgent", type: "action", applicability: "both", noteEffect: "create", description: "Time-sensitive, needs immediate attention", example: "We need your signature on the contract by EOD today or we lose the deal." },
|
||||
{ tag: "waiting", type: "action", applicability: "both", noteEffect: "create", description: "Waiting on a response from them" },
|
||||
{ tag: "unread", type: "status", applicability: "email", noteEffect: "none", description: "Not yet processed" },
|
||||
{ tag: "to-reply", type: "status", applicability: "email", noteEffect: "none", description: "Need to respond" },
|
||||
{ tag: "done", type: "status", applicability: "email", noteEffect: "none", description: "Handled, can be archived" },
|
||||
{ tag: "active", type: "status", applicability: "notes", noteEffect: "none", description: "Currently relevant, recent activity" },
|
||||
{ tag: "archived", type: "status", applicability: "notes", noteEffect: "none", description: "No longer active, kept for reference" },
|
||||
{ tag: "stale", type: "status", applicability: "notes", noteEffect: "none", description: "No activity in 60+ days, needs attention or archive" },
|
||||
{ tag: "email", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from email" },
|
||||
{ tag: "meeting", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from meeting transcript" },
|
||||
{ tag: "browser", type: "source", applicability: "notes", noteEffect: "none", description: "Content captured from web browsing" },
|
||||
{ tag: "web-search", type: "source", applicability: "notes", noteEffect: "none", description: "Information from web search" },
|
||||
{ tag: "manual", type: "source", applicability: "notes", noteEffect: "none", description: "Manually entered by user" },
|
||||
{ tag: "import", type: "source", applicability: "notes", noteEffect: "none", description: "Imported from another system" },
|
||||
]
|
||||
|
||||
function TagGroupTable({
|
||||
group,
|
||||
tags,
|
||||
tags: _tags,
|
||||
collapsed,
|
||||
onToggle,
|
||||
onAdd,
|
||||
|
|
@ -914,8 +1312,8 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
setTags(parsed)
|
||||
setOriginalTags(parsed)
|
||||
} catch {
|
||||
setTags([...DEFAULT_TAGS])
|
||||
setOriginalTags([...DEFAULT_TAGS])
|
||||
setTags([])
|
||||
setOriginalTags([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -976,7 +1374,7 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
const isEmailSection = activeSection === "email"
|
||||
const applicability = isEmailSection ? "email" as const : "notes" as const
|
||||
// For email-only types, always use "email"; for notes-only types, always use "notes"; otherwise use "both"
|
||||
const emailOnlyTypes = ["email-type", "filter"]
|
||||
const emailOnlyTypes = ["email-type", "noise"]
|
||||
const notesOnlyTypes = ["relationship-sub", "source"]
|
||||
let finalApplicability: "email" | "notes" | "both" = "both"
|
||||
if (emailOnlyTypes.includes(type)) finalApplicability = "email"
|
||||
|
|
@ -1014,11 +1412,6 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
}
|
||||
}, [tags])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (!confirm("Reset all tags to defaults? This will discard your changes.")) return
|
||||
setTags([...DEFAULT_TAGS])
|
||||
}, [])
|
||||
|
||||
const toggleGroup = useCallback((type: string) => {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
|
|
@ -1090,9 +1483,6 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
|
|
@ -1106,14 +1496,28 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
|
||||
export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<ConfigTab>("models")
|
||||
const [activeTab, setActiveTab] = useState<ConfigTab>("account")
|
||||
const [content, setContent] = useState("")
|
||||
const [originalContent, setOriginalContent] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [rowboatConnected, setRowboatConnected] = useState(false)
|
||||
|
||||
const activeTabConfig = tabs.find((t) => t.id === activeTab)!
|
||||
// Check if user is signed in to Rowboat
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||
const connected = result.config?.rowboat?.connected ?? false
|
||||
setRowboatConnected(connected)
|
||||
}).catch(() => {
|
||||
setRowboatConnected(false)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
const visibleTabs = useMemo(() => rowboatConnected ? tabs.filter(t => t.id !== "models") : tabs, [rowboatConnected])
|
||||
|
||||
const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0]
|
||||
const isJsonTab = activeTab === "mcp" || activeTab === "security"
|
||||
|
||||
const formatJson = (jsonString: string): string => {
|
||||
|
|
@ -1125,7 +1529,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
}
|
||||
|
||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging") return
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connected-accounts") return
|
||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||
if (!tabConfig.path) return
|
||||
setLoading(true)
|
||||
|
|
@ -1202,7 +1606,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
<h2 className="font-semibold text-sm">Settings</h2>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{tabs.map((tab) => (
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
|
|
@ -1226,18 +1630,28 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
<div className="px-4 py-3 border-b">
|
||||
<h3 className="font-medium text-sm">{activeTabConfig.label}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{activeTabConfig.description}
|
||||
{activeTab === "models" && rowboatConnected
|
||||
? "Select your default models"
|
||||
: activeTabConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "models" ? (
|
||||
<ModelSettings dialogOpen={open} />
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "account" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connected-accounts" ? (
|
||||
<ConnectedAccountsSettings dialogOpen={open} />
|
||||
) : activeTab === "models" ? (
|
||||
rowboatConnected
|
||||
? <RowboatModelSettings dialogOpen={open} />
|
||||
: <ModelSettings dialogOpen={open} />
|
||||
) : activeTab === "note-tagging" ? (
|
||||
<NoteTaggingSettings dialogOpen={open} />
|
||||
) : activeTab === "appearance" ? (
|
||||
<AppearanceSettings />
|
||||
) : activeTab === "tools" ? (
|
||||
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
||||
) : loading ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Loader2, User, CreditCard, LogOut } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface AccountSettingsProps {
|
||||
dialogOpen: boolean
|
||||
}
|
||||
|
||||
export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [connectionLoading, setConnectionLoading] = useState(true)
|
||||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
|
||||
|
||||
const checkConnection = useCallback(async () => {
|
||||
try {
|
||||
setConnectionLoading(true)
|
||||
const result = await window.ipc.invoke('oauth:getState', null)
|
||||
const connected = result.config?.rowboat?.connected ?? false
|
||||
setIsRowboatConnected(connected)
|
||||
} catch {
|
||||
setIsRowboatConnected(false)
|
||||
} finally {
|
||||
setConnectionLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
checkConnection()
|
||||
}
|
||||
}, [dialogOpen, checkConnection])
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (event.provider === 'rowboat') {
|
||||
setIsRowboatConnected(event.success)
|
||||
setConnecting(false)
|
||||
if (event.success) {
|
||||
toast.success('Logged in to Rowboat')
|
||||
}
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
try {
|
||||
setConnecting(true)
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider: 'rowboat' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to log in to Rowboat')
|
||||
setConnecting(false)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to log in to Rowboat')
|
||||
setConnecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
try {
|
||||
setDisconnecting(true)
|
||||
const result = await window.ipc.invoke('oauth:disconnect', { provider: 'rowboat' })
|
||||
if (result.success) {
|
||||
setIsRowboatConnected(false)
|
||||
toast.success('Logged out of Rowboat')
|
||||
} else {
|
||||
toast.error('Failed to log out of Rowboat')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to log out of Rowboat')
|
||||
} finally {
|
||||
setDisconnecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (connectionLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isRowboatConnected) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-muted">
|
||||
<User className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium">Not logged in</p>
|
||||
<p className="text-xs text-muted-foreground">Log in to your Rowboat account to access premium features</p>
|
||||
</div>
|
||||
<Button onClick={handleConnect} disabled={connecting}>
|
||||
{connecting ? <Loader2 className="size-4 animate-spin mr-2" /> : null}
|
||||
Log in to Rowboat
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Profile Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<User className="size-6 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{billing?.userEmail ?? 'Loading...'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Rowboat Account</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Plan Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="size-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium">Plan</h4>
|
||||
</div>
|
||||
|
||||
{billingLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading plan details...
|
||||
</div>
|
||||
) : billing ? (
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium capitalize">{billing.subscriptionPlan ?? 'Free'} Plan</p>
|
||||
{billing.subscriptionStatus && (
|
||||
<p className="text-xs text-muted-foreground capitalize">{billing.subscriptionStatus}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
Upgrade
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Unable to load plan details</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Log Out Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<LogOut className="size-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium">Log Out</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Logging out will remove access to synced data and Rowboat-provided models.
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive">
|
||||
Log Out
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Log out of your Rowboat account?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove access to synced data and Rowboat-provided models. You can log back in at any time.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnecting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{disconnecting ? <Loader2 className="size-4 animate-spin mr-2" /> : null}
|
||||
Log Out
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
import { useConnectors } from "@/hooks/useConnectors"
|
||||
|
||||
interface ConnectedAccountsSettingsProps {
|
||||
dialogOpen: boolean
|
||||
}
|
||||
|
||||
export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSettingsProps) {
|
||||
const c = useConnectors(dialogOpen)
|
||||
|
||||
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
|
||||
const state = c.providerStates[provider] || {
|
||||
isConnected: false,
|
||||
isLoading: true,
|
||||
isConnecting: false,
|
||||
}
|
||||
const needsReconnect = Boolean(c.providerStatus[provider]?.error)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">{displayName}</span>
|
||||
{state.isLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : needsReconnect ? (
|
||||
<span className="text-xs text-amber-600">Needs reconnect</span>
|
||||
) : state.isConnected ? (
|
||||
<span className="text-xs text-emerald-600">Connected</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{state.isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : needsReconnect ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (provider === 'google') {
|
||||
c.setGoogleClientIdDescription(
|
||||
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
||||
)
|
||||
c.setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
c.startConnect(provider)
|
||||
}}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
) : state.isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => c.handleDisconnect(provider)}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => c.handleConnect(provider)}
|
||||
disabled={state.isConnecting}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{state.isConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (c.providersLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoogleClientIdModal
|
||||
open={c.googleClientIdOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
c.setGoogleClientIdOpen(nextOpen)
|
||||
if (!nextOpen) {
|
||||
c.setGoogleClientIdDescription(undefined)
|
||||
}
|
||||
}}
|
||||
onSubmit={c.handleGoogleClientIdSubmit}
|
||||
isSubmitting={c.providerStates.google?.isConnecting ?? false}
|
||||
description={c.googleClientIdDescription}
|
||||
/>
|
||||
<ComposioApiKeyModal
|
||||
open={c.composioApiKeyOpen}
|
||||
onOpenChange={c.setComposioApiKeyOpen}
|
||||
onSubmit={c.handleComposioApiKeySubmit}
|
||||
isSubmitting={c.gmailConnecting}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
{/* Email & Calendar Section */}
|
||||
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
|
||||
<>
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Email & Calendar
|
||||
</span>
|
||||
</div>
|
||||
{c.useComposioForGoogle ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Gmail</span>
|
||||
{c.gmailLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : c.gmailConnected ? (
|
||||
<span className="text-xs text-emerald-600">Connected</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">Sync emails</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{c.gmailLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : c.gmailConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={c.handleDisconnectGmail}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={c.handleConnectGmail}
|
||||
disabled={c.gmailConnecting}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{c.gmailConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
|
||||
)}
|
||||
{c.useComposioForGoogleCalendar && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<Calendar className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Google Calendar</span>
|
||||
{c.googleCalendarLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : c.googleCalendarConnected ? (
|
||||
<span className="text-xs text-emerald-600">Connected</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">Sync calendar events</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{c.googleCalendarLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : c.googleCalendarConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={c.handleDisconnectGoogleCalendar}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={c.handleConnectGoogleCalendar}
|
||||
disabled={c.googleCalendarConnecting}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{c.googleCalendarConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Separator className="my-3" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Meeting Notes Section */}
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Meeting Notes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Granola */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<Mic className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Granola</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Local meeting notes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{c.granolaLoading && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
<Switch
|
||||
checked={c.granolaEnabled}
|
||||
onCheckedChange={c.handleGranolaToggle}
|
||||
disabled={c.granolaLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fireflies */}
|
||||
{c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
{/* Team Communication Section */}
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Team Communication
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
<div className="rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{c.slackEnabled && c.slackWorkspaces.length > 0 ? (
|
||||
<span className="text-xs text-emerald-600 truncate">
|
||||
{c.slackWorkspaces.map(w => w.name).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{(c.slackLoading || c.slackDiscovering) && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
{c.slackEnabled ? (
|
||||
<Switch
|
||||
checked={true}
|
||||
onCheckedChange={() => c.handleSlackDisable()}
|
||||
disabled={c.slackLoading}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={c.handleSlackEnable}
|
||||
disabled={c.slackLoading || c.slackDiscovering}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{c.slackPickerOpen && (
|
||||
<div className="mt-2 ml-12 space-y-2">
|
||||
{c.slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
{c.slackAvailableWorkspaces.map(w => (
|
||||
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c.slackSelectedUrls.has(w.url)}
|
||||
onChange={(e) => {
|
||||
c.setSlackSelectedUrls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (e.target.checked) next.add(w.url)
|
||||
else next.delete(w.url)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={c.handleSlackSaveWorkspaces}
|
||||
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
Bot,
|
||||
ChevronRight,
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Copy,
|
||||
ExternalLink,
|
||||
FilePlus,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
AlertTriangle,
|
||||
HelpCircle,
|
||||
|
|
@ -87,6 +88,7 @@ import { ConnectorsPopover } from "@/components/connectors-popover"
|
|||
import { HelpPopover } from "@/components/help-popover"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
import { toast } from "@/lib/toast"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { ServiceEvent } from "@x/shared/src/service-events.js"
|
||||
import z from "zod"
|
||||
|
||||
|
|
@ -401,6 +403,21 @@ export function SidebarContentPanel({
|
|||
const [connectorsOpen, setConnectorsOpen] = useState(false)
|
||||
const [openConnectorsAfterClose, setOpenConnectorsAfterClose] = useState(false)
|
||||
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
const { billing } = useBilling(isRowboatConnected)
|
||||
|
||||
const handleRowboatLogin = useCallback(async () => {
|
||||
try {
|
||||
setLoggingIn(true)
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider: 'rowboat' })
|
||||
if (!result.success) {
|
||||
setLoggingIn(false)
|
||||
}
|
||||
} catch {
|
||||
setLoggingIn(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
|
@ -412,6 +429,7 @@ export function SidebarContentPanel({
|
|||
const hasError = Object.values(config).some((entry) => Boolean(entry?.error))
|
||||
if (mounted) {
|
||||
setHasOauthError(hasError)
|
||||
setIsRowboatConnected(config['rowboat']?.connected ?? false)
|
||||
if (!hasError) {
|
||||
setShowOauthAlert(true)
|
||||
}
|
||||
|
|
@ -420,6 +438,7 @@ export function SidebarContentPanel({
|
|||
console.error('Failed to fetch OAuth state:', error)
|
||||
if (mounted) {
|
||||
setHasOauthError(false)
|
||||
setIsRowboatConnected(false)
|
||||
setShowOauthAlert(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -428,6 +447,7 @@ export function SidebarContentPanel({
|
|||
refreshOauthError()
|
||||
const cleanup = window.ipc.on('oauth:didConnect', () => {
|
||||
refreshOauthError()
|
||||
setLoggingIn(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
|
|
@ -483,17 +503,42 @@ export function SidebarContentPanel({
|
|||
/>
|
||||
)}
|
||||
</SidebarContent>
|
||||
{/* Billing / upgrade CTA or Log in CTA */}
|
||||
{isRowboatConnected && billing ? (
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
|
||||
<span className="text-xs font-medium capitalize text-sidebar-foreground">
|
||||
{billing.subscriptionPlan ?? 'Free'} plan
|
||||
</span>
|
||||
<button className="rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20">
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Sign in CTA */}
|
||||
{!isRowboatConnected && (
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
onClick={handleRowboatLogin}
|
||||
disabled={loggingIn}
|
||||
className="flex w-full items-center justify-center rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2.5 text-xs font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent/40 disabled:opacity-50"
|
||||
>
|
||||
{loggingIn ? 'Signing in…' : 'Sign in to Rowboat'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Bottom actions */}
|
||||
<div className="border-t border-sidebar-border px-2 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ConnectorsPopover open={connectorsOpen} onOpenChange={setConnectorsOpen}>
|
||||
<ConnectorsPopover open={connectorsOpen} onOpenChange={setConnectorsOpen} mode="unconnected">
|
||||
<button
|
||||
ref={connectorsButtonRef}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<Plug className="size-4" />
|
||||
<span>Connected accounts</span>
|
||||
<span>Connect Accounts</span>
|
||||
</button>
|
||||
</ConnectorsPopover>
|
||||
{hasOauthError && (
|
||||
|
|
@ -608,6 +653,9 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) =>
|
|||
const notePathRef = React.useRef<string | null>(null)
|
||||
const timestampRef = React.useRef<string | null>(null)
|
||||
const relativePathRef = React.useRef<string | null>(null)
|
||||
// Keep a ref to always call the latest onNoteCreated (avoids stale closure in recorder.onstop)
|
||||
const onNoteCreatedRef = React.useRef(onNoteCreated)
|
||||
React.useEffect(() => { onNoteCreatedRef.current = onNoteCreated }, [onNoteCreated])
|
||||
|
||||
React.useEffect(() => {
|
||||
window.ipc.invoke('workspace:readFile', {
|
||||
|
|
@ -642,11 +690,12 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) =>
|
|||
recursive: true,
|
||||
})
|
||||
|
||||
const initialContent = `# Voice Memo
|
||||
|
||||
**Type:** voice memo
|
||||
**Recorded:** ${now.toLocaleString()}
|
||||
**Path:** ${relativePath}
|
||||
const initialContent = `---
|
||||
type: voice memo
|
||||
recorded: "${now.toISOString()}"
|
||||
path: ${relativePath}
|
||||
---
|
||||
# Voice Memo
|
||||
|
||||
## Transcript
|
||||
|
||||
|
|
@ -659,7 +708,7 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) =>
|
|||
})
|
||||
|
||||
// Select the note so the user can see it
|
||||
onNoteCreated?.(notePath)
|
||||
onNoteCreatedRef.current?.(notePath)
|
||||
|
||||
// Start actual recording
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
|
|
@ -707,11 +756,12 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) =>
|
|||
const currentNotePath = notePathRef.current
|
||||
const currentRelativePath = relativePathRef.current
|
||||
if (currentNotePath && currentRelativePath) {
|
||||
const transcribingContent = `# Voice Memo
|
||||
|
||||
**Type:** voice memo
|
||||
**Recorded:** ${new Date().toLocaleString()}
|
||||
**Path:** ${currentRelativePath}
|
||||
const transcribingContent = `---
|
||||
type: voice memo
|
||||
recorded: "${new Date().toISOString()}"
|
||||
path: ${currentRelativePath}
|
||||
---
|
||||
# Voice Memo
|
||||
|
||||
## Transcript
|
||||
|
||||
|
|
@ -728,21 +778,23 @@ function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) =>
|
|||
const transcript = await transcribeWithDeepgram(blob)
|
||||
if (currentNotePath && currentRelativePath) {
|
||||
const finalContent = transcript
|
||||
? `# Voice Memo
|
||||
|
||||
**Type:** voice memo
|
||||
**Recorded:** ${new Date().toLocaleString()}
|
||||
**Path:** ${currentRelativePath}
|
||||
? `---
|
||||
type: voice memo
|
||||
recorded: "${new Date().toISOString()}"
|
||||
path: ${currentRelativePath}
|
||||
---
|
||||
# Voice Memo
|
||||
|
||||
## Transcript
|
||||
|
||||
${transcript}
|
||||
`
|
||||
: `# Voice Memo
|
||||
|
||||
**Type:** voice memo
|
||||
**Recorded:** ${new Date().toLocaleString()}
|
||||
**Path:** ${currentRelativePath}
|
||||
: `---
|
||||
type: voice memo
|
||||
recorded: "${new Date().toISOString()}"
|
||||
path: ${currentRelativePath}
|
||||
---
|
||||
# Voice Memo
|
||||
|
||||
## Transcript
|
||||
|
||||
|
|
@ -755,7 +807,7 @@ ${transcript}
|
|||
})
|
||||
|
||||
// Re-select to trigger refresh
|
||||
onNoteCreated?.(currentNotePath)
|
||||
onNoteCreatedRef.current?.(currentNotePath)
|
||||
|
||||
if (transcript) {
|
||||
toast('Voice note transcribed', 'success')
|
||||
|
|
@ -929,6 +981,16 @@ function KnowledgeSection({
|
|||
)
|
||||
}
|
||||
|
||||
function countFiles(node: TreeNode): number {
|
||||
if (node.kind === 'file') return 1
|
||||
return (node.children ?? []).reduce((sum, child) => sum + countFiles(child), 0)
|
||||
}
|
||||
|
||||
/** Display name overrides for top-level knowledge folders */
|
||||
const FOLDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
Notes: 'My Notes',
|
||||
}
|
||||
|
||||
// Tree component for file browser
|
||||
function Tree({
|
||||
item,
|
||||
|
|
@ -948,6 +1010,7 @@ function Tree({
|
|||
const isSelected = selectedPath === item.path
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const isSubmittingRef = React.useRef(false)
|
||||
const displayName = (isDir && FOLDER_DISPLAY_NAMES[item.name]) || item.name
|
||||
|
||||
// For files, strip .md extension for editing
|
||||
const baseName = !isDir && item.name.endsWith('.md')
|
||||
|
|
@ -1076,6 +1139,29 @@ function Tree({
|
|||
)
|
||||
}
|
||||
|
||||
// Top-level knowledge folders (except Notes) open bases view — render as flat items
|
||||
const parts = item.path.split('/')
|
||||
const isBasesFolder = isDir && parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes'
|
||||
|
||||
if (isBasesFolder) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={() => onSelect(item.path, item.kind)}>
|
||||
<Folder className="size-4 shrink-0" />
|
||||
<div className="flex w-full items-center gap-1 min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
<span className="text-xs text-sidebar-foreground/50 tabular-nums shrink-0">{countFiles(item)}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenuContent}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDir) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
|
|
@ -1118,7 +1204,10 @@ function Tree({
|
|||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<ChevronRight className="transition-transform size-4" />
|
||||
<span>{item.name}</span>
|
||||
<div className="flex w-full items-center gap-1 min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
<span className="text-xs text-sidebar-foreground/50 tabular-nums shrink-0">{countFiles(item)}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
|
|
@ -1235,9 +1324,6 @@ function TasksSection({
|
|||
}}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2 min-w-0">
|
||||
{processingRunIds?.has(run.id) ? (
|
||||
<span className="size-2 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
|
||||
{run.createdAt ? (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -67,9 +67,6 @@ export function TabBar<T>({
|
|||
)}
|
||||
style={layout === 'scroll' ? { flex: '0 0 auto' } : { flex: '1 1 0px' }}
|
||||
>
|
||||
{processing && (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
||||
)}
|
||||
<span className="truncate flex-1 text-left">{title}</span>
|
||||
{(allowSingleTabClose || tabs.length > 1) && (
|
||||
<span
|
||||
|
|
|
|||
378
apps/x/apps/renderer/src/extensions/calendar-block.tsx
Normal file
378
apps/x/apps/renderer/src/extensions/calendar-block.tsx
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import { mergeAttributes, Node as TiptapNode } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function getDateParts(dateStr: string): { day: number; month: string; weekday: string; isToday: boolean } {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
|
||||
return {
|
||||
day: d.getDate(),
|
||||
month: d.toLocaleDateString([], { month: 'short' }).toUpperCase(),
|
||||
weekday: d.toLocaleDateString([], { weekday: 'short' }).toUpperCase(),
|
||||
isToday,
|
||||
}
|
||||
}
|
||||
|
||||
function getEventDate(event: blocks.CalendarEvent): string {
|
||||
return event.start?.dateTime || event.start?.date || ''
|
||||
}
|
||||
|
||||
function isAllDay(event: blocks.CalendarEvent): boolean {
|
||||
return !event.start?.dateTime && !!event.start?.date
|
||||
}
|
||||
|
||||
function getTimeRange(event: blocks.CalendarEvent): string {
|
||||
if (isAllDay(event)) return 'All day'
|
||||
const start = event.start?.dateTime
|
||||
const end = event.end?.dateTime
|
||||
if (!start) return ''
|
||||
const startTime = formatTime(start)
|
||||
if (!end) return startTime
|
||||
const endTime = formatTime(end)
|
||||
return `${startTime} \u2013 ${endTime}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a video conference link from raw Google Calendar event JSON.
|
||||
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
||||
* to conferenceLink if already set.
|
||||
*/
|
||||
function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||
// Check conferenceData.entryPoints for video entry
|
||||
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
|
||||
if (confData?.entryPoints) {
|
||||
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
|
||||
if (video?.uri) return video.uri
|
||||
}
|
||||
// Check hangoutLink (Google Meet shortcut)
|
||||
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
|
||||
// Fall back to conferenceLink if present
|
||||
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface ResolvedEvent {
|
||||
event: blocks.CalendarEvent
|
||||
loaded: blocks.CalendarEvent | null
|
||||
conferenceLink?: string
|
||||
}
|
||||
|
||||
const GCAL_EVENT_COLOR = '#039be5'
|
||||
const GCAL_TODAY_COLOR = '#1a73e8'
|
||||
|
||||
function JoinMeetingSplitButton({ onJoinAndNotes, onNotesOnly }: {
|
||||
onJoinAndNotes: () => void
|
||||
onNotesOnly: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target
|
||||
if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="calendar-block-split-btn" ref={ref}>
|
||||
<button
|
||||
className="calendar-block-split-main"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); onJoinAndNotes() }}
|
||||
>
|
||||
<Video size={13} />
|
||||
Join meeting & take notes
|
||||
</button>
|
||||
<div className="calendar-block-split-chevron-wrap">
|
||||
<button
|
||||
className={`calendar-block-split-chevron ${open ? 'calendar-block-split-chevron-open' : ''}`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="calendar-block-split-dropdown">
|
||||
<button
|
||||
className="calendar-block-split-option"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(false); onNotesOnly() }}
|
||||
>
|
||||
<Mic size={13} />
|
||||
Take notes only
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Shared global to pass calendar event data to App.tsx when joining a meeting.
|
||||
// Set before dispatching the custom event, read by the handler in App.tsx.
|
||||
declare global {
|
||||
interface Window {
|
||||
__pendingCalendarEvent?: {
|
||||
summary?: string
|
||||
start?: { dateTime?: string; date?: string }
|
||||
end?: { dateTime?: string; date?: string }
|
||||
location?: string
|
||||
htmlLink?: string
|
||||
conferenceLink?: string
|
||||
source?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function CalendarBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.CalendarBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.CalendarBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
const [resolvedEvents, setResolvedEvents] = useState<ResolvedEvent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return
|
||||
|
||||
const eventsWithSources = config.events.filter(e => e.source)
|
||||
if (eventsWithSources.length === 0) {
|
||||
setResolvedEvents(config.events.map(e => ({ event: e, loaded: null })))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const ipc = (window as unknown as { ipc: { invoke: (channel: string, args: Record<string, string>) => Promise<{ data: string }> } }).ipc
|
||||
|
||||
Promise.all(
|
||||
config.events.map(async (event): Promise<ResolvedEvent> => {
|
||||
if (!event.source) return { event, loaded: null }
|
||||
try {
|
||||
const result = await ipc.invoke('workspace:readFile', { path: event.source, encoding: 'utf8' })
|
||||
const content = typeof result === 'string' ? result : result.data
|
||||
const rawEvent = JSON.parse(content) as Record<string, unknown>
|
||||
const parsed = blocks.CalendarEventSchema.parse(rawEvent)
|
||||
const conferenceLink = extractConferenceLink(rawEvent)
|
||||
return { event, loaded: parsed, conferenceLink }
|
||||
} catch {
|
||||
return { event, loaded: null }
|
||||
}
|
||||
})
|
||||
).then(results => {
|
||||
setResolvedEvents(results)
|
||||
setLoading(false)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [raw])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="calendar-block-wrapper" data-type="calendar-block">
|
||||
<div className="calendar-block-card calendar-block-error">
|
||||
<Calendar size={16} />
|
||||
<span>Invalid calendar block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const showJoinButton = config.showJoinButton === true
|
||||
|
||||
const events = resolvedEvents.map(r => {
|
||||
const e = r.loaded || r.event
|
||||
return {
|
||||
...e,
|
||||
htmlLink: e.htmlLink || r.event.htmlLink,
|
||||
conferenceLink: r.conferenceLink || e.conferenceLink || r.event.conferenceLink,
|
||||
}
|
||||
})
|
||||
|
||||
// Group events by date
|
||||
const dateGroups: { dateKey: string; dateStr: string; events: (blocks.CalendarEvent & { _idx: number; conferenceLink?: string })[] }[] = []
|
||||
|
||||
let globalIdx = 0
|
||||
for (const event of events) {
|
||||
const dateStr = getEventDate(event)
|
||||
const dateKey = dateStr ? new Date(dateStr).toDateString() : 'Unknown'
|
||||
|
||||
let group = dateGroups.find(g => g.dateKey === dateKey)
|
||||
if (!group) {
|
||||
group = { dateKey, dateStr, events: [] }
|
||||
dateGroups.push(group)
|
||||
}
|
||||
group.events.push({ ...event, _idx: globalIdx++ })
|
||||
}
|
||||
|
||||
const handleEventClick = (event: blocks.CalendarEvent) => {
|
||||
if (event.htmlLink) {
|
||||
window.open(event.htmlLink, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoinMeeting = (event: blocks.CalendarEvent & { conferenceLink?: string }, resolvedIdx: number, joinCall: boolean) => {
|
||||
if (joinCall) {
|
||||
const meetingUrl = event.conferenceLink
|
||||
if (meetingUrl) {
|
||||
window.open(meetingUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// Find the original source path from config
|
||||
const originalEvent = config!.events[resolvedIdx]
|
||||
|
||||
// Set calendar event data on window so App.tsx handler can read it
|
||||
window.__pendingCalendarEvent = {
|
||||
summary: event.summary,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
location: event.location,
|
||||
htmlLink: event.htmlLink,
|
||||
conferenceLink: event.conferenceLink,
|
||||
source: originalEvent?.source,
|
||||
}
|
||||
// Dispatch custom event so App.tsx can start meeting transcription
|
||||
window.dispatchEvent(new Event('calendar-block:join-meeting'))
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="calendar-block-wrapper" data-type="calendar-block">
|
||||
<div className="calendar-block-card">
|
||||
<button
|
||||
className="calendar-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete calendar block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{config.title && <div className="calendar-block-title">{config.title}</div>}
|
||||
{loading ? (
|
||||
<div className="calendar-block-loading">Loading events...</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="calendar-block-empty">No events</div>
|
||||
) : (
|
||||
<div className="calendar-block-list">
|
||||
{dateGroups.map((group, groupIdx) => {
|
||||
const parts = group.dateStr ? getDateParts(group.dateStr) : null
|
||||
return (
|
||||
<div key={group.dateKey} className="calendar-block-date-group">
|
||||
{groupIdx > 0 && <div className="calendar-block-separator" />}
|
||||
<div className="calendar-block-date-row">
|
||||
<div className="calendar-block-date-left">
|
||||
{parts ? (
|
||||
<>
|
||||
<span className="calendar-block-weekday" style={parts.isToday ? { color: GCAL_TODAY_COLOR } : undefined}>{parts.weekday}</span>
|
||||
<span className={`calendar-block-day${parts.isToday ? ' calendar-block-day-today' : ''}`}>{parts.day}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="calendar-block-day">?</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="calendar-block-events">
|
||||
{group.events.map(event => (
|
||||
<div
|
||||
key={event._idx}
|
||||
className={`calendar-block-event ${event.htmlLink ? 'calendar-block-event-clickable' : ''}`}
|
||||
style={{ backgroundColor: GCAL_EVENT_COLOR }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); handleEventClick(event) }}
|
||||
>
|
||||
<div className="calendar-block-event-content">
|
||||
<div className="calendar-block-event-title">
|
||||
{event.summary || '(No title)'}
|
||||
</div>
|
||||
<div className="calendar-block-event-time">
|
||||
{getTimeRange(event)}
|
||||
</div>
|
||||
{showJoinButton && event.conferenceLink && (
|
||||
<JoinMeetingSplitButton
|
||||
onJoinAndNotes={() => handleJoinMeeting(event, event._idx, true)}
|
||||
onNotesOnly={() => handleJoinMeeting(event, event._idx, false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const CalendarBlockExtension = TiptapNode.create({
|
||||
name: 'calendarBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-calendar')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'calendar-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CalendarBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```calendar\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
173
apps/x/apps/renderer/src/extensions/chart-block.tsx
Normal file
173
apps/x/apps/renderer/src/extensions/chart-block.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, BarChart3 } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
LineChart, Line,
|
||||
BarChart, Bar,
|
||||
PieChart, Pie, Cell,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts'
|
||||
|
||||
const CHART_COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#0088fe', '#00c49f']
|
||||
|
||||
function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.ChartBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.ChartBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
const [fileData, setFileData] = useState<Record<string, unknown>[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.source) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
;(window as unknown as { ipc: { invoke: (channel: string, args: Record<string, string>) => Promise<string> } })
|
||||
.ipc.invoke('workspace:readFile', { path: config.source, encoding: 'utf-8' })
|
||||
.then((content: string) => {
|
||||
const parsed = JSON.parse(content)
|
||||
if (Array.isArray(parsed)) {
|
||||
setFileData(parsed)
|
||||
} else {
|
||||
setError('Source file must contain a JSON array')
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(err.message || 'Failed to load data file')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [config?.source])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="chart-block-wrapper" data-type="chart-block">
|
||||
<div className="chart-block-card chart-block-error">
|
||||
<BarChart3 size={16} />
|
||||
<span>Invalid chart block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const data = config.data || fileData
|
||||
|
||||
const renderChart = () => {
|
||||
if (loading) return <div className="chart-block-loading">Loading data...</div>
|
||||
if (error) return <div className="chart-block-error-msg">{error}</div>
|
||||
if (!data || data.length === 0) return <div className="chart-block-empty">No data</div>
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
{config!.chart === 'line' ? (
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey={config!.x} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey={config!.y} stroke="#8884d8" />
|
||||
</LineChart>
|
||||
) : config!.chart === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey={config!.x} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey={config!.y} fill="#8884d8" />
|
||||
</BarChart>
|
||||
) : (
|
||||
<PieChart>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Pie data={data} dataKey={config!.y} nameKey={config!.x} cx="50%" cy="50%" outerRadius={80} label>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="chart-block-wrapper" data-type="chart-block">
|
||||
<div className="chart-block-card">
|
||||
<button
|
||||
className="chart-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete chart block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{config.title && <div className="chart-block-title">{config.title}</div>}
|
||||
{renderChart()}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChartBlockExtension = Node.create({
|
||||
name: 'chartBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-chart')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'chart-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ChartBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```chart\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
286
apps/x/apps/renderer/src/extensions/email-block.tsx
Normal file
286
apps/x/apps/renderer/src/extensions/email-block.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function formatEmailDate(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d.getTime())) return dateStr
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) +
|
||||
' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract just the name part from "Name <email>" format */
|
||||
function senderFirstName(from: string): string {
|
||||
const name = from.replace(/<.*>/, '').trim()
|
||||
return name.split(/\s+/)[0] || name
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__pendingEmailDraft?: { prompt: string }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Email Block ---
|
||||
|
||||
function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
updateAttributes: (attrs: Record<string, unknown>) => void
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.EmailBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.EmailBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
const hasDraft = !!config?.draft_response
|
||||
const hasPastSummary = !!config?.past_summary
|
||||
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
// Local draft state for editing
|
||||
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
|
||||
const [emailExpanded, setEmailExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Sync draft from external changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
const parsed = blocks.EmailBlockSchema.parse(JSON.parse(raw))
|
||||
setDraftBody(parsed.draft_response || '')
|
||||
} catch { /* ignore */ }
|
||||
}, [raw])
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) {
|
||||
bodyRef.current.style.height = 'auto'
|
||||
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
|
||||
}
|
||||
}, [draftBody])
|
||||
|
||||
const commitDraft = useCallback((newBody: string) => {
|
||||
try {
|
||||
const current = JSON.parse(raw) as Record<string, unknown>
|
||||
updateAttributes({ data: JSON.stringify({ ...current, draft_response: newBody }) })
|
||||
} catch { /* ignore */ }
|
||||
}, [raw, updateAttributes])
|
||||
|
||||
const draftWithAssistant = useCallback(() => {
|
||||
if (!config) return
|
||||
let prompt = draftBody
|
||||
? `Help me refine this draft response to an email`
|
||||
: `Help me draft a response to this email`
|
||||
if (config.threadId) {
|
||||
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
|
||||
}
|
||||
prompt += `.\n\n`
|
||||
prompt += `**From:** ${config.from || 'Unknown'}\n`
|
||||
prompt += `**Subject:** ${config.subject || 'No subject'}\n`
|
||||
if (draftBody) {
|
||||
prompt += `\n**Current draft:**\n${draftBody}\n`
|
||||
}
|
||||
window.__pendingEmailDraft = { prompt }
|
||||
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
||||
}, [config, draftBody])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
<div className="email-block-card email-block-error">
|
||||
<Mail size={16} />
|
||||
<span>Invalid email block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const gmailUrl = config.threadId
|
||||
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
|
||||
: null
|
||||
|
||||
// Build summary: use explicit summary, or auto-generate from sender + subject
|
||||
const summary = config.summary
|
||||
|| (config.from && config.subject
|
||||
? `${senderFirstName(config.from)} reached out about ${config.subject}`
|
||||
: config.subject || 'New email')
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
|
||||
<X size={14} />
|
||||
</button>
|
||||
|
||||
{/* Header: Email badge */}
|
||||
<div className="email-block-badge">
|
||||
<Mail size={13} />
|
||||
Email
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="email-block-summary">{summary}</div>
|
||||
|
||||
{/* Expandable email details */}
|
||||
<button
|
||||
className="email-block-expand-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronDown size={13} className={`email-block-toggle-chevron ${emailExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
|
||||
{emailExpanded ? 'Hide email' : 'Show email'}
|
||||
{config.from && <span className="email-block-expand-meta">· From {senderFirstName(config.from)}</span>}
|
||||
{config.date && <span className="email-block-expand-meta">· {formatEmailDate(config.date)}</span>}
|
||||
</button>
|
||||
|
||||
{emailExpanded && (
|
||||
<div className="email-block-email-details">
|
||||
<div className="email-block-message">
|
||||
<div className="email-block-message-header">
|
||||
<div className="email-block-sender-info">
|
||||
<div className="email-block-sender-row">
|
||||
<div className="email-block-sender-name">{config.from || 'Unknown'}</div>
|
||||
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
|
||||
</div>
|
||||
{config.subject && <div className="email-block-subject-line">Subject: {config.subject}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="email-block-message-body">{config.latest_email}</div>
|
||||
</div>
|
||||
{hasPastSummary && (
|
||||
<div className="email-block-context-section">
|
||||
<div className="email-block-context-label">Earlier conversation</div>
|
||||
<div className="email-block-context-summary">{config.past_summary}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draft section */}
|
||||
{hasDraft && (
|
||||
<div className="email-block-draft-section">
|
||||
<div className="email-block-draft-label">Draft reply</div>
|
||||
<textarea
|
||||
key={resolvedTheme}
|
||||
ref={bodyRef}
|
||||
className="email-draft-block-body-input"
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value)}
|
||||
onBlur={() => commitDraft(draftBody)}
|
||||
placeholder="Write your reply..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="email-block-actions">
|
||||
<button
|
||||
className="email-block-gmail-btn email-block-gmail-btn-primary"
|
||||
onClick={draftWithAssistant}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
|
||||
</button>
|
||||
{hasDraft && (
|
||||
<button
|
||||
className="email-block-gmail-btn email-block-gmail-btn-primary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(draftBody).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {
|
||||
// Fallback for Electron contexts where clipboard API may fail
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = draftBody
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
{copied ? 'Copied!' : 'Copy draft'}
|
||||
</button>
|
||||
)}
|
||||
{gmailUrl && (
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
onClick={() => window.open(gmailUrl, '_blank')}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Open in Gmail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const EmailBlockExtension = Node.create({
|
||||
name: 'emailBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: { default: '{}' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-email') && !cls.includes('language-emailDraft')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
}]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'email-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(EmailBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```email\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
143
apps/x/apps/renderer/src/extensions/embed-block.tsx
Normal file
143
apps/x/apps/renderer/src/extensions/embed-block.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, ExternalLink } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
function getEmbedUrl(provider: string, url: string): string | null {
|
||||
if (provider === 'youtube') {
|
||||
// Handle youtube.com/watch?v=X and youtu.be/X
|
||||
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
|
||||
if (match) return `https://www.youtube.com/embed/${match[1]}`
|
||||
}
|
||||
if (provider === 'figma') {
|
||||
// Convert www.figma.com/design/:key/... → embed.figma.com/design/:key?embed-host=rowboat
|
||||
const figmaMatch = url.match(/figma\.com\/(design|board|proto)\/([\w-]+)/)
|
||||
if (figmaMatch) {
|
||||
return `https://embed.figma.com/${figmaMatch[1]}/${figmaMatch[2]}?embed-host=rowboat`
|
||||
}
|
||||
// Legacy /file/ URLs
|
||||
const legacyMatch = url.match(/figma\.com\/file\/([\w-]+)/)
|
||||
if (legacyMatch) {
|
||||
return `https://embed.figma.com/design/${legacyMatch[1]}?embed-host=rowboat`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.EmbedBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.EmbedBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="embed-block-wrapper" data-type="embed-block">
|
||||
<div className="embed-block-card embed-block-error">
|
||||
<ExternalLink size={16} />
|
||||
<span>Invalid embed block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const embedUrl = getEmbedUrl(config.provider, config.url)
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="embed-block-wrapper" data-type="embed-block">
|
||||
<div className="embed-block-card">
|
||||
<button
|
||||
className="embed-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete embed block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{embedUrl ? (
|
||||
<div className="embed-block-iframe-container">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
className="embed-block-iframe"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={config.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="embed-block-link"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{config.url}
|
||||
</a>
|
||||
)}
|
||||
{config.caption && (
|
||||
<div className="embed-block-caption">{config.caption}</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const EmbedBlockExtension = Node.create({
|
||||
name: 'embedBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-embed')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'embed-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(EmbedBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```embed\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
104
apps/x/apps/renderer/src/extensions/image-block.tsx
Normal file
104
apps/x/apps/renderer/src/extensions/image-block.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, ImageIcon } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
function ImageBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.ImageBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.ImageBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="image-block-wrapper" data-type="image-block">
|
||||
<div className="image-block-card image-block-error">
|
||||
<ImageIcon size={16} />
|
||||
<span>Invalid image block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="image-block-wrapper" data-type="image-block">
|
||||
<div className="image-block-card">
|
||||
<button
|
||||
className="image-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete image block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<img
|
||||
src={config.src}
|
||||
alt={config.alt || ''}
|
||||
className="image-block-img"
|
||||
/>
|
||||
{config.caption && (
|
||||
<div className="image-block-caption">{config.caption}</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const ImageBlockExtension = Node.create({
|
||||
name: 'imageBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-image')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```image\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
124
apps/x/apps/renderer/src/extensions/table-block.tsx
Normal file
124
apps/x/apps/renderer/src/extensions/table-block.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, Table2 } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
function TableBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.TableBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.TableBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="table-block-wrapper" data-type="table-block">
|
||||
<div className="table-block-card table-block-error">
|
||||
<Table2 size={16} />
|
||||
<span>Invalid table block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="table-block-wrapper" data-type="table-block">
|
||||
<div className="table-block-card">
|
||||
<button
|
||||
className="table-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete table block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{config.title && <div className="table-block-title">{config.title}</div>}
|
||||
<div className="table-block-scroll">
|
||||
<table className="table-block-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{config.columns.map((col) => (
|
||||
<th key={col}>{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.data.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{config!.columns.map((col) => (
|
||||
<td key={col}>{String(row[col] ?? '')}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{config.data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={config.columns.length} className="table-block-empty">
|
||||
No data
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const TableBlockExtension = Node.create({
|
||||
name: 'tableBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-table')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'table-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TableBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```table\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -1,22 +1,39 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { CalendarClock, X } from 'lucide-react'
|
||||
import { CalendarClock, Loader2, X } from 'lucide-react'
|
||||
import { inlineTask } from '@x/shared'
|
||||
|
||||
function TaskBlockView({ node, deleteNode }: { node: { attrs: { data: string } }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
function TaskBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let instruction = ''
|
||||
let scheduleLabel = ''
|
||||
let processing = false
|
||||
let lastRunAt = ''
|
||||
|
||||
try {
|
||||
const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw))
|
||||
instruction = parsed.instruction
|
||||
scheduleLabel = parsed['schedule-label'] ?? ''
|
||||
processing = parsed.processing ?? false
|
||||
lastRunAt = parsed.lastRunAt ?? ''
|
||||
} catch {
|
||||
// Fallback: show raw data
|
||||
instruction = raw
|
||||
}
|
||||
|
||||
const lastRunLabel = lastRunAt ? formatDateTime(lastRunAt) : ''
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="task-block-wrapper" data-type="task-block">
|
||||
<div className="task-block-card">
|
||||
|
|
@ -29,10 +46,17 @@ function TaskBlockView({ node, deleteNode }: { node: { attrs: { data: string } }
|
|||
</button>
|
||||
<div className="task-block-content">
|
||||
<span className="task-block-instruction"><span className="task-block-prefix">@rowboat</span> {instruction}</span>
|
||||
{scheduleLabel && (
|
||||
{processing && (
|
||||
<span className="task-block-schedule">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
processing…
|
||||
</span>
|
||||
)}
|
||||
{!processing && scheduleLabel && (
|
||||
<span className="task-block-schedule">
|
||||
<CalendarClock size={12} />
|
||||
{scheduleLabel}
|
||||
{lastRunLabel && <span className="task-block-last-run"> · last ran {lastRunLabel}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
177
apps/x/apps/renderer/src/extensions/transcript-block.tsx
Normal file
177
apps/x/apps/renderer/src/extensions/transcript-block.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { ChevronDown, FileText } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
interface TranscriptEntry {
|
||||
speaker: string
|
||||
text: string
|
||||
}
|
||||
|
||||
function parseTranscript(raw: string): TranscriptEntry[] {
|
||||
const entries: TranscriptEntry[] = []
|
||||
const lines = raw.split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
// Match **Speaker Name:** text or **You:** text
|
||||
const match = trimmed.match(/^\*\*(.+?):\*\*\s*(.*)$/)
|
||||
if (match) {
|
||||
entries.push({ speaker: match[1], text: match[2] })
|
||||
} else if (entries.length > 0) {
|
||||
// Continuation line — append to last entry
|
||||
entries[entries.length - 1].text += ' ' + trimmed
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function speakerColor(speaker: string): string {
|
||||
// Simple hash to pick a consistent color per speaker
|
||||
let hash = 0
|
||||
for (let i = 0; i < speaker.length; i++) {
|
||||
hash = speaker.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const colors = [
|
||||
'#3b82f6', // blue
|
||||
'#06b6d4', // cyan
|
||||
'#6366f1', // indigo
|
||||
'#8b5cf6', // purple
|
||||
'#0ea5e9', // sky
|
||||
'#2563eb', // blue darker
|
||||
'#7c3aed', // violet
|
||||
]
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
function TranscriptBlockView({ node, getPos, editor }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
getPos: () => number | undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
editor: any
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.TranscriptBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.TranscriptBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
// Auto-detect: expand if this is the first real block (live recording),
|
||||
// collapse if there's other content above (notes have been generated)
|
||||
const isFirstBlock = useMemo(() => {
|
||||
try {
|
||||
const pos = getPos()
|
||||
if (pos === undefined) return false
|
||||
const firstChild = editor?.state?.doc?.firstChild
|
||||
if (!firstChild) return true
|
||||
// If the transcript block is right after the first node (heading), it's the main content
|
||||
return pos <= (firstChild.nodeSize ?? 0) + 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [getPos, editor])
|
||||
|
||||
const [expanded, setExpanded] = useState(isFirstBlock)
|
||||
|
||||
const entries = useMemo(() => {
|
||||
if (!config) return []
|
||||
return parseTranscript(config.transcript)
|
||||
}, [config])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="transcript-block-wrapper" data-type="transcript-block">
|
||||
<div className="transcript-block-card transcript-block-error">
|
||||
<FileText size={16} />
|
||||
<span>Invalid transcript block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="transcript-block-wrapper" data-type="transcript-block">
|
||||
<div className="transcript-block-card" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="transcript-block-toggle"
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronDown size={14} className={`transcript-block-chevron ${expanded ? 'transcript-block-chevron-open' : ''}`} />
|
||||
<FileText size={14} />
|
||||
<span>Raw transcript</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="transcript-block-content">
|
||||
{entries.length > 0 ? (
|
||||
entries.map((entry, i) => (
|
||||
<div key={i} className="transcript-entry">
|
||||
<span className="transcript-speaker" style={{ color: speakerColor(entry.speaker) }}>
|
||||
{entry.speaker}
|
||||
</span>
|
||||
<span className="transcript-text">{entry.text}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="transcript-raw">{config.transcript}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const TranscriptBlockExtension = Node.create({
|
||||
name: 'transcriptBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: { default: '{}' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-transcript')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
}]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'transcript-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TranscriptBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```transcript\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
74
apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts
Normal file
74
apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useEffect } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
/**
|
||||
* Identifies the user in PostHog when signed into Rowboat,
|
||||
* and sets user properties for connected OAuth providers.
|
||||
* Call once at the App level.
|
||||
*/
|
||||
export function useAnalyticsIdentity() {
|
||||
// On mount: check current OAuth state and identify if signed in
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:getState', null)
|
||||
const config = result.config || {}
|
||||
|
||||
// Identify if Rowboat account is connected
|
||||
const rowboat = config.rowboat
|
||||
if (rowboat?.connected && rowboat?.userId) {
|
||||
posthog.identify(rowboat.userId)
|
||||
}
|
||||
|
||||
// Set provider connection flags
|
||||
const providers = ['gmail', 'calendar', 'slack', 'rowboat']
|
||||
const props: Record<string, boolean> = { signed_in: !!rowboat?.connected }
|
||||
for (const p of providers) {
|
||||
props[`${p}_connected`] = !!config[p]?.connected
|
||||
}
|
||||
posthog.people.set(props)
|
||||
|
||||
// Count notes for total_notes property
|
||||
try {
|
||||
const dirs = await window.ipc.invoke('workspace:readdir', { path: '/' })
|
||||
let totalNotes = 0
|
||||
if (dirs?.entries) {
|
||||
for (const entry of dirs.entries) {
|
||||
if (entry.type === 'directory') {
|
||||
try {
|
||||
const sub = await window.ipc.invoke('workspace:readdir', { path: `/${entry.name}` })
|
||||
totalNotes += sub?.entries?.length ?? 0
|
||||
} catch {
|
||||
// skip inaccessible dirs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
posthog.people.set({ total_notes: totalNotes })
|
||||
} catch {
|
||||
// workspace may not be available
|
||||
}
|
||||
} catch {
|
||||
// oauth state unavailable
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
// Listen for OAuth connect/disconnect events to update identity
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (!event.success) return
|
||||
|
||||
// If Rowboat provider connected, identify user
|
||||
if (event.provider === 'rowboat' && event.userId) {
|
||||
posthog.identify(event.userId)
|
||||
posthog.people.set({ signed_in: true })
|
||||
}
|
||||
|
||||
posthog.people.set({ [`${event.provider}_connected`]: true })
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [])
|
||||
}
|
||||
38
apps/x/apps/renderer/src/hooks/useBilling.ts
Normal file
38
apps/x/apps/renderer/src/hooks/useBilling.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface BillingInfo {
|
||||
userEmail: string | null
|
||||
userId: string | null
|
||||
subscriptionPlan: string | null
|
||||
subscriptionStatus: string | null
|
||||
sanctionedCredits: number
|
||||
availableCredits: number
|
||||
}
|
||||
|
||||
export function useBilling(isRowboatConnected: boolean) {
|
||||
const [billing, setBilling] = useState<BillingInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const fetchBilling = useCallback(async () => {
|
||||
if (!isRowboatConnected) {
|
||||
setBilling(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const result = await window.ipc.invoke('billing:getInfo', null)
|
||||
setBilling(result)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch billing info:', error)
|
||||
setBilling(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [isRowboatConnected])
|
||||
|
||||
useEffect(() => {
|
||||
fetchBilling()
|
||||
}, [fetchBilling])
|
||||
|
||||
return { billing, isLoading, refresh: fetchBilling }
|
||||
}
|
||||
618
apps/x/apps/renderer/src/hooks/useConnectors.ts
Normal file
618
apps/x/apps/renderer/src/hooks/useConnectors.ts
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
import { useState, useEffect, useCallback } from "react"
|
||||
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export interface ProviderState {
|
||||
isConnected: boolean
|
||||
isLoading: boolean
|
||||
isConnecting: boolean
|
||||
}
|
||||
|
||||
export interface ProviderStatus {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function useConnectors(active: boolean) {
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
|
||||
const [providerStatus, setProviderStatus] = useState<Record<string, ProviderStatus>>({})
|
||||
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
|
||||
const [googleClientIdDescription, setGoogleClientIdDescription] = useState<string | undefined>(undefined)
|
||||
|
||||
// Granola state
|
||||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||
|
||||
// Composio API key state
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
||||
|
||||
// Slack state
|
||||
const [slackEnabled, setSlackEnabled] = useState(false)
|
||||
const [slackLoading, setSlackLoading] = useState(true)
|
||||
const [slackWorkspaces, setSlackWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
|
||||
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
|
||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Composio/Gmail state
|
||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(true)
|
||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||
|
||||
// Composio/Google Calendar state
|
||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
// Load available providers on mount
|
||||
useEffect(() => {
|
||||
async function loadProviders() {
|
||||
try {
|
||||
setProvidersLoading(true)
|
||||
const result = await window.ipc.invoke('oauth:list-providers', null)
|
||||
setProviders(result.providers || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to get available providers:', error)
|
||||
setProviders([])
|
||||
} finally {
|
||||
setProvidersLoading(false)
|
||||
}
|
||||
}
|
||||
loadProviders()
|
||||
}, [])
|
||||
|
||||
// Re-check composio-for-google flags when active
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
async function loadComposioForGoogleFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||
setUseComposioForGoogle(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google flag:', error)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleCalendarFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||
setUseComposioForGoogleCalendar(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||
}
|
||||
}
|
||||
loadComposioForGoogleFlag()
|
||||
loadComposioForGoogleCalendarFlag()
|
||||
}, [active])
|
||||
|
||||
// Load Granola config
|
||||
const refreshGranolaConfig = useCallback(async () => {
|
||||
try {
|
||||
setGranolaLoading(true)
|
||||
const result = await window.ipc.invoke('granola:getConfig', null)
|
||||
setGranolaEnabled(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Granola config:', error)
|
||||
setGranolaEnabled(false)
|
||||
} finally {
|
||||
setGranolaLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGranolaToggle = useCallback(async (enabled: boolean) => {
|
||||
try {
|
||||
setGranolaLoading(true)
|
||||
await window.ipc.invoke('granola:setConfig', { enabled })
|
||||
setGranolaEnabled(enabled)
|
||||
toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to update Granola config:', error)
|
||||
toast.error('Failed to update Granola sync settings')
|
||||
} finally {
|
||||
setGranolaLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Slack
|
||||
const refreshSlackConfig = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('slack:getConfig', null)
|
||||
setSlackEnabled(result.enabled)
|
||||
setSlackWorkspaces(result.workspaces || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load Slack config:', error)
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSlackEnable = useCallback(async () => {
|
||||
setSlackDiscovering(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:listWorkspaces', null)
|
||||
if (result.error || result.workspaces.length === 0) {
|
||||
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
|
||||
setSlackAvailableWorkspaces([])
|
||||
setSlackPickerOpen(true)
|
||||
} else {
|
||||
setSlackAvailableWorkspaces(result.workspaces)
|
||||
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
|
||||
setSlackPickerOpen(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to discover Slack workspaces:', error)
|
||||
setSlackDiscoverError('Failed to discover Slack workspaces')
|
||||
setSlackPickerOpen(true)
|
||||
} finally {
|
||||
setSlackDiscovering(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSlackSaveWorkspaces = useCallback(async () => {
|
||||
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected })
|
||||
setSlackEnabled(true)
|
||||
setSlackWorkspaces(selected)
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack enabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to save Slack config:', error)
|
||||
toast.error('Failed to save Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [slackAvailableWorkspaces, slackSelectedUrls])
|
||||
|
||||
const handleSlackDisable = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] })
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack disabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to update Slack config:', error)
|
||||
toast.error('Failed to update Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Gmail (Composio)
|
||||
const refreshGmailStatus = useCallback(async () => {
|
||||
try {
|
||||
setGmailLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' })
|
||||
setGmailConnected(result.isConnected)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Gmail status:', error)
|
||||
setGmailConnected(false)
|
||||
} finally {
|
||||
setGmailLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startGmailConnect = useCallback(async () => {
|
||||
try {
|
||||
setGmailConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Gmail')
|
||||
setGmailConnecting(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Gmail:', error)
|
||||
toast.error('Failed to connect to Gmail')
|
||||
setGmailConnecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConnectGmail = useCallback(async () => {
|
||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configResult.configured) {
|
||||
setComposioApiKeyTarget('gmail')
|
||||
setComposioApiKeyOpen(true)
|
||||
return
|
||||
}
|
||||
await startGmailConnect()
|
||||
}, [startGmailConnect])
|
||||
|
||||
const handleDisconnectGmail = useCallback(async () => {
|
||||
try {
|
||||
setGmailLoading(true)
|
||||
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'gmail' })
|
||||
if (result.success) {
|
||||
setGmailConnected(false)
|
||||
toast.success('Disconnected from Gmail')
|
||||
} else {
|
||||
toast.error('Failed to disconnect from Gmail')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect from Gmail:', error)
|
||||
toast.error('Failed to disconnect from Gmail')
|
||||
} finally {
|
||||
setGmailLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Google Calendar (Composio)
|
||||
const refreshGoogleCalendarStatus = useCallback(async () => {
|
||||
try {
|
||||
setGoogleCalendarLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' })
|
||||
setGoogleCalendarConnected(result.isConnected)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Google Calendar status:', error)
|
||||
setGoogleCalendarConnected(false)
|
||||
} finally {
|
||||
setGoogleCalendarLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startGoogleCalendarConnect = useCallback(async () => {
|
||||
try {
|
||||
setGoogleCalendarConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Google Calendar')
|
||||
setGoogleCalendarConnecting(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Google Calendar:', error)
|
||||
toast.error('Failed to connect to Google Calendar')
|
||||
setGoogleCalendarConnecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConnectGoogleCalendar = useCallback(async () => {
|
||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configResult.configured) {
|
||||
setComposioApiKeyTarget('gmail')
|
||||
setComposioApiKeyOpen(true)
|
||||
return
|
||||
}
|
||||
await startGoogleCalendarConnect()
|
||||
}, [startGoogleCalendarConnect])
|
||||
|
||||
const handleDisconnectGoogleCalendar = useCallback(async () => {
|
||||
try {
|
||||
setGoogleCalendarLoading(true)
|
||||
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'googlecalendar' })
|
||||
if (result.success) {
|
||||
setGoogleCalendarConnected(false)
|
||||
toast.success('Disconnected from Google Calendar')
|
||||
} else {
|
||||
toast.error('Failed to disconnect from Google Calendar')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect from Google Calendar:', error)
|
||||
toast.error('Failed to disconnect from Google Calendar')
|
||||
} finally {
|
||||
setGoogleCalendarLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Composio API key
|
||||
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
|
||||
try {
|
||||
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
||||
setComposioApiKeyOpen(false)
|
||||
toast.success('Composio API key saved')
|
||||
await startGmailConnect()
|
||||
} catch (error) {
|
||||
console.error('Failed to save Composio API key:', error)
|
||||
toast.error('Failed to save API key')
|
||||
}
|
||||
}, [startGmailConnect])
|
||||
|
||||
// OAuth connect/disconnect
|
||||
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, clientId })
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error || (provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`))
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: false }
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error)
|
||||
toast.error(provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`)
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: false }
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
setGoogleClientIdDescription(undefined)
|
||||
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)
|
||||
setGoogleClientIdDescription(undefined)
|
||||
startConnect('google', clientId)
|
||||
}, [startConnect])
|
||||
|
||||
const handleDisconnect = useCallback(async (provider: string) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isLoading: true }
|
||||
}))
|
||||
|
||||
try {
|
||||
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(provider === 'rowboat' ? 'Logged out of Rowboat' : `Disconnected from ${displayName}`)
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: {
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
toast.error(provider === 'rowboat' ? 'Failed to log out of Rowboat' : `Failed to disconnect from ${provider}`)
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isLoading: false }
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect:', error)
|
||||
toast.error(provider === 'rowboat' ? 'Failed to log out of Rowboat' : `Failed to disconnect from ${provider}`)
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isLoading: false }
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Refresh all statuses
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
refreshGranolaConfig()
|
||||
refreshSlackConfig()
|
||||
|
||||
if (useComposioForGoogle) {
|
||||
refreshGmailStatus()
|
||||
}
|
||||
|
||||
if (useComposioForGoogleCalendar) {
|
||||
refreshGoogleCalendarStatus()
|
||||
}
|
||||
|
||||
if (providers.length === 0) return
|
||||
|
||||
const newStates: Record<string, ProviderState> = {}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:getState', null)
|
||||
const config = result.config || {}
|
||||
const statusMap: Record<string, ProviderStatus> = {}
|
||||
|
||||
for (const provider of providers) {
|
||||
const providerConfig = config[provider]
|
||||
newStates[provider] = {
|
||||
isConnected: providerConfig?.connected ?? false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
if (providerConfig?.error) {
|
||||
statusMap[provider] = { error: providerConfig.error }
|
||||
}
|
||||
}
|
||||
|
||||
setProviderStatus(statusMap)
|
||||
} catch (error) {
|
||||
console.error('Failed to check connection statuses:', error)
|
||||
for (const provider of providers) {
|
||||
newStates[provider] = {
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}
|
||||
setProviderStatus({})
|
||||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
|
||||
|
||||
// Refresh when active or providers change
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
refreshAllStatuses()
|
||||
}
|
||||
}, [active, providers, refreshAllStatuses])
|
||||
|
||||
// Listen for OAuth events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
|
||||
const { provider, success } = event
|
||||
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: {
|
||||
isConnected: success,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}))
|
||||
|
||||
if (success) {
|
||||
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
if (provider === 'rowboat') {
|
||||
toast.success('Logged in to Rowboat')
|
||||
} else if (provider === 'google' || provider === 'fireflies-ai') {
|
||||
toast.success(`Connected to ${displayName}`, {
|
||||
description: 'Syncing your data in the background. This may take a few minutes before changes appear.',
|
||||
duration: 8000,
|
||||
})
|
||||
} else {
|
||||
toast.success(`Connected to ${displayName}`)
|
||||
}
|
||||
|
||||
if (provider === 'rowboat') {
|
||||
try {
|
||||
const [googleResult, calendarResult] = await Promise.all([
|
||||
window.ipc.invoke('composio:use-composio-for-google', null),
|
||||
window.ipc.invoke('composio:use-composio-for-google-calendar', null),
|
||||
])
|
||||
setUseComposioForGoogle(googleResult.enabled)
|
||||
setUseComposioForGoogleCalendar(calendarResult.enabled)
|
||||
} catch (err) {
|
||||
console.error('Failed to re-check composio flags:', err)
|
||||
}
|
||||
}
|
||||
|
||||
refreshAllStatuses()
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [refreshAllStatuses])
|
||||
|
||||
// Listen for Composio events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||
const { toolkitSlug, success, error } = event
|
||||
|
||||
if (toolkitSlug === 'gmail') {
|
||||
setGmailConnected(success)
|
||||
setGmailConnecting(false)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Gmail', {
|
||||
description: 'Syncing your emails in the background. This may take a few minutes before changes appear.',
|
||||
duration: 8000,
|
||||
})
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Gmail')
|
||||
}
|
||||
}
|
||||
|
||||
if (toolkitSlug === 'googlecalendar') {
|
||||
setGoogleCalendarConnected(success)
|
||||
setGoogleCalendarConnecting(false)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Google Calendar', {
|
||||
description: 'Syncing your calendar in the background. This may take a few minutes before changes appear.',
|
||||
duration: 8000,
|
||||
})
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Google Calendar')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
const hasProviderError = Object.values(providerStatus).some(
|
||||
(status) => Boolean(status?.error)
|
||||
)
|
||||
|
||||
return {
|
||||
// OAuth providers
|
||||
providers,
|
||||
providersLoading,
|
||||
providerStates,
|
||||
providerStatus,
|
||||
hasProviderError,
|
||||
handleConnect,
|
||||
handleDisconnect,
|
||||
startConnect,
|
||||
|
||||
// Google client ID modal
|
||||
googleClientIdOpen,
|
||||
setGoogleClientIdOpen,
|
||||
googleClientIdDescription,
|
||||
setGoogleClientIdDescription,
|
||||
handleGoogleClientIdSubmit,
|
||||
|
||||
// Granola
|
||||
granolaEnabled,
|
||||
granolaLoading,
|
||||
handleGranolaToggle,
|
||||
|
||||
// Composio API key modal
|
||||
composioApiKeyOpen,
|
||||
setComposioApiKeyOpen,
|
||||
composioApiKeyTarget,
|
||||
setComposioApiKeyTarget,
|
||||
handleComposioApiKeySubmit,
|
||||
|
||||
// Slack
|
||||
slackEnabled,
|
||||
slackLoading,
|
||||
slackWorkspaces,
|
||||
slackAvailableWorkspaces,
|
||||
slackSelectedUrls,
|
||||
setSlackSelectedUrls,
|
||||
slackPickerOpen,
|
||||
setSlackPickerOpen,
|
||||
slackDiscovering,
|
||||
slackDiscoverError,
|
||||
handleSlackEnable,
|
||||
handleSlackSaveWorkspaces,
|
||||
handleSlackDisable,
|
||||
|
||||
// Gmail (Composio)
|
||||
useComposioForGoogle,
|
||||
gmailConnected,
|
||||
gmailLoading,
|
||||
gmailConnecting,
|
||||
handleConnectGmail,
|
||||
handleDisconnectGmail,
|
||||
|
||||
// Google Calendar (Composio)
|
||||
useComposioForGoogleCalendar,
|
||||
googleCalendarConnected,
|
||||
googleCalendarLoading,
|
||||
googleCalendarConnecting,
|
||||
handleConnectGoogleCalendar,
|
||||
handleDisconnectGoogleCalendar,
|
||||
|
||||
// Refresh
|
||||
refreshAllStatuses,
|
||||
}
|
||||
}
|
||||
417
apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts
Normal file
417
apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
|
||||
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
|
||||
|
||||
const DEEPGRAM_PARAMS = new URLSearchParams({
|
||||
model: 'nova-3',
|
||||
encoding: 'linear16',
|
||||
sample_rate: '16000',
|
||||
channels: '2',
|
||||
multichannel: 'true',
|
||||
diarize: 'true',
|
||||
interim_results: 'true',
|
||||
smart_format: 'true',
|
||||
punctuate: 'true',
|
||||
language: 'en',
|
||||
});
|
||||
const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`;
|
||||
|
||||
// RMS threshold: system audio above this = "active" (speakers playing)
|
||||
const SYSTEM_AUDIO_GATE_THRESHOLD = 0.005;
|
||||
|
||||
// Auto-stop after 2 minutes of silence (no transcript from Deepgram)
|
||||
const SILENCE_AUTO_STOP_MS = 2 * 60 * 1000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Headphone detection
|
||||
// ---------------------------------------------------------------------------
|
||||
async function detectHeadphones(): Promise<boolean> {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const outputs = devices.filter(d => d.kind === 'audiooutput');
|
||||
const defaultOutput = outputs.find(d => d.deviceId === 'default');
|
||||
const label = (defaultOutput?.label ?? '').toLowerCase();
|
||||
// Heuristic: built-in speakers won't match these patterns
|
||||
const headphonePatterns = ['headphone', 'airpod', 'earpod', 'earphone', 'earbud', 'bluetooth', 'bt_', 'jabra', 'bose', 'sony wh', 'sony wf'];
|
||||
return headphonePatterns.some(p => label.includes(p));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transcript formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
interface TranscriptEntry {
|
||||
speaker: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CalendarEventMeta {
|
||||
summary?: string
|
||||
start?: { dateTime?: string; date?: string }
|
||||
end?: { dateTime?: string; date?: string }
|
||||
location?: string
|
||||
htmlLink?: string
|
||||
conferenceLink?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
function formatTranscript(entries: TranscriptEntry[], date: string, calendarEvent?: CalendarEventMeta): string {
|
||||
const noteTitle = calendarEvent?.summary || 'Meeting Notes';
|
||||
const lines = [
|
||||
'---',
|
||||
'type: meeting',
|
||||
'source: rowboat',
|
||||
`title: ${noteTitle}`,
|
||||
`date: "${date}"`,
|
||||
];
|
||||
if (calendarEvent) {
|
||||
// Serialize as a JSON string on one line — the frontmatter system
|
||||
// only supports flat key: value pairs, not nested YAML objects.
|
||||
const eventObj: Record<string, string> = {}
|
||||
if (calendarEvent.summary) eventObj.summary = calendarEvent.summary
|
||||
if (calendarEvent.start?.dateTime) eventObj.start = calendarEvent.start.dateTime
|
||||
else if (calendarEvent.start?.date) eventObj.start = calendarEvent.start.date
|
||||
if (calendarEvent.end?.dateTime) eventObj.end = calendarEvent.end.dateTime
|
||||
else if (calendarEvent.end?.date) eventObj.end = calendarEvent.end.date
|
||||
if (calendarEvent.location) eventObj.location = calendarEvent.location
|
||||
if (calendarEvent.htmlLink) eventObj.htmlLink = calendarEvent.htmlLink
|
||||
if (calendarEvent.conferenceLink) eventObj.conferenceLink = calendarEvent.conferenceLink
|
||||
if (calendarEvent.source) eventObj.source = calendarEvent.source
|
||||
lines.push(`calendar_event: '${JSON.stringify(eventObj).replace(/'/g, "''")}'`)
|
||||
}
|
||||
lines.push(
|
||||
'---',
|
||||
'',
|
||||
`# ${noteTitle}`,
|
||||
'',
|
||||
);
|
||||
// Build the raw transcript text
|
||||
const transcriptLines: string[] = [];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) {
|
||||
transcriptLines.push('');
|
||||
}
|
||||
transcriptLines.push(`**${entries[i].speaker}:** ${entries[i].text}`);
|
||||
transcriptLines.push('');
|
||||
}
|
||||
const transcriptText = transcriptLines.join('\n').trim();
|
||||
const transcriptData = JSON.stringify({ transcript: transcriptText });
|
||||
lines.push('```transcript', transcriptData, '```');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
export function useMeetingTranscription(onAutoStop?: () => void) {
|
||||
const { refresh: refreshRowboatAccount } = useRowboatAccount();
|
||||
const [state, setState] = useState<MeetingTranscriptionState>('idle');
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const micStreamRef = useRef<MediaStream | null>(null);
|
||||
const systemStreamRef = useRef<MediaStream | null>(null);
|
||||
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const transcriptRef = useRef<TranscriptEntry[]>([]);
|
||||
const interimRef = useRef<Map<number, { speaker: string; text: string }>>(new Map());
|
||||
const notePathRef = useRef<string>('');
|
||||
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const onAutoStopRef = useRef(onAutoStop);
|
||||
onAutoStopRef.current = onAutoStop;
|
||||
const dateRef = useRef<string>('');
|
||||
const calendarEventRef = useRef<CalendarEventMeta | undefined>(undefined);
|
||||
|
||||
const writeTranscriptToFile = useCallback(async () => {
|
||||
if (!notePathRef.current) return;
|
||||
const entries = [...transcriptRef.current];
|
||||
for (const interim of interimRef.current.values()) {
|
||||
if (!interim.text) continue;
|
||||
if (entries.length > 0 && entries[entries.length - 1].speaker === interim.speaker) {
|
||||
entries[entries.length - 1] = { speaker: interim.speaker, text: entries[entries.length - 1].text + ' ' + interim.text };
|
||||
} else {
|
||||
entries.push({ speaker: interim.speaker, text: interim.text });
|
||||
}
|
||||
}
|
||||
if (entries.length === 0) return;
|
||||
const content = formatTranscript(entries, dateRef.current, calendarEventRef.current);
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePathRef.current,
|
||||
data: content,
|
||||
opts: { encoding: 'utf8' },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[meeting] Failed to write transcript:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleDebouncedWrite = useCallback(() => {
|
||||
if (writeTimerRef.current) clearTimeout(writeTimerRef.current);
|
||||
writeTimerRef.current = setTimeout(() => {
|
||||
void writeTranscriptToFile();
|
||||
}, 1000);
|
||||
}, [writeTranscriptToFile]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (writeTimerRef.current) {
|
||||
clearTimeout(writeTimerRef.current);
|
||||
writeTimerRef.current = null;
|
||||
}
|
||||
if (silenceTimerRef.current) {
|
||||
clearTimeout(silenceTimerRef.current);
|
||||
silenceTimerRef.current = null;
|
||||
}
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
}
|
||||
if (audioCtxRef.current) {
|
||||
audioCtxRef.current.close();
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
if (micStreamRef.current) {
|
||||
micStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
micStreamRef.current = null;
|
||||
}
|
||||
if (systemStreamRef.current) {
|
||||
systemStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
systemStreamRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise<string | null> => {
|
||||
if (state !== 'idle') return null;
|
||||
setState('connecting');
|
||||
|
||||
// Run independent setup steps in parallel for faster startup
|
||||
const [headphoneResult, wsResult, micResult, systemResult] = await Promise.allSettled([
|
||||
// 1. Detect headphones vs speakers
|
||||
detectHeadphones(),
|
||||
// 2. Set up Deepgram WebSocket (account refresh + connect + wait for open)
|
||||
(async () => {
|
||||
const account = await refreshRowboatAccount();
|
||||
let ws: WebSocket;
|
||||
if (
|
||||
account?.signedIn &&
|
||||
account.accessToken &&
|
||||
account.config?.websocketApiUrl
|
||||
) {
|
||||
const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS);
|
||||
console.log('[meeting] Using Rowboat WebSocket');
|
||||
ws = new WebSocket(listenUrl, ['bearer', account.accessToken]);
|
||||
} else {
|
||||
const config = await window.ipc.invoke('voice:getConfig', null);
|
||||
if (!config?.deepgram) {
|
||||
throw new Error('No Deepgram config available');
|
||||
}
|
||||
console.log('[meeting] Using Deepgram API key');
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]);
|
||||
}
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
ws.onopen = () => resolve(true);
|
||||
ws.onerror = () => resolve(false);
|
||||
setTimeout(() => resolve(false), 5000);
|
||||
});
|
||||
if (!ok) throw new Error('WebSocket failed to connect');
|
||||
console.log('[meeting] WebSocket connected');
|
||||
return ws;
|
||||
})(),
|
||||
// 3. Get mic stream
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
}),
|
||||
// 4. Get system audio via getDisplayMedia (loopback)
|
||||
(async () => {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
|
||||
stream.getVideoTracks().forEach(t => t.stop());
|
||||
if (stream.getAudioTracks().length === 0) {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
throw new Error('No audio track from getDisplayMedia');
|
||||
}
|
||||
console.log('[meeting] System audio captured');
|
||||
return stream;
|
||||
})(),
|
||||
]);
|
||||
|
||||
// Check for failures — clean up any successful resources if something failed
|
||||
const failed = wsResult.status === 'rejected'
|
||||
|| micResult.status === 'rejected'
|
||||
|| systemResult.status === 'rejected';
|
||||
|
||||
if (failed) {
|
||||
if (wsResult.status === 'rejected') console.error('[meeting] WebSocket setup failed:', wsResult.reason);
|
||||
if (micResult.status === 'rejected') console.error('[meeting] Microphone access denied:', micResult.reason);
|
||||
if (systemResult.status === 'rejected') console.error('[meeting] System audio access denied:', systemResult.reason);
|
||||
// Clean up any resources that did succeed
|
||||
if (wsResult.status === 'fulfilled') { wsResult.value.close(); }
|
||||
if (micResult.status === 'fulfilled') { micResult.value.getTracks().forEach(t => t.stop()); }
|
||||
if (systemResult.status === 'fulfilled') { systemResult.value.getTracks().forEach(t => t.stop()); }
|
||||
cleanup();
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
|
||||
const usingHeadphones = headphoneResult.status === 'fulfilled' ? headphoneResult.value : false;
|
||||
console.log(`[meeting] Audio output mode: ${usingHeadphones ? 'headphones' : 'speakers'}`);
|
||||
|
||||
const ws = wsResult.value;
|
||||
wsRef.current = ws;
|
||||
|
||||
// Set up WS message handler
|
||||
transcriptRef.current = [];
|
||||
interimRef.current = new Map();
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (!data.channel?.alternatives?.[0]) return;
|
||||
const transcript = data.channel.alternatives[0].transcript;
|
||||
if (!transcript) return;
|
||||
|
||||
// Reset silence auto-stop timer on any transcript
|
||||
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
|
||||
silenceTimerRef.current = setTimeout(() => {
|
||||
console.log('[meeting] 2 minutes of silence — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
}, SILENCE_AUTO_STOP_MS);
|
||||
|
||||
const channelIndex = data.channel_index?.[0] ?? 0;
|
||||
const isMic = channelIndex === 0;
|
||||
|
||||
// Channel 0 = mic = "You", Channel 1 = system audio with diarization
|
||||
let speaker: string;
|
||||
if (isMic) {
|
||||
speaker = 'You';
|
||||
} else {
|
||||
// Use Deepgram diarization speaker ID for system audio channel
|
||||
const words = data.channel.alternatives[0].words;
|
||||
const speakerId = words?.[0]?.speaker;
|
||||
speaker = speakerId != null ? `Speaker ${speakerId}` : 'System audio';
|
||||
}
|
||||
|
||||
if (data.is_final) {
|
||||
interimRef.current.delete(channelIndex);
|
||||
const entries = transcriptRef.current;
|
||||
if (entries.length > 0 && entries[entries.length - 1].speaker === speaker) {
|
||||
entries[entries.length - 1].text += ' ' + transcript;
|
||||
} else {
|
||||
entries.push({ speaker, text: transcript });
|
||||
}
|
||||
} else {
|
||||
interimRef.current.set(channelIndex, { speaker, text: transcript });
|
||||
}
|
||||
scheduleDebouncedWrite();
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[meeting] WebSocket closed');
|
||||
wsRef.current = null;
|
||||
};
|
||||
|
||||
const micStream = micResult.value;
|
||||
micStreamRef.current = micStream;
|
||||
|
||||
const systemStream = systemResult.value;
|
||||
systemStreamRef.current = systemStream;
|
||||
|
||||
// ----- Audio pipeline -----
|
||||
const audioCtx = new AudioContext({ sampleRate: 16000 });
|
||||
audioCtxRef.current = audioCtx;
|
||||
|
||||
const micSource = audioCtx.createMediaStreamSource(micStream);
|
||||
const systemSource = audioCtx.createMediaStreamSource(systemStream);
|
||||
const merger = audioCtx.createChannelMerger(2);
|
||||
|
||||
micSource.connect(merger, 0, 0); // mic → channel 0
|
||||
systemSource.connect(merger, 0, 1); // system audio → channel 1
|
||||
|
||||
const processor = audioCtx.createScriptProcessor(4096, 2, 2);
|
||||
processorRef.current = processor;
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const micRaw = e.inputBuffer.getChannelData(0);
|
||||
const sysRaw = e.inputBuffer.getChannelData(1);
|
||||
|
||||
// Mode 1 (headphones): pass both streams through unmodified
|
||||
// Mode 2 (speakers): gate/mute mic when system audio is active
|
||||
let micOut: Float32Array;
|
||||
if (usingHeadphones) {
|
||||
micOut = micRaw;
|
||||
} else {
|
||||
// Compute system audio RMS to detect activity
|
||||
let sysSum = 0;
|
||||
for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i];
|
||||
const sysRms = Math.sqrt(sysSum / sysRaw.length);
|
||||
|
||||
if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) {
|
||||
// System audio is playing — mute mic to prevent bleed
|
||||
micOut = new Float32Array(micRaw.length); // all zeros
|
||||
} else {
|
||||
// System audio is silent — pass mic through
|
||||
micOut = micRaw;
|
||||
}
|
||||
}
|
||||
|
||||
// Interleave mic (ch0) + system audio (ch1) into stereo int16 PCM
|
||||
const int16 = new Int16Array(micOut.length * 2);
|
||||
for (let i = 0; i < micOut.length; i++) {
|
||||
const s0 = Math.max(-1, Math.min(1, micOut[i]));
|
||||
const s1 = Math.max(-1, Math.min(1, sysRaw[i]));
|
||||
int16[i * 2] = s0 < 0 ? s0 * 0x8000 : s0 * 0x7fff;
|
||||
int16[i * 2 + 1] = s1 < 0 ? s1 * 0x8000 : s1 * 0x7fff;
|
||||
}
|
||||
wsRef.current.send(int16.buffer);
|
||||
};
|
||||
|
||||
merger.connect(processor);
|
||||
processor.connect(audioCtx.destination);
|
||||
|
||||
// Create the note file, organized by date like voice memos
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString();
|
||||
dateRef.current = dateStr;
|
||||
const dateFolder = dateStr.split('T')[0]; // YYYY-MM-DD
|
||||
const timestamp = dateStr.replace(/:/g, '-').replace(/\.\d+Z$/, '');
|
||||
const filename = calendarEvent?.summary
|
||||
? calendarEvent.summary.replace(/[\\/*?:"<>|]/g, '').replace(/\s+/g, '_').substring(0, 100).trim()
|
||||
: `meeting-${timestamp}`;
|
||||
const notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`;
|
||||
notePathRef.current = notePath;
|
||||
calendarEventRef.current = calendarEvent;
|
||||
const initialContent = formatTranscript([], dateStr, calendarEvent);
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
data: initialContent,
|
||||
opts: { encoding: 'utf8', mkdirp: true },
|
||||
});
|
||||
|
||||
setState('recording');
|
||||
return notePath;
|
||||
}, [state, cleanup, scheduleDebouncedWrite, refreshRowboatAccount]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (state !== 'recording') return;
|
||||
setState('stopping');
|
||||
|
||||
cleanup();
|
||||
interimRef.current = new Map();
|
||||
await writeTranscriptToFile();
|
||||
|
||||
setState('idle');
|
||||
}, [state, cleanup, writeTranscriptToFile]);
|
||||
|
||||
return { state, start, stop };
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from '@/lib/toast';
|
||||
import posthog from 'posthog-js';
|
||||
import * as analytics from '@/lib/analytics';
|
||||
|
||||
/**
|
||||
* Hook for managing OAuth connection state for a specific provider
|
||||
|
|
@ -40,6 +42,8 @@ export function useOAuth(provider: string) {
|
|||
setIsLoading(false);
|
||||
|
||||
if (event.success) {
|
||||
analytics.oauthConnected(provider);
|
||||
posthog.people.set({ [`${provider}_connected`]: true });
|
||||
toast(`Successfully connected to ${provider}`, 'success');
|
||||
// Refresh connection status to ensure consistency
|
||||
checkConnection();
|
||||
|
|
@ -75,6 +79,8 @@ export function useOAuth(provider: string) {
|
|||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('oauth:disconnect', { provider });
|
||||
if (result.success) {
|
||||
analytics.oauthDisconnected(provider);
|
||||
posthog.people.set({ [`${provider}_connected`]: false });
|
||||
toast(`Disconnected from ${provider}`, 'success');
|
||||
setIsConnected(false);
|
||||
} else {
|
||||
|
|
|
|||
65
apps/x/apps/renderer/src/hooks/useRowboatAccount.ts
Normal file
65
apps/x/apps/renderer/src/hooks/useRowboatAccount.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { z } from 'zod';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { RowboatApiConfig } from '@x/shared/dist/rowboat-account.js';
|
||||
|
||||
|
||||
interface RowboatAccountState {
|
||||
signedIn: boolean;
|
||||
accessToken: string | null;
|
||||
config: z.infer<typeof RowboatApiConfig> | null;
|
||||
}
|
||||
|
||||
export type RowboatAccountSnapshot = RowboatAccountState;
|
||||
|
||||
const DEFAULT_STATE: RowboatAccountState = {
|
||||
signedIn: false,
|
||||
accessToken: null,
|
||||
config: null,
|
||||
};
|
||||
|
||||
export function useRowboatAccount() {
|
||||
const [state, setState] = useState<RowboatAccountState>(DEFAULT_STATE);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
const refresh = useCallback(async (): Promise<RowboatAccountSnapshot | null> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('account:getRowboat', null);
|
||||
const next: RowboatAccountSnapshot = {
|
||||
signedIn: result.signedIn,
|
||||
accessToken: result.accessToken,
|
||||
config: result.config,
|
||||
};
|
||||
setState(next);
|
||||
return next;
|
||||
} catch (error) {
|
||||
console.error('Failed to load Rowboat account state:', error);
|
||||
setState(DEFAULT_STATE);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (event.provider !== 'rowboat') {
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
});
|
||||
return cleanup;
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
signedIn: state.signedIn,
|
||||
accessToken: state.accessToken,
|
||||
config: state.config,
|
||||
isLoading,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
48
apps/x/apps/renderer/src/hooks/useSmoothedText.ts
Normal file
48
apps/x/apps/renderer/src/hooks/useSmoothedText.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Smoothly reveals streamed text by buffering incoming chunks and releasing
|
||||
* them gradually via requestAnimationFrame, producing the fluid typing effect
|
||||
* seen in apps like Claude and ChatGPT.
|
||||
*/
|
||||
export function useSmoothedText(targetText: string): string {
|
||||
const [displayText, setDisplayText] = useState('')
|
||||
const targetRef = useRef('')
|
||||
const displayLenRef = useRef(0)
|
||||
const rafRef = useRef<number>(0)
|
||||
|
||||
targetRef.current = targetText
|
||||
|
||||
useEffect(() => {
|
||||
// Target cleared → immediately clear display
|
||||
if (!targetText) {
|
||||
displayLenRef.current = 0
|
||||
setDisplayText('')
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const target = targetRef.current
|
||||
if (!target) return
|
||||
|
||||
const currentLen = displayLenRef.current
|
||||
if (currentLen < target.length) {
|
||||
const remaining = target.length - currentLen
|
||||
// Adaptive speed: reveal faster when buffer is large, slower when small
|
||||
const step = Math.max(2, Math.ceil(remaining * 0.18))
|
||||
displayLenRef.current = Math.min(currentLen + step, target.length)
|
||||
setDisplayText(target.slice(0, displayLenRef.current))
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
// When caught up, stop. New useEffect call restarts when more text arrives.
|
||||
}
|
||||
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [targetText])
|
||||
|
||||
return displayText
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
import posthog from 'posthog-js';
|
||||
import * as analytics from '@/lib/analytics';
|
||||
|
||||
export type VoiceState = 'idle' | 'connecting' | 'listening';
|
||||
|
||||
|
|
@ -11,10 +15,16 @@ const DEEPGRAM_PARAMS = new URLSearchParams({
|
|||
smart_format: 'true',
|
||||
punctuate: 'true',
|
||||
language: 'en',
|
||||
endpointing: '100',
|
||||
no_delay: 'true',
|
||||
});
|
||||
const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`;
|
||||
|
||||
// Cache auth details so we don't need IPC round-trips on every mic click
|
||||
let cachedAuth: { type: 'rowboat'; url: string; token: string } | { type: 'local'; apiKey: string } | null = null;
|
||||
|
||||
export function useVoiceMode() {
|
||||
const { refresh: refreshRowboatAccount } = useRowboatAccount();
|
||||
const [state, setState] = useState<VoiceState>('idle');
|
||||
const [interimText, setInterimText] = useState('');
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
|
@ -23,28 +33,54 @@ export function useVoiceMode() {
|
|||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const transcriptBufferRef = useRef('');
|
||||
const interimRef = useRef('');
|
||||
// Buffer audio chunks captured before the WebSocket is ready
|
||||
const audioBufferRef = useRef<ArrayBuffer[]>([]);
|
||||
|
||||
// Connect (or reconnect) the Deepgram WebSocket.
|
||||
// Fetches a fresh token on each connect — temp tokens have short TTL.
|
||||
// Refresh cached auth details (called on warmup, not on mic click)
|
||||
const refreshAuth = useCallback(async () => {
|
||||
const account = await refreshRowboatAccount();
|
||||
if (
|
||||
account?.signedIn &&
|
||||
account.accessToken &&
|
||||
account.config?.websocketApiUrl
|
||||
) {
|
||||
cachedAuth = { type: 'rowboat', url: account.config.websocketApiUrl, token: account.accessToken };
|
||||
} else {
|
||||
const config = await window.ipc.invoke('voice:getConfig', null);
|
||||
if (config?.deepgram) {
|
||||
cachedAuth = { type: 'local', apiKey: config.deepgram.apiKey };
|
||||
}
|
||||
}
|
||||
}, [refreshRowboatAccount]);
|
||||
|
||||
// Create and connect a Deepgram WebSocket using cached auth.
|
||||
// Starts the connection and returns immediately (does not wait for open).
|
||||
const connectWs = useCallback(async () => {
|
||||
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return;
|
||||
|
||||
let ws: WebSocket;
|
||||
// Refresh auth if we don't have it cached yet
|
||||
if (!cachedAuth) {
|
||||
await refreshAuth();
|
||||
}
|
||||
if (!cachedAuth) return;
|
||||
|
||||
// Try signed-in proxy token first (passed as query param for JWTs)
|
||||
const result = await window.ipc.invoke('voice:getDeepgramToken', null);
|
||||
if (result) {
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['bearer', result.token]);
|
||||
let ws: WebSocket;
|
||||
if (cachedAuth.type === 'rowboat') {
|
||||
const listenUrl = buildDeepgramListenUrl(cachedAuth.url, DEEPGRAM_PARAMS);
|
||||
ws = new WebSocket(listenUrl, ['bearer', cachedAuth.token]);
|
||||
} else {
|
||||
// Fall back to local API key (passed as subprotocol)
|
||||
const config = await window.ipc.invoke('voice:getConfig', null);
|
||||
if (!config?.deepgram) return;
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]);
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', cachedAuth.apiKey]);
|
||||
}
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[voice] WebSocket connected');
|
||||
// Flush any buffered audio captured while we were connecting
|
||||
const buffered = audioBufferRef.current;
|
||||
audioBufferRef.current = [];
|
||||
for (const chunk of buffered) {
|
||||
ws.send(chunk);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
|
|
@ -66,13 +102,15 @@ export function useVoiceMode() {
|
|||
|
||||
ws.onerror = () => {
|
||||
console.error('[voice] WebSocket error');
|
||||
// Auth may be stale — clear cache so next attempt refreshes
|
||||
cachedAuth = null;
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[voice] WebSocket closed');
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [refreshAuth]);
|
||||
|
||||
// Stop audio capture and close WS
|
||||
const stopAudioCapture = useCallback(() => {
|
||||
|
|
@ -93,6 +131,7 @@ export function useVoiceMode() {
|
|||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
audioBufferRef.current = [];
|
||||
setInterimText('');
|
||||
transcriptBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
|
|
@ -105,60 +144,50 @@ export function useVoiceMode() {
|
|||
transcriptBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
setInterimText('');
|
||||
audioBufferRef.current = [];
|
||||
|
||||
// If WS isn't connected, connect and wait for it
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
setState('connecting');
|
||||
connectWs();
|
||||
// Wait for WS to be ready (up to 5 seconds)
|
||||
const wsOk = await new Promise<boolean>((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
clearInterval(checkInterval);
|
||||
resolve(true);
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
resolve(false);
|
||||
}, 5000);
|
||||
});
|
||||
if (!wsOk) {
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show listening immediately — don't wait for WebSocket
|
||||
setState('listening');
|
||||
analytics.voiceInputStarted();
|
||||
posthog.people.set_once({ has_used_voice: true });
|
||||
|
||||
// Start mic
|
||||
let stream: MediaStream | null = null;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (err) {
|
||||
console.error('Microphone access denied:', err);
|
||||
// Kick off mic + WebSocket in parallel, don't await WebSocket
|
||||
const [stream] = await Promise.all([
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).catch((err) => {
|
||||
console.error('Microphone access denied:', err);
|
||||
return null;
|
||||
}),
|
||||
connectWs(),
|
||||
]);
|
||||
|
||||
if (!stream) {
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
mediaStreamRef.current = stream;
|
||||
|
||||
// Start audio capture
|
||||
// Start audio capture immediately — buffer if WS isn't open yet
|
||||
const audioCtx = new AudioContext({ sampleRate: 16000 });
|
||||
audioCtxRef.current = audioCtx;
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
const processor = audioCtx.createScriptProcessor(4096, 1, 1);
|
||||
const processor = audioCtx.createScriptProcessor(2048, 1, 1);
|
||||
processorRef.current = processor;
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
const float32 = e.inputBuffer.getChannelData(0);
|
||||
const int16 = new Int16Array(float32.length);
|
||||
for (let i = 0; i < float32.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, float32[i]));
|
||||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||||
}
|
||||
wsRef.current.send(int16.buffer);
|
||||
const buffer = int16.buffer;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(buffer);
|
||||
} else {
|
||||
// WebSocket still connecting — buffer the audio
|
||||
audioBufferRef.current.push(buffer);
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
|
|
@ -181,5 +210,10 @@ export function useVoiceMode() {
|
|||
stopAudioCapture();
|
||||
}, [stopAudioCapture]);
|
||||
|
||||
return { state, interimText, start, submit, cancel };
|
||||
/** Pre-cache auth details so mic click skips IPC round-trips */
|
||||
const warmup = useCallback(() => {
|
||||
refreshAuth().catch(() => {});
|
||||
}, [refreshAuth]);
|
||||
|
||||
return { state, interimText, start, submit, cancel, warmup };
|
||||
}
|
||||
|
|
|
|||
37
apps/x/apps/renderer/src/lib/analytics.ts
Normal file
37
apps/x/apps/renderer/src/lib/analytics.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import posthog from 'posthog-js'
|
||||
|
||||
export function chatSessionCreated(runId: string) {
|
||||
posthog.capture('chat_session_created', { run_id: runId })
|
||||
}
|
||||
|
||||
export function chatMessageSent(props: {
|
||||
voiceInput?: boolean
|
||||
voiceOutput?: string
|
||||
searchEnabled?: boolean
|
||||
}) {
|
||||
posthog.capture('chat_message_sent', {
|
||||
voice_input: props.voiceInput ?? false,
|
||||
voice_output: props.voiceOutput ?? false,
|
||||
search_enabled: props.searchEnabled ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
export function oauthConnected(provider: string) {
|
||||
posthog.capture('oauth_connected', { provider })
|
||||
}
|
||||
|
||||
export function oauthDisconnected(provider: string) {
|
||||
posthog.capture('oauth_disconnected', { provider })
|
||||
}
|
||||
|
||||
export function voiceInputStarted() {
|
||||
posthog.capture('voice_input_started')
|
||||
}
|
||||
|
||||
export function searchExecuted(types: string[]) {
|
||||
posthog.capture('search_executed', { types })
|
||||
}
|
||||
|
||||
export function noteExported(format: string) {
|
||||
posthog.capture('note_exported', { format })
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ToolUIPart } from 'ai'
|
||||
import z from 'zod'
|
||||
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
|
||||
|
||||
export interface MessageAttachment {
|
||||
path: string
|
||||
|
|
@ -46,6 +47,11 @@ export type ChatTabViewState = {
|
|||
permissionResponses: Map<string, PermissionResponse>
|
||||
}
|
||||
|
||||
export type ChatViewportAnchorState = {
|
||||
messageId: string | null
|
||||
requestKey: number
|
||||
}
|
||||
|
||||
export const createEmptyChatTabViewState = (): ChatTabViewState => ({
|
||||
runId: null,
|
||||
conversation: [],
|
||||
|
|
@ -115,35 +121,27 @@ export type WebSearchCardData = {
|
|||
|
||||
export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null => {
|
||||
if (tool.name === 'web-search') {
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
return {
|
||||
query: (input?.query as string) || '',
|
||||
results: (result?.results as WebSearchCardResult[]) || [],
|
||||
}
|
||||
}
|
||||
|
||||
if (tool.name === 'research-search') {
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
const rawResults = (result?.results as Array<{
|
||||
title: string
|
||||
url: string
|
||||
description?: string
|
||||
highlights?: string[]
|
||||
text?: string
|
||||
}>) || []
|
||||
const mapped = rawResults.map((entry) => ({
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
description: entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
|
||||
description: entry.description || entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
|
||||
}))
|
||||
const category = input?.category as string | undefined
|
||||
return {
|
||||
query: (input?.query as string) || '',
|
||||
results: mapped,
|
||||
title: category
|
||||
? `${category.charAt(0).toUpperCase() + category.slice(1)} search`
|
||||
: 'Researched the web',
|
||||
title: (!category || category === 'general')
|
||||
? 'Web search'
|
||||
: `${category.charAt(0).toUpperCase() + category.slice(1)} search`,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,6 +259,142 @@ export const parseAttachedFiles = (content: string): { message: string; files: s
|
|||
return { message: cleanMessage.trim(), files }
|
||||
}
|
||||
|
||||
// Composio connect card data
|
||||
export type ComposioConnectCardData = {
|
||||
toolkitSlug: string
|
||||
toolkitDisplayName: string
|
||||
alreadyConnected: boolean
|
||||
/** When true, the connect card should not be rendered (toolkit was already connected). */
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
|
||||
export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardData | null => {
|
||||
if (tool.name !== 'composio-connect-toolkit') return null
|
||||
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
|
||||
const toolkitSlug = (input?.toolkitSlug as string) || ''
|
||||
const alreadyConnected = result?.alreadyConnected === true
|
||||
|
||||
return {
|
||||
toolkitSlug,
|
||||
toolkitDisplayName: COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug,
|
||||
alreadyConnected,
|
||||
// Don't render a connect card if the toolkit was already connected —
|
||||
// the original card from the first connect call already shows the "Connected" state.
|
||||
hidden: alreadyConnected,
|
||||
}
|
||||
}
|
||||
|
||||
// Human-friendly display names for builtin tools
|
||||
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
'workspace-readFile': 'Reading file',
|
||||
'workspace-writeFile': 'Writing file',
|
||||
'workspace-edit': 'Editing file',
|
||||
'workspace-readdir': 'Reading directory',
|
||||
'workspace-exists': 'Checking path',
|
||||
'workspace-stat': 'Getting file info',
|
||||
'workspace-glob': 'Finding files',
|
||||
'workspace-grep': 'Searching files',
|
||||
'workspace-mkdir': 'Creating directory',
|
||||
'workspace-rename': 'Renaming',
|
||||
'workspace-copy': 'Copying file',
|
||||
'workspace-remove': 'Removing',
|
||||
'workspace-getRoot': 'Getting workspace root',
|
||||
'loadSkill': 'Loading skill',
|
||||
'parseFile': 'Parsing file',
|
||||
'LLMParse': 'Extracting content',
|
||||
'analyzeAgent': 'Analyzing agent',
|
||||
'executeCommand': 'Running command',
|
||||
'addMcpServer': 'Adding MCP server',
|
||||
'listMcpServers': 'Listing MCP servers',
|
||||
'listMcpTools': 'Listing MCP tools',
|
||||
'executeMcpTool': 'Running MCP tool',
|
||||
'web-search': 'Searching the web',
|
||||
'save-to-memory': 'Saving to memory',
|
||||
'app-navigation': 'Navigating app',
|
||||
'composio-list-toolkits': 'Listing integrations',
|
||||
'composio-search-tools': 'Searching tools',
|
||||
'composio-execute-tool': 'Running tool',
|
||||
'composio-connect-toolkit': 'Connecting service',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-friendly display name for a tool call.
|
||||
* For Composio tools, returns a contextual label (e.g., "Found 3 tools for 'send email' in Gmail").
|
||||
* For builtin tools, returns a static friendly name (e.g., "Reading file").
|
||||
* Falls back to the raw tool name if no mapping exists.
|
||||
*/
|
||||
export const getToolDisplayName = (tool: ToolCall): string => {
|
||||
const composioData = getComposioActionCardData(tool)
|
||||
if (composioData) return composioData.label
|
||||
return TOOL_DISPLAY_NAMES[tool.name] || tool.name
|
||||
}
|
||||
|
||||
// Composio action card data (for search, execute, list tools)
|
||||
export type ComposioActionCardData = {
|
||||
actionType: 'search' | 'execute' | 'list'
|
||||
label: string
|
||||
}
|
||||
|
||||
export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardData | null => {
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
|
||||
if (tool.name === 'composio-search-tools') {
|
||||
const query = (input?.query as string) || 'tools'
|
||||
const toolkitSlug = input?.toolkitSlug as string | undefined
|
||||
const toolkit = toolkitSlug ? COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug : null
|
||||
const count = (result?.resultCount as number) ?? null
|
||||
|
||||
let label = `Searching for "${query}"`
|
||||
if (toolkit) label += ` in ${toolkit}`
|
||||
if (count !== null && tool.status === 'completed') {
|
||||
label = count > 0 ? `Found ${count} tool${count !== 1 ? 's' : ''} for "${query}"` : `No tools found for "${query}"`
|
||||
if (toolkit) label += ` in ${toolkit}`
|
||||
}
|
||||
return { actionType: 'search', label }
|
||||
}
|
||||
|
||||
if (tool.name === 'composio-execute-tool') {
|
||||
const toolSlug = (input?.toolSlug as string) || ''
|
||||
const toolkitSlug = (input?.toolkitSlug as string) || ''
|
||||
const toolkit = COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug
|
||||
const successful = result?.successful as boolean | undefined
|
||||
|
||||
// Make the tool slug human-readable: GITHUB_ISSUES_LIST_FOR_REPO → "Issues list for repo"
|
||||
const readableName = toolSlug
|
||||
.replace(/^[A-Z]+_/, '') // Remove toolkit prefix
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/^\w/, c => c.toUpperCase())
|
||||
|
||||
let label = `Running ${readableName}`
|
||||
if (toolkit) label += ` on ${toolkit}`
|
||||
if (tool.status === 'completed') {
|
||||
label = successful === false ? `Failed: ${readableName}` : `${readableName}`
|
||||
if (toolkit) label += ` on ${toolkit}`
|
||||
}
|
||||
return { actionType: 'execute', label }
|
||||
}
|
||||
|
||||
if (tool.name === 'composio-list-toolkits') {
|
||||
const count = (result?.totalCount as number) ?? null
|
||||
const connected = (result?.connectedCount as number) ?? null
|
||||
|
||||
let label = 'Listing available integrations'
|
||||
if (count !== null && tool.status === 'completed') {
|
||||
label = `${count} integrations available`
|
||||
if (connected !== null && connected > 0) label += `, ${connected} connected`
|
||||
}
|
||||
return { actionType: 'list', label }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
||||
const { message } = parseAttachedFiles(content)
|
||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||
|
|
|
|||
10
apps/x/apps/renderer/src/lib/deepgram-listen-url.ts
Normal file
10
apps/x/apps/renderer/src/lib/deepgram-listen-url.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Merge Deepgram query params onto a Rowboat WebSocket base URL from account config.
|
||||
*/
|
||||
export function buildDeepgramListenUrl(baseWsUrl: string, params: URLSearchParams): string {
|
||||
const url = new URL("/deepgram/v1/listen", baseWsUrl);
|
||||
for (const [key, value] of params) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
|
@ -608,6 +608,914 @@
|
|||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-last-run {
|
||||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||
}
|
||||
|
||||
/* Shared block styles (image, embed, chart, table) */
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .calendar-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .email-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .transcript-block-wrapper {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-card,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card,
|
||||
.tiptap-editor .ProseMirror .email-block-card,
|
||||
.tiptap-editor .ProseMirror .email-draft-block-card,
|
||||
.tiptap-editor .ProseMirror .transcript-block-card {
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background-color: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
cursor: default;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .email-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .email-draft-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .transcript-block-card:hover {
|
||||
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card,
|
||||
.tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card,
|
||||
.tiptap-editor .ProseMirror .email-block-wrapper.ProseMirror-selectednode .email-block-card,
|
||||
.tiptap-editor .ProseMirror .email-draft-block-wrapper.ProseMirror-selectednode .email-draft-block-card {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-delete,
|
||||
.tiptap-editor .ProseMirror .calendar-block-delete,
|
||||
.tiptap-editor .ProseMirror .email-block-delete,
|
||||
.tiptap-editor .ProseMirror .email-draft-block-delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete,
|
||||
.tiptap-editor .ProseMirror .email-block-card:hover .email-block-delete,
|
||||
.tiptap-editor .ProseMirror .email-draft-block-card:hover .email-draft-block-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .calendar-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .email-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .email-draft-block-delete:hover {
|
||||
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Image block */
|
||||
.tiptap-editor .ProseMirror .image-block-img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-caption {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Embed block */
|
||||
.tiptap-editor .ProseMirror .embed-block-iframe-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-caption {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Chart block */
|
||||
.tiptap-editor .ProseMirror .chart-block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .chart-block-loading,
|
||||
.tiptap-editor .ProseMirror .chart-block-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
font-size: 13px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .chart-block-error-msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .chart-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Table block */
|
||||
.tiptap-editor .ProseMirror .table-block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table td {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-empty {
|
||||
text-align: center;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
padding: 16px 10px !important;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Calendar block – Google Calendar style */
|
||||
.tiptap-editor .ProseMirror .calendar-block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-loading,
|
||||
.tiptap-editor .ProseMirror .calendar-block-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
font-size: 14px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-date-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-separator {
|
||||
border: none;
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-date-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-date-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-weekday {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
line-height: 1;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-day {
|
||||
font-size: 26px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-day-today {
|
||||
background-color: #1a73e8;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
transition: filter 0.12s ease;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event-clickable:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-event-time {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-split-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
margin-top: 6px;
|
||||
border-radius: 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-split-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-right: none;
|
||||
border-radius: 4px 0 0 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-split-main:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-split-chevron-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-split-chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 6px;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 0 4px 4px 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-split-chevron:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-split-chevron-open {
|
||||
border-radius: 0 4px 0 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-split-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% - 1px);
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
background: #039be5;
|
||||
border: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0 0 4px 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-split-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-split-option:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-join-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .calendar-block-join-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Email block – Gmail style */
|
||||
.tiptap-editor .ProseMirror .email-block-card-gmail {
|
||||
background-color: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-card-gmail:hover {
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
/* Email badge */
|
||||
.tiptap-editor .ProseMirror .email-block-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Summary */
|
||||
.tiptap-editor .ProseMirror .email-block-summary {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Expand button */
|
||||
.tiptap-editor .ProseMirror .email-block-expand-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s ease;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-expand-btn:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-expand-meta {
|
||||
color: color-mix(in srgb, var(--foreground) 35%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-toggle-chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-toggle-chevron-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Email details (expanded) */
|
||||
.tiptap-editor .ProseMirror .email-block-email-details {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-message {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-message-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-sender-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-sender-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-sender-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-sender-date {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-subject-line {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-message-body {
|
||||
font-size: 14px;
|
||||
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-context-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-context-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-context-summary {
|
||||
font-size: 14px;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
line-height: 1.58;
|
||||
white-space: pre-wrap;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Draft section */
|
||||
.tiptap-editor .ProseMirror .email-block-draft-section {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-draft-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-draft-block-body-input {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
color: var(--foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 4px 0;
|
||||
font-family: inherit;
|
||||
line-height: 1.58;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-draft-block-body-input::placeholder {
|
||||
color: color-mix(in srgb, var(--foreground) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.tiptap-editor .ProseMirror .email-block-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-gmail-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
width: fit-content;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-gmail-btn:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-gmail-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-gmail-btn-primary {
|
||||
color: #fff;
|
||||
background: #1a73e8;
|
||||
border-color: #1a73e8;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-gmail-btn-primary:hover:not(:disabled) {
|
||||
background: #1765cc;
|
||||
box-shadow: 0 1px 2px 0 rgba(26, 115, 232, 0.45), 0 1px 3px 1px rgba(26, 115, 232, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .email-block-error,
|
||||
.tiptap-editor .ProseMirror .email-draft-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Transcript block */
|
||||
.tiptap-editor .ProseMirror .transcript-block-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-block-toggle:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-block-chevron {
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-block-chevron-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-block-content {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-entry {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-speaker {
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-text {
|
||||
color: color-mix(in srgb, var(--foreground) 75%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-raw {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .transcript-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Meeting event banner */
|
||||
.meeting-event-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 20px 0;
|
||||
}
|
||||
|
||||
.meeting-event-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.meeting-event-pill:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.meeting-event-chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.meeting-event-chevron-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.meeting-event-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 20px;
|
||||
z-index: 50;
|
||||
min-width: 260px;
|
||||
padding: 10px 12px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: #7ec8c8;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-time {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--foreground);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.meeting-event-dropdown-link:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .tiptap-editor .ProseMirror {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@
|
|||
"ai": "^5.0.133",
|
||||
"awilix": "^12.0.5",
|
||||
"chokidar": "^4.0.3",
|
||||
"cors": "^2.8.6",
|
||||
"cron-parser": "^5.5.0",
|
||||
"express": "^5.2.1",
|
||||
"glob": "^13.0.0",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"isomorphic-git": "^1.29.0",
|
||||
|
|
@ -41,6 +43,8 @@
|
|||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pdf-parse": "^1.1.5"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
|
|||
import { execTool } from "../application/lib/exec-tool.js";
|
||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||
import { CopilotAgent } from "../application/assistant/agent.js";
|
||||
import { buildCopilotAgent } from "../application/assistant/agent.js";
|
||||
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
|
|
@ -30,6 +30,61 @@ import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js";
|
|||
import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
||||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
||||
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
|
||||
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
||||
|
||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||
|
||||
function loadAgentNotesContext(): string | null {
|
||||
const sections: string[] = [];
|
||||
|
||||
const userFile = path.join(AGENT_NOTES_DIR, 'user.md');
|
||||
const prefsFile = path.join(AGENT_NOTES_DIR, 'preferences.md');
|
||||
|
||||
try {
|
||||
if (fs.existsSync(userFile)) {
|
||||
const content = fs.readFileSync(userFile, 'utf-8').trim();
|
||||
if (content) {
|
||||
sections.push(`## About the User\nThese are notes you took about the user in previous chats.\n\n${content}`);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
if (fs.existsSync(prefsFile)) {
|
||||
const content = fs.readFileSync(prefsFile, 'utf-8').trim();
|
||||
if (content) {
|
||||
sections.push(`## User Preferences\nThese are notes you took on their general preferences.\n\n${content}`);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// List other Agent Notes files for on-demand access
|
||||
const otherFiles: string[] = [];
|
||||
const skipFiles = new Set(['user.md', 'preferences.md', 'inbox.md']);
|
||||
try {
|
||||
if (fs.existsSync(AGENT_NOTES_DIR)) {
|
||||
function listMdFiles(dir: string, prefix: string) {
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
listMdFiles(fullPath, `${prefix}${entry}/`);
|
||||
} else if (entry.endsWith('.md') && !skipFiles.has(`${prefix}${entry}`)) {
|
||||
otherFiles.push(`${prefix}${entry}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
listMdFiles(AGENT_NOTES_DIR, '');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (otherFiles.length > 0) {
|
||||
sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using workspace-readFile. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`);
|
||||
}
|
||||
|
||||
if (sections.length === 0) return null;
|
||||
return `# Agent Memory\n\n${sections.join('\n\n')}`;
|
||||
}
|
||||
|
||||
export interface IAgentRuntime {
|
||||
trigger(runId: string): Promise<void>;
|
||||
|
|
@ -314,7 +369,7 @@ function formatLlmStreamError(rawError: unknown): string {
|
|||
|
||||
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||
if (id === "copilot" || id === "rowboatx") {
|
||||
return CopilotAgent;
|
||||
return buildCopilotAgent();
|
||||
}
|
||||
|
||||
if (id === 'note_creation') {
|
||||
|
|
@ -418,6 +473,31 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
return agent;
|
||||
}
|
||||
|
||||
if (id === 'agent_notes_agent') {
|
||||
const agentNotesAgentRaw = getAgentNotesAgentRaw();
|
||||
let agent: z.infer<typeof Agent> = {
|
||||
name: id,
|
||||
instructions: agentNotesAgentRaw,
|
||||
};
|
||||
|
||||
if (agentNotesAgentRaw.startsWith("---")) {
|
||||
const end = agentNotesAgentRaw.indexOf("\n---", 3);
|
||||
if (end !== -1) {
|
||||
const fm = agentNotesAgentRaw.slice(3, end).trim();
|
||||
const content = agentNotesAgentRaw.slice(end + 4).trim();
|
||||
const yaml = parse(fm);
|
||||
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
|
||||
agent = {
|
||||
...agent,
|
||||
...parsed,
|
||||
instructions: content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
return await repo.fetch(id);
|
||||
}
|
||||
|
|
@ -770,17 +850,28 @@ export async function* streamAgent({
|
|||
const tools = await buildTools(agent);
|
||||
|
||||
// set up provider + model
|
||||
const provider = await isSignedIn()
|
||||
const signedIn = await isSignedIn();
|
||||
const provider = signedIn
|
||||
? await getGatewayProvider()
|
||||
: createProvider(modelConfig.provider);
|
||||
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent"];
|
||||
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
|
||||
? modelConfig.knowledgeGraphModel
|
||||
: modelConfig.model;
|
||||
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"];
|
||||
const isKgAgent = knowledgeGraphAgents.includes(state.agentName!);
|
||||
const isInlineTaskAgent = state.agentName === "inline_task_agent";
|
||||
const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model;
|
||||
const defaultKgModel = signedIn ? "gpt-5.4-mini" : defaultModel;
|
||||
const defaultInlineTaskModel = signedIn ? "gpt-5.4" : defaultModel;
|
||||
const modelId = isInlineTaskAgent
|
||||
? defaultInlineTaskModel
|
||||
: (isKgAgent && modelConfig.knowledgeGraphModel)
|
||||
? modelConfig.knowledgeGraphModel
|
||||
: isKgAgent ? defaultKgModel : defaultModel;
|
||||
const model = provider.languageModel(modelId);
|
||||
logger.log(`using model: ${modelId}`);
|
||||
|
||||
let loopCounter = 0;
|
||||
let voiceInput = false;
|
||||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
let searchEnabled = false;
|
||||
while (true) {
|
||||
// Check abort at the top of each iteration
|
||||
signal.throwIfAborted();
|
||||
|
|
@ -894,9 +985,6 @@ export async function* streamAgent({
|
|||
}
|
||||
|
||||
// get any queued user messages
|
||||
let voiceInput = false;
|
||||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
let searchEnabled = false;
|
||||
while (true) {
|
||||
const msg = await messageQueue.dequeue(runId);
|
||||
if (!msg) {
|
||||
|
|
@ -951,20 +1039,27 @@ export async function* streamAgent({
|
|||
timeZoneName: 'short'
|
||||
});
|
||||
let instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
|
||||
// Inject Agent Notes context for copilot
|
||||
if (state.agentName === 'copilot' || state.agentName === 'rowboatx') {
|
||||
const agentNotesContext = loadAgentNotesContext();
|
||||
if (agentNotesContext) {
|
||||
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
||||
}
|
||||
}
|
||||
if (voiceInput) {
|
||||
loopLogger.log('voice input enabled, injecting voice input prompt');
|
||||
instructionsWithDateTime += `\n\n# Voice Input\nThe user's message was transcribed from speech. Be aware that:\n- There may be transcription errors. Silently correct obvious ones (e.g. homophones, misheard words). If an error is genuinely ambiguous, briefly mention your interpretation (e.g. "I'm assuming you meant X").\n- Spoken messages are often long-winded. The user may ramble, repeat themselves, or correct something they said earlier in the same message. Focus on their final intent, not every word verbatim.`;
|
||||
}
|
||||
if (voiceOutput === 'summary') {
|
||||
loopLogger.log('voice output enabled (summary mode), injecting voice output prompt');
|
||||
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY)\nThe user has voice output enabled. You MUST start your response with <voice></voice> tags that provide a spoken summary and guide to your written response. This is NOT optional — every response MUST begin with <voice> tags.\n\nRules:\n1. ALWAYS start your response with one or more <voice> tags. Never skip them.\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n\nExample — if the user asks "what happened in my meeting with Sarah yesterday?":\n<voice>Your meeting with Sarah covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Sarah — March 11\n(Then the full detailed written response follows without any more <voice> tags.)\n\nAny text outside <voice> tags is shown visually but not spoken.`;
|
||||
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY — READ THIS FIRST)\nThe user has voice output enabled. THIS IS YOUR #1 PRIORITY: you MUST start your response with <voice></voice> tags. If your response does not begin with <voice> tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "<voice>".\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all <voice> tags, you may include detailed written content (markdown, tables, code, etc.) that will be shown visually but not spoken.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Alex — March 11\n### Roadmap\n- Agreed to push Q2 launch to April 15...\n(detailed written content continues)\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You have five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.</voice>\n<voice>There's also a warm intro from a VC partner connecting you with someone at a prospective customer.</voice>\n<voice>I've drafted responses for three of them. The details and drafts are below.</voice>\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a pretty packed day — seven meetings starting with standup at 9.</voice>\n<voice>The big ones are your investor call at 11, lunch with a partner from your lead VC at 12:30, and a customer call at 4.</voice>\n<voice>Your only free block for deep work is 2:30 to 4.</voice>\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\n<voice>Done — I've drafted the email to Sam with your latest WAU and churn numbers.</voice>\n<voice>Take a look at the draft below and send it when you're ready.</voice>\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with <voice> tags, the user hears silence. Always speak first, then write.`;
|
||||
} else if (voiceOutput === 'full') {
|
||||
loopLogger.log('voice output enabled (full mode), injecting voice output prompt');
|
||||
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY)\nThe user wants your ENTIRE response spoken aloud. You MUST wrap your full response in <voice></voice> tags. This is NOT optional.\n\nRules:\n1. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n2. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n3. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n4. Every sentence MUST be inside a <voice> tag. Do not leave any content outside <voice> tags.\n\nExample:\n<voice>Your meeting with Sarah covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Sarah will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>`;
|
||||
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY — READ THIS FIRST)\nThe user wants your ENTIRE response spoken aloud. THIS IS YOUR #1 PRIORITY: every single sentence must be wrapped in <voice></voice> tags. If you write anything outside <voice> tags, the user will not hear it — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. The literal first characters of your response must be "<voice>".\n2. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n3. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n4. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n5. EVERY sentence MUST be inside a <voice> tag. Do not leave ANY content outside <voice> tags. If it's not in a <voice> tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Alex will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You've got five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.</voice>\n<voice>There's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.</voice>\n<voice>And someone from a prospective client wants to confirm your API tier before your call this afternoon.</voice>\n<voice>I've drafted replies for three of them — the metrics update, the intro, and the API question.</voice>\n<voice>The only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.</voice>\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a packed day — seven meetings starting with standup at 9.</voice>\n<voice>The highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.</voice>\n<voice>Your only open block for deep work is 2:30 to 4, so plan accordingly.</voice>\n<voice>Oh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.</voice>\n\nExample 4 — User asks: "how are our metrics looking?"\n\n<voice>Metrics are looking strong this week.</voice>\n<voice>You hit 2,573 weekly active users, which is up 12% week over week.</voice>\n<voice>That means you've crossed the 2,500 milestone — worth calling out in your next investor update.</voice>\n<voice>Churn is down to 4.1%, improving month over month.</voice>\n<voice>The trailing 8-week compound growth rate is about 10%.</voice>\n\nREMEMBER: Start with <voice> immediately. No preamble, no markdown before it. Speak first.`;
|
||||
}
|
||||
if (searchEnabled) {
|
||||
loopLogger.log('search enabled, injecting search prompt');
|
||||
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Load the search skill and use web search or research search as needed to answer their query.`;
|
||||
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
|
||||
}
|
||||
let streamError: string | null = null;
|
||||
for await (const event of streamLlm(
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import z from "zod";
|
||||
import { CopilotInstructions } from "./instructions.js";
|
||||
import { buildCopilotInstructions } from "./instructions.js";
|
||||
import { BuiltinTools } from "../lib/builtin-tools.js";
|
||||
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
for (const name of Object.keys(BuiltinTools)) {
|
||||
tools[name] = {
|
||||
type: "builtin",
|
||||
name,
|
||||
/**
|
||||
* Build the CopilotAgent dynamically.
|
||||
* Tools are derived from the current BuiltinTools (which include Composio meta-tools),
|
||||
* and instructions include the live Composio connection status.
|
||||
*/
|
||||
export async function buildCopilotAgent(): Promise<z.infer<typeof Agent>> {
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
for (const name of Object.keys(BuiltinTools)) {
|
||||
tools[name] = { type: "builtin", name };
|
||||
}
|
||||
const instructions = await buildCopilotInstructions();
|
||||
return {
|
||||
name: "rowboatx",
|
||||
description: "Rowboatx copilot",
|
||||
instructions,
|
||||
tools,
|
||||
};
|
||||
}
|
||||
|
||||
export const CopilotAgent: z.infer<typeof Agent> = {
|
||||
name: "rowboatx",
|
||||
description: "Rowboatx copilot",
|
||||
instructions: CopilotInstructions,
|
||||
tools,
|
||||
}
|
||||
|
|
@ -1,9 +1,42 @@
|
|||
import { skillCatalog } from "./skills/index.js";
|
||||
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
||||
import { skillCatalog } from "./skills/index.js"; // eslint-disable-line @typescript-eslint/no-unused-vars -- used in template literal
|
||||
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
|
||||
|
||||
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||
|
||||
/**
|
||||
* Generate dynamic instructions section for Composio integrations.
|
||||
* Lists connected toolkits and explains the meta-tool discovery flow.
|
||||
*/
|
||||
async function getComposioToolsPrompt(): Promise<string> {
|
||||
if (!(await isComposioConfigured())) {
|
||||
return `
|
||||
## Composio Integrations
|
||||
|
||||
**Composio is not configured.** Composio enables integrations with third-party services like Google Sheets, GitHub, Slack, Jira, Notion, LinkedIn, and 20+ others.
|
||||
|
||||
When the user asks to interact with any third-party service (e.g., "connect to Google Sheets", "create a GitHub issue"), do NOT attempt to write code, use shell commands, or load the composio-integration skill. Instead, let the user know that these integrations are available through Composio, and they can enable them by adding their Composio API key in **Settings > Tools Library**. They can get their key from https://app.composio.dev/settings.
|
||||
|
||||
**Exception — Email and Calendar:** For email-related requests (reading emails, sending emails, drafting replies) or calendar-related requests (checking schedule, listing events), do NOT direct the user to Composio. Instead, tell them to connect their email and calendar in **Settings > Connected Accounts**.
|
||||
`;
|
||||
}
|
||||
|
||||
const connectedToolkits = composioAccountsRepo.getConnectedToolkits();
|
||||
const connectedSection = connectedToolkits.length > 0
|
||||
? `**Currently connected:** ${connectedToolkits.map(slug => CURATED_TOOLKITS.find(t => t.slug === slug)?.displayName ?? slug).join(', ')}`
|
||||
: `**No services connected yet.** Load the \`composio-integration\` skill to help the user connect one.`;
|
||||
|
||||
return `
|
||||
## Composio Integrations
|
||||
|
||||
${connectedSection}
|
||||
|
||||
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.
|
||||
`;
|
||||
}
|
||||
|
||||
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
|
||||
|
||||
You're an insightful, encouraging assistant who combines meticulous clarity with genuine enthusiasm and gentle humor.
|
||||
|
|
@ -25,7 +58,9 @@ You're an insightful, encouraging assistant who combines meticulous clarity with
|
|||
## What Rowboat Is
|
||||
Rowboat is an agentic assistant for everyday work - emails, meetings, projects, and people. Users give you tasks like "draft a follow-up email," "prep me for this meeting," or "summarize where we are with this project." You figure out what context you need, pull from emails and meetings, and get it done.
|
||||
|
||||
**Email Drafting:** When users ask you to draft emails or respond to emails, load the \`draft-emails\` skill first. It provides structured guidance for processing emails, gathering context from calendar and knowledge base, and creating well-informed draft responses.
|
||||
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first. Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.
|
||||
|
||||
**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.
|
||||
|
||||
**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
|
||||
|
||||
|
|
@ -37,6 +72,30 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
|
|||
|
||||
**Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always show message drafts to the user before sending.
|
||||
|
||||
## Learning About the User (save-to-memory)
|
||||
|
||||
Use the \`save-to-memory\` tool to note things worth remembering about the user. This builds a persistent profile that helps you serve them better over time. Call it proactively — don't ask permission.
|
||||
|
||||
**When to save:**
|
||||
- User states a preference: "I prefer bullet points"
|
||||
- User corrects your style: "too formal, keep it casual"
|
||||
- You learn about their relationships: "Monica is my co-founder"
|
||||
- You notice workflow patterns: "no meetings before 11am"
|
||||
- User gives explicit instructions: "never use em-dashes"
|
||||
- User has preferences for specific tasks: "pitch decks should be minimal, max 12 slides"
|
||||
|
||||
**Capture context, not blanket rules:**
|
||||
- BAD: "User prefers casual tone" — this loses important context
|
||||
- GOOD: "User prefers casual tone with internal team (Ramnique, Monica) but formal/polished with investors (Brad, Dalton)"
|
||||
- BAD: "User likes short emails" — too vague
|
||||
- GOOD: "User sends very terse 1-2 line emails to co-founder Ramnique, but writes structured 2-3 paragraph emails to investors with proper greetings"
|
||||
- Always note WHO or WHAT CONTEXT a preference applies to. Most preferences are situational, not universal.
|
||||
|
||||
**When NOT to save:**
|
||||
- Ephemeral task details ("draft an email about X")
|
||||
- Things already in the knowledge graph
|
||||
- Information you can derive from reading their notes
|
||||
|
||||
## Memory That Compounds
|
||||
Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic.
|
||||
|
||||
|
|
@ -142,13 +201,9 @@ Always consult this catalog first so you load the right skills before taking act
|
|||
- Never start a response with a heading. Lead with a sentence or two of context first.
|
||||
- Avoid deeply nested bullets. If nesting beyond 2 levels, restructure.
|
||||
|
||||
## MCP Tool Discovery (CRITICAL)
|
||||
## Tool Priority
|
||||
|
||||
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
|
||||
|
||||
When a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, etc.), check MCP tools first using \`listMcpServers\` and \`listMcpTools\`. Load the "mcp-integration" skill for detailed guidance on discovering and executing MCP tools.
|
||||
|
||||
**DO NOT** immediately respond with "I can't access the internet" or "I don't have that capability" without checking MCP tools first!
|
||||
For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.
|
||||
|
||||
## Execution Reminders
|
||||
- Explore existing files and structure before creating new assets.
|
||||
|
|
@ -185,8 +240,10 @@ ${runtimeContextPrompt}
|
|||
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
||||
- \`loadSkill\` - Skill loading
|
||||
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
|
||||
- \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do.
|
||||
- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
|
||||
- \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
|
||||
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
|
||||
- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance.
|
||||
|
||||
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
|
||||
|
||||
|
|
@ -226,3 +283,29 @@ This renders as an interactive card in the UI that the user can click to open th
|
|||
**IMPORTANT:** Only use filepath blocks for files that already exist. The card is clickable and opens the file, so it must point to a real file. If you are proposing a path for a file that hasn't been created yet (e.g., "Shall I save it at ~/Documents/report.pdf?"), use inline code (\`~/Documents/report.pdf\`) instead of a filepath block. Use the filepath block only after the file has been written/created successfully.
|
||||
|
||||
Never output raw file paths in plain text when they could be wrapped in a filepath block — unless the file does not exist yet.`;
|
||||
|
||||
/**
|
||||
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
|
||||
*/
|
||||
let cachedInstructions: string | null = null;
|
||||
|
||||
/**
|
||||
* Invalidate the cached instructions so the next buildCopilotInstructions() call
|
||||
* regenerates the Composio section. Call this after connecting/disconnecting a toolkit.
|
||||
*/
|
||||
export function invalidateCopilotInstructionsCache(): void {
|
||||
cachedInstructions = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build full copilot instructions with dynamic Composio tools section.
|
||||
* Results are cached and reused until invalidated via invalidateCopilotInstructionsCache().
|
||||
*/
|
||||
export async function buildCopilotInstructions(): Promise<string> {
|
||||
if (cachedInstructions !== null) return cachedInstructions;
|
||||
const composioPrompt = await getComposioToolsPrompt();
|
||||
cachedInstructions = composioPrompt
|
||||
? CopilotInstructions + '\n' + composioPrompt
|
||||
: CopilotInstructions;
|
||||
return cachedInstructions;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
export const skill = String.raw`
|
||||
# Composio Integration
|
||||
|
||||
**Load this skill** when the user asks to interact with ANY third-party service — email, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools.
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **composio-list-toolkits** | List all available integrations and their connection status |
|
||||
| **composio-search-tools** | Search for tools by use case; returns slugs and input schemas |
|
||||
| **composio-execute-tool** | Execute a tool by slug with parameters |
|
||||
| **composio-connect-toolkit** | Connect a service via OAuth (opens browser) |
|
||||
|
||||
## Toolkit Slugs (exact values for toolkitSlug parameter)
|
||||
|
||||
| Service | Slug |
|
||||
|---------|------|
|
||||
| Gmail | \`gmail\` |
|
||||
| Google Calendar | \`googlecalendar\` |
|
||||
| Google Sheets | \`googlesheets\` |
|
||||
| Google Docs | \`googledocs\` |
|
||||
| Google Drive | \`googledrive\` |
|
||||
| Slack | \`slack\` |
|
||||
| GitHub | \`github\` |
|
||||
| Notion | \`notion\` |
|
||||
| Linear | \`linear\` |
|
||||
| Jira | \`jira\` |
|
||||
| Asana | \`asana\` |
|
||||
| Trello | \`trello\` |
|
||||
| HubSpot | \`hubspot\` |
|
||||
| Salesforce | \`salesforce\` |
|
||||
| LinkedIn | \`linkedin\` |
|
||||
| X (Twitter) | \`twitter\` |
|
||||
| Reddit | \`reddit\` |
|
||||
| Dropbox | \`dropbox\` |
|
||||
| OneDrive | \`onedrive\` |
|
||||
| Microsoft Outlook | \`microsoft_outlook\` |
|
||||
| Microsoft Teams | \`microsoft_teams\` |
|
||||
| Calendly | \`calendly\` |
|
||||
| Cal.com | \`cal\` |
|
||||
| Intercom | \`intercom\` |
|
||||
| Zendesk | \`zendesk\` |
|
||||
| Airtable | \`airtable\` |
|
||||
|
||||
**IMPORTANT:** Always use these exact slugs. Do NOT guess — e.g., Google Sheets is \`googlesheets\` (no underscore), not \`google_sheets\`.
|
||||
|
||||
## Critical: Check First, Connect Second
|
||||
|
||||
**BEFORE calling composio-connect-toolkit, ALWAYS check if the service is already connected.** The system prompt includes a "Currently connected" list. If the service is there, skip connecting and go straight to search + execute.
|
||||
|
||||
**Flow:**
|
||||
1. Check if the service is in the "Currently connected" list (in the system prompt above)
|
||||
2. If **connected** → go directly to step 4
|
||||
3. If **NOT connected** → call \`composio-connect-toolkit\` once, wait for user to authenticate, then continue
|
||||
4. Call \`composio-search-tools\` with SHORT keyword queries
|
||||
5. Read the \`inputSchema\` from results — note \`required\` fields
|
||||
6. Call \`composio-execute-tool\` with slug, toolkit, and all required arguments
|
||||
|
||||
**NEVER call composio-connect-toolkit for a service that's already connected.** This creates duplicate connect cards in the UI.
|
||||
|
||||
## Search Query Tips
|
||||
|
||||
Use **short keyword queries**, not full sentences:
|
||||
|
||||
| ✅ Good | ❌ Bad |
|
||||
|---------|--------|
|
||||
| "list issues" | "get all open issues for a GitHub repository" |
|
||||
| "send email" | "send an email to someone using Gmail" |
|
||||
| "get profile" | "fetch the authenticated user's profile details" |
|
||||
| "create spreadsheet" | "create a new Google Sheets spreadsheet with data" |
|
||||
|
||||
If the first search returns 0 results, try a different short query (e.g., "issues" instead of "list issues").
|
||||
|
||||
## Passing Arguments
|
||||
|
||||
**ALWAYS include the \`arguments\` field** when calling \`composio-execute-tool\`, even if the tool has no required parameters.
|
||||
|
||||
- Read the \`inputSchema\` from search results carefully
|
||||
- Extract user-provided values into the correct fields (e.g., "rowboatlabs/rowboat" → \`owner: "rowboatlabs", repo: "rowboat"\`)
|
||||
- For tools with empty \`properties: {}\`, pass \`arguments: {}\`
|
||||
- For tools with required fields, pass all of them
|
||||
|
||||
### Example: GitHub Issues
|
||||
|
||||
User says: "Get me the open issues on rowboatlabs/rowboat"
|
||||
|
||||
1. \`composio-search-tools({ query: "list issues", toolkitSlug: "github" })\`
|
||||
→ finds \`GITHUB_ISSUES_LIST_FOR_REPO\` with required: ["owner", "repo"]
|
||||
2. \`composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })\`
|
||||
|
||||
### Example: Gmail Fetch
|
||||
|
||||
User says: "What's my latest email?"
|
||||
|
||||
1. \`composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })\`
|
||||
→ finds \`GMAIL_FETCH_EMAILS\`
|
||||
2. \`composio-execute-tool({ toolSlug: "GMAIL_FETCH_EMAILS", toolkitSlug: "gmail", arguments: { user_id: "me", max_results: 5 } })\`
|
||||
|
||||
### Example: LinkedIn Profile (no-arg tool)
|
||||
|
||||
User says: "Get my LinkedIn profile"
|
||||
|
||||
1. \`composio-search-tools({ query: "get profile", toolkitSlug: "linkedin" })\`
|
||||
→ finds \`LINKEDIN_GET_MY_INFO\` with properties: {}
|
||||
2. \`composio-execute-tool({ toolSlug: "LINKEDIN_GET_MY_INFO", toolkitSlug: "linkedin", arguments: {} })\`
|
||||
|
||||
## Error Recovery
|
||||
|
||||
- **If a tool call fails** (missing fields, 500 error): Fix the arguments and retry IMMEDIATELY. Do NOT stop and narrate the error to the user.
|
||||
- **If search returns 0 results**: Try a different short query. If still 0, the tool may not exist for that service.
|
||||
- **If a tool requires connection**: Call \`composio-connect-toolkit\` once, then retry after connection.
|
||||
|
||||
## Multi-Part Requests
|
||||
|
||||
When the user says "connect X and then do Y" — complete BOTH parts in one turn:
|
||||
1. If X is already connected (check the connected list), skip to Y immediately
|
||||
2. If X needs connecting, connect it, then proceed to Y after authentication
|
||||
|
||||
## Confirmation Rules
|
||||
|
||||
- **Read-only actions** (fetch, list, get, search): Execute without asking
|
||||
- **Mutating actions** (send email, create issue, post, delete): Show the user what you're about to do and confirm before executing
|
||||
- **Connecting a toolkit**: Always safe — just do it when needed
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -173,6 +173,56 @@ Documents are stored in \`~/.rowboat/knowledge/\` with subfolders:
|
|||
- \`Topics/\` - Subject matter notes
|
||||
- Root level for general documents
|
||||
|
||||
## Rich Blocks
|
||||
|
||||
Notes support rich block types beyond standard Markdown. Blocks are fenced code blocks with a language identifier and a JSON body. Use these when the user asks for visual content like charts, tables, images, or embeds.
|
||||
|
||||
### Image Block
|
||||
Displays an image with optional alt text and caption.
|
||||
\`\`\`image
|
||||
{"src": "https://example.com/photo.png", "alt": "Description", "caption": "Optional caption"}
|
||||
\`\`\`
|
||||
- \`src\` (required): URL or relative path to the image
|
||||
- \`alt\` (optional): Alt text
|
||||
- \`caption\` (optional): Caption displayed below the image
|
||||
|
||||
### Embed Block
|
||||
Embeds external content (YouTube videos, Figma designs, or generic links).
|
||||
\`\`\`embed
|
||||
{"provider": "youtube", "url": "https://www.youtube.com/watch?v=VIDEO_ID", "caption": "Video title"}
|
||||
\`\`\`
|
||||
- \`provider\` (required): \`"youtube"\`, \`"figma"\`, or \`"generic"\`
|
||||
- \`url\` (required): Full URL to the content
|
||||
- \`caption\` (optional): Caption displayed below the embed
|
||||
- YouTube and Figma render as iframes; generic shows a link card
|
||||
|
||||
### Chart Block
|
||||
Renders a chart from inline data.
|
||||
\`\`\`chart
|
||||
{"chart": "bar", "title": "Q1 Revenue", "data": [{"month": "Jan", "revenue": 50000}, {"month": "Feb", "revenue": 62000}], "x": "month", "y": "revenue"}
|
||||
\`\`\`
|
||||
- \`chart\` (required): \`"line"\`, \`"bar"\`, or \`"pie"\`
|
||||
- \`title\` (optional): Chart title
|
||||
- \`data\` (optional): Array of objects with the data points
|
||||
- \`source\` (optional): Relative path to a JSON file containing the data array (alternative to inline data)
|
||||
- \`x\` (required): Key name for the x-axis / label field
|
||||
- \`y\` (required): Key name for the y-axis / value field
|
||||
|
||||
### Table Block
|
||||
Renders a styled table from structured data.
|
||||
\`\`\`table
|
||||
{"title": "Team", "columns": ["name", "role"], "data": [{"name": "Alice", "role": "Eng"}, {"name": "Bob", "role": "Design"}]}
|
||||
\`\`\`
|
||||
- \`columns\` (required): Array of column names (determines display order)
|
||||
- \`data\` (required): Array of row objects
|
||||
- \`title\` (optional): Table title
|
||||
|
||||
### Block Guidelines
|
||||
- The JSON must be valid and on a single line (no pretty-printing)
|
||||
- Insert blocks using \`workspace-editFile\` just like any other content
|
||||
- When the user asks for a chart, table, or embed — use blocks rather than plain Markdown tables or image links
|
||||
- When editing a note that already contains blocks, preserve them unless the user asks to change them
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Writing style:**
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ import organizeFilesSkill from "./organize-files/skill.js";
|
|||
import slackSkill from "./slack/skill.js";
|
||||
import backgroundAgentsSkill from "./background-agents/skill.js";
|
||||
import createPresentationsSkill from "./create-presentations/skill.js";
|
||||
import webSearchSkill from "./web-search/skill.js";
|
||||
|
||||
import appNavigationSkill from "./app-navigation/skill.js";
|
||||
import composioIntegrationSkill from "./composio-integration/skill.js";
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||
|
|
@ -85,10 +86,10 @@ const definitions: SkillDefinition[] = [
|
|||
content: mcpIntegrationSkill,
|
||||
},
|
||||
{
|
||||
id: "web-search",
|
||||
title: "Web Search",
|
||||
summary: "Searching the web or researching a topic. Guidance on when to use web-search vs research-search, and how many searches to do.",
|
||||
content: webSearchSkill,
|
||||
id: "composio-integration",
|
||||
title: "Composio Integration",
|
||||
summary: "Interact with third-party services (Gmail, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, etc.) via Composio. Search, connect, and execute tools.",
|
||||
content: composioIntegrationSkill,
|
||||
},
|
||||
{
|
||||
id: "deletion-guardrails",
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ export const skill = String.raw`
|
|||
|
||||
**Load this skill proactively** when a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, time/date, etc.). This skill provides complete guidance on discovering and executing MCP tools.
|
||||
|
||||
## CRITICAL: Always Check MCP Tools First
|
||||
## CRITICAL: Composio Tools Take Priority Over MCP
|
||||
|
||||
**IMPORTANT**: When a user asks for ANY task that might require external capabilities (web search, API calls, data fetching, etc.), ALWAYS:
|
||||
**If a Composio toolkit is connected for the service the user wants (GitHub, Gmail, Slack, etc.), use the \`composio-search-tools\` and \`composio-execute-tool\` builtin tools — NOT MCP tools.** Composio integrations are already authenticated and ready to use. Only fall back to MCP tools if the service is NOT available through Composio.
|
||||
|
||||
## When to Check MCP Tools
|
||||
|
||||
**IMPORTANT**: When a user asks for a task that requires external capabilities AND no Composio toolkit covers it, check MCP tools:
|
||||
|
||||
1. **First check**: Call \`listMcpServers\` to see what's available
|
||||
2. **Then list tools**: Call \`listMcpTools\` on relevant servers
|
||||
|
|
@ -18,14 +22,12 @@ export const skill = String.raw`
|
|||
|
||||
| User Request | Check For | Likely Tool |
|
||||
|--------------|-----------|-------------|
|
||||
| "Search the web/internet" | firecrawl, composio, fetch | \`firecrawl_search\`, \`COMPOSIO_SEARCH_WEB\` |
|
||||
| "Search the web/internet" | firecrawl, fetch | \`firecrawl_search\` |
|
||||
| "Scrape this website" | firecrawl | \`firecrawl_scrape\` |
|
||||
| "Read/write files" | filesystem | \`read_file\`, \`write_file\` |
|
||||
| "Get current time/date" | time | \`get_current_time\` |
|
||||
| "Make HTTP request" | fetch | \`fetch\`, \`post\` |
|
||||
| "GitHub operations" | github | \`create_issue\`, \`search_repos\` |
|
||||
| "Generate audio/speech" | elevenLabs | \`text_to_speech\` |
|
||||
| "Tweet/social media" | twitter, composio | Various social tools |
|
||||
|
||||
## Key concepts
|
||||
- MCP servers expose tools (web scraping, APIs, databases, etc.) declared in \`config/mcp.json\`.
|
||||
|
|
@ -242,7 +244,7 @@ The schema tells you:
|
|||
**Example schema from listMcpTools:**
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "COMPOSIO_SEARCH_WEB",
|
||||
"name": "firecrawl_search",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -263,10 +265,10 @@ The schema tells you:
|
|||
**Correct executeMcpTool call:**
|
||||
\`\`\`json
|
||||
{
|
||||
"serverName": "composio",
|
||||
"toolName": "COMPOSIO_SEARCH_WEB",
|
||||
"serverName": "firecrawl",
|
||||
"toolName": "firecrawl_search",
|
||||
"arguments": {
|
||||
"query": "elon musk latest news"
|
||||
"query": "latest AI news"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
|
@ -274,18 +276,18 @@ The schema tells you:
|
|||
**WRONG - Missing arguments:**
|
||||
\`\`\`json
|
||||
{
|
||||
"serverName": "composio",
|
||||
"toolName": "COMPOSIO_SEARCH_WEB"
|
||||
"serverName": "firecrawl",
|
||||
"toolName": "firecrawl_search"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**WRONG - Wrong parameter name:**
|
||||
\`\`\`json
|
||||
{
|
||||
"serverName": "composio",
|
||||
"toolName": "COMPOSIO_SEARCH_WEB",
|
||||
"serverName": "firecrawl",
|
||||
"toolName": "firecrawl_search",
|
||||
"arguments": {
|
||||
"search": "elon musk" // Wrong! Should be "query"
|
||||
"search": "latest AI news" // Wrong! Should be "query"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import { McpServerDefinition } from "@x/shared/dist/mcp.js";
|
|||
import * as workspace from "../../workspace/workspace.js";
|
||||
import { IAgentsRepo } from "../../agents/repo.js";
|
||||
import { WorkDir } from "../../config/config.js";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
|
||||
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
|
||||
import type { ToolContext } from "./exec-tool.js";
|
||||
import { generateText } from "ai";
|
||||
import { createProvider } from "../../models/models.js";
|
||||
|
|
@ -1026,123 +1029,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
|
||||
// ============================================================================
|
||||
// Web Search (Brave Search API)
|
||||
// Web Search (Exa Search API)
|
||||
// ============================================================================
|
||||
|
||||
'web-search': {
|
||||
description: 'Search the web using Brave Search. Returns web results with titles, URLs, and descriptions.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query'),
|
||||
count: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
|
||||
freshness: z.string().optional().describe('Filter by freshness: pd (past day), pw (past week), pm (past month), py (past year)'),
|
||||
}),
|
||||
isAvailable: async () => {
|
||||
if (await isSignedIn()) return true;
|
||||
try {
|
||||
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
|
||||
const raw = await fs.readFile(braveConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
return !!config.apiKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => {
|
||||
try {
|
||||
const resultCount = Math.min(Math.max(count || 5, 1), 20);
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
count: String(resultCount),
|
||||
});
|
||||
if (freshness) {
|
||||
params.set('freshness', freshness);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
if (await isSignedIn()) {
|
||||
// Use proxy
|
||||
const accessToken = await getAccessToken();
|
||||
response = await fetch(`${API_URL}/v1/search/brave?${params.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Read API key from config
|
||||
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
|
||||
|
||||
let apiKey: string;
|
||||
try {
|
||||
const raw = await fs.readFile(braveConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
apiKey = config.apiKey;
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Brave Search API key not configured. Create ~/.rowboat/config/brave-search.json with { "apiKey": "<your-key>" }',
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Brave Search API key is empty. Set "apiKey" in ~/.rowboat/config/brave-search.json',
|
||||
};
|
||||
}
|
||||
|
||||
response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params.toString()}`, {
|
||||
headers: {
|
||||
'X-Subscription-Token': apiKey,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
success: false,
|
||||
error: `Brave Search API error (${response.status}): ${body}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
|
||||
};
|
||||
|
||||
const results = (data.web?.results || []).map((r: { title?: string; url?: string; description?: string }) => ({
|
||||
title: r.title || '',
|
||||
url: r.url || '',
|
||||
description: r.description || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
query,
|
||||
results,
|
||||
count: results.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Research Search (Exa Search API)
|
||||
// ============================================================================
|
||||
|
||||
'research-search': {
|
||||
description: 'Use this for finding articles, blog posts, papers, companies, people, or exploring a topic in depth. Best for discovery and research where you need quality sources, not a quick fact.',
|
||||
description: 'Search the web for articles, blog posts, papers, companies, people, news, or explore a topic in depth. Returns rich results with full text, highlights, and metadata.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query'),
|
||||
numResults: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
|
||||
category: z.enum(['company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Filter results by category'),
|
||||
category: z.enum(['general', 'company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Search category. Defaults to "general" which searches the entire web. Only use a specific category when the query is clearly about that type (e.g. "research paper" for academic papers, "company" for company info). For everyday queries like weather, restaurants, prices, how-to, etc., use "general" or omit entirely.'),
|
||||
}),
|
||||
isAvailable: async () => {
|
||||
if (await isSignedIn()) return true;
|
||||
|
|
@ -1168,7 +1063,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
highlights: true,
|
||||
},
|
||||
};
|
||||
if (category) {
|
||||
if (category && category !== 'general') {
|
||||
reqBody.category = category;
|
||||
}
|
||||
|
||||
|
|
@ -1260,4 +1155,173 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
}
|
||||
},
|
||||
},
|
||||
'save-to-memory': {
|
||||
description: "Save a note about the user to the agent memory inbox. Use this when you observe something worth remembering — their preferences, communication patterns, relationship context, scheduling habits, or explicit instructions about how they want things done.",
|
||||
inputSchema: z.object({
|
||||
note: z.string().describe("The observation or preference to remember. Be specific and concise."),
|
||||
}),
|
||||
execute: async ({ note }: { note: string }) => {
|
||||
const inboxPath = path.join(WorkDir, 'knowledge', 'Agent Notes', 'inbox.md');
|
||||
const dir = path.dirname(inboxPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const entry = `\n- [${timestamp}] ${note}\n`;
|
||||
|
||||
await fs.appendFile(inboxPath, entry, 'utf-8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Saved to memory: ${note}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// Composio Meta-Tools
|
||||
// ========================================================================
|
||||
|
||||
'composio-list-toolkits': {
|
||||
description: 'List available Composio integrations (Gmail, Slack, GitHub, etc.) and their connection status. Use this to show the user what services they can connect to.',
|
||||
inputSchema: z.object({
|
||||
category: z.enum(['all', 'communication', 'productivity', 'development', 'crm', 'social', 'storage', 'support']).optional()
|
||||
.describe('Filter by category. Defaults to "all".'),
|
||||
}),
|
||||
execute: async ({ category }: { category?: string }) => {
|
||||
const toolkits = CURATED_TOOLKITS
|
||||
.filter(t => !category || category === 'all' || t.category === category)
|
||||
.map(t => ({
|
||||
slug: t.slug,
|
||||
name: t.displayName,
|
||||
category: t.category,
|
||||
isConnected: composioAccountsRepo.isConnected(t.slug),
|
||||
}));
|
||||
|
||||
const connectedCount = toolkits.filter(t => t.isConnected).length;
|
||||
return {
|
||||
toolkits,
|
||||
connectedCount,
|
||||
totalCount: toolkits.length,
|
||||
};
|
||||
},
|
||||
isAvailable: async () => isComposioConfigured(),
|
||||
},
|
||||
|
||||
'composio-search-tools': {
|
||||
description: 'Search for Composio tools by use case across connected services. Returns tool slugs, descriptions, and input schemas so you can call composio-execute-tool with the right parameters. Example: search "send email" to find Gmail tools, "create issue" to find GitHub/Jira tools.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Natural language description of what you want to do (e.g., "send an email", "create a GitHub issue", "schedule a meeting")'),
|
||||
toolkitSlug: z.string().optional().describe('Optional: limit search to a specific toolkit (e.g., "gmail", "github")'),
|
||||
}),
|
||||
execute: async ({ query, toolkitSlug }: { query: string; toolkitSlug?: string }) => {
|
||||
try {
|
||||
const toolkitFilter = toolkitSlug ? [toolkitSlug] : undefined;
|
||||
const result = await searchComposioTools(query, toolkitFilter);
|
||||
|
||||
// Filter to curated toolkits only (skip if a specific toolkit was requested —
|
||||
// the API already filtered server-side)
|
||||
const filtered = toolkitSlug
|
||||
? result.items
|
||||
: result.items.filter(t => CURATED_TOOLKIT_SLUGS.has(t.toolkitSlug));
|
||||
|
||||
// Annotate with connection status
|
||||
const tools = filtered.map(t => ({
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
toolkitSlug: t.toolkitSlug,
|
||||
isConnected: composioAccountsRepo.isConnected(t.toolkitSlug),
|
||||
inputSchema: t.inputParameters,
|
||||
}));
|
||||
|
||||
return {
|
||||
tools,
|
||||
resultCount: tools.length,
|
||||
hint: tools.some(t => !t.isConnected)
|
||||
? 'Some tools require connecting the toolkit first. Use composio-connect-toolkit to help the user authenticate.'
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { tools: [], resultCount: 0, error: message };
|
||||
}
|
||||
},
|
||||
isAvailable: async () => isComposioConfigured(),
|
||||
},
|
||||
|
||||
'composio-execute-tool': {
|
||||
description: 'Execute a Composio tool by its slug. You MUST pass the arguments field with all required parameters from the search results inputSchema. Example: composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })',
|
||||
inputSchema: z.object({
|
||||
toolSlug: z.string().describe('EXACT tool slug from search results (e.g., "GITHUB_ISSUES_LIST_FOR_REPO"). Copy it exactly — do not modify it.'),
|
||||
toolkitSlug: z.string().describe('The toolkit slug (e.g., "gmail", "github")'),
|
||||
arguments: z.record(z.string(), z.unknown()).describe('REQUIRED: Tool input parameters as key-value pairs. Get the required fields from the inputSchema returned by composio-search-tools. Never omit this.'),
|
||||
}),
|
||||
execute: async ({ toolSlug, toolkitSlug, arguments: args }: { toolSlug: string; toolkitSlug: string; arguments?: Record<string, unknown> }) => {
|
||||
// Default arguments to {} if the LLM omits the field entirely
|
||||
const toolArgs = args ?? {};
|
||||
|
||||
// Check connection
|
||||
const account = composioAccountsRepo.getAccount(toolkitSlug);
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return {
|
||||
successful: false,
|
||||
data: null,
|
||||
error: `Toolkit "${toolkitSlug}" is not connected. Use composio-connect-toolkit to help the user connect it first.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await executeComposioAction(toolSlug, {
|
||||
connected_account_id: account.id,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: toolArgs,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Composio] Tool execution failed for ${toolSlug}:`, message);
|
||||
return {
|
||||
successful: false,
|
||||
data: null,
|
||||
error: `Failed to execute ${toolSlug}: ${message}. If fields are missing, check the inputSchema and retry with the correct arguments.`,
|
||||
};
|
||||
}
|
||||
},
|
||||
isAvailable: async () => isComposioConfigured(),
|
||||
},
|
||||
|
||||
'composio-connect-toolkit': {
|
||||
description: 'Connect a Composio service (Gmail, Slack, GitHub, etc.) via OAuth. Shows a connect card for the user to authenticate.',
|
||||
inputSchema: z.object({
|
||||
toolkitSlug: z.string().describe('The toolkit slug to connect (e.g., "gmail", "github", "slack", "notion")'),
|
||||
}),
|
||||
execute: async ({ toolkitSlug }: { toolkitSlug: string }) => {
|
||||
// Validate against curated list
|
||||
if (!CURATED_TOOLKIT_SLUGS.has(toolkitSlug)) {
|
||||
const available = CURATED_TOOLKITS.map(t => `${t.slug} (${t.displayName})`).join(', ');
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown toolkit "${toolkitSlug}". Available toolkits: ${available}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if already connected
|
||||
if (composioAccountsRepo.isConnected(toolkitSlug)) {
|
||||
return {
|
||||
success: true,
|
||||
message: `${toolkitSlug} is already connected. You can search for and execute its tools.`,
|
||||
alreadyConnected: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Return signal — the UI renders a ComposioConnectCard with a Connect button.
|
||||
// OAuth only starts when the user clicks that button.
|
||||
const toolkit = CURATED_TOOLKITS.find(t => t.slug === toolkitSlug);
|
||||
return {
|
||||
success: true,
|
||||
message: `Please connect ${toolkit?.displayName ?? toolkitSlug} to continue.`,
|
||||
};
|
||||
},
|
||||
isAvailable: async () => isComposioConfigured(),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from 'zod';
|
||||
import { SUPABASE_PROJECT_URL } from '../config/env.js';
|
||||
import { getRowboatConfig } from '../config/rowboat.js';
|
||||
|
||||
/**
|
||||
* Discovery configuration - how to get OAuth endpoints
|
||||
|
|
@ -55,7 +55,7 @@ const providerConfigs: ProviderConfig = {
|
|||
rowboat: {
|
||||
discovery: {
|
||||
mode: 'issuer',
|
||||
issuer: `${SUPABASE_PROJECT_URL}/auth/v1/.well-known/oauth-authorization-server`,
|
||||
issuer: "TBD",
|
||||
},
|
||||
client: {
|
||||
mode: 'dcr',
|
||||
|
|
@ -98,21 +98,21 @@ const providerConfigs: ProviderConfig = {
|
|||
/**
|
||||
* Get provider configuration by name
|
||||
*/
|
||||
export function getProviderConfig(providerName: string): ProviderConfigEntry {
|
||||
export async function getProviderConfig(providerName: string): Promise<ProviderConfigEntry> {
|
||||
const config = providerConfigs[providerName];
|
||||
if (!config) {
|
||||
throw new Error(`Unknown OAuth provider: ${providerName}`);
|
||||
}
|
||||
if (providerName === 'rowboat') {
|
||||
const rowboatConfig = await getRowboatConfig();
|
||||
config.discovery = {
|
||||
mode: 'issuer',
|
||||
issuer: `${rowboatConfig.supabaseUrl}/auth/v1/.well-known/oauth-authorization-server`,
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all provider configurations
|
||||
*/
|
||||
export function getAllProviderConfigs(): ProviderConfig {
|
||||
return providerConfigs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all configured OAuth providers
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export async function getAccessToken(): Promise<string> {
|
|||
throw new Error('Rowboat token expired and no refresh token available. Please sign in again.');
|
||||
}
|
||||
|
||||
const providerConfig = getProviderConfig('rowboat');
|
||||
const providerConfig = await getProviderConfig('rowboat');
|
||||
if (providerConfig.discovery.mode !== 'issuer') {
|
||||
throw new Error('Rowboat provider requires issuer discovery mode');
|
||||
}
|
||||
|
|
|
|||
43
apps/x/packages/core/src/billing/billing.ts
Normal file
43
apps/x/packages/core/src/billing/billing.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { getAccessToken } from '../auth/tokens.js';
|
||||
import { API_URL } from '../config/env.js';
|
||||
|
||||
export interface BillingInfo {
|
||||
userEmail: string | null;
|
||||
userId: string | null;
|
||||
subscriptionPlan: string | null;
|
||||
subscriptionStatus: string | null;
|
||||
sanctionedCredits: number;
|
||||
availableCredits: number;
|
||||
}
|
||||
|
||||
export async function getBillingInfo(): Promise<BillingInfo> {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await fetch(`${API_URL}/v1/me`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Billing API failed: ${response.status}`);
|
||||
}
|
||||
const body = await response.json() as {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
billing: {
|
||||
plan: string | null;
|
||||
status: string | null;
|
||||
usage: {
|
||||
sanctionedCredits: number;
|
||||
availableCredits: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
return {
|
||||
userEmail: body.user.email ?? null,
|
||||
userId: body.user.id ?? null,
|
||||
subscriptionPlan: body.billing.plan,
|
||||
subscriptionStatus: body.billing.status,
|
||||
sanctionedCredits: body.billing.usage.sanctionedCredits,
|
||||
availableCredits: body.billing.usage.availableCredits,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Composio } from "@composio/core";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import {
|
||||
ZAuthConfig,
|
||||
|
|
@ -12,33 +11,37 @@ import {
|
|||
ZCreateConnectedAccountResponse,
|
||||
ZDeleteOperationResponse,
|
||||
ZErrorResponse,
|
||||
ZExecuteActionRequest,
|
||||
ZExecuteActionResponse,
|
||||
ZListResponse,
|
||||
ZSearchResultTool,
|
||||
ZToolkit,
|
||||
type NormalizedToolResult,
|
||||
} from "./types.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import { getAccessToken } from "../auth/tokens.js";
|
||||
import { API_URL } from "../config/env.js";
|
||||
|
||||
const BASE_URL = 'https://backend.composio.dev/api/v3';
|
||||
const COMPOSIO_BASE_URL = 'https://backend.composio.dev/api/v3';
|
||||
const CONFIG_FILE = path.join(WorkDir, 'config', 'composio.json');
|
||||
|
||||
// Composio SDK client (lazily initialized)
|
||||
let composioClient: Composio | null = null;
|
||||
|
||||
function getComposioClient(): Composio {
|
||||
if (composioClient) {
|
||||
return composioClient;
|
||||
async function getBaseUrl(): Promise<string> {
|
||||
if (await isSignedIn()) {
|
||||
return `${API_URL}/v1/composio`;
|
||||
}
|
||||
return COMPOSIO_BASE_URL;
|
||||
}
|
||||
|
||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
if (await isSignedIn()) {
|
||||
const token = await getAccessToken();
|
||||
return { 'Authorization': `Bearer ${token}` };
|
||||
}
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error('Composio API key not configured');
|
||||
}
|
||||
|
||||
composioClient = new Composio({ apiKey });
|
||||
return composioClient;
|
||||
}
|
||||
|
||||
function resetComposioClient(): void {
|
||||
composioClient = null;
|
||||
return { 'x-api-key': apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -46,6 +49,8 @@ function resetComposioClient(): void {
|
|||
*/
|
||||
const ZComposioConfig = z.object({
|
||||
apiKey: z.string().optional(),
|
||||
use_composio_for_google: z.boolean().optional(),
|
||||
use_composio_for_google_calendar: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type ComposioConfig = z.infer<typeof ZComposioConfig>;
|
||||
|
|
@ -68,7 +73,7 @@ function loadConfig(): ComposioConfig {
|
|||
/**
|
||||
* Save Composio configuration
|
||||
*/
|
||||
export function saveConfig(config: ComposioConfig): void {
|
||||
function saveConfig(config: ComposioConfig): void {
|
||||
const dir = path.dirname(CONFIG_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
|
@ -91,38 +96,58 @@ export function setApiKey(apiKey: string): void {
|
|||
const config = loadConfig();
|
||||
config.apiKey = apiKey;
|
||||
saveConfig(config);
|
||||
resetComposioClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio is configured
|
||||
*/
|
||||
export function isConfigured(): boolean {
|
||||
export async function isConfigured(): Promise<boolean> {
|
||||
if (await isSignedIn()) return true;
|
||||
return !!getApiKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio should be used for Google services (Gmail, etc.)
|
||||
*/
|
||||
export async function useComposioForGoogle(): Promise<boolean> {
|
||||
if (await isSignedIn()) return true;
|
||||
const config = loadConfig();
|
||||
return config.use_composio_for_google === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio should be used for Google Calendar
|
||||
*/
|
||||
export async function useComposioForGoogleCalendar(): Promise<boolean> {
|
||||
if (await isSignedIn()) return true;
|
||||
const config = loadConfig();
|
||||
return config.use_composio_for_google_calendar === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API call to Composio
|
||||
*/
|
||||
export async function composioApiCall<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
url: string,
|
||||
path: string,
|
||||
params: Record<string, string> = {},
|
||||
options: RequestInit = {},
|
||||
): Promise<z.infer<T>> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error('Composio API key not configured');
|
||||
}
|
||||
const authHeaders = await getAuthHeaders();
|
||||
const baseURL = await getBaseUrl();
|
||||
const url = new URL(`${baseURL}${path}`);
|
||||
|
||||
console.log(`[Composio] ${options.method || 'GET'} ${url}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
"x-api-key": apiKey,
|
||||
...authHeaders,
|
||||
...(options.method === 'POST' ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
});
|
||||
|
|
@ -143,7 +168,15 @@ export async function composioApiCall<T extends z.ZodTypeAny>(
|
|||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Composio API error: ${response.status} ${response.statusText}`);
|
||||
// Try to extract a human-readable message from the JSON body
|
||||
let detail = '';
|
||||
try {
|
||||
const body = JSON.parse(rawText);
|
||||
if (typeof body?.error === 'string') detail = body.error;
|
||||
else if (typeof body?.message === 'string') detail = body.message;
|
||||
} catch { /* body isn't JSON or has no message field */ }
|
||||
const suffix = detail ? `: ${detail}` : '';
|
||||
throw new Error(`Composio API error: ${response.status} ${response.statusText}${suffix}`);
|
||||
}
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
|
|
@ -158,7 +191,7 @@ export async function composioApiCall<T extends z.ZodTypeAny>(
|
|||
throw new Error(`Failed to parse response: ${message}`);
|
||||
}
|
||||
|
||||
if (typeof data === 'object' && data !== null && 'error' in data) {
|
||||
if (typeof data === 'object' && data !== null && 'error' in data && data.error !== null && typeof data.error === 'object') {
|
||||
const parsedError = ZErrorResponse.parse(data);
|
||||
throw new Error(`Composio error (${parsedError.error.error_code}): ${parsedError.error.message}`);
|
||||
}
|
||||
|
|
@ -174,47 +207,20 @@ export async function composioApiCall<T extends z.ZodTypeAny>(
|
|||
* List available toolkits
|
||||
*/
|
||||
export async function listToolkits(cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>> {
|
||||
const url = new URL(`${BASE_URL}/toolkits`);
|
||||
url.searchParams.set("sort_by", "usage");
|
||||
const params: Record<string, string> = {
|
||||
sort_by: "usage",
|
||||
};
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
params.cursor = cursor;
|
||||
}
|
||||
return composioApiCall(ZListResponse(ZToolkit), url.toString());
|
||||
return composioApiCall(ZListResponse(ZToolkit), "/toolkits", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific toolkit
|
||||
*/
|
||||
export async function getToolkit(toolkitSlug: string): Promise<z.infer<typeof ZToolkit>> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error('Composio API key not configured');
|
||||
}
|
||||
|
||||
const url = `${BASE_URL}/toolkits/${toolkitSlug}`;
|
||||
console.log(`[Composio] GET ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { "x-api-key": apiKey },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch toolkit: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const no_auth = data.composio_managed_auth_schemes?.includes('NO_AUTH') ||
|
||||
data.auth_config_details?.some((config: { mode: string }) => config.mode === 'NO_AUTH') ||
|
||||
false;
|
||||
|
||||
return ZToolkit.parse({
|
||||
...data,
|
||||
no_auth,
|
||||
meta: data.meta || { description: '', logo: '', tools_count: 0, triggers_count: 0 },
|
||||
auth_schemes: data.auth_schemes || [],
|
||||
composio_managed_auth_schemes: data.composio_managed_auth_schemes || [],
|
||||
});
|
||||
return composioApiCall(ZToolkit, `/toolkits/${toolkitSlug}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -225,15 +231,16 @@ export async function listAuthConfigs(
|
|||
cursor: string | null = null,
|
||||
managedOnly: boolean = false
|
||||
): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZAuthConfig>>>> {
|
||||
const url = new URL(`${BASE_URL}/auth_configs`);
|
||||
url.searchParams.set("toolkit_slug", toolkitSlug);
|
||||
const params: Record<string, string> = {
|
||||
toolkit_slug: toolkitSlug,
|
||||
};
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
params.cursor = cursor;
|
||||
}
|
||||
if (managedOnly) {
|
||||
url.searchParams.set("is_composio_managed", "true");
|
||||
params.is_composio_managed = "true";
|
||||
}
|
||||
return composioApiCall(ZListResponse(ZAuthConfig), url.toString());
|
||||
return composioApiCall(ZListResponse(ZAuthConfig), "/auth_configs", params);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -242,31 +249,19 @@ export async function listAuthConfigs(
|
|||
export async function createAuthConfig(
|
||||
request: z.infer<typeof ZCreateAuthConfigRequest>
|
||||
): Promise<z.infer<typeof ZCreateAuthConfigResponse>> {
|
||||
const url = new URL(`${BASE_URL}/auth_configs`);
|
||||
return composioApiCall(ZCreateAuthConfigResponse, url.toString(), {
|
||||
return composioApiCall(ZCreateAuthConfigResponse, "/auth_configs", {}, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an auth config
|
||||
*/
|
||||
export async function deleteAuthConfig(authConfigId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
|
||||
const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`);
|
||||
return composioApiCall(ZDeleteOperationResponse, url.toString(), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connected account
|
||||
*/
|
||||
export async function createConnectedAccount(
|
||||
request: z.infer<typeof ZCreateConnectedAccountRequest>
|
||||
): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> {
|
||||
const url = new URL(`${BASE_URL}/connected_accounts`);
|
||||
return composioApiCall(ZCreateConnectedAccountResponse, url.toString(), {
|
||||
return composioApiCall(ZCreateConnectedAccountResponse, "/connected_accounts", {}, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
|
@ -276,84 +271,63 @@ export async function createConnectedAccount(
|
|||
* Get a connected account
|
||||
*/
|
||||
export async function getConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZConnectedAccount>> {
|
||||
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
|
||||
return composioApiCall(ZConnectedAccount, url.toString());
|
||||
return composioApiCall(ZConnectedAccount, `/connected_accounts/${connectedAccountId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a connected account
|
||||
*/
|
||||
export async function deleteConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
|
||||
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
|
||||
return composioApiCall(ZDeleteOperationResponse, url.toString(), {
|
||||
return composioApiCall(ZDeleteOperationResponse, `/connected_accounts/${connectedAccountId}`, {}, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List available tools for a toolkit
|
||||
* Search for tools across all toolkits (or optionally filtered by specific toolkit slugs).
|
||||
* Returns tools with full input_parameters so the agent knows what params to pass.
|
||||
*
|
||||
* Uses a limit of 50 (not 15) to avoid the curated-filter-after-limit problem where
|
||||
* in-scope results at position 16+ would be discarded if earlier results are out-of-scope.
|
||||
*/
|
||||
export async function listToolkitTools(
|
||||
toolkitSlug: string,
|
||||
searchQuery: string | null = null,
|
||||
): Promise<{ items: Array<{ slug: string; name: string; description: string }> }> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error('Composio API key not configured');
|
||||
}
|
||||
|
||||
const url = new URL(`${BASE_URL}/tools`);
|
||||
url.searchParams.set('toolkit_slug', toolkitSlug);
|
||||
url.searchParams.set('limit', '200');
|
||||
if (searchQuery) {
|
||||
url.searchParams.set('search', searchQuery);
|
||||
}
|
||||
|
||||
console.log(`[Composio] Listing tools for toolkit: ${toolkitSlug}`);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { "x-api-key": apiKey },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list tools: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { items?: Array<Record<string, unknown>> };
|
||||
|
||||
return {
|
||||
items: (data.items || []).map((item) => ({
|
||||
slug: String(item.slug ?? ''),
|
||||
name: String(item.name ?? ''),
|
||||
description: String(item.description ?? ''),
|
||||
})),
|
||||
export async function searchTools(
|
||||
searchQuery: string,
|
||||
toolkitSlugs?: string[],
|
||||
): Promise<{ items: NormalizedToolResult[] }> {
|
||||
const params: Record<string, string> = {
|
||||
query: searchQuery,
|
||||
limit: '50',
|
||||
};
|
||||
if (toolkitSlugs && toolkitSlugs.length === 1) {
|
||||
params.toolkit_slug = toolkitSlugs[0];
|
||||
}
|
||||
|
||||
const result = await composioApiCall(ZListResponse(ZSearchResultTool), "/tools", params);
|
||||
|
||||
const items: NormalizedToolResult[] = result.items.map((item) => ({
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
toolkitSlug: item.toolkit.slug,
|
||||
inputParameters: {
|
||||
type: 'object' as const,
|
||||
properties: item.input_parameters?.properties ?? {},
|
||||
required: item.input_parameters?.required,
|
||||
},
|
||||
}));
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool action using Composio SDK
|
||||
* Execute a tool action
|
||||
*/
|
||||
export async function executeAction(
|
||||
actionSlug: string,
|
||||
connectedAccountId: string,
|
||||
input: Record<string, unknown>
|
||||
request: z.infer<typeof ZExecuteActionRequest>
|
||||
): Promise<z.infer<typeof ZExecuteActionResponse>> {
|
||||
console.log(`[Composio] Executing action: ${actionSlug} (account: ${connectedAccountId})`);
|
||||
|
||||
try {
|
||||
const client = getComposioClient();
|
||||
const result = await client.tools.execute(actionSlug, {
|
||||
userId: 'rowboat-user',
|
||||
arguments: input,
|
||||
connectedAccountId,
|
||||
dangerouslySkipVersionCheck: true,
|
||||
});
|
||||
|
||||
console.log(`[Composio] Action completed successfully`);
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
console.error(`[Composio] Action execution failed:`, JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}), 2));
|
||||
const message = error instanceof Error ? error.message : (typeof error === 'object' ? JSON.stringify(error) : 'Unknown error');
|
||||
return { success: false, data: null, error: message };
|
||||
}
|
||||
return composioApiCall(ZExecuteActionResponse, `/tools/execute/${actionSlug}`, {}, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { z } from "zod";
|
||||
import { ZToolkitMeta as ZSharedToolkitMeta, ZToolkitItem } from "@x/shared/dist/composio.js";
|
||||
|
||||
// Re-export the shared toolkit schemas so existing imports continue to work
|
||||
export const ZToolkitMeta = ZSharedToolkitMeta;
|
||||
|
||||
/**
|
||||
* Composio authentication schemes
|
||||
|
|
@ -29,26 +33,9 @@ export const ZConnectedAccountStatus = z.enum([
|
|||
]);
|
||||
|
||||
/**
|
||||
* Toolkit metadata
|
||||
* Toolkit schema — same shape as ZToolkitItem from shared, re-exported for convenience.
|
||||
*/
|
||||
export const ZToolkitMeta = z.object({
|
||||
description: z.string(),
|
||||
logo: z.string(),
|
||||
tools_count: z.number(),
|
||||
triggers_count: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Toolkit schema
|
||||
*/
|
||||
export const ZToolkit = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
meta: ZToolkitMeta,
|
||||
no_auth: z.boolean(),
|
||||
auth_schemes: z.array(ZAuthScheme),
|
||||
composio_managed_auth_schemes: z.array(ZAuthScheme),
|
||||
});
|
||||
export const ZToolkit = ZToolkitItem;
|
||||
|
||||
/**
|
||||
* Tool schema
|
||||
|
|
@ -68,7 +55,7 @@ export const ZTool = z.object({
|
|||
required: z.array(z.string()).optional(),
|
||||
additionalProperties: z.boolean().optional(),
|
||||
}),
|
||||
no_auth: z.boolean(),
|
||||
no_auth: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -147,7 +134,7 @@ export const ZCreateConnectedAccountRequest = z.object({
|
|||
*/
|
||||
export const ZCreateConnectedAccountResponse = z.object({
|
||||
id: z.string(),
|
||||
connectionData: ZConnectionData,
|
||||
connectionData: ZConnectionData.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -200,18 +187,19 @@ export const ZListResponse = <T extends z.ZodTypeAny>(schema: T) => z.object({
|
|||
* Execute action request
|
||||
*/
|
||||
export const ZExecuteActionRequest = z.object({
|
||||
action: z.string(),
|
||||
connected_account_id: z.string(),
|
||||
input: z.record(z.string(), z.unknown()),
|
||||
user_id: z.string(),
|
||||
version: z.string(),
|
||||
arguments: z.any().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute action response
|
||||
*/
|
||||
export const ZExecuteActionResponse = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.unknown(),
|
||||
error: z.string().optional(),
|
||||
successful: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -226,12 +214,44 @@ export const ZLocalConnectedAccount = z.object({
|
|||
lastUpdatedAt: z.string(),
|
||||
});
|
||||
|
||||
export type AuthScheme = z.infer<typeof ZAuthScheme>;
|
||||
export type ConnectedAccountStatus = z.infer<typeof ZConnectedAccountStatus>;
|
||||
export type Toolkit = z.infer<typeof ZToolkit>;
|
||||
export type Tool = z.infer<typeof ZTool>;
|
||||
export type AuthConfig = z.infer<typeof ZAuthConfig>;
|
||||
export type ConnectedAccount = z.infer<typeof ZConnectedAccount>;
|
||||
export type LocalConnectedAccount = z.infer<typeof ZLocalConnectedAccount>;
|
||||
export type ExecuteActionRequest = z.infer<typeof ZExecuteActionRequest>;
|
||||
export type ExecuteActionResponse = z.infer<typeof ZExecuteActionResponse>;
|
||||
export type ConnectedAccountStatus = z.infer<typeof ZConnectedAccountStatus>;
|
||||
|
||||
/**
|
||||
* Tool schema for search results.
|
||||
* Unlike ZTool, `toolkit` is optional because the Composio /tools search endpoint
|
||||
* sometimes omits the toolkit object from results. `input_parameters` uses
|
||||
* lenient defaults so tools with no params (e.g. LINKEDIN_GET_MY_INFO) parse cleanly.
|
||||
*/
|
||||
export const ZSearchResultTool = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
toolkit: z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
logo: z.string(),
|
||||
}),
|
||||
input_parameters: z.object({
|
||||
type: z.literal('object').optional().default('object'),
|
||||
properties: z.record(z.string(), z.unknown()).optional().default({}),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional().default({ type: 'object', properties: {} }),
|
||||
}).passthrough();
|
||||
|
||||
/**
|
||||
* Normalized tool result returned from searchTools().
|
||||
*/
|
||||
export const ZNormalizedToolResult = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
toolkitSlug: z.string(),
|
||||
inputParameters: z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.string(), z.unknown()),
|
||||
required: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
export type NormalizedToolResult = z.infer<typeof ZNormalizedToolResult>;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { homedir } from "os";
|
|||
import { fileURLToPath } from "url";
|
||||
|
||||
// Resolve app root relative to compiled file location (dist/...)
|
||||
export const WorkDir = path.join(homedir(), ".rowboat");
|
||||
// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage
|
||||
export const WorkDir = process.env.ROWBOAT_WORKDIR || path.join(homedir(), ".rowboat");
|
||||
|
||||
// Get the directory of this file (for locating bundled assets)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
|
@ -29,69 +30,13 @@ function ensureDefaultConfigs() {
|
|||
}
|
||||
}
|
||||
|
||||
// Welcome content inlined to work with bundled builds (esbuild changes __dirname)
|
||||
const WELCOME_CONTENT = `# Welcome to Rowboat
|
||||
|
||||
This vault is your work memory.
|
||||
|
||||
Rowboat extracts context from your emails and meetings and turns it into long-lived, editable Markdown notes. The goal is not to store everything, but to preserve the context that stays useful over time.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
**Entity-based notes**
|
||||
Notes represent people, projects, organizations, or topics that matter to your work.
|
||||
|
||||
**Auto-updating context**
|
||||
As new emails and meetings come in, Rowboat adds decisions, commitments, and relevant context to the appropriate notes.
|
||||
|
||||
**Living notes**
|
||||
These are not static summaries. Context accumulates over time, and notes evolve as your work evolves.
|
||||
|
||||
---
|
||||
|
||||
## Your AI coworker
|
||||
|
||||
Rowboat uses this shared memory to help with everyday work, such as:
|
||||
|
||||
- Drafting emails
|
||||
- Preparing for meetings
|
||||
- Summarizing the current state of a project
|
||||
- Taking local actions when appropriate
|
||||
|
||||
The AI works with deep context, but you stay in control. All notes are visible, editable, and yours.
|
||||
|
||||
---
|
||||
|
||||
## Design principles
|
||||
|
||||
**Reduce noise**
|
||||
Rowboat focuses on recurring contacts and active projects instead of trying to capture everything.
|
||||
|
||||
**Local and inspectable**
|
||||
All data is stored locally as plain Markdown. You can read, edit, or delete any file at any time.
|
||||
|
||||
**Built to improve over time**
|
||||
As you keep using Rowboat, context accumulates across notes instead of being reconstructed from scratch.
|
||||
|
||||
---
|
||||
|
||||
If something feels confusing or limiting, we'd love to hear about it.
|
||||
Rowboat is still evolving, and your workflow matters.
|
||||
`;
|
||||
|
||||
function ensureWelcomeFile() {
|
||||
// Create Welcome.md in knowledge directory if it doesn't exist
|
||||
const welcomeDest = path.join(WorkDir, "knowledge", "Welcome.md");
|
||||
if (!fs.existsSync(welcomeDest)) {
|
||||
fs.writeFileSync(welcomeDest, WELCOME_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
ensureDefaultConfigs();
|
||||
ensureWelcomeFile();
|
||||
|
||||
// Ensure default knowledge files exist
|
||||
import('../knowledge/ensure_daily_note.js').then(m => m.ensureDailyNote()).catch(err => {
|
||||
console.error('[DailyNote] Failed to ensure daily note:', err);
|
||||
});
|
||||
|
||||
// Initialize version history repo (async, fire-and-forget on startup)
|
||||
import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,2 @@
|
|||
export const API_URL =
|
||||
process.env.API_URL || 'http://localhost:3002/v1';
|
||||
|
||||
export const SUPABASE_PROJECT_URL =
|
||||
process.env.SUPABASE_PROJECT_URL || 'http://127.0.0.1:54321';
|
||||
process.env.API_URL || 'https://api.x.rowboatlabs.com';
|
||||
15
apps/x/packages/core/src/config/rowboat.ts
Normal file
15
apps/x/packages/core/src/config/rowboat.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from "zod";
|
||||
import { RowboatApiConfig } from "@x/shared/dist/rowboat-account.js";
|
||||
import { API_URL } from "./env.js";
|
||||
|
||||
let cached: z.infer<typeof RowboatApiConfig> | null = null;
|
||||
|
||||
export async function getRowboatConfig(): Promise<z.infer<typeof RowboatApiConfig>> {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const response = await fetch(`${API_URL}/v1/config`);
|
||||
const data = RowboatApiConfig.parse(await response.json());
|
||||
cached = data;
|
||||
return data;
|
||||
}
|
||||
|
|
@ -6,15 +6,40 @@ import { WorkDir } from "./config.js";
|
|||
export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json");
|
||||
|
||||
const DEFAULT_ALLOW_LIST = [
|
||||
"agent-slack",
|
||||
"awk",
|
||||
"basename",
|
||||
"cat",
|
||||
"cut",
|
||||
"date",
|
||||
"df",
|
||||
"diff",
|
||||
"dirname",
|
||||
"du",
|
||||
"echo",
|
||||
"env",
|
||||
"file",
|
||||
"find",
|
||||
"grep",
|
||||
"head",
|
||||
"hostname",
|
||||
"jq",
|
||||
"ls",
|
||||
"printenv",
|
||||
"printf",
|
||||
"pwd",
|
||||
"yq",
|
||||
"whoami"
|
||||
"readlink",
|
||||
"realpath",
|
||||
"sort",
|
||||
"stat",
|
||||
"tail",
|
||||
"tree",
|
||||
"uname",
|
||||
"uniq",
|
||||
"wc",
|
||||
"which",
|
||||
"whoami",
|
||||
"yq"
|
||||
]
|
||||
|
||||
let cachedAllowList: string[] | null = null;
|
||||
|
|
|
|||
44
apps/x/packages/core/src/config/user_config.ts
Normal file
44
apps/x/packages/core/src/config/user_config.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { WorkDir } from './config.js';
|
||||
|
||||
const USER_CONFIG_PATH = path.join(WorkDir, 'config', 'user.json');
|
||||
|
||||
export const UserConfig = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
domain: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UserConfig = z.infer<typeof UserConfig>;
|
||||
|
||||
export function loadUserConfig(): UserConfig | null {
|
||||
try {
|
||||
if (fs.existsSync(USER_CONFIG_PATH)) {
|
||||
const content = fs.readFileSync(USER_CONFIG_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
return UserConfig.parse(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[UserConfig] Error loading user config:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function saveUserConfig(config: UserConfig): void {
|
||||
const dir = path.dirname(USER_CONFIG_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const validated = UserConfig.parse(config);
|
||||
fs.writeFileSync(USER_CONFIG_PATH, JSON.stringify(validated, null, 2));
|
||||
}
|
||||
|
||||
export function updateUserEmail(email: string): void {
|
||||
const existing = loadUserConfig();
|
||||
const config = existing
|
||||
? { ...existing, email }
|
||||
: { email };
|
||||
saveUserConfig(config);
|
||||
}
|
||||
384
apps/x/packages/core/src/knowledge/agent_notes.ts
Normal file
384
apps/x/packages/core/src/knowledge/agent_notes.ts
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { google } from 'googleapis';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { bus } from '../runs/bus.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
|
||||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { useComposioForGoogle, executeAction } from '../composio/client.js';
|
||||
import { composioAccountsRepo } from '../composio/repo.js';
|
||||
import {
|
||||
loadAgentNotesState,
|
||||
saveAgentNotesState,
|
||||
markEmailProcessed,
|
||||
markRunProcessed,
|
||||
type AgentNotesState,
|
||||
} from './agent_notes_state.js';
|
||||
|
||||
const SYNC_INTERVAL_MS = 10 * 1000; // 10 seconds (for testing)
|
||||
const EMAIL_BATCH_SIZE = 5;
|
||||
const RUNS_BATCH_SIZE = 5;
|
||||
const GMAIL_SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||
const RUNS_DIR = path.join(WorkDir, 'runs');
|
||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||
const INBOX_FILE = path.join(AGENT_NOTES_DIR, 'inbox.md');
|
||||
const AGENT_ID = 'agent_notes_agent';
|
||||
|
||||
// --- File helpers ---
|
||||
|
||||
function ensureAgentNotesDir(): void {
|
||||
if (!fs.existsSync(AGENT_NOTES_DIR)) {
|
||||
fs.mkdirSync(AGENT_NOTES_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Email scanning ---
|
||||
|
||||
function findUserSentEmails(
|
||||
state: AgentNotesState,
|
||||
userEmail: string,
|
||||
limit: number,
|
||||
): string[] {
|
||||
if (!fs.existsSync(GMAIL_SYNC_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: { path: string; mtime: number }[] = [];
|
||||
const userEmailLower = userEmail.toLowerCase();
|
||||
|
||||
function traverse(dir: string) {
|
||||
const entries = fs.readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (entry !== 'attachments') {
|
||||
traverse(fullPath);
|
||||
}
|
||||
} else if (stat.isFile() && entry.endsWith('.md')) {
|
||||
if (state.processedEmails[fullPath]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const fromLines = content.match(/^### From:.*$/gm);
|
||||
if (fromLines?.some(line => line.toLowerCase().includes(userEmailLower))) {
|
||||
results.push({ path: fullPath, mtime: stat.mtimeMs });
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(GMAIL_SYNC_DIR);
|
||||
|
||||
results.sort((a, b) => b.mtime - a.mtime);
|
||||
return results.slice(0, limit).map(r => r.path);
|
||||
}
|
||||
|
||||
function extractUserPartsFromEmail(content: string, userEmail: string): string | null {
|
||||
const userEmailLower = userEmail.toLowerCase();
|
||||
const sections = content.split(/^---$/m);
|
||||
const userSections: string[] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
const fromMatch = section.match(/^### From:.*$/m);
|
||||
if (fromMatch && fromMatch[0].toLowerCase().includes(userEmailLower)) {
|
||||
userSections.push(section.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return userSections.length > 0 ? userSections.join('\n\n---\n\n') : null;
|
||||
}
|
||||
|
||||
// --- Inbox reading ---
|
||||
|
||||
function readInbox(): string[] {
|
||||
if (!fs.existsSync(INBOX_FILE)) {
|
||||
return [];
|
||||
}
|
||||
const content = fs.readFileSync(INBOX_FILE, 'utf-8').trim();
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
return content.split('\n').filter(l => l.trim());
|
||||
}
|
||||
|
||||
function clearInbox(): void {
|
||||
if (fs.existsSync(INBOX_FILE)) {
|
||||
fs.writeFileSync(INBOX_FILE, '');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Copilot run scanning ---
|
||||
|
||||
function findNewCopilotRuns(state: AgentNotesState): string[] {
|
||||
if (!fs.existsSync(RUNS_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: string[] = [];
|
||||
const files = fs.readdirSync(RUNS_DIR).filter(f => f.endsWith('.jsonl'));
|
||||
|
||||
for (const file of files) {
|
||||
if (state.processedRuns[file]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullPath = path.join(RUNS_DIR, file);
|
||||
const fd = fs.openSync(fullPath, 'r');
|
||||
const buf = Buffer.alloc(512);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
|
||||
fs.closeSync(fd);
|
||||
|
||||
const firstLine = buf.subarray(0, bytesRead).toString('utf-8').split('\n')[0];
|
||||
const event = JSON.parse(firstLine);
|
||||
if (event.agentName === 'copilot') {
|
||||
results.push(file);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
results.sort();
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractConversationMessages(runFilePath: string): { role: string; text: string }[] {
|
||||
const messages: { role: string; text: string }[] = [];
|
||||
try {
|
||||
const content = fs.readFileSync(runFilePath, 'utf-8');
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (event.type !== 'message') continue;
|
||||
|
||||
const msg = event.message;
|
||||
if (!msg || (msg.role !== 'user' && msg.role !== 'assistant')) continue;
|
||||
|
||||
let text = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
text = msg.content.trim();
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
text = msg.content
|
||||
.filter((p: { type: string }) => p.type === 'text')
|
||||
.map((p: { text: string }) => p.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
if (text) {
|
||||
messages.push({ role: msg.role, text });
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
// --- Wait for agent run completion ---
|
||||
|
||||
async function waitForRunCompletion(runId: string): Promise<void> {
|
||||
return new Promise(async (resolve) => {
|
||||
const unsubscribe = await bus.subscribe('*', async (event) => {
|
||||
if (event.type === 'run-processing-end' && event.runId === runId) {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- User email resolution ---
|
||||
|
||||
async function ensureUserEmail(): Promise<string | null> {
|
||||
const existing = loadUserConfig();
|
||||
if (existing?.email) {
|
||||
return existing.email;
|
||||
}
|
||||
|
||||
// Try Composio (used when signed in or composio configured)
|
||||
try {
|
||||
if (await useComposioForGoogle()) {
|
||||
const account = composioAccountsRepo.getAccount('gmail');
|
||||
if (account && account.status === 'ACTIVE') {
|
||||
const result = await executeAction('GMAIL_GET_PROFILE', {
|
||||
connected_account_id: account.id,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: { user_id: 'me' },
|
||||
});
|
||||
const email = (result.data as Record<string, unknown>)?.emailAddress as string | undefined;
|
||||
if (email) {
|
||||
updateUserEmail(email);
|
||||
console.log(`[AgentNotes] Auto-populated user email via Composio: ${email}`);
|
||||
return email;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[AgentNotes] Could not fetch email via Composio:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
|
||||
// Try direct Google OAuth
|
||||
try {
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
if (auth) {
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
||||
if (profile.data.emailAddress) {
|
||||
updateUserEmail(profile.data.emailAddress);
|
||||
console.log(`[AgentNotes] Auto-populated user email: ${profile.data.emailAddress}`);
|
||||
return profile.data.emailAddress;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[AgentNotes] Could not fetch Gmail profile for user email:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Main processing ---
|
||||
|
||||
async function processAgentNotes(): Promise<void> {
|
||||
ensureAgentNotesDir();
|
||||
const state = loadAgentNotesState();
|
||||
const userEmail = await ensureUserEmail();
|
||||
|
||||
// Collect all source material
|
||||
const messageParts: string[] = [];
|
||||
|
||||
// 1. Emails (only if we have user email)
|
||||
const emailPaths = userEmail
|
||||
? findUserSentEmails(state, userEmail, EMAIL_BATCH_SIZE)
|
||||
: [];
|
||||
if (emailPaths.length > 0) {
|
||||
messageParts.push(`## Emails sent by the user\n`);
|
||||
for (const p of emailPaths) {
|
||||
const content = fs.readFileSync(p, 'utf-8');
|
||||
const userParts = extractUserPartsFromEmail(content, userEmail!);
|
||||
if (userParts) {
|
||||
messageParts.push(`---\n${userParts}\n---\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Inbox entries
|
||||
const inboxEntries = readInbox();
|
||||
if (inboxEntries.length > 0) {
|
||||
messageParts.push(`## Notes from the assistant (save-to-memory inbox)\n`);
|
||||
messageParts.push(inboxEntries.join('\n'));
|
||||
}
|
||||
|
||||
// 3. Copilot conversations
|
||||
const newRuns = findNewCopilotRuns(state);
|
||||
const runsToProcess = newRuns.slice(-RUNS_BATCH_SIZE);
|
||||
if (runsToProcess.length > 0) {
|
||||
let conversationText = '';
|
||||
for (const runFile of runsToProcess) {
|
||||
const messages = extractConversationMessages(path.join(RUNS_DIR, runFile));
|
||||
if (messages.length === 0) continue;
|
||||
conversationText += `\n--- Conversation ---\n`;
|
||||
for (const msg of messages) {
|
||||
conversationText += `${msg.role}: ${msg.text}\n\n`;
|
||||
}
|
||||
}
|
||||
if (conversationText.trim()) {
|
||||
messageParts.push(`## Recent copilot conversations\n${conversationText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to process
|
||||
if (messageParts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceRun = await serviceLogger.startRun({
|
||||
service: 'agent_notes',
|
||||
message: 'Processing agent notes',
|
||||
trigger: 'timer',
|
||||
});
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
const message = `Current timestamp: ${timestamp}\n\nProcess the following source material and update the Agent Notes folder accordingly.\n\n${messageParts.join('\n\n')}`;
|
||||
|
||||
const agentRun = await createRun({ agentId: AGENT_ID });
|
||||
await createMessage(agentRun.id, message);
|
||||
await waitForRunCompletion(agentRun.id);
|
||||
|
||||
// Mark everything as processed
|
||||
for (const p of emailPaths) {
|
||||
markEmailProcessed(p, state);
|
||||
}
|
||||
for (const r of newRuns) {
|
||||
markRunProcessed(r, state);
|
||||
}
|
||||
if (inboxEntries.length > 0) {
|
||||
clearInbox();
|
||||
}
|
||||
|
||||
state.lastRunTime = new Date().toISOString();
|
||||
saveAgentNotesState(state);
|
||||
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: serviceRun.service,
|
||||
runId: serviceRun.runId,
|
||||
level: 'info',
|
||||
message: 'Agent notes processing complete',
|
||||
durationMs: Date.now() - serviceRun.startedAt,
|
||||
outcome: 'ok',
|
||||
summary: {
|
||||
emails: emailPaths.length,
|
||||
inboxEntries: inboxEntries.length,
|
||||
copilotRuns: runsToProcess.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AgentNotes] Error processing:', error);
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: serviceRun.service,
|
||||
runId: serviceRun.runId,
|
||||
level: 'error',
|
||||
message: 'Error processing agent notes',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Entry point ---
|
||||
|
||||
export async function init() {
|
||||
console.log('[AgentNotes] Starting Agent Notes Service...');
|
||||
console.log(`[AgentNotes] Will process every ${SYNC_INTERVAL_MS / 1000} seconds`);
|
||||
|
||||
// Initial run
|
||||
await processAgentNotes();
|
||||
|
||||
// Periodic polling
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
|
||||
try {
|
||||
await processAgentNotes();
|
||||
} catch (error) {
|
||||
console.error('[AgentNotes] Error in main loop:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
90
apps/x/packages/core/src/knowledge/agent_notes_agent.ts
Normal file
90
apps/x/packages/core/src/knowledge/agent_notes_agent.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
export function getRaw(): string {
|
||||
return `---
|
||||
tools:
|
||||
workspace-writeFile:
|
||||
type: builtin
|
||||
name: workspace-writeFile
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-edit:
|
||||
type: builtin
|
||||
name: workspace-edit
|
||||
workspace-readdir:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
workspace-mkdir:
|
||||
type: builtin
|
||||
name: workspace-mkdir
|
||||
---
|
||||
# Agent Notes
|
||||
|
||||
You are the Agent Notes agent. You maintain a set of notes about the user in the \`knowledge/Agent Notes/\` folder. Your job is to process new source material and update the notes accordingly.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
The Agent Notes folder contains markdown files that capture what you've learned about the user:
|
||||
|
||||
- **user.md** — Facts about who the user IS: their identity, role, company, team, projects, relationships, life context. NOT how they write or what they prefer. Each fact is a timestamped bullet point.
|
||||
- **preferences.md** — General preferences and explicit rules (e.g., "don't use em-dashes", "no meetings before 11am"). These are injected into the assistant's system prompt on every chat.
|
||||
- **style/email.md** — Email writing style patterns, bucketed by recipient context, with examples from actual emails.
|
||||
- Other files as needed — If you notice preferences specific to a topic (e.g., presentations, meeting prep), create a dedicated file for them (e.g., \`presentations.md\`, \`meeting-prep.md\`).
|
||||
|
||||
## How to Process Source Material
|
||||
|
||||
You will receive a message containing some combination of:
|
||||
1. **Emails sent by the user** — Analyze their writing style and update \`style/email.md\`. Do NOT put style observations in \`user.md\`.
|
||||
2. **Inbox entries** — Notes the assistant saved during conversations via save-to-memory. Route each to the appropriate file. General preferences go to \`preferences.md\`. Topic-specific preferences get their own file.
|
||||
3. **Copilot conversations** — User and assistant messages from recent chats. Extract lasting facts about the user and append timestamped entries to \`user.md\`.
|
||||
|
||||
## What Goes Where — Be Strict
|
||||
|
||||
### user.md — ONLY identity and context facts
|
||||
Good examples:
|
||||
- Co-founded Rowboat Labs with Ramnique
|
||||
- Team of 4 people
|
||||
- Previously worked at Twitter
|
||||
- Planning to fundraise after Product Hunt launch
|
||||
- Based in Bangalore, travels to SF periodically
|
||||
|
||||
Bad examples (do NOT put these in user.md):
|
||||
- "Uses concise, friendly scheduling replies" → this is style, goes in style/email.md
|
||||
- "Frequently replies with short confirmations" → this is style, goes in style/email.md
|
||||
- "Uses the abbreviation PFA" → this is style, goes in style/email.md
|
||||
- "Requested a children's story about a scientist grandmother" → this is an ephemeral task, skip entirely
|
||||
- "Prefers 30-minute meeting slots" → this is a preference, goes in preferences.md
|
||||
|
||||
### style/email.md — Writing patterns from emails
|
||||
Organize by recipient context. Include concrete examples quoted from actual emails.
|
||||
- Close team (very terse, no greeting/sign-off)
|
||||
- External/investors (casual but structured)
|
||||
- Formal/cold (concise, complete sentences)
|
||||
|
||||
### preferences.md — Explicit rules and preferences
|
||||
Things the user has stated they want or don't want.
|
||||
|
||||
### Other files — Topic-specific persistent preferences ONLY
|
||||
Create a new file ONLY for recurring preference themes where the user has expressed multiple lasting preferences about a specific skill or task type. Examples: \`presentations.md\` (if the user has stated preferences about slide design, deck structure, etc.), \`meeting-prep.md\` (if they have preferences about how meetings are prepared).
|
||||
|
||||
Do NOT create files for:
|
||||
- One-off facts or transient situations (e.g., "looking for housing in SF" — that's a user.md fact, not a preference file)
|
||||
- Topics with only a single observation
|
||||
- Things that are better captured in user.md or preferences.md
|
||||
|
||||
## Rules
|
||||
|
||||
- Always read a file before updating it so you know what's already there.
|
||||
- For \`user.md\`: Format is \`- [ISO_TIMESTAMP] The fact\`. The timestamp indicates when the fact was last confirmed.
|
||||
- **Add** new facts with the current timestamp.
|
||||
- **Refresh** existing facts: if you would add a fact that's already there, update its timestamp to the current one so it stays fresh.
|
||||
- **Remove** facts that are likely outdated. Use your judgment: time-bound facts (e.g., "planning to launch next week", "has a meeting with X on Friday") go stale quickly. Stable facts (e.g., "co-founded Rowboat with Ramnique", "previously worked at Twitter") persist. If a fact's timestamp is old and it describes something transient, remove it.
|
||||
- For \`preferences.md\` and other preference files: you may reorganize and deduplicate, but preserve all existing preferences that are still relevant.
|
||||
- **Deduplicate strictly.** Before adding anything, check if the same fact is already captured — even if worded differently. Do NOT add a near-duplicate.
|
||||
- **Skip ephemeral tasks.** If the user asked the assistant to do a one-off thing (draft an email, write a story, search for something), that is NOT a fact about the user. Skip it entirely.
|
||||
- Be concise — bullet points, not paragraphs.
|
||||
- Capture context, not blanket rules. BAD: "User prefers casual tone". GOOD: "User prefers casual tone with internal team but formal with investors."
|
||||
- **If there's nothing new to add to a file, do NOT touch it.** Do not create placeholder content, do not write "no preferences recorded", do not add explanatory notes about what the file is for. Leave it empty or leave it as-is.
|
||||
- **Do NOT create files unless you have actual content for them.** An empty or boilerplate file is worse than no file.
|
||||
- Create the \`style/\` directory if it doesn't exist yet and you have style content to write.
|
||||
`;
|
||||
}
|
||||
62
apps/x/packages/core/src/knowledge/agent_notes_state.ts
Normal file
62
apps/x/packages/core/src/knowledge/agent_notes_state.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
const STATE_FILE = path.join(WorkDir, 'agent_notes_state.json');
|
||||
|
||||
export interface AgentNotesState {
|
||||
processedEmails: Record<string, { processedAt: string }>;
|
||||
processedRuns: Record<string, { processedAt: string }>;
|
||||
lastRunTime: string;
|
||||
}
|
||||
|
||||
export function loadAgentNotesState(): AgentNotesState {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
||||
// Handle migration from older state without processedRuns
|
||||
if (!parsed.processedRuns) {
|
||||
parsed.processedRuns = {};
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error('Error loading agent notes state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processedEmails: {},
|
||||
processedRuns: {},
|
||||
lastRunTime: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function saveAgentNotesState(state: AgentNotesState): void {
|
||||
try {
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Error saving agent notes state:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function markEmailProcessed(filePath: string, state: AgentNotesState): void {
|
||||
state.processedEmails[filePath] = {
|
||||
processedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function markRunProcessed(runFile: string, state: AgentNotesState): void {
|
||||
state.processedRuns[runFile] = {
|
||||
processedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAgentNotesState(): void {
|
||||
const emptyState: AgentNotesState = {
|
||||
processedEmails: {},
|
||||
processedRuns: {},
|
||||
lastRunTime: new Date().toISOString(),
|
||||
};
|
||||
saveAgentNotesState(emptyState);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { commitAll } from './version_history.js';
|
||||
import { getTagDefinitions } from './tag_system.js';
|
||||
|
||||
/**
|
||||
* Build obsidian-style knowledge graph by running topic extraction
|
||||
|
|
@ -25,16 +26,58 @@ const NOTES_OUTPUT_DIR = path.join(WorkDir, 'knowledge');
|
|||
const NOTE_CREATION_AGENT = 'note_creation';
|
||||
|
||||
// Configuration for the graph builder service
|
||||
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
|
||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||
const SOURCE_FOLDERS = [
|
||||
'gmail_sync',
|
||||
'fireflies_transcripts',
|
||||
'granola_notes',
|
||||
path.join('knowledge', 'Meetings', 'fireflies'),
|
||||
path.join('knowledge', 'Meetings', 'granola'),
|
||||
];
|
||||
|
||||
// Voice memos are now created directly in knowledge/Voice Memos/<date>/
|
||||
const VOICE_MEMOS_KNOWLEDGE_DIR = path.join(NOTES_OUTPUT_DIR, 'Voice Memos');
|
||||
|
||||
/**
|
||||
* Check if email frontmatter contains any noise/skip filter tags.
|
||||
* Returns true if the email should be skipped.
|
||||
*/
|
||||
function hasNoiseLabels(content: string): boolean {
|
||||
if (!content.startsWith('---')) return false;
|
||||
|
||||
const endIdx = content.indexOf('---', 3);
|
||||
if (endIdx === -1) return false;
|
||||
|
||||
const frontmatter = content.slice(3, endIdx);
|
||||
|
||||
const noiseTags = new Set(
|
||||
getTagDefinitions()
|
||||
.filter(t => t.type === 'noise')
|
||||
.map(t => t.tag)
|
||||
);
|
||||
|
||||
// Match list items under filter: key
|
||||
const filterMatch = frontmatter.match(/filter:\s*\n((?:\s+-\s+.+\n?)*)/);
|
||||
if (filterMatch) {
|
||||
const filterLines = filterMatch[1].match(/^\s+-\s+(.+)$/gm);
|
||||
if (filterLines) {
|
||||
for (const line of filterLines) {
|
||||
const tag = line.replace(/^\s+-\s+/, '').trim().replace(/['"]/g, '');
|
||||
if (noiseTags.has(tag)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match inline array like filter: ['cold-outreach'] or filter: [cold-outreach]
|
||||
const inlineMatch = frontmatter.match(/filter:\s*\[([^\]]*)\]/);
|
||||
if (inlineMatch && inlineMatch[1].trim()) {
|
||||
const tags = inlineMatch[1].split(',').map(t => t.trim().replace(/['"]/g, ''));
|
||||
for (const tag of tags) {
|
||||
if (noiseTags.has(tag)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractPathFromToolInput(input: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(input) as { path?: string };
|
||||
|
|
@ -192,7 +235,9 @@ async function createNotesFromBatch(
|
|||
// Add each file's content
|
||||
message += `# Source Files to Process\n\n`;
|
||||
files.forEach((file, idx) => {
|
||||
message += `## Source File ${idx + 1}: ${path.basename(file.path)}\n\n`;
|
||||
// Pass workspace-relative path so the agent can link back to meeting notes
|
||||
const relativePath = path.relative(WorkDir, file.path);
|
||||
message += `## Source File ${idx + 1}: ${relativePath}\n\n`;
|
||||
message += file.content;
|
||||
message += `\n\n---\n\n`;
|
||||
});
|
||||
|
|
@ -364,16 +409,23 @@ export async function buildGraph(sourceDir: string): Promise<void> {
|
|||
// Get files that need processing (new or changed)
|
||||
let filesToProcess = getFilesToProcess(sourceDir, state);
|
||||
|
||||
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
|
||||
// For gmail_sync, only process emails that have been labeled AND don't have noise filter tags
|
||||
if (sourceDir.endsWith('gmail_sync')) {
|
||||
filesToProcess = filesToProcess.filter(filePath => {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return content.startsWith('---');
|
||||
if (!content.startsWith('---')) return false;
|
||||
if (hasNoiseLabels(content)) {
|
||||
console.log(`[buildGraph] Skipping noise email: ${path.basename(filePath)}`);
|
||||
markFileAsProcessed(filePath, state);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
saveState(state);
|
||||
}
|
||||
|
||||
if (filesToProcess.length === 0) {
|
||||
|
|
@ -533,7 +585,7 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
|
|||
/**
|
||||
* Process all configured source directories
|
||||
*/
|
||||
async function processAllSources(): Promise<void> {
|
||||
export async function processAllSources(): Promise<void> {
|
||||
console.log('[GraphBuilder] Checking for new content in all sources...');
|
||||
|
||||
|
||||
|
|
@ -566,16 +618,23 @@ async function processAllSources(): Promise<void> {
|
|||
try {
|
||||
let filesToProcess = getFilesToProcess(sourceDir, state);
|
||||
|
||||
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
|
||||
// For gmail_sync, only process emails that have been labeled AND don't have noise filter tags
|
||||
if (folder === 'gmail_sync') {
|
||||
filesToProcess = filesToProcess.filter(filePath => {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return content.startsWith('---');
|
||||
if (!content.startsWith('---')) return false;
|
||||
if (hasNoiseLabels(content)) {
|
||||
console.log(`[GraphBuilder] Skipping noise email: ${path.basename(filePath)}`);
|
||||
markFileAsProcessed(filePath, state);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
saveState(state);
|
||||
}
|
||||
|
||||
if (filesToProcess.length > 0) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
# Page Capture Chrome Extension
|
||||
|
||||
A Chrome extension that captures web pages you visit and sends them to a local server for storage as markdown files.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
/extension
|
||||
manifest.json # Chrome extension manifest (v3)
|
||||
background.js # Service worker that captures pages
|
||||
/server
|
||||
server.py # Flask server for storing captures
|
||||
captured_pages/ # Directory where pages are saved
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install Server Dependencies
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install flask flask-cors
|
||||
```
|
||||
|
||||
### 2. Start the Server
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python server.py
|
||||
```
|
||||
|
||||
The server will run at `http://localhost:3001`.
|
||||
|
||||
### 3. Install the Chrome Extension
|
||||
|
||||
1. Open Chrome and navigate to `chrome://extensions/`
|
||||
2. Enable "Developer mode" (toggle in top right)
|
||||
3. Click "Load unpacked"
|
||||
4. Select the `extension` folder
|
||||
|
||||
## Usage
|
||||
|
||||
Once both the server is running and the extension is installed, the extension will automatically capture pages as you browse:
|
||||
|
||||
- Every page load (http/https URLs only) triggers a capture
|
||||
- Content is hashed with SHA-256 to avoid duplicate captures
|
||||
- Pages are saved as markdown files with frontmatter metadata
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /capture
|
||||
|
||||
Receives captured page data.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"content": "Page text content...",
|
||||
"timestamp": 1706123456789,
|
||||
"title": "Page Title"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"status": "captured", "filename": "1706123456789_example_com.md"}
|
||||
```
|
||||
|
||||
### GET /status
|
||||
|
||||
Returns the count of captured pages.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"count": 42}
|
||||
```
|
||||
|
||||
## File Format
|
||||
|
||||
Captured pages are saved as markdown with YAML frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
url: https://example.com/page
|
||||
title: Page Title
|
||||
captured_at: 2024-01-24T12:34:56
|
||||
---
|
||||
|
||||
Page content here...
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
- **Extension logs**: Open `chrome://extensions/`, find "Page Capture", click "Service worker" to view console logs
|
||||
- **Server logs**: Check the terminal where `server.py` is running
|
||||
|
|
@ -0,0 +1,388 @@
|
|||
const SERVER_URL = 'http://localhost:3001';
|
||||
const contentHashMap = new Map();
|
||||
|
||||
let cachedConfig = null;
|
||||
let serverReachable = true;
|
||||
|
||||
// Default config
|
||||
const DEFAULT_CONFIG = {
|
||||
mode: 'ask',
|
||||
whitelist: [],
|
||||
blacklist: [],
|
||||
enabled: true
|
||||
};
|
||||
|
||||
// Config management
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/browse/config`);
|
||||
if (response.ok) {
|
||||
cachedConfig = await response.json();
|
||||
serverReachable = true;
|
||||
} else {
|
||||
throw new Error('Server returned error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`[Page Capture] Failed to load config: ${error.message}`);
|
||||
serverReachable = false;
|
||||
cachedConfig = cachedConfig || DEFAULT_CONFIG;
|
||||
}
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
async function saveConfig(config) {
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/browse/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
if (response.ok) {
|
||||
cachedConfig = config;
|
||||
serverReachable = true;
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`[Page Capture] Failed to save config: ${error.message}`);
|
||||
serverReachable = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
return cachedConfig || DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
function extractDomain(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isWhitelisted(domain) {
|
||||
const config = getConfig();
|
||||
return config.whitelist.some(d => domain === d || domain.endsWith('.' + d));
|
||||
}
|
||||
|
||||
function isBlacklisted(domain) {
|
||||
const config = getConfig();
|
||||
return config.blacklist.some(d => domain === d || domain.endsWith('.' + d));
|
||||
}
|
||||
|
||||
function getDomainStatus(domain) {
|
||||
const config = getConfig();
|
||||
if (isBlacklisted(domain)) return 'blacklisted';
|
||||
if (config.mode === 'all') return 'capturing';
|
||||
if (isWhitelisted(domain)) return 'whitelisted';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function shouldCapture(domain) {
|
||||
const config = getConfig();
|
||||
if (!config.enabled) return false;
|
||||
if (isBlacklisted(domain)) return false;
|
||||
if (config.mode === 'all') return true;
|
||||
return isWhitelisted(domain);
|
||||
}
|
||||
|
||||
// Badge management
|
||||
async function setBadge(tabId, type) {
|
||||
try {
|
||||
if (type === 'needs-approval') {
|
||||
await chrome.action.setBadgeText({ tabId, text: '?' });
|
||||
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#F59E0B' });
|
||||
} else if (type === 'server-error') {
|
||||
await chrome.action.setBadgeText({ tabId, text: '!' });
|
||||
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#EF4444' });
|
||||
} else {
|
||||
await chrome.action.setBadgeText({ tabId, text: '' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`[Page Capture] Failed to set badge: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateBadgeForTab(tabId, url) {
|
||||
if (!serverReachable) {
|
||||
await setBadge(tabId, 'server-error');
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = extractDomain(url);
|
||||
if (!domain) {
|
||||
await setBadge(tabId, 'clear');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = getDomainStatus(domain);
|
||||
if (status === 'unknown') {
|
||||
await setBadge(tabId, 'needs-approval');
|
||||
} else {
|
||||
await setBadge(tabId, 'clear');
|
||||
}
|
||||
}
|
||||
|
||||
// Content hashing
|
||||
async function hashContent(content) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(content);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function isValidUrl(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function capturePageContent(tabId) {
|
||||
try {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: () => document.body.innerText
|
||||
});
|
||||
return results[0]?.result || '';
|
||||
} catch (error) {
|
||||
console.log(`[Page Capture] Failed to capture content: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToServer(data) {
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/capture`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
serverReachable = response.ok;
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.log(`[Page Capture] Failed to send to server: ${error.message}`);
|
||||
serverReachable = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function captureTab(tabId, tab) {
|
||||
const content = await capturePageContent(tabId);
|
||||
if (content === null) return false;
|
||||
|
||||
const hash = await hashContent(content);
|
||||
const lastHash = contentHashMap.get(tab.url);
|
||||
|
||||
if (lastHash === hash) {
|
||||
console.log(`[Page Capture] Content unchanged for: ${tab.url}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
contentHashMap.set(tab.url, hash);
|
||||
|
||||
const payload = {
|
||||
url: tab.url,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
title: tab.title || 'Untitled'
|
||||
};
|
||||
|
||||
const success = await sendToServer(payload);
|
||||
if (success) {
|
||||
console.log(`[Page Capture] Captured: ${tab.url}`);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
// Tab update listener
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
|
||||
if (changeInfo.status !== 'complete') return;
|
||||
if (!isValidUrl(tab.url)) {
|
||||
console.log(`[Page Capture] Skipping non-http URL: ${tab.url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = extractDomain(tab.url);
|
||||
if (!domain) return;
|
||||
|
||||
await updateBadgeForTab(tabId, tab.url);
|
||||
|
||||
if (!shouldCapture(domain)) {
|
||||
console.log(`[Page Capture] Skipping (not whitelisted): ${tab.url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await captureTab(tabId, tab);
|
||||
});
|
||||
|
||||
// Tab activated listener - update badge
|
||||
chrome.tabs.onActivated.addListener(async (activeInfo) => {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(activeInfo.tabId);
|
||||
if (tab.url && isValidUrl(tab.url)) {
|
||||
await updateBadgeForTab(activeInfo.tabId, tab.url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`[Page Capture] Failed to update badge on tab switch: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle scroll capture messages from content script
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'SCROLL_CAPTURE') {
|
||||
const { url, content, timestamp, title, scrollY } = message;
|
||||
const domain = extractDomain(url);
|
||||
|
||||
if (!shouldCapture(domain)) {
|
||||
console.log(`[Page Capture] Skipping scroll capture (not whitelisted): ${url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Page Capture] Received scroll capture for: ${url}`);
|
||||
|
||||
hashContent(content).then(async (hash) => {
|
||||
const lastHash = contentHashMap.get(url);
|
||||
if (lastHash === hash) {
|
||||
console.log(`[Page Capture] Hash unchanged, skipping: ${url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
contentHashMap.set(url, hash);
|
||||
|
||||
const payload = { url, content, timestamp, title };
|
||||
const success = await sendToServer(payload);
|
||||
if (success) {
|
||||
console.log(`[Page Capture] Scroll captured (y=${scrollY}): ${url}`);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle messages from popup
|
||||
if (message.type === 'GET_CONFIG') {
|
||||
loadConfig().then(config => {
|
||||
sendResponse({ config, serverReachable });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'SAVE_CONFIG') {
|
||||
saveConfig(message.config).then(success => {
|
||||
sendResponse({ success });
|
||||
// Update badges on all tabs
|
||||
chrome.tabs.query({}, tabs => {
|
||||
tabs.forEach(tab => {
|
||||
if (tab.url && isValidUrl(tab.url)) {
|
||||
updateBadgeForTab(tab.id, tab.url);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'GET_DOMAIN_STATUS') {
|
||||
const domain = extractDomain(message.url);
|
||||
const status = domain ? getDomainStatus(domain) : 'unknown';
|
||||
sendResponse({ status, domain, serverReachable });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'APPROVE_DOMAIN') {
|
||||
const config = getConfig();
|
||||
const domain = message.domain;
|
||||
if (!config.whitelist.includes(domain)) {
|
||||
config.whitelist.push(domain);
|
||||
}
|
||||
config.blacklist = config.blacklist.filter(d => d !== domain);
|
||||
saveConfig(config).then(success => {
|
||||
sendResponse({ success });
|
||||
chrome.tabs.query({}, tabs => {
|
||||
tabs.forEach(tab => {
|
||||
if (tab.url && isValidUrl(tab.url)) {
|
||||
updateBadgeForTab(tab.id, tab.url);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'REJECT_DOMAIN') {
|
||||
const config = getConfig();
|
||||
const domain = message.domain;
|
||||
if (!config.blacklist.includes(domain)) {
|
||||
config.blacklist.push(domain);
|
||||
}
|
||||
config.whitelist = config.whitelist.filter(d => d !== domain);
|
||||
saveConfig(config).then(success => {
|
||||
sendResponse({ success });
|
||||
chrome.tabs.query({}, tabs => {
|
||||
tabs.forEach(tab => {
|
||||
if (tab.url && isValidUrl(tab.url)) {
|
||||
updateBadgeForTab(tab.id, tab.url);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'CAPTURE_ONCE') {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, async tabs => {
|
||||
if (tabs[0]) {
|
||||
const success = await captureTab(tabs[0].id, tabs[0]);
|
||||
sendResponse({ success });
|
||||
} else {
|
||||
sendResponse({ success: false });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'REMOVE_FROM_WHITELIST') {
|
||||
const config = getConfig();
|
||||
config.whitelist = config.whitelist.filter(d => d !== message.domain);
|
||||
saveConfig(config).then(success => {
|
||||
sendResponse({ success });
|
||||
chrome.tabs.query({}, tabs => {
|
||||
tabs.forEach(tab => {
|
||||
if (tab.url && isValidUrl(tab.url)) {
|
||||
updateBadgeForTab(tab.id, tab.url);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'REMOVE_FROM_BLACKLIST') {
|
||||
const config = getConfig();
|
||||
config.blacklist = config.blacklist.filter(d => d !== message.domain);
|
||||
saveConfig(config).then(success => {
|
||||
sendResponse({ success });
|
||||
chrome.tabs.query({}, tabs => {
|
||||
tabs.forEach(tab => {
|
||||
if (tab.url && isValidUrl(tab.url)) {
|
||||
updateBadgeForTab(tab.id, tab.url);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Load config on startup
|
||||
loadConfig().then(() => {
|
||||
console.log('[Page Capture] Config loaded');
|
||||
});
|
||||
|
||||
console.log('[Page Capture] Service worker started');
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
const DEBOUNCE_MS = 800;
|
||||
const MIN_SCROLL_PIXELS = 500;
|
||||
const MIN_CONTENT_CHANGE = 100; // characters
|
||||
|
||||
let debounceTimer = null;
|
||||
let lastCapturedContent = null;
|
||||
let lastScrollTop = 0;
|
||||
let scrollContainer = null;
|
||||
|
||||
function getScrollTop() {
|
||||
if (!scrollContainer || scrollContainer === window) {
|
||||
return window.scrollY;
|
||||
}
|
||||
if (scrollContainer === document) {
|
||||
return document.documentElement.scrollTop;
|
||||
}
|
||||
return scrollContainer.scrollTop || 0;
|
||||
}
|
||||
|
||||
function captureAndSend() {
|
||||
const content = document.body.innerText;
|
||||
|
||||
// Skip if content unchanged or minimal change
|
||||
if (lastCapturedContent) {
|
||||
const lengthDiff = Math.abs(content.length - lastCapturedContent.length);
|
||||
if (content === lastCapturedContent || lengthDiff < MIN_CONTENT_CHANGE) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
lastCapturedContent = content;
|
||||
lastScrollTop = getScrollTop();
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'SCROLL_CAPTURE',
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
scrollY: lastScrollTop
|
||||
});
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const currentScrollTop = getScrollTop();
|
||||
const scrollDelta = Math.abs(currentScrollTop - lastScrollTop);
|
||||
|
||||
if (scrollDelta < MIN_SCROLL_PIXELS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
captureAndSend();
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function init() {
|
||||
// Use document with capture to catch scroll events from any element
|
||||
document.addEventListener('scroll', (e) => {
|
||||
const target = e.target;
|
||||
const scrollTop = target === document ? document.documentElement.scrollTop : target.scrollTop;
|
||||
|
||||
// Update scroll container if we found the real one
|
||||
if (scrollTop > 0 && scrollContainer !== target) {
|
||||
scrollContainer = target;
|
||||
}
|
||||
|
||||
onScroll();
|
||||
}, { capture: true, passive: true });
|
||||
}
|
||||
|
||||
// Wait for page to be ready, then init
|
||||
if (document.readyState === 'complete') {
|
||||
init();
|
||||
} else {
|
||||
window.addEventListener('load', init);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 912 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Rowboat Browser Capture",
|
||||
"version": "1.1.1",
|
||||
"description": "Allows users to save and capture web page content to their Rowboat workspace.",
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"scripting",
|
||||
"activeTab"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rowboat</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
body {
|
||||
width: 320px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.domain {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.approval-section {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.approval-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.approval-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.approval-buttons .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toggle-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--error-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--error-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-radio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-radio label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.settings-radio input[type="radio"] {
|
||||
accent-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.stats-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<span class="domain" id="domainDisplay">-</span>
|
||||
<span class="status-badge" id="statusBadge">
|
||||
<span class="status-dot"></span>
|
||||
<span id="statusText">-</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="error-message hidden" id="errorMessage">
|
||||
Cannot reach Rowboat app.
|
||||
</div>
|
||||
|
||||
<div class="approval-section hidden" id="approvalSection">
|
||||
<div class="approval-title">Index this site?</div>
|
||||
<div class="approval-buttons">
|
||||
<button class="btn btn-primary btn-sm" id="approveBtn">Yes, always</button>
|
||||
<button class="btn btn-secondary btn-sm" id="rejectBtn">No</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm btn-block mt-2" id="captureOnceBtn">Just this page</button>
|
||||
</div>
|
||||
|
||||
<div class="toggle-section hidden" id="toggleSection">
|
||||
<span class="toggle-label" id="toggleLabel">Capturing this site</span>
|
||||
<button class="btn btn-secondary btn-sm" id="toggleBtn">Stop</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-title">Settings</div>
|
||||
<div class="settings-radio">
|
||||
<label>
|
||||
<input type="radio" name="captureMode" value="work">
|
||||
Auto-index active tab
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="captureMode" value="ask">
|
||||
Ask me each time
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<span class="stats-count" id="statsCount">-</span>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
const SERVER_URL = 'http://localhost:3001';
|
||||
|
||||
|
||||
let currentDomain = null;
|
||||
let currentStatus = null;
|
||||
let currentConfig = null;
|
||||
|
||||
async function getCurrentTab() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
return tab;
|
||||
}
|
||||
|
||||
function extractDomain(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusBadge(status, serverReachable) {
|
||||
const badge = document.getElementById('statusBadge');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
badge.classList.remove('capturing', 'not-capturing', 'awaiting', 'error');
|
||||
|
||||
if (!serverReachable) {
|
||||
badge.classList.add('error');
|
||||
statusText.textContent = 'Error';
|
||||
return;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'whitelisted':
|
||||
case 'capturing':
|
||||
badge.classList.add('capturing');
|
||||
statusText.textContent = 'Indexing';
|
||||
break;
|
||||
case 'blacklisted':
|
||||
badge.classList.add('not-capturing');
|
||||
statusText.textContent = 'Not indexing';
|
||||
break;
|
||||
case 'unknown':
|
||||
badge.classList.add('awaiting');
|
||||
statusText.textContent = 'Awaiting';
|
||||
break;
|
||||
default:
|
||||
badge.classList.add('not-capturing');
|
||||
statusText.textContent = 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function showApprovalSection(show) {
|
||||
document.getElementById('approvalSection').classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
function showToggleSection(show, isCapturing) {
|
||||
const section = document.getElementById('toggleSection');
|
||||
const label = document.getElementById('toggleLabel');
|
||||
const btn = document.getElementById('toggleBtn');
|
||||
|
||||
section.classList.toggle('hidden', !show);
|
||||
|
||||
if (isCapturing) {
|
||||
label.textContent = 'Capturing this site';
|
||||
btn.textContent = 'Stop';
|
||||
btn.onclick = () => removeDomain('whitelist');
|
||||
} else {
|
||||
label.textContent = 'Not capturing this site';
|
||||
btn.textContent = 'Start';
|
||||
btn.onclick = () => removeDomain('blacklist');
|
||||
}
|
||||
}
|
||||
|
||||
function showError(show) {
|
||||
document.getElementById('errorMessage').classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
// Settings section
|
||||
function getSelectedMode(config) {
|
||||
return config.mode === 'all' ? 'work' : 'ask';
|
||||
}
|
||||
|
||||
function initSettings(config) {
|
||||
currentConfig = config;
|
||||
const mode = getSelectedMode(config);
|
||||
|
||||
const radio = document.querySelector(`input[name="captureMode"][value="${mode}"]`);
|
||||
if (radio) radio.checked = true;
|
||||
}
|
||||
|
||||
async function saveSettingsFromUI() {
|
||||
const selectedRadio = document.querySelector('input[name="captureMode"]:checked');
|
||||
const mode = selectedRadio ? selectedRadio.value : 'ask';
|
||||
|
||||
let config;
|
||||
if (mode === 'work') {
|
||||
config = {
|
||||
mode: 'all',
|
||||
whitelist: currentConfig ? currentConfig.whitelist : [],
|
||||
blacklist: currentConfig ? currentConfig.blacklist : [],
|
||||
enabled: true
|
||||
};
|
||||
} else {
|
||||
config = {
|
||||
mode: 'ask',
|
||||
whitelist: currentConfig ? currentConfig.whitelist : [],
|
||||
blacklist: currentConfig ? currentConfig.blacklist : [],
|
||||
enabled: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.runtime.sendMessage({ type: 'SAVE_CONFIG', config });
|
||||
currentConfig = config;
|
||||
await loadStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Domain status
|
||||
async function loadStatus() {
|
||||
const tab = await getCurrentTab();
|
||||
if (!tab || !tab.url) {
|
||||
document.getElementById('domainDisplay').textContent = 'No page';
|
||||
return;
|
||||
}
|
||||
|
||||
currentDomain = extractDomain(tab.url);
|
||||
if (!currentDomain) {
|
||||
document.getElementById('domainDisplay').textContent = 'Invalid URL';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('domainDisplay').textContent = currentDomain;
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'GET_DOMAIN_STATUS',
|
||||
url: tab.url
|
||||
});
|
||||
|
||||
currentStatus = response.status;
|
||||
const serverReachable = response.serverReachable;
|
||||
|
||||
updateStatusBadge(currentStatus, serverReachable);
|
||||
showError(!serverReachable);
|
||||
|
||||
if (!serverReachable) {
|
||||
showApprovalSection(false);
|
||||
showToggleSection(false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStatus === 'unknown') {
|
||||
showApprovalSection(true);
|
||||
showToggleSection(false, false);
|
||||
} else if (currentStatus === 'whitelisted' || currentStatus === 'capturing') {
|
||||
showApprovalSection(false);
|
||||
showToggleSection(true, true);
|
||||
} else if (currentStatus === 'blacklisted') {
|
||||
showApprovalSection(false);
|
||||
showToggleSection(true, false);
|
||||
} else {
|
||||
showApprovalSection(false);
|
||||
showToggleSection(false, false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get status:', error);
|
||||
showError(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/status`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
document.getElementById('statsCount').textContent = `${data.count} pages indexed locally`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Failed to load stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function approveDomain() {
|
||||
if (!currentDomain) return;
|
||||
try {
|
||||
await chrome.runtime.sendMessage({ type: 'APPROVE_DOMAIN', domain: currentDomain });
|
||||
// Reload config to reflect the new whitelist in settings
|
||||
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
|
||||
if (resp && resp.config) initSettings(resp.config);
|
||||
await loadStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to approve domain:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectDomain() {
|
||||
if (!currentDomain) return;
|
||||
try {
|
||||
await chrome.runtime.sendMessage({ type: 'REJECT_DOMAIN', domain: currentDomain });
|
||||
await loadStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to reject domain:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureOnce() {
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ type: 'CAPTURE_ONCE' });
|
||||
if (response.success) {
|
||||
window.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to capture:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDomain(list) {
|
||||
if (!currentDomain) return;
|
||||
try {
|
||||
const messageType = list === 'whitelist' ? 'REMOVE_FROM_WHITELIST' : 'REMOVE_FROM_BLACKLIST';
|
||||
await chrome.runtime.sendMessage({ type: messageType, domain: currentDomain });
|
||||
// Reload config to reflect changes in settings
|
||||
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
|
||||
if (resp && resp.config) initSettings(resp.config);
|
||||
await loadStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove domain:', error);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Load config and init settings
|
||||
try {
|
||||
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
|
||||
if (resp && resp.config) {
|
||||
initSettings(resp.config);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error);
|
||||
}
|
||||
|
||||
// Radio change listeners
|
||||
document.querySelectorAll('input[name="captureMode"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => saveSettingsFromUI());
|
||||
});
|
||||
|
||||
loadStatus();
|
||||
loadStats();
|
||||
|
||||
document.getElementById('approveBtn').addEventListener('click', approveDomain);
|
||||
document.getElementById('rejectBtn').addEventListener('click', rejectDomain);
|
||||
document.getElementById('captureOnceBtn').addEventListener('click', captureOnce);
|
||||
});
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f9fafb;
|
||||
--bg-tertiary: #f3f4f6;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--text-muted: #9ca3af;
|
||||
--border-color: #e5e7eb;
|
||||
--accent-color: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--error-color: #ef4444;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: #1f2937;
|
||||
--bg-secondary: #111827;
|
||||
--bg-tertiary: #374151;
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-muted: #9ca3af;
|
||||
--border-color: #374151;
|
||||
--accent-color: #60a5fa;
|
||||
--accent-hover: #3b82f6;
|
||||
--success-color: #34d399;
|
||||
--warning-color: #fbbf24;
|
||||
--error-color: #f87171;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.capturing {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.status-badge.not-capturing {
|
||||
background-color: rgba(107, 114, 128, 0.1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-badge.awaiting {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.radio-option:hover {
|
||||
border-color: var(--accent-color);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.radio-option.selected {
|
||||
border-color: var(--accent-color);
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.radio-option input[type="radio"] {
|
||||
margin-top: 2px;
|
||||
accent-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.radio-option-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.radio-option-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.radio-option-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Toggle/Checkbox */
|
||||
.toggle-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.toggle-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toggle-item input[type="checkbox"] {
|
||||
accent-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.toggle-item label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: var(--border-color);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* Link */
|
||||
.link {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Text utilities */
|
||||
.text-sm {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Spacing utilities */
|
||||
.mt-1 { margin-top: 4px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-3 { margin-top: 12px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-1 { margin-bottom: 4px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.mb-3 { margin-bottom: 12px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
|
||||
/* Flex utilities */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-1 { gap: 4px; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../../../config/config.js';
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
const CAPTURED_PAGES_DIR = path.join(WorkDir, 'chrome_sync');
|
||||
const CONFIG_DIR = path.join(WorkDir, 'config');
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, 'chrome-plugin.json');
|
||||
|
||||
interface Config {
|
||||
mode: 'all' | 'ask';
|
||||
whitelist: string[];
|
||||
blacklist: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
mode: 'ask',
|
||||
whitelist: [],
|
||||
blacklist: [],
|
||||
enabled: true
|
||||
};
|
||||
|
||||
const contentHashes = new Map<string, string>();
|
||||
|
||||
function extractDomain(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.host || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function pathToSlug(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const p = parsed.pathname + (parsed.search || '');
|
||||
if (!p || p === '/') return 'index';
|
||||
let slug = p.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||
return slug.substring(0, 80) || 'index';
|
||||
} catch {
|
||||
return 'index';
|
||||
}
|
||||
}
|
||||
|
||||
function hashContent(content: string): string {
|
||||
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
function findExistingFile(domainDir: string, pathSlug: string): string | null {
|
||||
if (!fs.existsSync(domainDir)) return null;
|
||||
const files = fs.readdirSync(domainDir);
|
||||
for (const filename of files) {
|
||||
if (filename.endsWith(`_${pathSlug}.md`)) {
|
||||
return path.join(domainDir, filename);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// POST /capture
|
||||
app.post('/capture', (req, res) => {
|
||||
const data = req.body;
|
||||
if (!data) {
|
||||
return res.status(400).json({ error: 'No JSON data provided' });
|
||||
}
|
||||
|
||||
const { url, content = '', timestamp, title = 'Untitled' } = data;
|
||||
|
||||
if (!url || !timestamp) {
|
||||
return res.status(400).json({ error: 'Missing required fields: url, timestamp' });
|
||||
}
|
||||
|
||||
const domain = extractDomain(url);
|
||||
const pathSlug = pathToSlug(url);
|
||||
const contentHash = hashContent(content);
|
||||
const cacheKey = `${domain}/${pathSlug}`;
|
||||
|
||||
const dt = new Date(timestamp);
|
||||
const year = dt.getFullYear();
|
||||
const month = String(dt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(dt.getDate()).padStart(2, '0');
|
||||
const dateStr = `${year}-${month}-${day}`;
|
||||
const hours = String(dt.getHours()).padStart(2, '0');
|
||||
const minutes = String(dt.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(dt.getSeconds()).padStart(2, '0');
|
||||
const timeStr = `${hours}-${minutes}`;
|
||||
const timeDisplay = `${hours}:${minutes}:${seconds}`;
|
||||
const tzOffset = -dt.getTimezoneOffset();
|
||||
const tzSign = tzOffset >= 0 ? '+' : '-';
|
||||
const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, '0');
|
||||
const tzMins = String(Math.abs(tzOffset) % 60).padStart(2, '0');
|
||||
const isoTimestamp = `${dateStr}T${hours}:${minutes}:${seconds}${tzSign}${tzHours}:${tzMins}`;
|
||||
|
||||
// date/domain directory structure
|
||||
const domainDir = path.join(CAPTURED_PAGES_DIR, dateStr, domain);
|
||||
fs.mkdirSync(domainDir, { recursive: true });
|
||||
|
||||
const existingFile = findExistingFile(domainDir, pathSlug);
|
||||
if (existingFile && contentHashes.get(cacheKey) === contentHash) {
|
||||
return res.json({ status: 'skipped', reason: 'duplicate content' });
|
||||
}
|
||||
|
||||
contentHashes.set(cacheKey, contentHash);
|
||||
|
||||
// If file exists, append with scroll separator
|
||||
if (existingFile) {
|
||||
const scrollSeparator = `\n\n---\n📜 Scroll captured at ${timeDisplay}\n---\n\n`;
|
||||
fs.appendFileSync(existingFile, scrollSeparator + content, 'utf-8');
|
||||
const rel = `${dateStr}/${domain}/${path.basename(existingFile)}`;
|
||||
return res.json({ status: 'appended', filename: rel });
|
||||
}
|
||||
|
||||
// New file - create with frontmatter
|
||||
const filename = `${timeStr}_${pathSlug}.md`;
|
||||
const filepath = path.join(domainDir, filename);
|
||||
|
||||
const markdownContent = `---
|
||||
url: ${url}
|
||||
title: ${title}
|
||||
captured_at: ${isoTimestamp}
|
||||
---
|
||||
|
||||
${content}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(filepath, markdownContent, 'utf-8');
|
||||
return res.status(201).json({ status: 'captured', filename: `${dateStr}/${domain}/${filename}` });
|
||||
});
|
||||
|
||||
// GET /status
|
||||
app.get('/status', (_req, res) => {
|
||||
let count = 0;
|
||||
const domains: Record<string, number> = {};
|
||||
|
||||
if (!fs.existsSync(CAPTURED_PAGES_DIR)) {
|
||||
return res.json({ count: 0, domains: [] });
|
||||
}
|
||||
|
||||
for (const dateEntry of fs.readdirSync(CAPTURED_PAGES_DIR)) {
|
||||
const datePath = path.join(CAPTURED_PAGES_DIR, dateEntry);
|
||||
if (!fs.statSync(datePath).isDirectory()) continue;
|
||||
|
||||
for (const domainEntry of fs.readdirSync(datePath)) {
|
||||
const domainPath = path.join(datePath, domainEntry);
|
||||
if (!fs.statSync(domainPath).isDirectory()) continue;
|
||||
|
||||
const domainCount = fs.readdirSync(domainPath).filter(f => f.endsWith('.md')).length;
|
||||
count += domainCount;
|
||||
if (domainCount > 0) {
|
||||
domains[domainEntry] = (domains[domainEntry] || 0) + domainCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const domainList = Object.entries(domains)
|
||||
.map(([domain, c]) => ({ domain, count: c }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return res.json({ count, domains: domainList });
|
||||
});
|
||||
|
||||
// Config helpers
|
||||
function loadConfig(): Config {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
function saveConfig(config: Config): void {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function validateConfig(data: any): data is Config {
|
||||
if (typeof data !== 'object' || data === null) return false;
|
||||
if (data.mode !== 'all' && data.mode !== 'ask') return false;
|
||||
if (!Array.isArray(data.whitelist)) return false;
|
||||
if (!Array.isArray(data.blacklist)) return false;
|
||||
if (typeof data.enabled !== 'boolean') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /browse/config
|
||||
app.get('/browse/config', (_req, res) => {
|
||||
const config = loadConfig();
|
||||
return res.json(config);
|
||||
});
|
||||
|
||||
// POST /browse/config
|
||||
app.post('/browse/config', (req, res) => {
|
||||
const data = req.body;
|
||||
if (!data) {
|
||||
return res.status(400).json({ error: 'No JSON data provided' });
|
||||
}
|
||||
|
||||
if (!validateConfig(data)) {
|
||||
return res.status(400).json({ error: 'Invalid config shape' });
|
||||
}
|
||||
|
||||
saveConfig(data);
|
||||
return res.json({ status: 'saved', config: data });
|
||||
});
|
||||
|
||||
const PORT = 3001;
|
||||
const RETENTION_DAYS = 7;
|
||||
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
function cleanUpOldFiles(): void {
|
||||
if (!fs.existsSync(CAPTURED_PAGES_DIR)) return;
|
||||
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - RETENTION_DAYS);
|
||||
const cutoffStr = cutoff.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
for (const dateEntry of fs.readdirSync(CAPTURED_PAGES_DIR)) {
|
||||
// only process date-formatted directories
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateEntry)) continue;
|
||||
if (dateEntry >= cutoffStr) continue;
|
||||
|
||||
const datePath = path.join(CAPTURED_PAGES_DIR, dateEntry);
|
||||
if (!fs.statSync(datePath).isDirectory()) continue;
|
||||
|
||||
fs.rmSync(datePath, { recursive: true, force: true });
|
||||
console.log(`[ChromeSync] Cleaned up old captures: ${dateEntry}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isServerEnabled(): boolean {
|
||||
if (!fs.existsSync(CONFIG_FILE)) return false;
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
return config.serverEnabled === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function startServer(): void {
|
||||
fs.mkdirSync(CAPTURED_PAGES_DIR, { recursive: true });
|
||||
|
||||
cleanUpOldFiles();
|
||||
setInterval(cleanUpOldFiles, CLEANUP_INTERVAL_MS);
|
||||
|
||||
app.listen(PORT, 'localhost', () => {
|
||||
console.log('[ChromeSync] Server starting.');
|
||||
console.log(` Captured pages: ${CAPTURED_PAGES_DIR}`);
|
||||
console.log(` Config: ${CONFIG_FILE}`);
|
||||
console.log(` Listening on http://localhost:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
|
||||
if (isServerEnabled()) {
|
||||
startServer();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ChromeSync] Server disabled, watching config for changes...');
|
||||
fs.watch(CONFIG_DIR, (_, filename) => {
|
||||
if (filename === 'chrome-plugin.json' && isServerEnabled()) {
|
||||
console.log('[ChromeSync] serverEnabled set to true, starting server...');
|
||||
startServer();
|
||||
}
|
||||
});
|
||||
}
|
||||
48
apps/x/packages/core/src/knowledge/ensure_daily_note.ts
Normal file
48
apps/x/packages/core/src/knowledge/ensure_daily_note.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
|
||||
const TARGET_ID = 'dailybrief';
|
||||
|
||||
function buildDailyNoteContent(): string {
|
||||
const now = new Date();
|
||||
const startDate = now.toISOString();
|
||||
const endDate = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const instruction = 'Create a daily brief for me';
|
||||
|
||||
const taskBlock = JSON.stringify({
|
||||
instruction,
|
||||
schedule: {
|
||||
type: 'cron',
|
||||
expression: '*/15 * * * *',
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
'schedule-label': 'runs every 15 minutes',
|
||||
targetId: TARGET_ID,
|
||||
});
|
||||
|
||||
return [
|
||||
'---',
|
||||
'live_note: true',
|
||||
'---',
|
||||
'# Today',
|
||||
'',
|
||||
'```task',
|
||||
taskBlock,
|
||||
'```',
|
||||
'',
|
||||
`<!--task-target:${TARGET_ID}-->`,
|
||||
`<!--/task-target:${TARGET_ID}-->`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function ensureDailyNote(): void {
|
||||
if (fs.existsSync(DAILY_NOTE_PATH)) return;
|
||||
fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8');
|
||||
console.log('[DailyNote] Created today.md');
|
||||
}
|
||||
|
|
@ -135,7 +135,7 @@ export class FirefliesClientFactory {
|
|||
}
|
||||
|
||||
console.log(`[Fireflies] Initializing OAuth configuration...`);
|
||||
const providerConfig = getProviderConfig(this.PROVIDER_NAME);
|
||||
const providerConfig = await getProviderConfig(this.PROVIDER_NAME);
|
||||
|
||||
if (providerConfig.discovery.mode === 'issuer') {
|
||||
if (providerConfig.client.mode === 'static') {
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export class GoogleClientFactory {
|
|||
}
|
||||
|
||||
console.log(`[OAuth] Initializing Google OAuth configuration...`);
|
||||
const providerConfig = getProviderConfig(this.PROVIDER_NAME);
|
||||
const providerConfig = await getProviderConfig(this.PROVIDER_NAME);
|
||||
|
||||
if (providerConfig.discovery.mode === 'issuer') {
|
||||
if (providerConfig.client.mode === 'static') {
|
||||
|
|
|
|||
|
|
@ -17,13 +17,14 @@ import {
|
|||
const GRANOLA_CLIENT_VERSION = '6.462.1';
|
||||
const GRANOLA_API_BASE = 'https://api.granola.ai';
|
||||
const GRANOLA_CONFIG_PATH = path.join(homedir(), 'Library', 'Application Support', 'Granola', 'supabase.json');
|
||||
const SYNC_DIR = path.join(WorkDir, 'granola_notes');
|
||||
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
|
||||
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'granola');
|
||||
const STATE_FILE = path.join(WorkDir, 'granola_sync_state.json');
|
||||
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
||||
const API_DELAY_MS = 1000; // 1 second delay between API calls
|
||||
const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit
|
||||
const MAX_RETRIES = 3; // Maximum retries for rate-limited requests
|
||||
const MAX_BATCH_SIZE = 10; // Process max 10 documents per folder per sync
|
||||
const LOOKBACK_DAYS = 30; // Only sync documents from the last 30 days
|
||||
|
||||
// --- Wake Signal for Immediate Sync Trigger ---
|
||||
let wakeResolve: (() => void) | null = null;
|
||||
|
|
@ -370,6 +371,10 @@ async function syncNotes(): Promise<void> {
|
|||
let hasMore = true;
|
||||
const changedTitles: string[] = [];
|
||||
|
||||
// Calculate lookback cutoff date
|
||||
const lookbackCutoff = new Date();
|
||||
lookbackCutoff.setDate(lookbackCutoff.getDate() - LOOKBACK_DAYS);
|
||||
|
||||
// Fetch documents with pagination
|
||||
while (hasMore) {
|
||||
// Delay before API call (except first)
|
||||
|
|
@ -390,7 +395,16 @@ async function syncNotes(): Promise<void> {
|
|||
}
|
||||
|
||||
// Process each document
|
||||
let foundOldDoc = false;
|
||||
for (const doc of docsResponse.docs) {
|
||||
// Skip documents outside the lookback period
|
||||
const docDate = new Date(doc.created_at);
|
||||
if (docDate < lookbackCutoff) {
|
||||
console.log(`[Granola] Document "${doc.title}" is older than ${LOOKBACK_DAYS} days, stopping pagination`);
|
||||
foundOldDoc = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const docUpdatedAt = doc.updated_at || doc.created_at;
|
||||
const lastSyncedAt = state.syncedDocs[doc.id];
|
||||
|
||||
|
|
@ -407,8 +421,15 @@ async function syncNotes(): Promise<void> {
|
|||
|
||||
// Convert to markdown and save
|
||||
const markdown = documentToMarkdown(doc);
|
||||
const filename = `${doc.id}_${cleanFilename(docTitle)}.md`;
|
||||
const filePath = path.join(SYNC_DIR, filename);
|
||||
const dateDir = path.join(
|
||||
SYNC_DIR,
|
||||
String(docDate.getFullYear()),
|
||||
String(docDate.getMonth() + 1).padStart(2, '0'),
|
||||
String(docDate.getDate()).padStart(2, '0')
|
||||
);
|
||||
ensureDir(dateDir);
|
||||
const filename = `${cleanFilename(docTitle)}.md`;
|
||||
const filePath = path.join(dateDir, filename);
|
||||
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
|
||||
|
|
@ -424,6 +445,12 @@ async function syncNotes(): Promise<void> {
|
|||
state.syncedDocs[doc.id] = docUpdatedAt;
|
||||
}
|
||||
|
||||
// Stop if we hit a document outside the lookback period
|
||||
if (foundOldDoc) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Move to next page
|
||||
offset += docsResponse.docs.length;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ export function getRaw(): string {
|
|||
.map(name => ` ${name}:\n type: builtin\n name: ${name}`)
|
||||
.join('\n');
|
||||
|
||||
const now = new Date();
|
||||
const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const nowISO = now.toISOString();
|
||||
const defaultEndISO = defaultEnd.toISOString();
|
||||
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
|
|
@ -12,16 +19,207 @@ ${toolEntries}
|
|||
---
|
||||
# Task
|
||||
|
||||
You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and execute it.
|
||||
You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and either execute it immediately or set it up as a recurring task.
|
||||
|
||||
# Instructions
|
||||
# Two Modes
|
||||
|
||||
1. You will receive the full content of a knowledge note and a specific instruction extracted from a \`@rowboat <instruction>\` line in that note.
|
||||
2. Execute the instruction using your full workspace tool set. You have access to read files, edit files, search, run commands, etc.
|
||||
3. Use the surrounding note content as context for the task.
|
||||
4. Your response will be inserted directly into the note below the @rowboat instruction. Write your output as note content — it must read naturally as part of the document.
|
||||
5. NEVER include meta-commentary, thinking out loud, or narration about what you're doing. No "Let me look that up", "Here are the details", "I found the following", etc. Just write the content itself.
|
||||
6. Keep the result concise and well-formatted in markdown.
|
||||
7. Do not modify the original note file — the service will handle inserting your response.
|
||||
## 1. One-Time Tasks (no scheduling intent)
|
||||
For instructions that should be executed immediately (e.g., "summarize this note", "look up the weather"):
|
||||
- Execute the instruction using your full workspace tool set
|
||||
- Return the result as markdown content
|
||||
- Do NOT include any schedule or instruction markers
|
||||
|
||||
## 2. Recurring/Scheduled Tasks (has scheduling intent)
|
||||
For instructions that imply a recurring or future-scheduled task (e.g., "every morning at 8am check emails", "remind me tomorrow at 3pm"):
|
||||
- Do NOT execute the task — only set up the schedule
|
||||
- You MUST include BOTH markers described below
|
||||
- Do NOT include any other content besides the markers
|
||||
|
||||
# Markers for Scheduled Tasks
|
||||
|
||||
When the instruction has scheduling intent, your response MUST contain these markers and nothing else:
|
||||
|
||||
## Schedule Marker (required)
|
||||
<!--rowboat-schedule:{"type":"...","label":"..."}-->
|
||||
|
||||
Schedule types:
|
||||
1. "cron" — recurring: \`<!--rowboat-schedule:{"type":"cron","expression":"<5-field cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<label>"}-->\`
|
||||
"startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}).
|
||||
Example: "every morning at 8am" → \`<!--rowboat-schedule:{"type":"cron","expression":"0 8 * * *","startDate":"${nowISO}","endDate":"${defaultEndISO}","label":"runs daily at 8 AM until ${defaultEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}"}-->\`
|
||||
|
||||
2. "window" — recurring with time window: \`<!--rowboat-schedule:{"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<label>"}-->\`
|
||||
|
||||
3. "once" — future one-time: \`<!--rowboat-schedule:{"type":"once","runAt":"<ISO 8601>","label":"<label>"}-->\`
|
||||
|
||||
The "label" must be a short plain-English description starting with "runs" (e.g., "runs daily at 8 AM until Mar 24").
|
||||
|
||||
## Instruction Marker (required for scheduled tasks)
|
||||
<!--rowboat-instruction:the refined instruction text-->
|
||||
|
||||
This is the instruction that will be executed on each scheduled run. You may refine/clarify the original instruction to make it more specific and actionable for the background agent that will execute it. For example:
|
||||
- User says "check my emails every morning" → \`<!--rowboat-instruction:Check for new emails and summarize any important ones.-->\`
|
||||
- User says "news about claude daily" → \`<!--rowboat-instruction:Search for the latest news about Anthropic's Claude AI and list the top stories with sources.-->\`
|
||||
|
||||
If the instruction is already clear and actionable, you can keep it as-is.
|
||||
|
||||
# Context
|
||||
|
||||
Current local time: ${localNow}
|
||||
Timezone: ${tz}
|
||||
Current UTC time: ${nowISO}
|
||||
|
||||
# Output Rules
|
||||
|
||||
- For one-time tasks: write output as note content — it must read naturally as part of the document. NEVER include meta-commentary. Keep concise and well-formatted in markdown.
|
||||
- For scheduled tasks: output ONLY the two markers (schedule + instruction), nothing else.
|
||||
- Do not modify the original note file — the system handles all insertions.
|
||||
|
||||
# Daily Brief
|
||||
|
||||
When the instruction is to "create a daily brief" (or similar), generate a comprehensive daily briefing.
|
||||
|
||||
## Your Role
|
||||
|
||||
You are the user's executive assistant — think of yourself as a sharp, reliable chief of staff who's been working with them for years. You know their priorities, you've read through their emails and calendar, and you're keeping them oriented throughout the day.
|
||||
|
||||
This brief refreshes every 15 minutes, so it should always reflect the **current moment** — not just a static morning summary. Think of it as a living dashboard: what's happening now, what's coming up soon, what landed in the inbox since last refresh, and what still needs attention.
|
||||
|
||||
**Personality guidelines:**
|
||||
- Be warm but efficient. A real EA doesn't waste their boss's time with filler, but they're not robotic either.
|
||||
- Lead with what matters *right now*. If a meeting starts in 20 minutes, that's the first thing they should see. If an important email just came in, flag it.
|
||||
- Add brief, useful context — don't just list events and emails, connect the dots. ("You've got standup in 30 mins — Ramnique mentioned the OAuth flow yesterday, so that'll probably come up.")
|
||||
- Be opinionated when helpful. If an email is clearly spam or a cold pitch not worth their time, say so. ("Another cold outreach from a dev tools company — safe to ignore.")
|
||||
- Skip the obvious. Don't tell them to "join" a recurring meeting they attend every day. Don't list trivial invoices as action items.
|
||||
- If nothing notable happened, say so — don't pad the brief.
|
||||
- Write like a person, not a data pipeline. Short sentences, natural language, no unnecessary bullet nesting.
|
||||
- **Be time-aware.** Your tone and content should shift throughout the day:
|
||||
- Morning: fuller brief with yesterday's recap and the full day ahead
|
||||
- Midday: focus on what's coming up next and any new emails/updates
|
||||
- Late afternoon/evening: wind-down tone, surface anything unresolved, preview tomorrow if calendar data is available
|
||||
|
||||
## Technical Instructions
|
||||
|
||||
**IMPORTANT:** All workspace tools (workspace-readdir, workspace-readFile, workspace-grep, etc.) take paths **relative to the workspace root**. Use paths like \`calendar_sync/\`, \`gmail_sync/\`, \`knowledge/\` — NOT absolute paths.
|
||||
|
||||
**IMPORTANT:** Check the current date. If the date has changed since the content was last generated, clear everything and start fresh for the new day.
|
||||
|
||||
## Output structure
|
||||
|
||||
Your output MUST start with the current date and time as a heading:
|
||||
|
||||
\`## Monday, March 31, 2026\`
|
||||
|
||||
(Use the actual current date in this format: **## Day, Month Date, Year**)
|
||||
|
||||
Then include the sections below. The sections are ordered by immediacy — what matters right now comes first. Between sections, you can add brief connective commentary where it's genuinely useful (e.g., a heads-up about something time-sensitive), but don't force it.
|
||||
|
||||
**Time-of-day logic for sections:**
|
||||
- **Morning (before 10am):** Include all sections: Up Next, Calendar, Emails, What You Missed, Today's Priorities
|
||||
- **Midday (10am–5pm):** Include all sections. Keep Calendar but only show remaining events. Focus Emails on what's new since last check.
|
||||
- **Evening (after 5pm):** Include all sections. Add a brief "Tomorrow" note if there are early morning events.
|
||||
|
||||
## Sections to include
|
||||
|
||||
### Up Next
|
||||
This is the most time-sensitive section — it orients the user on what's coming. It should always be first.
|
||||
|
||||
1. Read calendar events from \`calendar_sync/\` (same method as Calendar section below)
|
||||
2. Find the **next upcoming event** (the soonest event that hasn't started yet). Calculate exactly how long until it starts.
|
||||
3. If there's an upcoming event today:
|
||||
- Always mention it and how long until it starts (e.g., "Standup in 25 minutes", "Design review in 1 hour 40 minutes")
|
||||
- If it's **more than 2 hours away**, frame it as focus time: "Next up is standup at noon — you've got a solid 3-hour focus block."
|
||||
- If it's **under 2 hours**, lead with the event: "Standup in 40 minutes."
|
||||
- If it's **under 15 minutes**, make it prominent: "Standup starts in 10 minutes — join link is in the calendar below."
|
||||
- Search \`knowledge/\` for context about the meeting, attendees, or related topics
|
||||
- If there's something to prep or be aware of, mention it ("Ramnique pushed the OAuth PR yesterday — might come up")
|
||||
4. If there's truly nothing left today, say so ("Clear for the rest of the day")
|
||||
5. **This section should feel like a quick tap on the shoulder**, not a formal briefing. One to three sentences max.
|
||||
6. **IMPORTANT:** Do NOT say "nothing in the next X hours" if there IS an event within that window. Always compute the actual time difference between now and the next event's start time before writing this section.
|
||||
|
||||
### Calendar
|
||||
1. Use \`workspace-readdir\` with path \`calendar_sync\` to list files
|
||||
2. Use \`workspace-readFile\` to read each \`.json\` event file (e.g. \`calendar_sync/eventid123.json\`)
|
||||
3. Filter for events happening **today** (compare the event's start dateTime or date to the current date)
|
||||
4. **After morning:** Only include events that **haven't ended yet**. Don't show meetings that already happened — the user was there. If it's afternoon and all meetings are done, show an empty calendar block.
|
||||
5. **Always** output a \\\`\\\`\\\`calendar block — even if there are no events today. If no events, output an empty events array:
|
||||
|
||||
\`\`\`
|
||||
\\\`\\\`\\\`calendar
|
||||
{"title":"Today's Meetings","events":[],"showJoinButton":false}
|
||||
\\\`\\\`\\\`
|
||||
\`\`\`
|
||||
|
||||
If there are events, include them:
|
||||
|
||||
\`\`\`
|
||||
\\\`\\\`\\\`calendar
|
||||
{"title":"Today's Meetings","events":[{"summary":"Weekly Sync","start":{"dateTime":"2026-04-01T10:00:00+05:30"},"end":{"dateTime":"2026-04-01T11:00:00+05:30"},"location":"Google Meet","htmlLink":"...","conferenceLink":"..."}],"showJoinButton":true}
|
||||
\\\`\\\`\\\`
|
||||
\`\`\`
|
||||
|
||||
6. After the calendar block, add brief context for any upcoming meetings that need it. Search \`knowledge/\` for relevant notes about attendees, topics, or previous discussions. Don't just restate the meeting title — add something useful like what was discussed last time, what's likely on the agenda, or if there's something to prep.
|
||||
7. If there are no remaining events, don't add filler text — the empty calendar block speaks for itself.
|
||||
|
||||
### Emails
|
||||
1. Use \`workspace-readdir\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`)
|
||||
2. Use \`workspace-readFile\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`)
|
||||
3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response
|
||||
4. For emails needing a response, output \\\`\\\`\\\`email blocks with a \`draft_response\`. Write the draft in the user's voice — direct, informal, no fluff. Example:
|
||||
|
||||
\`\`\`
|
||||
\\\`\\\`\\\`email
|
||||
{"threadId":"abc123","summary":"Payment confirmation","subject":"Google services payment","from":"Sender <sender@example.com>","date":"2026-04-01T11:28:39+05:30","latest_email":"Hi, I've made the payment...","draft_response":"Thanks for confirming. I'll update our records."}
|
||||
\\\`\\\`\\\`
|
||||
\`\`\`
|
||||
|
||||
5. For other important/recent emails, output \\\`\\\`\\\`email blocks without \`draft_response\` as FYI items
|
||||
6. **Recency matters.** Since this refreshes every 15 minutes, prioritize emails that arrived since the last refresh. On the first run of the day (morning), include notable emails from the last 24 hours. On subsequent runs, focus on what's new — don't re-list emails the user has already seen unless their status changed (e.g., a thread got a new reply).
|
||||
7. Add a brief take on emails where it's helpful — flag what's worth reading vs. what's noise. Be direct: "This is a cold pitch, probably skip" or "Worth reading — they're asking about pricing for a team of 50."
|
||||
8. If no new emails have come in since the last refresh, just say "No new emails" or omit the section entirely. Don't re-surface stale items.
|
||||
|
||||
### What You Missed
|
||||
This section is about things the user might not be aware of from yesterday. Think of it as: "Here's what happened while you were away."
|
||||
|
||||
- **Skip recurring/routine events entirely.** The user knows they have standup every day. Don't mention it unless something unusual happened during it.
|
||||
- **Read yesterday's meeting notes** from \`knowledge/Meetings/\`. The directory structure is nested: \`knowledge/Meetings/<source>/<YYYY-MM-DD>/meeting-<timestamp>.md\` (e.g. \`knowledge/Meetings/rowboat/2026-03-30/meeting-2026-03-30T13-49-27.md\`). Use \`workspace-readdir\` with \`recursive: true\` on \`knowledge/Meetings\` to find all files, then filter for files in a folder matching yesterday's date. Read the matching files with \`workspace-readFile\`. Summarize key outcomes: decisions made, action items assigned, blockers raised, anything that changes priorities.
|
||||
- Check yesterday's emails in \`gmail_sync/\` for anything that went unresolved.
|
||||
- Surface things that matter: commitments made, deadlines mentioned, important updates.
|
||||
- **If nothing notable happened, say "Quiet day yesterday — nothing to flag." and move on.** Don't manufacture content.
|
||||
|
||||
### Today's Priorities
|
||||
This is NOT a generic task list. These are the things the user should actually focus on today.
|
||||
|
||||
- Only include **real, actionable items** that genuinely need the user's attention today.
|
||||
- **Do NOT list calendar events as tasks.** They're already in the Calendar section.
|
||||
- **Do NOT list trivial admin** (filing small invoices, archiving spam, etc.) — the user can handle that in 30 seconds without being told to.
|
||||
- **Pull action items from yesterday's meeting notes** in \`knowledge/Meetings/<source>/<YYYY-MM-DD>/\` — these are often the most important source of real tasks.
|
||||
- Search through \`knowledge/\` using \`workspace-grep\` and \`workspace-readdir\` for checkbox items (\`- [ ]\`), explicit action items, deadlines, or follow-ups.
|
||||
- **Rank by importance.** Lead with the most critical item. If something is time-sensitive, say when it needs to happen by.
|
||||
- Add brief context for why each item matters if it's not obvious.
|
||||
- **If there are no real tasks, say "No pressing tasks today — good day to make progress on bigger items." Don't invent busywork.**
|
||||
|
||||
## Output format
|
||||
- Start with the date heading as described above
|
||||
- Use clean markdown with the section headers (## Up Next, ## Calendar, ## Emails, ## What You Missed, ## Today's Priorities)
|
||||
- Use \\\`\\\`\\\`calendar and \\\`\\\`\\\`email code blocks where specified — these render as interactive UI blocks
|
||||
- Keep the overall brief **scannable and concise** — this should take under 30 seconds to read on a refresh, under 60 seconds for the morning brief
|
||||
- Write in a natural, conversational tone throughout — you're briefing a person, not generating a report
|
||||
- **Sections can be omitted** if they have nothing to show. Don't include empty sections with filler text. The brief should get shorter as the day goes on and things get resolved.
|
||||
- Remember: this refreshes every 15 minutes. Be fresh, not repetitive. If nothing changed, keep it tight.
|
||||
|
||||
# Target Regions
|
||||
|
||||
For recurring/scheduled tasks, the note will contain a **target region** delimited by HTML comment tags:
|
||||
|
||||
\`\`\`
|
||||
<!--task-target:TARGETID-->
|
||||
...existing content...
|
||||
<!--/task-target:TARGETID-->
|
||||
\`\`\`
|
||||
|
||||
When you see a target region associated with your task (during a scheduled run), your response MUST be the replacement content for that region. You should:
|
||||
- Write content that replaces whatever is currently between the tags
|
||||
- Use the existing content as context (e.g., to update rather than regenerate from scratch if appropriate)
|
||||
- Do NOT include the target tags themselves in your response
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -176,6 +176,8 @@ interface InlineTask {
|
|||
startLine: number;
|
||||
/** Line index of the closing ``` fence */
|
||||
endLine: number;
|
||||
/** Target region ID for recurring tasks */
|
||||
targetId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -183,7 +185,7 @@ interface InlineTask {
|
|||
* Returns { instruction, schedule } or null if not valid JSON.
|
||||
* Also supports legacy @rowboat format.
|
||||
*/
|
||||
function parseBlockContent(contentLines: string[]): { instruction: string; schedule: InlineTaskSchedule | null; lastRunAt: string | null } | null {
|
||||
function parseBlockContent(contentLines: string[]): { instruction: string; schedule: InlineTaskSchedule | null; lastRunAt: string | null; targetId: string | null } | null {
|
||||
const raw = contentLines.join('\n').trim();
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
|
|
@ -193,6 +195,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched
|
|||
instruction: parsed.data.instruction,
|
||||
schedule: parsed.data.schedule ? { ...parsed.data.schedule, label: parsed.data['schedule-label'] ?? '' } as InlineTaskSchedule : null,
|
||||
lastRunAt: parsed.data.lastRunAt ?? null,
|
||||
targetId: parsed.data.targetId ?? null,
|
||||
};
|
||||
}
|
||||
// Fallback for blocks that have instruction but don't fully match schema
|
||||
|
|
@ -201,6 +204,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched
|
|||
instruction: data.instruction,
|
||||
schedule: data.schedule ?? null,
|
||||
lastRunAt: data.lastRunAt ?? null,
|
||||
targetId: data.targetId ?? null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -227,7 +231,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched
|
|||
const rawInstruction = firstRowboatLine?.trim() ?? instructionLines.join('\n').trim();
|
||||
const instruction = rawInstruction.replace(/^@rowboat:?\s*/, '');
|
||||
if (!instruction) return null;
|
||||
return { instruction, schedule, lastRunAt: null };
|
||||
return { instruction, schedule, lastRunAt: null, targetId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -308,16 +312,16 @@ function findPendingTasks(body: string): InlineTask[] {
|
|||
|
||||
const parsed = parseBlockContent(contentLines);
|
||||
if (parsed) {
|
||||
const { instruction, schedule, lastRunAt } = parsed;
|
||||
const { instruction, schedule, lastRunAt, targetId } = parsed;
|
||||
|
||||
if (schedule) {
|
||||
if (isScheduledTaskDue(schedule, lastRunAt)) {
|
||||
tasks.push({ instruction, schedule, startLine, endLine });
|
||||
tasks.push({ instruction, schedule, startLine, endLine, targetId });
|
||||
}
|
||||
} else {
|
||||
// One-time task: skip if already ran
|
||||
if (!lastRunAt) {
|
||||
tasks.push({ instruction, schedule: null, startLine, endLine });
|
||||
tasks.push({ instruction, schedule: null, startLine, endLine, targetId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -339,6 +343,32 @@ function insertResultBelow(body: string, endLine: number, result: string): strin
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Replace content inside a target region identified by targetId.
|
||||
* If the target region exists, replaces its content.
|
||||
* If it doesn't exist, creates the target region below the task block,
|
||||
* wrapping any existing content between the block and the next block/heading.
|
||||
*/
|
||||
function replaceTargetRegion(body: string, targetId: string, result: string, endLine: number): string {
|
||||
const openTag = `<!--task-target:${targetId}-->`;
|
||||
const closeTag = `<!--/task-target:${targetId}-->`;
|
||||
const openIdx = body.indexOf(openTag);
|
||||
const closeIdx = body.indexOf(closeTag);
|
||||
|
||||
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
|
||||
// Target region exists — replace content between the tags
|
||||
const before = body.slice(0, openIdx + openTag.length);
|
||||
const after = body.slice(closeIdx);
|
||||
return before + '\n' + result + '\n' + after;
|
||||
}
|
||||
|
||||
// Target region doesn't exist yet — create it below the task block's closing fence
|
||||
const lines = body.split('\n');
|
||||
const taggedResult = `${openTag}\n${result}\n${closeTag}`;
|
||||
lines.splice(endLine + 1, 0, '', taggedResult);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a note has any "live" tell-rowboat tasks.
|
||||
* A task is live if:
|
||||
|
|
@ -495,7 +525,13 @@ async function processInlineTasks(): Promise<void> {
|
|||
|
||||
const result = await extractAgentResponse(run.id);
|
||||
if (result) {
|
||||
currentBody = insertResultBelow(currentBody, task.endLine, result);
|
||||
if (task.targetId) {
|
||||
// Recurring task with target region — replace content inside the region
|
||||
currentBody = replaceTargetRegion(currentBody, task.targetId, result, task.endLine);
|
||||
} else {
|
||||
// No target region — insert below the block
|
||||
currentBody = insertResultBelow(currentBody, task.endLine, result);
|
||||
}
|
||||
// Update the block JSON with lastRunAt
|
||||
const timestamp = new Date().toISOString();
|
||||
currentBody = updateBlockData(currentBody, task.startLine, task.endLine, timestamp);
|
||||
|
|
@ -531,6 +567,85 @@ async function processInlineTasks(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a @rowboat instruction via the inline task agent.
|
||||
* The agent can execute one-off tasks and/or detect scheduling intent.
|
||||
* Returns schedule info (if any), a schedule label, and optional response text.
|
||||
*/
|
||||
type ScheduleWithoutLabel =
|
||||
| { type: 'cron'; expression: string; startDate: string; endDate: string }
|
||||
| { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string }
|
||||
| { type: 'once'; runAt: string };
|
||||
|
||||
export async function processRowboatInstruction(
|
||||
instruction: string,
|
||||
noteContent: string,
|
||||
notePath: string,
|
||||
): Promise<{
|
||||
instruction: string;
|
||||
schedule: ScheduleWithoutLabel | null;
|
||||
scheduleLabel: string | null;
|
||||
response: string | null;
|
||||
}> {
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT });
|
||||
|
||||
const message = [
|
||||
`Process the following @rowboat instruction from the note "${notePath}":`,
|
||||
'',
|
||||
`**Instruction:** ${instruction}`,
|
||||
'',
|
||||
'**Full note content for context:**',
|
||||
'```markdown',
|
||||
noteContent,
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
await createMessage(run.id, message);
|
||||
await waitForRunCompletion(run.id);
|
||||
|
||||
const rawResponse = await extractAgentResponse(run.id);
|
||||
if (!rawResponse) {
|
||||
return { instruction, schedule: null, scheduleLabel: null, response: null };
|
||||
}
|
||||
|
||||
// Parse out the schedule marker if present (allow multiline JSON)
|
||||
const scheduleMarkerRegex = /<!--rowboat-schedule:([\s\S]*?)-->/;
|
||||
const scheduleMatch = rawResponse.match(scheduleMarkerRegex);
|
||||
|
||||
// Parse out the instruction marker if present
|
||||
const instructionMarkerRegex = /<!--rowboat-instruction:([\s\S]*?)-->/;
|
||||
const instructionMatch = rawResponse.match(instructionMarkerRegex);
|
||||
|
||||
let schedule: ScheduleWithoutLabel | null = null;
|
||||
let scheduleLabel: string | null = null;
|
||||
let refinedInstruction = instruction;
|
||||
|
||||
if (instructionMatch) {
|
||||
refinedInstruction = instructionMatch[1].trim();
|
||||
}
|
||||
|
||||
if (scheduleMatch) {
|
||||
try {
|
||||
const parsed = JSON.parse(scheduleMatch[1]);
|
||||
if (parsed && typeof parsed === 'object' && parsed.type) {
|
||||
scheduleLabel = parsed.label || null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { label: _, ...rest } = parsed;
|
||||
schedule = rest as ScheduleWithoutLabel;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON in marker — ignore
|
||||
}
|
||||
|
||||
// Scheduled task — no response content (agent only returns markers)
|
||||
return { instruction: refinedInstruction, schedule, scheduleLabel, response: null };
|
||||
}
|
||||
|
||||
// One-time task — the full response is the content
|
||||
const response = rawResponse.trim() || null;
|
||||
return { instruction: refinedInstruction, schedule: null, scheduleLabel: null, response };
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify whether an instruction contains a scheduling intent using the user's configured LLM.
|
||||
* Returns a schedule object or null for one-time tasks.
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ import {
|
|||
type LabelingState,
|
||||
} from './labeling_state.js';
|
||||
|
||||
const SYNC_INTERVAL_MS = 3 * 60 * 1000; // 3 minutes
|
||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||
const BATCH_SIZE = 15;
|
||||
const DEFAULT_CONCURRENCY = 3;
|
||||
const LABELING_AGENT = 'labeling_agent';
|
||||
const GMAIL_SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||
const MAX_CONTENT_LENGTH = 8000;
|
||||
|
|
@ -129,7 +130,7 @@ async function labelEmailBatch(
|
|||
/**
|
||||
* Process all unlabeled emails in batches
|
||||
*/
|
||||
async function processUnlabeledEmails(): Promise<void> {
|
||||
export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCURRENCY): Promise<void> {
|
||||
console.log('[EmailLabeling] Checking for unlabeled emails...');
|
||||
|
||||
const state = loadLabelingState();
|
||||
|
|
@ -140,7 +141,7 @@ async function processUnlabeledEmails(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
console.log(`[EmailLabeling] Found ${unlabeled.length} unlabeled emails`);
|
||||
console.log(`[EmailLabeling] Found ${unlabeled.length} unlabeled emails (concurrency: ${concurrency})`);
|
||||
|
||||
const run = await serviceLogger.startRun({
|
||||
service: 'email_labeling',
|
||||
|
|
@ -161,69 +162,81 @@ async function processUnlabeledEmails(): Promise<void> {
|
|||
truncated: limitedFiles.truncated,
|
||||
});
|
||||
|
||||
const totalBatches = Math.ceil(unlabeled.length / BATCH_SIZE);
|
||||
let totalEdited = 0;
|
||||
let hadError = false;
|
||||
|
||||
// Build all batches upfront
|
||||
const batches: { batchNumber: number; files: { path: string; content: string }[] }[] = [];
|
||||
for (let i = 0; i < unlabeled.length; i += BATCH_SIZE) {
|
||||
const batchPaths = unlabeled.slice(i, i + BATCH_SIZE);
|
||||
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
|
||||
|
||||
try {
|
||||
// Read file contents for the batch
|
||||
const files: { path: string; content: string }[] = [];
|
||||
for (const filePath of batchPaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
files.push({ path: filePath, content });
|
||||
} catch (error) {
|
||||
console.error(`[EmailLabeling] Error reading ${filePath}:`, error);
|
||||
}
|
||||
const files: { path: string; content: string }[] = [];
|
||||
for (const filePath of batchPaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
files.push({ path: filePath, content });
|
||||
} catch (error) {
|
||||
console.error(`[EmailLabeling] Error reading ${filePath}:`, error);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[EmailLabeling] Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`);
|
||||
await serviceLogger.log({
|
||||
type: 'progress',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'info',
|
||||
message: `Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`,
|
||||
step: 'batch',
|
||||
current: batchNumber,
|
||||
total: totalBatches,
|
||||
details: { filesInBatch: files.length },
|
||||
});
|
||||
|
||||
const result = await labelEmailBatch(files);
|
||||
totalEdited += result.filesEdited.size;
|
||||
|
||||
// Only mark files that were actually edited by the agent
|
||||
for (const file of files) {
|
||||
const relativePath = path.relative(WorkDir, file.path);
|
||||
if (result.filesEdited.has(relativePath)) {
|
||||
markFileAsLabeled(file.path, state);
|
||||
}
|
||||
}
|
||||
|
||||
saveLabelingState(state);
|
||||
console.log(`[EmailLabeling] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files edited`);
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'error',
|
||||
message: `Error processing batch ${batchNumber}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
context: { batchNumber },
|
||||
});
|
||||
}
|
||||
if (files.length > 0) {
|
||||
batches.push({ batchNumber, files });
|
||||
}
|
||||
}
|
||||
|
||||
const totalBatches = batches.length;
|
||||
let totalEdited = 0;
|
||||
let hadError = false;
|
||||
|
||||
// Process batches with concurrency limit
|
||||
for (let i = 0; i < batches.length; i += concurrency) {
|
||||
const chunk = batches.slice(i, i + concurrency);
|
||||
|
||||
const promises = chunk.map(async ({ batchNumber, files }) => {
|
||||
try {
|
||||
console.log(`[EmailLabeling] Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`);
|
||||
await serviceLogger.log({
|
||||
type: 'progress',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'info',
|
||||
message: `Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`,
|
||||
step: 'batch',
|
||||
current: batchNumber,
|
||||
total: totalBatches,
|
||||
details: { filesInBatch: files.length },
|
||||
});
|
||||
|
||||
const result = await labelEmailBatch(files);
|
||||
|
||||
// Only mark files that were actually edited by the agent
|
||||
for (const file of files) {
|
||||
const relativePath = path.relative(WorkDir, file.path);
|
||||
if (result.filesEdited.has(relativePath)) {
|
||||
markFileAsLabeled(file.path, state);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[EmailLabeling] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files edited`);
|
||||
return result.filesEdited.size;
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'error',
|
||||
message: `Error processing batch ${batchNumber}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
context: { batchNumber },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
totalEdited += results.reduce((sum, n) => sum + n, 0);
|
||||
|
||||
// Save state after each concurrent chunk completes
|
||||
saveLabelingState(state);
|
||||
}
|
||||
|
||||
state.lastRunTime = new Date().toISOString();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue