mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-12 08:42:38 +02:00
Merge origin/dev into feature/composio-tools-library
Integrate 58 commits from dev including: - Composio gateway proxy (routes through Rowboat API when signed in) - Gmail/Calendar sync triggers on OAuth callback - Account, Connected Accounts, Note Tagging settings tabs - Rowboat model gateway support - app-navigation and save-to-memory builtin tools - Voice mode, billing, inline tasks, agent notes Resolved conflicts by keeping both sides: - types.ts: z.string().optional() (resilient + optional) - client.ts: updated listToolkitToolsDetailed to use new auth pattern - builtin-tools.ts: kept composio dynamic tool registration imports - instructions.ts: kept both new tools and composio tools prompt - composio-handler.ts: merged all imports from both sides - ipc.ts: kept tools library + useComposioForGoogle handlers - settings-dialog.tsx: kept all new tabs (account, connected-accounts, note-tagging) alongside tools library tab Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9e6683984c
118 changed files with 17836 additions and 4842 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>
|
||||
|
|
@ -13,6 +13,10 @@ module.exports = {
|
|||
appCategoryType: 'public.app-category.productivity',
|
||||
osxSign: {
|
||||
batchCodesignCalls: true,
|
||||
optionsForFile: () => ({
|
||||
entitlements: path.join(__dirname, 'entitlements.plist'),
|
||||
'entitlements-inherit': path.join(__dirname, 'entitlements.plist'),
|
||||
}),
|
||||
},
|
||||
osxNotarize: {
|
||||
appleId: process.env.APPLE_ID,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"@x/shared": "workspace:*",
|
||||
"chokidar": "^4.0.3",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import * as composioClient from '@x/core/dist/composio/client.js';
|
|||
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
|
||||
import { composioEnabledToolsRepo } from '@x/core/dist/composio/enabled-tools-repo.js';
|
||||
import type { EnabledTool } from '@x/core/dist/composio/enabled-tools-repo.js';
|
||||
import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js';
|
||||
import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js';
|
||||
import { refreshComposioTools } from '@x/core/dist/application/lib/builtin-tools.js';
|
||||
import { z } from 'zod';
|
||||
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';
|
||||
|
||||
|
|
@ -31,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() };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -71,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`,
|
||||
|
|
@ -146,7 +149,11 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
|
||||
// Set up callback server
|
||||
let cleanupTimeout: NodeJS.Timeout;
|
||||
let callbackHandled = false;
|
||||
const { server } = await createAuthServer(8081, async (_code, _state) => {
|
||||
// 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);
|
||||
|
|
@ -154,6 +161,12 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
|
||||
if (accountStatus.status === 'ACTIVE') {
|
||||
emitComposioEvent({ toolkitSlug, success: true });
|
||||
if (toolkitSlug === 'gmail') {
|
||||
triggerGmailSync();
|
||||
}
|
||||
if (toolkitSlug === 'googlecalendar') {
|
||||
triggerCalendarSync();
|
||||
}
|
||||
} else {
|
||||
emitComposioEvent({
|
||||
toolkitSlug,
|
||||
|
|
@ -273,6 +286,20 @@ export function listConnected(): { toolkits: string[] } {
|
|||
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio should be used for Google services (Gmail, etc.)
|
||||
*/
|
||||
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() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Composio action
|
||||
*/
|
||||
|
|
@ -280,23 +307,28 @@ export async function executeAction(
|
|||
actionSlug: string,
|
||||
toolkitSlug: string,
|
||||
input: Record<string, unknown>
|
||||
): Promise<{ success: boolean; data: unknown; error?: string }> {
|
||||
): Promise<z.infer<typeof ZExecuteActionResponse>> {
|
||||
try {
|
||||
const account = composioAccountsRepo.getAccount(toolkitSlug);
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
successful: false,
|
||||
error: `Toolkit ${toolkitSlug} is not connected`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await composioClient.executeAction(actionSlug, account.id, input);
|
||||
const result = await composioClient.executeAction(actionSlug, {
|
||||
connected_account_id: account.id,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: input,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Composio] Action execution failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
successful: false,
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
|
|
@ -311,9 +343,9 @@ export async function listToolkits(cursor?: string): Promise<{
|
|||
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[];
|
||||
no_auth?: boolean;
|
||||
auth_schemes?: string[];
|
||||
composio_managed_auth_schemes?: string[];
|
||||
}>;
|
||||
nextCursor: string | null;
|
||||
totalItems: number;
|
||||
|
|
|
|||
7
apps/x/apps/main/src/html-to-docx.d.ts
vendored
Normal file
7
apps/x/apps/main/src/html-to-docx.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
declare module 'html-to-docx' {
|
||||
export default function htmlToDocx(
|
||||
htmlString: string,
|
||||
headerHTMLString?: string,
|
||||
options?: Record<string, unknown>,
|
||||
): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ipcMain, BrowserWindow, shell } from 'electron';
|
||||
import { ipcMain, BrowserWindow, shell, dialog } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
|
@ -15,23 +15,100 @@ import { bus } from '@x/core/dist/runs/bus.js';
|
|||
import { serviceBus } from '@x/core/dist/services/service_bus.js';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
import fs from 'node:fs/promises';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import z from 'zod';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { RunEvent } from '@x/shared/dist/runs.js';
|
||||
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
|
||||
import { testModelConnection } from '@x/core/dist/models/models.js';
|
||||
import { isSignedIn } from '@x/core/dist/account/account.js';
|
||||
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
||||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
import * as composioHandler from './composio-handler.js';
|
||||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||
import { search } from '@x/core/dist/search/search.js';
|
||||
import { versionHistory } from '@x/core';
|
||||
import { versionHistory, voice } from '@x/core';
|
||||
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.
|
||||
*/
|
||||
function markdownToHtml(markdown: string, title: string): string {
|
||||
// Simple markdown to HTML conversion for export purposes
|
||||
let html = markdown
|
||||
// Resolve wiki links [[Folder/Note Name]] or [[Folder/Note Name|Display]] to plain text
|
||||
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, _path, display) => display.trim())
|
||||
.replace(/\[\[([^\]]+)\]\]/g, (_match, linkPath: string) => {
|
||||
// Use the last segment (filename) as the display name
|
||||
const segments = linkPath.trim().split('/')
|
||||
return segments[segments.length - 1]
|
||||
})
|
||||
// Escape HTML entities (but preserve markdown syntax)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Headings (must come before other processing)
|
||||
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
|
||||
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
|
||||
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
|
||||
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
|
||||
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
|
||||
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
|
||||
|
||||
// Bold and italic
|
||||
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---$/gm, '<hr>')
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>')
|
||||
|
||||
// Links
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
||||
|
||||
// Blockquotes
|
||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>')
|
||||
|
||||
// Paragraphs: wrap remaining lines that aren't already wrapped in HTML tags
|
||||
html = html.replace(/^(?!<[a-z/])((?!^\s*$).+)$/gm, '<p>$1</p>')
|
||||
|
||||
// Clean up consecutive list items into lists
|
||||
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`)
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>${title}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; color: #1a1a1a; line-height: 1.6; font-size: 14px; }
|
||||
h1 { font-size: 1.8em; margin-top: 1em; } h2 { font-size: 1.4em; margin-top: 1em; } h3 { font-size: 1.2em; }
|
||||
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
|
||||
blockquote { border-left: 3px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #555; }
|
||||
hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }
|
||||
ul { padding-left: 1.5em; }
|
||||
a { color: #0066cc; }
|
||||
</style></head><body>${html}</body></html>`
|
||||
}
|
||||
|
||||
type InvokeChannels = ipc.InvokeChannels;
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
|
@ -69,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);
|
||||
});
|
||||
|
|
@ -344,7 +421,7 @@ export function setupIpcHandlers() {
|
|||
return runsCore.createRun(args);
|
||||
},
|
||||
'runs:createMessage': async (_event, args) => {
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message) };
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled) };
|
||||
},
|
||||
'runs:authorizePermission': async (_event, args) => {
|
||||
await runsCore.authorizePermission(args.runId, args.authorization);
|
||||
|
|
@ -369,6 +446,9 @@ export function setupIpcHandlers() {
|
|||
return { success: true };
|
||||
},
|
||||
'models:list': async () => {
|
||||
if (await isSignedIn()) {
|
||||
return await listGatewayModels();
|
||||
}
|
||||
return await listOnboardingModels();
|
||||
},
|
||||
'models:test': async (_event, args) => {
|
||||
|
|
@ -393,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();
|
||||
|
|
@ -409,6 +504,30 @@ export function setupIpcHandlers() {
|
|||
|
||||
return { success: true };
|
||||
},
|
||||
'slack:getConfig': async () => {
|
||||
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
return { enabled: config.enabled, workspaces: config.workspaces };
|
||||
},
|
||||
'slack:setConfig': async (_event, args) => {
|
||||
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
|
||||
await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces });
|
||||
return { success: true };
|
||||
},
|
||||
'slack:listWorkspaces': async () => {
|
||||
try {
|
||||
const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 });
|
||||
const parsed = JSON.parse(stdout);
|
||||
const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({
|
||||
url: w.workspace_url || '',
|
||||
name: w.workspace_name || '',
|
||||
}));
|
||||
return { workspaces };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces';
|
||||
return { workspaces: [], error: message };
|
||||
}
|
||||
},
|
||||
'onboarding:getStatus': async () => {
|
||||
// Show onboarding if it hasn't been completed yet
|
||||
const complete = isOnboardingComplete();
|
||||
|
|
@ -459,6 +578,12 @@ export function setupIpcHandlers() {
|
|||
'composio:disable-tools': async (_event, args) => {
|
||||
return composioHandler.disableTools(args.toolSlugs);
|
||||
},
|
||||
'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 () => {
|
||||
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
|
||||
|
|
@ -547,5 +672,89 @@ export function setupIpcHandlers() {
|
|||
'search:query': async (_event, args) => {
|
||||
return search(args.query, args.limit, args.types);
|
||||
},
|
||||
// Inline task schedule classification
|
||||
'export:note': async (event, args) => {
|
||||
const { markdown, format, title } = args;
|
||||
const sanitizedTitle = title.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'Untitled';
|
||||
|
||||
const filterMap: Record<string, Electron.FileFilter[]> = {
|
||||
md: [{ name: 'Markdown', extensions: ['md'] }],
|
||||
pdf: [{ name: 'PDF', extensions: ['pdf'] }],
|
||||
docx: [{ name: 'Word Document', extensions: ['docx'] }],
|
||||
};
|
||||
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showSaveDialog(win!, {
|
||||
defaultPath: `${sanitizedTitle}.${format}`,
|
||||
filters: filterMap[format],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const filePath = result.filePath;
|
||||
|
||||
if (format === 'md') {
|
||||
await fs.writeFile(filePath, markdown, 'utf8');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (format === 'pdf') {
|
||||
// Render markdown as HTML in a hidden window, then print to PDF
|
||||
const htmlContent = markdownToHtml(markdown, sanitizedTitle);
|
||||
const hiddenWin = new BrowserWindow({
|
||||
show: false,
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: { offscreen: true },
|
||||
});
|
||||
await hiddenWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`);
|
||||
// Small delay to ensure CSS/fonts render
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
const pdfBuffer = await hiddenWin.webContents.printToPDF({
|
||||
printBackground: true,
|
||||
pageSize: 'A4',
|
||||
});
|
||||
hiddenWin.destroy();
|
||||
await fs.writeFile(filePath, pdfBuffer);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (format === 'docx') {
|
||||
const htmlContent = markdownToHtml(markdown, sanitizedTitle);
|
||||
const { default: htmlToDocx } = await import('html-to-docx');
|
||||
const docxBuffer = await htmlToDocx(htmlContent, undefined, {
|
||||
table: { row: { cantSplit: true } },
|
||||
footer: false,
|
||||
header: false,
|
||||
});
|
||||
await fs.writeFile(filePath, Buffer.from(docxBuffer as ArrayBuffer));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Unknown format' };
|
||||
},
|
||||
'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);
|
||||
},
|
||||
// Billing handler
|
||||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { app, BrowserWindow, protocol, net, shell } from "electron";
|
||||
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session } from "electron";
|
||||
import path from "node:path";
|
||||
import {
|
||||
setupIpcHandlers,
|
||||
|
|
@ -17,9 +17,14 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j
|
|||
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
||||
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
|
||||
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
|
||||
import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js";
|
||||
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 } from "node:child_process";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -27,6 +32,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")
|
||||
|
|
@ -89,8 +116,30 @@ function createWindow() {
|
|||
},
|
||||
});
|
||||
|
||||
// Grant microphone and display-capture permissions
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
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();
|
||||
});
|
||||
|
||||
|
|
@ -170,9 +219,21 @@ app.whenReady().then(async () => {
|
|||
// start knowledge graph builder
|
||||
initGraphBuilder();
|
||||
|
||||
// start email labeling service
|
||||
initEmailLabeling();
|
||||
|
||||
// start note tagging service
|
||||
initNoteTagging();
|
||||
|
||||
// start inline task service (@rowboat: mentions)
|
||||
initInlineTasks();
|
||||
|
||||
// start background agent runner (scheduled agents)
|
||||
initAgentRunner();
|
||||
|
||||
// start agent notes learning service
|
||||
initAgentNotes();
|
||||
|
||||
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,7 +186,11 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
});
|
||||
|
||||
// Create callback server
|
||||
let callbackHandled = false;
|
||||
const { server } = await createAuthServer(8080, async (code, receivedState) => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
if (callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
// Validate state
|
||||
if (receivedState !== state) {
|
||||
throw new Error('Invalid state parameter - possible CSRF attack');
|
||||
|
|
@ -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%);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
FileTextIcon,
|
||||
FilterIcon,
|
||||
LayoutGridIcon,
|
||||
LoaderIcon,
|
||||
NetworkIcon,
|
||||
PlusCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { AppActionCardData } from "@/lib/chat-conversation";
|
||||
|
||||
interface AppActionCardProps {
|
||||
data: AppActionCardData;
|
||||
status: "pending" | "running" | "completed" | "error";
|
||||
}
|
||||
|
||||
const actionIcons: Record<string, React.ReactNode> = {
|
||||
"open-note": <FileTextIcon className="size-4" />,
|
||||
"open-view": <NetworkIcon className="size-4" />,
|
||||
"update-base-view": <FilterIcon className="size-4" />,
|
||||
"create-base": <PlusCircleIcon className="size-4" />,
|
||||
};
|
||||
|
||||
export function AppActionCard({ data, status }: AppActionCardProps) {
|
||||
const isRunning = status === "pending" || status === "running";
|
||||
const isError = status === "error";
|
||||
|
||||
return (
|
||||
<div className="not-prose mb-4 flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<span className="text-muted-foreground">
|
||||
{actionIcons[data.action] || <LayoutGridIcon className="size-4" />}
|
||||
</span>
|
||||
<span className="text-sm flex-1">{data.label}</span>
|
||||
{isRunning ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : isError ? (
|
||||
<span className="text-xs text-destructive">Failed</span>
|
||||
) : (
|
||||
<CheckCircleIcon className="size-3.5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
830
apps/x/apps/renderer/src/components/bases-view.tsx
Normal file
830
apps/x/apps/renderer/src/components/bases-view.tsx
Normal file
|
|
@ -0,0 +1,830 @@
|
|||
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 { 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { splitFrontmatter, extractAllFrontmatterValues } from '@/lib/frontmatter'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
interface TreeNode {
|
||||
path: string
|
||||
name: string
|
||||
kind: 'file' | 'dir'
|
||||
children?: TreeNode[]
|
||||
stat?: { size: number; mtimeMs: number }
|
||||
}
|
||||
|
||||
type NoteEntry = {
|
||||
path: string
|
||||
name: string
|
||||
folder: string
|
||||
fields: Record<string, string | string[]>
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
type SortDir = 'asc' | 'desc'
|
||||
type ActiveFilter = { category: string; value: string }
|
||||
|
||||
export type BaseConfig = {
|
||||
name: string
|
||||
visibleColumns: string[]
|
||||
columnWidths: Record<string, number>
|
||||
sort: { field: string; dir: SortDir }
|
||||
filters: ActiveFilter[]
|
||||
}
|
||||
|
||||
export const DEFAULT_BASE_CONFIG: BaseConfig = {
|
||||
name: 'All Notes',
|
||||
visibleColumns: ['name', 'folder', 'relationship', 'topic', 'status', 'mtimeMs'],
|
||||
columnWidths: {},
|
||||
sort: { field: 'mtimeMs', dir: 'desc' },
|
||||
filters: [],
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
/** Built-in columns that don't come from frontmatter */
|
||||
const BUILTIN_COLUMNS = ['name', 'folder', 'mtimeMs'] as const
|
||||
type BuiltinColumn = (typeof BUILTIN_COLUMNS)[number]
|
||||
|
||||
const BUILTIN_LABELS: Record<BuiltinColumn, string> = {
|
||||
name: 'Name',
|
||||
folder: 'Folder',
|
||||
mtimeMs: 'Last Modified',
|
||||
}
|
||||
|
||||
/** Default pixel widths for columns */
|
||||
const DEFAULT_WIDTHS: Record<string, number> = {
|
||||
name: 200,
|
||||
folder: 140,
|
||||
mtimeMs: 140,
|
||||
}
|
||||
const DEFAULT_FRONTMATTER_WIDTH = 150
|
||||
|
||||
/** Convert key to title case: `first_met` → `First Met` */
|
||||
function toTitleCase(key: string): string {
|
||||
if (key in BUILTIN_LABELS) return BUILTIN_LABELS[key as BuiltinColumn]
|
||||
return key
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
type BasesViewProps = {
|
||||
tree: TreeNode[]
|
||||
onSelectNote: (path: string) => void
|
||||
config: BaseConfig
|
||||
onConfigChange: (config: BaseConfig) => void
|
||||
isDefaultBase: boolean
|
||||
onSave: (name: string | null) => void
|
||||
/** Search query set externally (e.g. by app-navigation tool). */
|
||||
externalSearch?: string
|
||||
/** Called after the external search has been consumed (applied to internal state). */
|
||||
onExternalSearchConsumed?: () => void
|
||||
}
|
||||
|
||||
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
||||
return nodes.flatMap((n) =>
|
||||
n.kind === 'file' && n.name.endsWith('.md')
|
||||
? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }]
|
||||
: n.children
|
||||
? collectFiles(n.children)
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function getFolder(path: string): string {
|
||||
const parts = path.split('/')
|
||||
if (parts.length >= 3) return parts[1]
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatDate(ms: number): string {
|
||||
if (!ms) return ''
|
||||
const d = new Date(ms)
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function filtersEqual(a: ActiveFilter, b: ActiveFilter): boolean {
|
||||
return a.category === b.category && a.value === b.value
|
||||
}
|
||||
|
||||
function hasFilter(filters: ActiveFilter[], f: ActiveFilter): boolean {
|
||||
return filters.some((x) => filtersEqual(x, f))
|
||||
}
|
||||
|
||||
/** Get the string values for a column from a note */
|
||||
function getColumnValues(note: NoteEntry, column: string): string[] {
|
||||
if (column === 'name') return [note.name]
|
||||
if (column === 'folder') return [note.folder]
|
||||
if (column === 'mtimeMs') return []
|
||||
const v = note.fields[column]
|
||||
if (!v) return []
|
||||
return Array.isArray(v) ? v : [v]
|
||||
}
|
||||
|
||||
/** Get a single sortable string for a column */
|
||||
function getSortValue(note: NoteEntry, column: string): string | number {
|
||||
if (column === 'name') return note.name
|
||||
if (column === 'folder') return note.folder
|
||||
if (column === 'mtimeMs') return note.mtimeMs
|
||||
const v = note.fields[column]
|
||||
if (!v) return ''
|
||||
return Array.isArray(v) ? v[0] ?? '' : v
|
||||
}
|
||||
|
||||
export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave, externalSearch, onExternalSearchConsumed }: BasesViewProps) {
|
||||
// Build notes instantly from tree
|
||||
const notes = useMemo<NoteEntry[]>(() => {
|
||||
return collectFiles(tree).map((f) => ({
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
folder: getFolder(f.path),
|
||||
fields: {},
|
||||
mtimeMs: f.mtimeMs,
|
||||
}))
|
||||
}, [tree])
|
||||
|
||||
// Frontmatter fields loaded async, keyed by path
|
||||
const [fieldsByPath, setFieldsByPath] = useState<Map<string, Record<string, string | string[]>>>(new Map())
|
||||
const loadGenRef = useRef(0)
|
||||
|
||||
// Load frontmatter in background batches
|
||||
useEffect(() => {
|
||||
const gen = ++loadGenRef.current
|
||||
let cancelled = false
|
||||
const paths = notes.map((n) => n.path)
|
||||
|
||||
async function load() {
|
||||
const BATCH = 30
|
||||
for (let i = 0; i < paths.length; i += BATCH) {
|
||||
if (cancelled) return
|
||||
const batch = paths.slice(i, i + BATCH)
|
||||
const results = await Promise.all(
|
||||
batch.map(async (p) => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: p, encoding: 'utf8' })
|
||||
const { raw } = splitFrontmatter(result.data)
|
||||
return { path: p, fields: extractAllFrontmatterValues(raw) }
|
||||
} catch {
|
||||
return { path: p, fields: {} as Record<string, string | string[]> }
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (cancelled || gen !== loadGenRef.current) return
|
||||
setFieldsByPath((prev) => {
|
||||
const next = new Map(prev)
|
||||
for (const r of results) next.set(r.path, r.fields)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [notes])
|
||||
|
||||
// Merge tree-derived notes with async-loaded fields
|
||||
const enrichedNotes = useMemo<NoteEntry[]>(() => {
|
||||
if (fieldsByPath.size === 0) return notes
|
||||
return notes.map((n) => {
|
||||
const f = fieldsByPath.get(n.path)
|
||||
return f ? { ...n, fields: f } : n
|
||||
})
|
||||
}, [notes, fieldsByPath])
|
||||
|
||||
// Collect all unique frontmatter property keys across all notes
|
||||
const allPropertyKeys = useMemo<string[]>(() => {
|
||||
const keys = new Set<string>()
|
||||
for (const fields of fieldsByPath.values()) {
|
||||
for (const k of Object.keys(fields)) keys.add(k)
|
||||
}
|
||||
return Array.from(keys).sort()
|
||||
}, [fieldsByPath])
|
||||
|
||||
// Filterable categories: "folder" + all frontmatter keys
|
||||
const filterCategories = useMemo<string[]>(() => {
|
||||
return ['folder', ...allPropertyKeys]
|
||||
}, [allPropertyKeys])
|
||||
|
||||
// All unique values per category, across all enriched notes
|
||||
const valuesByCategory = useMemo<Record<string, string[]>>(() => {
|
||||
const result: Record<string, Set<string>> = {}
|
||||
for (const cat of filterCategories) result[cat] = new Set()
|
||||
for (const note of enrichedNotes) {
|
||||
for (const cat of filterCategories) {
|
||||
for (const v of getColumnValues(note, cat)) {
|
||||
if (v) result[cat]?.add(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const [cat, set] of Object.entries(result)) {
|
||||
out[cat] = Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
return out
|
||||
}, [filterCategories, enrichedNotes])
|
||||
|
||||
const visibleColumns = config.visibleColumns
|
||||
const columnWidths = config.columnWidths
|
||||
const filters = config.filters
|
||||
const sortField = config.sort.field
|
||||
const sortDir = config.sort.dir
|
||||
const [page, setPage] = useState(0)
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
|
||||
const [saveName, setSaveName] = useState('')
|
||||
const saveInputRef = useRef<HTMLInputElement>(null)
|
||||
const [filterCategory, setFilterCategory] = useState<string | null>(null)
|
||||
|
||||
const handleSaveClick = useCallback(() => {
|
||||
if (isDefaultBase) {
|
||||
setSaveName('')
|
||||
setSaveDialogOpen(true)
|
||||
} else {
|
||||
onSave(null)
|
||||
}
|
||||
}, [isDefaultBase, onSave])
|
||||
|
||||
const handleSaveConfirm = useCallback(() => {
|
||||
const name = saveName.trim()
|
||||
if (!name) return
|
||||
setSaveDialogOpen(false)
|
||||
onSave(name)
|
||||
}, [saveName, onSave])
|
||||
|
||||
const getColWidth = useCallback((col: string) => {
|
||||
return columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH
|
||||
}, [columnWidths])
|
||||
|
||||
// Column resize via drag
|
||||
const resizingRef = useRef<{ col: string; startX: number; startW: number } | null>(null)
|
||||
|
||||
const configRef = useRef(config)
|
||||
configRef.current = config
|
||||
|
||||
const onResizeStart = useCallback((col: string, e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const startW = configRef.current.columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH
|
||||
resizingRef.current = { col, startX, startW }
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!resizingRef.current) return
|
||||
const delta = ev.clientX - resizingRef.current.startX
|
||||
const newW = Math.max(60, resizingRef.current.startW + delta)
|
||||
const c = configRef.current
|
||||
const updated = { ...c, columnWidths: { ...c.columnWidths, [resizingRef.current!.col]: newW } }
|
||||
onConfigChange(updated)
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
resizingRef.current = null
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}, [onConfigChange])
|
||||
|
||||
// Search
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Apply external search from app-navigation tool
|
||||
useEffect(() => {
|
||||
if (externalSearch !== undefined) {
|
||||
setSearchQuery(externalSearch)
|
||||
setSearchOpen(true)
|
||||
onExternalSearchConsumed?.()
|
||||
}
|
||||
}, [externalSearch, onExternalSearchConsumed])
|
||||
const debouncedSearch = useDebounce(searchQuery, 250)
|
||||
const [searchMatchPaths, setSearchMatchPaths] = useState<Set<string> | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedSearch.trim()) {
|
||||
setSearchMatchPaths(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
window.ipc.invoke('search:query', { query: debouncedSearch, limit: 200, types: ['knowledge'] })
|
||||
.then((res: { results: { path: string }[] }) => {
|
||||
if (!cancelled) {
|
||||
setSearchMatchPaths(new Set(res.results.map((r) => r.path)))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSearchMatchPaths(new Set())
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [debouncedSearch])
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
setSearchOpen((prev) => {
|
||||
if (prev) {
|
||||
setSearchQuery('')
|
||||
setSearchMatchPaths(null)
|
||||
}
|
||||
return !prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Focus input when search opens
|
||||
useEffect(() => {
|
||||
if (searchOpen) searchInputRef.current?.focus()
|
||||
}, [searchOpen])
|
||||
|
||||
// Reset page when filters or search change
|
||||
useEffect(() => { setPage(0) }, [filters, searchMatchPaths])
|
||||
|
||||
// Filter (search + badge filters)
|
||||
const filteredNotes = useMemo(() => {
|
||||
let result = enrichedNotes
|
||||
// Apply search filter
|
||||
if (searchMatchPaths) {
|
||||
result = result.filter((note) => searchMatchPaths.has(note.path))
|
||||
}
|
||||
// Apply badge filters
|
||||
if (filters.length > 0) {
|
||||
const byCategory = new Map<string, string[]>()
|
||||
for (const f of filters) {
|
||||
const vals = byCategory.get(f.category) ?? []
|
||||
vals.push(f.value)
|
||||
byCategory.set(f.category, vals)
|
||||
}
|
||||
result = result.filter((note) => {
|
||||
for (const [category, requiredValues] of byCategory) {
|
||||
const noteValues = getColumnValues(note, category)
|
||||
if (!requiredValues.some((v) => noteValues.includes(v))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [enrichedNotes, filters, searchMatchPaths])
|
||||
|
||||
// Sort
|
||||
const sortedNotes = useMemo(() => {
|
||||
return [...filteredNotes].sort((a, b) => {
|
||||
const va = getSortValue(a, sortField)
|
||||
const vb = getSortValue(b, sortField)
|
||||
let cmp: number
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
cmp = va - vb
|
||||
} else {
|
||||
cmp = String(va).localeCompare(String(vb))
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [filteredNotes, sortField, sortDir])
|
||||
|
||||
// Paginate
|
||||
const totalPages = Math.max(1, Math.ceil(sortedNotes.length / PAGE_SIZE))
|
||||
const clampedPage = Math.min(page, totalPages - 1)
|
||||
const pageNotes = useMemo(
|
||||
() => sortedNotes.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE),
|
||||
[sortedNotes, clampedPage],
|
||||
)
|
||||
|
||||
const toggleFilter = useCallback((category: string, value: string) => {
|
||||
const c = configRef.current
|
||||
const f: ActiveFilter = { category, value }
|
||||
const next = hasFilter(c.filters, f)
|
||||
? c.filters.filter((x) => !filtersEqual(x, f))
|
||||
: [...c.filters, f]
|
||||
onConfigChange({ ...c, filters: next })
|
||||
}, [onConfigChange])
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
onConfigChange({ ...configRef.current, filters: [] })
|
||||
}, [onConfigChange])
|
||||
|
||||
const handleSort = useCallback((field: string) => {
|
||||
const c = configRef.current
|
||||
if (field === c.sort.field) {
|
||||
onConfigChange({ ...c, sort: { field, dir: c.sort.dir === 'asc' ? 'desc' : 'asc' } })
|
||||
} else {
|
||||
onConfigChange({ ...c, sort: { field, dir: field === 'mtimeMs' ? 'desc' : 'asc' } })
|
||||
}
|
||||
}, [onConfigChange])
|
||||
|
||||
const toggleColumn = useCallback((key: string) => {
|
||||
const c = configRef.current
|
||||
const next = c.visibleColumns.includes(key)
|
||||
? c.visibleColumns.filter((col) => col !== key)
|
||||
: [...c.visibleColumns, key]
|
||||
onConfigChange({ ...c, visibleColumns: next })
|
||||
}, [onConfigChange])
|
||||
|
||||
const SortIcon = ({ field }: { field: string }) => {
|
||||
if (sortField !== field) return null
|
||||
return sortDir === 'asc'
|
||||
? <ArrowUp className="size-3 inline ml-1" />
|
||||
: <ArrowDown className="size-3 inline ml-1" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="shrink-0 border-b border-border px-4 py-2 flex items-center gap-3">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||
<ListFilter className="size-3.5" />
|
||||
Properties
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-56 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search properties..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No properties found.</CommandEmpty>
|
||||
<CommandGroup heading="Built-in">
|
||||
{BUILTIN_COLUMNS.map((col) => (
|
||||
<CommandItem key={col} onSelect={() => toggleColumn(col)}>
|
||||
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(col) ? 'opacity-100' : 'opacity-0')} />
|
||||
{BUILTIN_LABELS[col]}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Frontmatter">
|
||||
{allPropertyKeys.map((key) => (
|
||||
<CommandItem key={key} onSelect={() => toggleColumn(key)}>
|
||||
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(key) ? 'opacity-100' : 'opacity-0')} />
|
||||
{toTitleCase(key)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover onOpenChange={(open) => { if (!open) setFilterCategory(null) }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={cn(
|
||||
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground',
|
||||
filters.length > 0 && 'text-foreground',
|
||||
)}>
|
||||
<Filter className="size-3.5" />
|
||||
Filter
|
||||
{filters.length > 0 && (
|
||||
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight">
|
||||
{filters.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className={cn('p-0', filterCategory ? 'w-[420px]' : 'w-[200px]')}>
|
||||
<div className="flex h-[300px]">
|
||||
{/* Left: categories */}
|
||||
<div className={cn('overflow-auto', filterCategory ? 'w-[160px] border-r border-border' : 'flex-1')}>
|
||||
<div className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Attributes</span>
|
||||
{filters.length > 0 && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filterCategories.map((cat) => {
|
||||
const activeCount = filters.filter((f) => f.category === cat).length
|
||||
const isSelected = filterCategory === cat
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setFilterCategory(cat)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-1.5 px-2 py-1.5 text-xs text-left hover:bg-accent transition-colors',
|
||||
isSelected && 'bg-accent text-foreground',
|
||||
!isSelected && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 truncate">{toTitleCase(cat)}</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight shrink-0">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Right: values for selected category */}
|
||||
{filterCategory && (
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<Command className="flex-1 flex flex-col">
|
||||
<CommandInput placeholder={`Search ${toTitleCase(filterCategory).toLowerCase()}...`} />
|
||||
<CommandList className="flex-1 overflow-auto max-h-none">
|
||||
<CommandEmpty>No values found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{(valuesByCategory[filterCategory] ?? []).map((val) => {
|
||||
const active = hasFilter(filters, { category: filterCategory, value: val })
|
||||
return (
|
||||
<CommandItem key={val} onSelect={() => toggleFilter(filterCategory, val)}>
|
||||
<Check className={cn('size-3.5 mr-2 shrink-0', active ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="truncate">{val}</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<button
|
||||
onClick={toggleSearch}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0',
|
||||
searchOpen && 'text-foreground',
|
||||
)}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
Search
|
||||
</button>
|
||||
|
||||
{searchOpen && (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search notes..."
|
||||
className="flex-1 min-w-0 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{searchMatchPaths ? `${searchMatchPaths.size} matches` : '...'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSearch}
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={handleSaveClick}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<Save className="size-3.5" />
|
||||
{isDefaultBase ? 'Save As' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{filters.length > 0 && (
|
||||
<div className="shrink-0 border-b border-border px-4 py-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{sortedNotes.length} of {enrichedNotes.length} notes
|
||||
</span>
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={`${f.category}:${f.value}`}
|
||||
onClick={() => toggleFilter(f.category, f.value)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary text-primary-foreground px-2 py-0.5 text-[11px] font-medium"
|
||||
>
|
||||
<span className="text-primary-foreground/60">{f.category}:</span>
|
||||
{f.value}
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
))}
|
||||
<button onClick={clearFilters} className="text-xs text-muted-foreground hover:text-foreground">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-sm" style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
{visibleColumns.map((col) => (
|
||||
<col key={col} style={{ width: getColWidth(col) }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 bg-background border-b border-border z-10">
|
||||
<tr>
|
||||
{visibleColumns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="relative text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none group"
|
||||
onClick={() => handleSort(col)}
|
||||
>
|
||||
<span className="truncate block">{toTitleCase(col)}<SortIcon field={col} /></span>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize opacity-0 group-hover:opacity-100 hover:!opacity-100 bg-border/60"
|
||||
onMouseDown={(e) => onResizeStart(col, e)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageNotes.map((note) => (
|
||||
<tr
|
||||
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>
|
||||
))}
|
||||
{pageNotes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={visibleColumns.length} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No notes found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="shrink-0 border-t border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sortedNotes.length === 0
|
||||
? '0 notes'
|
||||
: `${clampedPage * PAGE_SIZE + 1}\u2013${Math.min((clampedPage + 1) * PAGE_SIZE, sortedNotes.length)} of ${sortedNotes.length}`}
|
||||
</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
disabled={clampedPage === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground px-2">
|
||||
Page {clampedPage + 1} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
disabled={clampedPage >= totalPages - 1}
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save As dialog */}
|
||||
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[360px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Base</DialogTitle>
|
||||
<DialogDescription>Choose a name for this base view.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<input
|
||||
ref={saveInputRef}
|
||||
type="text"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveConfirm() }}
|
||||
placeholder="e.g. Contacts, Projects..."
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<button
|
||||
onClick={() => setSaveDialogOpen(false)}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveConfirm}
|
||||
disabled={!saveName.trim()}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders a single table cell based on the column type */
|
||||
function CellRenderer({
|
||||
note,
|
||||
column,
|
||||
filters,
|
||||
toggleFilter,
|
||||
}: {
|
||||
note: NoteEntry
|
||||
column: string
|
||||
filters: ActiveFilter[]
|
||||
toggleFilter: (category: string, value: string) => void
|
||||
}) {
|
||||
if (column === 'name') {
|
||||
return <span className="font-medium truncate block">{note.name}</span>
|
||||
}
|
||||
if (column === 'folder') {
|
||||
return <span className="text-muted-foreground truncate block">{note.folder}</span>
|
||||
}
|
||||
if (column === 'mtimeMs') {
|
||||
return <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(note.mtimeMs)}</span>
|
||||
}
|
||||
|
||||
// Frontmatter column
|
||||
const value = note.fields[column]
|
||||
if (!value) return null
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{value.map((v) => (
|
||||
<CategoryBadge
|
||||
key={v}
|
||||
category={column}
|
||||
value={v}
|
||||
active={hasFilter(filters, { category: column, value: v })}
|
||||
onClick={toggleFilter}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Single string value — render as badge for filterability
|
||||
return (
|
||||
<CategoryBadge
|
||||
category={column}
|
||||
value={value}
|
||||
active={hasFilter(filters, { category: column, value })}
|
||||
onClick={toggleFilter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryBadge({
|
||||
category,
|
||||
value,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
category: string
|
||||
value: string
|
||||
active: boolean
|
||||
onClick: (category: string, value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant={active ? 'default' : 'secondary'}
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0 cursor-pointer',
|
||||
!active && 'hover:bg-primary hover:text-primary-foreground',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick(category, value)
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ArrowUp,
|
||||
AudioLines,
|
||||
|
|
@ -9,7 +10,10 @@ import {
|
|||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
Globe,
|
||||
Headphones,
|
||||
LoaderIcon,
|
||||
Mic,
|
||||
Plus,
|
||||
Square,
|
||||
X,
|
||||
|
|
@ -53,6 +57,7 @@ export type StagedAttachment = {
|
|||
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
|
|
@ -61,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
|
||||
|
|
@ -92,7 +98,7 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
|
|||
}
|
||||
|
||||
interface ChatInputInnerProps {
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean) => void
|
||||
onStop?: () => void
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
|
|
@ -102,6 +108,18 @@ interface ChatInputInnerProps {
|
|||
runId?: string | null
|
||||
initialDraft?: string
|
||||
onDraftChange?: (text: string) => void
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
recordingState?: 'connecting' | 'listening'
|
||||
onStartRecording?: () => void
|
||||
onSubmitRecording?: () => void
|
||||
onCancelRecording?: () => void
|
||||
voiceAvailable?: boolean
|
||||
ttsAvailable?: boolean
|
||||
ttsEnabled?: boolean
|
||||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
}
|
||||
|
||||
function ChatInputInner({
|
||||
|
|
@ -115,6 +133,18 @@ function ChatInputInner({
|
|||
runId,
|
||||
initialDraft,
|
||||
onDraftChange,
|
||||
isRecording,
|
||||
recordingText,
|
||||
recordingState,
|
||||
onStartRecording,
|
||||
onSubmitRecording,
|
||||
onCancelRecording,
|
||||
voiceAvailable,
|
||||
ttsAvailable,
|
||||
ttsEnabled,
|
||||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
}: ChatInputInnerProps) {
|
||||
const controller = usePromptInputController()
|
||||
const message = controller.textInput.value
|
||||
|
|
@ -125,51 +155,105 @@ function ChatInputInner({
|
|||
|
||||
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
|
||||
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()
|
||||
|
|
@ -182,26 +266,61 @@ function ChatInputInner({
|
|||
return () => window.removeEventListener('models-config-changed', handler)
|
||||
}, [loadModelConfig])
|
||||
|
||||
// Check search tool availability (brave or 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 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, 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')
|
||||
}
|
||||
|
|
@ -263,11 +382,12 @@ function ChatInputInner({
|
|||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!canSubmit) return
|
||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments)
|
||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined)
|
||||
controller.textInput.clear()
|
||||
controller.mentions.clearMentions()
|
||||
setAttachments([])
|
||||
}, [attachments, canSubmit, controller, message, onSubmit])
|
||||
setSearchEnabled(false)
|
||||
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
|
|
@ -367,6 +487,40 @@ function ChatInputInner({
|
|||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
{isRecording ? (
|
||||
/* ── Recording bar ── */
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelRecording}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Cancel recording"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex flex-1 items-center gap-2 overflow-hidden">
|
||||
<VoiceWaveform />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||
{recordingState === 'connecting' ? 'Connecting...' : recordingText || 'Listening...'}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={onSubmitRecording}
|
||||
disabled={!recordingText?.trim()}
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||
recordingText?.trim()
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Normal input ── */
|
||||
<>
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<PromptInputTextarea
|
||||
placeholder="Type your message..."
|
||||
|
|
@ -385,6 +539,28 @@ function ChatInputInner({
|
|||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{searchAvailable && (
|
||||
searchEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchEnabled(false)}
|
||||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 text-blue-600 transition-colors hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900"
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">Search</span>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchEnabled(true)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{configuredModels.length > 0 && (
|
||||
<DropdownMenu>
|
||||
|
|
@ -414,6 +590,63 @@ function ChatInputInner({
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{onToggleTts && ttsAvailable && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTts}
|
||||
className={cn(
|
||||
'relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors',
|
||||
ttsEnabled
|
||||
? 'text-foreground hover:bg-muted'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
aria-label={ttsEnabled ? 'Disable voice output' : 'Enable voice output'}
|
||||
>
|
||||
<Headphones className="h-4 w-4" />
|
||||
{!ttsEnabled && (
|
||||
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="block h-[1.5px] w-5 -rotate-45 rounded-full bg-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{ttsEnabled ? 'Voice output on' : 'Voice output off'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{ttsEnabled && onTtsModeChange && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={ttsMode ?? 'summary'} onValueChange={(v) => onTtsModeChange(v as 'summary' | 'full')}>
|
||||
<DropdownMenuRadioItem value="summary">Speak summary</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="full">Speak full response</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{voiceAvailable && onStartRecording && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartRecording}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Voice input"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{isProcessing ? (
|
||||
<Button
|
||||
size="icon"
|
||||
|
|
@ -448,6 +681,31 @@ function ChatInputInner({
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Animated waveform bars for the recording indicator */
|
||||
function VoiceWaveform() {
|
||||
return (
|
||||
<div className="flex items-center gap-[3px] h-5">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-[3px] rounded-full bg-primary"
|
||||
style={{
|
||||
animation: `voice-wave 1.2s ease-in-out ${i * 0.15}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<style>{`
|
||||
@keyframes voice-wave {
|
||||
0%, 100% { height: 4px; }
|
||||
50% { height: 16px; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -466,6 +724,18 @@ export interface ChatInputWithMentionsProps {
|
|||
runId?: string | null
|
||||
initialDraft?: string
|
||||
onDraftChange?: (text: string) => void
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
recordingState?: 'connecting' | 'listening'
|
||||
onStartRecording?: () => void
|
||||
onSubmitRecording?: () => void
|
||||
onCancelRecording?: () => void
|
||||
voiceAvailable?: boolean
|
||||
ttsAvailable?: boolean
|
||||
ttsEnabled?: boolean
|
||||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
}
|
||||
|
||||
export function ChatInputWithMentions({
|
||||
|
|
@ -482,6 +752,18 @@ export function ChatInputWithMentions({
|
|||
runId,
|
||||
initialDraft,
|
||||
onDraftChange,
|
||||
isRecording,
|
||||
recordingText,
|
||||
recordingState,
|
||||
onStartRecording,
|
||||
onSubmitRecording,
|
||||
onCancelRecording,
|
||||
voiceAvailable,
|
||||
ttsAvailable,
|
||||
ttsEnabled,
|
||||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
}: ChatInputWithMentionsProps) {
|
||||
return (
|
||||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||
|
|
@ -496,6 +778,18 @@ export function ChatInputWithMentions({
|
|||
runId={runId}
|
||||
initialDraft={initialDraft}
|
||||
onDraftChange={onDraftChange}
|
||||
isRecording={isRecording}
|
||||
recordingText={recordingText}
|
||||
recordingState={recordingState}
|
||||
onStartRecording={onStartRecording}
|
||||
onSubmitRecording={onSubmitRecording}
|
||||
onCancelRecording={onCancelRecording}
|
||||
voiceAvailable={voiceAvailable}
|
||||
ttsAvailable={ttsAvailable}
|
||||
ttsEnabled={ttsEnabled}
|
||||
ttsMode={ttsMode}
|
||||
onToggleTts={onToggleTts}
|
||||
onTtsModeChange={onTtsModeChange}
|
||||
/>
|
||||
</PromptInputProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -108,6 +108,19 @@ interface ChatSidebarProps {
|
|||
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
|
||||
onOpenKnowledgeFile?: (path: string) => void
|
||||
onActivate?: () => void
|
||||
// Voice / TTS props
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
recordingState?: 'connecting' | 'listening'
|
||||
onStartRecording?: () => void
|
||||
onSubmitRecording?: () => void
|
||||
onCancelRecording?: () => void
|
||||
voiceAvailable?: boolean
|
||||
ttsAvailable?: boolean
|
||||
ttsEnabled?: boolean
|
||||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
|
|
@ -146,6 +159,18 @@ export function ChatSidebar({
|
|||
onToolOpenChangeForTab,
|
||||
onOpenKnowledgeFile,
|
||||
onActivate,
|
||||
isRecording,
|
||||
recordingText,
|
||||
recordingState,
|
||||
onStartRecording,
|
||||
onSubmitRecording,
|
||||
onCancelRecording,
|
||||
voiceAvailable,
|
||||
ttsAvailable,
|
||||
ttsEnabled,
|
||||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
}: ChatSidebarProps) {
|
||||
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
|
|
@ -542,6 +567,18 @@ export function ChatSidebar({
|
|||
runId={tabState.runId}
|
||||
initialDraft={getInitialDraft?.(tab.id)}
|
||||
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
||||
isRecording={isActive && isRecording}
|
||||
recordingText={isActive ? recordingText : undefined}
|
||||
recordingState={isActive ? recordingState : undefined}
|
||||
onStartRecording={isActive ? onStartRecording : undefined}
|
||||
onSubmitRecording={isActive ? onSubmitRecording : undefined}
|
||||
onCancelRecording={isActive ? onCancelRecording : undefined}
|
||||
voiceAvailable={isActive && voiceAvailable}
|
||||
ttsAvailable={isActive && ttsAvailable}
|
||||
ttsEnabled={ttsEnabled}
|
||||
ttsMode={ttsMode}
|
||||
onToggleTts={isActive ? onToggleTts : undefined}
|
||||
onTtsModeChange={isActive ? onTtsModeChange : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { AlertTriangle, Loader2, Mic, Mail, MessageSquare } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { AlertTriangle, Loader2, Mic, Mail, Calendar, MessageSquare, User } from "lucide-react"
|
||||
|
||||
import {
|
||||
Popover,
|
||||
|
|
@ -17,385 +17,41 @@ import {
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface ProviderState {
|
||||
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)
|
||||
|
||||
// Composio/Slack state
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
const [slackConnected, setSlackConnected] = useState(false)
|
||||
const [slackLoading, setSlackLoading] = useState(true)
|
||||
const [slackConnecting, setSlackConnecting] = useState(false)
|
||||
|
||||
// Load available providers on mount
|
||||
useEffect(() => {
|
||||
async function loadProviders() {
|
||||
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 connection status
|
||||
const refreshSlackStatus = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
|
||||
setSlackConnected(result.isConnected)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Slack status:', error)
|
||||
setSlackConnected(false)
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect to Slack via Composio
|
||||
const startSlackConnect = useCallback(async () => {
|
||||
try {
|
||||
setSlackConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
}
|
||||
// Success will be handled by composio:didConnect event
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Slack:', error)
|
||||
toast.error('Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle Slack connect button click
|
||||
const handleConnectSlack = useCallback(async () => {
|
||||
// Check if Composio is configured
|
||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configResult.configured) {
|
||||
setComposioApiKeyOpen(true)
|
||||
return
|
||||
}
|
||||
await startSlackConnect()
|
||||
}, [startSlackConnect])
|
||||
|
||||
// Handle Composio API key submission
|
||||
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
|
||||
try {
|
||||
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
||||
setComposioApiKeyOpen(false)
|
||||
toast.success('Composio API key saved')
|
||||
// Now start the Slack connection
|
||||
await startSlackConnect()
|
||||
} catch (error) {
|
||||
console.error('Failed to save Composio API key:', error)
|
||||
toast.error('Failed to save API key')
|
||||
}
|
||||
}, [startSlackConnect])
|
||||
|
||||
// Disconnect from Slack
|
||||
const handleDisconnectSlack = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'slack' })
|
||||
if (result.success) {
|
||||
setSlackConnected(false)
|
||||
toast.success('Disconnected from Slack')
|
||||
} else {
|
||||
toast.error('Failed to disconnect from Slack')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect from Slack:', error)
|
||||
toast.error('Failed to disconnect from Slack')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
// Refresh Granola
|
||||
refreshGranolaConfig()
|
||||
|
||||
// Refresh Slack status
|
||||
refreshSlackStatus()
|
||||
|
||||
// Refresh OAuth providers
|
||||
if (providers.length === 0) return
|
||||
|
||||
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, refreshSlackStatus])
|
||||
|
||||
// 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])
|
||||
|
||||
// Listen for Composio connection events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||
const { toolkitSlug, success, error } = event
|
||||
|
||||
if (toolkitSlug === 'slack') {
|
||||
setSlackConnected(success)
|
||||
setSlackConnecting(false)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Slack')
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Slack')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: true }
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, 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
|
||||
|
|
@ -426,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"
|
||||
>
|
||||
|
|
@ -442,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>
|
||||
)}
|
||||
|
|
@ -467,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 ? (
|
||||
|
|
@ -506,129 +200,295 @@ 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>
|
||||
) : (
|
||||
<>
|
||||
{/* Email & Calendar Section - Google */}
|
||||
{providers.includes('google') && (
|
||||
{/* 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">Email & Calendar</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Email & Calendar
|
||||
</span>
|
||||
</div>
|
||||
{renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')}
|
||||
{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')
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
{/* 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 */}
|
||||
<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">
|
||||
{granolaLoading && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{/* 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>
|
||||
)}
|
||||
<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')}
|
||||
{/* Fireflies */}
|
||||
{c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
|
||||
|
||||
<Separator className="my-2" />
|
||||
<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="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">
|
||||
<MessageSquare className="size-4" />
|
||||
{/* 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="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{slackLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
|
||||
<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>
|
||||
<div className="shrink-0">
|
||||
{slackLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : slackConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDisconnectSlack}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConnectSlack}
|
||||
disabled={slackConnecting}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{slackConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ComposioApiKeyModal
|
||||
open={composioApiKeyOpen}
|
||||
onOpenChange={setComposioApiKeyOpen}
|
||||
onSubmit={handleComposioApiKeySubmit}
|
||||
isSubmitting={slackConnecting}
|
||||
open={c.composioApiKeyOpen}
|
||||
onOpenChange={c.setComposioApiKeyOpen}
|
||||
onSubmit={c.handleComposioApiKeySubmit}
|
||||
isSubmitting={c.gmailConnecting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,18 +25,30 @@ import {
|
|||
ExternalLinkIcon,
|
||||
Trash2Icon,
|
||||
ImageIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
FileTypeIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
interface EditorToolbarProps {
|
||||
editor: Editor | null
|
||||
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
|
||||
onImageUpload?: (file: File) => Promise<void> | void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
}
|
||||
|
||||
export function EditorToolbar({
|
||||
editor,
|
||||
onSelectionHighlight,
|
||||
onImageUpload,
|
||||
onExport,
|
||||
}: EditorToolbarProps) {
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
|
||||
|
|
@ -341,6 +353,38 @@ export function EditorToolbar({
|
|||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Export */}
|
||||
{onExport && (
|
||||
<>
|
||||
<div className="separator" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Export"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onExport('md')}>
|
||||
<FileTextIcon className="size-4 mr-2" />
|
||||
Markdown (.md)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onExport('pdf')}>
|
||||
<FileIcon className="size-4 mr-2" />
|
||||
PDF (.pdf)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onExport('docx')}>
|
||||
<FileTypeIcon className="size-4 mr-2" />
|
||||
Word (.docx)
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
252
apps/x/apps/renderer/src/components/frontmatter-properties.tsx
Normal file
252
apps/x/apps/renderer/src/components/frontmatter-properties.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { ChevronRight, X, Plus } from 'lucide-react'
|
||||
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
|
||||
|
||||
interface FrontmatterPropertiesProps {
|
||||
raw: string | null
|
||||
onRawChange: (raw: string | null) => void
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
type FieldEntry = { key: string; value: string | string[] }
|
||||
|
||||
function fieldsFromRaw(raw: string | null): FieldEntry[] {
|
||||
const record = extractAllFrontmatterValues(raw)
|
||||
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
|
||||
function fieldsToRaw(fields: FieldEntry[]): string | null {
|
||||
const record: Record<string, string | string[]> = {}
|
||||
for (const { key, value } of fields) {
|
||||
if (key.trim()) record[key.trim()] = value
|
||||
}
|
||||
return buildFrontmatter(record)
|
||||
}
|
||||
|
||||
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [fields, setFields] = useState<FieldEntry[]>(() => fieldsFromRaw(raw))
|
||||
const [editingNewKey, setEditingNewKey] = useState(false)
|
||||
const newKeyRef = useRef<HTMLInputElement>(null)
|
||||
const lastCommittedRaw = useRef(raw)
|
||||
|
||||
// Sync local fields when raw changes externally (e.g. tab switch)
|
||||
useEffect(() => {
|
||||
if (raw !== lastCommittedRaw.current) {
|
||||
setFields(fieldsFromRaw(raw))
|
||||
lastCommittedRaw.current = raw
|
||||
}
|
||||
}, [raw])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingNewKey && newKeyRef.current) {
|
||||
newKeyRef.current.focus()
|
||||
}
|
||||
}, [editingNewKey])
|
||||
|
||||
const commit = useCallback((updated: FieldEntry[]) => {
|
||||
const newRaw = fieldsToRaw(updated)
|
||||
lastCommittedRaw.current = newRaw
|
||||
onRawChange(newRaw)
|
||||
}, [onRawChange])
|
||||
|
||||
// For scalar fields: update local state immediately, commit on blur
|
||||
const updateLocalValue = useCallback((index: number, newValue: string) => {
|
||||
setFields(prev => {
|
||||
const next = [...prev]
|
||||
next[index] = { ...next[index], value: newValue }
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const commitField = useCallback((_index: number) => {
|
||||
setFields(prev => {
|
||||
commit(prev)
|
||||
return prev
|
||||
})
|
||||
}, [commit])
|
||||
|
||||
// For array fields and structural changes: update + commit immediately
|
||||
const updateAndCommit = useCallback((updater: (prev: FieldEntry[]) => FieldEntry[]) => {
|
||||
setFields(prev => {
|
||||
const next = updater(prev)
|
||||
commit(next)
|
||||
return next
|
||||
})
|
||||
}, [commit])
|
||||
|
||||
const removeField = useCallback((index: number) => {
|
||||
updateAndCommit(prev => prev.filter((_, i) => i !== index))
|
||||
}, [updateAndCommit])
|
||||
|
||||
const addField = useCallback((key: string) => {
|
||||
const trimmed = key.trim()
|
||||
if (!trimmed) return
|
||||
if (fields.some(f => f.key === trimmed)) return
|
||||
updateAndCommit(prev => [...prev, { key: trimmed, value: '' }])
|
||||
setEditingNewKey(false)
|
||||
}, [fields, updateAndCommit])
|
||||
|
||||
const count = fields.length
|
||||
|
||||
return (
|
||||
<div className="frontmatter-properties">
|
||||
<button
|
||||
className="frontmatter-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`frontmatter-chevron ${expanded ? 'expanded' : ''}`}
|
||||
/>
|
||||
<span className="frontmatter-label">
|
||||
Properties{count > 0 ? ` (${count})` : ''}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="frontmatter-fields">
|
||||
{fields.map((field, index) => (
|
||||
<div key={`${field.key}-${index}`} className="frontmatter-row">
|
||||
<span className="frontmatter-key" title={field.key}>
|
||||
{field.key}
|
||||
</span>
|
||||
<div className="frontmatter-value-area">
|
||||
{Array.isArray(field.value) ? (
|
||||
<ArrayField
|
||||
value={field.value}
|
||||
editable={editable}
|
||||
onChange={(v) => updateAndCommit(prev => {
|
||||
const next = [...prev]
|
||||
next[index] = { ...next[index], value: v }
|
||||
return next
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="frontmatter-input"
|
||||
value={field.value}
|
||||
readOnly={!editable}
|
||||
onChange={(e) => updateLocalValue(index, e.target.value)}
|
||||
onBlur={() => commitField(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{editable && (
|
||||
<button
|
||||
className="frontmatter-remove"
|
||||
onClick={() => removeField(index)}
|
||||
type="button"
|
||||
title="Remove property"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{editable && (
|
||||
editingNewKey ? (
|
||||
<div className="frontmatter-row frontmatter-new-row">
|
||||
<input
|
||||
ref={newKeyRef}
|
||||
className="frontmatter-input frontmatter-new-key-input"
|
||||
placeholder="Property name"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
addField(e.currentTarget.value)
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingNewKey(false)
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (e.currentTarget.value.trim()) {
|
||||
addField(e.currentTarget.value)
|
||||
} else {
|
||||
setEditingNewKey(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="frontmatter-add"
|
||||
onClick={() => setEditingNewKey(true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>Add property</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrayField({
|
||||
value,
|
||||
editable,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[]
|
||||
editable: boolean
|
||||
onChange: (v: string[]) => void
|
||||
}) {
|
||||
const removeItem = (index: number) => {
|
||||
onChange(value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const addItem = (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
onChange([...value, trimmed])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="frontmatter-array">
|
||||
{value.map((item, i) => (
|
||||
<span key={i} className="frontmatter-chip">
|
||||
<span className="frontmatter-chip-text">{item}</span>
|
||||
{editable && (
|
||||
<button
|
||||
className="frontmatter-chip-remove"
|
||||
onClick={() => removeItem(i)}
|
||||
type="button"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{editable && (
|
||||
<input
|
||||
className="frontmatter-chip-input"
|
||||
placeholder="Add..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
addItem(e.currentTarget.value)
|
||||
e.currentTarget.value = ''
|
||||
} else if (e.key === 'Backspace' && !e.currentTarget.value && value.length > 0) {
|
||||
removeItem(value.length - 1)
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (e.currentTarget.value.trim()) {
|
||||
addItem(e.currentTarget.value)
|
||||
e.currentTarget.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -8,8 +8,16 @@ import Placeholder from '@tiptap/extension-placeholder'
|
|||
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 { 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'
|
||||
|
|
@ -133,6 +141,20 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
|||
})
|
||||
})
|
||||
blocks.push(listLines.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 === 'codeBlock') {
|
||||
const lang = (node.attrs?.language as string) || ''
|
||||
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
||||
|
|
@ -176,12 +198,26 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
|||
return result
|
||||
}
|
||||
import { EditorToolbar } from './editor-toolbar'
|
||||
import { FrontmatterProperties } from './frontmatter-properties'
|
||||
import { WikiLink } from '@/extensions/wiki-link'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
|
||||
import { RowboatMentionPopover } from './rowboat-mention-popover'
|
||||
import '@/styles/editor.css'
|
||||
|
||||
type RowboatMentionMatch = {
|
||||
range: { from: number; to: number }
|
||||
}
|
||||
|
||||
type RowboatBlockEdit = {
|
||||
/** ProseMirror position of the taskBlock node */
|
||||
nodePos: number
|
||||
/** Existing instruction text */
|
||||
existingText: string
|
||||
}
|
||||
|
||||
type WikiLinkConfig = {
|
||||
files: string[]
|
||||
recent: string[]
|
||||
|
|
@ -189,6 +225,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
|
||||
|
|
@ -200,6 +347,10 @@ interface MarkdownEditorProps {
|
|||
editorSessionKey?: number
|
||||
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
|
||||
editable?: boolean
|
||||
frontmatter?: string | null
|
||||
onFrontmatterChange?: (raw: string | null) => void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
notePath?: string
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -288,6 +439,10 @@ export function MarkdownEditor({
|
|||
editorSessionKey = 0,
|
||||
onHistoryHandlersChange,
|
||||
editable = true,
|
||||
frontmatter,
|
||||
onFrontmatterChange,
|
||||
onExport,
|
||||
notePath,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -299,6 +454,17 @@ export function MarkdownEditor({
|
|||
const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit)
|
||||
const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
|
||||
const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {})
|
||||
const [activeRowboatMention, setActiveRowboatMention] = useState<RowboatMentionMatch | null>(null)
|
||||
const [rowboatBlockEdit, setRowboatBlockEdit] = useState<RowboatBlockEdit | null>(null)
|
||||
const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null)
|
||||
const rowboatBlockEditRef = useRef<RowboatBlockEdit | null>(null)
|
||||
|
||||
// @ mention autocomplete state (analogous to wiki-link state)
|
||||
const [activeAtMention, setActiveAtMention] = useState<{ range: { from: number; to: number }; query: string } | null>(null)
|
||||
const [atAnchorPosition, setAtAnchorPosition] = useState<{ left: number; top: number } | null>(null)
|
||||
const [atCommandValue, setAtCommandValue] = useState<string>('')
|
||||
const atKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
|
||||
const handleSelectAtMentionRef = useRef<(value: string) => void>(() => {})
|
||||
|
||||
// Keep ref in sync with state for the plugin to access
|
||||
selectionHighlightRef.current = selectionHighlight
|
||||
|
|
@ -394,6 +560,13 @@ export function MarkdownEditor({
|
|||
},
|
||||
}),
|
||||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
ChartBlockExtension,
|
||||
TableBlockExtension,
|
||||
CalendarBlockExtension,
|
||||
EmailBlockExtension,
|
||||
WikiLink.configure({
|
||||
onCreate: wikiLinks?.onCreate
|
||||
? (path) => {
|
||||
|
|
@ -466,6 +639,39 @@ export function MarkdownEditor({
|
|||
}
|
||||
}
|
||||
|
||||
// @ mention autocomplete keyboard handling
|
||||
const atState = atKeyStateRef.current
|
||||
if (atState.open) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
setAtCommandValue('')
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (atState.options.length === 0) return true
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const currentIndex = Math.max(0, atState.options.indexOf(atState.value))
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
const nextIndex = (currentIndex + delta + atState.options.length) % atState.options.length
|
||||
setAtCommandValue(atState.options[nextIndex])
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
if (atState.options.length === 0) return true
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const selected = atState.options.includes(atState.value) ? atState.value : atState.options[0]
|
||||
handleSelectAtMentionRef.current(selected)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (preventTitleHeadingDemotion(view, event)) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -570,6 +776,118 @@ export function MarkdownEditor({
|
|||
})
|
||||
}, [editor, wikiLinks])
|
||||
|
||||
const updateRowboatMentionState = useCallback(() => {
|
||||
if (!editor) return
|
||||
const { selection } = editor.state
|
||||
if (!selection.empty) {
|
||||
setActiveRowboatMention(null)
|
||||
setRowboatAnchorTop(null)
|
||||
return
|
||||
}
|
||||
|
||||
const { $from } = selection
|
||||
if ($from.parent.type.spec.code) {
|
||||
setActiveRowboatMention(null)
|
||||
setRowboatAnchorTop(null)
|
||||
return
|
||||
}
|
||||
|
||||
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
|
||||
const textBefore = text.slice(0, $from.parentOffset)
|
||||
|
||||
// Match @rowboat at a word boundary (preceded by nothing or whitespace)
|
||||
const match = textBefore.match(/(^|\s)@rowboat$/)
|
||||
if (!match) {
|
||||
setActiveRowboatMention(null)
|
||||
setRowboatAnchorTop(null)
|
||||
return
|
||||
}
|
||||
|
||||
const triggerStart = textBefore.length - '@rowboat'.length
|
||||
const from = selection.from - (textBefore.length - triggerStart)
|
||||
const to = selection.from
|
||||
setActiveRowboatMention({ range: { from, to } })
|
||||
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) {
|
||||
setRowboatAnchorTop(null)
|
||||
return
|
||||
}
|
||||
|
||||
const coords = editor.view.coordsAtPos(selection.from)
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
|
||||
const pmRect = proseMirrorEl?.getBoundingClientRect()
|
||||
setRowboatAnchorTop({
|
||||
top: coords.top - wrapperRect.top + wrapper.scrollTop,
|
||||
left: pmRect ? pmRect.left - wrapperRect.left : 0,
|
||||
width: pmRect ? pmRect.width : wrapperRect.width,
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
// Detect @ trigger for autocomplete popover (similar to [[ detection)
|
||||
const updateAtMentionState = useCallback(() => {
|
||||
if (!editor) return
|
||||
const { selection } = editor.state
|
||||
if (!selection.empty) {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const { $from } = selection
|
||||
// Skip code blocks
|
||||
if ($from.parent.type.spec.code) {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
// Skip inline code marks
|
||||
if ($from.marks().some((mark) => mark.type.spec.code)) {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
|
||||
const textBefore = text.slice(0, $from.parentOffset)
|
||||
|
||||
// Find @ at a word boundary (start of line or preceded by whitespace)
|
||||
const atMatch = textBefore.match(/(^|[\s])@([a-zA-Z0-9]*)$/)
|
||||
if (!atMatch) {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const query = atMatch[2] // text after @
|
||||
|
||||
// If the full "@rowboat" is already typed, let updateRowboatMentionState handle it
|
||||
if (query === 'rowboat') {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const atSymbolOffset = textBefore.lastIndexOf('@')
|
||||
const matchText = textBefore.slice(atSymbolOffset)
|
||||
const range = { from: selection.from - matchText.length, to: selection.from }
|
||||
setActiveAtMention({ range, query })
|
||||
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) {
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const coords = editor.view.coordsAtPos(selection.from)
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
setAtAnchorPosition({
|
||||
left: coords.left - wrapperRect.left,
|
||||
top: coords.bottom - wrapperRect.top,
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !wikiLinks) return
|
||||
editor.on('update', updateWikiLinkState)
|
||||
|
|
@ -580,6 +898,42 @@ export function MarkdownEditor({
|
|||
}
|
||||
}, [editor, wikiLinks, updateWikiLinkState])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.on('update', updateRowboatMentionState)
|
||||
editor.on('selectionUpdate', updateRowboatMentionState)
|
||||
return () => {
|
||||
editor.off('update', updateRowboatMentionState)
|
||||
editor.off('selectionUpdate', updateRowboatMentionState)
|
||||
}
|
||||
}, [editor, updateRowboatMentionState])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.on('update', updateAtMentionState)
|
||||
editor.on('selectionUpdate', updateAtMentionState)
|
||||
return () => {
|
||||
editor.off('update', updateAtMentionState)
|
||||
editor.off('selectionUpdate', updateAtMentionState)
|
||||
}
|
||||
}, [editor, updateAtMentionState])
|
||||
|
||||
// When a tell-rowboat block is clicked, compute anchor and open popover
|
||||
useEffect(() => {
|
||||
if (!rowboatBlockEdit || !editor) return
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
const coords = editor.view.coordsAtPos(rowboatBlockEdit.nodePos)
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
|
||||
const pmRect = proseMirrorEl?.getBoundingClientRect()
|
||||
setRowboatAnchorTop({
|
||||
top: coords.top - wrapperRect.top + wrapper.scrollTop,
|
||||
left: pmRect ? pmRect.left - wrapperRect.left : 0,
|
||||
width: pmRect ? pmRect.width : wrapperRect.width,
|
||||
})
|
||||
}, [editor, rowboatBlockEdit])
|
||||
|
||||
// Update editor content when prop changes (e.g., file selection changes)
|
||||
useEffect(() => {
|
||||
if (editor && content !== undefined) {
|
||||
|
|
@ -670,9 +1024,190 @@ export function MarkdownEditor({
|
|||
handleSelectWikiLinkRef.current = handleSelectWikiLink
|
||||
}, [handleSelectWikiLink])
|
||||
|
||||
const handleRowboatAdd = useCallback(async (instruction: string) => {
|
||||
if (!editor) return
|
||||
|
||||
if (rowboatBlockEdit) {
|
||||
// Editing existing taskBlock — update its data attribute
|
||||
const { nodePos } = rowboatBlockEdit
|
||||
const node = editor.state.doc.nodeAt(nodePos)
|
||||
if (node && node.type.name === 'taskBlock') {
|
||||
// Preserve existing schedule data
|
||||
let updated: Record<string, unknown> = { instruction }
|
||||
try {
|
||||
const existing = JSON.parse(node.attrs.data || '{}')
|
||||
updated = { ...existing, instruction }
|
||||
} catch {
|
||||
// Invalid JSON — just write new
|
||||
}
|
||||
const tr = editor.state.tr.setNodeMarkup(nodePos, undefined, { data: JSON.stringify(updated) })
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
setRowboatBlockEdit(null)
|
||||
rowboatBlockEditRef.current = null
|
||||
setRowboatAnchorTop(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (activeRowboatMention) {
|
||||
// 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: insertFrom, to: insertTo },
|
||||
[
|
||||
{ type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } },
|
||||
{ type: 'paragraph' },
|
||||
],
|
||||
)
|
||||
.run()
|
||||
|
||||
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, notePath])
|
||||
|
||||
const handleRowboatRemove = useCallback(() => {
|
||||
if (!editor || !rowboatBlockEdit) return
|
||||
const { nodePos } = rowboatBlockEdit
|
||||
const node = editor.state.doc.nodeAt(nodePos)
|
||||
if (node) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: nodePos, to: nodePos + node.nodeSize })
|
||||
.run()
|
||||
}
|
||||
setRowboatBlockEdit(null)
|
||||
rowboatBlockEditRef.current = null
|
||||
setRowboatAnchorTop(null)
|
||||
}, [editor, rowboatBlockEdit])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
updateWikiLinkState()
|
||||
}, [updateWikiLinkState])
|
||||
updateAtMentionState()
|
||||
}, [updateWikiLinkState, updateAtMentionState])
|
||||
|
||||
const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
|
||||
const wikiOptions = useMemo(() => {
|
||||
|
|
@ -700,6 +1235,63 @@ export function MarkdownEditor({
|
|||
setWikiCommandValue((prev) => (wikiOptions.includes(prev) ? prev : wikiOptions[0]))
|
||||
}, [showWikiPopover, wikiOptions])
|
||||
|
||||
// @ mention autocomplete options
|
||||
const atMentionOptions = useMemo(() => [
|
||||
{ value: 'rowboat', label: '@rowboat', description: 'Research, schedule, or run tasks with AI' },
|
||||
], [])
|
||||
|
||||
const filteredAtOptions = useMemo(() => {
|
||||
if (!activeAtMention) return []
|
||||
const q = activeAtMention.query.toLowerCase()
|
||||
if (!q) return atMentionOptions
|
||||
return atMentionOptions.filter((opt) => opt.value.toLowerCase().startsWith(q))
|
||||
}, [activeAtMention, atMentionOptions])
|
||||
|
||||
const atOptionValues = useMemo(() => filteredAtOptions.map((o) => o.value), [filteredAtOptions])
|
||||
const showAtPopover = Boolean(activeAtMention && atAnchorPosition && filteredAtOptions.length > 0)
|
||||
|
||||
useEffect(() => {
|
||||
atKeyStateRef.current = { open: showAtPopover, options: atOptionValues, value: atCommandValue }
|
||||
}, [showAtPopover, atOptionValues, atCommandValue])
|
||||
|
||||
// Keep @ cmdk selection in sync
|
||||
useEffect(() => {
|
||||
if (!showAtPopover) {
|
||||
setAtCommandValue('')
|
||||
return
|
||||
}
|
||||
if (atOptionValues.length === 0) {
|
||||
setAtCommandValue('')
|
||||
return
|
||||
}
|
||||
setAtCommandValue((prev) => (atOptionValues.includes(prev) ? prev : atOptionValues[0]))
|
||||
}, [showAtPopover, atOptionValues])
|
||||
|
||||
// @ mention selection handler
|
||||
const handleSelectAtMention = useCallback((value: string) => {
|
||||
if (!editor || !activeAtMention) return
|
||||
|
||||
if (value === 'rowboat') {
|
||||
// Replace "@<partial>" with "@rowboat" — this triggers updateRowboatMentionState
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
{ from: activeAtMention.range.from, to: activeAtMention.range.to },
|
||||
'@rowboat'
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
setAtCommandValue('')
|
||||
}, [editor, activeAtMention])
|
||||
|
||||
useEffect(() => {
|
||||
handleSelectAtMentionRef.current = handleSelectAtMention
|
||||
}, [handleSelectAtMention])
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
|
||||
|
|
@ -720,7 +1312,16 @@ export function MarkdownEditor({
|
|||
editor={editor}
|
||||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
onExport={onExport}
|
||||
/>
|
||||
{(frontmatter !== undefined) && onFrontmatterChange && (
|
||||
<FrontmatterProperties
|
||||
raw={frontmatter}
|
||||
onRawChange={onFrontmatterChange}
|
||||
editable={editable}
|
||||
/>
|
||||
)}
|
||||
<MeetingEventBanner frontmatter={frontmatter} />
|
||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||
<EditorContent editor={editor} />
|
||||
{wikiLinks ? (
|
||||
|
|
@ -777,6 +1378,64 @@ export function MarkdownEditor({
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
{/* @ mention autocomplete popover */}
|
||||
<Popover
|
||||
open={showAtPopover}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
setAtCommandValue('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<span
|
||||
className="wiki-link-anchor"
|
||||
style={
|
||||
atAnchorPosition
|
||||
? { left: atAnchorPosition.left, top: atAnchorPosition.top }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="w-72 p-1"
|
||||
align="start"
|
||||
side="bottom"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<Command shouldFilter={false} value={atCommandValue} onValueChange={setAtCommandValue}>
|
||||
<CommandList>
|
||||
{filteredAtOptions.map((opt) => (
|
||||
<CommandItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
onSelect={() => handleSelectAtMention(opt.value)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{opt.description}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<RowboatMentionPopover
|
||||
open={Boolean((activeRowboatMention || rowboatBlockEdit) && rowboatAnchorTop)}
|
||||
anchor={rowboatAnchorTop}
|
||||
initialText={rowboatBlockEdit?.existingText ?? ''}
|
||||
onAdd={handleRowboatAdd}
|
||||
onRemove={rowboatBlockEdit ? handleRowboatRemove : undefined}
|
||||
onClose={() => {
|
||||
setActiveRowboatMention(null)
|
||||
setRowboatBlockEdit(null)
|
||||
rowboatBlockEditRef.current = null
|
||||
setRowboatAnchorTop(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Loader2, Mic, Mail, CheckCircle2 } from "lucide-react"
|
||||
// import { MessageSquare } from "lucide-react"
|
||||
import { Loader2, Mic, Mail, Calendar, CheckCircle2, ArrowLeft, MessageSquare } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -23,10 +22,10 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { toast } from "sonner"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
|
||||
interface ProviderState {
|
||||
isConnected: boolean
|
||||
|
|
@ -39,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"
|
||||
|
||||
|
|
@ -51,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")
|
||||
|
|
@ -80,11 +82,31 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
||||
// Composio/Slack state
|
||||
// Composio API key state
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
const [slackConnected, setSlackConnected] = useState(false)
|
||||
// const [slackLoading, setSlackLoading] = useState(true)
|
||||
const [slackConnecting, setSlackConnecting] = useState(false)
|
||||
const [, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
||||
|
||||
// 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)
|
||||
|
||||
// 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 }>) => {
|
||||
|
|
@ -113,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
|
||||
|
||||
|
|
@ -129,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
|
||||
|
|
@ -212,49 +252,127 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Load Slack connection status
|
||||
const refreshSlackStatus = useCallback(async () => {
|
||||
// Load Slack config
|
||||
const refreshSlackConfig = useCallback(async () => {
|
||||
try {
|
||||
// setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
|
||||
setSlackConnected(result.isConnected)
|
||||
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 status:', error)
|
||||
setSlackConnected(false)
|
||||
console.error('Failed to load Slack config:', error)
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
} finally {
|
||||
// setSlackLoading(false)
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Start Slack connection
|
||||
const startSlackConnect = useCallback(async () => {
|
||||
// Enable Slack: discover workspaces
|
||||
const handleSlackEnable = useCallback(async () => {
|
||||
setSlackDiscovering(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
setSlackConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
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)
|
||||
}
|
||||
// Success will be handled by composio:didConnect event
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Slack:', error)
|
||||
toast.error('Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
console.error('Failed to discover Slack workspaces:', error)
|
||||
setSlackDiscoverError('Failed to discover Slack workspaces')
|
||||
setSlackPickerOpen(true)
|
||||
} finally {
|
||||
setSlackDiscovering(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect to Slack via Composio (checks if configured first)
|
||||
/*
|
||||
const handleConnectSlack = useCallback(async () => {
|
||||
// Check if Composio is configured
|
||||
// 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 startSlackConnect()
|
||||
}, [startSlackConnect])
|
||||
*/
|
||||
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) => {
|
||||
|
|
@ -262,20 +380,72 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
||||
setComposioApiKeyOpen(false)
|
||||
toast.success('Composio API key saved')
|
||||
// Now start the Slack connection
|
||||
await startSlackConnect()
|
||||
await startGmailConnect()
|
||||
} catch (error) {
|
||||
console.error('Failed to save Composio API key:', error)
|
||||
toast.error('Failed to save API key')
|
||||
}
|
||||
}, [startSlackConnect])
|
||||
}, [startGmailConnect])
|
||||
|
||||
// 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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
@ -319,8 +489,18 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
// Refresh Granola
|
||||
refreshGranolaConfig()
|
||||
|
||||
// Refresh Slack status
|
||||
refreshSlackStatus()
|
||||
// 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
|
||||
|
|
@ -349,7 +529,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackStatus])
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
|
||||
|
||||
// Refresh statuses when modal opens or providers list changes
|
||||
useEffect(() => {
|
||||
|
|
@ -358,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,
|
||||
|
|
@ -371,38 +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
|
||||
}, [])
|
||||
|
||||
// Listen for Composio connection events
|
||||
// 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, error } = event
|
||||
const { toolkitSlug, success } = event
|
||||
|
||||
if (toolkitSlug === 'slack') {
|
||||
setSlackConnected(success)
|
||||
setSlackConnecting(false)
|
||||
if (toolkitSlug === 'gmail') {
|
||||
setGmailConnected(success)
|
||||
setGmailConnecting(false)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Slack')
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Slack')
|
||||
}
|
||||
if (toolkitSlug === 'googlecalendar') {
|
||||
setGoogleCalendarConnected(success)
|
||||
setGoogleCalendarConnecting(false)
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
|
||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
|
|
@ -450,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) => {
|
||||
|
|
@ -545,29 +741,28 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</div>
|
||||
)
|
||||
|
||||
// Render Slack row
|
||||
/*
|
||||
const renderSlackRow = () => (
|
||||
// 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">
|
||||
<MessageSquare className="size-5" />
|
||||
<Mail className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{slackLoading ? (
|
||||
<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">
|
||||
Send messages and view channels
|
||||
Sync emails
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{slackLoading ? (
|
||||
{gmailLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : slackConnected ? (
|
||||
) : gmailConnected ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span>Connected</span>
|
||||
|
|
@ -576,10 +771,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConnectSlack}
|
||||
disabled={slackConnecting}
|
||||
onClick={handleConnectGmail}
|
||||
disabled={gmailConnecting}
|
||||
>
|
||||
{slackConnecting ? (
|
||||
{gmailConnecting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
|
|
@ -589,9 +784,249 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</div>
|
||||
</div>
|
||||
)
|
||||
*/
|
||||
|
||||
// Step 0: LLM Setup
|
||||
// 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">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-5" />
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</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}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{slackPickerOpen && (
|
||||
<div className="mt-2 ml-13 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}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// 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" },
|
||||
|
|
@ -767,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" ? (
|
||||
|
|
@ -784,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">
|
||||
|
|
@ -801,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>
|
||||
)}
|
||||
|
||||
|
|
@ -820,6 +1264,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-5" />, 'AI meeting transcripts')}
|
||||
</div>
|
||||
|
||||
{/* Team Communication Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="px-3">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Team Communication</span>
|
||||
</div>
|
||||
{renderSlackRow()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -828,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 || slackConnected
|
||||
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
|
|
@ -860,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" />
|
||||
|
|
@ -878,7 +1347,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
<span>Granola (Local meeting notes)</span>
|
||||
</div>
|
||||
)}
|
||||
{slackConnected && (
|
||||
{slackEnabled && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
<span>Slack (Team communication)</span>
|
||||
|
|
@ -908,7 +1377,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
open={composioApiKeyOpen}
|
||||
onOpenChange={setComposioApiKeyOpen}
|
||||
onSubmit={handleComposioApiKeySubmit}
|
||||
isSubmitting={slackConnecting}
|
||||
isSubmitting={gmailConnecting}
|
||||
/>
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
|
|
@ -918,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>
|
||||
109
apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx
Normal file
109
apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface RowboatMentionPopoverProps {
|
||||
open: boolean
|
||||
anchor: { top: number; left: number; width: number } | null
|
||||
initialText?: string
|
||||
onAdd: (instruction: string) => void | Promise<void>
|
||||
onRemove?: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, onRemove, onClose }: RowboatMentionPopoverProps) {
|
||||
const [text, setText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setText(initialText)
|
||||
setLoading(false)
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [open, initialText])
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleMouseDown)
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open || !anchor) return null
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed || loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
await onAdd(trimmed)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setText('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-50"
|
||||
style={{
|
||||
top: anchor.top,
|
||||
left: anchor.left,
|
||||
width: anchor.width,
|
||||
}}
|
||||
>
|
||||
<div className="relative border border-input rounded-md bg-popover shadow-sm">
|
||||
<div className="flex items-start gap-1.5 px-3 pt-2 pb-8">
|
||||
<span className="text-sm text-muted-foreground select-none shrink-0 leading-[1.5]">@rowboat</span>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none resize-none leading-[1.5]"
|
||||
placeholder=""
|
||||
rows={2}
|
||||
value={text}
|
||||
disabled={loading}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey || e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1.5">
|
||||
{onRemove && (
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded px-2.5 py-1 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={onRemove}
|
||||
disabled={loading}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={!text.trim() || loading}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronDown, ChevronRight, Check, Link2, Unlink } from "lucide-react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronDown, ChevronRight, Check, Link2, Unlink, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -18,11 +18,14 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
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" | "tools"
|
||||
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -33,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",
|
||||
|
|
@ -66,6 +81,13 @@ const tabs: TabConfig[] = [
|
|||
icon: Wrench,
|
||||
description: "Browse and enable Composio toolkits",
|
||||
},
|
||||
{
|
||||
id: "note-tagging",
|
||||
label: "Note Tagging",
|
||||
icon: Tags,
|
||||
path: "config/tags.json",
|
||||
description: "Configure tags for notes and emails",
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsDialogProps {
|
||||
|
|
@ -1225,18 +1247,561 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
tag: string
|
||||
type: string
|
||||
applicability: "email" | "notes" | "both"
|
||||
description: string
|
||||
example?: string
|
||||
noteEffect?: "create" | "skip" | "none"
|
||||
}
|
||||
|
||||
const NOTE_TAG_TYPE_ORDER = [
|
||||
"relationship", "relationship-sub", "topic", "action", "status", "source",
|
||||
]
|
||||
|
||||
const EMAIL_TAG_TYPE_ORDER = [
|
||||
"relationship", "topic", "email-type", "filter", "action", "status",
|
||||
]
|
||||
|
||||
const TAG_TYPE_LABELS: Record<string, string> = {
|
||||
"relationship": "Relationship",
|
||||
"relationship-sub": "Relationship Sub-Tags",
|
||||
"topic": "Topic",
|
||||
"email-type": "Email Type",
|
||||
"filter": "Filter",
|
||||
"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,
|
||||
collapsed,
|
||||
onToggle,
|
||||
onAdd,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
getGlobalIndex,
|
||||
isEmail,
|
||||
}: {
|
||||
group: { type: string; label: string; tags: TagDef[] }
|
||||
tags: TagDef[]
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
onAdd: () => void
|
||||
onUpdate: (index: number, field: keyof TagDef, value: string | boolean) => void
|
||||
onRemove: (index: number) => void
|
||||
getGlobalIndex: (type: string, localIndex: number) => number
|
||||
isEmail: boolean
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight className={cn("size-3.5 transition-transform", !collapsed && "rotate-90")} />
|
||||
{group.label}
|
||||
<span className="text-[10px] ml-0.5">({group.tags.length})</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onAdd}
|
||||
>
|
||||
<Plus className="size-3 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{!collapsed && group.tags.length > 0 && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className={cn(
|
||||
"gap-1 bg-muted/50 px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider grid",
|
||||
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
|
||||
)}>
|
||||
<div>Label</div>
|
||||
<div>Description</div>
|
||||
<div>Example</div>
|
||||
{isEmail && <div className="text-center" title="Emails with this label will be excluded from creating notes">Skip notes</div>}
|
||||
<div />
|
||||
</div>
|
||||
{group.tags.map((tag, localIdx) => {
|
||||
const globalIdx = getGlobalIndex(group.type, localIdx)
|
||||
return (
|
||||
<div key={globalIdx} className={cn(
|
||||
"gap-1 border-t px-2 py-0.5 items-center grid",
|
||||
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
|
||||
)}>
|
||||
<Input
|
||||
value={tag.tag}
|
||||
onChange={e => onUpdate(globalIdx, "tag", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="tag-name"
|
||||
title={tag.tag}
|
||||
/>
|
||||
<Input
|
||||
value={tag.description}
|
||||
onChange={e => onUpdate(globalIdx, "description", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="Description"
|
||||
title={tag.description}
|
||||
/>
|
||||
<Input
|
||||
value={tag.example || ""}
|
||||
onChange={e => onUpdate(globalIdx, "example", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="Example"
|
||||
title={tag.example || ""}
|
||||
/>
|
||||
{isEmail && (
|
||||
<div className="flex justify-center">
|
||||
<Switch
|
||||
checked={tag.noteEffect === "skip"}
|
||||
onCheckedChange={checked => onUpdate(globalIdx, "noteEffect", checked ? "skip" : "create")}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRemove(globalIdx)}
|
||||
className="flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && group.tags.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic px-2">No tags in this group</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [tags, setTags] = useState<TagDef[]>([])
|
||||
const [originalTags, setOriginalTags] = useState<TagDef[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
const [activeSection, setActiveSection] = useState<"notes" | "email">("notes")
|
||||
|
||||
const hasChanges = JSON.stringify(tags) !== JSON.stringify(originalTags)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("workspace:readFile", { path: "config/tags.json" })
|
||||
const parsed = JSON.parse(result.data)
|
||||
setTags(parsed)
|
||||
setOriginalTags(parsed)
|
||||
} catch {
|
||||
setTags([...DEFAULT_TAGS])
|
||||
setOriginalTags([...DEFAULT_TAGS])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [dialogOpen])
|
||||
|
||||
const noteGroups = useMemo(() => {
|
||||
const map = new Map<string, TagDef[]>()
|
||||
for (const tag of tags) {
|
||||
if (tag.applicability === "email") continue
|
||||
const list = map.get(tag.type) ?? []
|
||||
list.push(tag)
|
||||
map.set(tag.type, list)
|
||||
}
|
||||
return NOTE_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
|
||||
type,
|
||||
label: TAG_TYPE_LABELS[type],
|
||||
tags: map.get(type) ?? [],
|
||||
}))
|
||||
}, [tags])
|
||||
|
||||
const emailGroups = useMemo(() => {
|
||||
const map = new Map<string, TagDef[]>()
|
||||
for (const tag of tags) {
|
||||
if (tag.applicability === "notes") continue
|
||||
const list = map.get(tag.type) ?? []
|
||||
list.push(tag)
|
||||
map.set(tag.type, list)
|
||||
}
|
||||
return EMAIL_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
|
||||
type,
|
||||
label: TAG_TYPE_LABELS[type],
|
||||
tags: map.get(type) ?? [],
|
||||
}))
|
||||
}, [tags])
|
||||
|
||||
const getGlobalIndex = useCallback((type: string, localIndex: number) => {
|
||||
let count = 0
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
if (tags[i].type === type) {
|
||||
if (count === localIndex) return i
|
||||
count++
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}, [tags])
|
||||
|
||||
const updateTag = useCallback((index: number, field: keyof TagDef, value: string | boolean) => {
|
||||
setTags(prev => prev.map((t, i) => i === index ? { ...t, [field]: value } : t))
|
||||
}, [])
|
||||
|
||||
const removeTag = useCallback((index: number) => {
|
||||
setTags(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const addTag = useCallback((type: string) => {
|
||||
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 notesOnlyTypes = ["relationship-sub", "source"]
|
||||
let finalApplicability: "email" | "notes" | "both" = "both"
|
||||
if (emailOnlyTypes.includes(type)) finalApplicability = "email"
|
||||
else if (notesOnlyTypes.includes(type)) finalApplicability = "notes"
|
||||
else finalApplicability = isEmailSection ? "email" : applicability
|
||||
|
||||
const newTag: TagDef = {
|
||||
tag: "",
|
||||
type,
|
||||
applicability: finalApplicability === "email" && !isEmailSection ? "both" : finalApplicability === "notes" && isEmailSection ? "both" : finalApplicability,
|
||||
description: "",
|
||||
noteEffect: isEmailSection ? "create" : "none",
|
||||
}
|
||||
const lastIndex = tags.reduce((acc, t, i) => t.type === type ? i : acc, -1)
|
||||
if (lastIndex === -1) {
|
||||
setTags(prev => [...prev, newTag])
|
||||
} else {
|
||||
setTags(prev => [...prev.slice(0, lastIndex + 1), newTag, ...prev.slice(lastIndex + 1)])
|
||||
}
|
||||
}, [tags, activeSection])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("workspace:writeFile", {
|
||||
path: "config/tags.json",
|
||||
data: JSON.stringify(tags, null, 2),
|
||||
})
|
||||
setOriginalTags([...tags])
|
||||
toast.success("Tag configuration saved")
|
||||
} catch {
|
||||
toast.error("Failed to save tag configuration")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [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)
|
||||
if (next.has(type)) next.delete(type)
|
||||
else next.add(type)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentGroups = activeSection === "notes" ? noteGroups : emailGroups
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-1 mb-3 border-b">
|
||||
<button
|
||||
onClick={() => setActiveSection("notes")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
|
||||
activeSection === "notes"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3.5" />
|
||||
Note Tags
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSection("email")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
|
||||
activeSection === "email"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Mail className="size-3.5" />
|
||||
Email Labels
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
|
||||
{currentGroups.map(group => (
|
||||
<TagGroupTable
|
||||
key={group.type}
|
||||
group={group}
|
||||
tags={tags}
|
||||
collapsed={collapsedGroups.has(group.type)}
|
||||
onToggle={() => toggleGroup(group.type)}
|
||||
onAdd={() => addTag(group.type)}
|
||||
onUpdate={updateTag}
|
||||
onRemove={removeTag}
|
||||
getGlobalIndex={getGlobalIndex}
|
||||
isEmail={activeSection === "email"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-3 border-t mt-3 flex items-center justify-between">
|
||||
<div>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Settings Dialog ---
|
||||
|
||||
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 => {
|
||||
|
|
@ -1248,7 +1813,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
}
|
||||
|
||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||
if (tab === "appearance" || tab === "models") 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)
|
||||
|
|
@ -1325,7 +1890,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)}
|
||||
|
|
@ -1349,14 +1914,24 @@ 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" || activeTab === "tools") ? "overflow-y-auto" : "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" ? (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Mic,
|
||||
Network,
|
||||
Pencil,
|
||||
Table2,
|
||||
Plug,
|
||||
LoaderIcon,
|
||||
Settings,
|
||||
|
|
@ -86,6 +87,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"
|
||||
|
||||
|
|
@ -101,6 +103,7 @@ type KnowledgeActions = {
|
|||
createNote: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => void
|
||||
openGraph: () => void
|
||||
openBases: () => void
|
||||
expandAll: () => void
|
||||
collapseAll: () => void
|
||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||
|
|
@ -399,6 +402,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
|
||||
|
|
@ -410,6 +428,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)
|
||||
}
|
||||
|
|
@ -418,6 +437,7 @@ export function SidebarContentPanel({
|
|||
console.error('Failed to fetch OAuth state:', error)
|
||||
if (mounted) {
|
||||
setHasOauthError(false)
|
||||
setIsRowboatConnected(false)
|
||||
setShowOauthAlert(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -426,6 +446,7 @@ export function SidebarContentPanel({
|
|||
refreshOauthError()
|
||||
const cleanup = window.ipc.on('oauth:didConnect', () => {
|
||||
refreshOauthError()
|
||||
setLoggingIn(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
|
|
@ -481,17 +502,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 && (
|
||||
|
|
@ -606,6 +652,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', {
|
||||
|
|
@ -640,11 +689,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
|
||||
|
||||
|
|
@ -657,7 +707,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 })
|
||||
|
|
@ -705,11 +755,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
|
||||
|
||||
|
|
@ -726,21 +777,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
|
||||
|
||||
|
|
@ -753,7 +806,7 @@ ${transcript}
|
|||
})
|
||||
|
||||
// Re-select to trigger refresh
|
||||
onNoteCreated?.(currentNotePath)
|
||||
onNoteCreatedRef.current?.(currentNotePath)
|
||||
|
||||
if (transcript) {
|
||||
toast('Voice note transcribed', 'success')
|
||||
|
|
@ -855,6 +908,7 @@ function KnowledgeSection({
|
|||
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
||||
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
|
||||
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
|
||||
{ icon: Table2, label: "Bases", action: () => actions.openBases() },
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
|
|||
380
apps/x/apps/renderer/src/extensions/calendar-block.tsx
Normal file
380
apps/x/apps/renderer/src/extensions/calendar-block.tsx
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
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 } {
|
||||
const d = new Date(dateStr)
|
||||
return {
|
||||
day: d.getDate(),
|
||||
month: d.toLocaleDateString([], { month: 'long' }),
|
||||
weekday: d.toLocaleDateString([], { weekday: 'short' }),
|
||||
}
|
||||
}
|
||||
|
||||
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 EVENT_BAR_COLOR = '#7ec8c8'
|
||||
|
||||
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-day">{parts.day}</span>
|
||||
<div className="calendar-block-month-weekday">
|
||||
<span className="calendar-block-month">{parts.month}</span>
|
||||
<span className="calendar-block-weekday">{parts.weekday}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<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' : ''}`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); handleEventClick(event) }}
|
||||
>
|
||||
<div
|
||||
className="calendar-block-event-bar"
|
||||
style={{ backgroundColor: EVENT_BAR_COLOR }}
|
||||
/>
|
||||
<div className="calendar-block-event-content">
|
||||
<div className="calendar-block-event-title">
|
||||
{event.summary || 'Untitled event'}
|
||||
</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
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
407
apps/x/apps/renderer/src/extensions/email-block.tsx
Normal file
407
apps/x/apps/renderer/src/extensions/email-block.tsx
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, Sparkles, Loader2, MessageSquare } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
// --- 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
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase()
|
||||
}
|
||||
|
||||
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 responseMode = config?.response_mode || 'both'
|
||||
|
||||
// Local draft state for editing
|
||||
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
|
||||
const [contextExpanded, setContextExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [responseSplitOpen, setResponseSplitOpen] = useState(false)
|
||||
const responseSplitRef = useRef<HTMLDivElement>(null)
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Close split dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!responseSplitOpen) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (responseSplitRef.current && !responseSplitRef.current.contains(e.target as globalThis.Node)) setResponseSplitOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [responseSplitOpen])
|
||||
|
||||
// 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 generateResponse = useCallback(async () => {
|
||||
if (!config || generating) return
|
||||
setGenerating(true)
|
||||
try {
|
||||
const ipc = (window as unknown as { ipc: { invoke: (channel: string, args: Record<string, unknown>) => Promise<{ response?: string }> } }).ipc
|
||||
// Build context for the agent
|
||||
let noteContent = `# Email: ${config.subject || 'No subject'}\n\n`
|
||||
noteContent += `**From:** ${config.from || 'Unknown'}\n`
|
||||
noteContent += `**Date:** ${config.date || 'Unknown'}\n\n`
|
||||
noteContent += `## Latest email\n\n${config.latest_email}\n\n`
|
||||
if (config.past_summary) {
|
||||
noteContent += `## Earlier conversation summary\n\n${config.past_summary}\n\n`
|
||||
}
|
||||
|
||||
const result = await ipc.invoke('inline-task:process', {
|
||||
instruction: `Draft a concise, professional response to this email. Return only the email body text, no subject line or headers.`,
|
||||
noteContent,
|
||||
notePath: '',
|
||||
})
|
||||
|
||||
if (result.response) {
|
||||
// Clean up the response — strip any markdown headers the agent may add
|
||||
const cleaned = result.response.replace(/^#+\s+.*\n*/gm, '').trim()
|
||||
setDraftBody(cleaned)
|
||||
// Update the block data to include the draft
|
||||
const current = JSON.parse(raw) as Record<string, unknown>
|
||||
updateAttributes({ data: JSON.stringify({ ...current, draft_response: cleaned }) })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[email-block] Failed to generate response:', err)
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [config, generating, raw, updateAttributes])
|
||||
|
||||
const draftWithAssistant = useCallback(() => {
|
||||
if (!config) return
|
||||
let prompt = `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`
|
||||
window.__pendingEmailDraft = { prompt }
|
||||
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
||||
}, [config])
|
||||
|
||||
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
|
||||
|
||||
// --- Render: Draft mode (draft_response present) ---
|
||||
if (hasDraft) {
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
<div className="email-block-card" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/* Draft header */}
|
||||
{config.to && (
|
||||
<div className="email-draft-block-header">
|
||||
<div className="email-draft-block-field">
|
||||
<span className="email-draft-block-label">To</span>
|
||||
<span className="email-draft-block-value">{config.to}</span>
|
||||
</div>
|
||||
{config.subject && (
|
||||
<div className="email-draft-block-field">
|
||||
<span className="email-draft-block-label">Subject</span>
|
||||
<span className="email-draft-block-value">{config.subject}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Editable draft body */}
|
||||
<textarea
|
||||
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}
|
||||
/>
|
||||
{/* Action buttons */}
|
||||
<div className="email-draft-block-actions">
|
||||
{(hasPastSummary || config.latest_email) && (
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setContextExpanded(!contextExpanded) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronDown size={13} className={`email-block-toggle-chevron ${contextExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
|
||||
{contextExpanded ? 'Hide' : 'Show'} context
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(draftBody)
|
||||
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={() => {
|
||||
void navigator.clipboard.writeText(draftBody)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
window.open(gmailUrl, '_blank')
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Reply in Gmail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Context: latest email + past summary */}
|
||||
{contextExpanded && (
|
||||
<div className="email-block-context">
|
||||
<div className="email-block-context-section">
|
||||
<div className="email-block-message">
|
||||
<div className="email-block-message-header">
|
||||
{config.from && <div className="email-block-avatar">{getInitials(config.from)}</div>}
|
||||
<div className="email-block-sender-info">
|
||||
{config.from && <div className="email-block-sender-name">{config.from}</div>}
|
||||
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="email-block-message-body">{config.latest_email}</div>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Render: Read mode (no draft_response) ---
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
<div className="email-block-card" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
|
||||
<X size={14} />
|
||||
</button>
|
||||
{config.subject && <div className="email-block-subject">{config.subject}</div>}
|
||||
{/* Latest email message */}
|
||||
<div className="email-block-message">
|
||||
<div className="email-block-message-header">
|
||||
{config.from && <div className="email-block-avatar">{getInitials(config.from)}</div>}
|
||||
<div className="email-block-sender-info">
|
||||
{config.from && <div className="email-block-sender-name">{config.from}</div>}
|
||||
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="email-block-message-body">{config.latest_email}</div>
|
||||
</div>
|
||||
{/* Action buttons */}
|
||||
<div className="email-draft-block-actions">
|
||||
{hasPastSummary && (
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setContextExpanded(!contextExpanded) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronDown size={13} className={`email-block-toggle-chevron ${contextExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
|
||||
{contextExpanded ? 'Hide' : 'Show'} context
|
||||
</button>
|
||||
)}
|
||||
{responseMode === 'inline' && (
|
||||
<button
|
||||
className="email-block-gmail-btn email-block-generate-btn"
|
||||
onClick={generateResponse}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? <Loader2 size={13} className="email-block-spinner" /> : <Sparkles size={13} />}
|
||||
{generating ? 'Generating...' : 'Generate response'}
|
||||
</button>
|
||||
)}
|
||||
{responseMode === 'assistant' && (
|
||||
<button
|
||||
className="email-block-gmail-btn email-block-generate-btn"
|
||||
onClick={draftWithAssistant}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
Draft with assistant
|
||||
</button>
|
||||
)}
|
||||
{responseMode === 'both' && (
|
||||
<div className="email-block-response-split" ref={responseSplitRef}>
|
||||
<button
|
||||
className="email-block-split-main"
|
||||
onClick={generateResponse}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? <Loader2 size={13} className="email-block-spinner" /> : <Sparkles size={13} />}
|
||||
{generating ? 'Generating...' : 'Generate response'}
|
||||
</button>
|
||||
<button
|
||||
className={`email-block-split-chevron ${responseSplitOpen ? 'email-block-split-chevron-open' : ''}`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); setResponseSplitOpen(!responseSplitOpen) }}
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{responseSplitOpen && (
|
||||
<div className="email-block-split-dropdown">
|
||||
<button
|
||||
className="email-block-split-option"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); setResponseSplitOpen(false); draftWithAssistant() }}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
Draft with assistant
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{gmailUrl && (
|
||||
<button
|
||||
className="email-block-gmail-btn"
|
||||
onClick={() => window.open(gmailUrl, '_blank')}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Open in Gmail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Past summary context */}
|
||||
{contextExpanded && hasPastSummary && (
|
||||
<div className="email-block-context">
|
||||
<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>
|
||||
)}
|
||||
</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
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
122
apps/x/apps/renderer/src/extensions/task-block.tsx
Normal file
122
apps/x/apps/renderer/src/extensions/task-block.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { CalendarClock, Loader2, X } from 'lucide-react'
|
||||
import { inlineTask } from '@x/shared'
|
||||
|
||||
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">
|
||||
<button
|
||||
className="task-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete task block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="task-block-content">
|
||||
<span className="task-block-instruction"><span className="task-block-prefix">@rowboat</span> {instruction}</span>
|
||||
{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>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const TaskBlockExtension = Node.create({
|
||||
name: 'taskBlock',
|
||||
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-task') || cls.includes('language-tell-rowboat')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'task-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TaskBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```task\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
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,
|
||||
}
|
||||
}
|
||||
414
apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts
Normal file
414
apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
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 note';
|
||||
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}`,
|
||||
'',
|
||||
);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (i > 0 && entries[i].speaker !== entries[i - 1].speaker) {
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(`**${entries[i].speaker}:** ${entries[i].text}`);
|
||||
lines.push('');
|
||||
}
|
||||
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');
|
||||
|
||||
// Detect headphones vs speakers
|
||||
const usingHeadphones = await detectHeadphones();
|
||||
console.log(`[meeting] Audio output mode: ${usingHeadphones ? 'headphones' : 'speakers'}`);
|
||||
|
||||
// Rowboat WebSocket + bearer token when signed in; else local Deepgram API key
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
const account = await refreshRowboatAccount();
|
||||
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) {
|
||||
console.error('[meeting] No Deepgram config available');
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
console.log('[meeting] Using Deepgram API key');
|
||||
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', config.deepgram.apiKey]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[meeting] Failed to connect Deepgram:', err);
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
wsRef.current = ws;
|
||||
|
||||
// Wait for WS open
|
||||
const wsOk = await new Promise<boolean>((resolve) => {
|
||||
ws.onopen = () => resolve(true);
|
||||
ws.onerror = () => resolve(false);
|
||||
setTimeout(() => resolve(false), 5000);
|
||||
});
|
||||
if (!wsOk) {
|
||||
console.error('[meeting] WebSocket failed to connect');
|
||||
cleanup();
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
console.log('[meeting] WebSocket connected');
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// Get mic stream
|
||||
let micStream: MediaStream;
|
||||
try {
|
||||
micStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[meeting] Microphone access denied:', err);
|
||||
cleanup();
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
micStreamRef.current = micStream;
|
||||
|
||||
// Get system audio via getDisplayMedia (loopback)
|
||||
let systemStream: MediaStream;
|
||||
try {
|
||||
systemStream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
|
||||
systemStream.getVideoTracks().forEach(t => t.stop());
|
||||
} catch (err) {
|
||||
console.error('[meeting] System audio access denied:', err);
|
||||
cleanup();
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
if (systemStream.getAudioTracks().length === 0) {
|
||||
console.error('[meeting] No audio track from getDisplayMedia');
|
||||
systemStream.getTracks().forEach(t => t.stop());
|
||||
cleanup();
|
||||
setState('idle');
|
||||
return null;
|
||||
}
|
||||
console.log('[meeting] System audio captured');
|
||||
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 };
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
192
apps/x/apps/renderer/src/hooks/useVoiceMode.ts
Normal file
192
apps/x/apps/renderer/src/hooks/useVoiceMode.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
|
||||
export type VoiceState = 'idle' | 'connecting' | 'listening';
|
||||
|
||||
const DEEPGRAM_PARAMS = new URLSearchParams({
|
||||
model: 'nova-3',
|
||||
encoding: 'linear16',
|
||||
sample_rate: '16000',
|
||||
channels: '1',
|
||||
interim_results: 'true',
|
||||
smart_format: 'true',
|
||||
punctuate: 'true',
|
||||
language: 'en',
|
||||
});
|
||||
const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`;
|
||||
|
||||
export function useVoiceMode() {
|
||||
const { refresh: refreshRowboatAccount } = useRowboatAccount();
|
||||
const [state, setState] = useState<VoiceState>('idle');
|
||||
const [interimText, setInterimText] = useState('');
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const transcriptBufferRef = useRef('');
|
||||
const interimRef = useRef('');
|
||||
|
||||
// Connect (or reconnect) the Deepgram WebSocket.
|
||||
// Refreshes Rowboat account before connect so access token is current.
|
||||
const connectWs = useCallback(async () => {
|
||||
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return;
|
||||
|
||||
let ws: WebSocket;
|
||||
|
||||
const account = await refreshRowboatAccount();
|
||||
if (
|
||||
account?.signedIn &&
|
||||
account.accessToken &&
|
||||
account.config?.websocketApiUrl
|
||||
) {
|
||||
const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS);
|
||||
ws = new WebSocket(listenUrl, ['bearer', account.accessToken]);
|
||||
} 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]);
|
||||
}
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[voice] WebSocket connected');
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
if (data.is_final) {
|
||||
transcriptBufferRef.current += (transcriptBufferRef.current ? ' ' : '') + transcript;
|
||||
interimRef.current = '';
|
||||
setInterimText(transcriptBufferRef.current);
|
||||
} else {
|
||||
interimRef.current = transcript;
|
||||
setInterimText(transcriptBufferRef.current + (transcriptBufferRef.current ? ' ' : '') + transcript);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
console.error('[voice] WebSocket error');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[voice] WebSocket closed');
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [refreshRowboatAccount]);
|
||||
|
||||
// Stop audio capture and close WS
|
||||
const stopAudioCapture = useCallback(() => {
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
}
|
||||
if (audioCtxRef.current) {
|
||||
audioCtxRef.current.close();
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
setInterimText('');
|
||||
transcriptBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
setState('idle');
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (state !== 'idle') return;
|
||||
|
||||
transcriptBufferRef.current = '';
|
||||
interimRef.current = '';
|
||||
setInterimText('');
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
setState('listening');
|
||||
|
||||
// Start mic
|
||||
let stream: MediaStream | null = null;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (err) {
|
||||
console.error('Microphone access denied:', err);
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
mediaStreamRef.current = stream;
|
||||
|
||||
// Start audio capture
|
||||
const audioCtx = new AudioContext({ sampleRate: 16000 });
|
||||
audioCtxRef.current = audioCtx;
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
const processor = audioCtx.createScriptProcessor(4096, 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);
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioCtx.destination);
|
||||
}, [state, connectWs]);
|
||||
|
||||
/** Stop recording and return the full transcript (finalized + any current interim) */
|
||||
const submit = useCallback((): string => {
|
||||
let text = transcriptBufferRef.current;
|
||||
if (interimRef.current) {
|
||||
text += (text ? ' ' : '') + interimRef.current;
|
||||
}
|
||||
text = text.trim();
|
||||
stopAudioCapture();
|
||||
return text;
|
||||
}, [stopAudioCapture]);
|
||||
|
||||
/** Cancel recording without returning transcript */
|
||||
const cancel = useCallback(() => {
|
||||
stopAudioCapture();
|
||||
}, [stopAudioCapture]);
|
||||
|
||||
return { state, interimText, start, submit, cancel };
|
||||
}
|
||||
108
apps/x/apps/renderer/src/hooks/useVoiceTTS.ts
Normal file
108
apps/x/apps/renderer/src/hooks/useVoiceTTS.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
export type TTSState = 'idle' | 'synthesizing' | 'speaking';
|
||||
|
||||
interface SynthesizedAudio {
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
function synthesize(text: string): Promise<SynthesizedAudio> {
|
||||
return window.ipc.invoke('voice:synthesize', { text }).then(
|
||||
(result: { audioBase64: string; mimeType: string }) => ({
|
||||
dataUrl: `data:${result.mimeType};base64,${result.audioBase64}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function playAudio(dataUrl: string, audioRef: React.MutableRefObject<HTMLAudioElement | null>): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const audio = new Audio(dataUrl);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => {
|
||||
console.log('[tts] audio ended');
|
||||
resolve();
|
||||
};
|
||||
audio.onerror = (e) => {
|
||||
console.error('[tts] audio error:', e);
|
||||
reject(new Error('Audio playback failed'));
|
||||
};
|
||||
audio.play().then(() => {
|
||||
console.log('[tts] audio playing');
|
||||
}).catch((err) => {
|
||||
console.error('[tts] play() rejected:', err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function useVoiceTTS() {
|
||||
const [state, setState] = useState<TTSState>('idle');
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const queueRef = useRef<string[]>([]);
|
||||
const processingRef = useRef(false);
|
||||
// Pre-fetched audio ready to play immediately
|
||||
const prefetchedRef = useRef<Promise<SynthesizedAudio> | null>(null);
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (processingRef.current) return;
|
||||
processingRef.current = true;
|
||||
|
||||
while (queueRef.current.length > 0) {
|
||||
const text = queueRef.current.shift()!;
|
||||
if (!text.trim()) continue;
|
||||
|
||||
try {
|
||||
// Use pre-fetched result if available, otherwise synthesize now
|
||||
let audioPromise: Promise<SynthesizedAudio>;
|
||||
if (prefetchedRef.current) {
|
||||
console.log('[tts] using pre-fetched audio');
|
||||
audioPromise = prefetchedRef.current;
|
||||
prefetchedRef.current = null;
|
||||
} else {
|
||||
setState('synthesizing');
|
||||
console.log('[tts] synthesizing:', text.substring(0, 80));
|
||||
audioPromise = synthesize(text);
|
||||
}
|
||||
|
||||
const audio = await audioPromise;
|
||||
setState('speaking');
|
||||
|
||||
// Kick off pre-fetch for next chunk while this one plays
|
||||
const nextText = queueRef.current[0];
|
||||
if (nextText?.trim()) {
|
||||
console.log('[tts] pre-fetching next:', nextText.substring(0, 80));
|
||||
prefetchedRef.current = synthesize(nextText);
|
||||
}
|
||||
|
||||
await playAudio(audio.dataUrl, audioRef);
|
||||
} catch (err) {
|
||||
console.error('[tts] error:', err);
|
||||
prefetchedRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
audioRef.current = null;
|
||||
prefetchedRef.current = null;
|
||||
processingRef.current = false;
|
||||
setState('idle');
|
||||
}, []);
|
||||
|
||||
const speak = useCallback((text: string) => {
|
||||
console.log('[tts] speak() called:', text.substring(0, 80));
|
||||
queueRef.current.push(text);
|
||||
processQueue();
|
||||
}, [processQueue]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
queueRef.current = [];
|
||||
prefetchedRef.current = null;
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
processingRef.current = false;
|
||||
setState('idle');
|
||||
}, []);
|
||||
|
||||
return { state, speak, cancel };
|
||||
}
|
||||
|
|
@ -150,6 +150,89 @@ export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null =
|
|||
return null
|
||||
}
|
||||
|
||||
// App navigation action card data
|
||||
export type AppActionCardData = {
|
||||
action: string
|
||||
label: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const summarizeFilterUpdates = (updates: Record<string, unknown>): string => {
|
||||
const filters = updates.filters as Record<string, unknown> | undefined
|
||||
const parts: string[] = []
|
||||
|
||||
if (filters) {
|
||||
if (filters.clear) parts.push('Cleared filters')
|
||||
const set = filters.set as Array<{ category: string; value: string }> | undefined
|
||||
if (set?.length) parts.push(`Set ${set.length} filter${set.length !== 1 ? 's' : ''}: ${set.map(f => `${f.category}=${f.value}`).join(', ')}`)
|
||||
const add = filters.add as Array<{ category: string; value: string }> | undefined
|
||||
if (add?.length) parts.push(`Added ${add.length} filter${add.length !== 1 ? 's' : ''}`)
|
||||
const remove = filters.remove as Array<{ category: string; value: string }> | undefined
|
||||
if (remove?.length) parts.push(`Removed ${remove.length} filter${remove.length !== 1 ? 's' : ''}`)
|
||||
}
|
||||
|
||||
if (updates.sort) {
|
||||
const sort = updates.sort as { field: string; dir: string }
|
||||
parts.push(`Sorted by ${sort.field} ${sort.dir}`)
|
||||
}
|
||||
|
||||
if (updates.search !== undefined) {
|
||||
parts.push(updates.search ? `Searching "${updates.search}"` : 'Cleared search')
|
||||
}
|
||||
|
||||
const columns = updates.columns as Record<string, unknown> | undefined
|
||||
if (columns) {
|
||||
const set = columns.set as string[] | undefined
|
||||
if (set) parts.push(`Set ${set.length} column${set.length !== 1 ? 's' : ''}`)
|
||||
const add = columns.add as string[] | undefined
|
||||
if (add?.length) parts.push(`Added ${add.length} column${add.length !== 1 ? 's' : ''}`)
|
||||
const remove = columns.remove as string[] | undefined
|
||||
if (remove?.length) parts.push(`Removed ${remove.length} column${remove.length !== 1 ? 's' : ''}`)
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(', ') : 'Updated view'
|
||||
}
|
||||
|
||||
export const getAppActionCardData = (tool: ToolCall): AppActionCardData | null => {
|
||||
if (tool.name !== 'app-navigation') return null
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
|
||||
// While pending/running, derive label from input
|
||||
if (!result || !result.success) {
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
if (!input) return null
|
||||
const action = input.action as string
|
||||
switch (action) {
|
||||
case 'open-note': return { action, label: `Opening ${(input.path as string || '').split('/').pop()?.replace(/\.md$/, '') || 'note'}...` }
|
||||
case 'open-view': return { action, label: `Opening ${input.view} view...` }
|
||||
case 'update-base-view': return { action, label: 'Updating view...' }
|
||||
case 'create-base': return { action, label: `Creating "${input.name}"...` }
|
||||
case 'get-base-state': return null // renders as normal tool block
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
switch (result.action) {
|
||||
case 'open-note': {
|
||||
const filePath = result.path as string || ''
|
||||
const name = filePath.split('/').pop()?.replace(/\.md$/, '') || 'note'
|
||||
return { action: 'open-note', label: `Opened ${name}` }
|
||||
}
|
||||
case 'open-view':
|
||||
return { action: 'open-view', label: `Opened ${result.view} view` }
|
||||
case 'update-base-view':
|
||||
return {
|
||||
action: 'update-base-view',
|
||||
label: summarizeFilterUpdates(result.updates as Record<string, unknown> || {}),
|
||||
details: result.updates as Record<string, unknown>,
|
||||
}
|
||||
case 'create-base':
|
||||
return { action: 'create-base', label: `Created base "${result.name}"` }
|
||||
default:
|
||||
return null // get-base-state renders as normal tool block
|
||||
}
|
||||
}
|
||||
|
||||
// Parse attached files from message content and return clean message + file paths.
|
||||
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
|
||||
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
|
||||
|
|
|
|||
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();
|
||||
}
|
||||
367
apps/x/apps/renderer/src/lib/frontmatter.ts
Normal file
367
apps/x/apps/renderer/src/lib/frontmatter.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
/**
|
||||
* Utilities for splitting, joining, and extracting tags from YAML frontmatter
|
||||
* in knowledge notes and email files.
|
||||
*/
|
||||
|
||||
/** Split content into raw frontmatter block and body text. */
|
||||
export function splitFrontmatter(content: string): { raw: string | null; body: string } {
|
||||
if (!content.startsWith('---')) {
|
||||
return { raw: null, body: content }
|
||||
}
|
||||
const endIndex = content.indexOf('\n---', 3)
|
||||
if (endIndex === -1) {
|
||||
return { raw: null, body: content }
|
||||
}
|
||||
// raw includes both delimiters and the trailing newline after closing ---
|
||||
const closingEnd = endIndex + 4 // '\n---' is 4 chars
|
||||
const raw = content.slice(0, closingEnd)
|
||||
// body starts after the closing --- and its trailing newline
|
||||
let body = content.slice(closingEnd)
|
||||
if (body.startsWith('\n')) {
|
||||
body = body.slice(1)
|
||||
}
|
||||
return { raw, body }
|
||||
}
|
||||
|
||||
/** Re-prepend raw frontmatter before body when saving. */
|
||||
export function joinFrontmatter(raw: string | null, body: string): string {
|
||||
if (!raw) return body
|
||||
return raw + '\n' + body
|
||||
}
|
||||
|
||||
/** Structured frontmatter fields extracted from categorized YAML. */
|
||||
export type FrontmatterFields = {
|
||||
relationship: string | null
|
||||
relationship_sub: string[]
|
||||
topic: string[]
|
||||
email_type: string[]
|
||||
action: string[]
|
||||
status: string | null
|
||||
source: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract structured tag categories from raw frontmatter YAML.
|
||||
*
|
||||
* Handles both the new categorized format (top-level keys) and the legacy
|
||||
* flat `tags:` list. For legacy notes the flat tags are mapped into
|
||||
* categories using known tag values.
|
||||
*/
|
||||
export function extractFrontmatterFields(raw: string | null): FrontmatterFields {
|
||||
const fields: FrontmatterFields = {
|
||||
relationship: null,
|
||||
relationship_sub: [],
|
||||
topic: [],
|
||||
email_type: [],
|
||||
action: [],
|
||||
status: null,
|
||||
source: [],
|
||||
}
|
||||
if (!raw) return fields
|
||||
|
||||
const lines = raw.split('\n')
|
||||
let currentKey: string | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
// Top-level key detection
|
||||
const topMatch = line.match(/^(\w+):\s*(.*)$/)
|
||||
if (topMatch || line === '---') {
|
||||
currentKey = null
|
||||
}
|
||||
|
||||
if (topMatch) {
|
||||
const key = topMatch[1]
|
||||
const value = topMatch[2].trim()
|
||||
|
||||
if (key in fields) {
|
||||
currentKey = key
|
||||
if (value) {
|
||||
const field = fields[key as keyof FrontmatterFields]
|
||||
if (Array.isArray(field)) {
|
||||
(field as string[]).push(value)
|
||||
} else {
|
||||
// single-value field
|
||||
;(fields as Record<string, unknown>)[key] = value
|
||||
}
|
||||
currentKey = null // inline value, no list follows
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Legacy flat tags: — parse and distribute into categories
|
||||
if (key === 'tags') {
|
||||
currentKey = '__legacy_tags'
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// List items under a categorized key
|
||||
if (currentKey && currentKey !== '__legacy_tags') {
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||
if (itemMatch) {
|
||||
const value = itemMatch[1].trim()
|
||||
const field = fields[currentKey as keyof FrontmatterFields]
|
||||
if (Array.isArray(field)) {
|
||||
(field as string[]).push(value)
|
||||
} else {
|
||||
;(fields as Record<string, unknown>)[currentKey] = value
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Legacy flat tag items → map into categories
|
||||
if (currentKey === '__legacy_tags') {
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||
if (itemMatch) {
|
||||
const tag = itemMatch[1].trim()
|
||||
const cat = LEGACY_TAG_TO_CATEGORY[tag]
|
||||
if (cat) {
|
||||
const field = fields[cat as keyof FrontmatterFields]
|
||||
if (Array.isArray(field)) {
|
||||
(field as string[]).push(tag)
|
||||
} else if (!(fields as Record<string, unknown>)[cat]) {
|
||||
;(fields as Record<string, unknown>)[cat] = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ALL top-level YAML key/value pairs from raw frontmatter.
|
||||
* Returns a flat record where scalar values are strings and list values are string[].
|
||||
* Skips `---` delimiters and blank lines.
|
||||
*/
|
||||
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
|
||||
const result: Record<string, string | string[]> = {}
|
||||
if (!raw) return result
|
||||
|
||||
const lines = raw.split('\n')
|
||||
let currentKey: string | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === '---' || line.trim() === '') {
|
||||
currentKey = null
|
||||
continue
|
||||
}
|
||||
|
||||
// Top-level key: value
|
||||
const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/)
|
||||
if (topMatch) {
|
||||
const key = topMatch[1]
|
||||
const value = topMatch[2].trim()
|
||||
if (value) {
|
||||
result[key] = value
|
||||
currentKey = null
|
||||
} else {
|
||||
// List will follow
|
||||
currentKey = key
|
||||
result[key] = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// List item under current key
|
||||
if (currentKey) {
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||
if (itemMatch) {
|
||||
const arr = result[currentKey]
|
||||
if (Array.isArray(arr)) {
|
||||
arr.push(itemMatch[1].trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Record of frontmatter fields back to a raw YAML frontmatter string.
|
||||
* Returns null if no non-empty fields remain.
|
||||
*/
|
||||
export function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
|
||||
const lines: string[] = []
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) continue
|
||||
lines.push(`${key}:`)
|
||||
for (const item of value) {
|
||||
if (item.trim()) lines.push(` - ${item.trim()}`)
|
||||
}
|
||||
} else {
|
||||
const trimmed = (value ?? '').trim()
|
||||
if (!trimmed) continue
|
||||
lines.push(`${key}: ${trimmed}`)
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) return null
|
||||
return `---\n${lines.join('\n')}\n---`
|
||||
}
|
||||
|
||||
/** Map known tag values → category for legacy flat-list frontmatter. */
|
||||
const LEGACY_TAG_TO_CATEGORY: Record<string, string> = {
|
||||
// relationship
|
||||
investor: 'relationship', customer: 'relationship', prospect: 'relationship',
|
||||
partner: 'relationship', vendor: 'relationship', product: 'relationship',
|
||||
candidate: 'relationship', team: 'relationship', advisor: 'relationship',
|
||||
personal: 'relationship', press: 'relationship', community: 'relationship',
|
||||
government: 'relationship',
|
||||
// relationship_sub
|
||||
primary: 'relationship_sub', secondary: 'relationship_sub',
|
||||
'executive-assistant': 'relationship_sub', cc: 'relationship_sub',
|
||||
'referred-by': 'relationship_sub', former: 'relationship_sub',
|
||||
champion: 'relationship_sub', blocker: 'relationship_sub',
|
||||
// topic
|
||||
sales: 'topic', support: 'topic', legal: 'topic', finance: 'topic',
|
||||
hiring: 'topic', fundraising: 'topic', travel: 'topic', event: 'topic',
|
||||
shopping: 'topic', health: 'topic', learning: 'topic', research: 'topic',
|
||||
// email_type
|
||||
intro: 'email_type', followup: 'email_type',
|
||||
// action
|
||||
'action-required': 'action', urgent: 'action', waiting: 'action',
|
||||
// status
|
||||
active: 'status', archived: 'status', stale: 'status',
|
||||
// source
|
||||
email: 'source', meeting: 'source', browser: 'source',
|
||||
'web-search': 'source', manual: 'source', import: 'source',
|
||||
}
|
||||
|
||||
/** Tag category keys used in the categorized frontmatter format. */
|
||||
const TAG_CATEGORY_KEYS = new Set([
|
||||
'relationship',
|
||||
'relationship_sub',
|
||||
'topic',
|
||||
'email_type',
|
||||
'action',
|
||||
'status',
|
||||
'source',
|
||||
])
|
||||
|
||||
/** Keys that are metadata, not tags — skip when collecting tags. */
|
||||
const METADATA_KEYS = new Set(['processed', 'labeled_at', 'tagged_at'])
|
||||
|
||||
/**
|
||||
* Extract tags from raw frontmatter YAML.
|
||||
*
|
||||
* Handles three formats:
|
||||
* - Legacy flat list: `tags:` followed by ` - value` items
|
||||
* - Categorized format: top-level keys like `relationship: customer` or
|
||||
* `topic:` followed by ` - value` list items
|
||||
* - Email format: `labels:` with nested keys (relationship, topics, type, filter, action)
|
||||
* where values can be single strings or ` - value` arrays
|
||||
*
|
||||
* Skips metadata keys like `processed`, `labeled_at`, `tagged_at`.
|
||||
*/
|
||||
export function extractTags(raw: string | null): string[] {
|
||||
if (!raw) return []
|
||||
|
||||
const lines = raw.split('\n')
|
||||
const tags: string[] = []
|
||||
|
||||
let inTags = false
|
||||
let inLabels = false
|
||||
let inLabelSubKey = false
|
||||
let inCategoryList = false
|
||||
|
||||
for (const line of lines) {
|
||||
// Top-level key detection — resets all nested state
|
||||
if (/^\w/.test(line) || line === '---') {
|
||||
inTags = false
|
||||
inLabels = false
|
||||
inLabelSubKey = false
|
||||
inCategoryList = false
|
||||
}
|
||||
|
||||
// Legacy note format: tags:
|
||||
if (/^tags:\s*$/.test(line)) {
|
||||
inTags = true
|
||||
inLabels = false
|
||||
inCategoryList = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Email format: labels:
|
||||
if (/^labels:\s*$/.test(line)) {
|
||||
inLabels = true
|
||||
inTags = false
|
||||
inCategoryList = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Categorized format: top-level tag category key
|
||||
const topKeyMatch = line.match(/^(\w+):\s*(.*)$/)
|
||||
if (topKeyMatch) {
|
||||
const key = topKeyMatch[1]
|
||||
const inlineValue = topKeyMatch[2].trim()
|
||||
|
||||
if (TAG_CATEGORY_KEYS.has(key)) {
|
||||
if (inlineValue) {
|
||||
// Single value: `relationship: customer`
|
||||
tags.push(inlineValue)
|
||||
inCategoryList = false
|
||||
} else {
|
||||
// List follows: `topic:\n - sales`
|
||||
inCategoryList = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Collect tag items under `tags:`
|
||||
if (inTags) {
|
||||
const match = line.match(/^\s+-\s+(.+)$/)
|
||||
if (match) {
|
||||
tags.push(match[1].trim())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect list items under a category key
|
||||
if (inCategoryList) {
|
||||
const match = line.match(/^\s+-\s+(.+)$/)
|
||||
if (match) {
|
||||
tags.push(match[1].trim())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle labels: nested structure
|
||||
if (inLabels) {
|
||||
// Sub-key like ` relationship:` or ` topics:`
|
||||
const subKeyMatch = line.match(/^\s{2}(\w+):\s*(.*)$/)
|
||||
if (subKeyMatch) {
|
||||
const key = subKeyMatch[1]
|
||||
const inlineValue = subKeyMatch[2].trim()
|
||||
if (METADATA_KEYS.has(key)) {
|
||||
inLabelSubKey = false
|
||||
continue
|
||||
}
|
||||
if (inlineValue) {
|
||||
// Inline value like ` type: person`
|
||||
tags.push(inlineValue)
|
||||
inLabelSubKey = false
|
||||
} else {
|
||||
// Array follows
|
||||
inLabelSubKey = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Array item under a sub-key like ` - value`
|
||||
if (inLabelSubKey) {
|
||||
const itemMatch = line.match(/^\s{4}-\s+(.+)$/)
|
||||
if (itemMatch) {
|
||||
tags.push(itemMatch[1].trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
8
apps/x/packages/core/src/account/account.ts
Normal file
8
apps/x/packages/core/src/account/account.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import container from '../di/container.js';
|
||||
import { IOAuthRepo } from '../auth/repo.js';
|
||||
|
||||
export async function isSignedIn(): Promise<boolean> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const { tokens } = await oauthRepo.read('rowboat');
|
||||
return !!tokens;
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import { jsonSchema, ModelMessage } from "ai";
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { getNoteCreationStrictness } from "../config/note_creation_config.js";
|
||||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
|
||||
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
|
||||
|
|
@ -17,6 +16,8 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu
|
|||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { createProvider } from "../models/models.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import { getGatewayProvider } from "../models/gateway.js";
|
||||
import { IAgentsRepo } from "./repo.js";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
|
|
@ -26,9 +27,65 @@ import { IRunsLock } from "../runs/lock.js";
|
|||
import { IAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { PrefixLogger } from "@x/shared";
|
||||
import { parse } from "yaml";
|
||||
import { raw as noteCreationMediumRaw } from "../knowledge/note_creation_medium.js";
|
||||
import { raw as noteCreationLowRaw } from "../knowledge/note_creation_low.js";
|
||||
import { raw as noteCreationHighRaw } from "../knowledge/note_creation_high.js";
|
||||
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>;
|
||||
|
|
@ -320,24 +377,12 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
tools[name] = { type: "builtin", name };
|
||||
}
|
||||
// Rebuild instructions to include current Composio tools section
|
||||
const instructions = buildCopilotInstructions();
|
||||
const instructions = await buildCopilotInstructions();
|
||||
return { ...CopilotAgent, tools, instructions };
|
||||
}
|
||||
|
||||
if (id === 'note_creation') {
|
||||
const strictness = getNoteCreationStrictness();
|
||||
let raw = '';
|
||||
switch (strictness) {
|
||||
case 'medium':
|
||||
raw = noteCreationMediumRaw;
|
||||
break;
|
||||
case 'low':
|
||||
raw = noteCreationLowRaw;
|
||||
break;
|
||||
case 'high':
|
||||
raw = noteCreationHighRaw;
|
||||
break;
|
||||
}
|
||||
const raw = getNoteCreationRaw();
|
||||
let agent: z.infer<typeof Agent> = {
|
||||
name: id,
|
||||
instructions: raw,
|
||||
|
|
@ -362,6 +407,106 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
return agent;
|
||||
}
|
||||
|
||||
if (id === 'labeling_agent') {
|
||||
const labelingAgentRaw = getLabelingAgentRaw();
|
||||
let agent: z.infer<typeof Agent> = {
|
||||
name: id,
|
||||
instructions: labelingAgentRaw,
|
||||
};
|
||||
|
||||
if (labelingAgentRaw.startsWith("---")) {
|
||||
const end = labelingAgentRaw.indexOf("\n---", 3);
|
||||
if (end !== -1) {
|
||||
const fm = labelingAgentRaw.slice(3, end).trim();
|
||||
const content = labelingAgentRaw.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;
|
||||
}
|
||||
|
||||
if (id === 'note_tagging_agent') {
|
||||
const noteTaggingAgentRaw = getNoteTaggingAgentRaw();
|
||||
let agent: z.infer<typeof Agent> = {
|
||||
name: id,
|
||||
instructions: noteTaggingAgentRaw,
|
||||
};
|
||||
|
||||
if (noteTaggingAgentRaw.startsWith("---")) {
|
||||
const end = noteTaggingAgentRaw.indexOf("\n---", 3);
|
||||
if (end !== -1) {
|
||||
const fm = noteTaggingAgentRaw.slice(3, end).trim();
|
||||
const content = noteTaggingAgentRaw.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;
|
||||
}
|
||||
|
||||
if (id === 'inline_task_agent') {
|
||||
const inlineTaskAgentRaw = getInlineTaskAgentRaw();
|
||||
let agent: z.infer<typeof Agent> = {
|
||||
name: id,
|
||||
instructions: inlineTaskAgentRaw,
|
||||
};
|
||||
|
||||
if (inlineTaskAgentRaw.startsWith("---")) {
|
||||
const end = inlineTaskAgentRaw.indexOf("\n---", 3);
|
||||
if (end !== -1) {
|
||||
const fm = inlineTaskAgentRaw.slice(3, end).trim();
|
||||
const content = inlineTaskAgentRaw.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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -714,11 +859,21 @@ export async function* streamAgent({
|
|||
const tools = await buildTools(agent);
|
||||
|
||||
// set up provider + model
|
||||
const provider = createProvider(modelConfig.provider);
|
||||
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep"];
|
||||
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
|
||||
? modelConfig.knowledgeGraphModel
|
||||
: modelConfig.model;
|
||||
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", "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-mini" : defaultModel;
|
||||
const modelId = isInlineTaskAgent
|
||||
? defaultInlineTaskModel
|
||||
: (isKgAgent && modelConfig.knowledgeGraphModel)
|
||||
? modelConfig.knowledgeGraphModel
|
||||
: isKgAgent ? defaultKgModel : defaultModel;
|
||||
const model = provider.languageModel(modelId);
|
||||
logger.log(`using model: ${modelId}`);
|
||||
|
||||
|
|
@ -836,11 +991,23 @@ 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) {
|
||||
break;
|
||||
}
|
||||
if (msg.voiceInput) {
|
||||
voiceInput = true;
|
||||
}
|
||||
if (msg.searchEnabled) {
|
||||
searchEnabled = true;
|
||||
}
|
||||
if (msg.voiceOutput) {
|
||||
voiceOutput = msg.voiceOutput;
|
||||
}
|
||||
loopLogger.log('dequeued user message', msg.messageId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
|
|
@ -880,7 +1047,29 @@ export async function* streamAgent({
|
|||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
const instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
|
||||
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.`;
|
||||
} 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>`;
|
||||
}
|
||||
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.`;
|
||||
}
|
||||
let streamError: string | null = null;
|
||||
for await (const event of streamLlm(
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
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 { composioEnabledToolsRepo } from "../../composio/enabled-tools-repo.js";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
|
|
@ -11,8 +10,8 @@ const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
|||
* Generate dynamic instructions section for Composio tools.
|
||||
* Returns empty string if no tools are enabled.
|
||||
*/
|
||||
function getComposioToolsPrompt(): string {
|
||||
if (!isComposioConfigured()) return '';
|
||||
async function getComposioToolsPrompt(): Promise<string> {
|
||||
if (!(await isComposioConfigured())) return '';
|
||||
|
||||
const enabledTools = composioEnabledToolsRepo.getAll();
|
||||
const toolEntries = Object.values(enabledTools);
|
||||
|
|
@ -80,7 +79,33 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
|
|||
|
||||
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
|
||||
|
||||
**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 check if Slack is connected first with \`slack-checkConnection\`, and always show message drafts to the user before sending.
|
||||
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
|
||||
|
||||
**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.
|
||||
|
|
@ -231,6 +256,8 @@ ${runtimeContextPrompt}
|
|||
- \`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.
|
||||
- \`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 tools** (\`composio-*\`) — External service integrations enabled by the user in Settings > Tools Library. These connect to third-party apps like Gmail, GitHub, Linear, Notion, etc. See the "Composio Integration Tools" section below for available tools.
|
||||
|
||||
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
|
||||
|
|
@ -276,8 +303,8 @@ Never output raw file paths in plain text when they could be wrapped in a filepa
|
|||
* Build full copilot instructions with dynamic Composio tools section.
|
||||
* Called each time the agent is loaded to reflect currently enabled tools.
|
||||
*/
|
||||
export function buildCopilotInstructions(): string {
|
||||
const composioPrompt = getComposioToolsPrompt();
|
||||
export async function buildCopilotInstructions(): Promise<string> {
|
||||
const composioPrompt = await getComposioToolsPrompt();
|
||||
if (!composioPrompt) return CopilotInstructions;
|
||||
return CopilotInstructions + '\n' + composioPrompt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
export const skill = String.raw`
|
||||
# App Navigation Skill
|
||||
|
||||
You have access to the **app-navigation** tool which lets you control the Rowboat UI directly — opening notes, switching views, filtering the knowledge base, and creating saved views.
|
||||
|
||||
## Actions
|
||||
|
||||
### open-note
|
||||
Open a specific knowledge file in the editor pane.
|
||||
|
||||
**When to use:** When the user asks to see, open, or view a specific note (e.g., "open John's note", "show me the Acme project page").
|
||||
|
||||
**Parameters:**
|
||||
- ` + "`path`" + `: Full workspace-relative path (e.g., ` + "`knowledge/People/John Smith.md`" + `)
|
||||
|
||||
**Tips:**
|
||||
- Use ` + "`workspace-grep`" + ` first to find the exact path if you're unsure of the filename.
|
||||
- Always pass the full ` + "`knowledge/...`" + ` path, not just the filename.
|
||||
|
||||
### open-view
|
||||
Switch the UI to the graph or bases view.
|
||||
|
||||
**When to use:** When the user asks to see the knowledge graph, view all notes, or open the bases/table view.
|
||||
|
||||
**Parameters:**
|
||||
- ` + "`view`" + `: ` + "`\"graph\"`" + ` or ` + "`\"bases\"`" + `
|
||||
|
||||
### update-base-view
|
||||
Change filters, columns, sort order, or search in the bases (table) view.
|
||||
|
||||
**When to use:** When the user asks to find, filter, sort, or search notes. Examples: "show me all active customers", "filter by topic=hiring", "sort by name", "search for pricing".
|
||||
|
||||
**Parameters:**
|
||||
- ` + "`filters`" + `: Object with ` + "`set`" + `, ` + "`add`" + `, ` + "`remove`" + `, or ` + "`clear`" + ` — each takes an array of ` + "`{ category, value }`" + ` pairs.
|
||||
- ` + "`set`" + `: Replace ALL current filters with these.
|
||||
- ` + "`add`" + `: Append filters without removing existing ones.
|
||||
- ` + "`remove`" + `: Remove specific filters.
|
||||
- ` + "`clear: true`" + `: Remove all filters.
|
||||
- ` + "`columns`" + `: Object with ` + "`set`" + `, ` + "`add`" + `, or ` + "`remove`" + ` — each takes an array of column names (frontmatter keys).
|
||||
- ` + "`sort`" + `: ` + "`{ field, dir }`" + ` where dir is ` + "`\"asc\"`" + ` or ` + "`\"desc\"`" + `.
|
||||
- ` + "`search`" + `: Free-text search string.
|
||||
|
||||
**Tips:**
|
||||
- If unsure what categories/values are available, call ` + "`get-base-state`" + ` first.
|
||||
- For "show me X", prefer ` + "`filters.set`" + ` to start fresh rather than ` + "`filters.add`" + `.
|
||||
- Categories come from frontmatter keys (e.g., relationship, status, topic, type).
|
||||
- **CRITICAL: Do NOT pass ` + "`columns`" + ` unless the user explicitly asks to show/hide specific columns.** Omit the ` + "`columns`" + ` parameter entirely when only filtering, sorting, or searching. Passing ` + "`columns`" + ` will override the user's current column layout and can make the view appear empty.
|
||||
|
||||
### get-base-state
|
||||
Retrieve information about what's in the knowledge base — available filter categories, values, and note count.
|
||||
|
||||
**When to use:** When you need to know what properties exist before filtering, or when the user asks "what can I filter by?", "how many notes are there?", etc.
|
||||
|
||||
**Parameters:**
|
||||
- ` + "`base_name`" + ` (optional): Name of a saved base to inspect.
|
||||
|
||||
### create-base
|
||||
Save the current view configuration as a named base.
|
||||
|
||||
**When to use:** When the user asks to save a filtered view, create a saved search, or says "save this as [name]".
|
||||
|
||||
**Parameters:**
|
||||
- ` + "`name`" + `: Human-readable name for the base.
|
||||
|
||||
## Workflow Example
|
||||
|
||||
1. User: "Show me all people who are customers"
|
||||
2. First, check what properties are available:
|
||||
` + "`app-navigation({ action: \"get-base-state\" })`" + `
|
||||
3. Apply filters based on the available properties:
|
||||
` + "`app-navigation({ action: \"update-base-view\", filters: { set: [{ category: \"relationship\", value: \"customer\" }] } })`" + `
|
||||
4. If the user wants to save it:
|
||||
` + "`app-navigation({ action: \"create-base\", name: \"Customers\" })`" + `
|
||||
|
||||
## Important Notes
|
||||
- The ` + "`update-base-view`" + ` action will automatically navigate to the bases view if the user isn't already there.
|
||||
- ` + "`open-note`" + ` validates that the file exists before navigating.
|
||||
- Filter categories and values come from frontmatter in knowledge files.
|
||||
- **Never send ` + "`columns`" + ` or ` + "`sort`" + ` with ` + "`update-base-view`" + ` unless the user specifically asks to change them.** Only pass the parameters you intend to change — omitted parameters are left untouched.
|
||||
`;
|
||||
|
||||
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:**
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ 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";
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||
|
|
@ -95,6 +96,12 @@ const definitions: SkillDefinition[] = [
|
|||
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
|
||||
content: deletionGuardrailsSkill,
|
||||
},
|
||||
{
|
||||
id: "app-navigation",
|
||||
title: "App Navigation",
|
||||
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
|
||||
content: appNavigationSkill,
|
||||
},
|
||||
];
|
||||
|
||||
const skillEntries = definitions.map((definition) => ({
|
||||
|
|
|
|||
|
|
@ -1,121 +1,124 @@
|
|||
import { slackToolCatalogMarkdown } from "./tool-catalog.js";
|
||||
|
||||
const skill = String.raw`
|
||||
# Slack Integration Skill
|
||||
# Slack Integration Skill (agent-slack CLI)
|
||||
|
||||
You can interact with Slack to help users communicate with their team. This includes sending messages, viewing channel history, finding users, and searching conversations.
|
||||
You interact with Slack by running **agent-slack** commands through \`executeCommand\`.
|
||||
|
||||
## Prerequisites
|
||||
---
|
||||
|
||||
## 1. Check Connection
|
||||
|
||||
Before any Slack operation, read \`~/.rowboat/config/slack.json\`. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands.
|
||||
|
||||
If enabled, use the workspace URLs from the config for all commands.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Commands
|
||||
|
||||
### Messages
|
||||
|
||||
| Action | Command |
|
||||
|--------|---------|
|
||||
| List recent messages | \`agent-slack message list "#channel-name" --limit 25\` |
|
||||
| List thread replies | \`agent-slack message list "#channel" --thread-ts 1234567890.123456\` |
|
||||
| Get a single message | \`agent-slack message get "https://team.slack.com/archives/C.../p..."\` |
|
||||
| Send a message | \`agent-slack message send "#channel-name" "Hello team!"\` |
|
||||
| Reply in thread | \`agent-slack message send "#channel-name" "Reply text" --thread-ts 1234567890.123456\` |
|
||||
| Edit a message | \`agent-slack message edit "#channel-name" --ts 1234567890.123456 "Updated text"\` |
|
||||
| Delete a message | \`agent-slack message delete "#channel-name" --ts 1234567890.123456\` |
|
||||
|
||||
**Targets** can be:
|
||||
- A full Slack URL: \`https://team.slack.com/archives/C01234567/p1234567890123456\`
|
||||
- A channel name: \`"#general"\` or \`"general"\`
|
||||
- A channel ID: \`C01234567\`
|
||||
|
||||
### Reactions
|
||||
|
||||
Before using Slack tools, ALWAYS check if Slack is connected:
|
||||
\`\`\`
|
||||
slack-checkConnection({})
|
||||
agent-slack message react add "<target>" <emoji> --ts <ts>
|
||||
agent-slack message react remove "<target>" <emoji> --ts <ts>
|
||||
\`\`\`
|
||||
|
||||
If not connected, inform the user they need to connect Slack from the settings/onboarding.
|
||||
### Search
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Check Connection
|
||||
\`\`\`
|
||||
slack-checkConnection({})
|
||||
\`\`\`
|
||||
Returns whether Slack is connected and ready to use.
|
||||
|
||||
### List Users
|
||||
\`\`\`
|
||||
slack-listUsers({ limit: 100 })
|
||||
\`\`\`
|
||||
Lists users in the workspace. Use this to resolve a name to a user ID.
|
||||
|
||||
### List DM Conversations
|
||||
\`\`\`
|
||||
slack-getDirectMessages({ limit: 50 })
|
||||
\`\`\`
|
||||
Lists DM channels (type "im"). Each entry includes the DM channel ID and the user ID.
|
||||
|
||||
### List Channels
|
||||
\`\`\`
|
||||
slack-listChannels({ types: "public_channel,private_channel", limit: 100 })
|
||||
\`\`\`
|
||||
Lists channels the user has access to.
|
||||
|
||||
### Get Conversation History
|
||||
\`\`\`
|
||||
slack-getChannelHistory({ channel: "C01234567", limit: 20 })
|
||||
\`\`\`
|
||||
Fetches recent messages for a channel or DM.
|
||||
|
||||
### Search Messages
|
||||
\`\`\`
|
||||
slack-searchMessages({ query: "in:@username", count: 20 })
|
||||
\`\`\`
|
||||
Searches Slack messages using Slack search syntax.
|
||||
|
||||
### Send a Message
|
||||
\`\`\`
|
||||
slack-sendMessage({ channel: "C01234567", text: "Hello team!" })
|
||||
\`\`\`
|
||||
Sends a message to a channel or DM. Always show the draft first.
|
||||
|
||||
### Execute a Slack Action
|
||||
\`\`\`
|
||||
slack-executeAction({
|
||||
toolSlug: "EXACT_TOOL_SLUG_FROM_DISCOVERY",
|
||||
input: { /* tool-specific parameters */ }
|
||||
})
|
||||
\`\`\`
|
||||
Executes any Slack tool using its exact slug discovered from \`slack-listAvailableTools\`.
|
||||
|
||||
### Discover Available Tools (Fallback)
|
||||
\`\`\`
|
||||
slack-listAvailableTools({ search: "conversation" })
|
||||
\`\`\`
|
||||
Lists available Slack tools from Composio. Use this only if a builtin Slack tool fails and you need a specific slug.
|
||||
|
||||
## Composio Slack Tool Catalog (Pinned)
|
||||
Use the exact tool slugs below with \`slack-executeAction\` when needed. Prefer these over \`slack-listAvailableTools\` to avoid redundant discovery.
|
||||
|
||||
${slackToolCatalogMarkdown}
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Check Connection
|
||||
\`\`\`
|
||||
slack-checkConnection({})
|
||||
agent-slack search messages "query text" --limit 20
|
||||
agent-slack search messages "query" --channel "#channel-name" --user "@username"
|
||||
agent-slack search messages "query" --after 2025-01-01 --before 2025-02-01
|
||||
agent-slack search files "query" --limit 10
|
||||
\`\`\`
|
||||
|
||||
### Step 2: Choose the Builtin Tool
|
||||
Use the builtin Slack tools above for common tasks. Only fall back to \`slack-listAvailableTools\` + \`slack-executeAction\` if something is missing.
|
||||
### Channels
|
||||
|
||||
## Common Tasks
|
||||
\`\`\`
|
||||
agent-slack channel new --name "project-x" --workspace https://team.slack.com
|
||||
agent-slack channel new --name "secret-project" --private
|
||||
agent-slack channel invite --channel "#project-x" --users "@alice,@bob"
|
||||
\`\`\`
|
||||
|
||||
### Find the Most Recent DM with Someone
|
||||
1. Search messages first: \`slack-searchMessages({ query: "in:@Name", count: 1 })\`
|
||||
2. If you need exact DM history:
|
||||
- \`slack-listUsers({})\` to find the user ID
|
||||
- \`slack-getDirectMessages({})\` to find the DM channel for that user
|
||||
- \`slack-getChannelHistory({ channel: "D...", limit: 20 })\`
|
||||
### Users
|
||||
|
||||
### Send a Message
|
||||
1. Draft the message and show it to the user
|
||||
2. ONLY after user approval, send using \`slack-sendMessage\`
|
||||
\`\`\`
|
||||
agent-slack user list --limit 200
|
||||
agent-slack user get "@username"
|
||||
agent-slack user get U01234567
|
||||
\`\`\`
|
||||
|
||||
### Search Messages
|
||||
1. Use \`slack-searchMessages({ query: "...", count: 20 })\`
|
||||
### Canvases
|
||||
|
||||
\`\`\`
|
||||
agent-slack canvas get "https://team.slack.com/docs/F01234567"
|
||||
agent-slack canvas get F01234567 --workspace https://team.slack.com
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 3. Multi-Workspace
|
||||
|
||||
**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`~/.rowboat/config/slack.json\` to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces.
|
||||
|
||||
If the selected workspace list contains multiple entries, use \`--workspace <url>\` to disambiguate:
|
||||
|
||||
\`\`\`
|
||||
agent-slack message list "#general" --workspace https://team.slack.com
|
||||
\`\`\`
|
||||
|
||||
If only one workspace is selected, always use \`--workspace\` with its URL to avoid ambiguity with other authenticated workspaces.
|
||||
|
||||
---
|
||||
|
||||
## 4. Token Budget Control
|
||||
|
||||
Use \`--limit\` to control how many messages/results are returned. Use \`--max-body-chars\` or \`--max-content-chars\` to truncate long message bodies:
|
||||
|
||||
\`\`\`
|
||||
agent-slack message list "#channel" --limit 10
|
||||
agent-slack search messages "query" --limit 5 --max-content-chars 2000
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 5. Discovering More Commands
|
||||
|
||||
For any command you're unsure about:
|
||||
|
||||
\`\`\`
|
||||
agent-slack --help
|
||||
agent-slack message --help
|
||||
agent-slack search --help
|
||||
agent-slack channel --help
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Always show drafts before sending** - Never send Slack messages without user confirmation
|
||||
- **Summarize, don't dump** - When showing channel history, summarize the key points
|
||||
- **Cross-reference with knowledge base** - Check if mentioned people have notes in the knowledge base
|
||||
|
||||
## Error Handling
|
||||
|
||||
If a Slack operation fails:
|
||||
1. Try \`slack-listAvailableTools\` to verify the tool slug is correct
|
||||
2. Check if Slack is still connected with \`slack-checkConnection\`
|
||||
3. Inform the user of the specific error
|
||||
- **Always show drafts before sending** — Never send Slack messages without user confirmation
|
||||
- **Summarize, don't dump** — When showing channel history, summarize the key points rather than pasting everything
|
||||
- **Prefer Slack URLs** — When referring to messages, use Slack URLs over raw channel names when available
|
||||
- **Use --limit** — Always set reasonable limits to keep output concise and token-efficient
|
||||
- **Resolve user IDs** — Messages contain raw user IDs like \`U078AHJP341\`. Resolve them to real names before presenting to the user. Batch all lookups into a single \`executeCommand\` call using \`;\` separators, e.g. \`agent-slack user get U078AHJP341 --workspace ... ; agent-slack user get U090UEZCEQ0 --workspace ...\`
|
||||
- **Cross-reference with knowledge base** — Check if mentioned people have notes in the knowledge base
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
export type SlackToolDefinition = {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const slackToolCatalog: SlackToolDefinition[] = [
|
||||
{ name: "Add Emoji Alias", slug: "SLACK_ADD_AN_EMOJI_ALIAS_IN_SLACK", description: "Adds an alias for an existing custom emoji." },
|
||||
{ name: "Add Remote File", slug: "SLACK_ADD_A_REMOTE_FILE_FROM_A_SERVICE", description: "Adds a reference to an external file (e.g., GDrive, Dropbox) to Slack." },
|
||||
{ name: "Add Star to Item", slug: "SLACK_ADD_A_STAR_TO_AN_ITEM", description: "Stars a channel, file, comment, or message." },
|
||||
{ name: "Add Call Participants", slug: "SLACK_ADD_CALL_PARTICIPANTS", description: "Registers new participants added to a Slack call." },
|
||||
{ name: "Add Emoji", slug: "SLACK_ADD_EMOJI", description: "Adds a custom emoji to a workspace via a unique name and URL." },
|
||||
{ name: "Add Reaction", slug: "SLACK_ADD_REACTION_TO_AN_ITEM", description: "Adds a specified emoji reaction to a message." },
|
||||
{ name: "Archive Channel", slug: "SLACK_ARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Archives a public or private channel." },
|
||||
{ name: "Archive Conversation", slug: "SLACK_ARCHIVE_A_SLACK_CONVERSATION", description: "Archives a conversation by its ID." },
|
||||
{ name: "Close DM/MPDM", slug: "SLACK_CLOSE_DM_OR_MULTI_PERSON_DM", description: "Closes a DM or MPDM sidebar view for the user." },
|
||||
{ name: "Create Reminder", slug: "SLACK_CREATE_A_REMINDER", description: "Creates a reminder with text and time (natural language supported)." },
|
||||
{ name: "Create User Group", slug: "SLACK_CREATE_A_SLACK_USER_GROUP", description: "Creates a new user group (subteam)." },
|
||||
{ name: "Create Channel", slug: "SLACK_CREATE_CHANNEL", description: "Initiates a public or private channel conversation." },
|
||||
{ name: "Create Channel Conversation", slug: "SLACK_CREATE_CHANNEL_BASED_CONVERSATION", description: "Creates a new channel with specific org-wide or team settings." },
|
||||
{ name: "Customize URL Unfurl", slug: "SLACK_CUSTOMIZE_URL_UNFURL", description: "Defines custom content for URL previews in a specific message." },
|
||||
{ name: "Delete File Comment", slug: "SLACK_DELETE_A_COMMENT_ON_A_FILE", description: "Deletes a specific comment from a file." },
|
||||
{ name: "Delete File", slug: "SLACK_DELETE_A_FILE_BY_ID", description: "Permanently deletes a file by its ID." },
|
||||
{ name: "Delete Channel", slug: "SLACK_DELETE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Irreversibly deletes a channel and its history (Enterprise only)." },
|
||||
{ name: "Delete Scheduled Message", slug: "SLACK_DELETE_A_SCHEDULED_MESSAGE_IN_A_CHAT", description: "Deletes a pending scheduled message." },
|
||||
{ name: "Delete Reminder", slug: "SLACK_DELETE_A_SLACK_REMINDER", description: "Deletes an existing reminder." },
|
||||
{ name: "Delete Message", slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT", description: "Deletes a message by channel ID and timestamp." },
|
||||
{ name: "Delete Profile Photo", slug: "SLACK_DELETE_USER_PROFILE_PHOTO", description: "Reverts the user's profile photo to the default avatar." },
|
||||
{ name: "Disable User Group", slug: "SLACK_DISABLE_AN_EXISTING_SLACK_USER_GROUP", description: "Disables (archives) a user group." },
|
||||
{ name: "Enable User Group", slug: "SLACK_ENABLE_A_SPECIFIED_USER_GROUP", description: "Reactivates a disabled user group." },
|
||||
{ name: "Share File Publicly", slug: "SLACK_ENABLE_PUBLIC_SHARING_OF_A_FILE", description: "Generates a public URL for a file." },
|
||||
{ name: "End Call", slug: "SLACK_END_A_CALL_WITH_DURATION_AND_ID", description: "Ends an ongoing call." },
|
||||
{ name: "End Snooze", slug: "SLACK_END_SNOOZE", description: "Ends the current user's snooze mode immediately." },
|
||||
{ name: "End DND Session", slug: "SLACK_END_USER_DO_NOT_DISTURB_SESSION", description: "Ends the current DND session." },
|
||||
{ name: "Fetch Bot Info", slug: "SLACK_FETCH_BOT_USER_INFORMATION", description: "Fetches metadata for a specific bot user." },
|
||||
{ name: "Fetch History", slug: "SLACK_FETCH_CONVERSATION_HISTORY", description: "Fetches chronological messages and events from a channel." },
|
||||
{ name: "Fetch Item Reactions", slug: "SLACK_FETCH_ITEM_REACTIONS", description: "Fetches all reactions for a message, file, or comment." },
|
||||
{ name: "Retrieve Replies", slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION", description: "Retrieves replies to a specific parent message." },
|
||||
{ name: "Fetch Team Info", slug: "SLACK_FETCH_TEAM_INFO", description: "Fetches comprehensive metadata about the team." },
|
||||
{ name: "Fetch Workspace Settings", slug: "SLACK_FETCH_WORKSPACE_SETTINGS_INFORMATION", description: "Retrieves detailed settings for a specific workspace." },
|
||||
{ name: "Find Channels", slug: "SLACK_FIND_CHANNELS", description: "Searches channels by name, topic, or purpose." },
|
||||
{ name: "Find User by Email", slug: "SLACK_FIND_USER_BY_EMAIL_ADDRESS", description: "Finds a user object using their email address." },
|
||||
{ name: "Find Users", slug: "SLACK_FIND_USERS", description: "Searches users by name, email, or display name." },
|
||||
{ name: "Get Conversation Preferences", slug: "SLACK_GET_CHANNEL_CONVERSATION_PREFERENCES", description: "Retrieves posting/threading preferences for a channel." },
|
||||
{ name: "Get Reminder Info", slug: "SLACK_GET_REMINDER_INFORMATION", description: "Retrieves detailed information for a specific reminder." },
|
||||
{ name: "Get Remote File", slug: "SLACK_GET_REMOTE_FILE", description: "Retrieves info about a previously added remote file." },
|
||||
{ name: "Get Team DND Status", slug: "SLACK_GET_TEAM_DND_STATUS", description: "Retrieves the DND status for specific users." },
|
||||
{ name: "Get User Presence", slug: "SLACK_GET_USER_PRESENCE_INFO", description: "Retrieves real-time presence (active/away)." },
|
||||
{ name: "Invite to Channel", slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL", description: "Invites users to a channel by their user IDs." },
|
||||
{ name: "Invite to Workspace", slug: "SLACK_INVITE_USER_TO_WORKSPACE", description: "Invites a user to a workspace and channels via email." },
|
||||
{ name: "Join Conversation", slug: "SLACK_JOIN_AN_EXISTING_CONVERSATION", description: "Joins a conversation by channel ID." },
|
||||
{ name: "Leave Conversation", slug: "SLACK_LEAVE_A_CONVERSATION", description: "Leaves a conversation." },
|
||||
{ name: "List All Channels", slug: "SLACK_LIST_ALL_CHANNELS", description: "Lists all conversations with various filters." },
|
||||
{ name: "List All Users", slug: "SLACK_LIST_ALL_USERS", description: "Retrieves a paginated list of all users in the workspace." },
|
||||
{ name: "List User Group Members", slug: "SLACK_LIST_ALL_USERS_IN_A_USER_GROUP", description: "Lists all user IDs within a group." },
|
||||
{ name: "List Conversations", slug: "SLACK_LIST_CONVERSATIONS", description: "Retrieves conversations accessible to a specific user." },
|
||||
{ name: "List Files", slug: "SLACK_LIST_FILES_WITH_FILTERS_IN_SLACK", description: "Lists files and metadata with filtering options." },
|
||||
{ name: "List Reminders", slug: "SLACK_LIST_REMINDERS", description: "Lists all reminders for the authenticated user." },
|
||||
{ name: "List Remote Files", slug: "SLACK_LIST_REMOTE_FILES", description: "Retrieves info about a team's remote files." },
|
||||
{ name: "List Scheduled Messages", slug: "SLACK_LIST_SCHEDULED_MESSAGES", description: "Lists pending scheduled messages." },
|
||||
{ name: "List Pinned Items", slug: "SLACK_LISTS_PINNED_ITEMS_IN_A_CHANNEL", description: "Retrieves all messages/files pinned to a channel." },
|
||||
{ name: "List Starred Items", slug: "SLACK_LIST_STARRED_ITEMS", description: "Lists items starred by the user." },
|
||||
{ name: "List Custom Emojis", slug: "SLACK_LIST_TEAM_CUSTOM_EMOJIS", description: "Lists all workspace custom emojis and their URLs." },
|
||||
{ name: "List User Groups", slug: "SLACK_LIST_USER_GROUPS_FOR_TEAM_WITH_OPTIONS", description: "Lists user-created and default user groups." },
|
||||
{ name: "List User Reactions", slug: "SLACK_LIST_USER_REACTIONS", description: "Lists all reactions added by a specific user." },
|
||||
{ name: "List Admin Users", slug: "SLACK_LIST_WORKSPACE_USERS", description: "Retrieves a paginated list of workspace administrators." },
|
||||
{ name: "Set User Presence", slug: "SLACK_MANUALLY_SET_USER_PRESENCE", description: "Manually overrides automated presence status." },
|
||||
{ name: "Mark Reminder Complete", slug: "SLACK_MARK_REMINDER_AS_COMPLETE", description: "Marks a reminder as complete (deprecated by Slack in March 2023)." },
|
||||
{ name: "Open DM", slug: "SLACK_OPEN_DM", description: "Opens/resumes a DM or MPDM." },
|
||||
{ name: "Pin Item", slug: "SLACK_PINS_AN_ITEM_TO_A_CHANNEL", description: "Pins a message to a channel." },
|
||||
{ name: "Remove Remote File", slug: "SLACK_REMOVE_A_REMOTE_FILE", description: "Removes a reference to an external file." },
|
||||
{ name: "Remove Star", slug: "SLACK_REMOVE_A_STAR_FROM_AN_ITEM", description: "Unstars an item." },
|
||||
{ name: "Remove from Channel", slug: "SLACK_REMOVE_A_USER_FROM_A_CONVERSATION", description: "Removes a specified user from a conversation." },
|
||||
{ name: "Remove Call Participants", slug: "SLACK_REMOVE_CALL_PARTICIPANTS", description: "Registers the removal of participants from a call." },
|
||||
{ name: "Remove Reaction", slug: "SLACK_REMOVE_REACTION_FROM_ITEM", description: "Removes an emoji reaction from an item." },
|
||||
{ name: "Rename Conversation", slug: "SLACK_RENAME_A_CONVERSATION", description: "Renames a channel ID/Conversation." },
|
||||
{ name: "Rename Emoji", slug: "SLACK_RENAME_AN_EMOJI", description: "Renames a custom emoji." },
|
||||
{ name: "Rename Channel", slug: "SLACK_RENAME_A_SLACK_CHANNEL", description: "Renames a public or private channel." },
|
||||
{ name: "Retrieve Identity", slug: "SLACK_RETRIEVE_A_USER_S_IDENTITY_DETAILS", description: "Retrieves basic user/team identity details." },
|
||||
{ name: "Retrieve Call Info", slug: "SLACK_RETRIEVE_CALL_INFORMATION", description: "Retrieves a snapshot of a call's status." },
|
||||
{ name: "Retrieve Conversation Info", slug: "SLACK_RETRIEVE_CONVERSATION_INFORMATION", description: "Retrieves metadata for a specific conversation." },
|
||||
{ name: "Get Conversation Members", slug: "SLACK_RETRIEVE_CONVERSATION_MEMBERS_LIST", description: "Lists active user IDs in a conversation." },
|
||||
{ name: "Retrieve User DND", slug: "SLACK_RETRIEVE_CURRENT_USER_DND_STATUS", description: "Retrieves DND status for a user." },
|
||||
{ name: "Retrieve File Details", slug: "SLACK_RETRIEVE_DETAILED_INFORMATION_ABOUT_A_FILE", description: "Retrieves metadata and comments for a file." },
|
||||
{ name: "Retrieve User Details", slug: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION", description: "Retrieves comprehensive info for a specific user ID." },
|
||||
{ name: "Get Message Permalink", slug: "SLACK_RETRIEVE_MESSAGE_PERMALINK_URL", description: "Gets the permalink URL for a specific message." },
|
||||
{ name: "Retrieve Team Profile", slug: "SLACK_RETRIEVE_TEAM_PROFILE_DETAILS", description: "Retrieves the profile field structure for a team." },
|
||||
{ name: "Retrieve User Profile", slug: "SLACK_RETRIEVE_USER_PROFILE_INFORMATION", description: "Retrieves specific profile info for a user." },
|
||||
{ name: "Revoke Public File", slug: "SLACK_REVOKE_PUBLIC_SHARING_ACCESS_FOR_A_FILE", description: "Revokes a file's public sharing URL." },
|
||||
{ name: "Schedule Message", slug: "SLACK_SCHEDULE_MESSAGE", description: "Schedules a message for a future time (up to 120 days)." },
|
||||
{ name: "Search Messages", slug: "SLACK_SEARCH_MESSAGES", description: "Workspace-wide message search with advanced filters." },
|
||||
{ name: "Send Ephemeral", slug: "SLACK_SEND_EPHEMERAL_MESSAGE", description: "Sends a message visible only to a specific user." },
|
||||
{ name: "Send Message", slug: "SLACK_SEND_MESSAGE", description: "Posts a message to a channel, DM, or group." },
|
||||
{ name: "Set Conversation Purpose", slug: "SLACK_SET_A_CONVERSATION_S_PURPOSE", description: "Updates the purpose description of a channel." },
|
||||
{ name: "Set DND Duration", slug: "SLACK_SET_DND_DURATION", description: "Turns on DND or changes its current duration." },
|
||||
{ name: "Set Profile Photo", slug: "SLACK_SET_PROFILE_PHOTO", description: "Sets the user's profile image with cropping." },
|
||||
{ name: "Set Read Cursor", slug: "SLACK_SET_READ_CURSOR_IN_A_CONVERSATION", description: "Marks a specific timestamp as read." },
|
||||
{ name: "Set User Profile", slug: "SLACK_SET_SLACK_USER_PROFILE_INFORMATION", description: "Updates individual or multiple user profile fields." },
|
||||
{ name: "Set Conversation Topic", slug: "SLACK_SET_THE_TOPIC_OF_A_CONVERSATION", description: "Updates the topic of a conversation." },
|
||||
{ name: "Share Me Message", slug: "SLACK_SHARE_A_ME_MESSAGE_IN_A_CHANNEL", description: "Sends a third-person user action message (/me)." },
|
||||
{ name: "Share Remote File", slug: "SLACK_SHARE_REMOTE_FILE_IN_CHANNELS", description: "Shares a registered remote file into channels." },
|
||||
{ name: "Start Call", slug: "SLACK_START_CALL", description: "Registers a new call for third-party integration." },
|
||||
{ name: "Start RTM Session", slug: "SLACK_START_REAL_TIME_MESSAGING_SESSION", description: "Initiates a real-time messaging WebSocket session." },
|
||||
{ name: "Unarchive Channel", slug: "SLACK_UNARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Unarchives a specific channel." },
|
||||
{ name: "Unarchive Conversation", slug: "SLACK_UNARCHIVE_CHANNEL", description: "Reverses archival for a conversation." },
|
||||
{ name: "Unpin Item", slug: "SLACK_UNPIN_ITEM_FROM_CHANNEL", description: "Unpins a message from a channel." },
|
||||
{ name: "Update User Group", slug: "SLACK_UPDATE_AN_EXISTING_SLACK_USER_GROUP", description: "Updates name, handle, or channels for a user group." },
|
||||
{ name: "Update Remote File", slug: "SLACK_UPDATES_AN_EXISTING_REMOTE_FILE", description: "Updates metadata for a remote file reference." },
|
||||
{ name: "Update Message", slug: "SLACK_UPDATES_A_SLACK_MESSAGE", description: "Modifies the content of an existing message." },
|
||||
{ name: "Update Call Info", slug: "SLACK_UPDATE_SLACK_CALL_INFORMATION", description: "Updates call title or join URLs." },
|
||||
{ name: "Update Group Members", slug: "SLACK_UPDATE_USER_GROUP_MEMBERS", description: "Replaces the member list of a user group." },
|
||||
{ name: "Upload File", slug: "SLACK_UPLOAD_OR_CREATE_A_FILE_IN_SLACK", description: "Uploads content or binary files to Slack." },
|
||||
];
|
||||
|
||||
export const slackToolCatalogMarkdown = slackToolCatalog
|
||||
.map((tool) => `- ${tool.name} (${tool.slug}) - ${tool.description}`)
|
||||
.join("\n");
|
||||
|
|
@ -14,12 +14,15 @@ import { IAgentsRepo } from "../../agents/repo.js";
|
|||
import { WorkDir } from "../../config/config.js";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
import { composioEnabledToolsRepo } from "../../composio/enabled-tools-repo.js";
|
||||
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js";
|
||||
import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js";
|
||||
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||
import type { ToolContext } from "./exec-tool.js";
|
||||
import { generateText, jsonSchema } from "ai";
|
||||
import { createProvider } from "../../models/models.js";
|
||||
import { IModelConfigRepo } from "../../models/repo.js";
|
||||
import { isSignedIn } from "../../account/account.js";
|
||||
import { getGatewayProvider } from "../../models/gateway.js";
|
||||
import { getAccessToken } from "../../auth/tokens.js";
|
||||
import { API_URL } from "../../config/env.js";
|
||||
// Parser libraries are loaded dynamically inside parseFile.execute()
|
||||
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
|
||||
// Import paths are computed so esbuild cannot statically resolve them.
|
||||
|
|
@ -37,232 +40,6 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({
|
|||
isAvailable: z.custom<() => Promise<boolean>>().optional(),
|
||||
}));
|
||||
|
||||
type SlackToolHint = {
|
||||
search?: string;
|
||||
patterns: string[];
|
||||
fallbackSlugs?: string[];
|
||||
preferSlugIncludes?: string[];
|
||||
excludePatterns?: string[];
|
||||
minScore?: number;
|
||||
};
|
||||
|
||||
const slackToolHints: Record<string, SlackToolHint> = {
|
||||
sendMessage: {
|
||||
search: "message",
|
||||
patterns: ["send", "message", "channel"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_SEND_MESSAGE",
|
||||
"SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL",
|
||||
"SLACK_SEND_A_MESSAGE",
|
||||
],
|
||||
},
|
||||
listConversations: {
|
||||
search: "conversation",
|
||||
patterns: ["list", "conversation", "channel"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_LIST_CONVERSATIONS",
|
||||
"SLACK_LIST_ALL_CHANNELS",
|
||||
"SLACK_LIST_ALL_SLACK_TEAM_CHANNELS_WITH_VARIOUS_FILTERS",
|
||||
"SLACK_LIST_CHANNELS",
|
||||
"SLACK_LIST_CHANNEL",
|
||||
],
|
||||
preferSlugIncludes: ["list", "conversation"],
|
||||
minScore: 2,
|
||||
},
|
||||
getConversationHistory: {
|
||||
search: "history",
|
||||
patterns: ["history", "conversation", "message"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_FETCH_CONVERSATION_HISTORY",
|
||||
"SLACK_FETCHES_CONVERSATION_HISTORY",
|
||||
"SLACK_GET_CONVERSATION_HISTORY",
|
||||
"SLACK_GET_CHANNEL_HISTORY",
|
||||
],
|
||||
preferSlugIncludes: ["history"],
|
||||
minScore: 2,
|
||||
},
|
||||
listUsers: {
|
||||
search: "user",
|
||||
patterns: ["list", "user"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_LIST_ALL_USERS",
|
||||
"SLACK_LIST_ALL_SLACK_TEAM_USERS_WITH_PAGINATION",
|
||||
"SLACK_LIST_USERS",
|
||||
"SLACK_GET_USERS",
|
||||
"SLACK_USERS_LIST",
|
||||
],
|
||||
preferSlugIncludes: ["list", "user"],
|
||||
excludePatterns: ["find", "by name", "by email", "by_email", "by_name", "lookup", "profile", "info"],
|
||||
minScore: 2,
|
||||
},
|
||||
getUserInfo: {
|
||||
search: "user",
|
||||
patterns: ["user", "info", "profile"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_GET_USER_INFO",
|
||||
"SLACK_GET_USER",
|
||||
"SLACK_USER_INFO",
|
||||
],
|
||||
preferSlugIncludes: ["user", "info"],
|
||||
minScore: 1,
|
||||
},
|
||||
searchMessages: {
|
||||
search: "search",
|
||||
patterns: ["search", "message"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_SEARCH_FOR_MESSAGES_WITH_QUERY",
|
||||
"SLACK_SEARCH_MESSAGES",
|
||||
"SLACK_SEARCH_MESSAGE",
|
||||
],
|
||||
preferSlugIncludes: ["search"],
|
||||
minScore: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const slackToolSlugCache = new Map<string, string>();
|
||||
|
||||
const slackToolSlugOverrides: Partial<Record<keyof typeof slackToolHints, string>> = {
|
||||
sendMessage: "SLACK_SEND_MESSAGE",
|
||||
listConversations: "SLACK_LIST_CONVERSATIONS",
|
||||
getConversationHistory: "SLACK_FETCH_CONVERSATION_HISTORY",
|
||||
listUsers: "SLACK_LIST_ALL_USERS",
|
||||
getUserInfo: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION",
|
||||
searchMessages: "SLACK_SEARCH_MESSAGES",
|
||||
};
|
||||
|
||||
const compactObject = (input: Record<string, unknown>) =>
|
||||
Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
||||
|
||||
type SlackToolResult = { success: boolean; data?: unknown; error?: string };
|
||||
|
||||
/** Helper to execute a Slack tool with consistent account validation and error handling */
|
||||
async function executeSlackTool(
|
||||
hintKey: keyof typeof slackToolHints,
|
||||
params: Record<string, unknown>
|
||||
): Promise<SlackToolResult> {
|
||||
const account = composioAccountsRepo.getAccount('slack');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return { success: false, error: 'Slack is not connected' };
|
||||
}
|
||||
try {
|
||||
const toolSlug = await resolveSlackToolSlug(hintKey);
|
||||
return await executeComposioAction(toolSlug, account.id, compactObject(params));
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSlackTool = (tool: { slug: string; name?: string; description?: string }) =>
|
||||
`${tool.slug} ${tool.name || ""} ${tool.description || ""}`.toLowerCase();
|
||||
|
||||
const scoreSlackTool = (tool: { slug: string; name?: string; description?: string }, patterns: string[]) => {
|
||||
const slug = tool.slug.toLowerCase();
|
||||
const name = (tool.name || "").toLowerCase();
|
||||
const description = (tool.description || "").toLowerCase();
|
||||
|
||||
let score = 0;
|
||||
for (const pattern of patterns) {
|
||||
const needle = pattern.toLowerCase();
|
||||
if (slug.includes(needle)) score += 3;
|
||||
if (name.includes(needle)) score += 2;
|
||||
if (description.includes(needle)) score += 1;
|
||||
}
|
||||
return score;
|
||||
};
|
||||
|
||||
const pickSlackTool = (
|
||||
tools: Array<{ slug: string; name?: string; description?: string }>,
|
||||
hint: SlackToolHint,
|
||||
) => {
|
||||
let candidates = tools;
|
||||
|
||||
if (hint.excludePatterns && hint.excludePatterns.length > 0) {
|
||||
candidates = candidates.filter((tool) => {
|
||||
const haystack = normalizeSlackTool(tool);
|
||||
return !hint.excludePatterns!.some((pattern) => haystack.includes(pattern.toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
if (hint.preferSlugIncludes && hint.preferSlugIncludes.length > 0) {
|
||||
const preferred = candidates.filter((tool) =>
|
||||
hint.preferSlugIncludes!.every((pattern) => tool.slug.toLowerCase().includes(pattern.toLowerCase()))
|
||||
);
|
||||
if (preferred.length > 0) {
|
||||
candidates = preferred;
|
||||
}
|
||||
}
|
||||
|
||||
let best: { slug: string; name?: string; description?: string } | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const tool of candidates) {
|
||||
const score = scoreSlackTool(tool, hint.patterns);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = tool;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best || (hint.minScore !== undefined && bestScore < hint.minScore)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return best;
|
||||
};
|
||||
|
||||
const resolveSlackToolSlug = async (hintKey: keyof typeof slackToolHints) => {
|
||||
const cached = slackToolSlugCache.get(hintKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const hint = slackToolHints[hintKey];
|
||||
|
||||
const override = slackToolSlugOverrides[hintKey];
|
||||
if (override && slackToolCatalog.some((tool) => tool.slug === override)) {
|
||||
slackToolSlugCache.set(hintKey, override);
|
||||
return override;
|
||||
}
|
||||
const resolveFromTools = (tools: Array<{ slug: string; name?: string; description?: string }>) => {
|
||||
if (hint.fallbackSlugs && hint.fallbackSlugs.length > 0) {
|
||||
const fallbackSet = new Set(hint.fallbackSlugs.map((slug) => slug.toLowerCase()));
|
||||
const fallback = tools.find((tool) => fallbackSet.has(tool.slug.toLowerCase()));
|
||||
if (fallback) return fallback.slug;
|
||||
}
|
||||
|
||||
const best = pickSlackTool(tools, hint);
|
||||
return best?.slug || null;
|
||||
};
|
||||
|
||||
const initialTools = slackToolCatalog;
|
||||
|
||||
if (!initialTools.length) {
|
||||
throw new Error("No Slack tools returned from Composio");
|
||||
}
|
||||
|
||||
const initialSlug = resolveFromTools(initialTools);
|
||||
if (initialSlug) {
|
||||
slackToolSlugCache.set(hintKey, initialSlug);
|
||||
return initialSlug;
|
||||
}
|
||||
|
||||
const allSlug = resolveFromTools(slackToolCatalog);
|
||||
|
||||
if (!allSlug) {
|
||||
const fallback = await listToolkitTools("slack", hint.search || null);
|
||||
const fallbackSlug = resolveFromTools(fallback.items || []);
|
||||
if (!fallbackSlug) {
|
||||
throw new Error(`Unable to resolve Slack tool for ${hintKey}. Try slack-listAvailableTools.`);
|
||||
}
|
||||
slackToolSlugCache.set(hintKey, fallbackSlug);
|
||||
return fallbackSlug;
|
||||
}
|
||||
|
||||
slackToolSlugCache.set(hintKey, allSlug);
|
||||
return allSlug;
|
||||
};
|
||||
|
||||
const LLMPARSE_MIME_TYPES: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
|
|
@ -862,7 +639,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
// Resolve model config from DI container
|
||||
const modelConfigRepo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const modelConfig = await modelConfigRepo.getConfig();
|
||||
const provider = createProvider(modelConfig.provider);
|
||||
const provider = await isSignedIn()
|
||||
? await getGatewayProvider()
|
||||
: createProvider(modelConfig.provider);
|
||||
const model = provider.languageModel(modelConfig.model);
|
||||
|
||||
const userPrompt = prompt || 'Convert this file to well-structured markdown.';
|
||||
|
|
@ -1111,160 +890,141 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
|
||||
// ============================================================================
|
||||
// Slack Tools (via Composio)
|
||||
// App Navigation
|
||||
// ============================================================================
|
||||
|
||||
'slack-checkConnection': {
|
||||
description: 'Check if Slack is connected and ready to use. Use this before other Slack operations.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
if (!isComposioConfigured()) {
|
||||
return {
|
||||
connected: false,
|
||||
error: 'Composio is not configured. Please set up your Composio API key first.',
|
||||
};
|
||||
'app-navigation': {
|
||||
description: 'Control the app UI - navigate to notes, switch views, filter/search the knowledge base, and manage saved views.',
|
||||
inputSchema: z.object({
|
||||
action: z.enum(["open-note", "open-view", "update-base-view", "get-base-state", "create-base"]).describe("The navigation action to perform"),
|
||||
// open-note
|
||||
path: z.string().optional().describe("Knowledge file path for open-note, e.g. knowledge/People/John.md"),
|
||||
// open-view
|
||||
view: z.enum(["bases", "graph"]).optional().describe("Which view to open (for open-view action)"),
|
||||
// update-base-view
|
||||
filters: z.object({
|
||||
set: z.array(z.object({ category: z.string(), value: z.string() })).optional().describe("Replace all filters with these"),
|
||||
add: z.array(z.object({ category: z.string(), value: z.string() })).optional().describe("Add these filters"),
|
||||
remove: z.array(z.object({ category: z.string(), value: z.string() })).optional().describe("Remove these filters"),
|
||||
clear: z.boolean().optional().describe("Clear all filters"),
|
||||
}).optional().describe("Filter modifications (for update-base-view)"),
|
||||
columns: z.object({
|
||||
set: z.array(z.string()).optional().describe("Replace visible columns with these"),
|
||||
add: z.array(z.string()).optional().describe("Add these columns"),
|
||||
remove: z.array(z.string()).optional().describe("Remove these columns"),
|
||||
}).optional().describe("Column modifications (for update-base-view)"),
|
||||
sort: z.object({
|
||||
field: z.string(),
|
||||
dir: z.enum(["asc", "desc"]),
|
||||
}).optional().describe("Sort configuration (for update-base-view)"),
|
||||
search: z.string().optional().describe("Search query to filter notes (for update-base-view)"),
|
||||
// get-base-state
|
||||
base_name: z.string().optional().describe("Name of a saved base to inspect (for get-base-state). Omit for the current/default view."),
|
||||
// create-base
|
||||
name: z.string().optional().describe("Name for the saved base view (for create-base)"),
|
||||
}),
|
||||
execute: async (input: {
|
||||
action: string;
|
||||
[key: string]: unknown;
|
||||
}) => {
|
||||
switch (input.action) {
|
||||
case 'open-note': {
|
||||
const filePath = input.path as string;
|
||||
try {
|
||||
const result = await workspace.exists(filePath);
|
||||
if (!result.exists) {
|
||||
return { success: false, error: `File not found: ${filePath}` };
|
||||
}
|
||||
return { success: true, action: 'open-note', path: filePath };
|
||||
} catch {
|
||||
return { success: false, error: `Could not access file: ${filePath}` };
|
||||
}
|
||||
}
|
||||
|
||||
case 'open-view': {
|
||||
const view = input.view as string;
|
||||
return { success: true, action: 'open-view', view };
|
||||
}
|
||||
|
||||
case 'update-base-view': {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (input.filters) updates.filters = input.filters;
|
||||
if (input.columns) updates.columns = input.columns;
|
||||
if (input.sort) updates.sort = input.sort;
|
||||
if (input.search !== undefined) updates.search = input.search;
|
||||
return { success: true, action: 'update-base-view', updates };
|
||||
}
|
||||
|
||||
case 'get-base-state': {
|
||||
// Scan knowledge/ files and extract frontmatter properties
|
||||
try {
|
||||
const { parseFrontmatter } = await import("@x/shared/dist/frontmatter.js");
|
||||
const entries = await workspace.readdir("knowledge", { recursive: true, allowedExtensions: [".md"] });
|
||||
const files = entries.filter(e => e.kind === 'file');
|
||||
const properties = new Map<string, Set<string>>();
|
||||
let noteCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const { data } = await workspace.readFile(file.path);
|
||||
const { fields } = parseFrontmatter(data);
|
||||
noteCount++;
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (!value) continue;
|
||||
let set = properties.get(key);
|
||||
if (!set) { set = new Set(); properties.set(key, set); }
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const v of values) {
|
||||
const trimmed = v.trim();
|
||||
if (trimmed) set.add(trimmed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
const availableProperties: Record<string, string[]> = {};
|
||||
for (const [key, values] of properties) {
|
||||
availableProperties[key] = [...values].sort();
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'get-base-state',
|
||||
noteCount,
|
||||
availableProperties,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to read knowledge base',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case 'create-base': {
|
||||
const name = input.name as string;
|
||||
const safeName = name.replace(/[^a-zA-Z0-9_\- ]/g, '').trim();
|
||||
if (!safeName) {
|
||||
return { success: false, error: 'Invalid base name' };
|
||||
}
|
||||
const basePath = `bases/${safeName}.base`;
|
||||
try {
|
||||
const config = { name: safeName, filters: [], columns: [] };
|
||||
await workspace.writeFile(basePath, JSON.stringify(config, null, 2), { mkdirp: true });
|
||||
return { success: true, action: 'create-base', name: safeName, path: basePath };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create base',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, error: `Unknown action: ${input.action}` };
|
||||
}
|
||||
const account = composioAccountsRepo.getAccount('slack');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return {
|
||||
connected: false,
|
||||
error: 'Slack is not connected. Please connect Slack from the settings.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
connected: true,
|
||||
accountId: account.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
'slack-listAvailableTools': {
|
||||
description: 'List available Slack tools from Composio. Use this to discover the correct tool slugs before executing actions. Call this first if other Slack tools return errors.',
|
||||
inputSchema: z.object({
|
||||
search: z.string().optional().describe('Optional search query to filter tools (e.g., "message", "channel", "user")'),
|
||||
}),
|
||||
execute: async ({ search }: { search?: string }) => {
|
||||
if (!isComposioConfigured()) {
|
||||
return { success: false, error: 'Composio is not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listToolkitTools('slack', search || null);
|
||||
return {
|
||||
success: true,
|
||||
tools: result.items,
|
||||
count: result.items.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'slack-executeAction': {
|
||||
description: 'Execute a Slack action by its Composio tool slug. Use slack-listAvailableTools first to discover correct slugs. Pass the exact slug and the required input parameters.',
|
||||
inputSchema: z.object({
|
||||
toolSlug: z.string().describe('The exact Composio tool slug (e.g., "SLACKBOT_SEND_A_MESSAGE_TO_A_SLACK_CHANNEL")'),
|
||||
input: z.record(z.string(), z.unknown()).describe('Input parameters for the tool (check the tool description for required fields)'),
|
||||
}),
|
||||
execute: async ({ toolSlug, input }: { toolSlug: string; input: Record<string, unknown> }) => {
|
||||
const account = composioAccountsRepo.getAccount('slack');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return { success: false, error: 'Slack is not connected' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeComposioAction(toolSlug, account.id, input);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'slack-sendMessage': {
|
||||
description: 'Send a message to a Slack channel or user. Requires channel ID (starts with C for channels, D for DMs) or user ID.',
|
||||
inputSchema: z.object({
|
||||
channel: z.string().describe('Channel ID (e.g., C01234567) or user ID (e.g., U01234567) to send the message to'),
|
||||
text: z.string().describe('The message text to send'),
|
||||
}),
|
||||
execute: async ({ channel, text }: { channel: string; text: string }) => {
|
||||
return executeSlackTool("sendMessage", { channel, text });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-listChannels': {
|
||||
description: 'List Slack channels the user has access to. Returns channel IDs and names.',
|
||||
inputSchema: z.object({
|
||||
types: z.string().optional().describe('Comma-separated channel types: public_channel, private_channel, mpim, im (default: public_channel,private_channel)'),
|
||||
limit: z.number().optional().describe('Maximum number of channels to return (default: 100)'),
|
||||
}),
|
||||
execute: async ({ types, limit }: { types?: string; limit?: number }) => {
|
||||
return executeSlackTool("listConversations", {
|
||||
types: types || "public_channel,private_channel",
|
||||
limit: limit ?? 100,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
'slack-getChannelHistory': {
|
||||
description: 'Get recent messages from a Slack channel. Returns message history with timestamps and user IDs.',
|
||||
inputSchema: z.object({
|
||||
channel: z.string().describe('Channel ID to get history from (e.g., C01234567)'),
|
||||
limit: z.number().optional().describe('Maximum number of messages to return (default: 20, max: 100)'),
|
||||
}),
|
||||
execute: async ({ channel, limit }: { channel: string; limit?: number }) => {
|
||||
return executeSlackTool("getConversationHistory", {
|
||||
channel,
|
||||
limit: limit !== undefined ? Math.min(limit, 100) : 20,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
'slack-listUsers': {
|
||||
description: 'List users in the Slack workspace. Returns user IDs, names, and profile info.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().optional().describe('Maximum number of users to return (default: 100)'),
|
||||
}),
|
||||
execute: async ({ limit }: { limit?: number }) => {
|
||||
return executeSlackTool("listUsers", { limit: limit ?? 100 });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-getUserInfo': {
|
||||
description: 'Get detailed information about a specific Slack user by their user ID.',
|
||||
inputSchema: z.object({
|
||||
user: z.string().describe('User ID to get info for (e.g., U01234567)'),
|
||||
}),
|
||||
execute: async ({ user }: { user: string }) => {
|
||||
return executeSlackTool("getUserInfo", { user });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-searchMessages': {
|
||||
description: 'Search for messages in Slack. Find messages containing specific text across channels.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Search query text'),
|
||||
count: z.number().optional().describe('Maximum number of results (default: 20)'),
|
||||
}),
|
||||
execute: async ({ query, count }: { query: string; count?: number }) => {
|
||||
return executeSlackTool("searchMessages", { query, count: count ?? 20 });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-getDirectMessages': {
|
||||
description: 'List direct message (DM) channels. Returns IDs of DM conversations with other users.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().optional().describe('Maximum number of DM channels to return (default: 50)'),
|
||||
}),
|
||||
execute: async ({ limit }: { limit?: number }) => {
|
||||
return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 });
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -1280,9 +1040,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
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 homedir = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const braveConfigPath = path.join(homedir, '.rowboat', 'config', 'brave-search.json');
|
||||
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
|
||||
const raw = await fs.readFile(braveConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
return !!config.apiKey;
|
||||
|
|
@ -1292,30 +1052,6 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => {
|
||||
try {
|
||||
// Read API key from config
|
||||
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const braveConfigPath = path.join(homedir, '.rowboat', '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',
|
||||
};
|
||||
}
|
||||
|
||||
// Build query params
|
||||
const resultCount = Math.min(Math.max(count || 5, 1), 20);
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
|
|
@ -1325,13 +1061,47 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
params.set('freshness', freshness);
|
||||
}
|
||||
|
||||
const url = `https://api.search.brave.com/res/v1/web/search?${params.toString()}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-Subscription-Token': apiKey,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
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();
|
||||
|
|
@ -1378,9 +1148,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
category: z.enum(['company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Filter results by category'),
|
||||
}),
|
||||
isAvailable: async () => {
|
||||
if (await isSignedIn()) return true;
|
||||
try {
|
||||
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const exaConfigPath = path.join(homedir, '.rowboat', 'config', 'exa-search.json');
|
||||
const exaConfigPath = path.join(WorkDir, 'config', 'exa-search.json');
|
||||
const raw = await fs.readFile(exaConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
return !!config.apiKey;
|
||||
|
|
@ -1390,31 +1160,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
execute: async ({ query, numResults, category }: { query: string; numResults?: number; category?: string }) => {
|
||||
try {
|
||||
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const exaConfigPath = path.join(homedir, '.rowboat', 'config', 'exa-search.json');
|
||||
|
||||
let apiKey: string;
|
||||
try {
|
||||
const raw = await fs.readFile(exaConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
apiKey = config.apiKey;
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "<your-key>" }',
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json',
|
||||
};
|
||||
}
|
||||
|
||||
const resultCount = Math.min(Math.max(numResults || 5, 1), 20);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
const reqBody: Record<string, unknown> = {
|
||||
query,
|
||||
numResults: resultCount,
|
||||
type: 'auto',
|
||||
|
|
@ -1424,17 +1172,54 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
};
|
||||
if (category) {
|
||||
body.category = category;
|
||||
reqBody.category = category;
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.exa.ai/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let response: Response;
|
||||
|
||||
if (await isSignedIn()) {
|
||||
// Use proxy
|
||||
const accessToken = await getAccessToken();
|
||||
response = await fetch(`${API_URL}/v1/search/exa`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(reqBody),
|
||||
});
|
||||
} else {
|
||||
// Read API key from config
|
||||
const exaConfigPath = path.join(WorkDir, 'config', 'exa-search.json');
|
||||
|
||||
let apiKey: string;
|
||||
try {
|
||||
const raw = await fs.readFile(exaConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
apiKey = config.apiKey;
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "<your-key>" }',
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json',
|
||||
};
|
||||
}
|
||||
|
||||
response = await fetch('https://api.exa.ai/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(reqBody),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
|
@ -1478,6 +1263,27 @@ 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}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -1527,10 +1333,15 @@ function registerComposioTools(): void {
|
|||
error: `Toolkit "${toolkitSlug}" is not connected. Please connect it in Settings > Tools Library.`,
|
||||
};
|
||||
}
|
||||
return executeComposioAction(slug, account.id, input);
|
||||
return executeComposioAction(slug, {
|
||||
connected_account_id: account.id,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: input,
|
||||
});
|
||||
},
|
||||
isAvailable: async () => {
|
||||
return isComposioConfigured() && composioAccountsRepo.isConnected(toolkitSlug);
|
||||
return (await isComposioConfigured()) && composioAccountsRepo.isConnected(toolkitSlug);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { getSecurityAllowList } from '../../config/security.js';
|
|||
import { getExecutionShell } from '../assistant/runtime-context.js';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
|
||||
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@ import { UserMessageContent } from "@x/shared/dist/message.js";
|
|||
import z from "zod";
|
||||
|
||||
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
|
||||
export type VoiceOutputMode = 'summary' | 'full';
|
||||
|
||||
type EnqueuedMessage = {
|
||||
messageId: string;
|
||||
message: UserMessageContentType;
|
||||
voiceInput?: boolean;
|
||||
voiceOutput?: VoiceOutputMode;
|
||||
searchEnabled?: boolean;
|
||||
};
|
||||
|
||||
export interface IMessageQueue {
|
||||
enqueue(runId: string, message: UserMessageContentType): Promise<string>;
|
||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string>;
|
||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||
}
|
||||
|
||||
|
|
@ -26,7 +30,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
|
||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string> {
|
||||
if (!this.store[runId]) {
|
||||
this.store[runId] = [];
|
||||
}
|
||||
|
|
@ -34,6 +38,9 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
this.store[runId].push({
|
||||
messageId: id,
|
||||
message,
|
||||
voiceInput,
|
||||
voiceOutput,
|
||||
searchEnabled,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
|
@ -44,4 +51,4 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
}
|
||||
return this.store[runId].shift() ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,13 +46,15 @@ export async function discoverConfiguration(
|
|||
console.log(`[OAuth] Using cached configuration for ${issuerUrl}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
console.log(`[OAuth] Discovering authorization server metadata for ${issuerUrl}...`);
|
||||
const config = await client.discovery(
|
||||
new URL(issuerUrl),
|
||||
clientId,
|
||||
undefined, // no client_secret (PKCE flow)
|
||||
client.None() // PKCE doesn't require client authentication
|
||||
client.None(), // PKCE doesn't require client authentication
|
||||
{
|
||||
execute: [client.allowInsecureRequests],
|
||||
}
|
||||
);
|
||||
|
||||
configCache.set(cacheKey, config);
|
||||
|
|
@ -110,7 +112,10 @@ export async function registerClient(
|
|||
client_name: clientName,
|
||||
scope: scopes.join(' '),
|
||||
},
|
||||
client.None()
|
||||
client.None(),
|
||||
{
|
||||
execute: [client.allowInsecureRequests],
|
||||
},
|
||||
);
|
||||
|
||||
const metadata = config.clientMetadata();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from 'zod';
|
||||
import { getRowboatConfig } from '../config/rowboat.js';
|
||||
|
||||
/**
|
||||
* Discovery configuration - how to get OAuth endpoints
|
||||
|
|
@ -51,6 +52,20 @@ export type ProviderConfigEntry = ProviderConfig[string];
|
|||
* All configured OAuth providers
|
||||
*/
|
||||
const providerConfigs: ProviderConfig = {
|
||||
rowboat: {
|
||||
discovery: {
|
||||
mode: 'issuer',
|
||||
issuer: "TBD",
|
||||
},
|
||||
client: {
|
||||
mode: 'dcr',
|
||||
},
|
||||
scopes: [
|
||||
"openid",
|
||||
"email",
|
||||
"profile",
|
||||
],
|
||||
},
|
||||
google: {
|
||||
discovery: {
|
||||
mode: 'issuer',
|
||||
|
|
@ -83,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
|
||||
*/
|
||||
|
|
|
|||
46
apps/x/packages/core/src/auth/tokens.ts
Normal file
46
apps/x/packages/core/src/auth/tokens.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import container from '../di/container.js';
|
||||
import { IOAuthRepo } from './repo.js';
|
||||
import { IClientRegistrationRepo } from './client-repo.js';
|
||||
import { getProviderConfig } from './providers.js';
|
||||
import * as oauthClient from './oauth-client.js';
|
||||
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const { tokens } = await oauthRepo.read('rowboat');
|
||||
if (!tokens) {
|
||||
throw new Error('Not signed into Rowboat');
|
||||
}
|
||||
|
||||
if (!oauthClient.isTokenExpired(tokens)) {
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
if (!tokens.refresh_token) {
|
||||
throw new Error('Rowboat token expired and no refresh token available. Please sign in again.');
|
||||
}
|
||||
|
||||
const providerConfig = await getProviderConfig('rowboat');
|
||||
if (providerConfig.discovery.mode !== 'issuer') {
|
||||
throw new Error('Rowboat provider requires issuer discovery mode');
|
||||
}
|
||||
|
||||
const clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
|
||||
const registration = await clientRepo.getClientRegistration('rowboat');
|
||||
if (!registration) {
|
||||
throw new Error('Rowboat client not registered. Please sign in again.');
|
||||
}
|
||||
|
||||
const config = await oauthClient.discoverConfiguration(
|
||||
providerConfig.discovery.issuer,
|
||||
registration.client_id,
|
||||
);
|
||||
|
||||
const refreshed = await oauthClient.refreshTokens(
|
||||
config,
|
||||
tokens.refresh_token,
|
||||
tokens.scopes,
|
||||
);
|
||||
await oauthRepo.upsert('rowboat', { tokens: refreshed });
|
||||
|
||||
return refreshed.access_token;
|
||||
}
|
||||
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,36 @@ import {
|
|||
ZCreateConnectedAccountResponse,
|
||||
ZDeleteOperationResponse,
|
||||
ZErrorResponse,
|
||||
ZExecuteActionRequest,
|
||||
ZExecuteActionResponse,
|
||||
ZListResponse,
|
||||
ZTool,
|
||||
ZToolkit,
|
||||
} 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 +48,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>;
|
||||
|
|
@ -91,38 +95,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" } : {}),
|
||||
},
|
||||
});
|
||||
|
|
@ -158,7 +182,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 +198,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 +222,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,8 +240,7 @@ 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),
|
||||
});
|
||||
|
|
@ -253,8 +250,7 @@ export async function createAuthConfig(
|
|||
* 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(), {
|
||||
return composioApiCall(ZDeleteOperationResponse, `/auth_configs/${authConfigId}`, {}, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
|
@ -265,8 +261,7 @@ export async function deleteAuthConfig(authConfigId: string): Promise<z.infer<ty
|
|||
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,16 +271,14 @@ 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',
|
||||
});
|
||||
}
|
||||
|
|
@ -296,38 +289,15 @@ export async function deleteConnectedAccount(connectedAccountId: string): Promis
|
|||
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 ?? ''),
|
||||
})),
|
||||
): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
|
||||
const params: Record<string, string> = {
|
||||
toolkit_slug: toolkitSlug,
|
||||
limit: '200',
|
||||
};
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
}
|
||||
return composioApiCall(ZListResponse(ZTool), "/tools", params);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -337,12 +307,10 @@ export async function listToolkitToolsDetailed(
|
|||
toolkitSlug: string,
|
||||
searchQuery: string | null = null,
|
||||
): Promise<{ items: Array<{ slug: string; name: string; description: string; toolkitSlug: string; inputParameters: { type: 'object'; properties: Record<string, unknown>; required?: string[] } }> }> {
|
||||
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(`${BASE_URL}/tools`);
|
||||
const url = new URL(`${baseURL}/tools`);
|
||||
url.searchParams.set('toolkit_slug', toolkitSlug);
|
||||
url.searchParams.set('limit', '200');
|
||||
if (searchQuery) {
|
||||
|
|
@ -352,7 +320,7 @@ export async function listToolkitToolsDetailed(
|
|||
console.log(`[Composio] Listing tools (detailed) for toolkit: ${toolkitSlug}`);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { "x-api-key": apiKey },
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -380,29 +348,14 @@ export async function listToolkitToolsDetailed(
|
|||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,11 +45,11 @@ export const ZToolkit = z.object({
|
|||
slug: z.string(),
|
||||
name: z.string(),
|
||||
meta: ZToolkitMeta,
|
||||
no_auth: z.boolean(),
|
||||
no_auth: z.boolean().optional(),
|
||||
// Use z.string() instead of ZAuthScheme to be resilient against
|
||||
// new auth types added by the Composio API over time.
|
||||
auth_schemes: z.array(z.string()),
|
||||
composio_managed_auth_schemes: z.array(z.string()),
|
||||
auth_schemes: z.array(z.string()).optional(),
|
||||
composio_managed_auth_schemes: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -70,7 +70,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(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -202,18 +202,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(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ function ensureDefaultConfigs() {
|
|||
const noteCreationConfig = path.join(WorkDir, "config", "note_creation.json");
|
||||
if (!fs.existsSync(noteCreationConfig)) {
|
||||
fs.writeFileSync(noteCreationConfig, JSON.stringify({
|
||||
strictness: "high",
|
||||
strictness: "medium",
|
||||
configured: false
|
||||
}, null, 2));
|
||||
}
|
||||
|
|
|
|||
2
apps/x/packages/core/src/config/env.ts
Normal file
2
apps/x/packages/core/src/config/env.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const API_URL =
|
||||
process.env.API_URL || 'https://api.x.rowboatlabs.com';
|
||||
|
|
@ -11,7 +11,7 @@ interface NoteCreationConfig {
|
|||
}
|
||||
|
||||
const CONFIG_FILE = path.join(WorkDir, 'config', 'note_creation.json');
|
||||
const DEFAULT_STRICTNESS: NoteCreationStrictness = 'high';
|
||||
const DEFAULT_STRICTNESS: NoteCreationStrictness = 'medium';
|
||||
|
||||
/**
|
||||
* Read the full config file.
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/re
|
|||
import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
||||
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
|
||||
|
||||
const container = createContainer({
|
||||
injectionMode: InjectionMode.PROXY,
|
||||
|
|
@ -37,6 +38,7 @@ container.register({
|
|||
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
|
||||
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
||||
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
||||
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
|
||||
});
|
||||
|
||||
export default container;
|
||||
|
|
@ -9,3 +9,6 @@ export { initConfigs } from './config/initConfigs.js';
|
|||
|
||||
// Knowledge version history
|
||||
export * as versionHistory from './knowledge/version_history.js';
|
||||
|
||||
// Voice mode (config + TTS)
|
||||
export * as voice from './voice/voice.js';
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { autoConfigureStrictnessIfNeeded } from '../config/strictness_analyzer.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { bus } from '../runs/bus.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
|
|
@ -26,11 +25,11 @@ 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>/
|
||||
|
|
@ -193,7 +192,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`;
|
||||
});
|
||||
|
|
@ -363,7 +364,19 @@ export async function buildGraph(sourceDir: string): Promise<void> {
|
|||
console.log(`[buildGraph] State loaded. Previously processed: ${previouslyProcessedCount} files`);
|
||||
|
||||
// Get files that need processing (new or changed)
|
||||
const filesToProcess = getFilesToProcess(sourceDir, state);
|
||||
let filesToProcess = getFilesToProcess(sourceDir, state);
|
||||
|
||||
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
|
||||
if (sourceDir.endsWith('gmail_sync')) {
|
||||
filesToProcess = filesToProcess.filter(filePath => {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return content.startsWith('---');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (filesToProcess.length === 0) {
|
||||
console.log(`[buildGraph] No new or changed files to process in ${path.basename(sourceDir)}`);
|
||||
|
|
@ -525,8 +538,6 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
|
|||
async function processAllSources(): Promise<void> {
|
||||
console.log('[GraphBuilder] Checking for new content in all sources...');
|
||||
|
||||
// Auto-configure strictness on first run if not already done
|
||||
autoConfigureStrictnessIfNeeded();
|
||||
|
||||
let anyFilesProcessed = false;
|
||||
|
||||
|
|
@ -555,7 +566,19 @@ async function processAllSources(): Promise<void> {
|
|||
}
|
||||
|
||||
try {
|
||||
const filesToProcess = getFilesToProcess(sourceDir, state);
|
||||
let filesToProcess = getFilesToProcess(sourceDir, state);
|
||||
|
||||
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
|
||||
if (folder === 'gmail_sync') {
|
||||
filesToProcess = filesToProcess.filter(filePath => {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return content.startsWith('---');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (filesToProcess.length > 0) {
|
||||
console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
92
apps/x/packages/core/src/knowledge/inline_task_agent.ts
Normal file
92
apps/x/packages/core/src/knowledge/inline_task_agent.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { BuiltinTools } from '../application/lib/builtin-tools.js';
|
||||
|
||||
export function getRaw(): string {
|
||||
const toolEntries = Object.keys(BuiltinTools)
|
||||
.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:
|
||||
${toolEntries}
|
||||
---
|
||||
# Task
|
||||
|
||||
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.
|
||||
|
||||
# Two Modes
|
||||
|
||||
## 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.
|
||||
|
||||
# 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
|
||||
`;
|
||||
}
|
||||
741
apps/x/packages/core/src/knowledge/inline_tasks.ts
Normal file
741
apps/x/packages/core/src/knowledge/inline_tasks.ts
Normal file
|
|
@ -0,0 +1,741 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { generateText } from 'ai';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage, fetchRun } from '../runs/runs.js';
|
||||
import { bus } from '../runs/bus.js';
|
||||
import container from '../di/container.js';
|
||||
import type { IModelConfigRepo } from '../models/repo.js';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { inlineTask } from '@x/shared';
|
||||
|
||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||
const INLINE_TASK_AGENT = 'inline_task_agent';
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal frontmatter helpers (duplicated from renderer to avoid cross-package
|
||||
// dependency — can be moved to shared later).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function splitFrontmatter(content: string): { raw: string | null; body: string } {
|
||||
if (!content.startsWith('---')) {
|
||||
return { raw: null, body: content };
|
||||
}
|
||||
const endIndex = content.indexOf('\n---', 3);
|
||||
if (endIndex === -1) {
|
||||
return { raw: null, body: content };
|
||||
}
|
||||
const closingEnd = endIndex + 4;
|
||||
const raw = content.slice(0, closingEnd);
|
||||
let body = content.slice(closingEnd);
|
||||
if (body.startsWith('\n')) {
|
||||
body = body.slice(1);
|
||||
}
|
||||
return { raw, body };
|
||||
}
|
||||
|
||||
function joinFrontmatter(raw: string | null, body: string): string {
|
||||
if (!raw) return body;
|
||||
return raw + '\n' + body;
|
||||
}
|
||||
|
||||
function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
|
||||
const result: Record<string, string | string[]> = {};
|
||||
if (!raw) return result;
|
||||
|
||||
const lines = raw.split('\n');
|
||||
let currentKey: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === '---' || line.trim() === '') {
|
||||
currentKey = null;
|
||||
continue;
|
||||
}
|
||||
const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/);
|
||||
if (topMatch) {
|
||||
const key = topMatch[1];
|
||||
const value = topMatch[2].trim();
|
||||
if (value) {
|
||||
result[key] = value;
|
||||
currentKey = null;
|
||||
} else {
|
||||
currentKey = key;
|
||||
result[key] = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (currentKey) {
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/);
|
||||
if (itemMatch) {
|
||||
const arr = result[currentKey];
|
||||
if (Array.isArray(arr)) {
|
||||
arr.push(itemMatch[1].trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) continue;
|
||||
lines.push(`${key}:`);
|
||||
for (const item of value) {
|
||||
if (item.trim()) lines.push(` - ${item.trim()}`);
|
||||
}
|
||||
} else {
|
||||
const trimmed = (value ?? '').trim();
|
||||
if (!trimmed) continue;
|
||||
lines.push(`${key}: ${trimmed}`);
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) return null;
|
||||
return `---\n${lines.join('\n')}\n---`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type InlineTaskSchedule =
|
||||
| { type: 'cron'; expression: string; startDate: string; endDate: string; label: string }
|
||||
| { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string; label: string }
|
||||
| { type: 'once'; runAt: string; label: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function scanDirectoryRecursive(dir: string): string[] {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const files: string[] = [];
|
||||
const entries = fs.readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('.')) continue;
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...scanDirectoryRecursive(fullPath));
|
||||
} else if (stat.isFile() && entry.endsWith('.md')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a run to complete by listening for run-processing-end event
|
||||
*/
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the assistant's final text response from a run's log.
|
||||
*/
|
||||
async function extractAgentResponse(runId: string): Promise<string | null> {
|
||||
const run = await fetchRun(runId);
|
||||
// Walk backwards through the log to find the last assistant message
|
||||
for (let i = run.log.length - 1; i >= 0; i--) {
|
||||
const event = run.log[i];
|
||||
if (event.type === 'message' && event.message.role === 'assistant') {
|
||||
const content = event.message.content;
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
// Content may be an array of parts — concatenate text parts
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => (p as { type: 'text'; text: string }).text)
|
||||
.join('');
|
||||
return text || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface InlineTask {
|
||||
instruction: string;
|
||||
schedule: InlineTaskSchedule | null;
|
||||
/** Line index of the opening ```task fence in the body */
|
||||
startLine: number;
|
||||
/** Line index of the closing ``` fence */
|
||||
endLine: number;
|
||||
/** Target region ID for recurring tasks */
|
||||
targetId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the tell-rowboat block content (JSON format).
|
||||
* 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; targetId: string | null } | null {
|
||||
const raw = contentLines.join('\n').trim();
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
const parsed = inlineTask.InlineTaskBlockSchema.safeParse(data);
|
||||
if (parsed.success) {
|
||||
return {
|
||||
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
|
||||
if (data && typeof data === 'object' && data.instruction) {
|
||||
return {
|
||||
instruction: data.instruction,
|
||||
schedule: data.schedule ?? null,
|
||||
lastRunAt: data.lastRunAt ?? null,
|
||||
targetId: data.targetId ?? null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Legacy format: @rowboat lines + optional schedule: JSON line
|
||||
}
|
||||
|
||||
// Legacy fallback: parse @rowboat instruction and schedule: line
|
||||
let schedule: InlineTaskSchedule | null = null;
|
||||
const instructionLines: string[] = [];
|
||||
for (const cl of contentLines) {
|
||||
const schedMatch = cl.trim().match(/^schedule:\s*(.+)$/);
|
||||
if (schedMatch) {
|
||||
try {
|
||||
const obj = JSON.parse(schedMatch[1]);
|
||||
if (obj && typeof obj === 'object' && obj.type) {
|
||||
schedule = obj as InlineTaskSchedule;
|
||||
}
|
||||
} catch { /* not JSON schedule, skip */ }
|
||||
} else if (!/^schedule-config:\s/.test(cl.trim())) {
|
||||
instructionLines.push(cl);
|
||||
}
|
||||
}
|
||||
const firstRowboatLine = instructionLines.find(l => l.trim().startsWith('@rowboat'));
|
||||
const rawInstruction = firstRowboatLine?.trim() ?? instructionLines.join('\n').trim();
|
||||
const instruction = rawInstruction.replace(/^@rowboat:?\s*/, '');
|
||||
if (!instruction) return null;
|
||||
return { instruction, schedule, lastRunAt: null, targetId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a scheduled task is due to run.
|
||||
*/
|
||||
function isScheduledTaskDue(schedule: InlineTaskSchedule, lastRunAt: string | null): boolean {
|
||||
const now = new Date();
|
||||
|
||||
// Check startDate/endDate bounds for cron and window
|
||||
if (schedule.type === 'cron' || schedule.type === 'window') {
|
||||
if (schedule.startDate && now < new Date(schedule.startDate)) return false;
|
||||
if (schedule.endDate && now > new Date(schedule.endDate)) return false;
|
||||
}
|
||||
|
||||
switch (schedule.type) {
|
||||
case 'cron': {
|
||||
if (!lastRunAt) return true; // Never run → due
|
||||
try {
|
||||
const lastRun = new Date(lastRunAt);
|
||||
const interval = CronExpressionParser.parse(schedule.expression, {
|
||||
currentDate: lastRun,
|
||||
});
|
||||
const nextRun = interval.next().toDate();
|
||||
return now >= nextRun;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case 'window': {
|
||||
if (!lastRunAt) return true;
|
||||
try {
|
||||
const lastRun = new Date(lastRunAt);
|
||||
const interval = CronExpressionParser.parse(schedule.cron, {
|
||||
currentDate: lastRun,
|
||||
});
|
||||
const nextDate = interval.next().toDate();
|
||||
|
||||
// Check if we're within the time window
|
||||
const [startHour, startMin] = schedule.startTime.split(':').map(Number);
|
||||
const [endHour, endMin] = schedule.endTime.split(':').map(Number);
|
||||
const startMinutes = startHour * 60 + startMin;
|
||||
const endMinutes = endHour * 60 + endMin;
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
// The cron date must have passed and we need to be in the time window
|
||||
return now >= nextDate && nowMinutes >= startMinutes && nowMinutes <= endMinutes;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case 'once': {
|
||||
if (lastRunAt) return false; // Already ran
|
||||
const runAt = new Date(schedule.runAt);
|
||||
return now >= runAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Find ```tell-rowboat code blocks in a note body and return tasks that are pending execution.
|
||||
*/
|
||||
function findPendingTasks(body: string): InlineTask[] {
|
||||
const tasks: InlineTask[] = [];
|
||||
const lines = body.split('\n');
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed.startsWith('```task') || trimmed.startsWith('```tell-rowboat')) {
|
||||
const startLine = i;
|
||||
i++;
|
||||
const contentLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() !== '```') {
|
||||
contentLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
const endLine = i; // line with closing ```
|
||||
|
||||
const parsed = parseBlockContent(contentLines);
|
||||
if (parsed) {
|
||||
const { instruction, schedule, lastRunAt, targetId } = parsed;
|
||||
|
||||
if (schedule) {
|
||||
if (isScheduledTaskDue(schedule, lastRunAt)) {
|
||||
tasks.push({ instruction, schedule, startLine, endLine, targetId });
|
||||
}
|
||||
} else {
|
||||
// One-time task: skip if already ran
|
||||
if (!lastRunAt) {
|
||||
tasks.push({ instruction, schedule: null, startLine, endLine, targetId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the agent result below the tell-rowboat code block in the body.
|
||||
* Returns the updated body string.
|
||||
*/
|
||||
function insertResultBelow(body: string, endLine: number, result: string): string {
|
||||
const lines = body.split('\n');
|
||||
// Insert a blank line + result after the closing ``` fence
|
||||
lines.splice(endLine + 1, 0, '', result);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* - It's a one-time task that hasn't been completed yet
|
||||
* - It's a scheduled task whose endDate hasn't passed (or has no endDate)
|
||||
* - It's a scheduled task before its startDate (will run in the future)
|
||||
*/
|
||||
function hasLiveTasks(body: string): boolean {
|
||||
const now = new Date();
|
||||
const lines = body.split('\n');
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed.startsWith('```task') || trimmed.startsWith('```tell-rowboat')) {
|
||||
i++;
|
||||
const contentLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() !== '```') {
|
||||
contentLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
const parsed = parseBlockContent(contentLines);
|
||||
if (!parsed) { i++; continue; }
|
||||
|
||||
const { schedule, lastRunAt } = parsed;
|
||||
|
||||
if (schedule) {
|
||||
if (schedule.type === 'cron' || schedule.type === 'window') {
|
||||
const endDate = schedule.endDate;
|
||||
if (!endDate || now <= new Date(endDate)) {
|
||||
return true;
|
||||
}
|
||||
} else if (schedule.type === 'once') {
|
||||
if (!lastRunAt) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// One-time task without schedule: live if never ran
|
||||
if (!lastRunAt) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update the JSON content inside a task code block to include lastRunAt.
|
||||
* Replaces the content lines between the opening and closing fences.
|
||||
*/
|
||||
function updateBlockData(body: string, startLine: number, endLine: number, lastRunAt: string): string {
|
||||
const lines = body.split('\n');
|
||||
// Content is between startLine+1 and endLine-1
|
||||
const contentLines = lines.slice(startLine + 1, endLine);
|
||||
const raw = contentLines.join('\n').trim();
|
||||
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
data.lastRunAt = lastRunAt;
|
||||
const updatedJson = JSON.stringify(data);
|
||||
// Replace content lines with the updated JSON (single line)
|
||||
lines.splice(startLine + 1, endLine - startLine - 1, updatedJson);
|
||||
} catch {
|
||||
// Not valid JSON — skip update
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processInlineTasks(): Promise<void> {
|
||||
console.log('[InlineTasks] Checking live notes...');
|
||||
|
||||
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||
console.log('[InlineTasks] Knowledge directory not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const allFiles = scanDirectoryRecursive(KNOWLEDGE_DIR);
|
||||
let totalProcessed = 0;
|
||||
|
||||
for (const filePath of allFiles) {
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { raw, body } = splitFrontmatter(content);
|
||||
const fields = extractAllFrontmatterValues(raw);
|
||||
|
||||
// Only process files marked as live
|
||||
if (fields['live_note'] !== 'true') continue;
|
||||
|
||||
const tasks = findPendingTasks(body);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
// No pending tasks — check if still live, update if not
|
||||
const live = hasLiveTasks(body);
|
||||
if (!live) {
|
||||
fields['live_note'] = 'false';
|
||||
// Remove rowboat_tasks if present (legacy cleanup)
|
||||
delete fields['rowboat_tasks'];
|
||||
const newRaw = buildFrontmatter(fields);
|
||||
const newContent = joinFrontmatter(newRaw, body);
|
||||
try {
|
||||
fs.writeFileSync(filePath, newContent, 'utf-8');
|
||||
const rel = path.relative(WorkDir, filePath);
|
||||
console.log(`[InlineTasks] Marked ${rel} as no longer live`);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(WorkDir, filePath);
|
||||
console.log(`[InlineTasks] Found ${tasks.length} pending task(s) in ${relativePath}`);
|
||||
|
||||
// Process tasks one at a time, bottom-up so line indices stay valid
|
||||
// (inserting content shifts lines below, so process from bottom to top)
|
||||
const sortedTasks = [...tasks].sort((a, b) => b.endLine - a.endLine);
|
||||
|
||||
let currentBody = body;
|
||||
|
||||
for (const task of sortedTasks) {
|
||||
console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`);
|
||||
|
||||
try {
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT });
|
||||
|
||||
const message = [
|
||||
`Execute the following instruction from the note "${relativePath}":`,
|
||||
'',
|
||||
`**Instruction:** ${task.instruction}`,
|
||||
'',
|
||||
'**Full note content for context:**',
|
||||
'```markdown',
|
||||
content,
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
await createMessage(run.id, message);
|
||||
await waitForRunCompletion(run.id);
|
||||
|
||||
const result = await extractAgentResponse(run.id);
|
||||
if (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);
|
||||
totalProcessed++;
|
||||
console.log(`[InlineTasks] Task completed`);
|
||||
} else {
|
||||
console.warn(`[InlineTasks] No response from agent for task`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[InlineTasks] Error processing task:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update frontmatter — only manage live_note, remove legacy rowboat_tasks
|
||||
const live = hasLiveTasks(currentBody);
|
||||
fields['live_note'] = live ? 'true' : 'false';
|
||||
delete fields['rowboat_tasks'];
|
||||
const newRaw = buildFrontmatter(fields);
|
||||
const newContent = joinFrontmatter(newRaw, currentBody);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, newContent, 'utf-8');
|
||||
console.log(`[InlineTasks] Updated ${relativePath}`);
|
||||
} catch (error) {
|
||||
console.error(`[InlineTasks] Error writing ${relativePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalProcessed > 0) {
|
||||
console.log(`[InlineTasks] Done. Processed ${totalProcessed} task(s).`);
|
||||
} else {
|
||||
console.log('[InlineTasks] No pending tasks found');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function classifySchedule(instruction: string): Promise<InlineTaskSchedule | null> {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
const provider = createProvider(config.provider);
|
||||
const model = provider.languageModel(config.model);
|
||||
|
||||
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 localEnd = defaultEnd.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const nowISO = now.toISOString();
|
||||
const defaultEndISO = defaultEnd.toISOString();
|
||||
|
||||
const systemPrompt = `You classify whether a user instruction contains a scheduling intent.
|
||||
|
||||
If the instruction implies a recurring or future-scheduled task, return a JSON object with the schedule.
|
||||
If the instruction is a one-time immediate task, return null.
|
||||
|
||||
Every schedule object MUST include a "label" field: a short, plain-English description starting with "runs" that includes the end date (e.g. "runs every 2 minutes until Mar 12", "runs daily at 8 AM until Mar 12").
|
||||
|
||||
Schedule types:
|
||||
1. "cron" — recurring schedule. Return: {"type":"cron","expression":"<cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}
|
||||
Use standard 5-field cron (minute hour day-of-month month day-of-week).
|
||||
"startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}).
|
||||
Override these if the user specifies a duration (e.g. "for the next 3 days" → endDate = now + 3 days) or a start (e.g. "starting next Monday").
|
||||
Example: "every morning at 8am" → {"type":"cron","expression":"0 8 * * *","startDate":"${nowISO}","endDate":"${defaultEndISO}","label":"runs daily at 8 AM until Mar 12"}
|
||||
|
||||
2. "window" — recurring with a time window. Return: {"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}
|
||||
Use when the user specifies a range like "between 8am and 10am". Same startDate/endDate defaults and override rules as cron.
|
||||
|
||||
3. "once" — run once at a specific future time. Return: {"type":"once","runAt":"<ISO 8601 datetime>","label":"<human readable>"}
|
||||
Use when the user says "tomorrow at 3pm", "next Friday", etc. No startDate/endDate for once.
|
||||
|
||||
Current local time: ${localNow}
|
||||
Timezone: ${tz}
|
||||
Current UTC time: ${nowISO}
|
||||
Default end time (local): ${localEnd}
|
||||
|
||||
Respond with ONLY valid JSON: either a schedule object or null. No other text.`;
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: instruction,
|
||||
});
|
||||
|
||||
let text = result.text.trim();
|
||||
console.log('[classifySchedule] LLM response:', text);
|
||||
// Strip markdown code fences if the LLM wraps the JSON
|
||||
text = text.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
|
||||
if (text === 'null' || text === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as InlineTaskSchedule;
|
||||
} catch (error) {
|
||||
console.error('[classifySchedule] Error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point — runs as independent polling service
|
||||
*/
|
||||
export async function init() {
|
||||
console.log('[InlineTasks] Starting Inline Task Service...');
|
||||
console.log(`[InlineTasks] Will check for task blocks every ${SYNC_INTERVAL_MS / 1000} seconds`);
|
||||
|
||||
// Initial run
|
||||
await processInlineTasks();
|
||||
|
||||
// Periodic polling
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
|
||||
|
||||
try {
|
||||
await processInlineTasks();
|
||||
} catch (error) {
|
||||
console.error('[InlineTasks] Error in main loop:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
269
apps/x/packages/core/src/knowledge/label_emails.ts
Normal file
269
apps/x/packages/core/src/knowledge/label_emails.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
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 { limitEventItems } from './limit_event_items.js';
|
||||
import {
|
||||
loadLabelingState,
|
||||
saveLabelingState,
|
||||
markFileAsLabeled,
|
||||
type LabelingState,
|
||||
} from './labeling_state.js';
|
||||
|
||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||
const BATCH_SIZE = 15;
|
||||
const LABELING_AGENT = 'labeling_agent';
|
||||
const GMAIL_SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||
const MAX_CONTENT_LENGTH = 8000;
|
||||
|
||||
/**
|
||||
* Find email files that haven't been labeled yet
|
||||
*/
|
||||
function getUnlabeledEmails(state: LabelingState): string[] {
|
||||
if (!fs.existsSync(GMAIL_SYNC_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const unlabeled: string[] = [];
|
||||
|
||||
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()) {
|
||||
traverse(fullPath);
|
||||
} else if (stat.isFile() && entry.endsWith('.md')) {
|
||||
// Skip if already tracked in state
|
||||
if (state.processedFiles[fullPath]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if file already has frontmatter
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
if (content.startsWith('---')) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
unlabeled.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(GMAIL_SYNC_DIR);
|
||||
return unlabeled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a run to complete by listening for run-processing-end event
|
||||
*/
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Label a batch of email files using the labeling agent
|
||||
*/
|
||||
async function labelEmailBatch(
|
||||
files: { path: string; content: string }[]
|
||||
): Promise<{ runId: string; filesEdited: Set<string> }> {
|
||||
const run = await createRun({
|
||||
agentId: LABELING_AGENT,
|
||||
});
|
||||
|
||||
let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`;
|
||||
message += `**Important:** Use workspace-relative paths with workspace-edit (e.g. "gmail_sync/email.md", NOT absolute paths).\n\n`;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const relativePath = path.relative(WorkDir, file.path);
|
||||
const truncated = file.content.length > MAX_CONTENT_LENGTH
|
||||
? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use workspace-readFile for full content ...]'
|
||||
: file.content;
|
||||
|
||||
message += `## File ${i + 1}: ${relativePath}\n\n`;
|
||||
message += truncated;
|
||||
message += `\n\n---\n\n`;
|
||||
}
|
||||
|
||||
const filesEdited = new Set<string>();
|
||||
|
||||
const unsubscribe = await bus.subscribe(run.id, async (event) => {
|
||||
if (event.type !== 'tool-invocation') {
|
||||
return;
|
||||
}
|
||||
if (event.toolName !== 'workspace-edit') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(event.input) as { path?: string };
|
||||
if (typeof parsed.path === 'string') {
|
||||
filesEdited.add(parsed.path);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
});
|
||||
|
||||
await createMessage(run.id, message);
|
||||
await waitForRunCompletion(run.id);
|
||||
unsubscribe();
|
||||
|
||||
return { runId: run.id, filesEdited };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all unlabeled emails in batches
|
||||
*/
|
||||
async function processUnlabeledEmails(): Promise<void> {
|
||||
console.log('[EmailLabeling] Checking for unlabeled emails...');
|
||||
|
||||
const state = loadLabelingState();
|
||||
const unlabeled = getUnlabeledEmails(state);
|
||||
|
||||
if (unlabeled.length === 0) {
|
||||
console.log('[EmailLabeling] No unlabeled emails found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[EmailLabeling] Found ${unlabeled.length} unlabeled emails`);
|
||||
|
||||
const run = await serviceLogger.startRun({
|
||||
service: 'email_labeling',
|
||||
message: `Labeling ${unlabeled.length} email${unlabeled.length === 1 ? '' : 's'}`,
|
||||
trigger: 'timer',
|
||||
});
|
||||
|
||||
const relativeFiles = unlabeled.map(f => path.relative(WorkDir, f));
|
||||
const limitedFiles = limitEventItems(relativeFiles);
|
||||
await serviceLogger.log({
|
||||
type: 'changes_identified',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'info',
|
||||
message: `Found ${unlabeled.length} unlabeled email${unlabeled.length === 1 ? '' : 's'}`,
|
||||
counts: { emails: unlabeled.length },
|
||||
items: limitedFiles.items,
|
||||
truncated: limitedFiles.truncated,
|
||||
});
|
||||
|
||||
const totalBatches = Math.ceil(unlabeled.length / BATCH_SIZE);
|
||||
let totalEdited = 0;
|
||||
let hadError = false;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.lastRunTime = new Date().toISOString();
|
||||
saveLabelingState(state);
|
||||
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: hadError ? 'error' : 'info',
|
||||
message: `Email labeling complete: ${totalEdited} files labeled`,
|
||||
durationMs: Date.now() - run.startedAt,
|
||||
outcome: hadError ? 'error' : 'ok',
|
||||
summary: {
|
||||
totalEmails: unlabeled.length,
|
||||
filesLabeled: totalEdited,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[EmailLabeling] Done. ${totalEdited} emails labeled.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point - runs as independent polling service
|
||||
*/
|
||||
export async function init() {
|
||||
console.log('[EmailLabeling] Starting Email Labeling Service...');
|
||||
console.log(`[EmailLabeling] Will check for unlabeled emails every ${SYNC_INTERVAL_MS / 1000} seconds`);
|
||||
|
||||
// Initial run
|
||||
await processUnlabeledEmails();
|
||||
|
||||
// Periodic polling
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
|
||||
|
||||
try {
|
||||
await processUnlabeledEmails();
|
||||
} catch (error) {
|
||||
console.error('[EmailLabeling] Error in main loop:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
apps/x/packages/core/src/knowledge/labeling_agent.ts
Normal file
59
apps/x/packages/core/src/knowledge/labeling_agent.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { renderTagSystemForEmails } from './tag_system.js';
|
||||
|
||||
export function getRaw(): string {
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-edit:
|
||||
type: builtin
|
||||
name: workspace-edit
|
||||
workspace-readdir:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
---
|
||||
# Task
|
||||
|
||||
You are an email labeling agent. Given a batch of email files, you will classify each email and prepend YAML frontmatter with structured labels.
|
||||
|
||||
${renderTagSystemForEmails()}
|
||||
|
||||
# Instructions
|
||||
|
||||
1. For each email file provided in the message, read its content carefully.
|
||||
2. Classify the email using the taxonomy above. Be accurate and conservative — only apply labels that clearly fit.
|
||||
3. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Subject\` heading), and the newString should be the frontmatter followed by that same first line.
|
||||
4. Always include \`processed: true\` and \`labeled_at\` with the current ISO timestamp.
|
||||
5. If the email already has frontmatter (starts with \`---\`), skip it.
|
||||
|
||||
# Frontmatter Format
|
||||
|
||||
\`\`\`yaml
|
||||
---
|
||||
labels:
|
||||
relationship:
|
||||
- Investor
|
||||
topics:
|
||||
- Fundraising
|
||||
- Finance
|
||||
type: Intro
|
||||
filter:
|
||||
- Promotion
|
||||
action: FYI
|
||||
processed: true
|
||||
labeled_at: "2026-02-28T12:00:00Z"
|
||||
---
|
||||
\`\`\`
|
||||
|
||||
# Rules
|
||||
|
||||
- Every label category must be present in the frontmatter, even if empty (use \`[]\` for empty arrays).
|
||||
- \`type\` and \`action\` are single values (strings), not arrays.
|
||||
- \`relationship\`, \`topics\`, and \`filter\` are arrays.
|
||||
- Use the exact label values from the taxonomy — do not invent new ones.
|
||||
- The \`labeled_at\` timestamp should be the current time in ISO 8601 format.
|
||||
- Process all files in the batch. Do not skip any unless they already have frontmatter.
|
||||
`;
|
||||
}
|
||||
48
apps/x/packages/core/src/knowledge/labeling_state.ts
Normal file
48
apps/x/packages/core/src/knowledge/labeling_state.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
const STATE_FILE = path.join(WorkDir, 'labeling_state.json');
|
||||
|
||||
export interface LabelingState {
|
||||
processedFiles: Record<string, { labeledAt: string }>;
|
||||
lastRunTime: string;
|
||||
}
|
||||
|
||||
export function loadLabelingState(): LabelingState {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
||||
} catch (error) {
|
||||
console.error('Error loading labeling state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processedFiles: {},
|
||||
lastRunTime: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function saveLabelingState(state: LabelingState): void {
|
||||
try {
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Error saving labeling state:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function markFileAsLabeled(filePath: string, state: LabelingState): void {
|
||||
state.processedFiles[filePath] = {
|
||||
labeledAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetLabelingState(): void {
|
||||
const emptyState: LabelingState = {
|
||||
processedFiles: {},
|
||||
lastRunTime: new Date().toISOString(),
|
||||
};
|
||||
saveLabelingState(emptyState);
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
export const raw = `---
|
||||
import { renderNoteTypesBlock } from './note_system.js';
|
||||
import { renderNoteEffectRules } from './tag_system.js';
|
||||
|
||||
export function getRaw(): string {
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
workspace-writeFile:
|
||||
|
|
@ -23,6 +27,15 @@ tools:
|
|||
type: builtin
|
||||
name: workspace-glob
|
||||
---
|
||||
# Context
|
||||
|
||||
**Current date and time:** ${new Date().toISOString()}
|
||||
|
||||
Sources (emails, meetings, voice memos) are processed in roughly chronological order. This means:
|
||||
- Earlier sources may reference events that have since occurred — later sources will provide updates.
|
||||
- If a source mentions a future meeting or deadline, it may already be in the past by now. Use the current date above to reason about what is past vs. upcoming.
|
||||
- Don't treat old commitments as still "open" if later sources or the current date suggest they've likely been resolved.
|
||||
|
||||
# Task
|
||||
|
||||
You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), you will:
|
||||
|
|
@ -130,25 +143,15 @@ Either:
|
|||
|
||||
---
|
||||
|
||||
# The Core Rule: Medium Strictness
|
||||
# The Core Rule: Label-Based Filtering
|
||||
|
||||
**MEDIUM STRICTNESS MODE**
|
||||
**Emails now have YAML frontmatter with labels.** Use these labels to decide whether to process or skip.
|
||||
|
||||
**Meetings create notes because:**
|
||||
- You chose to spend time with these people
|
||||
- If you met them, they matter enough to track
|
||||
- Meeting transcripts have rich context
|
||||
**Meetings and voice memos always create notes** — no label check needed.
|
||||
|
||||
**Emails can create notes if:**
|
||||
- The email contains personalized content (not mass mail)
|
||||
- The sender seems relevant to your work (business context, not consumer services)
|
||||
- The email is part of a meaningful exchange (not one-off transactional)
|
||||
**For emails, read the YAML frontmatter labels and apply these rules:**
|
||||
|
||||
**Skip creating notes for:**
|
||||
- Mass emails and newsletters
|
||||
- Automated/transactional emails
|
||||
- Consumer service providers (utilities, subscriptions, etc.)
|
||||
- Cold sales outreach with no prior relationship indication
|
||||
${renderNoteEffectRules()}
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -163,6 +166,7 @@ workspace-readFile({ path: "{source_file}" })
|
|||
- Has \`Attendees:\` field
|
||||
- Has \`Meeting:\` title
|
||||
- Transcript format with speaker labels
|
||||
- Source file path is under \`knowledge/Meetings/\` (e.g. \`knowledge/Meetings/granola/...\` or \`knowledge/Meetings/fireflies/...\`)
|
||||
|
||||
**Email indicators:**
|
||||
- Has \`From:\` and \`To:\` fields
|
||||
|
|
@ -170,8 +174,8 @@ workspace-readFile({ path: "{source_file}" })
|
|||
- Email signature
|
||||
|
||||
**Voice memo indicators:**
|
||||
- Has \`**Type:** voice memo\` field
|
||||
- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\`
|
||||
- Has YAML frontmatter with \`type: voice memo\`
|
||||
- Has frontmatter \`path:\` field like \`Voice Memos/YYYY-MM-DD/...\`
|
||||
- Has \`## Transcript\` section
|
||||
|
||||
**Set processing mode:**
|
||||
|
|
@ -217,168 +221,40 @@ Emails containing calendar invites (\`.ics\` attachments or inline calendar data
|
|||
|
||||
---
|
||||
|
||||
# Step 1: Source Filtering
|
||||
# Step 1: Source Filtering (Label-Based)
|
||||
|
||||
## Skip These Sources (Both Meetings and Emails)
|
||||
## For Meetings and Voice Memos
|
||||
Always process — no filtering needed.
|
||||
|
||||
### Mass Emails and Newsletters
|
||||
## For Emails — Read YAML Frontmatter
|
||||
|
||||
**Indicators:**
|
||||
- Sent to a list (To: contains multiple addresses, or undisclosed-recipients)
|
||||
- Unsubscribe link in body or footer
|
||||
- From a no-reply or marketing address (noreply@, newsletter@, marketing@, hello@)
|
||||
- Generic greeting ("Hi there", "Dear subscriber", "Hello!")
|
||||
- Promotional language ("Don't miss out", "Limited time", "% off")
|
||||
- Mailing list headers (List-Unsubscribe, Mailing-List)
|
||||
- Sent via marketing platforms (via sendgrid, via mailchimp, etc.)
|
||||
Emails have YAML frontmatter with labels prepended by the labeling agent:
|
||||
|
||||
**Action:** SKIP with reason "Newsletter/mass email"
|
||||
\`\`\`yaml
|
||||
---
|
||||
labels:
|
||||
relationship:
|
||||
- Investor
|
||||
topics:
|
||||
- Fundraising
|
||||
type: Intro
|
||||
filter: []
|
||||
action: FYI
|
||||
processed: true
|
||||
labeled_at: "2026-02-28T12:00:00Z"
|
||||
---
|
||||
\`\`\`
|
||||
|
||||
### Product Updates & Changelogs
|
||||
## Decision Rules
|
||||
|
||||
**Indicators:**
|
||||
- Subject contains: "changelog", "what's new", "product update", "release notes", "v1.x", "new features"
|
||||
- Content describes feature releases, bug fixes, or product changes
|
||||
- Sent to all users/customers (not personalized to you specifically)
|
||||
- From tools/SaaS you use: Cal.com, Notion, Slack, Linear, Figma, etc.
|
||||
- No action required from you — purely informational
|
||||
- Written in announcement style, not conversational
|
||||
|
||||
**Examples to SKIP:**
|
||||
- "Cal.com Changelog v6.1" — product update
|
||||
- "What's new in Notion - January 2026" — feature announcement
|
||||
- "Introducing new Slack features" — product marketing
|
||||
- "Linear Release Notes" — changelog
|
||||
|
||||
**Action:** SKIP with reason "Product update/changelog"
|
||||
|
||||
### Cold Outreach / Sales Emails
|
||||
|
||||
**THE RULE: If someone emails you offering services and you never responded, SKIP.**
|
||||
|
||||
It doesn't matter how personalized, detailed, or relevant the pitch seems. If:
|
||||
1. They initiated contact (you didn't reach out first)
|
||||
2. They're offering services/products
|
||||
3. You never replied or engaged
|
||||
|
||||
Then it's cold outreach and should be SKIPPED. Do NOT create notes for cold outreach senders or their organizations.
|
||||
|
||||
**EXCEPTION:** If they reference a prior real-world interaction, CREATE a note:
|
||||
- "Great meeting you at [conference/event]"
|
||||
- "Following up on our conversation at..."
|
||||
- "It was nice chatting at [place]"
|
||||
- "[Mutual contact] suggested I reach out after we met"
|
||||
|
||||
This indicates a real relationship that started offline, not cold outreach.
|
||||
|
||||
**Indicators:**
|
||||
- Unsolicited contact from someone you've never interacted with
|
||||
- Offering services you didn't request (HR, payroll, compliance, bookkeeping, recruiting, dev shops, marketing, etc.)
|
||||
- Sales-y language: "wanted to reach out", "thought this might help", "quick question about your..."
|
||||
- Mentions your company growth/funding/hiring/tech stack as a hook
|
||||
- Attaches "free guides", "case studies", "resources", or "frameworks"
|
||||
- Asks for a call/meeting without any prior relationship
|
||||
- From domains you've never contacted or met with before
|
||||
- No existing note for this person or organization
|
||||
- **No reply from the user in the email thread**
|
||||
|
||||
**Examples to SKIP:**
|
||||
- "Saw you raised funding, wanted to reach out about our services"
|
||||
- "Quick question about your bookkeeping/compliance/hiring"
|
||||
- "Shared this guide that might help with [your problem]"
|
||||
- "Noticed you're scaling, we help startups with..."
|
||||
- "Would love 15 minutes to show you how we can help"
|
||||
- Detailed pitch about HR/payroll/India expansion services (still cold outreach!)
|
||||
- Follow-up emails to previous cold outreach that got no response
|
||||
|
||||
**Key distinction:**
|
||||
- **You reaching out to a vendor** → worth tracking (you initiated)
|
||||
- **You replied to their outreach** → worth tracking (you engaged)
|
||||
- **Vendor cold emailing you with no response** → SKIP (no relationship exists)
|
||||
|
||||
**IMPORTANT: CC'd people on cold outreach**
|
||||
When an email is identified as cold outreach, skip notes for ALL parties involved:
|
||||
- The sender (the person doing the outreach)
|
||||
- Anyone CC'd on the email (colleagues of the sender, other contacts they're trying to connect)
|
||||
- The organization they represent
|
||||
|
||||
If someone only appears in your memory as "CC'd on outreach emails from [Sender]", they don't warrant a note — they're just incidentally included in cold outreach, not a real relationship.
|
||||
|
||||
**Action:** SKIP with reason "Cold outreach/sales email - no engagement from user"
|
||||
|
||||
### Automated/Transactional
|
||||
|
||||
**Indicators:**
|
||||
- From automated systems (notifications@, alerts@, no-reply@)
|
||||
- Password resets, login alerts, shipping notifications
|
||||
- Calendar invites without substance
|
||||
- Receipts and invoices (unless from key vendor/customer)
|
||||
- GitHub/Jira/Slack notifications
|
||||
|
||||
**Action:** SKIP with reason "Automated/transactional"
|
||||
|
||||
### Low-Signal
|
||||
|
||||
**Indicators:**
|
||||
- Very short with no substance ("Thanks!", "Sounds good", "Got it")
|
||||
- Only contains forwarded message with no commentary
|
||||
- Auto-replies ("I'm out of office")
|
||||
|
||||
**Action:** SKIP with reason "Low signal"
|
||||
|
||||
### Consumer Services (Medium strictness specific)
|
||||
|
||||
**Indicators:**
|
||||
- From consumer service companies (utilities, streaming, retail)
|
||||
- Account management emails
|
||||
- Subscription confirmations
|
||||
- Delivery notifications
|
||||
|
||||
**Action:** SKIP with reason "Consumer service"
|
||||
|
||||
### Infrastructure & SaaS Providers
|
||||
|
||||
**Skip emails from these types of services:**
|
||||
- Domain registrars: GoDaddy, Namecheap, Google Domains, Cloudflare
|
||||
- Hosting providers: AWS, Google Cloud, Azure, DigitalOcean, Heroku, Vercel, Netlify
|
||||
- Email providers: Google Workspace, Microsoft 365, Zoho
|
||||
- Payment processors: Stripe, PayPal, Square, Razorpay
|
||||
- Developer tools: GitHub, GitLab, Bitbucket, npm, Docker Hub
|
||||
- Analytics: Google Analytics, Mixpanel, Amplitude, Segment
|
||||
- Auth providers: Auth0, Okta, Firebase Auth
|
||||
- Support platforms: Zendesk, Intercom, Freshdesk
|
||||
- HR/Payroll: Gusto, Rippling, Deel, Remote
|
||||
|
||||
**Indicators:**
|
||||
- Automated system notifications (renewal reminders, usage alerts, security notices)
|
||||
- No personalized content from a human
|
||||
- From domains like @godaddy.com, @aws.amazon.com, @stripe.com, etc.
|
||||
- Templates about account status, billing, or technical alerts
|
||||
|
||||
**Action:** SKIP with reason "Infrastructure/SaaS provider notification"
|
||||
|
||||
## Email-Specific Processing (Medium Strictness)
|
||||
|
||||
For emails, evaluate if the content is personalized and business-relevant:
|
||||
|
||||
**Create note if:**
|
||||
- The email is personally addressed and substantive
|
||||
- The sender appears to be from a business/organization relevant to your work
|
||||
- The content discusses work, projects, opportunities, or professional topics
|
||||
- It's a warm intro from anyone (not just existing contacts)
|
||||
- It's a thoughtful cold outreach that's specific to your work
|
||||
|
||||
**Do not create note if:**
|
||||
- Clearly mass/templated email
|
||||
- Consumer service interaction
|
||||
- Generic sales pitch with no personalization
|
||||
${renderNoteEffectRules()}
|
||||
|
||||
## Filter Decision Output
|
||||
|
||||
If skipping:
|
||||
\`\`\`
|
||||
SKIP
|
||||
Reason: {reason}
|
||||
Reason: Labels indicate skip-only categories: {list the labels}
|
||||
\`\`\`
|
||||
|
||||
If processing, continue to Step 2.
|
||||
|
|
@ -552,16 +428,16 @@ Resolution Map:
|
|||
- "the integration" → "Acme Integration" (same project)
|
||||
\`\`\`
|
||||
|
||||
## 4b: Apply Source Type Rules (Medium Strictness)
|
||||
## 4b: Apply Source Type Rules
|
||||
|
||||
**If source_type == "meeting":**
|
||||
**If source_type == "meeting" or "voice_memo":**
|
||||
- Resolved entities → Update existing notes
|
||||
- New entities that pass filters → Create new notes
|
||||
|
||||
**If source_type == "email" (MEDIUM STRICTNESS):**
|
||||
**If source_type == "email":**
|
||||
- The email already passed label-based filtering in Step 1
|
||||
- Resolved entities → Update existing notes
|
||||
- New entities → Create notes IF the email is personalized and business-relevant
|
||||
- New entities from cold sales pitches without personalization → Skip
|
||||
- New entities → Create notes (the labels already confirmed this email is worth processing)
|
||||
|
||||
## 4c: Disambiguation Rules
|
||||
|
||||
|
|
@ -628,39 +504,23 @@ For entities not resolved to existing notes, determine if they warrant new notes
|
|||
|
||||
## People
|
||||
|
||||
### Who Gets a Note (Medium Strictness)
|
||||
### Who Gets a Note
|
||||
|
||||
**CREATE a note for people who are:**
|
||||
- External (not @user.domain)
|
||||
- Attendees in meetings
|
||||
- Email correspondents sending personalized, business-relevant content
|
||||
- Email correspondents (emails that reach this step already passed label-based filtering)
|
||||
- Decision makers or contacts at customers, prospects, or partners
|
||||
- Investors or potential investors
|
||||
- Candidates you are interviewing
|
||||
- Advisors or mentors
|
||||
- Key collaborators
|
||||
- Introducers who connect you to valuable contacts
|
||||
- Anyone reaching out with a specific, relevant opportunity
|
||||
|
||||
**DO NOT create notes for:**
|
||||
- Transactional service providers (bank employees, support reps)
|
||||
- One-time administrative contacts
|
||||
- Large group meeting attendees you didn't interact with
|
||||
- Internal colleagues (@user.domain)
|
||||
- Assistants handling only logistics
|
||||
- Generic role-based contacts
|
||||
- Consumer service representatives
|
||||
- Generic cold sales outreach with no personalization
|
||||
|
||||
### The Relevance Test (Medium Strictness)
|
||||
|
||||
Ask: Is this person relevant to my professional work or goals?
|
||||
|
||||
- Sarah Chen, VP Engineering evaluating your product → **Yes, create note**
|
||||
- James from HSBC who set up your account → **No, skip**
|
||||
- Investor reaching out about your company → **Yes, create note**
|
||||
- Cold recruiter with a generic pitch → **No, skip**
|
||||
- Someone reaching out about a specific opportunity → **Yes, create note**
|
||||
|
||||
### Role Inference
|
||||
|
||||
|
|
@ -830,6 +690,16 @@ One line summarizing this source's relevance to the entity:
|
|||
**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]}
|
||||
\`\`\`
|
||||
|
||||
**For meetings:** Include a link to the source meeting note. Derive the wiki-link path from the source file path (strip the \`.md\` extension):
|
||||
\`\`\`
|
||||
**2025-01-15** (meeting): Discussed [[Projects/Acme Integration]] timeline with [[People/David Kim]]. See [[Meetings/granola/abc123_Weekly Sync]]
|
||||
\`\`\`
|
||||
|
||||
**For emails:** Include a Gmail web link to the thread. Extract the thread ID from the \`**Thread ID:**\` field in the email source file, then construct the URL as \`https://mail.google.com/mail/#inbox/{threadId}\`:
|
||||
\`\`\`
|
||||
**2025-01-15** (email): [[People/Sarah Chen]] sent pricing proposal for [[Projects/Acme Integration]]. [View thread](https://mail.google.com/mail/#inbox/18d5a3b2c1e4f567)
|
||||
\`\`\`
|
||||
|
||||
**For voice memos:** Include a link to the voice memo file using the Path field:
|
||||
\`\`\`
|
||||
**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]]
|
||||
|
|
@ -837,11 +707,13 @@ One line summarizing this source's relevance to the entity:
|
|||
|
||||
**Important:** Use canonical names with absolute paths from resolution map in all summaries:
|
||||
\`\`\`
|
||||
# Correct (uses absolute paths):
|
||||
**2025-01-15** (meeting): [[People/Sarah Chen]] confirmed timeline with [[People/David Kim]]. Blocked on [[Topics/Security Compliance]].
|
||||
# Correct (uses absolute paths and source links):
|
||||
**2025-01-15** (meeting): [[People/Sarah Chen]] confirmed timeline with [[People/David Kim]]. Blocked on [[Topics/Security Compliance]]. See [[Meetings/fireflies/abc_Team Sync]]
|
||||
**2025-01-15** (email): [[People/Sarah Chen]] shared the contract draft. [View thread](https://mail.google.com/mail/#inbox/18d5a3b2c1e4f567)
|
||||
|
||||
# Incorrect (uses variants or relative links):
|
||||
# Incorrect (uses variants or relative links, missing source links):
|
||||
**2025-01-15** (meeting): Sarah confirmed timeline with David. Blocked on SOC 2.
|
||||
**2025-01-15** (email): Sarah shared the contract draft.
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
|
@ -1025,153 +897,28 @@ After writing, verify links go both ways.
|
|||
|
||||
---
|
||||
|
||||
# Note Templates
|
||||
|
||||
## People
|
||||
\`\`\`markdown
|
||||
# {Full Name}
|
||||
|
||||
## Info
|
||||
**Role:** {role, or inferred role with qualifier, or leave blank if truly unknown}
|
||||
**Organization:** [[Organizations/{organization}]] or leave blank
|
||||
**Email:** {email or leave blank}
|
||||
**Aliases:** {comma-separated: first name, nicknames, email}
|
||||
**First met:** {YYYY-MM-DD}
|
||||
**Last seen:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: Who they are, why you know them, what you're working on together.}
|
||||
|
||||
## Connected to
|
||||
- [[Organizations/{Organization}]] — works at
|
||||
- [[People/{Person}]] — {colleague, introduced by, reports to}
|
||||
- [[Projects/{Project}]] — {role}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
\`\`\`
|
||||
|
||||
## Organizations
|
||||
\`\`\`markdown
|
||||
# {Organization Name}
|
||||
|
||||
## Info
|
||||
**Type:** {company|team|institution|other}
|
||||
**Industry:** {industry or leave blank}
|
||||
**Relationship:** {customer|prospect|partner|competitor|vendor|other}
|
||||
**Domain:** {primary email domain}
|
||||
**Aliases:** {comma-separated: short names, abbreviations}
|
||||
**First met:** {YYYY-MM-DD}
|
||||
**Last seen:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: What this org is, what your relationship is.}
|
||||
|
||||
## People
|
||||
- [[People/{Person}]] — {role}
|
||||
|
||||
## Contacts
|
||||
{For transactional contacts who don't get their own notes}
|
||||
|
||||
## Projects
|
||||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
\`\`\`
|
||||
|
||||
## Projects
|
||||
\`\`\`markdown
|
||||
# {Project Name}
|
||||
|
||||
## Info
|
||||
**Type:** {deal|product|initiative|hiring|other}
|
||||
**Status:** {active|planning|on hold|completed|cancelled}
|
||||
**Started:** {YYYY-MM-DD or leave blank}
|
||||
**Last activity:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: What this project is, goal, current state.}
|
||||
|
||||
## People
|
||||
- [[People/{Person}]] — {role}
|
||||
|
||||
## Organizations
|
||||
- [[Organizations/{Org}]] — {customer|partner|etc.}
|
||||
|
||||
## Related
|
||||
- [[Topics/{Topic}]] — {relationship}
|
||||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Timeline
|
||||
**{YYYY-MM-DD}** ({meeting|email})
|
||||
{What happened.}
|
||||
|
||||
## Decisions
|
||||
- **{YYYY-MM-DD}**: {Decision}. {Rationale}.
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
\`\`\`
|
||||
|
||||
## Topics
|
||||
\`\`\`markdown
|
||||
# {Topic Name}
|
||||
|
||||
## About
|
||||
{1-2 sentences: What this topic covers.}
|
||||
|
||||
**Keywords:** {comma-separated}
|
||||
**Aliases:** {other ways this topic is referenced}
|
||||
**First mentioned:** {YYYY-MM-DD}
|
||||
**Last mentioned:** {YYYY-MM-DD}
|
||||
|
||||
## Related
|
||||
- [[People/{Person}]] — {relationship}
|
||||
- [[Organizations/{Org}]] — {relationship}
|
||||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Log
|
||||
**{YYYY-MM-DD}** ({meeting|email}: {title})
|
||||
{Summary with [[Folder/Name]] links}
|
||||
|
||||
## Decisions
|
||||
- **{YYYY-MM-DD}**: {Decision}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
\`\`\`
|
||||
${renderNoteTypesBlock()}
|
||||
|
||||
---
|
||||
|
||||
# Summary: Medium Strictness Rules
|
||||
# Summary: Label-Based Rules
|
||||
|
||||
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|
||||
|-------------|---------------|----------------|------------------------|
|
||||
| Meeting | Yes | Yes | Yes |
|
||||
| Voice memo | Yes | Yes | Yes |
|
||||
| Email (personalized, business-relevant) | Yes | Yes | Yes |
|
||||
| Email (mass/automated/consumer) | No (SKIP) | No | No |
|
||||
| Email (cold outreach with personalization) | Yes | Yes | Yes |
|
||||
| Email (generic cold outreach) | No | No | No |
|
||||
| Email (has create label) | Yes | Yes | Yes |
|
||||
| Email (only skip labels) | No (SKIP) | No | No |
|
||||
|
||||
**Meeting activity format:** Always include a link to the source meeting note:
|
||||
\`\`\`
|
||||
**2025-01-15** (meeting): Discussed project timeline with [[People/Sarah Chen]]. See [[Meetings/granola/abc123_Weekly Sync]]
|
||||
\`\`\`
|
||||
|
||||
**Email activity format:** Always include a Gmail web link using the Thread ID from the source:
|
||||
\`\`\`
|
||||
**2025-01-15** (email): [[People/Sarah Chen]] sent pricing proposal. [View thread](https://mail.google.com/mail/#inbox/18d5a3b2c1e4f567)
|
||||
\`\`\`
|
||||
|
||||
**Voice memo activity format:** Always include a link to the source voice memo:
|
||||
\`\`\`
|
||||
|
|
@ -1198,7 +945,7 @@ Before completing, verify:
|
|||
|
||||
**Source Type:**
|
||||
- [ ] Correctly identified as meeting or email
|
||||
- [ ] Applied correct medium strictness rules
|
||||
- [ ] Applied label-based filtering rules correctly
|
||||
|
||||
**Resolution:**
|
||||
- [ ] Extracted all name variants from source
|
||||
|
|
@ -1233,4 +980,5 @@ Before completing, verify:
|
|||
- [ ] Dates are YYYY-MM-DD
|
||||
- [ ] Bidirectional links are consistent
|
||||
- [ ] New notes in correct folders
|
||||
`;
|
||||
`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,874 +0,0 @@
|
|||
export const raw = `---
|
||||
model: gpt-5.2
|
||||
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
|
||||
workspace-grep:
|
||||
type: builtin
|
||||
name: workspace-grep
|
||||
workspace-glob:
|
||||
type: builtin
|
||||
name: workspace-glob
|
||||
---
|
||||
# Task
|
||||
|
||||
You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), you will:
|
||||
|
||||
1. **Determine source type (meeting or email)**
|
||||
2. **Evaluate if the source is worth processing**
|
||||
3. **Search for all existing related notes**
|
||||
4. **Resolve entities to canonical names**
|
||||
5. Identify new entities worth tracking
|
||||
6. Extract structured information (decisions, commitments, key facts)
|
||||
7. **Detect state changes (status updates, resolved items, role changes)**
|
||||
8. Create new notes or update existing notes
|
||||
9. **Apply state changes to existing notes**
|
||||
|
||||
The core rule: **Capture broadly. Meetings, voice memos, and emails create notes for most external contacts.**
|
||||
|
||||
You have full read access to the existing knowledge directory. Use this extensively to:
|
||||
- Find existing notes for people, organizations, projects mentioned
|
||||
- Resolve ambiguous names (find existing note for "David")
|
||||
- Understand existing relationships before updating
|
||||
- Avoid creating duplicate notes
|
||||
- Maintain consistency with existing content
|
||||
- **Detect when new information changes the state of existing notes**
|
||||
|
||||
# Inputs
|
||||
|
||||
1. **source_file**: Path to a single file to process (email or meeting transcript)
|
||||
2. **knowledge_folder**: Path to Obsidian vault (read/write access)
|
||||
3. **user**: Information about the owner of this memory
|
||||
- name: e.g., "Arj"
|
||||
- email: e.g., "arj@rowboat.com"
|
||||
- domain: e.g., "rowboat.com"
|
||||
4. **knowledge_index**: A pre-built index of all existing notes (provided in the message)
|
||||
|
||||
# Knowledge Base Index
|
||||
|
||||
**IMPORTANT:** You will receive a pre-built index of all existing notes at the start of each request. This index contains:
|
||||
- All people notes with their names, emails, aliases, and organizations
|
||||
- All organization notes with their names, domains, and aliases
|
||||
- All project notes with their names and statuses
|
||||
- All topic notes with their names and keywords
|
||||
|
||||
**USE THE INDEX for entity resolution instead of grep/search commands.** This is much faster.
|
||||
|
||||
When you need to:
|
||||
- Check if a person exists → Look up by name/email/alias in the index
|
||||
- Find an organization → Look up by name/domain in the index
|
||||
- Resolve "David" to a full name → Check index for people with that name/alias + organization context
|
||||
|
||||
**Only use \`cat\` to read full note content** when you need details not in the index (e.g., existing activity logs, open items).
|
||||
|
||||
# Tools Available
|
||||
|
||||
You have access to these tools:
|
||||
|
||||
**For reading files:**
|
||||
\`\`\`
|
||||
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
**For creating NEW files:**
|
||||
\`\`\`
|
||||
workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
|
||||
\`\`\`
|
||||
|
||||
**For editing EXISTING files (preferred for updates):**
|
||||
\`\`\`
|
||||
workspace-edit({
|
||||
path: "knowledge/People/Sarah Chen.md",
|
||||
oldString: "## Activity\\n",
|
||||
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**For listing directories:**
|
||||
\`\`\`
|
||||
workspace-readdir({ path: "knowledge/People" })
|
||||
\`\`\`
|
||||
|
||||
**For creating directories:**
|
||||
\`\`\`
|
||||
workspace-mkdir({ path: "knowledge/Projects", recursive: true })
|
||||
\`\`\`
|
||||
|
||||
**For searching files:**
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
|
||||
\`\`\`
|
||||
|
||||
**For finding files by pattern:**
|
||||
\`\`\`
|
||||
workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
|
||||
\`\`\`
|
||||
|
||||
**IMPORTANT:**
|
||||
- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields)
|
||||
- Use \`workspace-writeFile\` only for creating new notes
|
||||
- Prefer the knowledge_index for entity resolution (it's faster than grep)
|
||||
|
||||
# Output
|
||||
|
||||
Either:
|
||||
- **SKIP** with reason, if source should be ignored
|
||||
- Updated or new markdown files in notes_folder
|
||||
|
||||
---
|
||||
|
||||
# The Core Rule: Low Strictness - Capture Broadly
|
||||
|
||||
**LOW STRICTNESS MODE**
|
||||
|
||||
This mode prioritizes comprehensive capture over selectivity. The goal is to never miss a potentially important contact.
|
||||
|
||||
**Meetings create notes for:**
|
||||
- All external attendees (anyone not @user.domain)
|
||||
|
||||
**Emails create notes for:**
|
||||
- Any personalized email from an identifiable sender
|
||||
- Anyone who reaches out directly
|
||||
- Any external contact who communicates with you
|
||||
|
||||
**Only skip:**
|
||||
- Obvious automated/system emails (no human sender)
|
||||
- Mass newsletters with unsubscribe links
|
||||
- Truly anonymous or unidentifiable senders
|
||||
|
||||
**Philosophy:** It's better to have a note you don't need than to miss tracking someone important.
|
||||
|
||||
---
|
||||
|
||||
# Step 0: Determine Source Type
|
||||
|
||||
Read the source file and determine if it's a meeting or email.
|
||||
\`\`\`
|
||||
workspace-readFile({ path: "{source_file}" })
|
||||
\`\`\`
|
||||
|
||||
**Meeting indicators:**
|
||||
- Has \`Attendees:\` field
|
||||
- Has \`Meeting:\` title
|
||||
- Transcript format with speaker labels
|
||||
|
||||
**Email indicators:**
|
||||
- Has \`From:\` and \`To:\` fields
|
||||
- Has \`Subject:\` field
|
||||
- Email signature
|
||||
|
||||
**Voice memo indicators:**
|
||||
- Has \`**Type:** voice memo\` field
|
||||
- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\`
|
||||
- Has \`## Transcript\` section
|
||||
|
||||
**Set processing mode:**
|
||||
- \`source_type = "meeting"\` → Create notes for all external attendees
|
||||
- \`source_type = "email"\` → Create notes for sender if identifiable human
|
||||
- \`source_type = "voice_memo"\` → Create notes for all mentioned entities (treat like meetings)
|
||||
|
||||
---
|
||||
|
||||
## Calendar Invite Emails
|
||||
|
||||
Emails containing calendar invites (\`.ics\` attachments) are **high signal** - a scheduled meeting means this person matters.
|
||||
|
||||
**How to identify:**
|
||||
- Subject contains "Invitation:", "Accepted:", "Declined:", or "Updated:"
|
||||
- Has \`.ics\` attachment reference
|
||||
|
||||
**Rules:**
|
||||
1. **CREATE a note for the primary contact** - the person you're meeting with
|
||||
2. **Skip automated notifications** - from calendar-no-reply@google.com with no human sender
|
||||
3. **Skip "Accepted/Declined" responses** - just RSVP confirmations
|
||||
|
||||
Once a note exists, subsequent emails will enrich it. When the meeting happens, the transcript adds more detail.
|
||||
|
||||
---
|
||||
|
||||
# Step 1: Source Filtering (Minimal)
|
||||
|
||||
## Skip Only These Sources
|
||||
|
||||
### Mass Newsletters
|
||||
|
||||
**Indicators (must have MULTIPLE of these):**
|
||||
- Unsubscribe link in body or footer
|
||||
- From a marketing address (noreply@, newsletter@, marketing@)
|
||||
- Sent to multiple recipients or undisclosed-recipients
|
||||
- Sent via marketing platforms (via sendgrid, via mailchimp, etc.)
|
||||
|
||||
**Action:** SKIP with reason "Mass newsletter"
|
||||
|
||||
### Purely Automated (No Human Sender)
|
||||
|
||||
**Indicators:**
|
||||
- From automated systems with no human behind them (alerts@, notifications@)
|
||||
- Password resets, login alerts
|
||||
- System notifications (GitHub automated, CI/CD alerts)
|
||||
- Receipt confirmations with no human contact info
|
||||
|
||||
**Action:** SKIP with reason "Automated system message"
|
||||
|
||||
### Truly Low-Signal
|
||||
|
||||
**Indicators (must be clearly content-free):**
|
||||
- Body is ONLY "Thanks!", "Got it", "OK" with nothing else
|
||||
- Auto-replies ("I'm out of office") with no human context
|
||||
|
||||
**Action:** SKIP with reason "No substantive content"
|
||||
|
||||
## Process Everything Else
|
||||
|
||||
**Important:** When in doubt, PROCESS. In low strictness mode, we err on the side of capturing more.
|
||||
|
||||
If skipping:
|
||||
\`\`\`
|
||||
SKIP
|
||||
Reason: {reason}
|
||||
\`\`\`
|
||||
|
||||
If processing, continue to Step 2.
|
||||
|
||||
---
|
||||
|
||||
# Step 2: Read and Parse Source File
|
||||
\`\`\`
|
||||
workspace-readFile({ path: "{source_file}" })
|
||||
\`\`\`
|
||||
|
||||
Extract metadata:
|
||||
|
||||
**For meetings:**
|
||||
- **Date:** From header or filename
|
||||
- **Title:** Meeting name
|
||||
- **Attendees:** List of participants
|
||||
- **Duration:** If available
|
||||
|
||||
**For emails:**
|
||||
- **Date:** From \`Date:\` header
|
||||
- **Subject:** From \`Subject:\` header
|
||||
- **From:** Sender email/name
|
||||
- **To/Cc:** Recipients
|
||||
|
||||
## 2a: Exclude Self
|
||||
|
||||
Never create or update notes for:
|
||||
- The user (matches user.name, user.email, or @user.domain)
|
||||
- Anyone @{user.domain} (colleagues at user's company)
|
||||
|
||||
Filter these out from attendees/participants before proceeding.
|
||||
|
||||
## 2b: Extract All Name Variants
|
||||
|
||||
From the source, collect every way entities are referenced:
|
||||
|
||||
**People variants:**
|
||||
- Full names: "Sarah Chen"
|
||||
- First names only: "Sarah"
|
||||
- Last names only: "Chen"
|
||||
- Initials: "S. Chen"
|
||||
- Email addresses: "sarah@acme.com"
|
||||
- Roles/titles: "their CTO", "the VP of Engineering"
|
||||
|
||||
**Organization variants:**
|
||||
- Full names: "Acme Corporation"
|
||||
- Short names: "Acme"
|
||||
- Abbreviations: "AC"
|
||||
- Email domains: "@acme.com"
|
||||
|
||||
**Project variants:**
|
||||
- Explicit names: "Project Atlas"
|
||||
- Descriptive references: "the integration", "the pilot", "the deal"
|
||||
|
||||
Create a list of all variants found.
|
||||
|
||||
---
|
||||
|
||||
# Step 3: Look Up Existing Notes in Index
|
||||
|
||||
**Use the provided knowledge_index to find existing notes. Do NOT use grep commands.**
|
||||
|
||||
## 3a: Look Up People
|
||||
|
||||
For each person variant (name, email, alias), check the index:
|
||||
|
||||
\`\`\`
|
||||
From index, find matches for:
|
||||
- "Sarah Chen" → Check People table for matching name
|
||||
- "Sarah" → Check People table for matching name or alias
|
||||
- "sarah@acme.com" → Check People table for matching email
|
||||
- "@acme.com" → Check People table for matching organization or check Organizations for domain
|
||||
\`\`\`
|
||||
|
||||
## 3b: Look Up Organizations
|
||||
|
||||
\`\`\`
|
||||
From index, find matches for:
|
||||
- "Acme Corp" → Check Organizations table for matching name
|
||||
- "Acme" → Check Organizations table for matching name or alias
|
||||
- "acme.com" → Check Organizations table for matching domain
|
||||
\`\`\`
|
||||
|
||||
## 3c: Look Up Projects and Topics
|
||||
|
||||
\`\`\`
|
||||
From index, find matches for:
|
||||
- "the pilot" → Check Projects table for related names
|
||||
- "SOC 2" → Check Topics table for matching keywords
|
||||
\`\`\`
|
||||
|
||||
## 3d: Read Full Notes When Needed
|
||||
|
||||
Only read the full note content when you need details not in the index (e.g., activity logs, open items):
|
||||
\`\`\`
|
||||
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
**Why read these notes:**
|
||||
- Find canonical names (David → David Kim)
|
||||
- Check Aliases fields for known variants
|
||||
- Understand existing relationships
|
||||
- See organization context for disambiguation
|
||||
- Check what's already captured (avoid duplicates)
|
||||
- Review open items (some might be resolved)
|
||||
- **Check current status fields (might need updating)**
|
||||
- **Check current roles (might have changed)**
|
||||
|
||||
## 3e: Matching Criteria
|
||||
|
||||
Use these criteria to determine if a variant matches an existing note:
|
||||
|
||||
**People matching:**
|
||||
|
||||
| Source has | Note has | Match if |
|
||||
|------------|----------|----------|
|
||||
| First name "Sarah" | Full name "Sarah Chen" | Same organization context |
|
||||
| Email "sarah@acme.com" | Email field | Exact match |
|
||||
| Email domain "@acme.com" | Organization "Acme Corp" | Domain matches org |
|
||||
| Role "VP Engineering" | Role field | Same org + same role |
|
||||
| First name + company context | Full name + Organization | Company matches |
|
||||
| Any variant | Aliases field | Listed in aliases |
|
||||
|
||||
**Organization matching:**
|
||||
|
||||
| Source has | Note has | Match if |
|
||||
|------------|----------|----------|
|
||||
| "Acme" | "Acme Corp" | Substring match |
|
||||
| "Acme Corporation" | "Acme Corp" | Same root name |
|
||||
| "@acme.com" | Domain field | Domain matches |
|
||||
| Any variant | Aliases field | Listed in aliases |
|
||||
|
||||
**Project matching:**
|
||||
|
||||
| Source has | Note has | Match if |
|
||||
|------------|----------|----------|
|
||||
| "the pilot" | "Acme Pilot" | Same org context in source |
|
||||
| "integration project" | "Acme Integration" | Same org + similar type |
|
||||
| "Series A" | "Series A Fundraise" | Unique identifier match |
|
||||
|
||||
---
|
||||
|
||||
# Step 4: Resolve Entities to Canonical Names
|
||||
|
||||
Using the search results from Step 3, resolve each variant to a canonical name.
|
||||
|
||||
## 4a: Build Resolution Map
|
||||
|
||||
Create a mapping from every source reference to its canonical form.
|
||||
|
||||
## 4b: Apply Source Type Rules (Low Strictness)
|
||||
|
||||
**If source_type == "meeting":**
|
||||
- Resolved entities → Update existing notes
|
||||
- New entities → Create new notes for ALL external attendees
|
||||
|
||||
**If source_type == "email" (LOW STRICTNESS):**
|
||||
- Resolved entities → Update existing notes
|
||||
- New entities → Create notes for the sender and any mentioned contacts
|
||||
|
||||
## 4c: Disambiguation Rules
|
||||
|
||||
When multiple candidates match a variant, disambiguate by:
|
||||
1. Email match (definitive)
|
||||
2. Organization context (strong signal)
|
||||
3. Role match
|
||||
4. Recency (tiebreaker)
|
||||
|
||||
## 4d: Resolution Map Output
|
||||
|
||||
Final resolution map before proceeding:
|
||||
\`\`\`
|
||||
RESOLVED (use canonical name with absolute path):
|
||||
- "Sarah", "Sarah Chen", "sarah@acme.com" → [[People/Sarah Chen]]
|
||||
|
||||
NEW ENTITIES (create notes):
|
||||
- "Jennifer" (CTO, Acme Corp) → Create [[People/Jennifer]]
|
||||
|
||||
AMBIGUOUS (create with disambiguation note):
|
||||
- "Mike" (no context) → Create [[People/Mike]] with note about ambiguity
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
# Step 5: Identify New Entities (Low Strictness - Capture Broadly)
|
||||
|
||||
For entities not resolved to existing notes, create notes for most of them.
|
||||
|
||||
## People
|
||||
|
||||
### Who Gets a Note (Low Strictness)
|
||||
|
||||
**CREATE a note for:**
|
||||
- ALL external meeting attendees (not @user.domain)
|
||||
- ALL email senders with identifiable names/emails
|
||||
- Anyone CC'd on emails who seems relevant
|
||||
- Anyone mentioned by name in conversations
|
||||
- Cold outreach senders (even if unsolicited)
|
||||
- Sales reps, recruiters, service providers
|
||||
- Anyone who might be useful to remember later
|
||||
|
||||
**DO NOT create notes for:**
|
||||
- Internal colleagues (@user.domain)
|
||||
- Truly anonymous/unidentifiable senders
|
||||
- System-generated sender names with no human behind them
|
||||
|
||||
### The Low Strictness Test
|
||||
|
||||
Ask: Could this person ever be useful to remember?
|
||||
|
||||
- Sarah Chen, VP Engineering → **Yes, create note**
|
||||
- James from HSBC → **Yes, create note** (might need banking help again)
|
||||
- Random recruiter → **Yes, create note** (might want to contact later)
|
||||
- Cold sales person → **Yes, create note** (might be relevant someday)
|
||||
- Support rep → **Yes, create note** (might need them again)
|
||||
|
||||
### Role Inference
|
||||
|
||||
If role is not explicitly stated, infer from context. Write "Unknown" only if truly impossible to infer anything.
|
||||
|
||||
### Relationship Type Guide (Low Strictness)
|
||||
|
||||
| Relationship Type | Create People Notes? | Create Org Note? |
|
||||
|-------------------|----------------------|------------------|
|
||||
| Customer | Yes — all contacts | Yes |
|
||||
| Prospect | Yes — all contacts | Yes |
|
||||
| Investor | Yes | Yes |
|
||||
| Partner | Yes — all contacts | Yes |
|
||||
| Vendor | Yes — all contacts | Yes |
|
||||
| Bank/Financial | Yes | Yes |
|
||||
| Candidate | Yes | No |
|
||||
| Recruiter | Yes | Optional |
|
||||
| Service provider | Yes | Optional |
|
||||
| Cold outreach | Yes | Optional |
|
||||
| Support interaction | Yes | Optional |
|
||||
|
||||
## Organizations
|
||||
|
||||
**CREATE a note if:**
|
||||
- Anyone from that org is mentioned or contacted you
|
||||
- The org is mentioned in any context
|
||||
|
||||
**Only skip:**
|
||||
- Organizations you genuinely can't identify
|
||||
|
||||
## Projects
|
||||
|
||||
**CREATE a note if:**
|
||||
- Discussed in meeting or email
|
||||
- Any indication of ongoing work or collaboration
|
||||
|
||||
## Topics
|
||||
|
||||
**CREATE a note if:**
|
||||
- Mentioned more than once
|
||||
- Seems like a recurring theme
|
||||
|
||||
---
|
||||
|
||||
# Step 6: Extract Content
|
||||
|
||||
For each entity that has or will have a note, extract relevant content.
|
||||
|
||||
## Decisions
|
||||
|
||||
Extract what was decided, when, by whom, and why.
|
||||
|
||||
## Commitments
|
||||
|
||||
Extract who committed to what, and any deadlines.
|
||||
|
||||
## Key Facts
|
||||
|
||||
Key facts should be **substantive information** — not commentary about missing data.
|
||||
|
||||
**Extract if:**
|
||||
- Specific numbers, dates, or metrics
|
||||
- Preferences or working style
|
||||
- Background information
|
||||
- Authority or decision process
|
||||
- Concerns or constraints
|
||||
- What they're working on or interested in
|
||||
|
||||
**Never include:**
|
||||
- Meta-commentary about missing data
|
||||
- Obvious facts already in Info section
|
||||
- Placeholder text
|
||||
|
||||
**If there are no substantive key facts, leave the section empty.**
|
||||
|
||||
## Open Items
|
||||
|
||||
**Include:**
|
||||
- Commitments made
|
||||
- Requests received
|
||||
- Next steps discussed
|
||||
- Follow-ups agreed
|
||||
|
||||
**Never include:**
|
||||
- Data gaps or research tasks
|
||||
- Wishes or hypotheticals
|
||||
|
||||
## Summary
|
||||
|
||||
The summary should answer: **"Who is this person and why do I know them?"**
|
||||
|
||||
Write 2-3 sentences covering their role/function, context of the relationship, and what you're discussing.
|
||||
|
||||
## Activity Summary
|
||||
|
||||
One line summarizing this source's relevance to the entity:
|
||||
\`\`\`
|
||||
**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]}
|
||||
\`\`\`
|
||||
|
||||
**For voice memos:** Include a link to the voice memo file using the Path field:
|
||||
\`\`\`
|
||||
**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]]
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
# Step 7: Detect State Changes
|
||||
|
||||
Review the extracted content for signals that existing note fields should be updated.
|
||||
|
||||
## 7a: Project Status Changes
|
||||
|
||||
Look for signals like "approved", "on hold", "cancelled", "completed", etc.
|
||||
|
||||
## 7b: Open Item Resolution
|
||||
|
||||
Look for signals that tracked items are now complete.
|
||||
|
||||
## 7c: Role/Title Changes
|
||||
|
||||
Look for new titles in signatures or explicit announcements.
|
||||
|
||||
## 7d: Organization/Relationship Changes
|
||||
|
||||
Look for company changes, partnership announcements, etc.
|
||||
|
||||
## 7e: Build State Change List
|
||||
|
||||
Compile all detected state changes before writing.
|
||||
|
||||
---
|
||||
|
||||
# Step 8: Check for Duplicates and Conflicts
|
||||
|
||||
Before writing:
|
||||
- Check if already processed this source
|
||||
- Skip duplicate key facts
|
||||
- Handle conflicting information by noting both versions
|
||||
|
||||
---
|
||||
|
||||
# Step 9: Write Updates
|
||||
|
||||
## 9a: Create and Update Notes
|
||||
|
||||
**IMPORTANT: Write sequentially, one file at a time.**
|
||||
- Generate content for exactly one note.
|
||||
- Issue exactly one write/edit command.
|
||||
- Wait for the tool to return before generating the next note.
|
||||
- Do NOT batch multiple write commands in a single response.
|
||||
|
||||
**For NEW entities (use workspace-writeFile):**
|
||||
\`\`\`
|
||||
workspace-writeFile({
|
||||
path: "{knowledge_folder}/People/Jennifer.md",
|
||||
data: "# Jennifer\\n\\n## Summary\\n..."
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**For EXISTING entities (use workspace-edit):**
|
||||
- Read current content first with workspace-readFile
|
||||
- Use workspace-edit to add activity entry at TOP (reverse chronological)
|
||||
- Update fields using targeted edits
|
||||
\`\`\`
|
||||
workspace-edit({
|
||||
path: "{knowledge_folder}/People/Sarah Chen.md",
|
||||
oldString: "## Activity\\n",
|
||||
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
## 9b: Apply State Changes
|
||||
|
||||
Update all fields identified in Step 7.
|
||||
|
||||
## 9c: Update Aliases
|
||||
|
||||
Add newly discovered name variants to Aliases field.
|
||||
|
||||
## 9d: Writing Rules
|
||||
|
||||
- **Always use absolute paths** with format \`[[Folder/Name]]\` for all links
|
||||
- Use YYYY-MM-DD format for dates
|
||||
- Be concise: one line per activity entry
|
||||
- Escape quotes properly in shell commands
|
||||
- Write only one file per response (no multi-file write batches)
|
||||
|
||||
---
|
||||
|
||||
# Step 10: Ensure Bidirectional Links
|
||||
|
||||
After writing, verify links go both ways.
|
||||
|
||||
## Absolute Link Format
|
||||
|
||||
**IMPORTANT:** Always use absolute links:
|
||||
\`\`\`markdown
|
||||
[[People/Sarah Chen]]
|
||||
[[Organizations/Acme Corp]]
|
||||
[[Projects/Acme Integration]]
|
||||
[[Topics/Security Compliance]]
|
||||
\`\`\`
|
||||
|
||||
## Bidirectional Link Rules
|
||||
|
||||
| If you add... | Then also add... |
|
||||
|---------------|------------------|
|
||||
| Person → Organization | Organization → Person |
|
||||
| Person → Project | Project → Person |
|
||||
| Project → Organization | Organization → Project |
|
||||
| Project → Topic | Topic → Project |
|
||||
| Person → Person | Person → Person (reverse) |
|
||||
|
||||
---
|
||||
|
||||
# Note Templates
|
||||
|
||||
## People
|
||||
\`\`\`markdown
|
||||
# {Full Name}
|
||||
|
||||
## Info
|
||||
**Role:** {role, inferred role, or Unknown}
|
||||
**Organization:** [[Organizations/{organization}]] or leave blank
|
||||
**Email:** {email or leave blank}
|
||||
**Aliases:** {comma-separated: first name, nicknames, email}
|
||||
**First met:** {YYYY-MM-DD}
|
||||
**Last seen:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: Who they are, why you know them.}
|
||||
|
||||
## Connected to
|
||||
- [[Organizations/{Organization}]] — works at
|
||||
- [[People/{Person}]] — {relationship}
|
||||
- [[Projects/{Project}]] — {role}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
\`\`\`
|
||||
|
||||
## Organizations
|
||||
\`\`\`markdown
|
||||
# {Organization Name}
|
||||
|
||||
## Info
|
||||
**Type:** {company|team|institution|other}
|
||||
**Industry:** {industry or leave blank}
|
||||
**Relationship:** {customer|prospect|partner|competitor|vendor|other}
|
||||
**Domain:** {primary email domain}
|
||||
**Aliases:** {short names, abbreviations}
|
||||
**First met:** {YYYY-MM-DD}
|
||||
**Last seen:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: What this org is, what your relationship is.}
|
||||
|
||||
## People
|
||||
- [[People/{Person}]] — {role}
|
||||
|
||||
## Contacts
|
||||
{For contacts who have their own notes}
|
||||
|
||||
## Projects
|
||||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
\`\`\`
|
||||
|
||||
## Projects
|
||||
\`\`\`markdown
|
||||
# {Project Name}
|
||||
|
||||
## Info
|
||||
**Type:** {deal|product|initiative|hiring|other}
|
||||
**Status:** {active|planning|on hold|completed|cancelled}
|
||||
**Started:** {YYYY-MM-DD or leave blank}
|
||||
**Last activity:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: What this project is, goal, current state.}
|
||||
|
||||
## People
|
||||
- [[People/{Person}]] — {role}
|
||||
|
||||
## Organizations
|
||||
- [[Organizations/{Org}]] — {relationship}
|
||||
|
||||
## Related
|
||||
- [[Topics/{Topic}]] — {relationship}
|
||||
|
||||
## Timeline
|
||||
**{YYYY-MM-DD}** ({meeting|email|voice memo})
|
||||
{What happened.}
|
||||
|
||||
## Decisions
|
||||
- **{YYYY-MM-DD}**: {Decision}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only.}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only.}
|
||||
\`\`\`
|
||||
|
||||
## Topics
|
||||
\`\`\`markdown
|
||||
# {Topic Name}
|
||||
|
||||
## About
|
||||
{1-2 sentences: What this topic covers.}
|
||||
|
||||
**Keywords:** {comma-separated}
|
||||
**Aliases:** {other references}
|
||||
**First mentioned:** {YYYY-MM-DD}
|
||||
**Last mentioned:** {YYYY-MM-DD}
|
||||
|
||||
## Related
|
||||
- [[People/{Person}]] — {relationship}
|
||||
- [[Organizations/{Org}]] — {relationship}
|
||||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Log
|
||||
**{YYYY-MM-DD}** ({meeting|email}: {title})
|
||||
{Summary}
|
||||
|
||||
## Decisions
|
||||
- **{YYYY-MM-DD}**: {Decision}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only.}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only.}
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
# Summary: Low Strictness Rules
|
||||
|
||||
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|
||||
|-------------|---------------|----------------|------------------------|
|
||||
| Meeting | Yes — ALL external attendees | Yes | Yes |
|
||||
| Voice memo | Yes — all mentioned entities | Yes | Yes |
|
||||
| Email (any human sender) | Yes | Yes | Yes |
|
||||
| Email (automated/newsletter) | No (SKIP) | No | No |
|
||||
|
||||
**Voice memo activity format:** Always include a link to the source voice memo:
|
||||
\`\`\`
|
||||
**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]]
|
||||
\`\`\`
|
||||
|
||||
**Philosophy:** Capture broadly, filter later if needed.
|
||||
|
||||
---
|
||||
|
||||
# Error Handling
|
||||
|
||||
1. **Missing data:** Leave blank or write "Unknown"
|
||||
2. **Ambiguous names:** Create note with disambiguation note
|
||||
3. **Conflicting info:** Note both versions
|
||||
4. **grep returns nothing:** Create new notes
|
||||
5. **State change unclear:** Log in activity but don't change the field
|
||||
6. **Note file malformed:** Log warning, attempt partial update
|
||||
7. **Shell command fails:** Log error, continue
|
||||
|
||||
---
|
||||
|
||||
# Quality Checklist
|
||||
|
||||
Before completing, verify:
|
||||
|
||||
**Source Type:**
|
||||
- [ ] Correctly identified as meeting or email
|
||||
- [ ] Applied low strictness rules (capture broadly)
|
||||
|
||||
**Resolution:**
|
||||
- [ ] Extracted all name variants
|
||||
- [ ] Searched existing notes
|
||||
- [ ] Built resolution map
|
||||
- [ ] Used absolute paths \`[[Folder/Name]]\`
|
||||
|
||||
**Filtering:**
|
||||
- [ ] Excluded only self and @user.domain
|
||||
- [ ] Created notes for all external contacts
|
||||
- [ ] Only skipped obvious automated/newsletters
|
||||
|
||||
**Content Quality:**
|
||||
- [ ] Summaries describe relationship
|
||||
- [ ] Roles inferred where possible
|
||||
- [ ] Key facts are substantive
|
||||
- [ ] Open items are commitments/next steps
|
||||
|
||||
**State Changes:**
|
||||
- [ ] Detected and applied state changes
|
||||
- [ ] Logged changes in activity
|
||||
|
||||
**Structure:**
|
||||
- [ ] All links use \`[[Folder/Name]]\` format
|
||||
- [ ] Activity entries reverse chronological
|
||||
- [ ] Dates are YYYY-MM-DD
|
||||
- [ ] Bidirectional links consistent
|
||||
`;
|
||||
209
apps/x/packages/core/src/knowledge/note_system.ts
Normal file
209
apps/x/packages/core/src/knowledge/note_system.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
|
||||
export interface NoteTypeDefinition {
|
||||
type: string;
|
||||
folder: string;
|
||||
template: string;
|
||||
extractionGuide: string;
|
||||
}
|
||||
|
||||
// ── Default definitions (used to seed ~/.rowboat/config/notes.json) ──────────
|
||||
|
||||
const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
|
||||
{
|
||||
type: "People",
|
||||
folder: "People",
|
||||
template: `# {Full Name}
|
||||
|
||||
## Info
|
||||
**Role:** {role, or inferred role with qualifier, or leave blank if truly unknown}
|
||||
**Organization:** [[Organizations/{organization}]] or leave blank
|
||||
**Email:** {email or leave blank}
|
||||
**Aliases:** {comma-separated: first name, nicknames, email}
|
||||
**First met:** {YYYY-MM-DD}
|
||||
**Last seen:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: Who they are, why you know them, what you're working on together.}
|
||||
|
||||
## Connected to
|
||||
- [[Organizations/{Organization}]] — works at
|
||||
- [[People/{Person}]] — {colleague, introduced by, reports to}
|
||||
- [[Projects/{Project}]] — {role}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}`,
|
||||
extractionGuide:
|
||||
"Look for: name, role, organization, email, aliases, relationship context",
|
||||
},
|
||||
{
|
||||
type: "Organizations",
|
||||
folder: "Organizations",
|
||||
template: `# {Organization Name}
|
||||
|
||||
## Info
|
||||
**Type:** {company|team|institution|other}
|
||||
**Industry:** {industry or leave blank}
|
||||
**Relationship:** {customer|prospect|partner|competitor|vendor|other}
|
||||
**Domain:** {primary email domain}
|
||||
**Aliases:** {comma-separated: short names, abbreviations}
|
||||
**First met:** {YYYY-MM-DD}
|
||||
**Last seen:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: What this org is, what your relationship is.}
|
||||
|
||||
## People
|
||||
- [[People/{Person}]] — {role}
|
||||
|
||||
## Contacts
|
||||
{For transactional contacts who don't get their own notes}
|
||||
|
||||
## Projects
|
||||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}`,
|
||||
extractionGuide:
|
||||
"Look for: organization name, type, industry, relationship, domain, key people, projects",
|
||||
},
|
||||
{
|
||||
type: "Projects",
|
||||
folder: "Projects",
|
||||
template: `# {Project Name}
|
||||
|
||||
## Info
|
||||
**Type:** {deal|product|initiative|hiring|other}
|
||||
**Status:** {active|planning|on hold|completed|cancelled}
|
||||
**Started:** {YYYY-MM-DD or leave blank}
|
||||
**Last activity:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: What this project is, goal, current state.}
|
||||
|
||||
## People
|
||||
- [[People/{Person}]] — {role}
|
||||
|
||||
## Organizations
|
||||
- [[Organizations/{Org}]] — {customer|partner|etc.}
|
||||
|
||||
## Related
|
||||
- [[Topics/{Topic}]] — {relationship}
|
||||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Timeline
|
||||
**{YYYY-MM-DD}** ({meeting|email})
|
||||
{What happened.}
|
||||
|
||||
## Decisions
|
||||
- **{YYYY-MM-DD}**: {Decision}. {Rationale}.
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}`,
|
||||
extractionGuide:
|
||||
"Look for: project name, type, status, people involved, organizations, timeline, decisions",
|
||||
},
|
||||
{
|
||||
type: "Topics",
|
||||
folder: "Topics",
|
||||
template: `# {Topic Name}
|
||||
|
||||
## About
|
||||
{1-2 sentences: What this topic covers.}
|
||||
|
||||
**Keywords:** {comma-separated}
|
||||
**Aliases:** {other ways this topic is referenced}
|
||||
**First mentioned:** {YYYY-MM-DD}
|
||||
**Last mentioned:** {YYYY-MM-DD}
|
||||
|
||||
## Related
|
||||
- [[People/{Person}]] — {relationship}
|
||||
- [[Organizations/{Org}]] — {relationship}
|
||||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Log
|
||||
**{YYYY-MM-DD}** ({meeting|email}: {title})
|
||||
{Summary with [[Folder/Name]] links}
|
||||
|
||||
## Decisions
|
||||
- **{YYYY-MM-DD}**: {Decision}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}`,
|
||||
extractionGuide:
|
||||
"Look for: topic name, keywords, related people/orgs/projects, decisions, key facts",
|
||||
},
|
||||
{
|
||||
type: "Meetings",
|
||||
folder: "Meetings",
|
||||
template: "",
|
||||
extractionGuide:
|
||||
"Look for: meeting title, date, attendees, source (granola or fireflies), duration, topics discussed",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Disk-backed config with mtime caching ──────────────────────────────────
|
||||
|
||||
export const NOTES_CONFIG_PATH = path.join(WorkDir, "config", "notes.json");
|
||||
|
||||
let cachedNoteTypeDefinitions: NoteTypeDefinition[] | null = null;
|
||||
let cachedMtimeMs: number | null = null;
|
||||
|
||||
function ensureNotesConfigSync(): void {
|
||||
if (!fs.existsSync(NOTES_CONFIG_PATH)) {
|
||||
fs.writeFileSync(
|
||||
NOTES_CONFIG_PATH,
|
||||
JSON.stringify(DEFAULT_NOTE_TYPE_DEFINITIONS, null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getNoteTypeDefinitions(): NoteTypeDefinition[] {
|
||||
ensureNotesConfigSync();
|
||||
try {
|
||||
const stats = fs.statSync(NOTES_CONFIG_PATH);
|
||||
if (cachedNoteTypeDefinitions && cachedMtimeMs === stats.mtimeMs) {
|
||||
return cachedNoteTypeDefinitions;
|
||||
}
|
||||
const content = fs.readFileSync(NOTES_CONFIG_PATH, "utf8");
|
||||
cachedNoteTypeDefinitions = JSON.parse(content);
|
||||
cachedMtimeMs = stats.mtimeMs;
|
||||
return cachedNoteTypeDefinitions!;
|
||||
} catch {
|
||||
cachedNoteTypeDefinitions = null;
|
||||
cachedMtimeMs = null;
|
||||
return DEFAULT_NOTE_TYPE_DEFINITIONS;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render helper ────────────────────────────────────────────────────────
|
||||
|
||||
export function renderNoteTypesBlock(): string {
|
||||
const defs = getNoteTypeDefinitions();
|
||||
const sections = defs.map(
|
||||
(d) =>
|
||||
`## ${d.type}\n\`\`\`markdown\n${d.template}\n\`\`\``,
|
||||
);
|
||||
return `# Note Templates\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
142
apps/x/packages/core/src/knowledge/note_tagging_agent.ts
Normal file
142
apps/x/packages/core/src/knowledge/note_tagging_agent.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { renderTagSystemForNotes } from './tag_system.js';
|
||||
|
||||
export function getRaw(): string {
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-edit:
|
||||
type: builtin
|
||||
name: workspace-edit
|
||||
workspace-readdir:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
---
|
||||
# Task
|
||||
|
||||
You are a note tagging agent. Given a batch of knowledge notes (People, Organizations, Projects, Topics, Meetings), you will classify each note and prepend YAML frontmatter with categorized tags and Info/metadata attributes.
|
||||
|
||||
# Instructions
|
||||
|
||||
1. For each note file provided in the message, read its content carefully.
|
||||
2. Determine the note type from its folder path (People/, Organizations/, Projects/, Topics/, Meetings/).
|
||||
3. Classify the note using the Rowboat Tag System (Note Tags section) appended below.
|
||||
4. Extract attributes from the note's \`## Info\` section (or \`## About\` for Topics). For Meetings, extract metadata from the note content and file path (see Meeting extraction rules below).
|
||||
5. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Title\` heading), and the newString should be the frontmatter followed by that same first line.
|
||||
6. If the note already has frontmatter (starts with \`---\`), skip it.
|
||||
|
||||
# Frontmatter Format
|
||||
|
||||
Tags are organized by **category** (not a flat list). Each tag category is a top-level YAML key. Use a plain string for single values, or a YAML list for multiple values.
|
||||
|
||||
Info attributes from the \`## Info\` section are also included as top-level keys.
|
||||
|
||||
\`\`\`yaml
|
||||
---
|
||||
relationship: customer
|
||||
relationship_sub: primary
|
||||
topic:
|
||||
- sales
|
||||
- fundraising
|
||||
source: email
|
||||
status: active
|
||||
action: action-required
|
||||
role: VP Engineering
|
||||
organization: Acme Corp
|
||||
email: sarah@acme.com
|
||||
first_met: "2024-06-15"
|
||||
last_seen: "2025-01-20"
|
||||
---
|
||||
\`\`\`
|
||||
|
||||
## Tag category keys
|
||||
|
||||
Use these exact keys for each tag category:
|
||||
|
||||
| Category | Key | Single or multi | Example |
|
||||
|----------|-----|-----------------|---------|
|
||||
| Relationship | \`relationship\` | single | \`relationship: customer\` |
|
||||
| Relationship sub | \`relationship_sub\` | single or multi | \`relationship_sub: primary\` |
|
||||
| Topic | \`topic\` | single or multi | \`topic: sales\` or list |
|
||||
| Email type | \`email_type\` | single or multi | \`email_type: followup\` |
|
||||
| Action | \`action\` | single or multi | \`action: action-required\` |
|
||||
| Status | \`status\` | single | \`status: active\` |
|
||||
| Source | \`source\` | single or multi | \`source: email\` or list |
|
||||
|
||||
**Rules:**
|
||||
- Use a plain string when there's only one value: \`topic: sales\`
|
||||
- Use a YAML list when there are multiple values:
|
||||
\`\`\`yaml
|
||||
topic:
|
||||
- sales
|
||||
- fundraising
|
||||
\`\`\`
|
||||
- **Omit a category entirely** if no tags apply for it. Do not include empty keys.
|
||||
- Only use tag values from the Rowboat Tag System — do not invent new tags.
|
||||
|
||||
# Info Attribute Extraction Rules
|
||||
|
||||
Extract all \`**Key:** value\` fields from the \`## Info\` (or \`## About\`) section into YAML frontmatter keys:
|
||||
|
||||
1. **Convert keys to snake_case**: e.g. \`**First met:**\` → \`first_met\`, \`**Last activity:**\` → \`last_activity\`, \`**Last seen:**\` → \`last_seen\`.
|
||||
2. **Strip wiki-link syntax**: \`[[Organizations/Acme Corp]]\` → \`Acme Corp\`. Extract just the display name (last path segment).
|
||||
3. **Skip blank/placeholder values**: If a field says "leave blank", is empty, or contains only template placeholders like \`{role}\`, omit it from the frontmatter.
|
||||
4. **Quote dates**: Wrap date values in quotes, e.g. \`first_met: "2024-06-15"\`.
|
||||
5. **Aliases as list**: If the value is comma-separated (like Aliases), store as a YAML list:
|
||||
\`\`\`yaml
|
||||
aliases:
|
||||
- Sarah
|
||||
- sarah@acme.com
|
||||
\`\`\`
|
||||
|
||||
**Per note type, extract these fields:**
|
||||
|
||||
- **People**: role, organization, email, aliases, first_met, last_seen
|
||||
- **Organizations**: type, industry, relationship, domain, aliases, first_met, last_seen
|
||||
- **Projects**: type, status, started, last_activity
|
||||
- **Topics** (from \`## About\`): keywords, aliases, first_mentioned, last_mentioned
|
||||
- **Meetings**: Extract from the note content and file path:
|
||||
- \`date\`: meeting date (from the file path \`Meetings/{source}/YYYY/MM/DD/\` or from \`created_at\`/\`Date:\` in content)
|
||||
- \`source\`: \`granola\` or \`fireflies\` (from the file path)
|
||||
- \`attendees\`: list of attendee names (from \`Attendees:\` field or participant list)
|
||||
- \`title\`: meeting title
|
||||
- \`topic\`: relevant topic tags based on meeting content
|
||||
|
||||
Note: For Organizations, the Info \`**Relationship:**\` field is separate from the \`relationship\` tag category. Include both — the Info field as \`info_relationship\` and the tag as \`relationship\`.
|
||||
|
||||
# Tag Selection Rules
|
||||
|
||||
1. **Always include at least one relationship or topic tag** — every note must be classifiable.
|
||||
2. **Always include a source tag** — \`email\` or \`meeting\` based on what the note's Activity section shows.
|
||||
3. **Default status is \`active\`** for all new tags.
|
||||
4. **For People notes**, include:
|
||||
- One primary relationship tag (e.g. \`customer\`, \`investor\`, \`prospect\`)
|
||||
- Relationship sub-tags if applicable (e.g. \`primary\`, \`champion\`, \`former\`)
|
||||
- Topic tags based on what you're working on together
|
||||
- Source tags based on the Activity section
|
||||
- Action tags if there are open items
|
||||
5. **For Organization notes**, include:
|
||||
- One primary relationship tag
|
||||
- Topic tags based on the relationship context
|
||||
- Source tags
|
||||
6. **For Project notes**, include:
|
||||
- Topic tags based on project type
|
||||
- Source tags
|
||||
- Action tags if there are open items
|
||||
7. **For Topic notes**, include:
|
||||
- The relevant topic tag
|
||||
- Source tags
|
||||
8. **For Meeting notes**, include:
|
||||
- \`source: meeting\`
|
||||
- Topic tags based on what was discussed
|
||||
- The \`date\`, \`attendees\`, and \`title\` fields extracted from content
|
||||
9. **Only use tags from the Rowboat Tag System** — do not invent new tags.
|
||||
9. Process all files in the batch. Do not skip any unless they already have frontmatter.
|
||||
|
||||
---
|
||||
|
||||
${renderTagSystemForNotes()}
|
||||
`;
|
||||
}
|
||||
48
apps/x/packages/core/src/knowledge/note_tagging_state.ts
Normal file
48
apps/x/packages/core/src/knowledge/note_tagging_state.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
const STATE_FILE = path.join(WorkDir, 'note_tagging_state.json');
|
||||
|
||||
export interface NoteTaggingState {
|
||||
processedFiles: Record<string, { taggedAt: string }>;
|
||||
lastRunTime: string;
|
||||
}
|
||||
|
||||
export function loadNoteTaggingState(): NoteTaggingState {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
||||
} catch (error) {
|
||||
console.error('Error loading note tagging state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processedFiles: {},
|
||||
lastRunTime: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function saveNoteTaggingState(state: NoteTaggingState): void {
|
||||
try {
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Error saving note tagging state:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function markNoteAsTagged(filePath: string, state: NoteTaggingState): void {
|
||||
state.processedFiles[filePath] = {
|
||||
taggedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetNoteTaggingState(): void {
|
||||
const emptyState: NoteTaggingState = {
|
||||
processedFiles: {},
|
||||
lastRunTime: new Date().toISOString(),
|
||||
};
|
||||
saveNoteTaggingState(emptyState);
|
||||
}
|
||||
168
apps/x/packages/core/src/knowledge/summarize_meeting.ts
Normal file
168
apps/x/packages/core/src/knowledge/summarize_meeting.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { generateText } from 'ai';
|
||||
import container from '../di/container.js';
|
||||
import type { IModelConfigRepo } from '../models/repo.js';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { isSignedIn } from '../account/account.js';
|
||||
import { getGatewayProvider } from '../models/gateway.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
||||
|
||||
const SYSTEM_PROMPT = `You are a meeting notes assistant. Given a raw meeting transcript and a list of calendar events from around the same time, create concise, well-organized meeting notes.
|
||||
|
||||
## Calendar matching
|
||||
You will be given the transcript (with a timestamp of when recording started) and recent calendar events with their titles, times, and attendees. If a calendar event clearly matches this meeting (overlapping time + content aligns), then:
|
||||
- Do NOT output a title or heading — the title is already set by the caller.
|
||||
- Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc.
|
||||
- "You" in the transcript is the local user — if the calendar event has an organizer or you can identify who "You" is from context, use their name.
|
||||
|
||||
If no calendar event matches with high confidence, or if no calendar events are provided, use "They" for all non-"You" speakers.
|
||||
|
||||
## Format rules
|
||||
- Do NOT output a title or top-level heading (# or ##). Start directly with section content.
|
||||
- Use ### for section headers that group related discussion topics
|
||||
- Section headers should be in sentence case (e.g. "### Onboarding flow status"), NOT Title Case
|
||||
- Use bullet points with sub-bullets for details
|
||||
- Include a "### Action items" section at the end if any were discussed
|
||||
- Focus on decisions, key discussions, and takeaways — not verbatim quotes
|
||||
- Attribute statements to speakers when relevant
|
||||
- Keep it concise — the notes should be much shorter than the transcript
|
||||
- Output markdown only, no preamble or explanation`;
|
||||
|
||||
/**
|
||||
* Load recent calendar events from the calendar_sync directory.
|
||||
* Returns a formatted string of events for the LLM prompt.
|
||||
*/
|
||||
function loadRecentCalendarEvents(meetingTime: string): string {
|
||||
try {
|
||||
if (!fs.existsSync(CALENDAR_SYNC_DIR)) return '';
|
||||
|
||||
const files = fs.readdirSync(CALENDAR_SYNC_DIR).filter(f => f.endsWith('.json') && f !== 'sync_state.json' && f !== 'composio_state.json');
|
||||
if (files.length === 0) return '';
|
||||
|
||||
const meetingDate = new Date(meetingTime);
|
||||
// Only consider events within ±3 hours of the meeting
|
||||
const windowMs = 3 * 60 * 60 * 1000;
|
||||
|
||||
const relevantEvents: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(CALENDAR_SYNC_DIR, file), 'utf-8');
|
||||
const event = JSON.parse(content);
|
||||
|
||||
const startTime = event.start?.dateTime || event.start?.date;
|
||||
if (!startTime) continue;
|
||||
|
||||
const eventStart = new Date(startTime);
|
||||
if (Math.abs(eventStart.getTime() - meetingDate.getTime()) > windowMs) continue;
|
||||
|
||||
const attendees = (event.attendees || [])
|
||||
.map((a: { displayName?: string; email?: string }) => a.displayName || a.email)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
const endTime = event.end?.dateTime || event.end?.date || '';
|
||||
const organizer = event.organizer?.displayName || event.organizer?.email || '';
|
||||
|
||||
relevantEvents.push(
|
||||
`- Title: ${event.summary || 'Untitled'}\n` +
|
||||
` Start: ${startTime}\n` +
|
||||
` End: ${endTime}\n` +
|
||||
` Organizer: ${organizer}\n` +
|
||||
` Attendees: ${attendees || 'none listed'}`
|
||||
);
|
||||
} catch {
|
||||
// Skip malformed files
|
||||
}
|
||||
}
|
||||
|
||||
if (relevantEvents.length === 0) return '';
|
||||
return `\n\n## Calendar events around this time\n\n${relevantEvents.join('\n\n')}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific calendar event from the calendar_sync directory using
|
||||
* the calendar_event JSON stored in the meeting note frontmatter.
|
||||
* If a `source` field is present, loads the full event file for richer
|
||||
* details (attendees, organizer, etc.).
|
||||
*/
|
||||
function loadCalendarEventContext(calendarEventJson: string): string {
|
||||
try {
|
||||
const meta = JSON.parse(calendarEventJson) as {
|
||||
summary?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
location?: string;
|
||||
htmlLink?: string;
|
||||
conferenceLink?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
// Try to load the full event from source file for attendee info
|
||||
let attendees = '';
|
||||
let organizer = '';
|
||||
if (meta.source) {
|
||||
try {
|
||||
const fullPath = path.join(WorkDir, meta.source);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
const event = JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
|
||||
attendees = (event.attendees || [])
|
||||
.map((a: { displayName?: string; email?: string }) => a.displayName || a.email)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
organizer = event.organizer?.displayName || event.organizer?.email || '';
|
||||
}
|
||||
} catch {
|
||||
// Fall through — use metadata only
|
||||
}
|
||||
}
|
||||
|
||||
const eventStr =
|
||||
`- Title: ${meta.summary || 'Untitled'}\n` +
|
||||
` Start: ${meta.start || ''}\n` +
|
||||
` End: ${meta.end || ''}\n` +
|
||||
` Organizer: ${organizer || 'unknown'}\n` +
|
||||
` Attendees: ${attendees || 'none listed'}`;
|
||||
|
||||
return `\n\n## Calendar event for this meeting\n\n${eventStr}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise<string> {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
const signedIn = await isSignedIn();
|
||||
const provider = signedIn
|
||||
? await getGatewayProvider()
|
||||
: createProvider(config.provider);
|
||||
const modelId = config.meetingNotesModel
|
||||
|| (signedIn ? "gpt-5.4" : config.model);
|
||||
const model = provider.languageModel(modelId);
|
||||
|
||||
// If a specific calendar event was linked, use it directly.
|
||||
// Otherwise fall back to scanning events within ±3 hours.
|
||||
let calendarContext: string;
|
||||
if (calendarEventJson) {
|
||||
calendarContext = loadCalendarEventContext(calendarEventJson);
|
||||
} else {
|
||||
calendarContext = meetingStartTime ? loadRecentCalendarEvents(meetingStartTime) : '';
|
||||
}
|
||||
|
||||
const prompt = `Meeting recording started at: ${meetingStartTime || 'unknown'}\n\n${transcript}${calendarContext}`;
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt,
|
||||
});
|
||||
|
||||
return result.text.trim();
|
||||
}
|
||||
|
|
@ -5,13 +5,16 @@ import { OAuth2Client } from 'google-auth-library';
|
|||
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js';
|
||||
import { composioAccountsRepo } from '../composio/repo.js';
|
||||
|
||||
// Configuration
|
||||
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
||||
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
||||
const LOOKBACK_DAYS = 14;
|
||||
const COMPOSIO_LOOKBACK_DAYS = 14;
|
||||
const REQUIRED_SCOPES = [
|
||||
'https://www.googleapis.com/auth/calendar.events.readonly',
|
||||
'https://www.googleapis.com/auth/drive.readonly'
|
||||
|
|
@ -56,7 +59,7 @@ function cleanUpOldFiles(currentEventIds: Set<string>, syncDir: string): string[
|
|||
const files = fs.readdirSync(syncDir);
|
||||
const deleted: string[] = [];
|
||||
for (const filename of files) {
|
||||
if (filename === 'sync_state.json') continue;
|
||||
if (filename === 'sync_state.json' || filename === 'composio_state.json') continue;
|
||||
|
||||
// We expect files like:
|
||||
// {eventId}.json
|
||||
|
|
@ -133,10 +136,10 @@ async function processAttachments(drive: drive.Drive, event: cal.Schema$Event, s
|
|||
const filename = `${eventId}_doc_${safeTitle}.md`;
|
||||
const filePath = path.join(syncDir, filename);
|
||||
|
||||
// Simple cache check: if file exists, skip.
|
||||
// Simple cache check: if file exists, skip.
|
||||
// Ideally we check modifiedTime, but that requires an extra API call per file.
|
||||
// Given the loop interval, we can just check existence to save quota.
|
||||
// If user updates notes, they might want them re-synced.
|
||||
// If user updates notes, they might want them re-synced.
|
||||
// For now, let's just check existence. To be smarter, we'd need a state file or check API.
|
||||
if (fs.existsSync(filePath)) continue;
|
||||
|
||||
|
|
@ -343,20 +346,248 @@ async function performSync(syncDir: string, lookbackDays: number) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Composio-based Sync ---
|
||||
|
||||
interface ComposioCalendarState {
|
||||
last_sync: string; // ISO string
|
||||
}
|
||||
|
||||
function loadComposioState(stateFile: string): ComposioCalendarState | null {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (data.last_sync) {
|
||||
return { last_sync: data.last_sync };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Calendar] Failed to load composio state:', e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveComposioState(stateFile: string, lastSync: string): void {
|
||||
fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a Composio calendar event as JSON (same format used by Google OAuth path).
|
||||
* The event data from Composio is already structured similarly to Google Calendar API.
|
||||
*/
|
||||
function saveComposioEvent(eventData: Record<string, unknown>, syncDir: string): { changed: boolean; isNew: boolean; title: string } {
|
||||
const eventId = eventData.id as string | undefined;
|
||||
if (!eventId) return { changed: false, isNew: false, title: 'Unknown' };
|
||||
|
||||
const filePath = path.join(syncDir, `${eventId}.json`);
|
||||
const content = JSON.stringify(eventData, null, 2);
|
||||
const exists = fs.existsSync(filePath);
|
||||
|
||||
try {
|
||||
if (exists) {
|
||||
const existing = fs.readFileSync(filePath, 'utf-8');
|
||||
if (existing === content) {
|
||||
return { changed: false, isNew: false, title: (eventData.summary as string) || eventId };
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content);
|
||||
return { changed: true, isNew: !exists, title: (eventData.summary as string) || eventId };
|
||||
} catch (e) {
|
||||
console.error(`[Calendar] Error saving event ${eventId}:`, e);
|
||||
return { changed: false, isNew: false, title: (eventData.summary as string) || eventId };
|
||||
}
|
||||
}
|
||||
|
||||
async function performSyncComposio() {
|
||||
const STATE_FILE = path.join(SYNC_DIR, 'composio_state.json');
|
||||
|
||||
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
|
||||
|
||||
const account = composioAccountsRepo.getAccount('googlecalendar');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
console.log('[Calendar] Google Calendar not connected via Composio. Skipping sync.');
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedAccountId = account.id;
|
||||
|
||||
// Calculate time window: lookback + 14 days forward
|
||||
const now = new Date();
|
||||
const lookbackMs = COMPOSIO_LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
|
||||
const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const timeMin = new Date(now.getTime() - lookbackMs).toISOString();
|
||||
const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString();
|
||||
|
||||
console.log(`[Calendar] Syncing via Composio from ${timeMin} to ${timeMax} (lookback: ${COMPOSIO_LOOKBACK_DAYS} days)...`);
|
||||
|
||||
let run: ServiceRunContext | null = null;
|
||||
const ensureRun = async (): Promise<ServiceRunContext> => {
|
||||
if (!run) {
|
||||
run = await serviceLogger.startRun({
|
||||
service: 'calendar',
|
||||
message: 'Syncing calendar (Composio)',
|
||||
trigger: 'timer',
|
||||
});
|
||||
}
|
||||
return run;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executeAction(
|
||||
'GOOGLECALENDAR_FIND_EVENT',
|
||||
{
|
||||
connected_account_id: connectedAccountId,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: {
|
||||
calendar_id: 'primary',
|
||||
time_min: timeMin,
|
||||
time_max: timeMax,
|
||||
single_events: true,
|
||||
order_by: 'startTime',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.successful || !result.data) {
|
||||
console.error('[Calendar] Failed to list events via Composio:', result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = result.data as Record<string, unknown>;
|
||||
// Composio may return events in different structures
|
||||
let events: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (Array.isArray(data.items)) {
|
||||
events = data.items as Array<Record<string, unknown>>;
|
||||
} else if (Array.isArray(data.events)) {
|
||||
events = data.events as Array<Record<string, unknown>>;
|
||||
} else if (Array.isArray(data)) {
|
||||
events = data as unknown as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const currentEventIds = new Set<string>();
|
||||
let newCount = 0;
|
||||
let updatedCount = 0;
|
||||
const changedTitles: string[] = [];
|
||||
|
||||
if (events.length === 0) {
|
||||
console.log('[Calendar] No events found in this window.');
|
||||
} else {
|
||||
console.log(`[Calendar] Found ${events.length} events.`);
|
||||
for (const event of events) {
|
||||
const eventId = event.id as string | undefined;
|
||||
if (eventId) {
|
||||
const saveResult = saveComposioEvent(event, SYNC_DIR);
|
||||
currentEventIds.add(eventId);
|
||||
|
||||
if (saveResult.changed) {
|
||||
await ensureRun();
|
||||
changedTitles.push(saveResult.title);
|
||||
if (saveResult.isNew) {
|
||||
newCount++;
|
||||
} else {
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up events no longer in the window
|
||||
const deletedFiles = cleanUpOldFiles(currentEventIds, SYNC_DIR);
|
||||
let deletedCount = 0;
|
||||
if (deletedFiles.length > 0) {
|
||||
await ensureRun();
|
||||
deletedCount = deletedFiles.length;
|
||||
}
|
||||
|
||||
// Log results if any changes were detected (run was started by ensureRun)
|
||||
if (run) {
|
||||
const r = run as ServiceRunContext;
|
||||
const totalChanges = newCount + updatedCount + deletedCount;
|
||||
const limitedTitles = limitEventItems(changedTitles);
|
||||
await serviceLogger.log({
|
||||
type: 'changes_identified',
|
||||
service: r.service,
|
||||
runId: r.runId,
|
||||
level: 'info',
|
||||
message: `Calendar updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`,
|
||||
counts: {
|
||||
newEvents: newCount,
|
||||
updatedEvents: updatedCount,
|
||||
deletedFiles: deletedCount,
|
||||
},
|
||||
items: limitedTitles.items,
|
||||
truncated: limitedTitles.truncated,
|
||||
});
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: r.service,
|
||||
runId: r.runId,
|
||||
level: 'info',
|
||||
message: `Calendar sync complete: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`,
|
||||
durationMs: Date.now() - r.startedAt,
|
||||
outcome: 'ok',
|
||||
summary: {
|
||||
newEvents: newCount,
|
||||
updatedEvents: updatedCount,
|
||||
deletedFiles: deletedCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Save state
|
||||
saveComposioState(STATE_FILE, new Date().toISOString());
|
||||
console.log(`[Calendar] Composio sync completed. ${newCount} new, ${updatedCount} updated, ${deletedCount} deleted.`);
|
||||
} catch (error) {
|
||||
console.error('[Calendar] Error during Composio sync:', error);
|
||||
const errRun = await ensureRun();
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: errRun.service,
|
||||
runId: errRun.runId,
|
||||
level: 'error',
|
||||
message: 'Calendar sync error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: errRun.service,
|
||||
runId: errRun.runId,
|
||||
level: 'error',
|
||||
message: 'Calendar sync failed',
|
||||
durationMs: Date.now() - errRun.startedAt,
|
||||
outcome: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
console.log("Starting Google Calendar & Notes Sync (TS)...");
|
||||
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
// Check if credentials are available with required scopes
|
||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES);
|
||||
|
||||
if (!hasCredentials) {
|
||||
console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping...");
|
||||
const composioMode = await useComposioForGoogleCalendar();
|
||||
if (composioMode) {
|
||||
const isConnected = composioAccountsRepo.isConnected('googlecalendar');
|
||||
if (!isConnected) {
|
||||
console.log('[Calendar] Google Calendar not connected via Composio. Sleeping...');
|
||||
} else {
|
||||
await performSyncComposio();
|
||||
}
|
||||
} else {
|
||||
// Perform one sync
|
||||
await performSync(SYNC_DIR, LOOKBACK_DAYS);
|
||||
// Check if credentials are available with required scopes
|
||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES);
|
||||
|
||||
if (!hasCredentials) {
|
||||
console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping...");
|
||||
} else {
|
||||
// Perform one sync
|
||||
await performSync(SYNC_DIR, LOOKBACK_DAYS);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in main loop:", error);
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
|
|||
import { limitEventItems } from './limit_event_items.js';
|
||||
|
||||
// Configuration
|
||||
const SYNC_DIR = path.join(WorkDir, 'fireflies_transcripts');
|
||||
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies');
|
||||
const SYNC_INTERVAL_MS = 30 * 60 * 1000; // Check every 30 minutes (reduced from 1 minute)
|
||||
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
|
||||
const STATE_FILE = path.join(WorkDir, 'fireflies_sync_state.json');
|
||||
const LOOKBACK_DAYS = 30; // Last 1 month
|
||||
const API_DELAY_MS = 2000; // 2 second delay between API calls
|
||||
const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit
|
||||
|
|
@ -569,8 +569,16 @@ async function syncMeetings() {
|
|||
|
||||
// Convert to markdown and save
|
||||
const markdown = meetingToMarkdown(meetingData);
|
||||
const filename = `${meetingId}_${cleanFilename(meetingData.title || 'untitled')}.md`;
|
||||
const filePath = path.join(SYNC_DIR, filename);
|
||||
const meetingDate = new Date(meetingData.dateString || meetingData.date || Date.now());
|
||||
const dateDir = path.join(
|
||||
SYNC_DIR,
|
||||
String(meetingDate.getFullYear()),
|
||||
String(meetingDate.getMonth() + 1).padStart(2, '0'),
|
||||
String(meetingDate.getDate()).padStart(2, '0')
|
||||
);
|
||||
fs.mkdirSync(dateDir, { recursive: true });
|
||||
const filename = `${cleanFilename(meetingData.title || 'untitled')}.md`;
|
||||
const filePath = path.join(dateDir, filename);
|
||||
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
console.log(`[Fireflies] Saved: ${filename}`);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { WorkDir } from '../config/config.js';
|
|||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { executeAction, useComposioForGoogle } from '../composio/client.js';
|
||||
import { composioAccountsRepo } from '../composio/repo.js';
|
||||
|
||||
// Configuration
|
||||
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||
|
|
@ -440,20 +442,370 @@ async function performSync() {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Composio-based Sync ---
|
||||
|
||||
const COMPOSIO_LOOKBACK_DAYS = 30;
|
||||
|
||||
interface ComposioSyncState {
|
||||
last_sync: string; // ISO string
|
||||
}
|
||||
|
||||
function loadComposioState(stateFile: string): ComposioSyncState | null {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (data.last_sync) {
|
||||
return { last_sync: data.last_sync };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Gmail] Failed to load composio state:', e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveComposioState(stateFile: string, lastSync: string): void {
|
||||
fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2));
|
||||
}
|
||||
|
||||
function tryParseDate(dateStr: string): Date | null {
|
||||
const d = new Date(dateStr);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
from: string;
|
||||
date: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseMessageData(messageData: Record<string, unknown>): ParsedMessage {
|
||||
const headers = messageData.payload && typeof messageData.payload === 'object'
|
||||
? (messageData.payload as Record<string, unknown>).headers as Array<{ name: string; value: string }> | undefined
|
||||
: undefined;
|
||||
|
||||
const from = headers?.find(h => h.name === 'From')?.value || String(messageData.from || messageData.sender || 'Unknown');
|
||||
const date = headers?.find(h => h.name === 'Date')?.value || String(messageData.date || messageData.internalDate || 'Unknown');
|
||||
const subject = headers?.find(h => h.name === 'Subject')?.value || String(messageData.subject || '(No Subject)');
|
||||
|
||||
let body = '';
|
||||
|
||||
if (messageData.payload && typeof messageData.payload === 'object') {
|
||||
body = extractBodyFromPayload(messageData.payload as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
if (typeof messageData.body === 'string') {
|
||||
body = messageData.body;
|
||||
} else if (typeof messageData.snippet === 'string') {
|
||||
body = messageData.snippet;
|
||||
} else if (typeof messageData.text === 'string') {
|
||||
body = messageData.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (body && (body.includes('<html') || body.includes('<div') || body.includes('<p'))) {
|
||||
body = nhm.translate(body);
|
||||
}
|
||||
|
||||
if (body) {
|
||||
body = body.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
|
||||
}
|
||||
|
||||
return { from, date, subject, body };
|
||||
}
|
||||
|
||||
function extractBodyFromPayload(payload: Record<string, unknown>): string {
|
||||
const parts = payload.parts as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
if (parts) {
|
||||
for (const part of parts) {
|
||||
const mimeType = part.mimeType as string | undefined;
|
||||
const bodyData = part.body && typeof part.body === 'object'
|
||||
? (part.body as Record<string, unknown>).data as string | undefined
|
||||
: undefined;
|
||||
|
||||
if ((mimeType === 'text/plain' || mimeType === 'text/html') && bodyData) {
|
||||
const decoded = Buffer.from(bodyData, 'base64').toString('utf-8');
|
||||
if (mimeType === 'text/html') {
|
||||
return nhm.translate(decoded);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
if (part.parts) {
|
||||
const result = extractBodyFromPayload(part as Record<string, unknown>);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bodyData = payload.body && typeof payload.body === 'object'
|
||||
? (payload.body as Record<string, unknown>).data as string | undefined
|
||||
: undefined;
|
||||
|
||||
if (bodyData) {
|
||||
const decoded = Buffer.from(bodyData, 'base64').toString('utf-8');
|
||||
const mimeType = payload.mimeType as string | undefined;
|
||||
if (mimeType === 'text/html') {
|
||||
return nhm.translate(decoded);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise<string | null> {
|
||||
let threadResult;
|
||||
try {
|
||||
threadResult = await executeAction(
|
||||
'GMAIL_FETCH_MESSAGE_BY_THREAD_ID',
|
||||
{
|
||||
connected_account_id: connectedAccountId,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: { thread_id: threadId, user_id: 'me' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!threadResult.successful || !threadResult.data) {
|
||||
console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = threadResult.data as Record<string, unknown>;
|
||||
const messages = data.messages as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
let newestDate: Date | null = null;
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
const parsed = parseMessageData(data);
|
||||
const mdContent = `# ${parsed.subject}\n\n` +
|
||||
`**Thread ID:** ${threadId}\n` +
|
||||
`**Message Count:** 1\n\n---\n\n` +
|
||||
`### From: ${parsed.from}\n` +
|
||||
`**Date:** ${parsed.date}\n\n` +
|
||||
`${parsed.body}\n\n---\n\n`;
|
||||
|
||||
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||
console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`);
|
||||
newestDate = tryParseDate(parsed.date);
|
||||
} else {
|
||||
const firstParsed = parseMessageData(messages[0]);
|
||||
let mdContent = `# ${firstParsed.subject}\n\n`;
|
||||
mdContent += `**Thread ID:** ${threadId}\n`;
|
||||
mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`;
|
||||
|
||||
for (const msg of messages) {
|
||||
const parsed = parseMessageData(msg);
|
||||
mdContent += `### From: ${parsed.from}\n`;
|
||||
mdContent += `**Date:** ${parsed.date}\n\n`;
|
||||
mdContent += `${parsed.body}\n\n`;
|
||||
mdContent += `---\n\n`;
|
||||
|
||||
const msgDate = tryParseDate(parsed.date);
|
||||
if (msgDate && (!newestDate || msgDate > newestDate)) {
|
||||
newestDate = msgDate;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||
console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`);
|
||||
}
|
||||
|
||||
if (!newestDate) return null;
|
||||
return new Date(newestDate.getTime() + 1000).toISOString();
|
||||
}
|
||||
|
||||
async function performSyncComposio() {
|
||||
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
|
||||
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
|
||||
|
||||
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
|
||||
if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
||||
|
||||
const account = composioAccountsRepo.getAccount('gmail');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
console.log('[Gmail] Gmail not connected via Composio. Skipping sync.');
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedAccountId = account.id;
|
||||
|
||||
const state = loadComposioState(STATE_FILE);
|
||||
let afterEpochSeconds: number;
|
||||
|
||||
if (state) {
|
||||
afterEpochSeconds = Math.floor(new Date(state.last_sync).getTime() / 1000);
|
||||
console.log(`[Gmail] Syncing messages since ${state.last_sync}...`);
|
||||
} else {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - COMPOSIO_LOOKBACK_DAYS);
|
||||
afterEpochSeconds = Math.floor(pastDate.getTime() / 1000);
|
||||
console.log(`[Gmail] First sync - fetching last ${COMPOSIO_LOOKBACK_DAYS} days...`);
|
||||
}
|
||||
|
||||
let run: ServiceRunContext | null = null;
|
||||
const ensureRun = async () => {
|
||||
if (!run) {
|
||||
run = await serviceLogger.startRun({
|
||||
service: 'gmail',
|
||||
message: 'Syncing Gmail (Composio)',
|
||||
trigger: 'timer',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const allThreadIds: string[] = [];
|
||||
let pageToken: string | undefined;
|
||||
|
||||
do {
|
||||
const params: Record<string, unknown> = {
|
||||
query: `after:${afterEpochSeconds}`,
|
||||
max_results: 20,
|
||||
user_id: 'me',
|
||||
};
|
||||
if (pageToken) {
|
||||
params.page_token = pageToken;
|
||||
}
|
||||
|
||||
const result = await executeAction(
|
||||
'GMAIL_LIST_THREADS',
|
||||
{
|
||||
connected_account_id: connectedAccountId,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: params,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.successful || !result.data) {
|
||||
console.error('[Gmail] Failed to list threads:', result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const threads = data.threads as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
if (threads && threads.length > 0) {
|
||||
for (const thread of threads) {
|
||||
const threadId = thread.id as string | undefined;
|
||||
if (threadId) {
|
||||
allThreadIds.push(threadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = data.nextPageToken as string | undefined;
|
||||
} while (pageToken);
|
||||
|
||||
if (allThreadIds.length === 0) {
|
||||
console.log('[Gmail] No new threads.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Gmail] Found ${allThreadIds.length} threads to sync.`);
|
||||
|
||||
await ensureRun();
|
||||
const limitedThreads = limitEventItems(allThreadIds);
|
||||
await serviceLogger.log({
|
||||
type: 'changes_identified',
|
||||
service: run!.service,
|
||||
runId: run!.runId,
|
||||
level: 'info',
|
||||
message: `Found ${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'} to sync`,
|
||||
counts: { threads: allThreadIds.length },
|
||||
items: limitedThreads.items,
|
||||
truncated: limitedThreads.truncated,
|
||||
});
|
||||
|
||||
// Process oldest first so high-water mark advances chronologically
|
||||
allThreadIds.reverse();
|
||||
|
||||
let highWaterMark: string | null = state?.last_sync ?? null;
|
||||
let processedCount = 0;
|
||||
for (const threadId of allThreadIds) {
|
||||
try {
|
||||
const newestInThread = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR);
|
||||
processedCount++;
|
||||
|
||||
if (newestInThread) {
|
||||
if (!highWaterMark || new Date(newestInThread) > new Date(highWaterMark)) {
|
||||
highWaterMark = newestInThread;
|
||||
}
|
||||
saveComposioState(STATE_FILE, highWaterMark);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Gmail] Error processing thread ${threadId}, skipping:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: run!.service,
|
||||
runId: run!.runId,
|
||||
level: 'info',
|
||||
message: `Gmail sync complete: ${processedCount}/${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'}`,
|
||||
durationMs: Date.now() - run!.startedAt,
|
||||
outcome: 'ok',
|
||||
summary: { threads: processedCount },
|
||||
});
|
||||
|
||||
console.log(`[Gmail] Sync completed. Processed ${processedCount}/${allThreadIds.length} threads.`);
|
||||
} catch (error) {
|
||||
console.error('[Gmail] Error during sync:', error);
|
||||
await ensureRun();
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: run!.service,
|
||||
runId: run!.runId,
|
||||
level: 'error',
|
||||
message: 'Gmail sync error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: run!.service,
|
||||
runId: run!.runId,
|
||||
level: 'error',
|
||||
message: 'Gmail sync failed',
|
||||
durationMs: Date.now() - run!.startedAt,
|
||||
outcome: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
console.log("Starting Gmail Sync (TS)...");
|
||||
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
// Check if credentials are available with required scopes
|
||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE);
|
||||
|
||||
if (!hasCredentials) {
|
||||
console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping...");
|
||||
const composioMode = await useComposioForGoogle();
|
||||
if (composioMode) {
|
||||
const isConnected = composioAccountsRepo.isConnected('gmail');
|
||||
if (!isConnected) {
|
||||
console.log('[Gmail] Gmail not connected via Composio. Sleeping...');
|
||||
} else {
|
||||
await performSyncComposio();
|
||||
}
|
||||
} else {
|
||||
// Perform one sync
|
||||
await performSync();
|
||||
// Check if credentials are available with required scopes
|
||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE);
|
||||
|
||||
if (!hasCredentials) {
|
||||
console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping...");
|
||||
} else {
|
||||
// Perform one sync
|
||||
await performSync();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in main loop:", error);
|
||||
|
|
|
|||
282
apps/x/packages/core/src/knowledge/tag_notes.ts
Normal file
282
apps/x/packages/core/src/knowledge/tag_notes.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
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 { limitEventItems } from './limit_event_items.js';
|
||||
import {
|
||||
loadNoteTaggingState,
|
||||
saveNoteTaggingState,
|
||||
markNoteAsTagged,
|
||||
type NoteTaggingState,
|
||||
} from './note_tagging_state.js';
|
||||
import { getNoteTypeDefinitions } from './note_system.js';
|
||||
|
||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||
const BATCH_SIZE = 15;
|
||||
const NOTE_TAGGING_AGENT = 'note_tagging_agent';
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
const MAX_CONTENT_LENGTH = 8000;
|
||||
|
||||
/**
|
||||
* Find knowledge notes that haven't been tagged yet
|
||||
*/
|
||||
function getUntaggedNotes(state: NoteTaggingState): string[] {
|
||||
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const untagged: string[] = [];
|
||||
const noteFolders = getNoteTypeDefinitions().map(d => d.folder);
|
||||
|
||||
function scanDir(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()) {
|
||||
scanDir(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!stat.isFile() || !entry.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already tracked in state
|
||||
if (state.processedFiles[fullPath]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if file already has frontmatter
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
if (content.startsWith('---')) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
untagged.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const folder of noteFolders) {
|
||||
const folderPath = path.join(KNOWLEDGE_DIR, folder);
|
||||
if (!fs.existsSync(folderPath)) {
|
||||
continue;
|
||||
}
|
||||
scanDir(folderPath);
|
||||
}
|
||||
|
||||
return untagged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a run to complete by listening for run-processing-end event
|
||||
*/
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag a batch of note files using the tagging agent
|
||||
*/
|
||||
async function tagNoteBatch(
|
||||
files: { path: string; content: string }[]
|
||||
): Promise<{ runId: string; filesEdited: Set<string> }> {
|
||||
const run = await createRun({
|
||||
agentId: NOTE_TAGGING_AGENT,
|
||||
});
|
||||
|
||||
let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`;
|
||||
message += `**Important:** Use workspace-relative paths with workspace-edit (e.g. "knowledge/People/Sarah Chen.md", NOT absolute paths).\n\n`;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const relativePath = path.relative(WorkDir, file.path);
|
||||
const truncated = file.content.length > MAX_CONTENT_LENGTH
|
||||
? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use workspace-readFile for full content ...]'
|
||||
: file.content;
|
||||
|
||||
message += `## File ${i + 1}: ${relativePath}\n\n`;
|
||||
message += truncated;
|
||||
message += `\n\n---\n\n`;
|
||||
}
|
||||
|
||||
const filesEdited = new Set<string>();
|
||||
|
||||
const unsubscribe = await bus.subscribe(run.id, async (event) => {
|
||||
if (event.type !== 'tool-invocation') {
|
||||
return;
|
||||
}
|
||||
if (event.toolName !== 'workspace-edit') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(event.input) as { path?: string };
|
||||
if (typeof parsed.path === 'string') {
|
||||
filesEdited.add(parsed.path);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
});
|
||||
|
||||
await createMessage(run.id, message);
|
||||
await waitForRunCompletion(run.id);
|
||||
unsubscribe();
|
||||
|
||||
return { runId: run.id, filesEdited };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all untagged notes in batches
|
||||
*/
|
||||
async function processUntaggedNotes(): Promise<void> {
|
||||
console.log('[NoteTagging] Checking for untagged notes...');
|
||||
|
||||
const state = loadNoteTaggingState();
|
||||
const untagged = getUntaggedNotes(state);
|
||||
|
||||
if (untagged.length === 0) {
|
||||
console.log('[NoteTagging] No untagged notes found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[NoteTagging] Found ${untagged.length} untagged notes`);
|
||||
|
||||
const run = await serviceLogger.startRun({
|
||||
service: 'note_tagging',
|
||||
message: `Tagging ${untagged.length} note${untagged.length === 1 ? '' : 's'}`,
|
||||
trigger: 'timer',
|
||||
});
|
||||
|
||||
const relativeFiles = untagged.map(f => path.relative(WorkDir, f));
|
||||
const limitedFiles = limitEventItems(relativeFiles);
|
||||
await serviceLogger.log({
|
||||
type: 'changes_identified',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'info',
|
||||
message: `Found ${untagged.length} untagged note${untagged.length === 1 ? '' : 's'}`,
|
||||
counts: { notes: untagged.length },
|
||||
items: limitedFiles.items,
|
||||
truncated: limitedFiles.truncated,
|
||||
});
|
||||
|
||||
const totalBatches = Math.ceil(untagged.length / BATCH_SIZE);
|
||||
let totalEdited = 0;
|
||||
let hadError = false;
|
||||
|
||||
for (let i = 0; i < untagged.length; i += BATCH_SIZE) {
|
||||
const batchPaths = untagged.slice(i, i + BATCH_SIZE);
|
||||
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
|
||||
|
||||
try {
|
||||
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(`[NoteTagging] Error reading ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[NoteTagging] 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 tagNoteBatch(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)) {
|
||||
markNoteAsTagged(file.path, state);
|
||||
}
|
||||
}
|
||||
|
||||
saveNoteTaggingState(state);
|
||||
console.log(`[NoteTagging] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files tagged`);
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error(`[NoteTagging] 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.lastRunTime = new Date().toISOString();
|
||||
saveNoteTaggingState(state);
|
||||
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: hadError ? 'error' : 'info',
|
||||
message: `Note tagging complete: ${totalEdited} notes tagged`,
|
||||
durationMs: Date.now() - run.startedAt,
|
||||
outcome: hadError ? 'error' : 'ok',
|
||||
summary: {
|
||||
totalNotes: untagged.length,
|
||||
notesTagged: totalEdited,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[NoteTagging] Done. ${totalEdited} notes tagged.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point - runs as independent polling service
|
||||
*/
|
||||
export async function init() {
|
||||
console.log('[NoteTagging] Starting Note Tagging Service...');
|
||||
console.log(`[NoteTagging] Will check for untagged notes every ${SYNC_INTERVAL_MS / 1000} seconds`);
|
||||
|
||||
// Initial run
|
||||
await processUntaggedNotes();
|
||||
|
||||
// Periodic polling
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
|
||||
|
||||
try {
|
||||
await processUntaggedNotes();
|
||||
} catch (error) {
|
||||
console.error('[NoteTagging] Error in main loop:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
230
apps/x/packages/core/src/knowledge/tag_system.ts
Normal file
230
apps/x/packages/core/src/knowledge/tag_system.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
|
||||
export type TagApplicability = 'email' | 'notes' | 'both';
|
||||
|
||||
export type TagType =
|
||||
| 'relationship'
|
||||
| 'relationship-sub'
|
||||
| 'topic'
|
||||
| 'email-type'
|
||||
| 'filter'
|
||||
| 'action'
|
||||
| 'status'
|
||||
| 'source';
|
||||
|
||||
export type NoteEffect = 'create' | 'skip' | 'none';
|
||||
|
||||
export interface TagDefinition {
|
||||
tag: string;
|
||||
type: TagType;
|
||||
applicability: TagApplicability;
|
||||
description: string;
|
||||
example?: string;
|
||||
/** Whether an email with this tag should create notes ('create'), be skipped ('skip'), or has no effect on note creation ('none'). */
|
||||
noteEffect?: NoteEffect;
|
||||
}
|
||||
|
||||
// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ──────────
|
||||
|
||||
const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [
|
||||
// ── Relationship (both) ──────────────────────────────────────────────
|
||||
{ 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.' },
|
||||
|
||||
// ── Relationship Sub-Tags (notes only) ───────────────────────────────
|
||||
{ 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.' },
|
||||
|
||||
// ── Topic (both) ─────────────────────────────────────────────────────
|
||||
{ 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.' },
|
||||
|
||||
// ── Email Type ───────────────────────────────────────────────────────
|
||||
{ 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.' },
|
||||
|
||||
// ── Filter (email only) ──────────────────────────────────────────────
|
||||
{ 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' },
|
||||
|
||||
// ── Action ───────────────────────────────────────────────────────────
|
||||
{ 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' },
|
||||
|
||||
// ── Status (email) ───────────────────────────────────────────────────
|
||||
{ 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' },
|
||||
|
||||
// ── Source (notes only) ──────────────────────────────────────────────
|
||||
{ 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' },
|
||||
|
||||
// ── Status (notes) ──────────────────────────────────────────────────
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
// ── Disk-backed config with mtime caching ──────────────────────────────────
|
||||
|
||||
export const TAGS_CONFIG_PATH = path.join(WorkDir, "config", "tags.json");
|
||||
|
||||
let cachedTagDefinitions: TagDefinition[] | null = null;
|
||||
let cachedMtimeMs: number | null = null;
|
||||
|
||||
function ensureTagsConfigSync(): void {
|
||||
if (!fs.existsSync(TAGS_CONFIG_PATH)) {
|
||||
fs.writeFileSync(
|
||||
TAGS_CONFIG_PATH,
|
||||
JSON.stringify(DEFAULT_TAG_DEFINITIONS, null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getTagDefinitions(): TagDefinition[] {
|
||||
ensureTagsConfigSync();
|
||||
try {
|
||||
const stats = fs.statSync(TAGS_CONFIG_PATH);
|
||||
if (cachedTagDefinitions && cachedMtimeMs === stats.mtimeMs) {
|
||||
return cachedTagDefinitions;
|
||||
}
|
||||
const content = fs.readFileSync(TAGS_CONFIG_PATH, "utf8");
|
||||
cachedTagDefinitions = JSON.parse(content);
|
||||
cachedMtimeMs = stats.mtimeMs;
|
||||
return cachedTagDefinitions!;
|
||||
} catch {
|
||||
cachedTagDefinitions = null;
|
||||
cachedMtimeMs = null;
|
||||
return DEFAULT_TAG_DEFINITIONS;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_ORDER: TagType[] = [
|
||||
'relationship', 'relationship-sub', 'topic', 'email-type',
|
||||
'filter', 'action', 'status', 'source',
|
||||
];
|
||||
|
||||
const TYPE_LABELS: Record<TagType, string> = {
|
||||
'relationship': 'Relationship',
|
||||
'relationship-sub': 'Relationship Sub-Tags',
|
||||
'topic': 'Topic',
|
||||
'email-type': 'Email Type',
|
||||
'filter': 'Filter',
|
||||
'action': 'Action',
|
||||
'status': 'Status',
|
||||
'source': 'Source',
|
||||
};
|
||||
|
||||
function renderTagGroups(tags: TagDefinition[]): string {
|
||||
const groups = new Map<TagType, TagDefinition[]>();
|
||||
for (const tag of tags) {
|
||||
const list = groups.get(tag.type) ?? [];
|
||||
list.push(tag);
|
||||
groups.set(tag.type, list);
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
for (const type of TYPE_ORDER) {
|
||||
const group = groups.get(type);
|
||||
if (!group || group.length === 0) continue;
|
||||
|
||||
const label = TYPE_LABELS[type];
|
||||
const rows = group.map(t => {
|
||||
const example = t.example ?? '';
|
||||
return `| ${t.tag} | ${t.description} | ${example} |`;
|
||||
});
|
||||
|
||||
sections.push(
|
||||
`## ${label}\n\n` +
|
||||
`| Tag | Description | Example |\n` +
|
||||
`|-----|-------------|---------|\n` +
|
||||
rows.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
return `# Tag System Reference\n\n${sections.join('\n\n')}`;
|
||||
}
|
||||
|
||||
export function renderNoteEffectRules(): string {
|
||||
const tags = getTagDefinitions();
|
||||
const skipByType = new Map<string, string[]>();
|
||||
const createByType = new Map<string, string[]>();
|
||||
|
||||
for (const t of tags) {
|
||||
const effect = t.noteEffect ?? 'none';
|
||||
if (effect === 'none') continue;
|
||||
const label = TYPE_LABELS[t.type] ?? t.type;
|
||||
const map = effect === 'skip' ? skipByType : createByType;
|
||||
const list = map.get(label) ?? [];
|
||||
list.push(t.tag.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' '));
|
||||
map.set(label, list);
|
||||
}
|
||||
|
||||
const formatList = (map: Map<string, string[]>) =>
|
||||
Array.from(map.entries()).map(([type, tags]) => `- **${type}:** ${tags.join(', ')}`).join('\n');
|
||||
|
||||
return [
|
||||
`**SKIP if the email has ANY of these labels (skip labels override everything):**`,
|
||||
formatList(skipByType),
|
||||
``,
|
||||
`**CREATE/UPDATE notes if the email has ANY of these labels (and no skip labels present):**`,
|
||||
formatList(createByType),
|
||||
``,
|
||||
`**Logic:** If even one label falls in the "skip" list, skip the email — skip labels are hard filters that override create labels.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function renderTagSystemForNotes(): string {
|
||||
const tags = getTagDefinitions().filter(t => t.applicability !== 'email');
|
||||
return renderTagGroups(tags);
|
||||
}
|
||||
|
||||
export function renderTagSystemForEmails(): string {
|
||||
const tags = getTagDefinitions().filter(t => t.applicability !== 'notes');
|
||||
return renderTagGroups(tags);
|
||||
}
|
||||
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