mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-27 01:36:30 +02:00
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:
commit
6e86cd7e8a
803 changed files with 152168 additions and 14005 deletions
130
surfsense_web/components/tool-ui/crypto/action-confirmation.tsx
Normal file
130
surfsense_web/components/tool-ui/crypto/action-confirmation.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
121
surfsense_web/components/tool-ui/crypto/alert-configuration.tsx
Normal file
121
surfsense_web/components/tool-ui/crypto/alert-configuration.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
144
surfsense_web/components/tool-ui/crypto/holder-analysis.tsx
Normal file
144
surfsense_web/components/tool-ui/crypto/holder-analysis.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
139
surfsense_web/components/tool-ui/crypto/index.ts
Normal file
139
surfsense_web/components/tool-ui/crypto/index.ts
Normal 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";
|
||||
246
surfsense_web/components/tool-ui/crypto/live-token-data.tsx
Normal file
246
surfsense_web/components/tool-ui/crypto/live-token-data.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
161
surfsense_web/components/tool-ui/crypto/live-token-price.tsx
Normal file
161
surfsense_web/components/tool-ui/crypto/live-token-price.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
126
surfsense_web/components/tool-ui/crypto/market-overview-tool.tsx
Normal file
126
surfsense_web/components/tool-ui/crypto/market-overview-tool.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
121
surfsense_web/components/tool-ui/crypto/portfolio-display.tsx
Normal file
121
surfsense_web/components/tool-ui/crypto/portfolio-display.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
136
surfsense_web/components/tool-ui/crypto/proactive-alert.tsx
Normal file
136
surfsense_web/components/tool-ui/crypto/proactive-alert.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
162
surfsense_web/components/tool-ui/crypto/token-analysis.tsx
Normal file
162
surfsense_web/components/tool-ui/crypto/token-analysis.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
236
surfsense_web/components/tool-ui/crypto/trading-suggestion.tsx
Normal file
236
surfsense_web/components/tool-ui/crypto/trading-suggestion.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
124
surfsense_web/components/tool-ui/crypto/trending-tokens.tsx
Normal file
124
surfsense_web/components/tool-ui/crypto/trending-tokens.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
142
surfsense_web/components/tool-ui/crypto/user-profile.tsx
Normal file
142
surfsense_web/components/tool-ui/crypto/user-profile.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
145
surfsense_web/components/tool-ui/crypto/watchlist-display.tsx
Normal file
145
surfsense_web/components/tool-ui/crypto/watchlist-display.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
154
surfsense_web/components/tool-ui/crypto/whale-activity.tsx
Normal file
154
surfsense_web/components/tool-ui/crypto/whale-activity.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue