[OQI-007] feat: Add hooks, utils, and SignalExecutionPanel
- useChatAssistant: Centralized chat logic with retry and streaming support - useStreamingChat: SSE streaming with token animation - messageFormatters: Signal parsing, price formatting, markdown processing - SignalExecutionPanel: Execute trading signals with risk validation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bf726a595c
commit
ed2e1472f4
471
src/modules/assistant/components/SignalExecutionPanel.tsx
Normal file
471
src/modules/assistant/components/SignalExecutionPanel.tsx
Normal file
@ -0,0 +1,471 @@
|
|||||||
|
/**
|
||||||
|
* SignalExecutionPanel Component
|
||||||
|
* Execute and validate trading signals from assistant recommendations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Shield,
|
||||||
|
Target,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
Calculator,
|
||||||
|
Zap,
|
||||||
|
Info,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { TradingSignal } from './SignalCard';
|
||||||
|
|
||||||
|
interface SignalExecutionPanelProps {
|
||||||
|
signal: TradingSignal;
|
||||||
|
accountBalance?: number;
|
||||||
|
onExecute?: (params: ExecutionParams) => Promise<ExecutionResult>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionParams {
|
||||||
|
symbol: string;
|
||||||
|
direction: 'BUY' | 'SELL';
|
||||||
|
volume: number;
|
||||||
|
entry: number;
|
||||||
|
stopLoss?: number;
|
||||||
|
takeProfit?: number;
|
||||||
|
riskPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionResult {
|
||||||
|
success: boolean;
|
||||||
|
ticket?: number;
|
||||||
|
error?: string;
|
||||||
|
executedPrice?: number;
|
||||||
|
executedVolume?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
warnings: string[];
|
||||||
|
errors: string[];
|
||||||
|
riskAssessment: 'low' | 'medium' | 'high' | 'extreme';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SignalExecutionPanel: React.FC<SignalExecutionPanelProps> = ({
|
||||||
|
signal,
|
||||||
|
accountBalance = 10000,
|
||||||
|
onExecute,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const [volume, setVolume] = useState('0.01');
|
||||||
|
const [riskPercent, setRiskPercent] = useState('1');
|
||||||
|
const [useAutoSize, setUseAutoSize] = useState(true);
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
const [isExecuting, setIsExecuting] = useState(false);
|
||||||
|
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null);
|
||||||
|
const [confirmStep, setConfirmStep] = useState(false);
|
||||||
|
|
||||||
|
const isBuy = signal.direction === 'BUY' || signal.direction === 'LONG';
|
||||||
|
|
||||||
|
// Calculate position size based on risk
|
||||||
|
const calculatedVolume = useMemo(() => {
|
||||||
|
if (!signal.stopLoss || !useAutoSize) return parseFloat(volume) || 0.01;
|
||||||
|
|
||||||
|
const risk = parseFloat(riskPercent) || 1;
|
||||||
|
const riskAmount = (accountBalance * risk) / 100;
|
||||||
|
const slPips = Math.abs(signal.entry - signal.stopLoss) * (signal.symbol.includes('JPY') ? 100 : 10000);
|
||||||
|
const pipValue = signal.symbol.includes('JPY') ? 1000 : 10; // Approximate per standard lot
|
||||||
|
|
||||||
|
if (slPips === 0) return 0.01;
|
||||||
|
|
||||||
|
const lots = riskAmount / (slPips * pipValue);
|
||||||
|
return Math.max(0.01, Math.min(parseFloat(lots.toFixed(2)), 10));
|
||||||
|
}, [signal, accountBalance, riskPercent, useAutoSize, volume]);
|
||||||
|
|
||||||
|
// Calculate risk/reward
|
||||||
|
const riskReward = useMemo(() => {
|
||||||
|
if (!signal.stopLoss || !signal.takeProfit) return null;
|
||||||
|
const risk = Math.abs(signal.entry - signal.stopLoss);
|
||||||
|
const reward = Math.abs(signal.takeProfit - signal.entry);
|
||||||
|
if (risk === 0) return null;
|
||||||
|
return reward / risk;
|
||||||
|
}, [signal]);
|
||||||
|
|
||||||
|
// Calculate potential outcomes
|
||||||
|
const potentialLoss = useMemo(() => {
|
||||||
|
if (!signal.stopLoss) return null;
|
||||||
|
const pips = Math.abs(signal.entry - signal.stopLoss) * (signal.symbol.includes('JPY') ? 100 : 10000);
|
||||||
|
const pipValue = signal.symbol.includes('JPY') ? 1000 : 10;
|
||||||
|
return calculatedVolume * pips * pipValue;
|
||||||
|
}, [signal, calculatedVolume]);
|
||||||
|
|
||||||
|
const potentialProfit = useMemo(() => {
|
||||||
|
if (!signal.takeProfit) return null;
|
||||||
|
const pips = Math.abs(signal.takeProfit - signal.entry) * (signal.symbol.includes('JPY') ? 100 : 10000);
|
||||||
|
const pipValue = signal.symbol.includes('JPY') ? 1000 : 10;
|
||||||
|
return calculatedVolume * pips * pipValue;
|
||||||
|
}, [signal, calculatedVolume]);
|
||||||
|
|
||||||
|
// Validate the signal
|
||||||
|
const validation = useMemo((): ValidationResult => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Check for stop loss
|
||||||
|
if (!signal.stopLoss) {
|
||||||
|
warnings.push('No stop loss defined - unlimited risk');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for take profit
|
||||||
|
if (!signal.takeProfit) {
|
||||||
|
warnings.push('No take profit defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check confidence
|
||||||
|
if (signal.confidence && signal.confidence < 60) {
|
||||||
|
warnings.push(`Low confidence signal (${signal.confidence}%)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check risk/reward
|
||||||
|
if (riskReward && riskReward < 1) {
|
||||||
|
warnings.push(`Poor risk/reward ratio (1:${riskReward.toFixed(2)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check position size vs account
|
||||||
|
const riskAmount = potentialLoss || 0;
|
||||||
|
const riskPct = (riskAmount / accountBalance) * 100;
|
||||||
|
|
||||||
|
if (riskPct > 5) {
|
||||||
|
errors.push(`Risk exceeds 5% of account (${riskPct.toFixed(1)}%)`);
|
||||||
|
} else if (riskPct > 2) {
|
||||||
|
warnings.push(`High risk position (${riskPct.toFixed(1)}% of account)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine risk assessment
|
||||||
|
let riskAssessment: ValidationResult['riskAssessment'] = 'low';
|
||||||
|
if (errors.length > 0) riskAssessment = 'extreme';
|
||||||
|
else if (warnings.length > 2) riskAssessment = 'high';
|
||||||
|
else if (warnings.length > 0) riskAssessment = 'medium';
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
warnings,
|
||||||
|
errors,
|
||||||
|
riskAssessment,
|
||||||
|
};
|
||||||
|
}, [signal, riskReward, potentialLoss, accountBalance]);
|
||||||
|
|
||||||
|
const riskColors = {
|
||||||
|
low: 'text-green-400 bg-green-500/20',
|
||||||
|
medium: 'text-yellow-400 bg-yellow-500/20',
|
||||||
|
high: 'text-orange-400 bg-orange-500/20',
|
||||||
|
extreme: 'text-red-400 bg-red-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecute = async () => {
|
||||||
|
if (!onExecute || !validation.isValid) return;
|
||||||
|
|
||||||
|
setIsExecuting(true);
|
||||||
|
setExecutionResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await onExecute({
|
||||||
|
symbol: signal.symbol,
|
||||||
|
direction: isBuy ? 'BUY' : 'SELL',
|
||||||
|
volume: calculatedVolume,
|
||||||
|
entry: signal.entry,
|
||||||
|
stopLoss: signal.stopLoss,
|
||||||
|
takeProfit: signal.takeProfit,
|
||||||
|
riskPercent: parseFloat(riskPercent),
|
||||||
|
});
|
||||||
|
|
||||||
|
setExecutionResult(result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setConfirmStep(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setExecutionResult({
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Execution failed',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsExecuting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Already executed successfully
|
||||||
|
if (executionResult?.success) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-xl border border-green-500/30 p-6 text-center">
|
||||||
|
<CheckCircle className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||||
|
<h4 className="font-medium text-white mb-1">Order Executed</h4>
|
||||||
|
<p className="text-sm text-gray-400 mb-2">
|
||||||
|
Ticket #{executionResult.ticket}
|
||||||
|
</p>
|
||||||
|
<div className="text-sm text-gray-300">
|
||||||
|
<p>{signal.symbol} {isBuy ? 'BUY' : 'SELL'} @ {executionResult.executedPrice?.toFixed(5)}</p>
|
||||||
|
<p>Volume: {executionResult.executedVolume} lots</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`flex items-center justify-between p-4 border-b ${
|
||||||
|
isBuy ? 'bg-green-500/10 border-green-500/30' : 'bg-red-500/10 border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${isBuy ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||||
|
{isBuy ? (
|
||||||
|
<TrendingUp className="w-5 h-5 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="w-5 h-5 text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-white">Execute Signal</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{signal.symbol} • {isBuy ? 'BUY' : 'SELL'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`px-3 py-1 rounded-full text-sm font-medium ${riskColors[validation.riskAssessment]}`}>
|
||||||
|
{validation.riskAssessment.charAt(0).toUpperCase() + validation.riskAssessment.slice(1)} Risk
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Signal Details */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div className="text-center p-3 bg-gray-900/50 rounded-lg">
|
||||||
|
<p className="text-gray-500 text-xs mb-1">Entry</p>
|
||||||
|
<p className="text-white font-mono font-medium">{signal.entry.toFixed(5)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-gray-900/50 rounded-lg">
|
||||||
|
<p className="text-gray-500 text-xs mb-1">Stop Loss</p>
|
||||||
|
<p className={`font-mono font-medium ${signal.stopLoss ? 'text-red-400' : 'text-gray-500'}`}>
|
||||||
|
{signal.stopLoss?.toFixed(5) || 'Not set'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-gray-900/50 rounded-lg">
|
||||||
|
<p className="text-gray-500 text-xs mb-1">Take Profit</p>
|
||||||
|
<p className={`font-mono font-medium ${signal.takeProfit ? 'text-green-400' : 'text-gray-500'}`}>
|
||||||
|
{signal.takeProfit?.toFixed(5) || 'Not set'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position Sizing */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm text-gray-400">Position Size</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setUseAutoSize(!useAutoSize)}
|
||||||
|
className={`flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors ${
|
||||||
|
useAutoSize
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Calculator className="w-3 h-3" />
|
||||||
|
Auto-size
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useAutoSize ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-400">Risk:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0.1"
|
||||||
|
max="5"
|
||||||
|
value={riskPercent}
|
||||||
|
onChange={(e) => setRiskPercent(e.target.value)}
|
||||||
|
className="w-20 px-2 py-1 bg-gray-900 border border-gray-700 rounded text-white text-sm focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-400">% of account</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-green-400">
|
||||||
|
Calculated: <span className="font-medium">{calculatedVolume.toFixed(2)}</span> lots
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
max="10"
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => setVolume(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Potential Outcomes */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm mb-1">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
<span>Potential Loss</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-red-400">
|
||||||
|
${potentialLoss?.toFixed(2) || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-green-400 text-sm mb-1">
|
||||||
|
<Target className="w-4 h-4" />
|
||||||
|
<span>Potential Profit</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-green-400">
|
||||||
|
${potentialProfit?.toFixed(2) || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Risk/Reward & Confidence */}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-400">R:R Ratio:</span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
riskReward && riskReward >= 2 ? 'text-green-400' :
|
||||||
|
riskReward && riskReward >= 1 ? 'text-yellow-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{riskReward ? `1:${riskReward.toFixed(2)}` : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{signal.confidence && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-400">Confidence:</span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
signal.confidence >= 70 ? 'text-green-400' :
|
||||||
|
signal.confidence >= 50 ? 'text-yellow-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{signal.confidence}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Warnings */}
|
||||||
|
{(validation.warnings.length > 0 || validation.errors.length > 0) && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className="flex items-center gap-2 text-sm text-yellow-400 hover:text-yellow-300"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
{validation.errors.length + validation.warnings.length} issue(s) found
|
||||||
|
{showDetails ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDetails && (
|
||||||
|
<div className="mt-2 p-3 bg-gray-900/50 rounded-lg space-y-2">
|
||||||
|
{validation.errors.map((error, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 text-sm text-red-400">
|
||||||
|
<XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{validation.warnings.map((warning, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 text-sm text-yellow-400">
|
||||||
|
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{warning}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execution Error */}
|
||||||
|
{executionResult?.error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||||
|
<XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-red-400">Execution Failed</p>
|
||||||
|
<p className="text-sm text-gray-300">{executionResult.error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
{!confirmStep ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmStep(true)}
|
||||||
|
disabled={!validation.isValid || !onExecute}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
isBuy
|
||||||
|
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||||
|
: 'bg-red-600 hover:bg-red-500 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
Review Order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-yellow-400">
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
<span className="font-medium">Confirm Execution</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-300 mt-1">
|
||||||
|
{isBuy ? 'Buy' : 'Sell'} {calculatedVolume.toFixed(2)} lots of {signal.symbol} at market price
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmStep(false)}
|
||||||
|
className="flex-1 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExecute}
|
||||||
|
disabled={isExecuting}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${
|
||||||
|
isBuy
|
||||||
|
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||||
|
: 'bg-red-600 hover:bg-red-500 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isExecuting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Executing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Confirm {isBuy ? 'Buy' : 'Sell'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignalExecutionPanel;
|
||||||
@ -32,3 +32,7 @@ export { TypingDots, PulseIndicator, ProcessingSteps } from './StreamingIndicato
|
|||||||
// Settings
|
// Settings
|
||||||
export { default as AssistantSettingsPanel } from './AssistantSettingsPanel';
|
export { default as AssistantSettingsPanel } from './AssistantSettingsPanel';
|
||||||
export type { AssistantSettings } from './AssistantSettingsPanel';
|
export type { AssistantSettings } from './AssistantSettingsPanel';
|
||||||
|
|
||||||
|
// Signal Execution
|
||||||
|
export { default as SignalExecutionPanel } from './SignalExecutionPanel';
|
||||||
|
export type { ExecutionParams, ExecutionResult } from './SignalExecutionPanel';
|
||||||
|
|||||||
22
src/modules/assistant/hooks/index.ts
Normal file
22
src/modules/assistant/hooks/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Assistant Hooks - Index Export
|
||||||
|
* Custom hooks for LLM assistant functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as useChatAssistant } from './useChatAssistant';
|
||||||
|
export type {
|
||||||
|
ChatAssistantOptions,
|
||||||
|
SendMessageOptions,
|
||||||
|
ChatAssistantState,
|
||||||
|
ChatAssistantActions,
|
||||||
|
UseChatAssistantReturn,
|
||||||
|
} from './useChatAssistant';
|
||||||
|
|
||||||
|
export { default as useStreamingChat } from './useStreamingChat';
|
||||||
|
export type {
|
||||||
|
StreamStatus,
|
||||||
|
StreamChunk,
|
||||||
|
StreamState,
|
||||||
|
StreamOptions,
|
||||||
|
UseStreamingChatReturn,
|
||||||
|
} from './useStreamingChat';
|
||||||
233
src/modules/assistant/hooks/useChatAssistant.ts
Normal file
233
src/modules/assistant/hooks/useChatAssistant.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* useChatAssistant Hook
|
||||||
|
* Centralized chat logic for the LLM assistant
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useChatStore } from '../../../stores/chatStore';
|
||||||
|
import type { Message } from '../components/ChatMessage';
|
||||||
|
import type { ToolCall } from '../components/ToolCallCard';
|
||||||
|
import { extractTradingSignals, extractMentionedTools } from '../utils/messageFormatters';
|
||||||
|
|
||||||
|
export interface ChatAssistantOptions {
|
||||||
|
sessionId?: string;
|
||||||
|
autoScroll?: boolean;
|
||||||
|
maxRetries?: number;
|
||||||
|
retryDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageOptions {
|
||||||
|
attachments?: File[];
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
tools?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatAssistantState {
|
||||||
|
messages: Message[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
error: string | null;
|
||||||
|
currentSessionId: string | null;
|
||||||
|
pendingToolCalls: ToolCall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatAssistantActions {
|
||||||
|
sendMessage: (content: string, options?: SendMessageOptions) => Promise<void>;
|
||||||
|
regenerateLastResponse: () => Promise<void>;
|
||||||
|
cancelGeneration: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
createNewSession: () => Promise<string>;
|
||||||
|
loadSession: (sessionId: string) => Promise<void>;
|
||||||
|
deleteMessage: (messageId: string) => void;
|
||||||
|
editMessage: (messageId: string, newContent: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatAssistant(options: ChatAssistantOptions = {}): ChatAssistantState & ChatAssistantActions {
|
||||||
|
const {
|
||||||
|
sessionId: initialSessionId,
|
||||||
|
autoScroll = true,
|
||||||
|
maxRetries = 3,
|
||||||
|
retryDelay = 1000,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Store state
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
messages: storeMessages,
|
||||||
|
loading,
|
||||||
|
error: storeError,
|
||||||
|
createNewSession: storeCreateSession,
|
||||||
|
loadSession: storeLoadSession,
|
||||||
|
sendMessage: storeSendMessage,
|
||||||
|
deleteSession,
|
||||||
|
} = useChatStore();
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
const [pendingToolCalls, setPendingToolCalls] = useState<ToolCall[]>([]);
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const retryCountRef = useRef(0);
|
||||||
|
|
||||||
|
// Initialize session
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialSessionId && initialSessionId !== currentSessionId) {
|
||||||
|
storeLoadSession(initialSessionId);
|
||||||
|
}
|
||||||
|
}, [initialSessionId, currentSessionId, storeLoadSession]);
|
||||||
|
|
||||||
|
// Auto-scroll on new messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && messagesEndRef.current) {
|
||||||
|
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [storeMessages, autoScroll]);
|
||||||
|
|
||||||
|
// Transform store messages to include parsed data
|
||||||
|
const messages: Message[] = storeMessages.map((msg) => ({
|
||||||
|
...msg,
|
||||||
|
toolsUsed: msg.toolsUsed || extractMentionedTools(msg.content),
|
||||||
|
signals: extractTradingSignals(msg.content),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Send message with retry logic
|
||||||
|
const sendMessage = useCallback(async (
|
||||||
|
content: string,
|
||||||
|
sendOptions: SendMessageOptions = {}
|
||||||
|
) => {
|
||||||
|
if (!content.trim()) return;
|
||||||
|
|
||||||
|
setLocalError(null);
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add user message optimistically
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If tools are requested, set up pending tool calls
|
||||||
|
if (sendOptions.tools?.length) {
|
||||||
|
setPendingToolCalls(
|
||||||
|
sendOptions.tools.map((tool) => ({
|
||||||
|
id: `pending-${tool}`,
|
||||||
|
name: tool,
|
||||||
|
arguments: {},
|
||||||
|
status: 'pending',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsStreaming(true);
|
||||||
|
|
||||||
|
// Send to store/API
|
||||||
|
await storeSendMessage(content);
|
||||||
|
|
||||||
|
// Clear pending tool calls on success
|
||||||
|
setPendingToolCalls([]);
|
||||||
|
retryCountRef.current = 0;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to send message';
|
||||||
|
|
||||||
|
// Retry logic
|
||||||
|
if (retryCountRef.current < maxRetries && !abortControllerRef.current?.signal.aborted) {
|
||||||
|
retryCountRef.current++;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryDelay * retryCountRef.current));
|
||||||
|
return sendMessage(content, sendOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalError(errorMessage);
|
||||||
|
setPendingToolCalls([]);
|
||||||
|
} finally {
|
||||||
|
setIsStreaming(false);
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [storeSendMessage, maxRetries, retryDelay]);
|
||||||
|
|
||||||
|
// Regenerate last assistant response
|
||||||
|
const regenerateLastResponse = useCallback(async () => {
|
||||||
|
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user');
|
||||||
|
if (!lastUserMessage) return;
|
||||||
|
|
||||||
|
// Remove the last assistant message from local state
|
||||||
|
const lastAssistantIdx = messages.findLastIndex((m) => m.role === 'assistant');
|
||||||
|
if (lastAssistantIdx >= 0) {
|
||||||
|
// In a real implementation, we'd call an API to regenerate
|
||||||
|
await sendMessage(lastUserMessage.content);
|
||||||
|
}
|
||||||
|
}, [messages, sendMessage]);
|
||||||
|
|
||||||
|
// Cancel ongoing generation
|
||||||
|
const cancelGeneration = useCallback(() => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
setIsStreaming(false);
|
||||||
|
setPendingToolCalls([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear error
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setLocalError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Create new session
|
||||||
|
const createNewSession = useCallback(async (): Promise<string> => {
|
||||||
|
const session = await storeCreateSession();
|
||||||
|
return session.id;
|
||||||
|
}, [storeCreateSession]);
|
||||||
|
|
||||||
|
// Load existing session
|
||||||
|
const loadSession = useCallback(async (sessionId: string) => {
|
||||||
|
await storeLoadSession(sessionId);
|
||||||
|
}, [storeLoadSession]);
|
||||||
|
|
||||||
|
// Delete a message (local only, would need API support)
|
||||||
|
const deleteMessage = useCallback((messageId: string) => {
|
||||||
|
// This would require store support
|
||||||
|
console.warn('deleteMessage not implemented in store');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Edit a message and regenerate
|
||||||
|
const editMessage = useCallback(async (messageId: string, newContent: string) => {
|
||||||
|
// Find the message and regenerate from that point
|
||||||
|
const messageIdx = messages.findIndex((m) => m.id === messageId);
|
||||||
|
if (messageIdx === -1) return;
|
||||||
|
|
||||||
|
// In a real implementation, we'd truncate history and regenerate
|
||||||
|
await sendMessage(newContent);
|
||||||
|
}, [messages, sendMessage]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
messages,
|
||||||
|
isLoading: loading,
|
||||||
|
isStreaming,
|
||||||
|
error: localError || storeError,
|
||||||
|
currentSessionId,
|
||||||
|
pendingToolCalls,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
sendMessage,
|
||||||
|
regenerateLastResponse,
|
||||||
|
cancelGeneration,
|
||||||
|
clearError,
|
||||||
|
createNewSession,
|
||||||
|
loadSession,
|
||||||
|
deleteMessage,
|
||||||
|
editMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
export type UseChatAssistantReturn = ReturnType<typeof useChatAssistant>;
|
||||||
|
|
||||||
|
export default useChatAssistant;
|
||||||
345
src/modules/assistant/hooks/useStreamingChat.ts
Normal file
345
src/modules/assistant/hooks/useStreamingChat.ts
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* useStreamingChat Hook
|
||||||
|
* Handle real-time streaming responses with chunk buffering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'complete' | 'error' | 'cancelled';
|
||||||
|
|
||||||
|
export interface StreamChunk {
|
||||||
|
type: 'content' | 'tool_start' | 'tool_end' | 'thinking' | 'error' | 'done';
|
||||||
|
content?: string;
|
||||||
|
toolName?: string;
|
||||||
|
toolResult?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamState {
|
||||||
|
status: StreamStatus;
|
||||||
|
content: string;
|
||||||
|
chunks: StreamChunk[];
|
||||||
|
currentTool: string | null;
|
||||||
|
progress: number;
|
||||||
|
error: string | null;
|
||||||
|
startTime: number | null;
|
||||||
|
endTime: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamOptions {
|
||||||
|
onChunk?: (chunk: StreamChunk) => void;
|
||||||
|
onComplete?: (content: string) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
onToolStart?: (toolName: string) => void;
|
||||||
|
onToolEnd?: (toolName: string, result: unknown) => void;
|
||||||
|
animateTokens?: boolean;
|
||||||
|
tokenDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: StreamState = {
|
||||||
|
status: 'idle',
|
||||||
|
content: '',
|
||||||
|
chunks: [],
|
||||||
|
currentTool: null,
|
||||||
|
progress: 0,
|
||||||
|
error: null,
|
||||||
|
startTime: null,
|
||||||
|
endTime: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useStreamingChat(options: StreamOptions = {}) {
|
||||||
|
const {
|
||||||
|
onChunk,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
onToolStart,
|
||||||
|
onToolEnd,
|
||||||
|
animateTokens = true,
|
||||||
|
tokenDelay = 20,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [state, setState] = useState<StreamState>(initialState);
|
||||||
|
|
||||||
|
// Refs for cleanup and animation
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
const tokenQueueRef = useRef<string[]>([]);
|
||||||
|
const isProcessingRef = useRef(false);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
tokenQueueRef.current = [];
|
||||||
|
isProcessingRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return cleanup;
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
|
// Token animation processor
|
||||||
|
const processTokenQueue = useCallback(() => {
|
||||||
|
if (!isProcessingRef.current || tokenQueueRef.current.length === 0) {
|
||||||
|
isProcessingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = tokenQueueRef.current.shift();
|
||||||
|
if (token) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
content: prev.content + token,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenQueueRef.current.length > 0) {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
setTimeout(processTokenQueue, tokenDelay);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
isProcessingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [tokenDelay]);
|
||||||
|
|
||||||
|
// Add content with optional animation
|
||||||
|
const addContent = useCallback((text: string) => {
|
||||||
|
if (animateTokens) {
|
||||||
|
// Split into tokens (words or characters based on length)
|
||||||
|
const tokens = text.length > 50 ? text.match(/.{1,5}/g) || [text] : text.split('');
|
||||||
|
tokenQueueRef.current.push(...tokens);
|
||||||
|
|
||||||
|
if (!isProcessingRef.current) {
|
||||||
|
isProcessingRef.current = true;
|
||||||
|
processTokenQueue();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
content: prev.content + text,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [animateTokens, processTokenQueue]);
|
||||||
|
|
||||||
|
// Process incoming chunk
|
||||||
|
const processChunk = useCallback((chunk: StreamChunk) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
chunks: [...prev.chunks, chunk],
|
||||||
|
}));
|
||||||
|
|
||||||
|
onChunk?.(chunk);
|
||||||
|
|
||||||
|
switch (chunk.type) {
|
||||||
|
case 'content':
|
||||||
|
if (chunk.content) {
|
||||||
|
addContent(chunk.content);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_start':
|
||||||
|
setState((prev) => ({ ...prev, currentTool: chunk.toolName || null }));
|
||||||
|
if (chunk.toolName) {
|
||||||
|
onToolStart?.(chunk.toolName);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_end':
|
||||||
|
setState((prev) => ({ ...prev, currentTool: null }));
|
||||||
|
if (chunk.toolName) {
|
||||||
|
onToolEnd?.(chunk.toolName, chunk.toolResult);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'thinking':
|
||||||
|
setState((prev) => ({ ...prev, status: 'streaming' }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'error',
|
||||||
|
error: chunk.error || 'Unknown error',
|
||||||
|
}));
|
||||||
|
if (chunk.error) {
|
||||||
|
onError?.(chunk.error);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'done':
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'complete',
|
||||||
|
endTime: Date.now(),
|
||||||
|
progress: 100,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [addContent, onChunk, onToolStart, onToolEnd, onError]);
|
||||||
|
|
||||||
|
// Start SSE stream
|
||||||
|
const startStream = useCallback(async (url: string, body?: Record<string, unknown>) => {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
setState({
|
||||||
|
...initialState,
|
||||||
|
status: 'connecting',
|
||||||
|
startTime: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For POST requests with SSE, we need to use fetch + ReadableStream
|
||||||
|
if (body) {
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, status: 'streaming' }));
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
processChunk({ type: 'done' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Process SSE format: data: {...}\n\n
|
||||||
|
const lines = buffer.split('\n\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6));
|
||||||
|
processChunk(data as StreamChunk);
|
||||||
|
} catch {
|
||||||
|
// If not JSON, treat as plain content
|
||||||
|
processChunk({ type: 'content', content: line.slice(6) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For GET requests, use EventSource
|
||||||
|
eventSourceRef.current = new EventSource(url);
|
||||||
|
|
||||||
|
eventSourceRef.current.onopen = () => {
|
||||||
|
setState((prev) => ({ ...prev, status: 'streaming' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSourceRef.current.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
processChunk(data as StreamChunk);
|
||||||
|
} catch {
|
||||||
|
processChunk({ type: 'content', content: event.data });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSourceRef.current.onerror = () => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'error',
|
||||||
|
error: 'Connection lost',
|
||||||
|
}));
|
||||||
|
onError?.('Connection lost');
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === 'AbortError') {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'cancelled',
|
||||||
|
endTime: Date.now(),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Stream failed';
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'error',
|
||||||
|
error: errorMsg,
|
||||||
|
endTime: Date.now(),
|
||||||
|
}));
|
||||||
|
onError?.(errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [cleanup, processChunk, onError]);
|
||||||
|
|
||||||
|
// Stop streaming
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
cleanup();
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: prev.status === 'streaming' ? 'cancelled' : prev.status,
|
||||||
|
endTime: Date.now(),
|
||||||
|
}));
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
cleanup();
|
||||||
|
setState(initialState);
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
|
// Calculate duration
|
||||||
|
const duration = state.startTime
|
||||||
|
? (state.endTime || Date.now()) - state.startTime
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
...state,
|
||||||
|
duration,
|
||||||
|
isActive: state.status === 'connecting' || state.status === 'streaming',
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
startStream,
|
||||||
|
stopStream,
|
||||||
|
reset,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
appendContent: addContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
export type UseStreamingChatReturn = ReturnType<typeof useStreamingChat>;
|
||||||
|
|
||||||
|
export default useStreamingChat;
|
||||||
44
src/modules/assistant/utils/index.ts
Normal file
44
src/modules/assistant/utils/index.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Assistant Utilities - Index Export
|
||||||
|
* Utility functions for message parsing and formatting
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Price & Number Formatting
|
||||||
|
formatPrice,
|
||||||
|
formatPercentage,
|
||||||
|
formatPnL,
|
||||||
|
formatVolume,
|
||||||
|
formatPips,
|
||||||
|
formatCurrency,
|
||||||
|
|
||||||
|
// Signal Parsing
|
||||||
|
extractTradingSignals,
|
||||||
|
extractPriceLevels,
|
||||||
|
|
||||||
|
// Tool Call Parsing
|
||||||
|
parseToolCallReferences,
|
||||||
|
extractMentionedTools,
|
||||||
|
|
||||||
|
// Markdown & Text Processing
|
||||||
|
parseMarkdownTable,
|
||||||
|
stripMarkdown,
|
||||||
|
extractCodeBlocks,
|
||||||
|
|
||||||
|
// Time & Date Formatting
|
||||||
|
formatChatTime,
|
||||||
|
formatDuration,
|
||||||
|
|
||||||
|
// Validation Helpers
|
||||||
|
containsTradingContent,
|
||||||
|
isValidPrice,
|
||||||
|
|
||||||
|
// Default export
|
||||||
|
default as messageFormatters,
|
||||||
|
} from './messageFormatters';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PriceLevel,
|
||||||
|
ToolCallReference,
|
||||||
|
ParsedSignal,
|
||||||
|
} from './messageFormatters';
|
||||||
390
src/modules/assistant/utils/messageFormatters.ts
Normal file
390
src/modules/assistant/utils/messageFormatters.ts
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* Message Formatters & Signal Parsers
|
||||||
|
* Utility functions for parsing assistant responses and formatting trading data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TradingSignal } from '../components/SignalCard';
|
||||||
|
import type { ToolCall } from '../components/ToolCallCard';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface PriceLevel {
|
||||||
|
price: number;
|
||||||
|
type: 'entry' | 'stop_loss' | 'take_profit' | 'support' | 'resistance' | 'unknown';
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallReference {
|
||||||
|
toolName: string;
|
||||||
|
status: 'pending' | 'running' | 'success' | 'error';
|
||||||
|
position: { start: number; end: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedSignal {
|
||||||
|
symbol: string;
|
||||||
|
direction: 'BUY' | 'SELL' | 'HOLD';
|
||||||
|
entry?: number;
|
||||||
|
stopLoss?: number;
|
||||||
|
takeProfit?: number;
|
||||||
|
confidence?: number;
|
||||||
|
timeframe?: string;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Price & Number Formatting
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format price based on symbol type
|
||||||
|
*/
|
||||||
|
export function formatPrice(price: number, symbol: string): string {
|
||||||
|
const isJpy = symbol.toUpperCase().includes('JPY');
|
||||||
|
const decimals = isJpy ? 3 : 5;
|
||||||
|
return price.toFixed(decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format percentage with optional decimals
|
||||||
|
*/
|
||||||
|
export function formatPercentage(value: number, decimals = 2): string {
|
||||||
|
const sign = value >= 0 ? '+' : '';
|
||||||
|
return `${sign}${value.toFixed(decimals)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format P&L with value and percentage
|
||||||
|
*/
|
||||||
|
export function formatPnL(pnl: number, pnlPct?: number): string {
|
||||||
|
const sign = pnl >= 0 ? '+' : '';
|
||||||
|
const pnlStr = `${sign}$${Math.abs(pnl).toFixed(2)}`;
|
||||||
|
|
||||||
|
if (pnlPct !== undefined) {
|
||||||
|
return `${pnlStr} (${formatPercentage(pnlPct)})`;
|
||||||
|
}
|
||||||
|
return pnlStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format volume/lots
|
||||||
|
*/
|
||||||
|
export function formatVolume(volume: number): string {
|
||||||
|
if (volume >= 1) return volume.toFixed(2);
|
||||||
|
return volume.toFixed(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format pips difference
|
||||||
|
*/
|
||||||
|
export function formatPips(pips: number): string {
|
||||||
|
const sign = pips >= 0 ? '+' : '';
|
||||||
|
return `${sign}${pips.toFixed(1)} pips`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format currency
|
||||||
|
*/
|
||||||
|
export function formatCurrency(value: number, currency = 'USD'): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Signal Parsing
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract trading signals from assistant response text
|
||||||
|
*/
|
||||||
|
export function extractTradingSignals(content: string): ParsedSignal[] {
|
||||||
|
const signals: ParsedSignal[] = [];
|
||||||
|
|
||||||
|
// Pattern for explicit signal mentions
|
||||||
|
const signalPatterns = [
|
||||||
|
// "BUY EURUSD at 1.0850"
|
||||||
|
/\b(BUY|SELL)\s+([A-Z]{6})\s+(?:at\s+)?(\d+\.?\d*)/gi,
|
||||||
|
// "Signal: LONG GBPUSD"
|
||||||
|
/signal[:\s]+?(LONG|SHORT|BUY|SELL)\s+([A-Z]{6})/gi,
|
||||||
|
// "Recommendation: Buy EUR/USD"
|
||||||
|
/recommend(?:ation)?[:\s]+?(BUY|SELL)\s+([A-Z]{3})\/?([A-Z]{3})/gi,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of signalPatterns) {
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(content)) !== null) {
|
||||||
|
const direction = match[1].toUpperCase();
|
||||||
|
const symbol = match[2] ? match[2].toUpperCase() : `${match[2]}${match[3]}`.toUpperCase();
|
||||||
|
const entry = match[3] ? parseFloat(match[3]) : undefined;
|
||||||
|
|
||||||
|
signals.push({
|
||||||
|
symbol: symbol.replace('/', ''),
|
||||||
|
direction: direction === 'LONG' ? 'BUY' : direction === 'SHORT' ? 'SELL' : direction as 'BUY' | 'SELL',
|
||||||
|
entry,
|
||||||
|
raw: match[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract SL/TP if present near signal
|
||||||
|
for (const signal of signals) {
|
||||||
|
const slMatch = content.match(new RegExp(`SL[:\\s]*?(\\d+\\.\\d+)`, 'i'));
|
||||||
|
const tpMatch = content.match(new RegExp(`TP[:\\s]*?(\\d+\\.\\d+)`, 'i'));
|
||||||
|
const confMatch = content.match(/confidence[:\s]*?(\d+)%?/i);
|
||||||
|
|
||||||
|
if (slMatch) signal.stopLoss = parseFloat(slMatch[1]);
|
||||||
|
if (tpMatch) signal.takeProfit = parseFloat(tpMatch[1]);
|
||||||
|
if (confMatch) signal.confidence = parseInt(confMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return signals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract price levels from text
|
||||||
|
*/
|
||||||
|
export function extractPriceLevels(content: string): PriceLevel[] {
|
||||||
|
const levels: PriceLevel[] = [];
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
{ regex: /entry[:\s]*?(\d+\.?\d*)/gi, type: 'entry' as const },
|
||||||
|
{ regex: /stop[- ]?loss[:\s]*?(\d+\.?\d*)/gi, type: 'stop_loss' as const },
|
||||||
|
{ regex: /take[- ]?profit[:\s]*?(\d+\.?\d*)/gi, type: 'take_profit' as const },
|
||||||
|
{ regex: /support[:\s]*?(\d+\.?\d*)/gi, type: 'support' as const },
|
||||||
|
{ regex: /resistance[:\s]*?(\d+\.?\d*)/gi, type: 'resistance' as const },
|
||||||
|
{ regex: /SL[:\s]*?(\d+\.?\d*)/gi, type: 'stop_loss' as const },
|
||||||
|
{ regex: /TP[:\s]*?(\d+\.?\d*)/gi, type: 'take_profit' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { regex, type } of patterns) {
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
levels.push({
|
||||||
|
price: parseFloat(match[1]),
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tool Call Parsing
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse tool call references from message content
|
||||||
|
*/
|
||||||
|
export function parseToolCallReferences(content: string): ToolCallReference[] {
|
||||||
|
const references: ToolCallReference[] = [];
|
||||||
|
|
||||||
|
// Pattern: [tool:get_price:running] or {tool:analyze_symbol:success}
|
||||||
|
const pattern = /[\[{]tool:(\w+):(\w+)[\]}]/gi;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(content)) !== null) {
|
||||||
|
references.push({
|
||||||
|
toolName: match[1],
|
||||||
|
status: match[2] as ToolCallReference['status'],
|
||||||
|
position: { start: match.index, end: match.index + match[0].length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract tool names mentioned in text
|
||||||
|
*/
|
||||||
|
export function extractMentionedTools(content: string): string[] {
|
||||||
|
const toolKeywords = [
|
||||||
|
'get_price', 'get_quote', 'analyze_symbol', 'get_signal',
|
||||||
|
'create_order', 'close_position', 'get_portfolio', 'search_symbols',
|
||||||
|
'backtest', 'calculate_risk', 'get_history',
|
||||||
|
];
|
||||||
|
|
||||||
|
return toolKeywords.filter((tool) =>
|
||||||
|
content.toLowerCase().includes(tool.replace('_', ' ')) ||
|
||||||
|
content.toLowerCase().includes(tool)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Markdown & Text Processing
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown-style tables to structured data
|
||||||
|
*/
|
||||||
|
export function parseMarkdownTable(text: string): Record<string, string>[] {
|
||||||
|
const lines = text.trim().split('\n');
|
||||||
|
const data: Record<string, string>[] = [];
|
||||||
|
|
||||||
|
// Find table header
|
||||||
|
const headerLine = lines.findIndex((line) => line.includes('|'));
|
||||||
|
if (headerLine === -1) return data;
|
||||||
|
|
||||||
|
const headers = lines[headerLine]
|
||||||
|
.split('|')
|
||||||
|
.map((h) => h.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Skip separator line
|
||||||
|
const dataStart = lines[headerLine + 1]?.includes('---') ? headerLine + 2 : headerLine + 1;
|
||||||
|
|
||||||
|
for (let i = dataStart; i < lines.length; i++) {
|
||||||
|
if (!lines[i].includes('|')) continue;
|
||||||
|
|
||||||
|
const values = lines[i]
|
||||||
|
.split('|')
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
headers.forEach((header, idx) => {
|
||||||
|
row[header] = values[idx] || '';
|
||||||
|
});
|
||||||
|
data.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip markdown formatting for plain text
|
||||||
|
*/
|
||||||
|
export function stripMarkdown(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\*\*(.*?)\*\*/g, '$1') // Bold
|
||||||
|
.replace(/\*(.*?)\*/g, '$1') // Italic
|
||||||
|
.replace(/`(.*?)`/g, '$1') // Inline code
|
||||||
|
.replace(/```[\s\S]*?```/g, '') // Code blocks
|
||||||
|
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // Links
|
||||||
|
.replace(/#{1,6}\s/g, '') // Headers
|
||||||
|
.replace(/[>\-*+]\s/g, '') // List items
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract code blocks from markdown
|
||||||
|
*/
|
||||||
|
export function extractCodeBlocks(content: string): { language: string; code: string }[] {
|
||||||
|
const blocks: { language: string; code: string }[] = [];
|
||||||
|
const pattern = /```(\w*)\n([\s\S]*?)```/g;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(content)) !== null) {
|
||||||
|
blocks.push({
|
||||||
|
language: match[1] || 'text',
|
||||||
|
code: match[2].trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Time & Date Formatting
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timestamp for chat display
|
||||||
|
*/
|
||||||
|
export function formatChatTime(timestamp: string | Date): string {
|
||||||
|
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
|
||||||
|
// Less than a minute
|
||||||
|
if (diff < 60000) return 'Just now';
|
||||||
|
|
||||||
|
// Less than an hour
|
||||||
|
if (diff < 3600000) {
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
return `${mins}m ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same day
|
||||||
|
if (date.toDateString() === now.toDateString()) {
|
||||||
|
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yesterday
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return 'Yesterday';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within a week
|
||||||
|
if (diff < 7 * 24 * 3600000) {
|
||||||
|
return date.toLocaleDateString('en-US', { weekday: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration in ms to human readable
|
||||||
|
*/
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Validation Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text contains trading-related content
|
||||||
|
*/
|
||||||
|
export function containsTradingContent(content: string): boolean {
|
||||||
|
const tradingKeywords = [
|
||||||
|
'buy', 'sell', 'long', 'short', 'signal', 'entry', 'exit',
|
||||||
|
'stop loss', 'take profit', 'sl', 'tp', 'pips', 'lots',
|
||||||
|
'bullish', 'bearish', 'support', 'resistance', 'trend',
|
||||||
|
];
|
||||||
|
|
||||||
|
const lowerContent = content.toLowerCase();
|
||||||
|
return tradingKeywords.some((kw) => lowerContent.includes(kw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate price format for symbol
|
||||||
|
*/
|
||||||
|
export function isValidPrice(price: number, symbol: string): boolean {
|
||||||
|
const isJpy = symbol.toUpperCase().includes('JPY');
|
||||||
|
|
||||||
|
if (isJpy) {
|
||||||
|
return price > 50 && price < 500; // JPY pairs typically 100-200
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most forex pairs
|
||||||
|
return price > 0.5 && price < 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export all formatters
|
||||||
|
export default {
|
||||||
|
formatPrice,
|
||||||
|
formatPercentage,
|
||||||
|
formatPnL,
|
||||||
|
formatVolume,
|
||||||
|
formatPips,
|
||||||
|
formatCurrency,
|
||||||
|
extractTradingSignals,
|
||||||
|
extractPriceLevels,
|
||||||
|
parseToolCallReferences,
|
||||||
|
extractMentionedTools,
|
||||||
|
parseMarkdownTable,
|
||||||
|
stripMarkdown,
|
||||||
|
extractCodeBlocks,
|
||||||
|
formatChatTime,
|
||||||
|
formatDuration,
|
||||||
|
containsTradingContent,
|
||||||
|
isValidPrice,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user