mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-03 04:12:38 +02:00
Feature/composio tools library (#461)
* first version of composio * Enhance error handling in Composio tool execution. Added try-catch block to log errors and return a structured error response when executing tools fails. * Add tool search functionality to settings dialog Implemented a debounced search feature for tools within the toolkit, allowing users to filter tools based on a search query. Added state management for search results and loading status. Updated the UI to accommodate the search input and results display. Additionally, modified the API call to use 'query' instead of 'search' for consistency. * Enhance Composio OAuth flow management and improve tool handling - Updated the activeFlows management to prevent concurrent OAuth flows for the same toolkit by using toolkitSlug as the key. - Implemented cleanup logic for existing flows, ensuring proper resource management by aborting and closing servers as needed. - Introduced a timeout mechanism for abandoned flows, enhancing reliability. - Refactored the Composio tools repository to use an in-memory cache for improved performance and added methods for persisting changes to disk. - Updated the detailed tools listing to use a consistent API call structure and improved input parameter handling. - Made connectionData in the response optional for better flexibility in handling connected accounts. * Improve error handling in Composio API calls - Enhanced error reporting by extracting human-readable messages from the JSON response body when the API call fails. - Added logic to parse the response and include specific error details in the thrown error message, improving debugging and user feedback. * Refactor Composio integration handling and improve UI components - Updated composio-handler.ts to invalidate the copilot instructions cache upon connection and disconnection. - Removed unused functions related to tool management in composio-handler.ts. - Enhanced IPC handlers in ipc.ts to streamline Composio connection processes. - Introduced ComposioConnectCard in the renderer to display connection status and handle events. - Refactored tool rendering in App.tsx and chat-sidebar.tsx to utilize new tabbed content for parameters and results. - Improved Composio tools prompt generation in instructions.ts to clarify integration usage and discovery flow. - Cleaned up unused code and improved overall structure for better maintainability. * Refactor listToolkits function to remove cursor parameter and implement pagination - Updated listToolkits in composio-handler.ts to paginate through API results, collecting all curated toolkits. - Adjusted IPC handler in ipc.ts to call the modified listToolkits without cursor argument. - Made properties in ToolkitInfo optional in settings-dialog.tsx for improved flexibility. - Removed the unused enabled-tools-repo.ts file to clean up the codebase. * Refactor Composio toolkit management for improved structure and maintainability - Consolidated toolkit definitions and display names into a single source of truth in shared/composio.ts. - Updated core composio/curated-toolkits.ts to re-export types and constants for backward compatibility. - Enhanced the organization of toolkit data, ensuring clarity and ease of access for future development. * Refactor Composio integration and improve component structure - Updated imports in composio-handler.ts and various components to utilize shared/composio.js for consistency. - Simplified ComposioConnectCard by removing unnecessary state management and improving event handling. - Enhanced chat-conversation.ts to directly reference COMPOSIO_DISPLAY_NAMES from shared/composio.js. - Cleaned up unused functions and types in client.ts and types.ts for better maintainability. - Removed deprecated curated-toolkits.ts file to streamline the codebase. * Refactor Composio connection handling and improve tool display logic - Removed the connection bridge for Composio toolkit OAuth, simplifying the connection process. - Updated ComposioConnectCard to display a more user-friendly connection message. - Introduced a new utility function, getToolDisplayName, to provide human-friendly names for builtin tools. - Refactored App and ChatSidebar components to utilize the new getToolDisplayName function for improved clarity in tool titles. - Cleaned up imports and removed unused code for better maintainability. * remove from diff * Address PR review: consolidate types, refactor CopilotAgent, sort toolkits - Move ZSearchResultTool and ZNormalizedToolResult into composio/types.ts - Convert CopilotAgent from static const to async buildCopilotAgent() - Simplify loadAgent to delegate to buildCopilotAgent() - Sort CURATED_TOOLKITS alphabetically by slug - Remove inline type annotations in composio-handler, use inferred types - Bump search limit from 15→50 for unscoped queries - Add docstrings explaining inferToolkitSlug fallback behavior - Add IPC schema reference comment for composio channels Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Enhance Composio connection handling and improve rendering logic - Added a 'hidden' property to ComposioConnectCardData to prevent rendering of duplicate connection cards. - Updated App and ChatSidebar components to skip rendering if the card is marked as hidden. - Refactored ComposioConnectCard to utilize a ref for callback firing, ensuring onConnected is only called once. - Improved instructions for Composio integration to clarify usage and loading of the composio-integration skill. This update streamlines the user experience by avoiding duplicate connection prompts and enhances the overall clarity of integration instructions. * Address PR round 2: use query param, remove inferToolkitSlug, consolidate types - Rename deprecated `search` param to `query` per Composio docs - Remove inferToolkitSlug fallback; make toolkit required in ZSearchResultTool - Replace inline Awaited<ReturnType<...>> with concrete Toolkit type in handler - Move ZToolkitMeta/ZToolkitItem/ZListToolkitsResponse to shared/composio.ts - Reference shared schemas in ipc.ts and core/types.ts (single source of truth) - Remove unused ZTool import from client.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add Toolkit type inference to composio/types.ts - Introduced a new type `Toolkit` inferred from `ZToolkit` to enhance type safety and clarity in type definitions. - This addition supports better integration and usage of the toolkit within the Composio framework. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bc929b6c1b
commit
e0aaa9a27e
19 changed files with 1324 additions and 214 deletions
|
|
@ -2,18 +2,21 @@ import { shell, BrowserWindow } from 'electron';
|
|||
import { createAuthServer } from './auth-server.js';
|
||||
import * as composioClient from '@x/core/dist/composio/client.js';
|
||||
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
|
||||
import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js';
|
||||
import { z } from 'zod';
|
||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { CURATED_TOOLKIT_SLUGS } from '@x/shared/dist/composio.js';
|
||||
import type { LocalConnectedAccount, Toolkit } from '@x/core/dist/composio/types.js';
|
||||
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
|
||||
|
||||
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
|
||||
|
||||
// Store active OAuth flows
|
||||
// Store active OAuth flows (keyed by toolkitSlug to prevent concurrent flows for the same toolkit)
|
||||
const activeFlows = new Map<string, {
|
||||
toolkitSlug: string;
|
||||
connectedAccountId: string;
|
||||
authConfigId: string;
|
||||
server: import('http').Server;
|
||||
timeout: NodeJS.Timeout;
|
||||
}>();
|
||||
|
||||
/**
|
||||
|
|
@ -125,13 +128,14 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
};
|
||||
}
|
||||
|
||||
// Store flow state
|
||||
const flowKey = `${toolkitSlug}-${Date.now()}`;
|
||||
activeFlows.set(flowKey, {
|
||||
toolkitSlug,
|
||||
connectedAccountId,
|
||||
authConfigId,
|
||||
});
|
||||
// Abort any existing flow for this toolkit before starting a new one
|
||||
const existingFlow = activeFlows.get(toolkitSlug);
|
||||
if (existingFlow) {
|
||||
console.log(`[Composio] Aborting existing flow for ${toolkitSlug}`);
|
||||
clearTimeout(existingFlow.timeout);
|
||||
existingFlow.server.close();
|
||||
activeFlows.delete(toolkitSlug);
|
||||
}
|
||||
|
||||
// Save initial account state
|
||||
const account: LocalConnectedAccount = {
|
||||
|
|
@ -145,7 +149,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
composioAccountsRepo.saveAccount(account);
|
||||
|
||||
// Set up callback server
|
||||
let cleanupTimeout: NodeJS.Timeout;
|
||||
const timeoutRef: { current: NodeJS.Timeout | null } = { current: null };
|
||||
let callbackHandled = false;
|
||||
const { server } = await createAuthServer(8081, async () => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
|
|
@ -157,6 +161,8 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
|
||||
|
||||
if (accountStatus.status === 'ACTIVE') {
|
||||
// Invalidate instructions cache so the copilot knows about the new connection
|
||||
invalidateCopilotInstructionsCache();
|
||||
emitComposioEvent({ toolkitSlug, success: true });
|
||||
if (toolkitSlug === 'gmail') {
|
||||
triggerGmailSync();
|
||||
|
|
@ -179,17 +185,17 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
activeFlows.delete(flowKey);
|
||||
activeFlows.delete(toolkitSlug);
|
||||
server.close();
|
||||
clearTimeout(cleanupTimeout);
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout for abandoned flows (5 minutes)
|
||||
cleanupTimeout = setTimeout(() => {
|
||||
if (activeFlows.has(flowKey)) {
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
if (activeFlows.has(toolkitSlug)) {
|
||||
console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`);
|
||||
activeFlows.delete(flowKey);
|
||||
activeFlows.delete(toolkitSlug);
|
||||
server.close();
|
||||
emitComposioEvent({
|
||||
toolkitSlug,
|
||||
|
|
@ -198,6 +204,16 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
});
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
timeoutRef.current = cleanupTimeout;
|
||||
|
||||
// Store flow state (keyed by toolkit to prevent concurrent flows)
|
||||
activeFlows.set(toolkitSlug, {
|
||||
toolkitSlug,
|
||||
connectedAccountId,
|
||||
authConfigId,
|
||||
server,
|
||||
timeout: cleanupTimeout,
|
||||
});
|
||||
|
||||
// Open browser for OAuth
|
||||
shell.openExternal(redirectUrl);
|
||||
|
|
@ -257,18 +273,16 @@ export async function disconnect(toolkitSlug: string): Promise<{ success: boolea
|
|||
try {
|
||||
const account = composioAccountsRepo.getAccount(toolkitSlug);
|
||||
if (account) {
|
||||
// Delete from Composio
|
||||
await composioClient.deleteConnectedAccount(account.id);
|
||||
// Delete local record
|
||||
composioAccountsRepo.deleteAccount(toolkitSlug);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Composio] Disconnect failed:', error);
|
||||
// Still delete local record even if API call fails
|
||||
} finally {
|
||||
// Always clean up local state, even if the API call fails
|
||||
composioAccountsRepo.deleteAccount(toolkitSlug);
|
||||
return { success: true };
|
||||
invalidateCopilotInstructionsCache();
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -293,36 +307,24 @@ export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean
|
|||
}
|
||||
|
||||
/**
|
||||
* Execute a Composio action
|
||||
* List available Composio toolkits — filtered to curated list only.
|
||||
* Return type matches the ZToolkit schema from core/composio/types.ts.
|
||||
*/
|
||||
export async function executeAction(
|
||||
actionSlug: string,
|
||||
toolkitSlug: string,
|
||||
input: Record<string, unknown>
|
||||
): Promise<z.infer<typeof ZExecuteActionResponse>> {
|
||||
try {
|
||||
const account = composioAccountsRepo.getAccount(toolkitSlug);
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return {
|
||||
data: null,
|
||||
successful: false,
|
||||
error: `Toolkit ${toolkitSlug} is not connected`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await composioClient.executeAction(actionSlug, {
|
||||
connected_account_id: account.id,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: input,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Composio] Action execution failed:', error);
|
||||
return {
|
||||
successful: false,
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
export async function listToolkits() {
|
||||
// Paginate through all API pages to collect every curated toolkit
|
||||
const allItems: Toolkit[] = [];
|
||||
let cursor: string | null = null;
|
||||
const maxPages = 10; // safety limit
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const result = await composioClient.listToolkits(cursor);
|
||||
allItems.push(...result.items);
|
||||
cursor = result.next_cursor;
|
||||
if (!cursor) break;
|
||||
}
|
||||
const filtered = allItems.filter(item => CURATED_TOOLKIT_SLUGS.has(item.slug));
|
||||
return {
|
||||
items: filtered,
|
||||
nextCursor: null as string | null,
|
||||
totalItems: filtered.length,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -559,8 +559,9 @@ export function setupIpcHandlers() {
|
|||
'composio:list-connected': async () => {
|
||||
return composioHandler.listConnected();
|
||||
},
|
||||
'composio:execute-action': async (_event, args) => {
|
||||
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
|
||||
// Composio Tools Library handlers
|
||||
'composio:list-toolkits': async () => {
|
||||
return composioHandler.listToolkits();
|
||||
},
|
||||
'composio:use-composio-for-google': async () => {
|
||||
return composioHandler.useComposioForGoogle();
|
||||
|
|
|
|||
|
|
@ -34,9 +34,10 @@ import {
|
|||
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||
import { useSmoothedText } from './hooks/useSmoothedText';
|
||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool';
|
||||
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
||||
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
||||
import { Suggestions } from '@/components/ai-elements/suggestions';
|
||||
|
|
@ -67,6 +68,8 @@ import {
|
|||
createEmptyChatTabViewState,
|
||||
getWebSearchCardData,
|
||||
getAppActionCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
inferRunTitleFromMessage,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
|
|
@ -76,6 +79,7 @@ import {
|
|||
parseAttachedFiles,
|
||||
toToolState,
|
||||
} from '@/lib/chat-conversation'
|
||||
import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js'
|
||||
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
|
||||
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -2242,6 +2246,12 @@ function App() {
|
|||
}
|
||||
handlePromptSubmitRef.current = handlePromptSubmit
|
||||
|
||||
const handleComposioConnected = useCallback((toolkitSlug: string) => {
|
||||
// Auto-send a continuation message when a Composio toolkit connects
|
||||
const name = composioDisplayNames[toolkitSlug] || toolkitSlug
|
||||
handlePromptSubmitRef.current?.({ text: `${name} connected successfully.`, files: [] })
|
||||
}, [])
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!runId) return
|
||||
const now = Date.now()
|
||||
|
|
@ -3826,6 +3836,22 @@ function App() {
|
|||
/>
|
||||
)
|
||||
}
|
||||
const composioConnectData = getComposioConnectCardData(item)
|
||||
if (composioConnectData) {
|
||||
// Skip rendering if this is a duplicate "already connected" card
|
||||
if (composioConnectData.hidden) return null
|
||||
return (
|
||||
<ComposioConnectCard
|
||||
key={item.id}
|
||||
toolkitSlug={composioConnectData.toolkitSlug}
|
||||
toolkitDisplayName={composioConnectData.toolkitDisplayName}
|
||||
status={item.status}
|
||||
alreadyConnected={composioConnectData.alreadyConnected}
|
||||
onConnected={handleComposioConnected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const toolTitle = getToolDisplayName(item)
|
||||
const errorText = item.status === 'error' ? 'Tool error' : ''
|
||||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
|
|
@ -3836,15 +3862,12 @@ function App() {
|
|||
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
|
||||
>
|
||||
<ToolHeader
|
||||
title={item.name}
|
||||
title={toolTitle}
|
||||
type={`tool-${item.name}`}
|
||||
state={toToolState(item.status)}
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolInput input={input} />
|
||||
{output !== null ? (
|
||||
<ToolOutput output={output} errorText={errorText} />
|
||||
) : null}
|
||||
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
|
|
@ -4470,6 +4493,7 @@ function App() {
|
|||
ttsMode={ttsMode}
|
||||
onToggleTts={handleToggleTts}
|
||||
onTtsModeChange={handleTtsModeChange}
|
||||
onComposioConnected={handleComposioConnected}
|
||||
/>
|
||||
)}
|
||||
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
Link2Icon,
|
||||
LoaderIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ComposioConnectCardProps {
|
||||
toolkitSlug: string;
|
||||
toolkitDisplayName: string;
|
||||
status: "pending" | "running" | "completed" | "error";
|
||||
alreadyConnected?: boolean;
|
||||
onConnected?: (toolkitSlug: string) => void;
|
||||
}
|
||||
|
||||
export function ComposioConnectCard({
|
||||
toolkitSlug,
|
||||
toolkitDisplayName,
|
||||
status,
|
||||
alreadyConnected,
|
||||
onConnected,
|
||||
}: ComposioConnectCardProps) {
|
||||
const [connectionState, setConnectionState] = useState<
|
||||
"idle" | "connecting" | "connected" | "error"
|
||||
>(alreadyConnected ? "connected" : "idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const didFireCallback = useRef(alreadyConnected ?? false);
|
||||
|
||||
// Listen for composio:didConnect events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on(
|
||||
"composio:didConnect",
|
||||
(event: { toolkitSlug: string; success: boolean; error?: string }) => {
|
||||
if (event.toolkitSlug !== toolkitSlug) return;
|
||||
if (event.success) {
|
||||
setConnectionState("connected");
|
||||
setErrorMessage(null);
|
||||
if (!didFireCallback.current) {
|
||||
didFireCallback.current = true;
|
||||
onConnected?.(toolkitSlug);
|
||||
}
|
||||
} else {
|
||||
setConnectionState("error");
|
||||
setErrorMessage(event.error || "Connection failed");
|
||||
}
|
||||
}
|
||||
);
|
||||
return cleanup;
|
||||
}, [toolkitSlug, onConnected]);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
setConnectionState("connecting");
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:initiate-connection", {
|
||||
toolkitSlug,
|
||||
});
|
||||
if (!result.success) {
|
||||
setConnectionState("error");
|
||||
setErrorMessage(result.error || "Failed to initiate connection");
|
||||
}
|
||||
} catch {
|
||||
setConnectionState("error");
|
||||
setErrorMessage("Failed to initiate connection");
|
||||
}
|
||||
}, [toolkitSlug]);
|
||||
|
||||
const isToolRunning = status === "pending" || status === "running";
|
||||
const displayName = toolkitDisplayName || toolkitSlug;
|
||||
|
||||
return (
|
||||
<div className="not-prose mb-4 flex items-center gap-3 rounded-lg border px-3 py-2.5">
|
||||
{/* Toolkit initial */}
|
||||
<div className="size-7 rounded bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-bold text-muted-foreground">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Name & status */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{displayName}</span>
|
||||
{connectionState === "connected" && (
|
||||
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{connectionState === "error" && errorMessage && (
|
||||
<p className="text-xs text-destructive truncate">{errorMessage}</p>
|
||||
)}
|
||||
{connectionState === "idle" && isToolRunning && (
|
||||
<p className="text-xs text-muted-foreground">Waiting to connect...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action area */}
|
||||
{connectionState === "connected" ? (
|
||||
<CheckCircleIcon className="size-4 text-green-600 flex-shrink-0" />
|
||||
) : connectionState === "connecting" ? (
|
||||
<Button size="sm" disabled className="text-xs h-7 flex-shrink-0">
|
||||
<LoaderIcon className="size-3 animate-spin mr-1" />
|
||||
Connecting...
|
||||
</Button>
|
||||
) : connectionState === "error" ? (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<XCircleIcon className="size-3.5 text-destructive" />
|
||||
<Button size="sm" variant="outline" onClick={handleConnect} className="text-xs h-7">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : isToolRunning ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<Button size="sm" onClick={handleConnect} className="text-xs h-7 flex-shrink-0">
|
||||
<Link2Icon className="size-3 mr-1" />
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,8 +16,8 @@ import {
|
|||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||
|
||||
const formatToolValue = (value: unknown) => {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
|
|
@ -37,7 +37,7 @@ const ToolCode = ({
|
|||
}) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"whitespace-pre-wrap text-xs font-mono",
|
||||
"whitespace-pre-wrap text-xs font-mono break-all",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -129,64 +129,90 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
|||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
/* ── Tabbed content (Parameters / Result) ────────────────────────── */
|
||||
|
||||
export type ToolTabbedContentProps = {
|
||||
input: ToolUIPart["input"];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md border bg-muted/50 p-4 text-foreground">
|
||||
<ToolCode code={formatToolValue(input ?? {})} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolUIPart["output"];
|
||||
errorText: ToolUIPart["errorText"];
|
||||
errorText?: ToolUIPart["errorText"];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
export const ToolTabbedContent = ({
|
||||
input,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
}: ToolTabbedContentProps) => {
|
||||
const [activeTab, setActiveTab] = useState<"parameters" | "result">("parameters");
|
||||
const hasOutput = output != null || !!errorText;
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = <ToolCode code={formatToolValue(output ?? null)} />;
|
||||
} else if (typeof output === "string") {
|
||||
Output = <ToolCode code={formatToolValue(output)} />;
|
||||
let OutputNode: ReactNode = null;
|
||||
if (errorText) {
|
||||
OutputNode = <ToolCode code={errorText} className="text-destructive" />;
|
||||
} else if (output != null) {
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
OutputNode = <ToolCode code={formatToolValue(output)} />;
|
||||
} else if (typeof output === "string") {
|
||||
OutputNode = <ToolCode code={output} />;
|
||||
} else {
|
||||
OutputNode = <div>{output as ReactNode}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 p-4", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto rounded-md border p-4 text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{errorText && (
|
||||
<div className="mb-2 font-sans text-xs text-destructive">
|
||||
{errorText}
|
||||
<div className="border-t">
|
||||
{/* Tabs */}
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
|
||||
activeTab === "parameters"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("parameters")}
|
||||
>
|
||||
Parameters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
|
||||
activeTab === "result"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("result")}
|
||||
>
|
||||
Result
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="p-3">
|
||||
{activeTab === "parameters" && (
|
||||
<div className="rounded-md border bg-muted/50 p-3 max-h-64 overflow-auto">
|
||||
<ToolCode code={formatToolValue(input ?? {})} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "result" && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border p-3 max-h-64 overflow-auto",
|
||||
errorText ? "bg-destructive/10" : "bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{hasOutput ? (
|
||||
<div className={cn(errorText && "text-destructive")}>
|
||||
{OutputNode}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">(pending...)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ import {
|
|||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
|
||||
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||
import { Suggestions } from '@/components/ai-elements/suggestions'
|
||||
|
|
@ -34,6 +35,8 @@ import {
|
|||
type PermissionResponse,
|
||||
createEmptyChatTabViewState,
|
||||
getWebSearchCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
|
|
@ -121,6 +124,7 @@ interface ChatSidebarProps {
|
|||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
onComposioConnected?: (toolkitSlug: string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
|
|
@ -171,6 +175,7 @@ export function ChatSidebar({
|
|||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onComposioConnected,
|
||||
}: ChatSidebarProps) {
|
||||
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
|
|
@ -337,6 +342,21 @@ export function ChatSidebar({
|
|||
/>
|
||||
)
|
||||
}
|
||||
const composioConnectData = getComposioConnectCardData(item)
|
||||
if (composioConnectData) {
|
||||
if (composioConnectData.hidden) return null
|
||||
return (
|
||||
<ComposioConnectCard
|
||||
key={item.id}
|
||||
toolkitSlug={composioConnectData.toolkitSlug}
|
||||
toolkitDisplayName={composioConnectData.toolkitDisplayName}
|
||||
status={item.status}
|
||||
alreadyConnected={composioConnectData.alreadyConnected}
|
||||
onConnected={onComposioConnected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const toolTitle = getToolDisplayName(item)
|
||||
const errorText = item.status === 'error' ? 'Tool error' : ''
|
||||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
|
|
@ -346,10 +366,9 @@ export function ChatSidebar({
|
|||
open={isToolOpenForTab?.(tabId, item.id) ?? false}
|
||||
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
|
||||
>
|
||||
<ToolHeader title={item.name} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
<ToolInput input={input} />
|
||||
{output !== null ? <ToolOutput output={output} errorText={errorText} /> : null}
|
||||
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X, User, Plug } from "lucide-react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -25,7 +25,7 @@ import { toast } from "sonner"
|
|||
import { AccountSettings } from "@/components/settings/account-settings"
|
||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
|
||||
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "note-tagging"
|
||||
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -75,6 +75,12 @@ const tabs: TabConfig[] = [
|
|||
icon: Palette,
|
||||
description: "Customize the look and feel",
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tools Library",
|
||||
icon: Wrench,
|
||||
description: "Browse and enable Composio toolkits",
|
||||
},
|
||||
{
|
||||
id: "note-tagging",
|
||||
label: "Note Tagging",
|
||||
|
|
@ -707,6 +713,323 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Tools Library Settings ---
|
||||
|
||||
interface ToolkitInfo {
|
||||
slug: string
|
||||
name: string
|
||||
meta: { description: string; logo: string; tools_count: number; triggers_count: number }
|
||||
no_auth?: boolean
|
||||
auth_schemes?: string[]
|
||||
composio_managed_auth_schemes?: string[]
|
||||
}
|
||||
|
||||
function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
// API key state
|
||||
const [apiKeyConfigured, setApiKeyConfigured] = useState(false)
|
||||
const [apiKeyInput, setApiKeyInput] = useState("")
|
||||
const [apiKeySaving, setApiKeySaving] = useState(false)
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false)
|
||||
|
||||
// Toolkit browsing state
|
||||
const [toolkits, setToolkits] = useState<ToolkitInfo[]>([])
|
||||
const [toolkitsLoading, setToolkitsLoading] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// Connection state
|
||||
const [connectedToolkits, setConnectedToolkits] = useState<Set<string>>(new Set())
|
||||
const [connectingToolkit, setConnectingToolkit] = useState<string | null>(null)
|
||||
|
||||
// Check API key configuration
|
||||
const checkApiKey = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:is-configured", null)
|
||||
setApiKeyConfigured(result.configured)
|
||||
if (!result.configured) {
|
||||
setShowApiKeyInput(true)
|
||||
}
|
||||
} catch {
|
||||
setApiKeyConfigured(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load connected toolkits
|
||||
const loadConnected = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:list-connected", null)
|
||||
setConnectedToolkits(new Set(result.toolkits))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load toolkits
|
||||
const loadToolkits = useCallback(async () => {
|
||||
setToolkitsLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:list-toolkits", {})
|
||||
setToolkits(result.items)
|
||||
} catch {
|
||||
toast.error("Failed to load toolkits")
|
||||
} finally {
|
||||
setToolkitsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
checkApiKey()
|
||||
loadConnected()
|
||||
}, [dialogOpen, checkApiKey, loadConnected])
|
||||
|
||||
// Load toolkits when API key is configured
|
||||
useEffect(() => {
|
||||
if (dialogOpen && apiKeyConfigured) {
|
||||
loadToolkits()
|
||||
}
|
||||
}, [dialogOpen, apiKeyConfigured, loadToolkits])
|
||||
|
||||
// Listen for composio connection events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||
const { toolkitSlug, success, error } = event
|
||||
setConnectingToolkit(null)
|
||||
if (success) {
|
||||
setConnectedToolkits(prev => new Set([...prev, toolkitSlug]))
|
||||
toast.success(`Connected to ${toolkitSlug}`)
|
||||
} else {
|
||||
toast.error(error || `Failed to connect to ${toolkitSlug}`)
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Save API key
|
||||
const handleSaveApiKey = async () => {
|
||||
const trimmed = apiKeyInput.trim()
|
||||
if (!trimmed) return
|
||||
setApiKeySaving(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:set-api-key", { apiKey: trimmed })
|
||||
if (result.success) {
|
||||
setApiKeyConfigured(true)
|
||||
setShowApiKeyInput(false)
|
||||
setApiKeyInput("")
|
||||
toast.success("Composio API key saved")
|
||||
} else {
|
||||
toast.error(result.error || "Failed to save API key")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to save API key")
|
||||
} finally {
|
||||
setApiKeySaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect a toolkit
|
||||
const handleConnect = async (toolkitSlug: string) => {
|
||||
setConnectingToolkit(toolkitSlug)
|
||||
try {
|
||||
const result = await window.ipc.invoke("composio:initiate-connection", { toolkitSlug })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || "Failed to connect")
|
||||
setConnectingToolkit(null)
|
||||
}
|
||||
// Success will be handled by composio:didConnect event
|
||||
} catch {
|
||||
toast.error("Failed to connect")
|
||||
setConnectingToolkit(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect a toolkit
|
||||
const handleDisconnect = async (toolkitSlug: string) => {
|
||||
try {
|
||||
await window.ipc.invoke("composio:disconnect", { toolkitSlug })
|
||||
setConnectedToolkits(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(toolkitSlug)
|
||||
return next
|
||||
})
|
||||
toast.success(`Disconnected from ${toolkitSlug}`)
|
||||
} catch {
|
||||
toast.error("Failed to disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
// Filter toolkits by search
|
||||
const filteredToolkits = searchQuery.trim()
|
||||
? toolkits.filter(t =>
|
||||
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.meta.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: toolkits
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section A: API Key */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Composio API Key</span>
|
||||
{apiKeyConfigured && !showApiKeyInput ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
API key configured
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowApiKeyInput(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter your Composio API key to browse and enable tool integrations.
|
||||
Get your key from{" "}
|
||||
<a
|
||||
href="https://app.composio.dev/settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
app.composio.dev/settings
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
placeholder="Paste your Composio API key"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveApiKey}
|
||||
disabled={!apiKeyInput.trim() || apiKeySaving}
|
||||
size="sm"
|
||||
>
|
||||
{apiKeySaving ? <Loader2 className="size-4 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
{apiKeyConfigured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setShowApiKeyInput(false); setApiKeyInput("") }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section B: Toolkit Browser (only when API key configured) */}
|
||||
{apiKeyConfigured && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Available Toolkits</span>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search toolkits..."
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolkitsLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading toolkits...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-[400px] overflow-y-auto pr-1">
|
||||
{filteredToolkits.map((toolkit) => {
|
||||
const isConnected = connectedToolkits.has(toolkit.slug)
|
||||
const isConnecting = connectingToolkit === toolkit.slug
|
||||
|
||||
return (
|
||||
<div key={toolkit.slug} className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{/* Logo */}
|
||||
{toolkit.meta.logo ? (
|
||||
<img
|
||||
src={toolkit.meta.logo}
|
||||
alt=""
|
||||
className="size-7 rounded object-contain flex-shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Wrench className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name & description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{toolkit.name}</span>
|
||||
{isConnected && (
|
||||
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{toolkit.meta.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connect / Disconnect button */}
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(toolkit.slug)}
|
||||
className="text-xs h-7 flex-shrink-0"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleConnect(toolkit.slug)}
|
||||
disabled={isConnecting}
|
||||
className="text-xs h-7 flex-shrink-0"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Connecting...</>
|
||||
) : (
|
||||
<><Link2 className="size-3 mr-1" />Connect</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredToolkits.length === 0 && !toolkitsLoading && (
|
||||
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||
{searchQuery ? "No toolkits match your search" : "No toolkits available"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Rowboat Model Settings (when signed in via Rowboat) ---
|
||||
|
||||
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
|
|
@ -1312,7 +1635,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "account" || activeTab === "connected-accounts" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "account" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connected-accounts" ? (
|
||||
|
|
@ -1325,6 +1648,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
<NoteTaggingSettings dialogOpen={open} />
|
||||
) : activeTab === "appearance" ? (
|
||||
<AppearanceSettings />
|
||||
) : activeTab === "tools" ? (
|
||||
<ToolsLibrarySettings dialogOpen={open} />
|
||||
) : loading ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ToolUIPart } from 'ai'
|
||||
import z from 'zod'
|
||||
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
|
||||
|
||||
export interface MessageAttachment {
|
||||
path: string
|
||||
|
|
@ -253,6 +254,142 @@ export const parseAttachedFiles = (content: string): { message: string; files: s
|
|||
return { message: cleanMessage.trim(), files }
|
||||
}
|
||||
|
||||
// Composio connect card data
|
||||
export type ComposioConnectCardData = {
|
||||
toolkitSlug: string
|
||||
toolkitDisplayName: string
|
||||
alreadyConnected: boolean
|
||||
/** When true, the connect card should not be rendered (toolkit was already connected). */
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
|
||||
export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardData | null => {
|
||||
if (tool.name !== 'composio-connect-toolkit') return null
|
||||
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
|
||||
const toolkitSlug = (input?.toolkitSlug as string) || ''
|
||||
const alreadyConnected = result?.alreadyConnected === true
|
||||
|
||||
return {
|
||||
toolkitSlug,
|
||||
toolkitDisplayName: COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug,
|
||||
alreadyConnected,
|
||||
// Don't render a connect card if the toolkit was already connected —
|
||||
// the original card from the first connect call already shows the "Connected" state.
|
||||
hidden: alreadyConnected,
|
||||
}
|
||||
}
|
||||
|
||||
// Human-friendly display names for builtin tools
|
||||
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
'workspace-readFile': 'Reading file',
|
||||
'workspace-writeFile': 'Writing file',
|
||||
'workspace-edit': 'Editing file',
|
||||
'workspace-readdir': 'Reading directory',
|
||||
'workspace-exists': 'Checking path',
|
||||
'workspace-stat': 'Getting file info',
|
||||
'workspace-glob': 'Finding files',
|
||||
'workspace-grep': 'Searching files',
|
||||
'workspace-mkdir': 'Creating directory',
|
||||
'workspace-rename': 'Renaming',
|
||||
'workspace-copy': 'Copying file',
|
||||
'workspace-remove': 'Removing',
|
||||
'workspace-getRoot': 'Getting workspace root',
|
||||
'loadSkill': 'Loading skill',
|
||||
'parseFile': 'Parsing file',
|
||||
'LLMParse': 'Extracting content',
|
||||
'analyzeAgent': 'Analyzing agent',
|
||||
'executeCommand': 'Running command',
|
||||
'addMcpServer': 'Adding MCP server',
|
||||
'listMcpServers': 'Listing MCP servers',
|
||||
'listMcpTools': 'Listing MCP tools',
|
||||
'executeMcpTool': 'Running MCP tool',
|
||||
'web-search': 'Searching the web',
|
||||
'save-to-memory': 'Saving to memory',
|
||||
'app-navigation': 'Navigating app',
|
||||
'composio-list-toolkits': 'Listing integrations',
|
||||
'composio-search-tools': 'Searching tools',
|
||||
'composio-execute-tool': 'Running tool',
|
||||
'composio-connect-toolkit': 'Connecting service',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-friendly display name for a tool call.
|
||||
* For Composio tools, returns a contextual label (e.g., "Found 3 tools for 'send email' in Gmail").
|
||||
* For builtin tools, returns a static friendly name (e.g., "Reading file").
|
||||
* Falls back to the raw tool name if no mapping exists.
|
||||
*/
|
||||
export const getToolDisplayName = (tool: ToolCall): string => {
|
||||
const composioData = getComposioActionCardData(tool)
|
||||
if (composioData) return composioData.label
|
||||
return TOOL_DISPLAY_NAMES[tool.name] || tool.name
|
||||
}
|
||||
|
||||
// Composio action card data (for search, execute, list tools)
|
||||
export type ComposioActionCardData = {
|
||||
actionType: 'search' | 'execute' | 'list'
|
||||
label: string
|
||||
}
|
||||
|
||||
export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardData | null => {
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
|
||||
if (tool.name === 'composio-search-tools') {
|
||||
const query = (input?.query as string) || 'tools'
|
||||
const toolkitSlug = input?.toolkitSlug as string | undefined
|
||||
const toolkit = toolkitSlug ? COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug : null
|
||||
const count = (result?.resultCount as number) ?? null
|
||||
|
||||
let label = `Searching for "${query}"`
|
||||
if (toolkit) label += ` in ${toolkit}`
|
||||
if (count !== null && tool.status === 'completed') {
|
||||
label = count > 0 ? `Found ${count} tool${count !== 1 ? 's' : ''} for "${query}"` : `No tools found for "${query}"`
|
||||
if (toolkit) label += ` in ${toolkit}`
|
||||
}
|
||||
return { actionType: 'search', label }
|
||||
}
|
||||
|
||||
if (tool.name === 'composio-execute-tool') {
|
||||
const toolSlug = (input?.toolSlug as string) || ''
|
||||
const toolkitSlug = (input?.toolkitSlug as string) || ''
|
||||
const toolkit = COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug
|
||||
const successful = result?.successful as boolean | undefined
|
||||
|
||||
// Make the tool slug human-readable: GITHUB_ISSUES_LIST_FOR_REPO → "Issues list for repo"
|
||||
const readableName = toolSlug
|
||||
.replace(/^[A-Z]+_/, '') // Remove toolkit prefix
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/^\w/, c => c.toUpperCase())
|
||||
|
||||
let label = `Running ${readableName}`
|
||||
if (toolkit) label += ` on ${toolkit}`
|
||||
if (tool.status === 'completed') {
|
||||
label = successful === false ? `Failed: ${readableName}` : `${readableName}`
|
||||
if (toolkit) label += ` on ${toolkit}`
|
||||
}
|
||||
return { actionType: 'execute', label }
|
||||
}
|
||||
|
||||
if (tool.name === 'composio-list-toolkits') {
|
||||
const count = (result?.totalCount as number) ?? null
|
||||
const connected = (result?.connectedCount as number) ?? null
|
||||
|
||||
let label = 'Listing available integrations'
|
||||
if (count !== null && tool.status === 'completed') {
|
||||
label = `${count} integrations available`
|
||||
if (connected !== null && connected > 0) label += `, ${connected} connected`
|
||||
}
|
||||
return { actionType: 'list', label }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
||||
const { message } = parseAttachedFiles(content)
|
||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue