mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +02:00
Compare commits
20 commits
55776eddd0
...
b1bbfdbc9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1bbfdbc9e | ||
|
|
ef7bc579a1 | ||
|
|
44fd94d5e6 | ||
|
|
1ac4e648da | ||
|
|
50bce6c1d6 | ||
|
|
924e136505 | ||
|
|
1d29ca8886 | ||
|
|
79a21c715e | ||
|
|
1b81a42ed3 | ||
|
|
9c010dabd8 | ||
|
|
89b6b963a2 | ||
|
|
d854b3f4f0 | ||
|
|
de24293cce | ||
|
|
52f5922495 | ||
|
|
34d64382d5 | ||
|
|
6da447ef3b | ||
|
|
4a16af6e02 | ||
|
|
670b623fe6 | ||
|
|
e1c6758a3f | ||
|
|
598aeb59cc |
44 changed files with 439 additions and 338 deletions
54
README.md
54
README.md
|
|
@ -35,18 +35,18 @@ Rowboat connects to your email and meeting notes, builds a long-lived knowledge
|
|||
You can do things like:
|
||||
- `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph
|
||||
- `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note)
|
||||
- Track a person, company or topic through live notes
|
||||
- Visualize, edit, and update your knowledge graph anytime (it’s just Markdown)
|
||||
- Record voice memos that automatically capture and update key takeaways in the graph
|
||||
|
||||
Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads)
|
||||
|
||||
⭐ If you find Rowboat useful, please star the repo. It helps more people find it.
|
||||
|
||||
## Demo
|
||||
[](https://www.youtube.com/watch?v=7xTpciZCfpw)
|
||||
|
||||
|
||||
[](https://www.youtube.com/watch?v=5AWoGo-L16I)
|
||||
|
||||
[Watch the full video](https://www.youtube.com/watch?v=5AWoGo-L16I)
|
||||
[Watch the full video](https://www.youtube.com/watch?v=7xTpciZCfpw)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -60,23 +60,27 @@ Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/do
|
|||
To connect Google services (Gmail, Calendar, and Drive), follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md).
|
||||
|
||||
### Voice input
|
||||
To enable voice input and voice notes (optional), add a Deepgram API key in ~/.rowboat/config/deepgram.json:
|
||||
To enable voice input and voice notes (optional), add a Deepgram API key in `~/.rowboat/config/deepgram.json`
|
||||
|
||||
### Voice output
|
||||
|
||||
To enable voice output (optional), add an ElevenLabs API key in `~/.rowboat/config/elevenlabs.json`
|
||||
|
||||
### Web search
|
||||
|
||||
To use Exa research search (optional), add the Exa API key in `~/.rowboat/config/exa-search.json`
|
||||
|
||||
### External tools
|
||||
|
||||
To enable external tools (optional), you can add any MCP server or use Composio tools by adding an API key in `~/.rowboat/config/composio.json`
|
||||
|
||||
All API key files use the same format:
|
||||
```
|
||||
{
|
||||
"apiKey": "<key>"
|
||||
}
|
||||
```
|
||||
|
||||
### Voice output
|
||||
|
||||
To enable voice output (optional), add a Elevenlabs API key in ~/.rowboat/config/elevenlabs.json
|
||||
|
||||
### Web search
|
||||
|
||||
To use Exa research search (optional), add the Exa API key in ~/.rowboat/config/exa-search.json.
|
||||
|
||||
(same format as above)
|
||||
|
||||
## What it does
|
||||
|
||||
Rowboat is a **local-first AI coworker** that can:
|
||||
|
|
@ -90,8 +94,10 @@ Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Mark
|
|||
|
||||
Rowboat builds memory from the work you already do, including:
|
||||
- **Gmail** (email)
|
||||
- **Granola** (meeting notes)
|
||||
- **Fireflies** (meeting notes)
|
||||
- **Google Calendar**
|
||||
- **Rowboat meeting notes** or **Fireflies**
|
||||
|
||||
It also contains a library of product integrations through Composio.dev
|
||||
|
||||
## How it’s different
|
||||
|
||||
|
|
@ -113,17 +119,15 @@ The result is memory that compounds, rather than retrieval that starts cold ever
|
|||
- **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped
|
||||
- **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions)
|
||||
|
||||
## Background agents
|
||||
## Live notes
|
||||
|
||||
Rowboat can spin up **background agents** to do repeatable work automatically - so routine tasks happen without you having to ask every time.
|
||||
Live notes are notes that stay updated automatically. You can create one by typing '@rowboat' on a note.
|
||||
|
||||
Examples:
|
||||
- Draft email replies in the background (grounded in your past context and commitments)
|
||||
- Generate a daily voice note each morning (agenda, priorities, upcoming meetings)
|
||||
- Create recurring project updates from the latest emails/notes
|
||||
- Keep your knowledge graph up to date as new information comes in
|
||||
- Track a competitor or market topic across X, Reddit, and the news
|
||||
- Monitor a person, project, or deal across web or your communications
|
||||
- Keep a running summary of any subject you care about
|
||||
|
||||
You control what runs, when it runs, and what gets written back into your local Markdown vault.
|
||||
Everything is written back into your local Markdown vault. You control what runs and when.
|
||||
|
||||
## Bring your own model
|
||||
|
||||
|
|
|
|||
BIN
apps/docs/docs/img/google-setup/07-enter-credentials.png
Normal file
BIN
apps/docs/docs/img/google-setup/07-enter-credentials.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
|
|
@ -22,10 +22,11 @@ export interface AuthServerResult {
|
|||
/**
|
||||
* Create a local HTTP server to handle OAuth callback
|
||||
* Listens on http://localhost:8080/oauth/callback
|
||||
* Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds.
|
||||
*/
|
||||
export function createAuthServer(
|
||||
port: number = DEFAULT_PORT,
|
||||
onCallback: (params: Record<string, string>) => void | Promise<void>
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>
|
||||
): Promise<AuthServerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
|
|
@ -38,8 +39,6 @@ export function createAuthServer(
|
|||
const url = new URL(req.url, `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === OAUTH_CALLBACK_PATH) {
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
|
|
@ -65,9 +64,8 @@ export function createAuthServer(
|
|||
return;
|
||||
}
|
||||
|
||||
// Handle callback - either traditional OAuth with code/state or Composio-style notification
|
||||
// Composio callbacks may not have code/state, just a notification that the flow completed
|
||||
onCallback(Object.fromEntries(url.searchParams.entries()));
|
||||
// Handle callback - pass full URL so params like iss (OpenID Connect) are preserved for token exchange
|
||||
onCallback(url);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
// Set up callback server
|
||||
const timeoutRef: { current: NodeJS.Timeout | null } = { current: null };
|
||||
let callbackHandled = false;
|
||||
const { server } = await createAuthServer(8081, async () => {
|
||||
const { server } = await createAuthServer(8081, async (_callbackUrl) => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
if (callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
|
|
|
|||
|
|
@ -110,6 +110,18 @@ function markdownToHtml(markdown: string, title: string): string {
|
|||
</style></head><body>${html}</body></html>`
|
||||
}
|
||||
|
||||
function resolveShellPath(filePath: string): string {
|
||||
if (filePath.startsWith('~')) {
|
||||
return path.join(os.homedir(), filePath.slice(1));
|
||||
}
|
||||
|
||||
if (path.isAbsolute(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return workspace.resolveWorkspacePath(filePath);
|
||||
}
|
||||
|
||||
type InvokeChannels = ipc.InvokeChannels;
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
||||
|
|
@ -271,7 +283,7 @@ function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceCh
|
|||
|
||||
/**
|
||||
* Start workspace watcher
|
||||
* Watches ~/.rowboat recursively and emits change events to renderer
|
||||
* Watches the configured workspace root recursively and emits change events to renderer
|
||||
*
|
||||
* This should be called once when the app starts (from main.ts).
|
||||
* The watcher runs as a main-process service and catches ALL filesystem changes
|
||||
|
|
@ -460,7 +472,10 @@ export function setupIpcHandlers() {
|
|||
return { success: true };
|
||||
},
|
||||
'oauth:connect': async (_event, args) => {
|
||||
return await connectProvider(args.provider, args.clientId?.trim());
|
||||
const credentials = args.clientId && args.clientSecret
|
||||
? { clientId: args.clientId.trim(), clientSecret: args.clientSecret.trim() }
|
||||
: undefined;
|
||||
return await connectProvider(args.provider, credentials);
|
||||
},
|
||||
'oauth:disconnect': async (_event, args) => {
|
||||
return await disconnectProvider(args.provider);
|
||||
|
|
@ -604,24 +619,12 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
// Shell integration handlers
|
||||
'shell:openPath': async (_event, args) => {
|
||||
let filePath = args.path;
|
||||
if (filePath.startsWith('~')) {
|
||||
filePath = path.join(os.homedir(), filePath.slice(1));
|
||||
} else if (!path.isAbsolute(filePath)) {
|
||||
// Workspace-relative path — resolve against ~/.rowboat/
|
||||
filePath = path.join(os.homedir(), '.rowboat', filePath);
|
||||
}
|
||||
const filePath = resolveShellPath(args.path);
|
||||
const error = await shell.openPath(filePath);
|
||||
return { error: error || undefined };
|
||||
},
|
||||
'shell:readFileBase64': async (_event, args) => {
|
||||
let filePath = args.path;
|
||||
if (filePath.startsWith('~')) {
|
||||
filePath = path.join(os.homedir(), filePath.slice(1));
|
||||
} else if (!path.isAbsolute(filePath)) {
|
||||
// Workspace-relative path — resolve against ~/.rowboat/
|
||||
filePath = path.join(os.homedir(), '.rowboat', filePath);
|
||||
}
|
||||
const filePath = resolveShellPath(args.path);
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.size > 10 * 1024 * 1024) {
|
||||
throw new Error('File too large (>10MB)');
|
||||
|
|
|
|||
|
|
@ -112,6 +112,8 @@ function createWindow() {
|
|||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 600,
|
||||
minHeight: 480,
|
||||
show: false, // Don't show until ready
|
||||
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
|
||||
titleBarStyle: "hiddenInset",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,43 @@ import { emitOAuthEvent } from './ipc.js';
|
|||
|
||||
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
|
||||
|
||||
/** Top-level openid-client messages that often wrap a more specific cause. */
|
||||
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
|
||||
|
||||
function firstCauseMessage(error: unknown): string | undefined {
|
||||
if (error == null || typeof error !== 'object' || !('cause' in error)) {
|
||||
return undefined;
|
||||
}
|
||||
const cause = (error as { cause?: unknown }).cause;
|
||||
if (cause instanceof Error && cause.message.trim()) {
|
||||
return cause.message;
|
||||
}
|
||||
if (typeof cause === 'string' && cause.trim()) {
|
||||
return cause;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing message for token-exchange failures. Prefer the first cause message when
|
||||
* the top-level message is opaque (common for openid-client) or when code is OAUTH_INVALID_RESPONSE.
|
||||
* The catch block below still logs the full cause chain for any error; this helper stays conservative.
|
||||
*/
|
||||
function getOAuthErrorMessage(error: unknown): string {
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||
const code = error != null && typeof error === 'object' && 'code' in error
|
||||
? (error as { code?: string }).code
|
||||
: undefined;
|
||||
const causeMsg = firstCauseMessage(error);
|
||||
if (code === 'OAUTH_INVALID_RESPONSE' && causeMsg) {
|
||||
return causeMsg;
|
||||
}
|
||||
if (causeMsg && OPAQUE_OAUTH_TOP_MESSAGES.has(msg.trim().toLowerCase())) {
|
||||
return causeMsg;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Store active OAuth flows (state -> { codeVerifier, provider, config })
|
||||
const activeFlows = new Map<string, {
|
||||
codeVerifier: string;
|
||||
|
|
@ -74,19 +111,19 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
|
|||
/**
|
||||
* Get or create OAuth configuration for a provider
|
||||
*/
|
||||
async function getProviderConfiguration(provider: string, clientIdOverride?: string): Promise<Configuration> {
|
||||
async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise<Configuration> {
|
||||
const config = await getProviderConfig(provider);
|
||||
const resolveClientId = async (): Promise<string> => {
|
||||
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
|
||||
if (config.client.mode === 'static' && config.client.clientId) {
|
||||
return config.client.clientId;
|
||||
return { clientId: config.client.clientId, clientSecret: credentialsOverride?.clientSecret };
|
||||
}
|
||||
if (clientIdOverride) {
|
||||
return clientIdOverride;
|
||||
if (credentialsOverride) {
|
||||
return { clientId: credentialsOverride.clientId, clientSecret: credentialsOverride.clientSecret };
|
||||
}
|
||||
const oauthRepo = getOAuthRepo();
|
||||
const { clientId } = await oauthRepo.read(provider);
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
const connection = await oauthRepo.read(provider);
|
||||
if (connection.clientId) {
|
||||
return { clientId: connection.clientId, clientSecret: connection.clientSecret ?? undefined };
|
||||
}
|
||||
throw new Error(`${provider} client ID not configured. Please provide a client ID.`);
|
||||
};
|
||||
|
|
@ -95,10 +132,11 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
|
|||
if (config.client.mode === 'static') {
|
||||
// Discover endpoints, use static client ID
|
||||
console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`);
|
||||
const clientId = await resolveClientId();
|
||||
const { clientId, clientSecret } = await resolveClientCredentials();
|
||||
return await oauthClient.discoverConfiguration(
|
||||
config.discovery.issuer,
|
||||
clientId
|
||||
clientId,
|
||||
clientSecret
|
||||
);
|
||||
} else {
|
||||
// DCR mode - check for existing registration or register new
|
||||
|
|
@ -135,12 +173,13 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
|
|||
}
|
||||
|
||||
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
|
||||
const clientId = await resolveClientId();
|
||||
const { clientId, clientSecret } = await resolveClientCredentials();
|
||||
return oauthClient.createStaticConfiguration(
|
||||
config.discovery.authorizationEndpoint,
|
||||
config.discovery.tokenEndpoint,
|
||||
clientId,
|
||||
config.discovery.revocationEndpoint
|
||||
config.discovery.revocationEndpoint,
|
||||
clientSecret
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -148,7 +187,7 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
|
|||
/**
|
||||
* Initiate OAuth flow for a provider
|
||||
*/
|
||||
export async function connectProvider(provider: string, clientId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
export async function connectProvider(provider: string, credentials?: { clientId: string; clientSecret: string }): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log(`[OAuth] Starting connection flow for ${provider}...`);
|
||||
|
||||
|
|
@ -159,13 +198,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
const providerConfig = await getProviderConfig(provider);
|
||||
|
||||
if (provider === 'google') {
|
||||
if (!clientId) {
|
||||
return { success: false, error: 'Google client ID is required to connect.' };
|
||||
if (!credentials?.clientId || !credentials?.clientSecret) {
|
||||
return { success: false, error: 'Google client ID and client secret are required to connect.' };
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create OAuth configuration
|
||||
const config = await getProviderConfiguration(provider, clientId);
|
||||
const config = await getProviderConfiguration(provider, credentials);
|
||||
|
||||
// Generate PKCE codes
|
||||
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
||||
|
|
@ -187,12 +226,17 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
|
||||
// Create callback server
|
||||
let callbackHandled = false;
|
||||
const { server } = await createAuthServer(8080, async (params: Record<string, string>) => {
|
||||
const { server } = await createAuthServer(8080, async (callbackUrl) => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
if (callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
// Validate state
|
||||
if (params.state !== state) {
|
||||
const receivedState = callbackUrl.searchParams.get('state');
|
||||
if (receivedState == null || receivedState === '') {
|
||||
throw new Error(
|
||||
'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.'
|
||||
);
|
||||
}
|
||||
if (receivedState !== state) {
|
||||
throw new Error('Invalid state parameter - possible CSRF attack');
|
||||
}
|
||||
|
||||
|
|
@ -202,10 +246,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
}
|
||||
|
||||
try {
|
||||
// Build callback URL for token exchange
|
||||
const callbackUrl = new URL(`${REDIRECT_URI}?${new URLSearchParams(params).toString()}`);
|
||||
|
||||
// Exchange code for tokens
|
||||
// Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds
|
||||
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
|
||||
const tokens = await oauthClient.exchangeCodeForTokens(
|
||||
flow.config,
|
||||
|
|
@ -214,13 +255,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
state
|
||||
);
|
||||
|
||||
// Save tokens
|
||||
// Save tokens and credentials
|
||||
console.log(`[OAuth] Token exchange successful for ${provider}`);
|
||||
await oauthRepo.upsert(provider, { tokens });
|
||||
if (provider === 'google' && clientId) {
|
||||
await oauthRepo.upsert(provider, { clientId });
|
||||
}
|
||||
await oauthRepo.upsert(provider, { error: null });
|
||||
await oauthRepo.upsert(provider, {
|
||||
tokens,
|
||||
...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Trigger immediate sync for relevant providers
|
||||
if (provider === 'google') {
|
||||
|
|
@ -234,7 +275,15 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
emitOAuthEvent({ provider, success: true });
|
||||
} catch (error) {
|
||||
console.error('OAuth token exchange failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
|
||||
let cause: unknown = error;
|
||||
while (cause != null && typeof cause === 'object' && 'cause' in cause) {
|
||||
cause = (cause as { cause?: unknown }).cause;
|
||||
if (cause != null) {
|
||||
console.error('[OAuth] Caused by:', cause);
|
||||
}
|
||||
}
|
||||
const errorMessage = getOAuthErrorMessage(error);
|
||||
emitOAuthEvent({ provider, success: false, error: errorMessage });
|
||||
throw error;
|
||||
} finally {
|
||||
|
|
@ -302,8 +351,8 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
|||
export async function getAccessToken(provider: string): Promise<string | null> {
|
||||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
|
||||
const { tokens } = await oauthRepo.read(provider);
|
||||
|
||||
let { tokens } = await oauthRepo.read(provider);
|
||||
if (!tokens) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -319,11 +368,12 @@ export async function getAccessToken(provider: string): Promise<string | null> {
|
|||
try {
|
||||
// Get configuration for refresh
|
||||
const config = await getProviderConfiguration(provider);
|
||||
|
||||
|
||||
// Refresh token, preserving existing scopes
|
||||
const existingScopes = tokens.scopes;
|
||||
const refreshedTokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes);
|
||||
await oauthRepo.upsert(provider, { tokens });
|
||||
await oauthRepo.upsert(provider, { tokens: refreshedTokens });
|
||||
tokens = refreshedTokens;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Token refresh failed';
|
||||
await oauthRepo.upsert(provider, { error: message });
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { bus } from '@x/core/dist/runs/bus.js';
|
|||
|
||||
async function main() {
|
||||
const { id } = await runsCore.createRun({
|
||||
// this will expect an agent file to exist at ~/.rowboat/agents/test-agent.md
|
||||
// this expects an agent file to exist at WorkDir/agents/test-agent.md
|
||||
agentId: 'test-agent',
|
||||
});
|
||||
console.log(`created run: ${id}`);
|
||||
|
|
@ -16,4 +16,4 @@ async function main() {
|
|||
console.log(`created message: ${msgId}`);
|
||||
}
|
||||
|
||||
main();
|
||||
main();
|
||||
|
|
|
|||
|
|
@ -124,8 +124,8 @@ const TITLEBAR_BUTTON_PX = 32
|
|||
const TITLEBAR_BUTTON_GAP_PX = 4
|
||||
const TITLEBAR_HEADER_GAP_PX = 8
|
||||
const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
|
||||
const TITLEBAR_BUTTONS_COLLAPSED = 5
|
||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4
|
||||
const TITLEBAR_BUTTONS_COLLAPSED = 4
|
||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
|
||||
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
||||
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||
|
||||
|
|
@ -446,12 +446,8 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
|||
return true // both graph
|
||||
}
|
||||
|
||||
/** Sidebar toggle + back/forward nav */
|
||||
/** Sidebar toggle + utility buttons (fixed position, top-left) */
|
||||
function FixedSidebarToggle({
|
||||
onNavigateBack,
|
||||
onNavigateForward,
|
||||
canNavigateBack,
|
||||
canNavigateForward,
|
||||
onNewChat,
|
||||
onOpenSearch,
|
||||
meetingState,
|
||||
|
|
@ -460,10 +456,6 @@ function FixedSidebarToggle({
|
|||
onToggleMeeting,
|
||||
leftInsetPx,
|
||||
}: {
|
||||
onNavigateBack: () => void
|
||||
onNavigateForward: () => void
|
||||
canNavigateBack: boolean
|
||||
canNavigateForward: boolean
|
||||
onNewChat: () => void
|
||||
onOpenSearch: () => void
|
||||
meetingState: MeetingTranscriptionState
|
||||
|
|
@ -472,8 +464,7 @@ function FixedSidebarToggle({
|
|||
onToggleMeeting: () => void
|
||||
leftInsetPx: number
|
||||
}) {
|
||||
const { toggleSidebar, state } = useSidebar()
|
||||
const isCollapsed = state === "collapsed"
|
||||
const { toggleSidebar } = useSidebar()
|
||||
return (
|
||||
<div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<div aria-hidden="true" className="h-10 shrink-0" style={{ width: leftInsetPx }} />
|
||||
|
|
@ -536,30 +527,6 @@ function FixedSidebarToggle({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Back / Forward navigation */}
|
||||
{isCollapsed && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateBack}
|
||||
disabled={!canNavigateBack}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateForward}
|
||||
disabled={!canNavigateForward}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label="Go forward"
|
||||
>
|
||||
<ChevronRightIcon className="size-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -584,15 +551,14 @@ function ContentHeader({
|
|||
const isCollapsed = state === "collapsed"
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border px-3 bg-sidebar transition-[padding] duration-200 ease-linear overflow-hidden",
|
||||
// When the sidebar is collapsed the content area shifts left, so we need enough left padding
|
||||
// to avoid overlapping the fixed traffic-lights/toggle/back/forward controls.
|
||||
isCollapsed && !collapsedLeftPaddingPx && "pl-[196px]"
|
||||
)}
|
||||
style={isCollapsed && collapsedLeftPaddingPx ? { paddingLeft: collapsedLeftPaddingPx } : undefined}
|
||||
className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar overflow-hidden"
|
||||
style={{
|
||||
paddingLeft: isCollapsed ? (collapsedLeftPaddingPx ?? 196) : 12,
|
||||
paddingRight: 12,
|
||||
transition: 'padding-left 200ms linear',
|
||||
}}
|
||||
>
|
||||
{!isCollapsed && onNavigateBack && onNavigateForward ? (
|
||||
{onNavigateBack && onNavigateForward ? (
|
||||
<div className="titlebar-no-drag flex items-center gap-1 pr-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -4553,10 +4519,6 @@ function App() {
|
|||
)}
|
||||
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
|
||||
<FixedSidebarToggle
|
||||
onNavigateBack={() => { void navigateBack() }}
|
||||
onNavigateForward={() => { void navigateForward() }}
|
||||
canNavigateBack={canNavigateBack}
|
||||
canNavigateForward={canNavigateForward}
|
||||
onNewChat={handleNewChatTab}
|
||||
onOpenSearch={() => setIsSearchOpen(true)}
|
||||
meetingState={meetingTranscription.state}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const GOOGLE_CLIENT_ID_SETUP_GUIDE_URL =
|
|||
interface GoogleClientIdModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (clientId: string) => void
|
||||
onSubmit: (clientId: string, clientSecret: string) => void
|
||||
isSubmitting?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
|
@ -30,19 +30,22 @@ export function GoogleClientIdModal({
|
|||
description,
|
||||
}: GoogleClientIdModalProps) {
|
||||
const [clientId, setClientId] = useState("")
|
||||
const [clientSecret, setClientSecret] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setClientId("")
|
||||
setClientSecret("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const trimmedClientId = clientId.trim()
|
||||
const isValid = trimmedClientId.length > 0
|
||||
const trimmedClientSecret = clientSecret.trim()
|
||||
const isValid = trimmedClientId.length > 0 && trimmedClientSecret.length > 0
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isValid || isSubmitting) return
|
||||
onSubmit(trimmedClientId)
|
||||
onSubmit(trimmedClientId, trimmedClientSecret)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -50,9 +53,9 @@ export function GoogleClientIdModal({
|
|||
<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>
|
||||
<DialogTitle className="text-lg font-semibold">Google OAuth Credentials</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
{description ?? "Enter the client ID for your Google OAuth app to connect."}
|
||||
{description ?? "Enter the credentials for your Google OAuth app to connect."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
|
@ -76,6 +79,25 @@ export function GoogleClientIdModal({
|
|||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block" htmlFor="google-client-secret">
|
||||
Client Secret
|
||||
</label>
|
||||
<Input
|
||||
id="google-client-secret"
|
||||
type="password"
|
||||
placeholder="GOCSPX-..."
|
||||
value={clientSecret}
|
||||
onChange={(event) => setClientSecret(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Need help?{" "}
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export function HelpPopover({ children, tooltip }: HelpPopoverProps) {
|
|||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleDiscordClick = () => {
|
||||
window.open("https://discord.gg/htdKpBZF", "_blank")
|
||||
window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { setGoogleCredentials } from "@/lib/google-credentials-store"
|
||||
import { toast } from "sonner"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
|
||||
|
|
@ -589,14 +589,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}, [])
|
||||
|
||||
|
||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||
const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: true }
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret })
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error || `Failed to connect to ${provider}`)
|
||||
|
|
@ -618,22 +618,17 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
// 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)
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await startConnect(provider)
|
||||
}, [startConnect])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
|
||||
setGoogleClientId(clientId)
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||
setGoogleCredentials(clientId, clientSecret)
|
||||
setGoogleClientIdOpen(false)
|
||||
startConnect('google', clientId)
|
||||
startConnect('google', { clientId, clientSecret })
|
||||
}, [startConnect])
|
||||
|
||||
// Step indicator - dynamic based on path
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useCallback } from "react"
|
||||
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { setGoogleCredentials } from "@/lib/google-credentials-store"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export interface ProviderState {
|
||||
|
|
@ -576,14 +576,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
return cleanup
|
||||
}, [])
|
||||
|
||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||
const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: true }
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret })
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error || `Failed to connect to ${provider}`)
|
||||
|
|
@ -605,22 +605,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
// 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)
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await startConnect(provider)
|
||||
}, [startConnect])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
|
||||
setGoogleClientId(clientId)
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||
setGoogleCredentials(clientId, clientSecret)
|
||||
setGoogleClientIdOpen(false)
|
||||
startConnect('google', clientId)
|
||||
startConnect('google', { clientId, clientSecret })
|
||||
}, [startConnect])
|
||||
|
||||
// Switch to rowboat path from BYOK inline callout
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ function formatRunTime(ts: string): string {
|
|||
}
|
||||
|
||||
function SyncStatusBar() {
|
||||
const { state, isMobile } = useSidebar()
|
||||
const { state } = useSidebar()
|
||||
const [activeServices, setActiveServices] = useState<Map<string, string>>(new Map())
|
||||
const [popoverOpen, setPopoverOpen] = useState(false)
|
||||
const [logEvents, setLogEvents] = useState<ServiceEventType[]>([])
|
||||
|
|
@ -301,7 +301,7 @@ function SyncStatusBar() {
|
|||
|
||||
return (
|
||||
<>
|
||||
{!isMobile && isCollapsed && isSyncing && (
|
||||
{isCollapsed && isSyncing && (
|
||||
<div
|
||||
className="fixed bottom-4 z-40 flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background shadow-sm"
|
||||
style={{ left: "0.5rem" }}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,10 @@ import { Slot } from "@radix-ui/react-slot"
|
|||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -28,17 +20,14 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
|||
const SIDEBAR_WIDTH = 256 // 16rem in pixels
|
||||
const SIDEBAR_WIDTH_MIN = 200
|
||||
const SIDEBAR_WIDTH_MAX = 480
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_OFFSET = "0px" // Default offset for nested sidebars
|
||||
const SIDEBAR_AUTO_COLLAPSE_WIDTH = 760 // Auto-collapse when window narrower than this
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
sidebarWidth: number
|
||||
setSidebarWidth: (width: number) => void
|
||||
|
|
@ -70,8 +59,6 @@ function SidebarProvider({
|
|||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
const [sidebarWidth, setSidebarWidth] = React.useState(SIDEBAR_WIDTH)
|
||||
const [isResizing, setIsResizing] = React.useState(false)
|
||||
|
||||
|
|
@ -94,10 +81,20 @@ function SidebarProvider({
|
|||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Auto-collapse sidebar when window crosses below the threshold
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${SIDEBAR_AUTO_COLLAPSE_WIDTH - 1}px)`)
|
||||
const onChange = () => {
|
||||
if (mql.matches) setOpen(false)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [setOpen])
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
return setOpen((open) => !open)
|
||||
}, [setOpen])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
|
|
@ -108,16 +105,13 @@ function SidebarProvider({
|
|||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
sidebarWidth,
|
||||
setSidebarWidth,
|
||||
isResizing,
|
||||
setIsResizing,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, sidebarWidth, isResizing]
|
||||
[state, open, setOpen, toggleSidebar, sidebarWidth, isResizing]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -161,7 +155,7 @@ function Sidebar({
|
|||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { state } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
|
|
@ -178,34 +172,9 @@ function Sidebar({
|
|||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
className="group peer text-sidebar-foreground block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
|
|
@ -217,7 +186,7 @@ function Sidebar({
|
|||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent",
|
||||
"[[data-resizing=false]_&]:transition-[width] [[data-resizing=false]_&]:duration-200 [[data-resizing=false]_&]:ease-linear",
|
||||
"in-data-[resizing=false]:transition-[width] in-data-[resizing=false]:duration-200 in-data-[resizing=false]:ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
|
|
@ -228,10 +197,10 @@ function Sidebar({
|
|||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
|
||||
"[[data-resizing=false]_&]:transition-[left,right,width] [[data-resizing=false]_&]:duration-200 [[data-resizing=false]_&]:ease-linear",
|
||||
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width)",
|
||||
"in-data-[resizing=false]:transition-[left,right,width] in-data-[resizing=false]:duration-200 in-data-[resizing=false]:ease-linear",
|
||||
side === "left"
|
||||
? "left-[var(--sidebar-offset)] group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-offset)-var(--sidebar-width))]"
|
||||
? "left-(--sidebar-offset) group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-offset)-var(--sidebar-width))]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
|
|
@ -347,7 +316,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
"peer-data-[variant=inset]:m-2 peer-data-[variant=inset]:ml-0 peer-data-[variant=inset]:rounded-xl peer-data-[variant=inset]:shadow-sm peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -546,7 +515,7 @@ function SidebarMenuButton({
|
|||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
const { state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
|
|
@ -575,7 +544,7 @@ function SidebarMenuButton({
|
|||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
hidden={state !== "collapsed"}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ export function useAnalyticsIdentity() {
|
|||
|
||||
// Count notes for total_notes property
|
||||
try {
|
||||
const entries = await window.ipc.invoke('workspace:readdir', { path: '/' })
|
||||
const entries = await window.ipc.invoke('workspace:readdir', { path: '' })
|
||||
let totalNotes = 0
|
||||
if (entries) {
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'dir') {
|
||||
try {
|
||||
const sub = await window.ipc.invoke('workspace:readdir', { path: `/${entry.name}` })
|
||||
const sub = await window.ipc.invoke('workspace:readdir', { path: `${entry.name}` })
|
||||
totalNotes += sub?.length ?? 0
|
||||
} catch {
|
||||
// skip inaccessible dirs
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useCallback } from "react"
|
||||
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { setGoogleCredentials, clearGoogleCredentials } from "@/lib/google-credentials-store"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export interface ProviderState {
|
||||
|
|
@ -318,14 +318,14 @@ export function useConnectors(active: boolean) {
|
|||
}, [startGmailConnect])
|
||||
|
||||
// OAuth connect/disconnect
|
||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||
const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: true }
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret })
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error || (provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`))
|
||||
|
|
@ -347,23 +347,18 @@ export function useConnectors(active: boolean) {
|
|||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
setGoogleClientIdDescription(undefined)
|
||||
const existingClientId = getGoogleClientId()
|
||||
if (!existingClientId) {
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
await startConnect(provider, existingClientId)
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await startConnect(provider)
|
||||
}, [startConnect])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
|
||||
setGoogleClientId(clientId)
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||
setGoogleCredentials(clientId, clientSecret)
|
||||
setGoogleClientIdOpen(false)
|
||||
setGoogleClientIdDescription(undefined)
|
||||
startConnect('google', clientId)
|
||||
startConnect('google', { clientId, clientSecret })
|
||||
}, [startConnect])
|
||||
|
||||
const handleDisconnect = useCallback(async (provider: string) => {
|
||||
|
|
@ -377,7 +372,7 @@ export function useConnectors(active: boolean) {
|
|||
|
||||
if (result.success) {
|
||||
if (provider === 'google') {
|
||||
clearGoogleClientId()
|
||||
clearGoogleCredentials()
|
||||
}
|
||||
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
toast.success(provider === 'rowboat' ? 'Logged out of Rowboat' : `Disconnected from ${displayName}`)
|
||||
|
|
@ -562,7 +557,7 @@ export function useConnectors(active: boolean) {
|
|||
handleDisconnect,
|
||||
startConnect,
|
||||
|
||||
// Google client ID modal
|
||||
// Google credentials modal
|
||||
googleClientIdOpen,
|
||||
setGoogleClientIdOpen,
|
||||
googleClientIdDescription,
|
||||
|
|
|
|||
|
|
@ -55,10 +55,10 @@ export function useOAuth(provider: string) {
|
|||
return cleanup;
|
||||
}, [provider, checkConnection]);
|
||||
|
||||
const connect = useCallback(async (clientId?: string) => {
|
||||
const connect = useCallback(async (credentials?: { clientId: string; clientSecret: string }) => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, clientId });
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret });
|
||||
if (result.success) {
|
||||
// OAuth flow started - keep isConnecting state, wait for event
|
||||
// Event listener will handle the actual completion
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
let googleClientId: string | null = null;
|
||||
|
||||
export function getGoogleClientId(): string | null {
|
||||
return googleClientId;
|
||||
}
|
||||
|
||||
export function setGoogleClientId(clientId: string): void {
|
||||
const trimmed = clientId.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
googleClientId = trimmed;
|
||||
}
|
||||
|
||||
export function clearGoogleClientId(): void {
|
||||
googleClientId = null;
|
||||
}
|
||||
23
apps/x/apps/renderer/src/lib/google-credentials-store.ts
Normal file
23
apps/x/apps/renderer/src/lib/google-credentials-store.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
interface GoogleCredentials {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
let credentials: GoogleCredentials | null = null;
|
||||
|
||||
export function getGoogleCredentials(): GoogleCredentials | null {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
export function setGoogleCredentials(clientId: string, clientSecret: string): void {
|
||||
const trimmedId = clientId.trim();
|
||||
const trimmedSecret = clientSecret.trim();
|
||||
if (!trimmedId || !trimmedSecret) {
|
||||
return;
|
||||
}
|
||||
credentials = { clientId: trimmedId, clientSecret: trimmedSecret };
|
||||
}
|
||||
|
||||
export function clearGoogleCredentials(): void {
|
||||
credentials = null;
|
||||
}
|
||||
|
|
@ -112,10 +112,10 @@ Users can interact with the knowledge graph through you, open it directly in Obs
|
|||
## How to Access the Knowledge Graph
|
||||
|
||||
**CRITICAL PATH REQUIREMENT:**
|
||||
- The workspace root is \`~/.rowboat/\`
|
||||
- The workspace root is the configured workdir
|
||||
- The knowledge base is in the \`knowledge/\` subfolder
|
||||
- When using workspace tools, ALWAYS include \`knowledge/\` in the path
|
||||
- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or \`path: "~/.rowboat"\`
|
||||
- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or any absolute path to the workspace root
|
||||
- **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
|
||||
|
||||
Use the builtin workspace tools to search and read the knowledge base:
|
||||
|
|
@ -212,16 +212,16 @@ For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integ
|
|||
${runtimeContextPrompt}
|
||||
|
||||
## Workspace Access & Scope
|
||||
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
|
||||
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
|
||||
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
|
||||
- **Inside the workspace root:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
|
||||
- **Outside the workspace root (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
|
||||
- **IMPORTANT:** Do NOT access files outside the workspace root unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
|
||||
|
||||
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
|
||||
**CRITICAL - When the user asks you to work with files outside the workspace root:**
|
||||
- Follow the detected runtime platform above for shell syntax and filesystem path style.
|
||||
- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS).
|
||||
- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\<name>\\Desktop\`).
|
||||
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
|
||||
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
|
||||
- NEVER say "I can only run commands inside the workspace root" or "I don't have access to your Desktop" - just use \`executeCommand\`.
|
||||
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
|
||||
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
|
||||
- NEVER ask what OS the user is on if runtime platform is already available.
|
||||
|
|
@ -244,14 +244,14 @@ ${runtimeContextPrompt}
|
|||
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
|
||||
- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance.
|
||||
|
||||
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
|
||||
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside the workspace root, always use these instead of \`executeCommand\`.
|
||||
|
||||
**Shell commands via \`executeCommand\`:**
|
||||
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`~/.rowboat/config/security.json\` and run immediately.
|
||||
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`config/security.json\` within the workspace root and run immediately.
|
||||
- Commands not on the pre-approved list will trigger a one-time approval prompt for the user — this is fine and expected, just a minor friction. Do NOT let this stop you from running commands you need.
|
||||
- **Never say "I can't run this command"** or ask the user to run something manually. Just call \`executeCommand\` and let the approval flow handle it.
|
||||
- When calling \`executeCommand\`, do NOT provide the \`cwd\` parameter unless absolutely necessary. The default working directory is already set to the workspace root.
|
||||
- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
|
||||
- Always confirm with the user before executing commands that modify files outside the workspace root (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
|
||||
|
||||
**CRITICAL: MCP Server Configuration**
|
||||
- ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Load this skill whenever a user wants to inspect, create, edit, or schedule back
|
|||
- Agents configure a model, tools (in frontmatter), and instructions (in the body)
|
||||
- Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents**
|
||||
- **"Workflows" are just agents that orchestrate other agents** by having them as tools
|
||||
- **Background agents run on schedules** defined in ` + "`~/.rowboat/config/agent-schedule.json`" + `
|
||||
- **Background agents run on schedules** defined in ` + "`config/agent-schedule.json`" + ` within the workspace root
|
||||
|
||||
## How multi-agent workflows work
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ Load this skill whenever a user wants to inspect, create, edit, or schedule back
|
|||
|
||||
## Scheduling Background Agents
|
||||
|
||||
Background agents run automatically based on schedules defined in ` + "`~/.rowboat/config/agent-schedule.json`" + `.
|
||||
Background agents run automatically based on schedules defined in ` + "`config/agent-schedule.json`" + ` in the workspace root.
|
||||
|
||||
### Schedule Configuration File
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ You can add a ` + "`description`" + ` field to describe what the agent does. Thi
|
|||
|
||||
**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner.
|
||||
|
||||
The runner automatically tracks execution state in ` + "`~/.rowboat/config/agent-schedule-state.json`" + `:
|
||||
The runner automatically tracks execution state in ` + "`config/agent-schedule-state.json`" + ` in the workspace root:
|
||||
- ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules)
|
||||
- ` + "`lastRunAt`" + `: When the agent last ran
|
||||
- ` + "`nextRunAt`" + `: When the agent will run next
|
||||
|
|
@ -410,7 +410,7 @@ Create a morning briefing:
|
|||
Execute these steps in sequence. Don't ask for human input.
|
||||
` + "```" + `
|
||||
|
||||
**4. Schedule the workflow** in ` + "`~/.rowboat/config/agent-schedule.json`" + `:
|
||||
**4. Schedule the workflow** in ` + "`config/agent-schedule.json`" + `:
|
||||
` + "```json" + `
|
||||
{
|
||||
"agents": {
|
||||
|
|
@ -548,7 +548,7 @@ Use the search tool to find information on the web.
|
|||
5. When creating multi-agent workflows, create an orchestrator agent
|
||||
6. Add other agents as tools with ` + "`type: agent`" + ` for chaining
|
||||
7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations
|
||||
8. Configure schedules in ` + "`~/.rowboat/config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file)
|
||||
8. Configure schedules in ` + "`config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file)
|
||||
9. Confirm work done and outline next steps once changes are complete
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Agents can use builtin tools by declaring them in the YAML frontmatter \`tools\`
|
|||
### executeCommand
|
||||
**The most powerful and versatile builtin tool** - Execute any bash/shell command and get the output.
|
||||
|
||||
**Security note:** Commands are filtered through \`.rowboat/config/security.json\`. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy.
|
||||
**Security note:** Commands are filtered through \`config/security.json\` in the workspace root. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy.
|
||||
|
||||
**Agent tool declaration (YAML frontmatter):**
|
||||
\`\`\`yaml
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ workspace-readFile("knowledge/Projects/[Project].md")
|
|||
|
||||
## Document Locations
|
||||
|
||||
Documents are stored in \`~/.rowboat/knowledge/\` with subfolders:
|
||||
Documents are stored in \`knowledge/\` within the workspace root, with subfolders:
|
||||
- \`People/\` - Notes about individuals
|
||||
- \`Organizations/\` - Notes about companies, teams
|
||||
- \`Projects/\` - Project documentation
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ You are helping the user draft email responses. Use their calendar and knowledge
|
|||
|
||||
**BEFORE drafting any email, you MUST look up the person/organization in the knowledge base.**
|
||||
|
||||
**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not root, not \`~/.rowboat\`).
|
||||
**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not the workspace root, not an absolute path).
|
||||
- **WRONG:** \`path: ""\` or \`path: "."\`
|
||||
- **CORRECT:** \`path: "knowledge/"\`
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ You are helping the user prepare for meetings by gathering context from their kn
|
|||
|
||||
**BEFORE creating any meeting brief, you MUST look up the attendees in the knowledge base.**
|
||||
|
||||
**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not root, not \`~/.rowboat\`).
|
||||
**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not the workspace root, not an absolute path).
|
||||
- **WRONG:** \`path: ""\` or \`path: "."\`
|
||||
- **CORRECT:** \`path: "knowledge/"\`
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ You interact with Slack by running **agent-slack** commands through \`executeCom
|
|||
|
||||
## 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.
|
||||
Before any Slack operation, read \`config/slack.json\` from the workspace root. 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.
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ 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.
|
||||
**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`config/slack.json\` from the workspace root 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -1092,14 +1092,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "<your-key>" }',
|
||||
error: `Exa Search API key not configured. Create ${exaConfigPath} with { "apiKey": "<your-key>" }`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json',
|
||||
error: `Exa Search API key is empty. Set "apiKey" in ${exaConfigPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,9 +37,10 @@ function toOAuthTokens(response: client.TokenEndpointResponse): OAuthTokens {
|
|||
*/
|
||||
export async function discoverConfiguration(
|
||||
issuerUrl: string,
|
||||
clientId: string
|
||||
clientId: string,
|
||||
clientSecret?: string
|
||||
): Promise<client.Configuration> {
|
||||
const cacheKey = `${issuerUrl}:${clientId}`;
|
||||
const cacheKey = `${issuerUrl}:${clientId}:${clientSecret ? 'secret' : 'none'}`;
|
||||
|
||||
const cached = configCache.get(cacheKey);
|
||||
if (cached) {
|
||||
|
|
@ -50,8 +51,8 @@ export async function discoverConfiguration(
|
|||
const config = await client.discovery(
|
||||
new URL(issuerUrl),
|
||||
clientId,
|
||||
undefined, // no client_secret (PKCE flow)
|
||||
client.None(), // PKCE doesn't require client authentication
|
||||
clientSecret ?? undefined,
|
||||
clientSecret ? client.ClientSecretPost(clientSecret) : client.None(),
|
||||
{
|
||||
execute: [client.allowInsecureRequests],
|
||||
}
|
||||
|
|
@ -69,7 +70,8 @@ export function createStaticConfiguration(
|
|||
authorizationEndpoint: string,
|
||||
tokenEndpoint: string,
|
||||
clientId: string,
|
||||
revocationEndpoint?: string
|
||||
revocationEndpoint?: string,
|
||||
clientSecret?: string
|
||||
): client.Configuration {
|
||||
console.log(`[OAuth] Creating static configuration (no discovery)`);
|
||||
|
||||
|
|
@ -86,8 +88,8 @@ export function createStaticConfiguration(
|
|||
return new client.Configuration(
|
||||
serverMetadata,
|
||||
clientId,
|
||||
undefined, // no client_secret
|
||||
client.None() // PKCE auth
|
||||
clientSecret ?? undefined,
|
||||
clientSecret ? client.ClientSecretPost(clientSecret) : client.None()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import z from 'zod';
|
|||
const ProviderConnectionSchema = z.object({
|
||||
tokens: OAuthTokens.nullable().optional(),
|
||||
clientId: z.string().nullable().optional(),
|
||||
clientSecret: z.string().nullable().optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ const OAuthConfigSchema = z.object({
|
|||
const ClientFacingConfigSchema = z.record(z.string(), z.object({
|
||||
connected: z.boolean(),
|
||||
error: z.string().nullable().optional(),
|
||||
clientId: z.string().nullable().optional(),
|
||||
}));
|
||||
|
||||
const LegacyOauthConfigSchema = z.record(z.string(), OAuthTokens);
|
||||
|
|
@ -111,8 +113,9 @@ export class FSOAuthRepo implements IOAuthRepo {
|
|||
clientFacingConfig[provider] = {
|
||||
connected: !!providerConfig.tokens,
|
||||
error: providerConfig.error,
|
||||
clientId: providerConfig.clientId ?? null,
|
||||
};
|
||||
}
|
||||
return clientFacingConfig;
|
||||
return ClientFacingConfigSchema.parse(clientFacingConfig);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,25 @@ import fs from "fs";
|
|||
import { homedir } from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
function resolveWorkDir(): string {
|
||||
const configured = process.env.ROWBOAT_WORKDIR;
|
||||
if (!configured) {
|
||||
return path.join(homedir(), ".rowboat");
|
||||
}
|
||||
|
||||
const expanded = configured === "~"
|
||||
? homedir()
|
||||
: (configured.startsWith("~/") || configured.startsWith("~\\"))
|
||||
? path.join(homedir(), configured.slice(2))
|
||||
: configured;
|
||||
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
// Resolve app root relative to compiled file location (dist/...)
|
||||
// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage
|
||||
export const WorkDir = process.env.ROWBOAT_WORKDIR || path.join(homedir(), ".rowboat");
|
||||
// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage.
|
||||
// Normalize to an absolute path so workspace boundary checks behave consistently.
|
||||
export const WorkDir = resolveWorkDir();
|
||||
|
||||
// Get the directory of this file (for locating bundled assets)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Main orchestrator that:
|
|||
### `graph_state.ts`
|
||||
State management module that tracks which files have been processed:
|
||||
- Uses hybrid mtime + hash approach for change detection
|
||||
- Stores state in `~/.rowboat/knowledge_graph_state.json`
|
||||
- Stores state in `WorkDir/knowledge_graph_state.json`
|
||||
- Provides modular functions for state operations
|
||||
|
||||
### `sync_gmail.ts` & `sync_fireflies.ts`
|
||||
|
|
@ -39,7 +39,7 @@ This is efficient (only hashes potentially changed files) and reliable (confirms
|
|||
|
||||
### State File Structure
|
||||
|
||||
`~/.rowboat/knowledge_graph_state.json`:
|
||||
`WorkDir/knowledge_graph_state.json`:
|
||||
```json
|
||||
{
|
||||
"processedFiles": {
|
||||
|
|
@ -69,7 +69,7 @@ This is efficient (only hashes potentially changed files) and reliable (confirms
|
|||
|
||||
3. **Agent processes batch**
|
||||
- Extracts entities (people, orgs, projects, topics)
|
||||
- Creates/updates notes in `~/.rowboat/knowledge/`
|
||||
- Creates/updates notes in `WorkDir/knowledge/`
|
||||
- Merges information for entities appearing in multiple files
|
||||
|
||||
## Replacing the Change Detection Logic
|
||||
|
|
@ -135,7 +135,7 @@ import { resetGraphState } from './build_graph.js';
|
|||
resetGraphState(); // Clears the state file
|
||||
```
|
||||
|
||||
Or manually delete: `~/.rowboat/knowledge_graph_state.json`
|
||||
Or manually delete: `WorkDir/knowledge_graph_state.json`
|
||||
|
||||
## Note Creation Strictness
|
||||
|
||||
|
|
@ -143,7 +143,7 @@ The system supports three strictness levels that control how aggressively notes
|
|||
|
||||
### Configuration
|
||||
|
||||
Strictness is configured in `~/.rowboat/config/note_creation.json`:
|
||||
Strictness is configured in `WorkDir/config/note_creation.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -218,7 +218,7 @@ Each strictness level has its own agent prompt:
|
|||
Change `BATCH_SIZE` in `build_graph.ts` (currently 25 files per batch)
|
||||
|
||||
### State File Location
|
||||
Change `STATE_FILE` in `graph_state.ts` (currently `~/.rowboat/knowledge_graph_state.json`)
|
||||
Change `STATE_FILE` in `graph_state.ts` (currently `WorkDir/knowledge_graph_state.json`)
|
||||
|
||||
### Hash Algorithm
|
||||
Change `crypto.createHash('sha256')` in `graph_state.ts` to use a different algorithm (md5, sha1, etc.)
|
||||
|
|
|
|||
|
|
@ -18,21 +18,23 @@ export class GoogleClientFactory {
|
|||
client: OAuth2Client | null;
|
||||
tokens: OAuthTokens | null;
|
||||
clientId: string | null;
|
||||
clientSecret: string | null;
|
||||
} = {
|
||||
config: null,
|
||||
client: null,
|
||||
tokens: null,
|
||||
clientId: null,
|
||||
clientSecret: null,
|
||||
};
|
||||
|
||||
private static async resolveClientId(): Promise<string> {
|
||||
private static async resolveCredentials(): Promise<{ clientId: string; clientSecret?: string }> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const { clientId } = await oauthRepo.read(this.PROVIDER_NAME);
|
||||
if (!clientId) {
|
||||
const connection = await oauthRepo.read(this.PROVIDER_NAME);
|
||||
if (!connection.clientId) {
|
||||
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Google client ID missing. Please reconnect.' });
|
||||
throw new Error('Google client ID missing. Please reconnect.');
|
||||
}
|
||||
return clientId;
|
||||
return { clientId: connection.clientId, clientSecret: connection.clientSecret ?? undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,9 +84,11 @@ export class GoogleClientFactory {
|
|||
// Update cached tokens and recreate client
|
||||
this.cache.tokens = refreshedTokens;
|
||||
if (!this.cache.clientId) {
|
||||
this.cache.clientId = await this.resolveClientId();
|
||||
const creds = await this.resolveCredentials();
|
||||
this.cache.clientId = creds.clientId;
|
||||
this.cache.clientSecret = creds.clientSecret ?? null;
|
||||
}
|
||||
this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId);
|
||||
this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
|
||||
console.log(`[OAuth] Token refreshed successfully`);
|
||||
return this.cache.client;
|
||||
} catch (error) {
|
||||
|
|
@ -105,9 +109,11 @@ export class GoogleClientFactory {
|
|||
console.log(`[OAuth] Creating new OAuth2Client instance`);
|
||||
this.cache.tokens = tokens;
|
||||
if (!this.cache.clientId) {
|
||||
this.cache.clientId = await this.resolveClientId();
|
||||
const creds = await this.resolveCredentials();
|
||||
this.cache.clientId = creds.clientId;
|
||||
this.cache.clientSecret = creds.clientSecret ?? null;
|
||||
}
|
||||
this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId);
|
||||
this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
|
||||
return this.cache.client;
|
||||
}
|
||||
|
||||
|
|
@ -138,19 +144,20 @@ export class GoogleClientFactory {
|
|||
this.cache.client = null;
|
||||
this.cache.tokens = null;
|
||||
this.cache.clientId = null;
|
||||
this.cache.clientSecret = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize cached configuration (called once)
|
||||
*/
|
||||
private static async initializeConfigCache(): Promise<void> {
|
||||
const clientId = await this.resolveClientId();
|
||||
const { clientId, clientSecret } = await this.resolveCredentials();
|
||||
|
||||
if (this.cache.config && this.cache.clientId === clientId) {
|
||||
return; // Already initialized for this client ID
|
||||
if (this.cache.config && this.cache.clientId === clientId && this.cache.clientSecret === (clientSecret ?? null)) {
|
||||
return; // Already initialized for these credentials
|
||||
}
|
||||
|
||||
if (this.cache.clientId && this.cache.clientId !== clientId) {
|
||||
if (this.cache.clientId && (this.cache.clientId !== clientId || this.cache.clientSecret !== (clientSecret ?? null))) {
|
||||
this.clearCache();
|
||||
}
|
||||
|
||||
|
|
@ -163,18 +170,19 @@ export class GoogleClientFactory {
|
|||
console.log(`[OAuth] Discovery mode: issuer with static client ID`);
|
||||
this.cache.config = await oauthClient.discoverConfiguration(
|
||||
providerConfig.discovery.issuer,
|
||||
clientId
|
||||
clientId,
|
||||
clientSecret
|
||||
);
|
||||
} else {
|
||||
// DCR mode - need existing registration
|
||||
console.log(`[OAuth] Discovery mode: issuer with DCR`);
|
||||
const clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
|
||||
const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME);
|
||||
|
||||
|
||||
if (!existingRegistration) {
|
||||
throw new Error('Google client not registered. Please connect account first.');
|
||||
}
|
||||
|
||||
|
||||
this.cache.config = await oauthClient.discoverConfiguration(
|
||||
providerConfig.discovery.issuer,
|
||||
existingRegistration.client_id
|
||||
|
|
@ -185,28 +193,29 @@ export class GoogleClientFactory {
|
|||
if (providerConfig.client.mode !== 'static') {
|
||||
throw new Error('DCR requires discovery mode "issuer", not "static"');
|
||||
}
|
||||
|
||||
|
||||
console.log(`[OAuth] Using static endpoints (no discovery)`);
|
||||
this.cache.config = oauthClient.createStaticConfiguration(
|
||||
providerConfig.discovery.authorizationEndpoint,
|
||||
providerConfig.discovery.tokenEndpoint,
|
||||
clientId,
|
||||
providerConfig.discovery.revocationEndpoint
|
||||
providerConfig.discovery.revocationEndpoint,
|
||||
clientSecret
|
||||
);
|
||||
}
|
||||
|
||||
this.cache.clientId = clientId;
|
||||
this.cache.clientSecret = clientSecret ?? null;
|
||||
console.log(`[OAuth] Google OAuth configuration initialized`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OAuth2Client from OAuthTokens
|
||||
*/
|
||||
private static createClientFromTokens(tokens: OAuthTokens, clientId: string): OAuth2Client {
|
||||
// Create OAuth2Client directly (PKCE flow doesn't use client secret)
|
||||
private static createClientFromTokens(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client {
|
||||
const client = new OAuth2Client(
|
||||
clientId,
|
||||
undefined, // client_secret not needed for PKCE
|
||||
clientSecret ?? undefined,
|
||||
undefined // redirect_uri not needed for token usage
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ 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
|
||||
const LOOKBACK_DAYS = 7; // Only sync documents from the last 1 week
|
||||
|
||||
// --- Wake Signal for Immediate Sync Trigger ---
|
||||
let wakeResolve: (() => void) | null = null;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export interface NoteTypeDefinition {
|
|||
extractionGuide: string;
|
||||
}
|
||||
|
||||
// ── Default definitions (used to seed ~/.rowboat/config/notes.json) ──────────
|
||||
// ── Default definitions (used to seed WorkDir/config/notes.json) ─────────────
|
||||
|
||||
const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ 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 LOOKBACK_DAYS = 7;
|
||||
const COMPOSIO_LOOKBACK_DAYS = 7;
|
||||
const REQUIRED_SCOPES = [
|
||||
'https://www.googleapis.com/auth/calendar.events.readonly',
|
||||
'https://www.googleapis.com/auth/drive.readonly'
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { limitEventItems } from './limit_event_items.js';
|
|||
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(WorkDir, 'fireflies_sync_state.json');
|
||||
const LOOKBACK_DAYS = 30; // Last 1 month
|
||||
const LOOKBACK_DAYS = 7; // Last 1 week
|
||||
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
|
||||
const MAX_RETRIES = 3; // Maximum retries for rate-limited requests
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
|
|||
}
|
||||
|
||||
async function performSync() {
|
||||
const LOOKBACK_DAYS = 30; // Default to 1 month
|
||||
const LOOKBACK_DAYS = 7; // Default to 1 week
|
||||
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
|
||||
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
|
||||
|
||||
|
|
@ -444,7 +444,7 @@ async function performSync() {
|
|||
|
||||
// --- Composio-based Sync ---
|
||||
|
||||
const COMPOSIO_LOOKBACK_DAYS = 30;
|
||||
const COMPOSIO_LOOKBACK_DAYS = 7;
|
||||
|
||||
interface ComposioSyncState {
|
||||
last_sync: string; // ISO string
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export interface TagDefinition {
|
|||
noteEffect?: NoteEffect;
|
||||
}
|
||||
|
||||
// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ──────────
|
||||
// ── Default definitions (used to seed WorkDir/config/tags.json) ─────────────
|
||||
|
||||
const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [
|
||||
// ── Relationship — who is this from/about (all create) ────────────────
|
||||
|
|
|
|||
|
|
@ -6,12 +6,66 @@ import { WorkDir } from "../config/config.js";
|
|||
const CACHE_PATH = path.join(WorkDir, "config", "models.dev.json");
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/*
|
||||
"claude-opus-4-6": {
|
||||
"id": "claude-opus-4-6",
|
||||
"name": "Claude Opus 4.6",
|
||||
"family": "claude-opus",
|
||||
"attachment": true,
|
||||
"reasoning": true,
|
||||
"tool_call": true,
|
||||
"temperature": true,
|
||||
"knowledge": "2025-05",
|
||||
"release_date": "2026-02-05",
|
||||
"last_updated": "2026-03-13",
|
||||
"modalities": {
|
||||
"input": [
|
||||
"text",
|
||||
"image",
|
||||
"pdf"
|
||||
],
|
||||
"output": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"open_weights": false,
|
||||
"cost": {
|
||||
"input": 5,
|
||||
"output": 25,
|
||||
"cache_read": 0.5,
|
||||
"cache_write": 6.25
|
||||
},
|
||||
"limit": {
|
||||
"context": 1000000,
|
||||
"output": 128000
|
||||
},
|
||||
"experimental": {
|
||||
"modes": {
|
||||
"fast": {
|
||||
"cost": {
|
||||
"input": 30,
|
||||
"output": 150,
|
||||
"cache_read": 3,
|
||||
"cache_write": 37.5
|
||||
},
|
||||
"provider": {
|
||||
"body": {
|
||||
"speed": "fast"
|
||||
},
|
||||
"headers": {
|
||||
"anthropic-beta": "fast-mode-2026-02-01"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
const ModelsDevModel = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
release_date: z.string().optional(),
|
||||
tool_call: z.boolean().optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
|
|
@ -125,7 +179,6 @@ function pickProvider(
|
|||
}
|
||||
|
||||
function isStableModel(model: z.infer<typeof ModelsDevModel>): boolean {
|
||||
if (model.experimental) return false;
|
||||
if (model.status && ["alpha", "beta", "deprecated"].includes(model.status)) return false;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -141,7 +194,6 @@ function normalizeModels(models: Record<string, z.infer<typeof ModelsDevModel>>)
|
|||
name: model.name,
|
||||
release_date: model.release_date,
|
||||
tool_call: model.tool_call,
|
||||
experimental: model.experimental,
|
||||
status: model.status,
|
||||
}))
|
||||
.filter((model) => isStableModel(model) && supportsToolCall(model))
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ import * as fs from 'fs/promises';
|
|||
import * as path from 'path';
|
||||
import { isSignedIn } from '../account/account.js';
|
||||
import { getAccessToken } from '../auth/tokens.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { API_URL } from '../config/env.js';
|
||||
|
||||
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
||||
|
||||
export interface VoiceConfig {
|
||||
deepgram: { apiKey: string } | null;
|
||||
elevenlabs: { apiKey: string; voiceId?: string } | null;
|
||||
|
|
@ -13,7 +12,7 @@ export interface VoiceConfig {
|
|||
|
||||
async function readJsonConfig(filename: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const configPath = path.join(homedir, '.rowboat', 'config', filename);
|
||||
const configPath = path.join(WorkDir, 'config', filename);
|
||||
const raw = await fs.readFile(configPath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
|
|
@ -51,7 +50,7 @@ export async function synthesizeSpeech(text: string): Promise<{ audioBase64: str
|
|||
console.log('[voice] synthesizing speech via Rowboat proxy, text length:', text.length, 'voiceId:', voiceId);
|
||||
} else {
|
||||
if (!config.elevenlabs) {
|
||||
throw new Error('ElevenLabs not configured. Create ~/.rowboat/config/elevenlabs.json with { "apiKey": "<your-key>" }');
|
||||
throw new Error(`ElevenLabs not configured. Create ${path.join(WorkDir, 'config', 'elevenlabs.json')} with { "apiKey": "<your-key>" }`);
|
||||
}
|
||||
const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0';
|
||||
url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export type WorkspaceChangeCallback = (event: z.infer<typeof WorkspaceChangeEven
|
|||
|
||||
/**
|
||||
* Create a workspace watcher
|
||||
* Watches ~/.rowboat recursively and emits change events via callback
|
||||
* Watches the configured workspace root recursively and emits change events via callback
|
||||
*
|
||||
* Returns a watcher instance that can be closed.
|
||||
* The watcher emits events immediately without debouncing.
|
||||
|
|
@ -74,4 +74,3 @@ export async function createWorkspaceWatcher(
|
|||
|
||||
return watcher;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ const ipcSchemas = {
|
|||
req: z.object({
|
||||
provider: z.string(),
|
||||
clientId: z.string().optional(),
|
||||
clientSecret: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
|
|
@ -252,6 +253,7 @@ const ipcSchemas = {
|
|||
connected: z.boolean(),
|
||||
error: z.string().nullable().optional(),
|
||||
userId: z.string().optional(),
|
||||
clientId: z.string().nullable().optional(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Connecting Google to Rowboat
|
||||
|
||||
Rowboat requires a Google OAuth Client ID to connect to Gmail, Calendar, and Drive. Follow the steps below to generate your Client ID correctly.
|
||||
Rowboat requires Google OAuth credentials (Client ID and Client Secret) to connect to Gmail, Calendar, and Drive. Follow the steps below to generate them.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -114,25 +114,43 @@ Click **Create Credentials → OAuth Client ID**
|
|||
|
||||
Select:
|
||||
|
||||
**Universal Windows Platform (UWP)**
|
||||
**Web application**
|
||||
|
||||
- Name it anything (e.g. `Rowboat Desktop`)
|
||||
- Store ID can be anything (e.g. `test` )
|
||||
- Click **Create**
|
||||
|
||||

|
||||
### Authorized redirect URIs
|
||||
|
||||
Add the following redirect URI:
|
||||
|
||||
- `http://localhost:8080/oauth/callback`
|
||||
|
||||
Use this exactly: no trailing slash, port **8080**. This must match what the app uses for the OAuth callback.
|
||||
|
||||
Click **Create**.
|
||||
|
||||
---
|
||||
|
||||
## 7️⃣ Copy the Client ID
|
||||
## 7️⃣ Copy the Client ID and Client Secret
|
||||
|
||||
After creation, Google will show:
|
||||
|
||||
- **Client ID**
|
||||
- **Client Secret**
|
||||
|
||||
Copy the **Client ID** and paste it into Rowboat where prompted.
|
||||
Copy **both values** and paste them into Rowboat when prompted.
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Error after "Authorization Successful"**
|
||||
|
||||
If the browser shows "Authorization Successful" but the app then shows an error (e.g. "invalid response encountered" or "response parameter \"iss\" (issuer) missing"):
|
||||
|
||||
1. **Check the app logs** (e.g. terminal or dev tools) for the full error. The message there will often indicate the cause (e.g. redirect URI mismatch, missing parameter).
|
||||
2. **Verify redirect URI in Google Cloud Console**: Open [Credentials → your OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients). Ensure `http://localhost:8080/oauth/callback` is listed under **Authorized redirect URIs**.
|
||||
3. **Client type**: Make sure you selected **Web application** as the application type. Other types (Desktop, UWP) may not provide a client secret or may handle redirect URIs differently.
|
||||
|
||||
---
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue