chore: merge upstream with local feature additions

- Merged dexscreener connector, composio connectors, crypto realtime tools from upstream
- Kept local additions: dropbox/onedrive connectors, memory routes, model_list routes, RefreshToken model
- Resolved frontend conflicts: kept tool UIs from both sides
- Accepted upstream lock files (uv.lock, pnpm-lock.yaml)
This commit is contained in:
Vonic 2026-04-13 23:31:52 +07:00
commit 6e86cd7e8a
803 changed files with 152168 additions and 14005 deletions

View file

@ -0,0 +1,387 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info, Plus, X } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
// Token configuration schema
const tokenSchema = z.object({
chain: z.string().min(1, "Chain is required"),
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid token address (must be 0x followed by 40 hex characters)"),
name: z.string().optional(),
});
type TokenConfig = z.infer<typeof tokenSchema>;
// Form schema
const dexScreenerFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
tokens: z.array(tokenSchema).min(1, "At least one token is required").max(50, "Maximum 50 tokens allowed"),
});
type DexScreenerFormValues = z.infer<typeof dexScreenerFormSchema>;
// Supported chains
const SUPPORTED_CHAINS = [
{ value: "ethereum", label: "Ethereum" },
{ value: "bsc", label: "BSC (Binance Smart Chain)" },
{ value: "polygon", label: "Polygon" },
{ value: "arbitrum", label: "Arbitrum" },
{ value: "optimism", label: "Optimism" },
{ value: "base", label: "Base" },
{ value: "avalanche", label: "Avalanche" },
{ value: "solana", label: "Solana" },
] as const;
export const DexScreenerConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const [tokens, setTokens] = useState<TokenConfig[]>([
{ chain: "ethereum", address: "", name: "" },
]);
const form = useForm<DexScreenerFormValues>({
resolver: zodResolver(dexScreenerFormSchema),
defaultValues: {
name: "DexScreener Connector",
tokens: tokens,
},
});
// Sync tokens state with form
const updateFormTokens = (newTokens: TokenConfig[]) => {
setTokens(newTokens);
form.setValue("tokens", newTokens);
};
const addToken = () => {
if (tokens.length < 50) {
updateFormTokens([...tokens, { chain: "ethereum", address: "", name: "" }]);
}
};
const removeToken = (index: number) => {
if (tokens.length > 1) {
updateFormTokens(tokens.filter((_, i) => i !== index));
}
};
const updateToken = (index: number, field: keyof TokenConfig, value: string) => {
const newTokens = [...tokens];
newTokens[index] = { ...newTokens[index], [field]: value };
updateFormTokens(newTokens);
};
const handleSubmit = async (values: DexScreenerFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.DEXSCREENER_CONNECTOR,
config: {
tokens: values.tokens,
},
is_indexable: true,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">No API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
DexScreener API is public and free to use. Simply add the tokens you want to track.{" "}
<a
href="https://docs.dexscreener.com/api/reference"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
View API Documentation
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="dexscreener-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Crypto Tracker"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Token List */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<div className="flex items-center justify-between">
<h3 className="text-sm sm:text-base font-medium">Tracked Tokens</h3>
<span className="text-xs text-muted-foreground">
{tokens.length} / 50 tokens
</span>
</div>
<div className="space-y-3">
{tokens.map((token, index) => (
<div
key={index}
className="rounded-lg border border-slate-400/20 bg-slate-400/5 dark:bg-white/5 p-3 space-y-3"
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
Token #{index + 1}
</span>
{tokens.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeToken(index)}
disabled={isSubmitting}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor={`chain-${index}`} className="text-xs sm:text-sm">
Chain
</Label>
<Select
value={token.chain}
onValueChange={(value) => updateToken(index, "chain", value)}
disabled={isSubmitting}
>
<SelectTrigger
id={`chain-${index}`}
className="h-8 sm:h-10 bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select chain" />
</SelectTrigger>
<SelectContent className="z-[100]">
{SUPPORTED_CHAINS.map((chain) => (
<SelectItem
key={chain.value}
value={chain.value}
className="text-xs sm:text-sm"
>
{chain.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`address-${index}`} className="text-xs sm:text-sm">
Token Address
</Label>
<Input
id={`address-${index}`}
placeholder="0x..."
value={token.address}
onChange={(e) => updateToken(index, "address", e.target.value)}
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
disabled={isSubmitting}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`name-${index}`} className="text-xs sm:text-sm">
Token Name (Optional)
</Label>
<Input
id={`name-${index}`}
placeholder="e.g., Wrapped Ether"
value={token.name || ""}
onChange={(e) => updateToken(index, "name", e.target.value)}
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
/>
</div>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addToken}
disabled={tokens.length >= 50 || isSubmitting}
className="w-full h-8 sm:h-10 text-xs sm:text-sm"
>
<Plus className="h-3 w-3 sm:h-4 sm:w-4 mr-2" />
Add Token {tokens.length >= 50 && "(Maximum reached)"}
</Button>
</div>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
allowFutureDates={true}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.DEXSCREENER_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with DexScreener integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.DEXSCREENER_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
</div>
);
};

View file

@ -111,6 +111,13 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
"Incremental sync - only changed files are re-indexed",
"Full support for your vault's folder structure",
],
DEXSCREENER_CONNECTOR: [
"Real-time cryptocurrency trading pair data from multiple DEXs",
"Track token prices, volume, and liquidity across chains",
"Search and analyze market data with AI-powered insights",
"Monitor your crypto portfolio with automated updates",
"Access historical price trends and trading volumes",
],
};
return benefits[connectorType] || null;

View file

@ -1,5 +1,17 @@
import dynamic from "next/dynamic";
import type { FC } from "react";
import { BaiduSearchApiConnectForm } from "./components/baidu-search-api-connect-form";
import { BookStackConnectForm } from "./components/bookstack-connect-form";
import { CirclebackConnectForm } from "./components/circleback-connect-form";
import { DexScreenerConnectForm } from "./components/dexscreener-connect-form";
import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form";
import { GithubConnectForm } from "./components/github-connect-form";
import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
import { LumaConnectForm } from "./components/luma-connect-form";
import { MCPConnectForm } from "./components/mcp-connect-form";
import { ObsidianConnectForm } from "./components/obsidian-connect-form";
import { SearxngConnectForm } from "./components/searxng-connect-form";
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
export interface ConnectFormProps {
onSubmit: (data: {
@ -65,12 +77,33 @@ const componentCache = new Map<string, ConnectFormComponent>();
* Factory function to get the appropriate connect form component for a connector type
*/
export function getConnectFormComponent(connectorType: string): ConnectFormComponent | null {
const loader = formMap[connectorType];
if (!loader) return null;
if (!componentCache.has(connectorType)) {
componentCache.set(connectorType, dynamic(loader, { ssr: false }));
switch (connectorType) {
case "TAVILY_API":
return TavilyApiConnectForm;
case "SEARXNG_API":
return SearxngConnectForm;
case "LINKUP_API":
return LinkupApiConnectForm;
case "BAIDU_SEARCH_API":
return BaiduSearchApiConnectForm;
case "ELASTICSEARCH_CONNECTOR":
return ElasticsearchConnectForm;
case "BOOKSTACK_CONNECTOR":
return BookStackConnectForm;
case "GITHUB_CONNECTOR":
return GithubConnectForm;
case "LUMA_CONNECTOR":
return LumaConnectForm;
case "CIRCLEBACK_CONNECTOR":
return CirclebackConnectForm;
case "DEXSCREENER_CONNECTOR":
return DexScreenerConnectForm;
case "MCP_CONNECTOR":
return MCPConnectForm;
case "OBSIDIAN_CONNECTOR":
return ObsidianConnectForm;
// Add other connector types here as needed
default:
return null;
}
return componentCache.get(connectorType)!;
}

View file

@ -0,0 +1,210 @@
"use client";
import { Plus, X } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ConnectorConfigProps } from "../index";
// Token configuration interface
interface TokenConfig {
chain: string;
address: string;
name?: string;
}
// Supported chains
const SUPPORTED_CHAINS = [
{ value: "ethereum", label: "Ethereum" },
{ value: "bsc", label: "BSC (Binance Smart Chain)" },
{ value: "polygon", label: "Polygon" },
{ value: "arbitrum", label: "Arbitrum" },
{ value: "optimism", label: "Optimism" },
{ value: "base", label: "Base" },
{ value: "avalanche", label: "Avalanche" },
{ value: "solana", label: "Solana" },
] as const;
export const DexScreenerConfig: FC<ConnectorConfigProps> = ({
connector,
onConfigChange,
onNameChange,
}) => {
const [tokens, setTokens] = useState<TokenConfig[]>(
(connector.config?.tokens as TokenConfig[]) || []
);
const [name, setName] = useState(connector.name || "");
const handleTokensChange = (newTokens: TokenConfig[]) => {
setTokens(newTokens);
onConfigChange?.({ ...connector.config, tokens: newTokens });
};
const handleNameChange = (newName: string) => {
setName(newName);
onNameChange?.(newName);
};
const addToken = () => {
if (tokens.length < 50) {
handleTokensChange([...tokens, { chain: "ethereum", address: "", name: "" }]);
}
};
const removeToken = (index: number) => {
if (tokens.length > 1) {
handleTokensChange(tokens.filter((_, i) => i !== index));
}
};
const updateToken = (index: number, field: keyof TokenConfig, value: string) => {
const newTokens = [...tokens];
newTokens[index] = { ...newTokens[index], [field]: value };
handleTokensChange(newTokens);
};
return (
<div className="space-y-6">
{/* Connector Name */}
<div className="space-y-2">
<Label htmlFor="connector-name" className="text-sm font-medium">
Connector Name
</Label>
<Input
id="connector-name"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Crypto Tracker"
className="h-10 px-3 text-sm border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
{/* Token Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium">Tracked Tokens</h3>
<span className="text-xs text-muted-foreground">
{tokens.length} / 50 tokens
</span>
</div>
<div className="space-y-3">
{tokens.map((token, index) => (
<div
key={index}
className="rounded-lg border border-slate-400/20 bg-slate-400/5 dark:bg-white/5 p-3 space-y-3"
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
Token #{index + 1}
</span>
{tokens.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeToken(index)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor={`chain-${index}`} className="text-sm">
Chain
</Label>
<Select
value={token.chain}
onValueChange={(value) => updateToken(index, "chain", value)}
>
<SelectTrigger
id={`chain-${index}`}
className="h-10 bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-sm"
>
<SelectValue placeholder="Select chain" />
</SelectTrigger>
<SelectContent className="z-[100]">
{SUPPORTED_CHAINS.map((chain) => (
<SelectItem
key={chain.value}
value={chain.value}
className="text-sm"
>
{chain.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`address-${index}`} className="text-sm">
Token Address
</Label>
<Input
id={`address-${index}`}
placeholder="0x..."
value={token.address}
onChange={(e) => updateToken(index, "address", e.target.value)}
className="h-10 px-3 text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`name-${index}`} className="text-sm">
Token Name (Optional)
</Label>
<Input
id={`name-${index}`}
placeholder="e.g., Wrapped Ether"
value={token.name || ""}
onChange={(e) => updateToken(index, "name", e.target.value)}
className="h-10 px-3 text-sm border-slate-400/20 focus-visible:border-slate-400/40"
/>
</div>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addToken}
disabled={tokens.length >= 50}
className="w-full h-10 text-sm"
>
<Plus className="h-4 w-4 mr-2" />
Add Token {tokens.length >= 50 && "(Maximum reached)"}
</Button>
</div>
{/* Info */}
<div className="rounded-lg bg-slate-400/5 dark:bg-white/5 p-4 space-y-2">
<h4 className="text-sm font-medium">Configuration Tips</h4>
<ul className="list-disc pl-5 text-xs text-muted-foreground space-y-1">
<li>Token addresses must be valid 40-character hex strings (0x...)</li>
<li>You can track up to 50 tokens per connector</li>
<li>Changes are saved automatically when you update the configuration</li>
<li>Token names are optional but help identify tokens in search results</li>
</ul>
</div>
</div>
);
};

View file

@ -3,6 +3,29 @@
import dynamic from "next/dynamic";
import type { FC } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
import { BookStackConfig } from "./components/bookstack-config";
import { CirclebackConfig } from "./components/circleback-config";
import { ClickUpConfig } from "./components/clickup-config";
import { ComposioCalendarConfig } from "./components/composio-calendar-config";
import { ComposioDriveConfig } from "./components/composio-drive-config";
import { ComposioGmailConfig } from "./components/composio-gmail-config";
import { ConfluenceConfig } from "./components/confluence-config";
import { DexScreenerConfig } from "./components/dexscreener-config";
import { DiscordConfig } from "./components/discord-config";
import { ElasticsearchConfig } from "./components/elasticsearch-config";
import { GithubConfig } from "./components/github-config";
import { GoogleDriveConfig } from "./components/google-drive-config";
import { JiraConfig } from "./components/jira-config";
import { LinkupApiConfig } from "./components/linkup-api-config";
import { LumaConfig } from "./components/luma-config";
import { MCPConfig } from "./components/mcp-config";
import { ObsidianConfig } from "./components/obsidian-config";
import { SearxngConfig } from "./components/searxng-config";
import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config";
import { TeamsConfig } from "./components/teams-config";
import { WebcrawlerConfig } from "./components/webcrawler-config";
export interface ConnectorConfigProps {
connector: SearchSourceConnector;
@ -71,12 +94,55 @@ const componentCache = new Map<string, ConnectorConfigComponent>();
export function getConnectorConfigComponent(
connectorType: string
): ConnectorConfigComponent | null {
const loader = configMap[connectorType];
if (!loader) return null;
if (!componentCache.has(connectorType)) {
componentCache.set(connectorType, dynamic(loader, { ssr: false }));
switch (connectorType) {
case "GOOGLE_DRIVE_CONNECTOR":
return GoogleDriveConfig;
case "TAVILY_API":
return TavilyApiConfig;
case "SEARXNG_API":
return SearxngConfig;
case "LINKUP_API":
return LinkupApiConfig;
case "BAIDU_SEARCH_API":
return BaiduSearchApiConfig;
case "WEBCRAWLER_CONNECTOR":
return WebcrawlerConfig;
case "ELASTICSEARCH_CONNECTOR":
return ElasticsearchConfig;
case "SLACK_CONNECTOR":
return SlackConfig;
case "DISCORD_CONNECTOR":
return DiscordConfig;
case "TEAMS_CONNECTOR":
return TeamsConfig;
case "CONFLUENCE_CONNECTOR":
return ConfluenceConfig;
case "BOOKSTACK_CONNECTOR":
return BookStackConfig;
case "GITHUB_CONNECTOR":
return GithubConfig;
case "JIRA_CONNECTOR":
return JiraConfig;
case "CLICKUP_CONNECTOR":
return ClickUpConfig;
case "LUMA_CONNECTOR":
return LumaConfig;
case "CIRCLEBACK_CONNECTOR":
return CirclebackConfig;
case "DEXSCREENER_CONNECTOR":
return DexScreenerConfig;
case "MCP_CONNECTOR":
return MCPConfig;
case "OBSIDIAN_CONNECTOR":
return ObsidianConfig;
case "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
return ComposioDriveConfig;
case "COMPOSIO_GMAIL_CONNECTOR":
return ComposioGmailConfig;
case "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR":
return ComposioCalendarConfig;
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
default:
return null;
}
return componentCache.get(connectorType)!;
}

View file

@ -141,6 +141,12 @@ export const OTHER_CONNECTORS = [
description: "Search Luma events",
connectorType: EnumConnectorName.LUMA_CONNECTOR,
},
{
id: "dexscreener-connector",
title: "DexScreener",
description: "Track cryptocurrency trading pairs across DEXs",
connectorType: EnumConnectorName.DEXSCREENER_CONNECTOR,
},
{
id: "elasticsearch-connector",
title: "Elasticsearch",

View file

@ -0,0 +1,150 @@
"use client";
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Search, Loader2 } from "lucide-react";
interface AddTokenModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAddToken: (token: { symbol: string; name: string; chain: string; contractAddress?: string }) => void;
}
const SUPPORTED_CHAINS = [
{ value: "solana", label: "Solana" },
{ value: "ethereum", label: "Ethereum" },
{ value: "base", label: "Base" },
{ value: "arbitrum", label: "Arbitrum" },
{ value: "polygon", label: "Polygon" },
];
export function AddTokenModal({ open, onOpenChange, onAddToken }: AddTokenModalProps) {
const [symbol, setSymbol] = useState("");
const [name, setName] = useState("");
const [chain, setChain] = useState("solana");
const [contractAddress, setContractAddress] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!symbol.trim()) {
setError("Token symbol is required");
return;
}
if (!chain) {
setError("Please select a chain");
return;
}
setIsLoading(true);
// Simulate API call delay
await new Promise((resolve) => setTimeout(resolve, 500));
onAddToken({
symbol: symbol.toUpperCase().trim(),
name: name.trim() || symbol.toUpperCase().trim(),
chain,
contractAddress: contractAddress.trim() || undefined,
});
// Reset form
setSymbol("");
setName("");
setChain("solana");
setContractAddress("");
setIsLoading(false);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
Add Token to Watchlist
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="symbol">Token Symbol *</Label>
<Input
id="symbol"
placeholder="e.g., BULLA, SOL, ETH"
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
className="uppercase"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="name">Token Name</Label>
<Input
id="name"
placeholder="e.g., Bulla Token"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="chain">Chain *</Label>
<Select value={chain} onValueChange={setChain}>
<SelectTrigger>
<SelectValue placeholder="Select chain" />
</SelectTrigger>
<SelectContent>
{SUPPORTED_CHAINS.map((c) => (
<SelectItem key={c.value} value={c.value}>
{c.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="contract">Contract Address (optional)</Label>
<Input
id="contract"
placeholder="0x... or token mint address"
value={contractAddress}
onChange={(e) => setContractAddress(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Provide contract address for accurate token identification
</p>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Adding...
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Add to Watchlist
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,171 @@
"use client";
import { cn } from "@/lib/utils";
import { Bell, BellOff, Check, AlertTriangle, Info, XCircle } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ChainIcon } from "./ChainIcon";
import type { Alert } from "@/lib/mock/cryptoMockData";
interface AlertsPanelProps {
alerts: Alert[];
onAlertClick?: (alert: Alert) => void;
onMarkAsRead?: (alertId: string) => void;
onMarkAllAsRead?: () => void;
onDismiss?: (alertId: string) => void;
className?: string;
}
function formatTimeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function getSeverityConfig(severity: Alert["severity"]) {
switch (severity) {
case "critical":
return {
icon: XCircle,
color: "text-red-500",
bg: "bg-red-500/10",
border: "border-red-500/20",
};
case "warning":
return {
icon: AlertTriangle,
color: "text-yellow-500",
bg: "bg-yellow-500/10",
border: "border-yellow-500/20",
};
default:
return {
icon: Info,
color: "text-blue-500",
bg: "bg-blue-500/10",
border: "border-blue-500/20",
};
}
}
function AlertItem({
alert,
onClick,
onMarkAsRead,
onDismiss,
}: {
alert: Alert;
onClick?: () => void;
onMarkAsRead?: () => void;
onDismiss?: () => void;
}) {
const config = getSeverityConfig(alert.severity);
const Icon = config.icon;
return (
<div
className={cn(
"flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors",
config.bg,
config.border,
!alert.isRead && "ring-1 ring-primary/20",
"hover:bg-muted/50"
)}
onClick={onClick}
>
<div className={cn("mt-0.5", config.color)}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<ChainIcon chain={alert.chain} size="sm" />
<span className="font-medium text-sm">{alert.tokenSymbol}</span>
{!alert.isRead && (
<Badge variant="default" className="h-4 px-1 text-[10px]">NEW</Badge>
)}
</div>
<p className="text-sm text-muted-foreground line-clamp-2">{alert.message}</p>
<p className="text-xs text-muted-foreground mt-1">{formatTimeAgo(alert.timestamp)}</p>
</div>
<div className="flex flex-col gap-1">
{!alert.isRead && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
onMarkAsRead?.();
}}
title="Mark as read"
>
<Check className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
}
export function AlertsPanel({
alerts,
onAlertClick,
onMarkAsRead,
onMarkAllAsRead,
onDismiss,
className,
}: AlertsPanelProps) {
const unreadCount = alerts.filter((a) => !a.isRead).length;
return (
<Card className={cn("", className)}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Bell className="h-5 w-5" /> Alerts
{unreadCount > 0 && (
<Badge variant="destructive" className="ml-1">{unreadCount}</Badge>
)}
</CardTitle>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={onMarkAllAsRead}>
<Check className="mr-1 h-3 w-3" />
Mark all read
</Button>
)}
</div>
</CardHeader>
<CardContent>
{alerts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<BellOff className="h-8 w-8 mb-2" />
<p className="text-sm">No alerts yet</p>
<p className="text-xs">Configure alerts on your watchlist tokens</p>
</div>
) : (
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-2">
{alerts.map((alert) => (
<AlertItem
key={alert.id}
alert={alert}
onClick={() => onAlertClick?.(alert)}
onMarkAsRead={() => onMarkAsRead?.(alert.id)}
onDismiss={() => onDismiss?.(alert.id)}
/>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,48 @@
"use client";
import { cn } from "@/lib/utils";
import type { ChainType } from "@/lib/mock/cryptoMockData";
interface ChainIconProps {
chain: ChainType;
size?: "sm" | "md" | "lg";
showName?: boolean;
className?: string;
}
const chainConfig: Record<ChainType, { color: string; icon: string; name: string }> = {
solana: { color: "#9945FF", icon: "◎", name: "Solana" },
ethereum: { color: "#627EEA", icon: "Ξ", name: "Ethereum" },
base: { color: "#0052FF", icon: "🔵", name: "Base" },
arbitrum: { color: "#28A0F0", icon: "🔷", name: "Arbitrum" },
polygon: { color: "#8247E5", icon: "⬡", name: "Polygon" },
bsc: { color: "#F0B90B", icon: "⬢", name: "BNB Chain" },
};
const sizeClasses = {
sm: "h-4 w-4 text-xs",
md: "h-5 w-5 text-sm",
lg: "h-6 w-6 text-base",
};
export function ChainIcon({ chain, size = "md", showName = false, className }: ChainIconProps) {
const config = chainConfig[chain] || { color: "#888888", icon: "?", name: chain };
return (
<div className={cn("flex items-center gap-1.5", className)}>
<span
className={cn(
"flex items-center justify-center rounded-full",
sizeClasses[size]
)}
style={{ backgroundColor: `${config.color}20`, color: config.color }}
>
{config.icon}
</span>
{showName && (
<span className="text-sm text-muted-foreground">{config.name}</span>
)}
</div>
);
}

View file

@ -0,0 +1,166 @@
"use client";
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Bell, Loader2 } from "lucide-react";
interface CreateAlertModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateAlert: (alert: AlertConfig) => void;
prefilledToken?: { symbol: string; chain: string };
}
export interface AlertConfig {
tokenSymbol: string;
chain: string;
alertType: string;
threshold?: number;
enabled: boolean;
}
const ALERT_TYPES = [
{ value: "price_above", label: "Price Above", hasThreshold: true, unit: "$" },
{ value: "price_below", label: "Price Below", hasThreshold: true, unit: "$" },
{ value: "price_change", label: "Price Change %", hasThreshold: true, unit: "%" },
{ value: "volume_spike", label: "Volume Spike", hasThreshold: true, unit: "x" },
{ value: "whale_buy", label: "Whale Buy", hasThreshold: false },
{ value: "whale_sell", label: "Whale Sell", hasThreshold: false },
];
const SUPPORTED_CHAINS = [
{ value: "solana", label: "Solana" },
{ value: "ethereum", label: "Ethereum" },
{ value: "base", label: "Base" },
];
export function CreateAlertModal({ open, onOpenChange, onCreateAlert, prefilledToken }: CreateAlertModalProps) {
const [tokenSymbol, setTokenSymbol] = useState(prefilledToken?.symbol || "");
const [chain, setChain] = useState(prefilledToken?.chain || "solana");
const [alertType, setAlertType] = useState("price_above");
const [threshold, setThreshold] = useState("");
const [enabled, setEnabled] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const selectedAlertType = ALERT_TYPES.find((t) => t.value === alertType);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!tokenSymbol.trim()) {
setError("Token symbol is required");
return;
}
if (selectedAlertType?.hasThreshold && !threshold) {
setError("Threshold value is required for this alert type");
return;
}
setIsLoading(true);
await new Promise((resolve) => setTimeout(resolve, 500));
onCreateAlert({
tokenSymbol: tokenSymbol.toUpperCase().trim(),
chain,
alertType,
threshold: selectedAlertType?.hasThreshold ? parseFloat(threshold) : undefined,
enabled,
});
// Reset form
setTokenSymbol("");
setAlertType("price_above");
setThreshold("");
setEnabled(true);
setIsLoading(false);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Create Alert
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="token">Token Symbol *</Label>
<Input
id="token"
placeholder="e.g., SOL"
value={tokenSymbol}
onChange={(e) => setTokenSymbol(e.target.value)}
className="uppercase"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="chain">Chain</Label>
<Select value={chain} onValueChange={setChain}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_CHAINS.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="alertType">Alert Type *</Label>
<Select value={alertType} onValueChange={setAlertType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALERT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedAlertType?.hasThreshold && (
<div className="grid gap-2">
<Label htmlFor="threshold">Threshold ({selectedAlertType.unit}) *</Label>
<Input
id="threshold"
type="number"
step="any"
placeholder={`Enter value in ${selectedAlertType.unit}`}
value={threshold}
onChange={(e) => setThreshold(e.target.value)}
/>
</div>
)}
<div className="flex items-center justify-between">
<Label htmlFor="enabled">Enable Alert</Label>
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Creating...</> : <><Bell className="h-4 w-4 mr-2" />Create Alert</>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,64 @@
"use client";
import { cn } from "@/lib/utils";
import { TrendingUp, TrendingDown } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { TokenPrice } from "@/lib/mock/cryptoMockData";
import { formatPrice, formatPercent, formatLargeNumber } from "@/lib/mock/cryptoMockData";
interface MarketOverviewProps {
tokens: TokenPrice[];
className?: string;
}
function MarketCard({ token }: { token: TokenPrice }) {
const isPositive = token.priceChange24h > 0;
const isNegative = token.priceChange24h < 0;
return (
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-lg font-bold">
{token.icon || token.symbol.charAt(0)}
</div>
<div>
<div className="font-semibold">{token.symbol}</div>
<div className="text-xs text-muted-foreground">{token.name}</div>
</div>
</div>
<div className="text-right">
<div className="font-semibold">{formatPrice(token.price)}</div>
<div
className={cn(
"flex items-center justify-end gap-1 text-xs",
isPositive && "text-green-500",
isNegative && "text-red-500",
!isPositive && !isNegative && "text-muted-foreground"
)}
>
{isPositive && <TrendingUp className="h-3 w-3" />}
{isNegative && <TrendingDown className="h-3 w-3" />}
{formatPercent(token.priceChange24h)}
</div>
</div>
</div>
);
}
export function MarketOverview({ tokens, className }: MarketOverviewProps) {
return (
<Card className={cn("", className)}>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<span>📊</span> Market Overview
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{tokens.map((token) => (
<MarketCard key={token.symbol} token={token} />
))}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,133 @@
"use client";
import { cn } from "@/lib/utils";
import { TrendingUp, TrendingDown, Wallet, PieChart } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChainIcon } from "./ChainIcon";
import type { PortfolioSummary as PortfolioSummaryType, PortfolioToken } from "@/lib/mock/cryptoMockData";
import { formatPrice, formatPercent, formatLargeNumber } from "@/lib/mock/cryptoMockData";
interface PortfolioSummaryProps {
portfolio: PortfolioSummaryType;
className?: string;
}
function StatCard({
label,
value,
change,
changePercent,
}: {
label: string;
value: string;
change?: number;
changePercent?: number;
}) {
const isPositive = change !== undefined && change > 0;
const isNegative = change !== undefined && change < 0;
return (
<div className="p-4 rounded-lg bg-muted/50">
<p className="text-sm text-muted-foreground mb-1">{label}</p>
<p className="text-2xl font-bold">{value}</p>
{change !== undefined && changePercent !== undefined && (
<div
className={cn(
"flex items-center gap-1 text-sm mt-1",
isPositive && "text-green-500",
isNegative && "text-red-500",
!isPositive && !isNegative && "text-muted-foreground"
)}
>
{isPositive && <TrendingUp className="h-3 w-3" />}
{isNegative && <TrendingDown className="h-3 w-3" />}
<span>{formatPrice(Math.abs(change))}</span>
<span>({formatPercent(changePercent)})</span>
</div>
)}
</div>
);
}
function TokenRow({ token }: { token: PortfolioToken }) {
const isPositive = token.pnl > 0;
const isNegative = token.pnl < 0;
return (
<div className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<ChainIcon chain={token.chain} size="sm" />
<div>
<div className="font-medium">{token.symbol}</div>
<div className="text-xs text-muted-foreground">
{token.amount.toLocaleString()} tokens
</div>
</div>
</div>
<div className="text-right">
<div className="font-medium">{formatPrice(token.value)}</div>
<div
className={cn(
"text-xs",
isPositive && "text-green-500",
isNegative && "text-red-500"
)}
>
{formatPercent(token.pnlPercent)}
</div>
</div>
<div className="w-16 text-right">
<div className="text-sm text-muted-foreground">{token.allocation.toFixed(1)}%</div>
<div className="h-1.5 w-full bg-muted rounded-full mt-1 overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: `${token.allocation}%` }}
/>
</div>
</div>
</div>
);
}
export function PortfolioSummary({ portfolio, className }: PortfolioSummaryProps) {
return (
<Card className={cn("", className)}>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Wallet className="h-5 w-5" /> Portfolio
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-3">
<StatCard
label="Total Value"
value={formatPrice(portfolio.totalValue)}
change={portfolio.change24h}
changePercent={portfolio.change24hPercent}
/>
<StatCard
label="Total P&L"
value={formatPrice(portfolio.totalPnl)}
change={portfolio.totalPnl}
changePercent={portfolio.totalPnlPercent}
/>
</div>
{/* Token Holdings */}
<div>
<div className="flex items-center gap-2 mb-3">
<PieChart className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Holdings</span>
</div>
<div className="space-y-1">
{portfolio.tokens.map((token) => (
<TokenRow key={token.id} token={token} />
))}
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import { cn } from "@/lib/utils";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { formatPrice, formatPercent } from "@/lib/mock/cryptoMockData";
interface PriceDisplayProps {
price: number;
priceChange?: number;
size?: "sm" | "md" | "lg";
showIcon?: boolean;
className?: string;
}
const sizeClasses = {
sm: { price: "text-sm font-medium", change: "text-xs" },
md: { price: "text-lg font-semibold", change: "text-sm" },
lg: { price: "text-2xl font-bold", change: "text-base" },
};
export function PriceDisplay({
price,
priceChange,
size = "md",
showIcon = true,
className,
}: PriceDisplayProps) {
const isPositive = priceChange !== undefined && priceChange > 0;
const isNegative = priceChange !== undefined && priceChange < 0;
const isNeutral = priceChange === undefined || priceChange === 0;
return (
<div className={cn("flex items-baseline gap-2", className)}>
<span className={sizeClasses[size].price}>{formatPrice(price)}</span>
{priceChange !== undefined && (
<span
className={cn(
"flex items-center gap-0.5",
sizeClasses[size].change,
isPositive && "text-green-500",
isNegative && "text-red-500",
isNeutral && "text-muted-foreground"
)}
>
{showIcon && (
<>
{isPositive && <TrendingUp className="h-3 w-3" />}
{isNegative && <TrendingDown className="h-3 w-3" />}
{isNeutral && <Minus className="h-3 w-3" />}
</>
)}
{formatPercent(priceChange)}
</span>
)}
</div>
);
}

View file

@ -0,0 +1,64 @@
"use client";
import { cn } from "@/lib/utils";
import { Shield, ShieldAlert, ShieldCheck, ShieldX } from "lucide-react";
import { getSafetyLabel } from "@/lib/mock/cryptoMockData";
interface SafetyBadgeProps {
score: number;
size?: "sm" | "md" | "lg";
showScore?: boolean;
className?: string;
}
const sizeClasses = {
sm: { badge: "px-1.5 py-0.5 text-xs", icon: "h-3 w-3" },
md: { badge: "px-2 py-1 text-sm", icon: "h-4 w-4" },
lg: { badge: "px-3 py-1.5 text-base", icon: "h-5 w-5" },
};
function getScoreConfig(score: number) {
if (score >= 80) {
return {
color: "bg-green-500/10 text-green-600 border-green-500/20",
Icon: ShieldCheck,
};
}
if (score >= 60) {
return {
color: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20",
Icon: Shield,
};
}
if (score >= 40) {
return {
color: "bg-orange-500/10 text-orange-600 border-orange-500/20",
Icon: ShieldAlert,
};
}
return {
color: "bg-red-500/10 text-red-600 border-red-500/20",
Icon: ShieldX,
};
}
export function SafetyBadge({ score, size = "md", showScore = true, className }: SafetyBadgeProps) {
const { color, Icon } = getScoreConfig(score);
const label = getSafetyLabel(score);
return (
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border font-medium",
color,
sizeClasses[size].badge,
className
)}
>
<Icon className={sizeClasses[size].icon} />
<span>{label}</span>
{showScore && <span className="opacity-70">({score})</span>}
</div>
);
}

View file

@ -0,0 +1,165 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { User, Shield, Target, Bell, Save, Loader2 } from "lucide-react";
export interface UserProfile {
riskTolerance: "conservative" | "moderate" | "aggressive";
investmentStyle: "day_trader" | "swing" | "long_term";
preferredChains: string[];
notifications: {
priceAlerts: boolean;
whaleAlerts: boolean;
newsAlerts: boolean;
};
}
interface UserProfileSectionProps {
profile: UserProfile;
onSave: (profile: UserProfile) => void;
}
const CHAINS = ["solana", "ethereum", "base", "arbitrum", "polygon"];
export function UserProfileSection({ profile: initialProfile, onSave }: UserProfileSectionProps) {
const [profile, setProfile] = useState<UserProfile>(initialProfile);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const updateProfile = (updates: Partial<UserProfile>) => {
setProfile((prev) => ({ ...prev, ...updates }));
setHasChanges(true);
};
const toggleChain = (chain: string) => {
const newChains = profile.preferredChains.includes(chain)
? profile.preferredChains.filter((c) => c !== chain)
: [...profile.preferredChains, chain];
updateProfile({ preferredChains: newChains });
};
const handleSave = async () => {
setIsSaving(true);
await new Promise((resolve) => setTimeout(resolve, 500));
onSave(profile);
setIsSaving(false);
setHasChanges(false);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Investment Profile
</CardTitle>
<CardDescription>
Configure your risk preferences and notification settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Risk Tolerance */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Risk Tolerance
</Label>
<Select
value={profile.riskTolerance}
onValueChange={(v) => updateProfile({ riskTolerance: v as UserProfile["riskTolerance"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="conservative">Conservative - Lower risk, stable returns</SelectItem>
<SelectItem value="moderate">Moderate - Balanced risk/reward</SelectItem>
<SelectItem value="aggressive">Aggressive - Higher risk, higher potential</SelectItem>
</SelectContent>
</Select>
</div>
{/* Investment Style */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Target className="h-4 w-4" />
Investment Style
</Label>
<Select
value={profile.investmentStyle}
onValueChange={(v) => updateProfile({ investmentStyle: v as UserProfile["investmentStyle"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day_trader">Day Trader - Quick trades, high frequency</SelectItem>
<SelectItem value="swing">Swing Trader - Hold for days to weeks</SelectItem>
<SelectItem value="long_term">Long Term - Hold for months to years</SelectItem>
</SelectContent>
</Select>
</div>
{/* Preferred Chains */}
<div className="space-y-2">
<Label>Preferred Chains</Label>
<div className="flex flex-wrap gap-2">
{CHAINS.map((chain) => (
<Badge
key={chain}
variant={profile.preferredChains.includes(chain) ? "default" : "outline"}
className="cursor-pointer capitalize"
onClick={() => toggleChain(chain)}
>
{chain}
</Badge>
))}
</div>
</div>
{/* Notifications */}
<div className="space-y-4">
<Label className="flex items-center gap-2">
<Bell className="h-4 w-4" />
Notifications
</Label>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">Price Alerts</span>
<Switch
checked={profile.notifications.priceAlerts}
onCheckedChange={(v) => updateProfile({ notifications: { ...profile.notifications, priceAlerts: v } })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Whale Activity Alerts</span>
<Switch
checked={profile.notifications.whaleAlerts}
onCheckedChange={(v) => updateProfile({ notifications: { ...profile.notifications, whaleAlerts: v } })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">News & Updates</span>
<Switch
checked={profile.notifications.newsAlerts}
onCheckedChange={(v) => updateProfile({ notifications: { ...profile.notifications, newsAlerts: v } })}
/>
</div>
</div>
</div>
{/* Save Button */}
<Button onClick={handleSave} disabled={!hasChanges || isSaving} className="w-full">
{isSaving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</> : <><Save className="h-4 w-4 mr-2" />Save Profile</>}
</Button>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,211 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
Star,
Bell,
ExternalLink,
MoreHorizontal,
ArrowUpDown,
Trash2,
Settings,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { ChainIcon } from "./ChainIcon";
import { SafetyBadge } from "./SafetyBadge";
import type { WatchlistToken } from "@/lib/mock/cryptoMockData";
import { formatPrice, formatPercent, formatLargeNumber } from "@/lib/mock/cryptoMockData";
interface WatchlistTableProps {
tokens: WatchlistToken[];
onTokenClick?: (token: WatchlistToken) => void;
onRemoveToken?: (tokenId: string) => void;
onConfigureAlerts?: (token: WatchlistToken) => void;
className?: string;
}
type SortField = "symbol" | "price" | "priceChange24h" | "volume24h" | "marketCap" | "safetyScore";
type SortDirection = "asc" | "desc";
export function WatchlistTable({
tokens,
onTokenClick,
onRemoveToken,
onConfigureAlerts,
className,
}: WatchlistTableProps) {
const [sortField, setSortField] = useState<SortField>("priceChange24h");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDirection("desc");
}
};
const sortedTokens = [...tokens].sort((a, b) => {
const aVal = a[sortField];
const bVal = b[sortField];
if (typeof aVal === "string" && typeof bVal === "string") {
return sortDirection === "asc" ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
}
return sortDirection === "asc"
? (aVal as number) - (bVal as number)
: (bVal as number) - (aVal as number);
});
const SortableHeader = ({ field, children }: { field: SortField; children: React.ReactNode }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 data-[state=open]:bg-accent"
onClick={() => handleSort(field)}
>
{children}
<ArrowUpDown className="ml-2 h-3 w-3" />
</Button>
);
return (
<Card className={cn("", className)}>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Star className="h-5 w-5 text-yellow-500" /> Watchlist
<Badge variant="secondary" className="ml-2">{tokens.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">
<SortableHeader field="symbol">Token</SortableHeader>
</TableHead>
<TableHead>
<SortableHeader field="price">Price</SortableHeader>
</TableHead>
<TableHead>
<SortableHeader field="priceChange24h">24h</SortableHeader>
</TableHead>
<TableHead className="hidden md:table-cell">
<SortableHeader field="volume24h">Volume</SortableHeader>
</TableHead>
<TableHead className="hidden lg:table-cell">
<SortableHeader field="marketCap">MCap</SortableHeader>
</TableHead>
<TableHead className="hidden lg:table-cell">
<SortableHeader field="safetyScore">Safety</SortableHeader>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedTokens.map((token) => (
<TableRow
key={token.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onTokenClick?.(token)}
>
<TableCell>
<div className="flex items-center gap-2">
<ChainIcon chain={token.chain} size="sm" />
<div>
<div className="font-medium flex items-center gap-1">
{token.symbol}
{token.hasAlerts && (
<Bell className="h-3 w-3 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground">
{token.name}
</div>
</div>
</div>
</TableCell>
<TableCell className="font-medium">
{formatPrice(token.price)}
</TableCell>
<TableCell>
<span className={cn(
"font-medium",
token.priceChange24h > 0 && "text-green-500",
token.priceChange24h < 0 && "text-red-500"
)}>
{formatPercent(token.priceChange24h)}
</span>
</TableCell>
<TableCell className="hidden md:table-cell">
{formatLargeNumber(token.volume24h)}
</TableCell>
<TableCell className="hidden lg:table-cell">
{formatLargeNumber(token.marketCap)}
</TableCell>
<TableCell className="hidden lg:table-cell">
<SafetyBadge score={token.safetyScore} size="sm" />
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onConfigureAlerts?.(token);
}}>
<Settings className="mr-2 h-4 w-4" />
Configure Alerts
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
window.open(`https://dexscreener.com/${token.chain}/${token.contractAddress}`, "_blank");
}}>
<ExternalLink className="mr-2 h-4 w-4" />
View on DexScreener
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={(e) => {
e.stopPropagation();
onRemoveToken?.(token.id);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,12 @@
export { PriceDisplay } from "./PriceDisplay";
export { SafetyBadge } from "./SafetyBadge";
export { ChainIcon } from "./ChainIcon";
export { MarketOverview } from "./MarketOverview";
export { WatchlistTable } from "./WatchlistTable";
export { AlertsPanel } from "./AlertsPanel";
export { PortfolioSummary } from "./PortfolioSummary";
// Modal Components
export { AddTokenModal } from "./AddTokenModal";
export { CreateAlertModal, type AlertConfig } from "./CreateAlertModal";
export { UserProfileSection, type UserProfile } from "./UserProfileSection";

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
import { AlertTriangle, Coins, Inbox, LogOut, Megaphone, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -373,6 +373,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
badge:
announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
},
{
title: "Crypto",
url: `/dashboard/${searchSpaceId}/crypto`,
icon: Coins,
isActive: pathname?.includes("/crypto"),
},
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
[
@ -382,6 +388,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
totalUnreadCount,
isAnnouncementsSidebarOpen,
announcementUnreadCount,
searchSpaceId,
pathname,
]
);

View file

@ -0,0 +1,130 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { CheckCircle, Star, Bell, Trash2, Eye, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
// Schema for action confirmation tool arguments
export const ActionConfirmationArgsSchema = z.object({
actionType: z.enum(["watchlist_add", "watchlist_remove", "alert_set", "alert_delete"]),
tokenSymbol: z.string(),
details: z.array(z.string()).optional(),
});
export type ActionConfirmationArgs = z.infer<typeof ActionConfirmationArgsSchema>;
// Schema for action confirmation result
export const ActionConfirmationResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
});
export type ActionConfirmationResult = z.infer<typeof ActionConfirmationResultSchema>;
const ACTION_CONFIG = {
watchlist_add: {
icon: Star,
title: "Added to Watchlist",
iconColor: "text-yellow-500",
bgColor: "bg-yellow-500/10",
},
watchlist_remove: {
icon: Trash2,
title: "Removed from Watchlist",
iconColor: "text-red-500",
bgColor: "bg-red-500/10",
},
alert_set: {
icon: Bell,
title: "Alert Created",
iconColor: "text-blue-500",
bgColor: "bg-blue-500/10",
},
alert_delete: {
icon: Trash2,
title: "Alert Deleted",
iconColor: "text-red-500",
bgColor: "bg-red-500/10",
},
};
/**
* ActionConfirmationToolUI - Shows confirmation when AI executes actions
* Used for watchlist add/remove, alert set/delete confirmations
*/
export const ActionConfirmationToolUI = makeAssistantToolUI<ActionConfirmationArgs, ActionConfirmationResult>({
toolName: "confirm_action",
render: ({ args, result, status }) => {
const isLoading = status.type === "running";
const config = ACTION_CONFIG[args.actionType];
const Icon = config.icon;
return (
<Card className={cn("my-3 overflow-hidden border-l-4",
args.actionType.includes("add") || args.actionType === "alert_set"
? "border-l-green-500"
: "border-l-red-500"
)}>
<CardContent className="py-4">
<div className="flex items-start gap-3">
{/* Icon */}
<div className={cn("p-2 rounded-full", config.bgColor)}>
{isLoading ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<CheckCircle className="h-5 w-5 text-green-500" />
)}
</div>
{/* Content */}
<div className="flex-1">
<div className="flex items-center gap-2">
<Icon className={cn("h-4 w-4", config.iconColor)} />
<span className="font-medium">{config.title}</span>
<Badge variant="secondary" className="font-mono">
{args.tokenSymbol}
</Badge>
</div>
{/* Details */}
{args.details && args.details.length > 0 && (
<div className="mt-2 text-sm text-muted-foreground">
<p className="mb-1">Default monitoring enabled:</p>
<ul className="list-disc list-inside space-y-0.5">
{args.details.map((detail, i) => (
<li key={i}>{detail}</li>
))}
</ul>
</div>
)}
{/* Result message */}
{result?.message && (
<p className="mt-2 text-sm text-muted-foreground">{result.message}</p>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex gap-2 mt-4 ml-11">
<Button variant="outline" size="sm">
<Eye className="h-3 w-3 mr-1" />
View Watchlist
</Button>
{(args.actionType === "watchlist_add" || args.actionType === "alert_set") && (
<Button variant="outline" size="sm">
<Settings className="h-3 w-3 mr-1" />
Edit Alerts
</Button>
)}
</div>
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,121 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { Bell, TrendingUp, TrendingDown, Percent, DollarSign, Activity, Trash2, Edit2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
// Schema for alert configuration
const AlertConfigSchema = z.object({
id: z.string(),
type: z.enum(["price_above", "price_below", "percent_change", "volume_spike", "whale_activity"]),
value: z.number(),
enabled: z.boolean(),
});
// Schema for alert configuration tool arguments
export const AlertConfigurationArgsSchema = z.object({
tokenSymbol: z.string(),
tokenName: z.string().optional(),
alerts: z.array(AlertConfigSchema),
});
export type AlertConfigurationArgs = z.infer<typeof AlertConfigurationArgsSchema>;
// Schema for alert configuration result
export const AlertConfigurationResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
});
export type AlertConfigurationResult = z.infer<typeof AlertConfigurationResultSchema>;
const ALERT_TYPE_CONFIG = {
price_above: { icon: TrendingUp, label: "Price Above", color: "text-green-500" },
price_below: { icon: TrendingDown, label: "Price Below", color: "text-red-500" },
percent_change: { icon: Percent, label: "% Change", color: "text-blue-500" },
volume_spike: { icon: Activity, label: "Volume Spike", color: "text-purple-500" },
whale_activity: { icon: DollarSign, label: "Whale Activity", color: "text-orange-500" },
};
const formatValue = (type: string, value: number): string => {
if (type === "percent_change") return `${value > 0 ? "+" : ""}${value}%`;
if (type === "volume_spike") return `${value}x normal`;
if (type === "whale_activity") return `>${value.toLocaleString()} USD`;
return `$${value < 1 ? value.toFixed(6) : value.toLocaleString()}`;
};
/**
* AlertConfigurationToolUI - Displays/edits alert configurations for a token
* Used when AI responds to "set alert for BULLA" or "show my alerts for BULLA"
*/
export const AlertConfigurationToolUI = makeAssistantToolUI<AlertConfigurationArgs, AlertConfigurationResult>({
toolName: "configure_alerts",
render: ({ args, status }) => {
const isLoading = status.type === "running";
const alerts = args.alerts || [];
const enabledCount = alerts.filter(a => a.enabled).length;
return (
<Card className="my-3 overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-blue-500" />
Alerts for {args.tokenSymbol}
<Badge variant="secondary">{enabledCount} active</Badge>
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
</div>
<Button variant="outline" size="sm">
<Bell className="h-4 w-4 mr-1" />
Add Alert
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{alerts.length === 0 ? (
<div className="py-6 text-center text-muted-foreground">
<Bell className="h-10 w-10 mx-auto mb-2 opacity-50" />
<p>No alerts configured</p>
<p className="text-sm">Say "Alert me if {args.tokenSymbol} drops 20%"</p>
</div>
) : (
<div className="divide-y">
{alerts.map((alert) => {
const config = ALERT_TYPE_CONFIG[alert.type];
const Icon = config.icon;
return (
<div key={alert.id} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Icon className={cn("h-4 w-4", config.color)} />
<div>
<p className="font-medium">{config.label}</p>
<p className="text-sm text-muted-foreground">
{formatValue(alert.type, alert.value)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={alert.enabled} />
<Button variant="ghost" size="icon" className="h-8 w-8">
<Edit2 className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500">
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,144 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { Users, AlertTriangle, Shield, Crown } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ChainIcon } from "@/components/crypto/ChainIcon";
// Schema for holder
const HolderSchema = z.object({
rank: z.number(),
address: z.string(),
label: z.string().optional(),
balance: z.number(),
percentage: z.number(),
isContract: z.boolean().optional(),
});
// Schema for holder analysis tool arguments
export const HolderAnalysisArgsSchema = z.object({
tokenSymbol: z.string(),
chain: z.string(),
totalHolders: z.number(),
top10Percentage: z.number(),
top50Percentage: z.number().optional(),
holders: z.array(HolderSchema),
concentrationRisk: z.enum(["low", "medium", "high", "critical"]).optional(),
});
export type HolderAnalysisArgs = z.infer<typeof HolderAnalysisArgsSchema>;
// Schema for holder analysis result
export const HolderAnalysisResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
});
export type HolderAnalysisResult = z.infer<typeof HolderAnalysisResultSchema>;
const shortenAddress = (address: string): string => {
return `${address.slice(0, 6)}...${address.slice(-4)}`;
};
const formatBalance = (balance: number): string => {
if (balance >= 1e9) return `${(balance / 1e9).toFixed(2)}B`;
if (balance >= 1e6) return `${(balance / 1e6).toFixed(2)}M`;
if (balance >= 1e3) return `${(balance / 1e3).toFixed(2)}K`;
return balance.toFixed(2);
};
const getRiskColor = (risk: string) => {
switch (risk) {
case "low": return "text-green-500 bg-green-500/10";
case "medium": return "text-yellow-500 bg-yellow-500/10";
case "high": return "text-orange-500 bg-orange-500/10";
case "critical": return "text-red-500 bg-red-500/10";
default: return "text-muted-foreground bg-muted";
}
};
/**
* HolderAnalysisToolUI - Displays holder distribution inline in chat
* Used when AI responds to "who holds BULLA?" or "analyze holders"
*/
export const HolderAnalysisToolUI = makeAssistantToolUI<HolderAnalysisArgs, HolderAnalysisResult>({
toolName: "analyze_holders",
render: ({ args, status }) => {
const isLoading = status.type === "running";
const holders = args.holders || [];
const risk = args.concentrationRisk || "medium";
return (
<Card className="my-3 overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-purple-500" />
Holder Analysis - {args.tokenSymbol}
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
</div>
<ChainIcon chain={args.chain} size="sm" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Holders</p>
<p className="font-medium">{args.totalHolders.toLocaleString()}</p>
</div>
<div className={cn("rounded-lg p-3", args.top10Percentage > 50 ? "bg-red-500/10" : "bg-muted/50")}>
<p className="text-xs text-muted-foreground">Top 10 Hold</p>
<p className={cn("font-medium", args.top10Percentage > 50 && "text-red-500")}>{args.top10Percentage.toFixed(1)}%</p>
</div>
{args.top50Percentage && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Top 50 Hold</p>
<p className="font-medium">{args.top50Percentage.toFixed(1)}%</p>
</div>
)}
<div className={cn("rounded-lg p-3", getRiskColor(risk))}>
<p className="text-xs text-muted-foreground">Concentration Risk</p>
<p className="font-medium capitalize">{risk}</p>
</div>
</div>
{/* Risk Warning */}
{(risk === "high" || risk === "critical") && (
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 text-sm bg-yellow-500/10 rounded-lg p-3">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span>High holder concentration detected. Top wallets could significantly impact price.</span>
</div>
)}
{/* Top Holders List */}
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Top Holders</p>
<div className="divide-y max-h-[250px] overflow-y-auto">
{holders.slice(0, 10).map((holder) => (
<div key={holder.address} className="flex items-center justify-between py-2">
<div className="flex items-center gap-3">
<span className="text-sm font-bold text-muted-foreground w-6">#{holder.rank}</span>
{holder.rank <= 3 && <Crown className={cn("h-4 w-4", holder.rank === 1 ? "text-yellow-500" : holder.rank === 2 ? "text-gray-400" : "text-amber-600")} />}
<div>
<p className="font-medium text-sm">{holder.label || shortenAddress(holder.address)}</p>
{holder.isContract && <Badge variant="outline" className="text-xs">Contract</Badge>}
</div>
</div>
<div className="text-right">
<p className="font-medium text-sm">{holder.percentage.toFixed(2)}%</p>
<p className="text-xs text-muted-foreground">{formatBalance(holder.balance)}</p>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,139 @@
/**
* Crypto Tool UI Components
*
* These components render rich UI for crypto-related AI tools in the chat interface.
* They follow the conversational UX paradigm where all crypto features are
* AI-callable tools that render inline in the chat.
*/
// Token Analysis - displays comprehensive token analysis
export {
TokenAnalysisToolUI,
TokenAnalysisArgsSchema,
TokenAnalysisResultSchema,
type TokenAnalysisArgs,
type TokenAnalysisResult,
} from "./token-analysis";
// Watchlist Display - shows user's watchlist inline
export {
WatchlistDisplayToolUI,
WatchlistDisplayArgsSchema,
WatchlistDisplayResultSchema,
type WatchlistDisplayArgs,
type WatchlistDisplayResult,
} from "./watchlist-display";
// Action Confirmation - confirms executed actions
export {
ActionConfirmationToolUI,
ActionConfirmationArgsSchema,
ActionConfirmationResultSchema,
type ActionConfirmationArgs,
type ActionConfirmationResult,
} from "./action-confirmation";
// Alert Configuration - displays/edits alert settings
export {
AlertConfigurationToolUI,
AlertConfigurationArgsSchema,
AlertConfigurationResultSchema,
type AlertConfigurationArgs,
type AlertConfigurationResult,
} from "./alert-configuration";
// Proactive Alert - AI-initiated alerts
export {
ProactiveAlertToolUI,
ProactiveAlertArgsSchema,
ProactiveAlertResultSchema,
type ProactiveAlertArgs,
type ProactiveAlertResult,
} from "./proactive-alert";
// Trending Tokens - displays hot/trending tokens
export {
TrendingTokensToolUI,
TrendingTokensArgsSchema,
TrendingTokensResultSchema,
type TrendingTokensArgs,
type TrendingTokensResult,
} from "./trending-tokens";
// Whale Activity - displays whale transactions
export {
WhaleActivityToolUI,
WhaleActivityArgsSchema,
WhaleActivityResultSchema,
type WhaleActivityArgs,
type WhaleActivityResult,
} from "./whale-activity";
// Market Overview - displays market summary
export {
MarketOverviewToolUI,
MarketOverviewArgsSchema,
MarketOverviewResultSchema,
type MarketOverviewArgs,
type MarketOverviewResult,
} from "./market-overview-tool";
// Holder Analysis - displays holder distribution
export {
HolderAnalysisToolUI,
HolderAnalysisArgsSchema,
HolderAnalysisResultSchema,
type HolderAnalysisArgs,
type HolderAnalysisResult,
} from "./holder-analysis";
// Portfolio Display - displays user's portfolio
export {
PortfolioDisplayToolUI,
PortfolioDisplayArgsSchema,
PortfolioDisplayResultSchema,
type PortfolioDisplayArgs,
type PortfolioDisplayResult,
} from "./portfolio-display";
// User Profile - displays user's investment profile
export {
UserProfileToolUI,
UserProfileArgsSchema,
UserProfileResultSchema,
type UserProfileArgs,
type UserProfileResult,
} from "./user-profile";
// =========================================================================
// REAL-TIME CRYPTO TOOLS - Hybrid approach (RAG + Real-time)
// =========================================================================
// These components render results from real-time DexScreener API calls.
// Used alongside RAG-based tools for comprehensive crypto analysis.
// Live Token Price - displays real-time price from DexScreener
export {
LiveTokenPriceToolUI,
LiveTokenPriceArgsSchema,
LiveTokenPriceResultSchema,
type LiveTokenPriceArgs,
type LiveTokenPriceResult,
} from "./live-token-price";
// Live Token Data - displays comprehensive real-time market data
export {
LiveTokenDataToolUI,
LiveTokenDataArgsSchema,
LiveTokenDataResultSchema,
type LiveTokenDataArgs,
type LiveTokenDataResult,
} from "./live-token-data";
// Trading Suggestion - displays AI-powered entry/exit suggestions
export {
TradingSuggestionToolUI,
TradingSuggestionArgsSchema,
TradingSuggestionResultSchema,
type TradingSuggestionArgs,
type TradingSuggestionResult,
} from "./trading-suggestion";

View file

@ -0,0 +1,246 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { TrendingUp, TrendingDown, ExternalLink, Zap, RefreshCw, Activity, Droplets, BarChart3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ChainIcon } from "@/components/crypto/ChainIcon";
// Schema for live token data tool arguments
export const LiveTokenDataArgsSchema = z.object({
chain: z.string(),
token_address: z.string(),
token_symbol: z.string().optional(),
include_all_pairs: z.boolean().optional(),
});
export type LiveTokenDataArgs = z.infer<typeof LiveTokenDataArgsSchema>;
// Schema for live token data result (matches backend response)
export const LiveTokenDataResultSchema = z.object({
id: z.string(),
kind: z.literal("live_token_data"),
chain: z.string(),
token_address: z.string(),
token_symbol: z.string().optional(),
token_name: z.string().optional(),
price_usd: z.string().optional(),
price_native: z.string().optional(),
price_change_5m: z.number().optional(),
price_change_1h: z.number().optional(),
price_change_6h: z.number().optional(),
price_change_24h: z.number().optional(),
volume_24h: z.number().optional(),
volume_6h: z.number().optional(),
volume_1h: z.number().optional(),
liquidity_usd: z.number().optional(),
market_cap: z.number().optional(),
fdv: z.number().optional(),
txns_24h_buys: z.number().optional(),
txns_24h_sells: z.number().optional(),
txns_6h_buys: z.number().optional(),
txns_6h_sells: z.number().optional(),
txns_1h_buys: z.number().optional(),
txns_1h_sells: z.number().optional(),
total_volume_24h_all_pairs: z.number().optional(),
total_liquidity_all_pairs: z.number().optional(),
total_buys_24h_all_pairs: z.number().optional(),
total_sells_24h_all_pairs: z.number().optional(),
dex: z.string().optional(),
pair_url: z.string().optional(),
total_pairs: z.number().optional(),
data_source: z.string().optional(),
error: z.string().optional(),
});
export type LiveTokenDataResult = z.infer<typeof LiveTokenDataResultSchema>;
const formatPrice = (price: string | undefined): string => {
if (!price || price === "N/A") return "N/A";
const num = parseFloat(price);
if (isNaN(num)) return price;
if (num < 0.00001) return `$${num.toExponential(2)}`;
if (num < 1) return `$${num.toFixed(6)}`;
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatLargeNumber = (num: number | undefined): string => {
if (num === undefined || num === null || num === 0) return "N/A";
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
return `$${num.toFixed(2)}`;
};
const formatNumber = (num: number | undefined): string => {
if (num === undefined || num === null) return "0";
return num.toLocaleString();
};
const PriceChange = ({ value, label }: { value: number | undefined; label: string }) => {
if (value === undefined || value === null) return null;
const isPositive = value >= 0;
return (
<div className="text-center">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn("text-sm font-medium", isPositive ? "text-green-500" : "text-red-500")}>
{isPositive ? "+" : ""}{value.toFixed(2)}%
</p>
</div>
);
};
/**
* LiveTokenDataToolUI - Displays comprehensive real-time market data
* Used when AI fetches detailed live market information
*/
export const LiveTokenDataToolUI = makeAssistantToolUI<LiveTokenDataArgs, LiveTokenDataResult>({
toolName: "get_live_token_data",
render: ({ args, result, status }) => {
const isLoading = status.type === "running";
const hasError = result?.error;
const handleOpenDexScreener = () => {
if (result?.pair_url) {
window.open(result.pair_url, "_blank");
} else if (args.token_address) {
window.open(`https://dexscreener.com/${args.chain}/${args.token_address}`, "_blank");
}
};
const totalTxns24h = (result?.txns_24h_buys || 0) + (result?.txns_24h_sells || 0);
const buyRatio = totalTxns24h > 0 ? ((result?.txns_24h_buys || 0) / totalTxns24h) * 100 : 50;
return (
<Card className="my-3 overflow-hidden border-purple-500/20">
<CardHeader className="pb-3 bg-gradient-to-r from-purple-500/5 to-transparent">
<CardTitle className="text-lg flex items-center gap-2">
<Activity className="h-5 w-5 text-purple-500" />
Live Market Data
{isLoading && <Badge variant="secondary" className="animate-pulse">Fetching...</Badge>}
{!isLoading && !hasError && (
<Badge variant="outline" className="text-xs text-purple-500 border-purple-500/30">
<RefreshCw className="h-3 w-3 mr-1" />
Real-time
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pt-4">
{hasError ? (
<div className="text-red-500 text-sm p-3 bg-red-500/10 rounded-lg">
{result.error}
</div>
) : (
<>
{/* Token Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ChainIcon chain={result?.chain || args.chain} size="md" />
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-xl">
{result?.token_symbol || args.token_symbol || "Token"}
</span>
{result?.token_name && (
<span className="text-muted-foreground text-sm">{result.token_name}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="font-semibold text-2xl">{formatPrice(result?.price_usd)}</span>
{result?.price_change_24h !== undefined && (
<span className={cn(
"flex items-center gap-0.5 text-sm font-medium",
result.price_change_24h >= 0 ? "text-green-500" : "text-red-500"
)}>
{result.price_change_24h >= 0 ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
{result.price_change_24h >= 0 ? "+" : ""}{result.price_change_24h.toFixed(2)}%
</span>
)}
</div>
</div>
</div>
</div>
{/* Price Changes */}
<div className="flex justify-around py-2 bg-muted/30 rounded-lg">
<PriceChange value={result?.price_change_5m} label="5m" />
<PriceChange value={result?.price_change_1h} label="1h" />
<PriceChange value={result?.price_change_6h} label="6h" />
<PriceChange value={result?.price_change_24h} label="24h" />
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<BarChart3 className="h-3 w-3" /> 24h Volume
</p>
<p className="font-medium">{formatLargeNumber(result?.volume_24h)}</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Droplets className="h-3 w-3" /> Liquidity
</p>
<p className="font-medium">{formatLargeNumber(result?.liquidity_usd)}</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Market Cap</p>
<p className="font-medium">{formatLargeNumber(result?.market_cap)}</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">FDV</p>
<p className="font-medium">{formatLargeNumber(result?.fdv)}</p>
</div>
</div>
{/* Transaction Activity */}
<div className="space-y-2">
<p className="text-sm font-medium flex items-center gap-2">
<Activity className="h-4 w-4" /> 24h Transactions
</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all"
style={{ width: `${buyRatio}%` }}
/>
</div>
</div>
<div className="flex justify-between text-sm">
<span className="text-green-500">
{formatNumber(result?.txns_24h_buys)} buys
</span>
<span className="text-muted-foreground">
{formatNumber(totalTxns24h)} total
</span>
<span className="text-red-500">
{formatNumber(result?.txns_24h_sells)} sells
</span>
</div>
</div>
{/* DEX Info & Actions */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="text-sm text-muted-foreground">
<span>DEX: {result?.dex || "Unknown"}</span>
{result?.total_pairs && result.total_pairs > 1 && (
<span className="ml-2"> {result.total_pairs} pairs</span>
)}
</div>
<Button variant="outline" size="sm" onClick={handleOpenDexScreener}>
<ExternalLink className="h-4 w-4 mr-1" />
DexScreener
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,161 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { TrendingUp, TrendingDown, ExternalLink, Zap, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ChainIcon } from "@/components/crypto/ChainIcon";
// Schema for live token price tool arguments
export const LiveTokenPriceArgsSchema = z.object({
chain: z.string(),
token_address: z.string(),
token_symbol: z.string().optional(),
});
export type LiveTokenPriceArgs = z.infer<typeof LiveTokenPriceArgsSchema>;
// Schema for live token price result (matches backend response)
export const LiveTokenPriceResultSchema = z.object({
id: z.string(),
kind: z.literal("live_token_price"),
chain: z.string(),
token_address: z.string(),
token_symbol: z.string().optional(),
token_name: z.string().optional(),
price_usd: z.string().optional(),
price_native: z.string().optional(),
price_change_5m: z.number().optional(),
price_change_1h: z.number().optional(),
price_change_6h: z.number().optional(),
price_change_24h: z.number().optional(),
volume_24h: z.number().optional(),
liquidity_usd: z.number().optional(),
market_cap: z.number().optional(),
fdv: z.number().optional(),
dex: z.string().optional(),
pair_url: z.string().optional(),
total_pairs: z.number().optional(),
data_source: z.string().optional(),
error: z.string().optional(),
});
export type LiveTokenPriceResult = z.infer<typeof LiveTokenPriceResultSchema>;
const formatPrice = (price: string | undefined): string => {
if (!price || price === "N/A") return "N/A";
const num = parseFloat(price);
if (isNaN(num)) return price;
if (num < 0.00001) return `$${num.toExponential(2)}`;
if (num < 1) return `$${num.toFixed(6)}`;
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatLargeNumber = (num: number | undefined): string => {
if (num === undefined || num === null) return "N/A";
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
return `$${num.toFixed(2)}`;
};
const PriceChange = ({ value, label }: { value: number | undefined; label: string }) => {
if (value === undefined || value === null) return null;
const isPositive = value >= 0;
return (
<div className="text-center">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn("text-sm font-medium", isPositive ? "text-green-500" : "text-red-500")}>
{isPositive ? "+" : ""}{value.toFixed(2)}%
</p>
</div>
);
};
/**
* LiveTokenPriceToolUI - Displays real-time token price from DexScreener
* Used when AI fetches current/live price data
*/
export const LiveTokenPriceToolUI = makeAssistantToolUI<LiveTokenPriceArgs, LiveTokenPriceResult>({
toolName: "get_live_token_price",
render: ({ args, result, status }) => {
const isLoading = status.type === "running";
const hasError = result?.error;
const handleOpenDexScreener = () => {
if (result?.pair_url) {
window.open(result.pair_url, "_blank");
} else if (args.token_address) {
window.open(`https://dexscreener.com/${args.chain}/${args.token_address}`, "_blank");
}
};
return (
<Card className="my-3 overflow-hidden border-blue-500/20">
<CardHeader className="pb-3 bg-gradient-to-r from-blue-500/5 to-transparent">
<CardTitle className="text-lg flex items-center gap-2">
<Zap className="h-5 w-5 text-blue-500" />
Live Price
{isLoading && <Badge variant="secondary" className="animate-pulse">Fetching...</Badge>}
{!isLoading && !hasError && (
<Badge variant="outline" className="text-xs text-blue-500 border-blue-500/30">
<RefreshCw className="h-3 w-3 mr-1" />
Real-time
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pt-4">
{hasError ? (
<div className="text-red-500 text-sm p-3 bg-red-500/10 rounded-lg">
{result.error}
</div>
) : (
<>
{/* Token Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ChainIcon chain={result?.chain || args.chain} size="md" />
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-xl">
{result?.token_symbol || args.token_symbol || "Token"}
</span>
{result?.token_name && (
<span className="text-muted-foreground text-sm">{result.token_name}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="font-semibold text-2xl">{formatPrice(result?.price_usd)}</span>
{result?.price_change_24h !== undefined && (
<span className={cn(
"flex items-center gap-0.5 text-sm font-medium",
result.price_change_24h >= 0 ? "text-green-500" : "text-red-500"
)}>
{result.price_change_24h >= 0 ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
{result.price_change_24h >= 0 ? "+" : ""}{result.price_change_24h.toFixed(2)}%
</span>
)}
</div>
</div>
</div>
</div>
{/* Price Changes */}
<div className="flex justify-around py-2 bg-muted/30 rounded-lg">
<PriceChange value={result?.price_change_5m} label="5m" />
<PriceChange value={result?.price_change_1h} label="1h" />
<PriceChange value={result?.price_change_6h} label="6h" />
<PriceChange value={result?.price_change_24h} label="24h" />
</div>
</>
)}
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,126 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { BarChart3, TrendingUp, TrendingDown, Globe } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
// Schema for market token
const MarketTokenSchema = z.object({
symbol: z.string(),
name: z.string(),
price: z.number(),
priceChange24h: z.number(),
marketCap: z.number().optional(),
volume24h: z.number().optional(),
});
// Schema for market overview tool arguments
export const MarketOverviewArgsSchema = z.object({
tokens: z.array(MarketTokenSchema),
totalMarketCap: z.number().optional(),
totalVolume24h: z.number().optional(),
btcDominance: z.number().optional(),
fearGreedIndex: z.number().optional(),
});
export type MarketOverviewArgs = z.infer<typeof MarketOverviewArgsSchema>;
// Schema for market overview result
export const MarketOverviewResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
});
export type MarketOverviewResult = z.infer<typeof MarketOverviewResultSchema>;
const formatPrice = (price: number): string => {
if (price < 1) return `$${price.toFixed(4)}`;
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatLargeNumber = (num: number): string => {
if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`;
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
return `$${num.toFixed(2)}`;
};
/**
* MarketOverviewToolUI - Displays market overview inline in chat
* Used when AI responds to "show market overview" or "how's the market?"
*/
export const MarketOverviewToolUI = makeAssistantToolUI<MarketOverviewArgs, MarketOverviewResult>({
toolName: "get_market_overview",
render: ({ args, status }) => {
const isLoading = status.type === "running";
const tokens = args.tokens || [];
return (
<Card className="my-3 overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Globe className="h-5 w-5 text-blue-500" />
Market Overview
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Global Stats */}
{(args.totalMarketCap || args.btcDominance || args.fearGreedIndex) && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{args.totalMarketCap && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Market Cap</p>
<p className="font-medium">{formatLargeNumber(args.totalMarketCap)}</p>
</div>
)}
{args.totalVolume24h && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">24h Volume</p>
<p className="font-medium">{formatLargeNumber(args.totalVolume24h)}</p>
</div>
)}
{args.btcDominance && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">BTC Dominance</p>
<p className="font-medium">{args.btcDominance.toFixed(1)}%</p>
</div>
)}
{args.fearGreedIndex && (
<div className={cn("rounded-lg p-3", args.fearGreedIndex > 50 ? "bg-green-500/10" : "bg-red-500/10")}>
<p className="text-xs text-muted-foreground">Fear & Greed</p>
<p className={cn("font-medium", args.fearGreedIndex > 50 ? "text-green-500" : "text-red-500")}>
{args.fearGreedIndex} - {args.fearGreedIndex > 75 ? "Extreme Greed" : args.fearGreedIndex > 50 ? "Greed" : args.fearGreedIndex > 25 ? "Fear" : "Extreme Fear"}
</p>
</div>
)}
</div>
)}
{/* Token Prices */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{tokens.map((token) => (
<div key={token.symbol} className="bg-muted/50 rounded-lg p-4 flex items-center justify-between">
<div>
<p className="font-bold text-lg">{token.symbol}</p>
<p className="text-xs text-muted-foreground">{token.name}</p>
</div>
<div className="text-right">
<p className="font-medium">{formatPrice(token.price)}</p>
<p className={cn("text-sm flex items-center justify-end gap-0.5", token.priceChange24h >= 0 ? "text-green-500" : "text-red-500")}>
{token.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(2)}%
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,121 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { Wallet, TrendingUp, TrendingDown, PieChart } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ChainIcon } from "@/components/crypto/ChainIcon";
// Schema for portfolio holding
const HoldingSchema = z.object({
symbol: z.string(),
name: z.string(),
chain: z.string(),
balance: z.number(),
value: z.number(),
costBasis: z.number().optional(),
pnl: z.number().optional(),
pnlPercent: z.number().optional(),
allocation: z.number(),
});
// Schema for portfolio display tool arguments
export const PortfolioDisplayArgsSchema = z.object({
holdings: z.array(HoldingSchema),
totalValue: z.number(),
totalPnl: z.number().optional(),
totalPnlPercent: z.number().optional(),
lastUpdated: z.string().optional(),
});
export type PortfolioDisplayArgs = z.infer<typeof PortfolioDisplayArgsSchema>;
// Schema for portfolio display result
export const PortfolioDisplayResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
});
export type PortfolioDisplayResult = z.infer<typeof PortfolioDisplayResultSchema>;
const formatValue = (value: number): string => {
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
if (value >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
return `$${value.toFixed(2)}`;
};
/**
* PortfolioDisplayToolUI - Displays user's portfolio inline in chat
* Used when AI responds to "how's my portfolio?" or "show my holdings"
*/
export const PortfolioDisplayToolUI = makeAssistantToolUI<PortfolioDisplayArgs, PortfolioDisplayResult>({
toolName: "get_portfolio",
render: ({ args, status }) => {
const isLoading = status.type === "running";
const holdings = args.holdings || [];
const hasPnl = args.totalPnl !== undefined;
return (
<Card className="my-3 overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<Wallet className="h-5 w-5 text-emerald-500" />
Your Portfolio
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
</div>
{args.lastUpdated && (
<span className="text-xs text-muted-foreground">Updated {args.lastUpdated}</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Total Value */}
<div className="bg-gradient-to-r from-emerald-500/10 to-blue-500/10 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Total Value</p>
<p className="text-3xl font-bold">{formatValue(args.totalValue)}</p>
{hasPnl && (
<p className={cn("text-sm flex items-center gap-1 mt-1", (args.totalPnl || 0) >= 0 ? "text-green-500" : "text-red-500")}>
{(args.totalPnl || 0) >= 0 ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
{(args.totalPnl || 0) >= 0 ? "+" : ""}{formatValue(args.totalPnl || 0)} ({(args.totalPnlPercent || 0) >= 0 ? "+" : ""}{(args.totalPnlPercent || 0).toFixed(2)}%)
</p>
)}
</div>
{/* Holdings List */}
{holdings.length === 0 ? (
<p className="text-muted-foreground text-center py-4">No holdings found</p>
) : (
<div className="divide-y">
{holdings.map((holding) => (
<div key={holding.symbol} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<ChainIcon chain={holding.chain} size="sm" />
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{holding.symbol}</span>
<Badge variant="secondary" className="text-xs">{holding.allocation.toFixed(1)}%</Badge>
</div>
<span className="text-xs text-muted-foreground">{holding.balance.toLocaleString()} tokens</span>
</div>
</div>
<div className="text-right">
<p className="font-medium">{formatValue(holding.value)}</p>
{holding.pnlPercent !== undefined && (
<p className={cn("text-sm", holding.pnlPercent >= 0 ? "text-green-500" : "text-red-500")}>
{holding.pnlPercent >= 0 ? "+" : ""}{holding.pnlPercent.toFixed(2)}%
</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,136 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { AlertTriangle, TrendingUp, TrendingDown, Activity, Zap, Eye, Bell, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
// Schema for proactive alert tool arguments
export const ProactiveAlertArgsSchema = z.object({
alertType: z.enum(["price_surge", "price_drop", "whale_buy", "whale_sell", "volume_spike", "safety_warning"]),
tokenSymbol: z.string(),
tokenName: z.string().optional(),
value: z.number(),
previousValue: z.number().optional(),
message: z.string(),
severity: z.enum(["info", "warning", "critical"]).optional(),
timestamp: z.string().optional(),
});
export type ProactiveAlertArgs = z.infer<typeof ProactiveAlertArgsSchema>;
// Schema for proactive alert result
export const ProactiveAlertResultSchema = z.object({
acknowledged: z.boolean(),
});
export type ProactiveAlertResult = z.infer<typeof ProactiveAlertResultSchema>;
const ALERT_TYPE_CONFIG = {
price_surge: { icon: TrendingUp, color: "text-green-500", bgColor: "bg-green-500/10", borderColor: "border-l-green-500" },
price_drop: { icon: TrendingDown, color: "text-red-500", bgColor: "bg-red-500/10", borderColor: "border-l-red-500" },
whale_buy: { icon: Zap, color: "text-green-500", bgColor: "bg-green-500/10", borderColor: "border-l-green-500" },
whale_sell: { icon: Zap, color: "text-red-500", bgColor: "bg-red-500/10", borderColor: "border-l-red-500" },
volume_spike: { icon: Activity, color: "text-purple-500", bgColor: "bg-purple-500/10", borderColor: "border-l-purple-500" },
safety_warning: { icon: AlertTriangle, color: "text-yellow-500", bgColor: "bg-yellow-500/10", borderColor: "border-l-yellow-500" },
};
const SEVERITY_CONFIG = {
info: { badge: "secondary", pulse: false },
warning: { badge: "warning", pulse: false },
critical: { badge: "destructive", pulse: true },
};
/**
* ProactiveAlertToolUI - Displays AI-initiated alerts in chat
* Used when AI proactively notifies user about price changes, whale activity, etc.
*/
export const ProactiveAlertToolUI = makeAssistantToolUI<ProactiveAlertArgs, ProactiveAlertResult>({
toolName: "proactive_alert",
render: ({ args, result }) => {
const config = ALERT_TYPE_CONFIG[args.alertType];
const severity = args.severity || "info";
const severityConfig = SEVERITY_CONFIG[severity];
const Icon = config.icon;
const isAcknowledged = result?.acknowledged;
const formatChange = () => {
if (args.previousValue === undefined) return null;
const change = ((args.value - args.previousValue) / args.previousValue) * 100;
return change;
};
const change = formatChange();
return (
<Card className={cn(
"my-3 overflow-hidden border-l-4 transition-all",
config.borderColor,
isAcknowledged && "opacity-60"
)}>
<CardContent className="py-4">
<div className="flex items-start gap-3">
{/* Alert Icon */}
<div className={cn(
"p-2 rounded-full",
config.bgColor,
severityConfig.pulse && "animate-pulse"
)}>
<Icon className={cn("h-5 w-5", config.color)} />
</div>
{/* Content */}
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={severityConfig.badge as any} className="uppercase text-xs">
{args.alertType.replace("_", " ")}
</Badge>
<span className="font-bold">{args.tokenSymbol}</span>
{change !== null && (
<span className={cn(
"font-medium",
change >= 0 ? "text-green-500" : "text-red-500"
)}>
{change >= 0 ? "+" : ""}{change.toFixed(1)}%
</span>
)}
{args.timestamp && (
<span className="text-xs text-muted-foreground ml-auto">
{args.timestamp}
</span>
)}
</div>
<p className="mt-2 text-sm">{args.message}</p>
</div>
{/* Dismiss */}
{!isAcknowledged && (
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground">
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Action buttons */}
{!isAcknowledged && (
<div className="flex gap-2 mt-3 ml-11">
<Button variant="outline" size="sm">
<Eye className="h-3 w-3 mr-1" />
View Details
</Button>
<Button variant="outline" size="sm">
<Bell className="h-3 w-3 mr-1" />
Adjust Alert
</Button>
</div>
)}
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,162 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { Shield, TrendingUp, TrendingDown, Users, AlertTriangle, Star, Bell, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ChainIcon } from "@/components/crypto/ChainIcon";
import { SafetyBadge } from "@/components/crypto/SafetyBadge";
// Schema for token analysis tool arguments
export const TokenAnalysisArgsSchema = z.object({
symbol: z.string(),
name: z.string().optional(),
chain: z.string(),
contractAddress: z.string().optional(),
price: z.number(),
priceChange24h: z.number(),
marketCap: z.number().optional(),
volume24h: z.number().optional(),
liquidity: z.number().optional(),
safetyScore: z.number().optional(),
holderCount: z.number().optional(),
top10HolderPercent: z.number().optional(),
});
export type TokenAnalysisArgs = z.infer<typeof TokenAnalysisArgsSchema>;
// Schema for token analysis result
export const TokenAnalysisResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
isInWatchlist: z.boolean().optional(),
});
export type TokenAnalysisResult = z.infer<typeof TokenAnalysisResultSchema>;
const formatPrice = (price: number): string => {
if (price < 0.00001) return `$${price.toExponential(2)}`;
if (price < 1) return `$${price.toFixed(6)}`;
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatLargeNumber = (num: number): string => {
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
return `$${num.toFixed(2)}`;
};
/**
* TokenAnalysisToolUI - Displays comprehensive token analysis in chat
* Used when AI responds to token research queries like "analyze BULLA" or "is BULLA safe?"
*/
export const TokenAnalysisToolUI = makeAssistantToolUI<TokenAnalysisArgs, TokenAnalysisResult>({
toolName: "analyze_token",
render: ({ args, result, status }) => {
const isLoading = status.type === "running";
const isInWatchlist = result?.isInWatchlist ?? false;
const handleOpenDexScreener = () => {
if (args.contractAddress) {
window.open(`https://dexscreener.com/${args.chain}/${args.contractAddress}`, "_blank");
}
};
return (
<Card className="my-3 overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<span>📊</span>
Token Analysis
{isLoading && <Badge variant="secondary" className="animate-pulse">Analyzing...</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Token Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ChainIcon chain={args.chain} size="md" />
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-lg">{args.symbol}</span>
{args.name && <span className="text-muted-foreground text-sm">{args.name}</span>}
</div>
<div className="flex items-center gap-2">
<span className="font-medium">{formatPrice(args.price)}</span>
<span className={cn(
"flex items-center gap-0.5 text-sm font-medium",
args.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
)}>
{args.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{args.priceChange24h >= 0 ? "+" : ""}{args.priceChange24h.toFixed(2)}%
</span>
</div>
</div>
</div>
{args.safetyScore !== undefined && (
<SafetyBadge score={args.safetyScore} size="lg" />
)}
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{args.marketCap && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Market Cap</p>
<p className="font-medium">{formatLargeNumber(args.marketCap)}</p>
</div>
)}
{args.volume24h && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">24h Volume</p>
<p className="font-medium">{formatLargeNumber(args.volume24h)}</p>
</div>
)}
{args.liquidity && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Liquidity</p>
<p className="font-medium">{formatLargeNumber(args.liquidity)}</p>
</div>
)}
{args.holderCount && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Holders</p>
<p className="font-medium flex items-center gap-1">
<Users className="h-3 w-3" />
{args.holderCount.toLocaleString()}
</p>
</div>
)}
</div>
{/* Holder Concentration Warning */}
{args.top10HolderPercent && args.top10HolderPercent > 50 && (
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 text-sm bg-yellow-500/10 rounded-lg p-2">
<AlertTriangle className="h-4 w-4" />
<span>Top 10 holders own {args.top10HolderPercent}% of supply - high concentration risk</span>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1">
<Star className={cn("h-4 w-4 mr-2", isInWatchlist && "fill-yellow-500 text-yellow-500")} />
{isInWatchlist ? "In Watchlist" : "Add to Watchlist"}
</Button>
<Button variant="outline" size="sm">
<Bell className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={handleOpenDexScreener}>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,236 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { Target, AlertCircle, Info, TrendingUp, TrendingDown, Bell, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ChainIcon } from "@/components/crypto/ChainIcon";
import { useState } from "react";
// Schema for trading suggestion tool arguments
export const TradingSuggestionArgsSchema = z.object({
tokenSymbol: z.string(),
tokenName: z.string().optional(),
chain: z.string(),
contractAddress: z.string().optional(),
currentPrice: z.number(),
entry: z.object({
min: z.number(),
max: z.number(),
reasoning: z.string(),
}),
targets: z.array(z.object({
level: z.number(),
price: z.number(),
percentGain: z.number(),
confidence: z.number(),
})),
stopLoss: z.object({
price: z.number(),
percentLoss: z.number(),
reasoning: z.string(),
}),
riskReward: z.number(),
overallConfidence: z.number(),
reasoning: z.array(z.string()),
invalidationConditions: z.array(z.string()),
});
export type TradingSuggestionArgs = z.infer<typeof TradingSuggestionArgsSchema>;
// Schema for trading suggestion result
export const TradingSuggestionResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
alertsSet: z.boolean().optional(),
});
export type TradingSuggestionResult = z.infer<typeof TradingSuggestionResultSchema>;
const formatPrice = (price: number): string => {
if (price < 0.00001) return `$${price.toExponential(2)}`;
if (price < 0.01) return `$${price.toFixed(8)}`;
if (price < 1) return `$${price.toFixed(6)}`;
return `$${price.toFixed(4)}`;
};
const getRiskRewardColor = (ratio: number) => {
if (ratio >= 3) return "text-green-500";
if (ratio >= 2) return "text-yellow-500";
return "text-red-500";
};
const getRiskRewardLabel = (ratio: number) => {
if (ratio >= 3) return "Excellent";
if (ratio >= 2) return "Good";
if (ratio >= 1.5) return "Fair";
return "Poor";
};
/**
* TradingSuggestionToolUI - Displays AI-powered trading suggestions in chat
* Used when AI responds to queries like "suggest entry for BONK" or "trading suggestion for SOL"
*/
export const TradingSuggestionToolUI = makeAssistantToolUI<TradingSuggestionArgs, TradingSuggestionResult>({
toolName: "trading_suggestion",
render: ({ args, result, status }) => {
const [showDetails, setShowDetails] = useState(false);
const isLoading = status.type === "running";
const handleOpenDexScreener = () => {
if (args.contractAddress) {
window.open(`https://dexscreener.com/${args.chain}/${args.contractAddress}`, "_blank");
}
};
return (
<Card className="my-3 overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="h-5 w-5 text-primary" />
Trading Suggestion
{isLoading && <Badge variant="secondary" className="animate-pulse">Analyzing...</Badge>}
</div>
<div className="text-right">
<div className="text-xs text-muted-foreground">Confidence</div>
<div className="font-bold text-sm">{args.overallConfidence}%</div>
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Token Header */}
<div className="flex items-center gap-3">
<ChainIcon chain={args.chain} size="md" />
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-lg">{args.tokenSymbol}</span>
{args.tokenName && <span className="text-muted-foreground text-sm">{args.tokenName}</span>}
</div>
<span className="font-medium text-xl">{formatPrice(args.currentPrice)}</span>
</div>
</div>
{/* Entry Zone */}
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="font-semibold text-sm">Entry Zone</span>
</div>
<div className="font-bold text-lg text-green-600 dark:text-green-400 mb-1">
{formatPrice(args.entry.min)} - {formatPrice(args.entry.max)}
</div>
<p className="text-xs text-muted-foreground">{args.entry.reasoning}</p>
</div>
{/* Targets */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-muted-foreground" />
<span className="font-semibold text-sm">Take Profit Targets</span>
</div>
<div className="grid gap-2">
{args.targets.map((target) => (
<div key={target.level} className="p-2 bg-blue-500/10 border border-blue-500/20 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm">🎯 T{target.level}</span>
<span className="font-bold text-blue-600 dark:text-blue-400">{formatPrice(target.price)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-green-500 font-medium">+{target.percentGain.toFixed(1)}%</span>
<Badge variant="outline" className="text-xs">{target.confidence}%</Badge>
</div>
</div>
))}
</div>
</div>
{/* Stop Loss */}
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="font-semibold text-sm">Stop Loss</span>
</div>
<div className="flex items-center justify-between mb-1">
<span className="font-bold text-lg text-red-600 dark:text-red-400">
{formatPrice(args.stopLoss.price)}
</span>
<span className="text-sm text-red-500 font-medium">
{args.stopLoss.percentLoss.toFixed(1)}%
</span>
</div>
<p className="text-xs text-muted-foreground">{args.stopLoss.reasoning}</p>
</div>
{/* Risk/Reward */}
<div className="p-3 bg-muted/50 rounded-lg flex items-center justify-between">
<span className="text-sm font-medium">Risk/Reward Ratio</span>
<div className="flex items-center gap-2">
<Badge variant={args.riskReward >= 3 ? "default" : args.riskReward >= 2 ? "secondary" : "destructive"}>
{getRiskRewardLabel(args.riskReward)}
</Badge>
<span className={cn("font-bold text-lg", getRiskRewardColor(args.riskReward))}>
1:{args.riskReward.toFixed(1)}
</span>
</div>
</div>
{/* Why? Section - Collapsible */}
<div className="space-y-2">
<button
className="flex items-center gap-2 w-full text-left"
onClick={() => setShowDetails(!showDetails)}
>
<Info className="h-4 w-4 text-muted-foreground" />
<span className="font-semibold text-sm">Why?</span>
<span className={cn("ml-auto transition-transform text-xs", showDetails && "rotate-180")}></span>
</button>
{showDetails && (
<div className="space-y-3 pl-6 text-sm">
<div>
<h4 className="text-xs font-semibold text-muted-foreground mb-1">Reasoning:</h4>
<ul className="space-y-1">
{args.reasoning.map((reason, i) => (
<li key={i} className="text-xs flex items-start gap-2">
<TrendingUp className="h-3 w-3 text-green-500 mt-0.5 flex-shrink-0" />
<span>{reason}</span>
</li>
))}
</ul>
</div>
<div>
<h4 className="text-xs font-semibold text-muted-foreground mb-1">Invalidation:</h4>
<ul className="space-y-1">
{args.invalidationConditions.map((condition, i) => (
<li key={i} className="text-xs flex items-start gap-2">
<AlertCircle className="h-3 w-3 text-red-500 mt-0.5 flex-shrink-0" />
<span>{condition}</span>
</li>
))}
</ul>
</div>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<Button variant="default" size="sm" className="flex-1">
<Bell className="h-4 w-4 mr-2" />
Set Alerts
</Button>
<Button variant="outline" size="sm" onClick={handleOpenDexScreener}>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,124 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { Flame, TrendingUp, TrendingDown, Star, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ChainIcon } from "@/components/crypto/ChainIcon";
// Schema for trending token
const TrendingTokenSchema = z.object({
symbol: z.string(),
name: z.string(),
chain: z.string(),
contractAddress: z.string().optional(),
price: z.number(),
priceChange24h: z.number(),
priceChange1h: z.number().optional(),
volume24h: z.number().optional(),
liquidity: z.number().optional(),
rank: z.number().optional(),
});
// Schema for trending tokens tool arguments
export const TrendingTokensArgsSchema = z.object({
chain: z.string().optional(),
tokens: z.array(TrendingTokenSchema),
timeframe: z.string().optional(),
});
export type TrendingTokensArgs = z.infer<typeof TrendingTokensArgsSchema>;
// Schema for trending tokens result
export const TrendingTokensResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
});
export type TrendingTokensResult = z.infer<typeof TrendingTokensResultSchema>;
const formatPrice = (price: number): string => {
if (price < 0.00001) return `$${price.toExponential(2)}`;
if (price < 1) return `$${price.toFixed(6)}`;
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatLargeNumber = (num: number): string => {
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
return `$${num.toFixed(2)}`;
};
/**
* TrendingTokensToolUI - Displays trending/hot tokens inline in chat
* Used when AI responds to "what's hot on Solana?" or "show trending tokens"
*/
export const TrendingTokensToolUI = makeAssistantToolUI<TrendingTokensArgs, TrendingTokensResult>({
toolName: "get_trending_tokens",
render: ({ args, status }) => {
const isLoading = status.type === "running";
const tokens = args.tokens || [];
const chain = args.chain || "all chains";
const timeframe = args.timeframe || "24h";
return (
<Card className="my-3 overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<Flame className="h-5 w-5 text-orange-500" />
Trending on {chain}
<Badge variant="secondary">{timeframe}</Badge>
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{tokens.length === 0 ? (
<p className="text-muted-foreground text-center py-4">No trending tokens found</p>
) : (
<div className="divide-y">
{tokens.map((token, index) => (
<div key={token.symbol + index} className="flex items-center justify-between py-3 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors">
<div className="flex items-center gap-3">
<span className="text-lg font-bold text-muted-foreground w-6">#{token.rank || index + 1}</span>
<ChainIcon chain={token.chain} size="sm" />
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{token.symbol}</span>
</div>
<span className="text-xs text-muted-foreground">{token.name}</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="font-medium">{formatPrice(token.price)}</p>
<p className={cn("text-sm flex items-center justify-end gap-0.5", token.priceChange24h >= 0 ? "text-green-500" : "text-red-500")}>
{token.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(1)}%
</p>
</div>
{token.volume24h && (
<div className="text-right hidden md:block">
<p className="text-xs text-muted-foreground">Volume</p>
<p className="text-sm">{formatLargeNumber(token.volume24h)}</p>
</div>
)}
<Button variant="ghost" size="icon" className="h-8 w-8">
<Star className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,142 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { User, Shield, Target, Clock, Zap } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
// Schema for user profile tool arguments
export const UserProfileArgsSchema = z.object({
riskTolerance: z.enum(["conservative", "moderate", "aggressive"]),
investmentStyle: z.enum(["day_trader", "swing", "long_term"]),
preferredChains: z.array(z.string()),
portfolioSizeRange: z.enum(["small", "medium", "large"]).optional(),
experienceLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(),
notificationPreferences: z.object({
priceAlerts: z.boolean(),
whaleAlerts: z.boolean(),
newsAlerts: z.boolean(),
}).optional(),
});
export type UserProfileArgs = z.infer<typeof UserProfileArgsSchema>;
// Schema for user profile result
export const UserProfileResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
});
export type UserProfileResult = z.infer<typeof UserProfileResultSchema>;
const getRiskColor = (risk: string) => {
switch (risk) {
case "conservative": return "text-green-500 bg-green-500/10 border-green-500/20";
case "moderate": return "text-yellow-500 bg-yellow-500/10 border-yellow-500/20";
case "aggressive": return "text-red-500 bg-red-500/10 border-red-500/20";
default: return "";
}
};
const getStyleIcon = (style: string) => {
switch (style) {
case "day_trader": return <Zap className="h-4 w-4" />;
case "swing": return <Target className="h-4 w-4" />;
case "long_term": return <Clock className="h-4 w-4" />;
default: return null;
}
};
const formatStyle = (style: string) => {
switch (style) {
case "day_trader": return "Day Trader";
case "swing": return "Swing Trader";
case "long_term": return "Long Term Investor";
default: return style;
}
};
/**
* UserProfileToolUI - Displays user's investment profile inline in chat
* Used when AI responds to "show my profile" or "what's my risk setting?"
*/
export const UserProfileToolUI = makeAssistantToolUI<UserProfileArgs, UserProfileResult>({
toolName: "get_user_profile",
render: ({ args, status }) => {
const isLoading = status.type === "running";
return (
<Card className="my-3 overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5 text-indigo-500" />
Your Investment Profile
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Main Profile Settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Risk Tolerance */}
<div className={cn("rounded-lg p-4 border", getRiskColor(args.riskTolerance))}>
<div className="flex items-center gap-2 mb-2">
<Shield className="h-4 w-4" />
<span className="text-sm font-medium">Risk Tolerance</span>
</div>
<p className="text-lg font-bold capitalize">{args.riskTolerance}</p>
<p className="text-xs text-muted-foreground mt-1">
{args.riskTolerance === "conservative" && "Prefer stable, lower-risk investments"}
{args.riskTolerance === "moderate" && "Balance between risk and reward"}
{args.riskTolerance === "aggressive" && "Willing to take higher risks for higher returns"}
</p>
</div>
{/* Investment Style */}
<div className="rounded-lg p-4 border bg-muted/50">
<div className="flex items-center gap-2 mb-2">
{getStyleIcon(args.investmentStyle)}
<span className="text-sm font-medium">Investment Style</span>
</div>
<p className="text-lg font-bold">{formatStyle(args.investmentStyle)}</p>
<p className="text-xs text-muted-foreground mt-1">
{args.investmentStyle === "day_trader" && "Quick trades, high frequency"}
{args.investmentStyle === "swing" && "Hold for days to weeks"}
{args.investmentStyle === "long_term" && "Hold for months to years"}
</p>
</div>
</div>
{/* Preferred Chains */}
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Preferred Chains</p>
<div className="flex flex-wrap gap-2">
{args.preferredChains.map((chain) => (
<Badge key={chain} variant="secondary" className="capitalize">{chain}</Badge>
))}
</div>
</div>
{/* Notification Preferences */}
{args.notificationPreferences && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Notifications</p>
<div className="flex flex-wrap gap-2">
{args.notificationPreferences.priceAlerts && <Badge variant="outline">Price Alerts</Badge>}
{args.notificationPreferences.whaleAlerts && <Badge variant="outline">Whale Alerts</Badge>}
{args.notificationPreferences.newsAlerts && <Badge variant="outline">News Alerts</Badge>}
</div>
</div>
)}
{/* Edit Hint */}
<p className="text-xs text-muted-foreground text-center pt-2">
Say "update my risk tolerance to moderate" to change settings
</p>
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,145 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { Star, TrendingUp, TrendingDown, Bell, Trash2, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ChainIcon } from "@/components/crypto/ChainIcon";
// Schema for watchlist token
const WatchlistTokenSchema = z.object({
id: z.string(),
symbol: z.string(),
name: z.string(),
chain: z.string(),
price: z.number(),
priceChange24h: z.number(),
alertCount: z.number().optional(),
});
// Schema for watchlist display tool arguments
export const WatchlistDisplayArgsSchema = z.object({
tokens: z.array(WatchlistTokenSchema),
});
export type WatchlistDisplayArgs = z.infer<typeof WatchlistDisplayArgsSchema>;
// Schema for watchlist display result
export const WatchlistDisplayResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
});
export type WatchlistDisplayResult = z.infer<typeof WatchlistDisplayResultSchema>;
const formatPrice = (price: number): string => {
if (price < 0.00001) return `$${price.toExponential(2)}`;
if (price < 1) return `$${price.toFixed(6)}`;
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
/**
* WatchlistDisplayToolUI - Displays user's watchlist inline in chat
* Used when AI responds to "show my watchlist" or similar commands
*/
export const WatchlistDisplayToolUI = makeAssistantToolUI<WatchlistDisplayArgs, WatchlistDisplayResult>({
toolName: "show_watchlist",
render: ({ args, status }) => {
const isLoading = status.type === "running";
const tokens = args.tokens || [];
if (tokens.length === 0) {
return (
<Card className="my-3">
<CardContent className="py-8 text-center">
<Star className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground">Your watchlist is empty</p>
<p className="text-sm text-muted-foreground mt-1">
Say "Add [token] to my watchlist" to start tracking
</p>
</CardContent>
</Card>
);
}
// Find best and worst performers
const sortedByChange = [...tokens].sort((a, b) => b.priceChange24h - a.priceChange24h);
const bestPerformer = sortedByChange[0];
const worstPerformer = sortedByChange[sortedByChange.length - 1];
return (
<Card className="my-3 overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<Star className="h-5 w-5 text-yellow-500" />
Your Watchlist
<Badge variant="secondary">{tokens.length}</Badge>
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
</div>
<Button variant="outline" size="sm">
<Plus className="h-4 w-4 mr-1" />
Add Token
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{/* Token List */}
<div className="divide-y">
{tokens.map((token) => (
<div
key={token.id}
className="flex items-center justify-between py-3 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors"
>
<div className="flex items-center gap-3">
<ChainIcon chain={token.chain} size="sm" />
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{token.symbol}</span>
{token.alertCount && token.alertCount > 0 && (
<Badge variant="outline" className="text-xs px-1.5 py-0">
<Bell className="h-2.5 w-2.5 mr-0.5" />
{token.alertCount}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">{token.name}</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="font-medium">{formatPrice(token.price)}</p>
<p className={cn(
"text-sm flex items-center justify-end gap-0.5",
token.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
)}>
{token.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(1)}%
</p>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-red-500">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{/* Summary */}
{tokens.length > 1 && (
<div className="pt-3 border-t text-sm text-muted-foreground">
<span className="text-green-500 font-medium">{bestPerformer.symbol}</span> is your best performer (+{bestPerformer.priceChange24h.toFixed(1)}%)
{worstPerformer.priceChange24h < 0 && (
<span> <span className="text-red-500 font-medium">{worstPerformer.symbol}</span> needs attention ({worstPerformer.priceChange24h.toFixed(1)}%)</span>
)}
</div>
)}
</CardContent>
</Card>
);
},
});

View file

@ -0,0 +1,154 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { Fish, ArrowUpRight, ArrowDownRight, ExternalLink, Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ChainIcon } from "@/components/crypto/ChainIcon";
// Schema for whale transaction
const WhaleTransactionSchema = z.object({
id: z.string(),
type: z.enum(["buy", "sell", "transfer"]),
amount: z.number(),
amountUsd: z.number(),
tokenSymbol: z.string(),
walletAddress: z.string(),
walletLabel: z.string().optional(),
timestamp: z.string(),
txHash: z.string().optional(),
});
// Schema for whale activity tool arguments
export const WhaleActivityArgsSchema = z.object({
tokenSymbol: z.string(),
chain: z.string(),
transactions: z.array(WhaleTransactionSchema),
summary: z.object({
totalBuyVolume: z.number(),
totalSellVolume: z.number(),
netFlow: z.number(),
uniqueWhales: z.number(),
}).optional(),
});
export type WhaleActivityArgs = z.infer<typeof WhaleActivityArgsSchema>;
// Schema for whale activity result
export const WhaleActivityResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
});
export type WhaleActivityResult = z.infer<typeof WhaleActivityResultSchema>;
const formatLargeNumber = (num: number): string => {
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
return `$${num.toFixed(2)}`;
};
const shortenAddress = (address: string): string => {
return `${address.slice(0, 6)}...${address.slice(-4)}`;
};
const formatTimeAgo = (timestamp: string): string => {
const diff = Date.now() - new Date(timestamp).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
/**
* WhaleActivityToolUI - Displays whale transactions inline in chat
* Used when AI responds to "show whale activity for BULLA" or similar
*/
export const WhaleActivityToolUI = makeAssistantToolUI<WhaleActivityArgs, WhaleActivityResult>({
toolName: "get_whale_activity",
render: ({ args, status }) => {
const isLoading = status.type === "running";
const transactions = args.transactions || [];
const summary = args.summary;
return (
<Card className="my-3 overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<Fish className="h-5 w-5 text-blue-500" />
Whale Activity - {args.tokenSymbol}
{isLoading && <Badge variant="outline" className="animate-pulse">Loading...</Badge>}
</div>
<ChainIcon chain={args.chain} size="sm" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Summary Stats */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-green-500/10 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Buy Volume</p>
<p className="font-medium text-green-500">{formatLargeNumber(summary.totalBuyVolume)}</p>
</div>
<div className="bg-red-500/10 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Sell Volume</p>
<p className="font-medium text-red-500">{formatLargeNumber(summary.totalSellVolume)}</p>
</div>
<div className={cn("rounded-lg p-3", summary.netFlow >= 0 ? "bg-green-500/10" : "bg-red-500/10")}>
<p className="text-xs text-muted-foreground">Net Flow</p>
<p className={cn("font-medium", summary.netFlow >= 0 ? "text-green-500" : "text-red-500")}>
{summary.netFlow >= 0 ? "+" : ""}{formatLargeNumber(summary.netFlow)}
</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Unique Whales</p>
<p className="font-medium">{summary.uniqueWhales}</p>
</div>
</div>
)}
{/* Transaction List */}
{transactions.length === 0 ? (
<p className="text-muted-foreground text-center py-4">No whale transactions detected</p>
) : (
<div className="divide-y max-h-[300px] overflow-y-auto">
{transactions.map((tx) => (
<div key={tx.id} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<div className={cn("p-2 rounded-full", tx.type === "buy" ? "bg-green-500/10" : tx.type === "sell" ? "bg-red-500/10" : "bg-muted")}>
{tx.type === "buy" ? <ArrowUpRight className="h-4 w-4 text-green-500" /> : tx.type === "sell" ? <ArrowDownRight className="h-4 w-4 text-red-500" /> : <ArrowUpRight className="h-4 w-4" />}
</div>
<div>
<div className="flex items-center gap-2">
<span className={cn("font-medium capitalize", tx.type === "buy" ? "text-green-500" : tx.type === "sell" ? "text-red-500" : "")}>
{tx.type}
</span>
<span className="font-medium">{formatLargeNumber(tx.amountUsd)}</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{tx.walletLabel || shortenAddress(tx.walletAddress)}</span>
<Clock className="h-3 w-3" />
<span>{formatTimeAgo(tx.timestamp)}</span>
</div>
</div>
</div>
{tx.txHash && (
<Button variant="ghost" size="icon" className="h-8 w-8">
<ExternalLink className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
},
});

View file

@ -59,3 +59,37 @@ export {
} from "./user-memory";
export { GenerateVideoPresentationToolUI } from "./video-presentation";
export { type WriteTodosData, WriteTodosSchema, WriteTodosToolUI } from "./write-todos";
// Crypto Tool UI Components - Conversational Crypto Advisor
export {
// Token Analysis
TokenAnalysisToolUI,
TokenAnalysisArgsSchema,
TokenAnalysisResultSchema,
type TokenAnalysisArgs,
type TokenAnalysisResult,
// Watchlist Display
WatchlistDisplayToolUI,
WatchlistDisplayArgsSchema,
WatchlistDisplayResultSchema,
type WatchlistDisplayArgs,
type WatchlistDisplayResult,
// Action Confirmation
ActionConfirmationToolUI,
ActionConfirmationArgsSchema,
ActionConfirmationResultSchema,
type ActionConfirmationArgs,
type ActionConfirmationResult,
// Alert Configuration
AlertConfigurationToolUI,
AlertConfigurationArgsSchema,
AlertConfigurationResultSchema,
type AlertConfigurationArgs,
type AlertConfigurationResult,
// Proactive Alert
ProactiveAlertToolUI,
ProactiveAlertArgsSchema,
ProactiveAlertResultSchema,
type ProactiveAlertArgs,
type ProactiveAlertResult,
} from "./crypto";