mirror of https://github.com/kortix-ai/suna.git
Merge pull request #1032 from escapade-mckv/fix-ux-issues
add tools info in the app card
This commit is contained in:
commit
b32b035c81
|
@ -13,6 +13,9 @@ from .domain.exceptions import (
|
|||
ConnectionNotFoundError, AppNotFoundError, MCPConnectionError,
|
||||
ValidationException, PipedreamException
|
||||
)
|
||||
# Add HTTPX and JSON for tools listing endpoint
|
||||
import httpx
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix="/pipedream", tags=["pipedream"])
|
||||
pipedream_manager: Optional[PipedreamManager] = None
|
||||
|
@ -478,6 +481,40 @@ async def get_app_icon(app_slug: str):
|
|||
logger.error(f"Failed to fetch icon for app {app_slug}: {str(e)}")
|
||||
raise _handle_pipedream_exception(e)
|
||||
|
||||
@router.get("/apps/{app_slug}/tools")
|
||||
async def get_app_tools(app_slug: str):
|
||||
logger.info(f"Getting tools for app: {app_slug}")
|
||||
url = f"https://remote.mcp.pipedream.net/?app={app_slug}&externalUserId=tools_preview"
|
||||
payload = {"jsonrpc": "2.0", "method": "tools/list", "params": {}, "id": 1}
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with client.stream("POST", url, json=payload, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
tools = []
|
||||
async for line in resp.aiter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
data_str = line[len("data:"):].strip()
|
||||
try:
|
||||
data_obj = json.loads(data_str)
|
||||
tools = data_obj.get("result", {}).get("tools", [])
|
||||
for tool in tools:
|
||||
desc = tool.get("description", "") or ""
|
||||
idx = desc.find("[")
|
||||
if idx != -1:
|
||||
tool["description"] = desc[:idx].strip()
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse JSON data: {data_str}")
|
||||
continue
|
||||
return {"success": True, "tools": tools}
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"HTTP error when fetching tools for app {app_slug}: {e}")
|
||||
raise HTTPException(status_code=502, detail="Bad Gateway")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error when fetching tools for app {app_slug}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/profiles", response_model=ProfileResponse)
|
||||
|
|
|
@ -63,6 +63,7 @@
|
|||
"comment-json": "^4.2.5",
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^7.0.0",
|
||||
"extract-colors": "^4.2.0",
|
||||
"flags": "^3.2.0",
|
||||
"framer-motion": "^12.6.5",
|
||||
"geist": "^1.2.1",
|
||||
|
@ -8660,6 +8661,12 @@
|
|||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extract-colors": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/extract-colors/-/extract-colors-4.2.0.tgz",
|
||||
"integrity": "sha512-+0X1EMDSea/upbny8RMEoBZdB0in6yiWFVDNZy2d/ehXINRCly+PXC8gp7enSJomeB9dhD7Sud4jzROStuHOJA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
|
|
@ -66,6 +66,7 @@
|
|||
"comment-json": "^4.2.5",
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^7.0.0",
|
||||
"extract-colors": "^4.2.0",
|
||||
"flags": "^3.2.0",
|
||||
"framer-motion": "^12.6.5",
|
||||
"geist": "^1.2.1",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
@ -8,12 +8,16 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Plus, Settings, Zap, Bot, ChevronDown, Star, CheckCircle, Eye } from 'lucide-react';
|
||||
import { Plus, Settings, Zap, Bot, ChevronDown, Star, CheckCircle, Eye, ExternalLink, Info } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getCategoryEmoji } from '../utils';
|
||||
import type { AppCardProps } from '../types';
|
||||
import { usePipedreamProfiles } from '@/hooks/react-query/pipedream/use-pipedream-profiles';
|
||||
import { usePipedreamAppIcon } from '@/hooks/react-query/pipedream/use-pipedream';
|
||||
import { usePipedreamAppTools } from '@/hooks/react-query/pipedream/use-pipedream';
|
||||
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export const AppCard: React.FC<AppCardProps> = ({
|
||||
app,
|
||||
|
@ -28,6 +32,10 @@ export const AppCard: React.FC<AppCardProps> = ({
|
|||
handleCategorySelect,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const { data: appToolsData, isLoading: isToolsLoading } = usePipedreamAppTools(app.name_slug, { enabled: isDialogOpen });
|
||||
const tools = appToolsData?.tools ?? [];
|
||||
|
||||
const { data: profiles } = usePipedreamProfiles();
|
||||
const { data: iconData } = usePipedreamAppIcon(app.name_slug, {
|
||||
enabled: !app.img_src
|
||||
|
@ -108,12 +116,159 @@ export const AppCard: React.FC<AppCardProps> = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<h3 className="font-semibold text-base text-foreground truncate group-hover:text-primary transition-colors">
|
||||
{app.name}
|
||||
</h3>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Badge variant="outline" className="text-xs cursor-pointer hover:bg-primary/10 transition-colors flex items-center gap-1">
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="text-xs">Info</span>
|
||||
</Badge>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="overflow-hidden max-w-4xl p-0 h-[600px]">
|
||||
<DialogTitle className='sr-only'>{app.name} Tools</DialogTitle>
|
||||
<div className="grid grid-cols-2 h-full">
|
||||
<div className="p-8 border-r bg-gradient-to-br from-background to-muted/20">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="text-start mb-4">
|
||||
<div className="mx-auto mb-6 relative">
|
||||
<div className="h-20 w-20 rounded-3xl border bg-muted flex items-center justify-center overflow-hidden">
|
||||
{(app.img_src || iconData?.icon_url) ? (
|
||||
<img
|
||||
src={app.img_src || iconData?.icon_url}
|
||||
alt={`${app.name} icon`}
|
||||
className="h-12 w-12 object-cover rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-bold text-2xl text-primary">
|
||||
{app.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">{app.name}</h2>
|
||||
<Badge variant="outline" className="mb-4 bg-muted">
|
||||
{tools.length} {tools.length === 1 ? 'Tool' : 'Tools'} Available
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mb-8 flex-1">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
{isConnected && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">
|
||||
Connected ({connectedProfiles.length} profile{connectedProfiles.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
setIsDialogOpen(false);
|
||||
handleConnectClick();
|
||||
}}
|
||||
className="w-full font-medium"
|
||||
>
|
||||
{mode === 'simple' ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
Connect
|
||||
</>
|
||||
) : mode === 'profile-only' ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
{isConnected ? 'Add Profile' : 'Connect'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Zap className="h-4 w-4" />
|
||||
Add Tools
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<h3 className="text-lg font-semibold text-foreground">Available Tools</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{tools.length} tool{tools.length !== 1 ? 's' : ''} ready to integrate
|
||||
</p>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 max-h-[500px]">
|
||||
<div className="p-4">
|
||||
{isToolsLoading ? (
|
||||
<div className="flex flex-col gap-2 items-center justify-center">
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
) : tools.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{tools.map((tool, index) => (
|
||||
<Card
|
||||
key={tool.name}
|
||||
className="group p-4 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<CardHeader className='p-0'>
|
||||
<CardTitle>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className=" flex-shrink-0 h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className='flex flex-col items-start'>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 font-normal">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mx-auto mb-4">
|
||||
<Settings className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">No tools available</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Check back later for updates</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">
|
||||
{app.description}
|
||||
|
|
|
@ -28,7 +28,7 @@ const buttonVariants = cva(
|
|||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-lg gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-lg px-6 has-[>svg]:px-4',
|
||||
lg: 'h-10 rounded-xl px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
node_secondary: 'px-0',
|
||||
},
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
type PipedreamAppResponse,
|
||||
type PipedreamToolsResponse,
|
||||
type AppIconResponse,
|
||||
type PipedreamTool,
|
||||
} from './utils';
|
||||
import { pipedreamKeys } from './keys';
|
||||
|
||||
|
@ -129,3 +130,15 @@ export const usePipedreamAvailableTools = createQueryHook(
|
|||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
}
|
||||
);
|
||||
|
||||
export const usePipedreamAppTools = (appSlug: string, options?: { enabled?: boolean }) => {
|
||||
return useQuery({
|
||||
queryKey: ['pipedream', 'app-tools', appSlug],
|
||||
queryFn: async (): Promise<{ success: boolean; tools: PipedreamTool[] }> => {
|
||||
return await pipedreamApi.getAppTools(appSlug);
|
||||
},
|
||||
enabled: options?.enabled ?? !!appSlug,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 2,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -405,14 +405,26 @@ export const pipedreamApi = {
|
|||
const result = await backendApi.get<AppIconResponse>(
|
||||
`/pipedream/apps/${appSlug}/icon`,
|
||||
{
|
||||
errorContext: { operation: 'get app icon', resource: 'Pipedream app icon' },
|
||||
errorContext: { operation: 'load app icon', resource: 'Pipedream app icon' },
|
||||
}
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error?.message || 'Failed to get app icon');
|
||||
}
|
||||
|
||||
return result.data!;
|
||||
},
|
||||
|
||||
|
||||
async getAppTools(appSlug: string): Promise<{ success: boolean; tools: PipedreamTool[] }> {
|
||||
const result = await backendApi.get<{ success: boolean; tools: PipedreamTool[] }>(
|
||||
`/pipedream/apps/${appSlug}/tools`,
|
||||
{
|
||||
errorContext: { operation: 'load app tools', resource: 'Pipedream app tools' },
|
||||
}
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error?.message || 'Failed to get app tools');
|
||||
}
|
||||
return result.data!;
|
||||
},
|
||||
};
|
Loading…
Reference in New Issue