mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
use native gmail in copilot
This commit is contained in:
parent
6b2120d376
commit
b493baf689
7 changed files with 104 additions and 27 deletions
|
|
@ -622,6 +622,9 @@ function emitServiceEvent(event: z.infer<typeof ServiceEvent>): void {
|
|||
}
|
||||
|
||||
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void {
|
||||
// Native connection status (e.g. Google) is baked into the Copilot system
|
||||
// prompt, so any OAuth state change must rebuild it.
|
||||
invalidateCopilotInstructionsCache();
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
|
|
|
|||
|
|
@ -631,7 +631,7 @@ type ViewState =
|
|||
| { type: 'suggested-topics' }
|
||||
| { type: 'meetings' }
|
||||
| { type: 'live-notes' }
|
||||
| { type: 'email'; threadId?: string }
|
||||
| { type: 'email'; threadId?: string; searchQuery?: string }
|
||||
| { type: 'workspace'; path?: string }
|
||||
| { type: 'knowledge-view'; folderPath?: string; mode?: KnowledgeViewMode }
|
||||
| { type: 'chat-history' }
|
||||
|
|
@ -646,7 +646,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
|||
if (a.type === 'task' && b.type === 'task') return a.name === b.name
|
||||
if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '')
|
||||
if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') && (a.mode ?? '') === (b.mode ?? '')
|
||||
if (a.type === 'email' && b.type === 'email') return (a.threadId ?? '') === (b.threadId ?? '')
|
||||
if (a.type === 'email' && b.type === 'email') return (a.threadId ?? '') === (b.threadId ?? '') && (a.searchQuery ?? '') === (b.searchQuery ?? '')
|
||||
return true // both graph
|
||||
}
|
||||
|
||||
|
|
@ -842,6 +842,10 @@ function App() {
|
|||
const [isHomeOpen, setIsHomeOpen] = useState(true)
|
||||
const [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
|
||||
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
|
||||
// Search query pushed into the email view's search box (e.g. the assistant's
|
||||
// read-view email query), so threads outside the synced inbox get real rows.
|
||||
const [emailInitialSearchQuery, setEmailInitialSearchQuery] = useState<string | null>(null)
|
||||
const [emailSearchQueryVersion, setEmailSearchQueryVersion] = useState(0)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{
|
||||
path: string | null
|
||||
graph: boolean
|
||||
|
|
@ -4258,6 +4262,10 @@ function App() {
|
|||
setEmailInitialThreadId(view.threadId)
|
||||
setEmailThreadIdVersion((v) => v + 1)
|
||||
}
|
||||
if (view.searchQuery) {
|
||||
setEmailInitialSearchQuery(view.searchQuery)
|
||||
setEmailSearchQueryVersion((v) => v + 1)
|
||||
}
|
||||
ensureEmailFileTab()
|
||||
return
|
||||
case 'workspace':
|
||||
|
|
@ -4606,7 +4614,15 @@ function App() {
|
|||
break
|
||||
case 'open-view':
|
||||
case 'read-view':
|
||||
navigateToNamedView(result.view as string)
|
||||
// A read-view email search runs against the whole mailbox, so drive
|
||||
// the email view's own search box with the same query — matched
|
||||
// threads get real rows even when they're outside the synced inbox
|
||||
// (and a follow-up open-item can then select them).
|
||||
if (result.action === 'read-view' && result.view === 'email' && typeof result.query === 'string' && result.query.trim()) {
|
||||
void navigateToView({ type: 'email', searchQuery: result.query.trim() })
|
||||
} else {
|
||||
navigateToNamedView(result.view as string)
|
||||
}
|
||||
break
|
||||
case 'open-item': {
|
||||
switch (result.kind) {
|
||||
|
|
@ -6185,7 +6201,7 @@ function App() {
|
|||
</div>
|
||||
) : isEmailOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<EmailView initialThreadId={emailInitialThreadId} threadIdVersion={emailThreadIdVersion} />
|
||||
<EmailView initialThreadId={emailInitialThreadId} threadIdVersion={emailThreadIdVersion} initialSearchQuery={emailInitialSearchQuery} searchQueryVersion={emailSearchQueryVersion} />
|
||||
</div>
|
||||
) : isWorkspaceOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -2383,9 +2383,13 @@ export type EmailViewProps = {
|
|||
initialThreadId?: string | null
|
||||
/** Bump to re-focus on the same threadId after navigating away inside the view. */
|
||||
threadIdVersion?: number
|
||||
/** Query to load into the search box (e.g. the assistant's read-view email search). */
|
||||
initialSearchQuery?: string | null
|
||||
/** Bump to re-apply the same search query. */
|
||||
searchQueryVersion?: number
|
||||
}
|
||||
|
||||
export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = {}) {
|
||||
export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery, searchQueryVersion }: EmailViewProps = {}) {
|
||||
const [important, setImportant] = useState<SectionState>(() => clearLoadingFlag(persistedImportant))
|
||||
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
|
||||
const hadPersistedDataOnMount = useRef(persistedImportant !== null)
|
||||
|
|
@ -2404,6 +2408,13 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
// Externally-driven search (assistant read-view email query): load it into
|
||||
// the search box so the debounced full-mailbox search below runs — matched
|
||||
// threads get real rows even when they're not in the synced inbox.
|
||||
useEffect(() => {
|
||||
const q = initialSearchQuery?.trim()
|
||||
if (q) setQuery(q)
|
||||
}, [initialSearchQuery, searchQueryVersion])
|
||||
const [composeOpen, setComposeOpen] = useState(false)
|
||||
// Stable so the open composer isn't re-rendered on every inbox sync tick.
|
||||
const closeCompose = useCallback(() => setComposeOpen(false), [])
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
|
|||
import container from "../../di/container.js";
|
||||
import type { ICodeModeConfigRepo } from "../../code-mode/repo.js";
|
||||
import type { ISlackConfigRepo } from "../../slack/repo.js";
|
||||
import type { IOAuthRepo } from "../../auth/repo.js";
|
||||
import { knowledgeSourcesRepo } from "../../knowledge/sources/repo.js";
|
||||
|
||||
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||
|
|
@ -14,7 +15,7 @@ const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
|||
* Generate dynamic instructions section for Composio integrations.
|
||||
* Lists connected toolkits and explains the meta-tool discovery flow.
|
||||
*/
|
||||
async function getComposioToolsPrompt(slackConnected: boolean = false): Promise<string> {
|
||||
async function getComposioToolsPrompt(slackConnected: boolean = false, googleConnected: boolean = false): Promise<string> {
|
||||
if (!(await isComposioConfigured())) {
|
||||
return '';
|
||||
}
|
||||
|
|
@ -29,30 +30,50 @@ async function getComposioToolsPrompt(slackConnected: boolean = false): Promise<
|
|||
? ` Exception: **Slack is connected natively** — use the \`slack\` skill for Slack, not Composio.`
|
||||
: '';
|
||||
|
||||
// Google is connected natively, so email reading must not route to Composio.
|
||||
const googleException = googleConnected
|
||||
? ` Exception: **Gmail is connected natively** — read/check/search email with the \`app-navigation\` tool (\`read-view\`, \`view: "email"\`), not Composio.`
|
||||
: '';
|
||||
|
||||
return `
|
||||
## Composio Integrations
|
||||
|
||||
${connectedSection}
|
||||
|
||||
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.${slackException}
|
||||
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.${slackException}${googleException}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true, slackConnected: boolean = false, slackChannelsHint: string = ''): string {
|
||||
// Conditionally include Composio-related instruction sections
|
||||
const emailDraftSuffix = composioEnabled
|
||||
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
|
||||
: ` Do NOT load this skill for reading, fetching, or checking emails.`;
|
||||
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true, slackConnected: boolean = false, slackChannelsHint: string = '', googleConnected: boolean = false): string {
|
||||
// Conditionally include Composio-related instruction sections.
|
||||
// When Google is connected natively, email reading routes to the native
|
||||
// app-navigation email view — never to Composio.
|
||||
const emailDraftSuffix = googleConnected
|
||||
? ` Do NOT load this skill for reading, fetching, or checking emails — Gmail is connected natively; use the \`app-navigation\` tool (\`read-view\`, \`view: "email"\`) for that instead.`
|
||||
: composioEnabled
|
||||
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
|
||||
: ` Do NOT load this skill for reading, fetching, or checking emails.`;
|
||||
|
||||
// When Slack is connected natively (desktop/cURL auth, not Composio), keep it
|
||||
// out of the Composio routing examples so the Copilot doesn't try to connect
|
||||
// it through Composio and wrongly report it as unavailable.
|
||||
const composioServiceExamples = slackConnected
|
||||
? 'Gmail, GitHub, LinkedIn, Notion, Google Sheets, Jira, etc.'
|
||||
: 'Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.';
|
||||
// When Slack or Google is connected natively (not via Composio), keep them
|
||||
// out of the Composio routing examples so the Copilot doesn't route their
|
||||
// requests through Composio or wrongly report them as unavailable.
|
||||
const composioServiceExamples = ['Gmail', 'GitHub', 'Slack', 'LinkedIn', 'Notion', 'Google Sheets', 'Jira']
|
||||
.filter(service => !(slackConnected && service === 'Slack') && !(googleConnected && service === 'Gmail'))
|
||||
.join(', ') + ', etc.';
|
||||
|
||||
const thirdPartyExamples = googleConnected
|
||||
? 'listing issues, sending messages, fetching profiles'
|
||||
: 'reading emails, listing issues, sending messages, fetching profiles';
|
||||
|
||||
const thirdPartyBlock = composioEnabled
|
||||
? `\n**Third-Party Services:** When users ask to interact with any external service (${composioServiceExamples}) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
|
||||
? `\n**Third-Party Services:** When users ask to interact with any external service (${composioServiceExamples}) — ${thirdPartyExamples} — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
|
||||
: '';
|
||||
|
||||
// Google is connected directly in Rowboat (native OAuth + background sync),
|
||||
// independent of Composio. Route email reading to the native app-navigation
|
||||
// email view so the Copilot never sends it through Composio.
|
||||
const gmailBlock = googleConnected
|
||||
? `\n**Gmail (connected natively):** The user's Google account is connected directly in Rowboat, and their email is synced continuously. For ANY request to read, fetch, check, or search emails — "get my last few emails", "any new emails?", "find the email from X", "search my gmail for Y" — load the \`app-navigation\` skill and use the \`app-navigation\` tool's \`read-view\` action with \`view: "email"\`. Its \`query\` parameter runs a LIVE Gmail search over the entire mailbox via the Gmail API with full Gmail search operators (\`from:\`, \`subject:\`, \`before:\`, etc.) — it IS Gmail's real search, so use it even when the user explicitly asks to "search Gmail directly". NEVER route email reading through the \`composio-integration\` skill or Composio Gmail tools, and NEVER tell the user Gmail isn't connected. Email *drafting* still goes through the \`draft-emails\` skill.\n`
|
||||
: '';
|
||||
|
||||
// Slack is connected directly in Rowboat (agent-slack CLI), independent of
|
||||
|
|
@ -69,9 +90,15 @@ function buildStaticInstructions(composioEnabled: boolean, catalog: string, code
|
|||
? ` For Slack specifically, load the \`slack\` skill and use the agent-slack CLI — Slack is connected natively, not via Composio.`
|
||||
: '';
|
||||
|
||||
const googleToolPriority = googleConnected
|
||||
? ` For reading email specifically, use the \`app-navigation\` tool (\`read-view\`, \`view: "email"\`) — Gmail is connected natively, not via Composio.`
|
||||
: '';
|
||||
|
||||
const toolPriorityServiceExamples = googleConnected ? 'GitHub, Notion, etc.' : 'GitHub, Gmail, etc.';
|
||||
|
||||
const toolPriority = composioEnabled
|
||||
? `For third-party services (GitHub, Gmail, etc.), load the \`composio-integration\` skill.${slackToolPriority} For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
|
||||
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.${slackToolPriority}`;
|
||||
? `For third-party services (${toolPriorityServiceExamples}), load the \`composio-integration\` skill.${slackToolPriority}${googleToolPriority} For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
|
||||
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.${slackToolPriority}${googleToolPriority}`;
|
||||
|
||||
const slackToolsLine = composioEnabled
|
||||
? `- \`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.\n`
|
||||
|
|
@ -104,7 +131,7 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
|
|||
|
||||
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first.${emailDraftSuffix}
|
||||
|
||||
${thirdPartyBlock}${slackBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
|
||||
${thirdPartyBlock}${gmailBlock}${slackBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
|
||||
|
||||
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
|
||||
|
||||
|
|
@ -371,6 +398,14 @@ export async function buildCopilotInstructions(): Promise<string> {
|
|||
} catch {
|
||||
// repo unavailable — default to not connected
|
||||
}
|
||||
let googleConnected = false;
|
||||
try {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const googleConnection = await oauthRepo.read('google');
|
||||
googleConnected = !!googleConnection.tokens;
|
||||
} catch {
|
||||
// repo unavailable — default to not connected
|
||||
}
|
||||
if (slackConnected) {
|
||||
try {
|
||||
// Surface the channels the user selected for sync so the Copilot
|
||||
|
|
@ -395,8 +430,8 @@ export async function buildCopilotInstructions(): Promise<string> {
|
|||
const catalog = excludeIds.length > 0
|
||||
? buildSkillCatalog({ excludeIds })
|
||||
: skillCatalog;
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, slackConnected, slackChannelsHint);
|
||||
const composioPrompt = await getComposioToolsPrompt(slackConnected);
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, slackConnected, slackChannelsHint, googleConnected);
|
||||
const composioPrompt = await getComposioToolsPrompt(slackConnected, googleConnected);
|
||||
cachedInstructions = composioPrompt
|
||||
? baseInstructions + '\n' + composioPrompt
|
||||
: baseInstructions;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,17 @@ Returns the same data the view renders; the app simultaneously navigates to
|
|||
that view so the user sees it.
|
||||
|
||||
- ` + "`view: \"email\"`" + ` → latest important inbox threads: ` + "`{ threadId, subject, from, date, unread, summary }`" + `.
|
||||
Pass ` + "`query`" + ` to search instead (sender name, subject words — e.g. ` + "`query: \"from Arjun\"`" + ` or just ` + "`\"Arjun\"`" + `).
|
||||
Pass ` + "`query`" + ` to search instead. **This is a LIVE Gmail search over the
|
||||
user's ENTIRE mailbox via the Gmail API** (not a local/semantic search) and
|
||||
supports full Gmail search operators — ` + "`from:`, `to:`, `subject:`, `before:`/`after:`, `has:attachment`" + `,
|
||||
quoted phrases, ` + "`OR`" + ` — e.g. ` + "`query: \"from:arjun subject:deck\"`" + ` or plain words like ` + "`\"Arjun\"`" + `.
|
||||
When the user says "search my gmail" or wants Gmail's real search, THIS is
|
||||
it — do not reach for any other integration. Gmail matches whole words
|
||||
literally, so prefer broad queries (one or two distinctive words, or
|
||||
` + "`OR`" + ` variants like ` + "`\"intern OR internship\"`" + `) over long phrases.
|
||||
A ` + "`query`" + ` search also fills the email view's search box on the user's
|
||||
screen, so they see the same results — and a follow-up open-item works for
|
||||
any thread the search returned, including old threads outside the inbox.
|
||||
- ` + "`view: \"bg-tasks\"`" + ` → background agents: ` + "`{ name, slug, active, triggers, lastRunAt, lastRunSummary, lastRunError }`" + `.
|
||||
- ` + "`view: \"chat-history\"`" + ` → past chats: ` + "`{ sessionId, title, updatedAt, turnCount }`" + `.
|
||||
- ` + "`limit`" + ` (optional, default 15).
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ export const skill = String.raw`
|
|||
|
||||
**Load this skill** when the user asks to interact with ANY third-party service — email, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools.
|
||||
|
||||
**Native connections win over Composio.** If the system prompt says a service is connected natively in Rowboat (e.g. "Gmail is connected natively" or "Slack is connected natively"), do NOT use Composio for that service — follow the native routing in the system prompt instead (Gmail reading → \`app-navigation\` \`read-view\` \`view: "email"\`; Slack → the \`slack\` skill).
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|
|
@ -91,7 +93,7 @@ User says: "Get me the open issues on rowboatlabs/rowboat"
|
|||
|
||||
### Example: Gmail Fetch
|
||||
|
||||
User says: "What's my latest email?"
|
||||
User says: "What's my latest email?" (only when Gmail is connected via Composio — if the system prompt says Gmail is connected natively, use \`app-navigation\` instead)
|
||||
|
||||
1. \`composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })\`
|
||||
→ finds \`GMAIL_FETCH_EMAILS\`
|
||||
|
|
|
|||
|
|
@ -1076,7 +1076,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
// open-view / read-view
|
||||
view: z.enum(["home", "email", "meetings", "live-notes", "bg-tasks", "chat-history", "knowledge", "workspace", "code", "bases", "graph"]).optional().describe("Which view to open (open-view) or read (read-view; supported for read: email, bg-tasks, chat-history)"),
|
||||
// read-view (email)
|
||||
query: z.string().optional().describe("For read-view on email: search query (sender name, subject words, etc.). Omit to list the latest important inbox threads."),
|
||||
query: z.string().optional().describe("For read-view on email: runs a LIVE Gmail search over the user's ENTIRE mailbox (not just synced mail) via the Gmail API. Supports full Gmail search operators: from:, to:, subject:, before:/after:, has:attachment, quoted phrases, OR, etc. Omit to list the latest important inbox threads."),
|
||||
limit: z.number().int().min(1).max(50).optional().describe("For read-view: max items to return (default 15)"),
|
||||
// open-item
|
||||
kind: z.enum(["email-thread", "note", "bg-task", "session"]).optional().describe("What to open (for open-item)"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue