feat(chat): add guided next actions
This commit is contained in:
parent
d160beadfd
commit
73db534d7d
@ -232,6 +232,7 @@ interface ChatProfilePayload {
|
|||||||
type ChatAction =
|
type ChatAction =
|
||||||
| 'create_profile'
|
| 'create_profile'
|
||||||
| 'update_profile'
|
| 'update_profile'
|
||||||
|
| 'recommend_profile_change'
|
||||||
| 'explain'
|
| 'explain'
|
||||||
| 'explain_position'
|
| 'explain_position'
|
||||||
| 'explain_waiting'
|
| 'explain_waiting'
|
||||||
@ -354,6 +355,7 @@ interface ChatResponsePayload {
|
|||||||
profile?: ChatProfilePayload;
|
profile?: ChatProfilePayload;
|
||||||
summary: string;
|
summary: string;
|
||||||
reasoning: string;
|
reasoning: string;
|
||||||
|
nextActions?: string[];
|
||||||
fallback?: 'local_deterministic';
|
fallback?: 'local_deterministic';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1100,7 +1102,11 @@ export class ApiServer {
|
|||||||
return {
|
return {
|
||||||
action: 'explain_position',
|
action: 'explain_position',
|
||||||
summary: `${target.symbol} is currently ${pnl >= 0 ? 'up' : 'down'} ${Math.abs(pnl).toFixed(2)} (${Math.abs(pnlPercent).toFixed(2)}%) under ${profileName}.`,
|
summary: `${target.symbol} is currently ${pnl >= 0 ? 'up' : 'down'} ${Math.abs(pnl).toFixed(2)} (${Math.abs(pnlPercent).toFixed(2)}%) under ${profileName}.`,
|
||||||
reasoning: `${nextStep}${targetHints.length > 0 ? ` Current guardrails: ${targetHints.join(' and ')}.` : ''} Entry ${Number(target.entryPrice || 0).toFixed(2)}, current ${Number(target.currentPrice || 0).toFixed(2)}, size ${Number(target.size || 0).toFixed(6)}.`
|
reasoning: `${nextStep}${targetHints.length > 0 ? ` Current guardrails: ${targetHints.join(' and ')}.` : ''} Entry ${Number(target.entryPrice || 0).toFixed(2)}, current ${Number(target.currentPrice || 0).toFixed(2)}, size ${Number(target.size || 0).toFixed(6)}.`,
|
||||||
|
nextActions: [
|
||||||
|
'Open Trade Plans if you want to attach, pause, or resume automated exits.',
|
||||||
|
'Review Portfolio for the linked holding and current P&L before changing the plan.',
|
||||||
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1149,7 +1155,12 @@ export class ApiServer {
|
|||||||
summary: summaryParts.join(' ') || 'A recent operational event indicates the trade flow is blocked.',
|
summary: summaryParts.join(' ') || 'A recent operational event indicates the trade flow is blocked.',
|
||||||
reasoning: healthNotes.length > 0
|
reasoning: healthNotes.length > 0
|
||||||
? `Operational context also shows ${healthNotes.join(', ')}. The safest next step is to review the related profile, live order state, and reconciliation surfaces before retrying execution.`
|
? `Operational context also shows ${healthNotes.join(', ')}. The safest next step is to review the related profile, live order state, and reconciliation surfaces before retrying execution.`
|
||||||
: 'The safest next step is to review the related profile, live order state, and recent operational events before retrying execution.'
|
: 'The safest next step is to review the related profile, live order state, and recent operational events before retrying execution.',
|
||||||
|
nextActions: [
|
||||||
|
'Check the latest operational event and order failure reason for the affected symbol.',
|
||||||
|
'If the blocker is persistent, inspect the Admin reconciliation surfaces before retrying.',
|
||||||
|
'If the issue is configuration-driven, ask the copilot to recommend a profile change.',
|
||||||
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1173,7 +1184,11 @@ export class ApiServer {
|
|||||||
summary: `${label} on ${target.symbol || 'the current symbol'} is currently ${executionStatus.toLowerCase()} with signal ${signalDirection}.${summarySource ? ` ${summarySource}` : ''}`,
|
summary: `${label} on ${target.symbol || 'the current symbol'} is currently ${executionStatus.toLowerCase()} with signal ${signalDirection}.${summarySource ? ` ${summarySource}` : ''}`,
|
||||||
reasoning: target.executionCode
|
reasoning: target.executionCode
|
||||||
? `Execution code: ${target.executionCode}. This means the bot evaluated the setup but did not proceed yet.${target.orderId ? ` Related order: ${target.orderId}.` : ''}`
|
? `Execution code: ${target.executionCode}. This means the bot evaluated the setup but did not proceed yet.${target.orderId ? ` Related order: ${target.orderId}.` : ''}`
|
||||||
: 'The bot is still evaluating rule alignment, risk constraints, or session eligibility before entering.'
|
: 'The bot is still evaluating rule alignment, risk constraints, or session eligibility before entering.',
|
||||||
|
nextActions: [
|
||||||
|
'Review the active profile rules and recent symbol signal panel to see which rule or guardrail is not aligned.',
|
||||||
|
'If the waiting state persists, ask the copilot to recommend a profile change for this setup.',
|
||||||
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1200,7 +1215,69 @@ export class ApiServer {
|
|||||||
: `Reconciliation currently looks healthy with no active mismatches, NO_GO trades, or quarantined parity trades.`,
|
: `Reconciliation currently looks healthy with no active mismatches, NO_GO trades, or quarantined parity trades.`,
|
||||||
reasoning: degraded
|
reasoning: degraded
|
||||||
? `Missing-from-exchange: ${missingExchange}. Missing-in-DB: ${missingDb}. Auto-closed parity trades: ${autoClosedCount}.${recentReconEvents.length > 0 ? ` Recent events: ${recentReconEvents.join(' | ')}` : ''}`
|
? `Missing-from-exchange: ${missingExchange}. Missing-in-DB: ${missingDb}. Auto-closed parity trades: ${autoClosedCount}.${recentReconEvents.length > 0 ? ` Recent events: ${recentReconEvents.join(' | ')}` : ''}`
|
||||||
: `Auto-closed parity trades this cycle: ${autoClosedCount}.${recentReconEvents.length > 0 ? ` Recent events: ${recentReconEvents.join(' | ')}` : ''}`
|
: `Auto-closed parity trades this cycle: ${autoClosedCount}.${recentReconEvents.length > 0 ? ` Recent events: ${recentReconEvents.join(' | ')}` : ''}`,
|
||||||
|
nextActions: degraded
|
||||||
|
? [
|
||||||
|
'Open the Admin reconciliation audit to inspect mismatches, NO_GO trades, and any quarantined rows.',
|
||||||
|
'Avoid forcing new execution on the affected profile until the reconciliation issue is understood.',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'No immediate action is needed. Continue monitoring normal runtime health and recent operational events.',
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProfileChangeRecommendation(message: string, chatContext: ChatRequestContext): ChatResponsePayload | null {
|
||||||
|
const targetProfile = this.detectProfileToUpdate(message, chatContext.profiles)
|
||||||
|
|| (Array.isArray(chatContext.profiles) ? chatContext.profiles[0] : null);
|
||||||
|
if (!targetProfile) return null;
|
||||||
|
|
||||||
|
const execution = targetProfile?.strategy_config?.execution || {};
|
||||||
|
const riskLimits = targetProfile?.strategy_config?.riskLimits || {};
|
||||||
|
const currentRisk = Number(targetProfile?.risk_per_trade_percent || 1);
|
||||||
|
const currentCooldown = Number(execution.cooldownMinutes ?? 30);
|
||||||
|
const currentOpenTrades = Number(riskLimits.maxOpenTrades ?? 3);
|
||||||
|
|
||||||
|
const waitingSignal = (chatContext.runtime.signalContexts || []).find((signal) =>
|
||||||
|
String(signal.profileId || '') === String(targetProfile.id || '')
|
||||||
|
|| String(signal.profileName || '').toLowerCase() === String(targetProfile.name || '').toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
const recommendedProfile: ChatProfilePayload = {
|
||||||
|
...targetProfile,
|
||||||
|
id: targetProfile.id,
|
||||||
|
name: targetProfile.name || 'Recommended Profile Change',
|
||||||
|
allocated_capital: Number(targetProfile.allocated_capital || 1000),
|
||||||
|
risk_per_trade_percent: Math.max(0.5, Number((currentRisk * 0.85).toFixed(2))),
|
||||||
|
symbols: String(targetProfile.symbols || 'BTC/USDT'),
|
||||||
|
is_active: targetProfile.is_active !== false,
|
||||||
|
strategy_config: {
|
||||||
|
...targetProfile.strategy_config,
|
||||||
|
rules: Array.isArray(targetProfile?.strategy_config?.rules) ? targetProfile.strategy_config.rules : [],
|
||||||
|
riskLimits: {
|
||||||
|
maxDailyLossUsd: Number(riskLimits.maxDailyLossUsd || 50),
|
||||||
|
maxOpenTrades: Math.max(1, currentOpenTrades - (waitingSignal?.executionCode === 'rule_ratio_not_met' ? 1 : 0)),
|
||||||
|
maxConsecutiveLosses: Number(riskLimits.maxConsecutiveLosses ?? 2),
|
||||||
|
},
|
||||||
|
execution: {
|
||||||
|
orderType: execution.orderType === 'limit' ? 'limit' : 'market',
|
||||||
|
cooldownMinutes: Math.max(10, currentCooldown - (waitingSignal?.executionCode === 'rule_ratio_not_met' ? 10 : 0)),
|
||||||
|
entryMode: execution.entryMode === 'long_only' ? 'long_only' : 'both',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: 'recommend_profile_change',
|
||||||
|
profile: recommendedProfile,
|
||||||
|
summary: `I recommend tuning "${recommendedProfile.name}" to improve execution quality without changing its overall strategy intent.`,
|
||||||
|
reasoning: waitingSignal?.executionCode === 'rule_ratio_not_met'
|
||||||
|
? `Recent signal context shows ${waitingSignal.symbol} is waiting on rule alignment. I reduced cooldown pressure and slightly tightened exposure so the profile can recycle opportunities more cleanly.`
|
||||||
|
: 'I preserved the existing strategy structure and adjusted only the higher-leverage controls such as risk per trade, cooldown, and open-trade exposure.',
|
||||||
|
nextActions: [
|
||||||
|
'Review the suggested profile draft below before applying it.',
|
||||||
|
'After applying, monitor the next signal cycle to see whether the waiting or blocker state improves.',
|
||||||
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1218,6 +1295,7 @@ export class ApiServer {
|
|||||||
private buildLocalChatFallback(message: string, context: ChatRequestContext): ChatResponsePayload {
|
private buildLocalChatFallback(message: string, context: ChatRequestContext): ChatResponsePayload {
|
||||||
const lower = String(message || '').toLowerCase();
|
const lower = String(message || '').toLowerCase();
|
||||||
const asksForReconciliation = /\b(reconciliation|reconcile|quarantine|manual review|stale order|no[_\s-]*go|drift)\b/i.test(lower);
|
const asksForReconciliation = /\b(reconciliation|reconcile|quarantine|manual review|stale order|no[_\s-]*go|drift)\b/i.test(lower);
|
||||||
|
const asksForRecommendation = /\b(recommend|improve|tune|optimi[sz]e|adjust my profile|suggest a profile change)\b/i.test(lower);
|
||||||
const asksForWaiting = /\b(why no trade|why.*no trade|why didn'?t.*trade|why did not.*trade|why.*enter|waiting for|no entry|no signal)\b/i.test(lower);
|
const asksForWaiting = /\b(why no trade|why.*no trade|why didn'?t.*trade|why did not.*trade|why.*enter|waiting for|no entry|no signal)\b/i.test(lower);
|
||||||
const asksForBlocker = /\b(why.*(blocked|stuck|failed|not execute|didn'?t execute|did not execute)|blocker|blocked|manual trader unavailable|unauthorized|forbidden|failed order)\b/i.test(lower);
|
const asksForBlocker = /\b(why.*(blocked|stuck|failed|not execute|didn'?t execute|did not execute)|blocker|blocked|manual trader unavailable|unauthorized|forbidden|failed order)\b/i.test(lower);
|
||||||
const asksForPosition = /\b(position|holding|portfolio|pnl|profit|loss|exit|take profit|stop loss)\b/i.test(lower);
|
const asksForPosition = /\b(position|holding|portfolio|pnl|profit|loss|exit|take profit|stop loss)\b/i.test(lower);
|
||||||
@ -1228,6 +1306,19 @@ export class ApiServer {
|
|||||||
return this.buildReconciliationSummary(context);
|
return this.buildReconciliationSummary(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (asksForRecommendation) {
|
||||||
|
return this.buildProfileChangeRecommendation(message, context)
|
||||||
|
|| {
|
||||||
|
action: 'explain',
|
||||||
|
summary: 'I could not find a concrete profile to tune from the current scoped context.',
|
||||||
|
reasoning: 'Mention a specific profile name or ask after selecting the profile you want to improve.',
|
||||||
|
nextActions: [
|
||||||
|
'Name the profile you want tuned, for example: "Tune High Risk Scalper".',
|
||||||
|
'If the issue is execution-related, ask why the trade is waiting or blocked first.',
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (asksForBlocker) {
|
if (asksForBlocker) {
|
||||||
return this.buildBlockerExplanation(message, context)
|
return this.buildBlockerExplanation(message, context)
|
||||||
|| {
|
|| {
|
||||||
@ -2991,7 +3082,7 @@ AVAILABLE RULES (use these exact ruleId values):
|
|||||||
|
|
||||||
PROFILE SCHEMA:
|
PROFILE SCHEMA:
|
||||||
{
|
{
|
||||||
"action": "create_profile" | "update_profile" | "explain" | "explain_position" | "explain_waiting" | "explain_blocker" | "summarize_reconciliation",
|
"action": "create_profile" | "update_profile" | "recommend_profile_change" | "explain" | "explain_position" | "explain_waiting" | "explain_blocker" | "summarize_reconciliation",
|
||||||
"profile": {
|
"profile": {
|
||||||
"name": string,
|
"name": string,
|
||||||
"allocated_capital": number,
|
"allocated_capital": number,
|
||||||
@ -3005,7 +3096,8 @@ PROFILE SCHEMA:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"summary": string (1-2 sentence human-readable summary of what you did),
|
"summary": string (1-2 sentence human-readable summary of what you did),
|
||||||
"reasoning": string (brief explanation of why you chose these parameters or what operational state means)
|
"reasoning": string (brief explanation of why you chose these parameters or what operational state means),
|
||||||
|
"nextActions": string[] (optional safe next steps for the user)
|
||||||
}
|
}
|
||||||
|
|
||||||
CURRENT CONTEXT (existing profiles):
|
CURRENT CONTEXT (existing profiles):
|
||||||
@ -3017,16 +3109,17 @@ ${JSON.stringify(chatContext.runtime, null, 2)}
|
|||||||
RULES:
|
RULES:
|
||||||
1. For "create_profile": generate a complete profile with sensible defaults based on the user's description.
|
1. For "create_profile": generate a complete profile with sensible defaults based on the user's description.
|
||||||
2. For "update_profile": include the profile "id" field and only change what the user asked for. Keep everything else the same.
|
2. For "update_profile": include the profile "id" field and only change what the user asked for. Keep everything else the same.
|
||||||
3. For "explain": use it for general educational answers. No profile needed.
|
3. For "recommend_profile_change": include a profile draft with the recommended adjustments and explain why.
|
||||||
4. For "explain_position": use live position context and explain the current holding plus likely next step. No profile needed.
|
4. For "explain": use it for general educational answers. No profile needed.
|
||||||
5. For "explain_waiting": explain why a profile/symbol has not entered yet using signal/rule/execution context. No profile needed.
|
5. For "explain_position": use live position context and explain the current holding plus likely next step. No profile needed.
|
||||||
6. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed.
|
6. For "explain_waiting": explain why a profile/symbol has not entered yet using signal/rule/execution context. No profile needed.
|
||||||
7. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed.
|
7. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed.
|
||||||
8. Only include "profile" when the action is create_profile or update_profile.
|
8. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed.
|
||||||
9. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice.
|
9. Only include "profile" when the action is create_profile, update_profile, or recommend_profile_change.
|
||||||
10. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled.
|
10. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice.
|
||||||
11. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety.
|
11. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled.
|
||||||
12. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`;
|
12. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety.
|
||||||
|
13. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let aiResponse: string | null = null;
|
let aiResponse: string | null = null;
|
||||||
@ -3066,6 +3159,7 @@ RULES:
|
|||||||
const allowedActions = new Set<ChatAction>([
|
const allowedActions = new Set<ChatAction>([
|
||||||
'create_profile',
|
'create_profile',
|
||||||
'update_profile',
|
'update_profile',
|
||||||
|
'recommend_profile_change',
|
||||||
'explain',
|
'explain',
|
||||||
'explain_position',
|
'explain_position',
|
||||||
'explain_waiting',
|
'explain_waiting',
|
||||||
|
|||||||
@ -255,4 +255,60 @@ describe('ChatControl DOM flow', () => {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders suggested next actions and applies a recommended profile change as an update', async () => {
|
||||||
|
getPlatformAccessTokenMock.mockResolvedValue('token-4');
|
||||||
|
const fetchMock = vi.mocked(fetch);
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
summary: 'I recommend tuning "High Risk Scalper ⚡" to improve execution quality without changing its overall strategy intent.',
|
||||||
|
reasoning: 'Recent signal context shows BTC/USDT is waiting on rule alignment.',
|
||||||
|
nextActions: [
|
||||||
|
'Review the suggested profile draft below before applying it.',
|
||||||
|
'After applying, monitor the next signal cycle to see whether the waiting state improves.'
|
||||||
|
],
|
||||||
|
action: 'recommend_profile_change',
|
||||||
|
profile: {
|
||||||
|
id: 'p1',
|
||||||
|
name: 'High Risk Scalper ⚡',
|
||||||
|
allocated_capital: 1000,
|
||||||
|
risk_per_trade_percent: 1.1,
|
||||||
|
symbols: 'BTC/USDT',
|
||||||
|
is_active: true,
|
||||||
|
strategy_config: {
|
||||||
|
rules: [{ ruleId: 'TrendBiasRule', enabled: true }],
|
||||||
|
riskLimits: { maxDailyLossUsd: 50, maxOpenTrades: 2, maxConsecutiveLosses: 2 },
|
||||||
|
execution: { orderType: 'market', cooldownMinutes: 20, entryMode: 'both' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const onApplyProfile = vi.fn(async () => ({ success: true }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={onApplyProfile} />);
|
||||||
|
await user.click(screen.getAllByRole('button')[0]);
|
||||||
|
await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'Recommend a profile change for High Risk Scalper ⚡{enter}');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Suggested next actions/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Review the suggested profile draft below before applying it/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Suggested Profile Change/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Apply to Dashboard/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onApplyProfile).toHaveBeenCalledWith(
|
||||||
|
'update_profile',
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'p1',
|
||||||
|
name: 'High Risk Scalper ⚡',
|
||||||
|
risk_per_trade_percent: 1.1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -19,6 +19,7 @@ interface ChatMessage {
|
|||||||
content: string;
|
content: string;
|
||||||
profileData?: any;
|
profileData?: any;
|
||||||
action?: ChatAssistantAction;
|
action?: ChatAssistantAction;
|
||||||
|
nextActions?: string[];
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,8 +32,10 @@ interface ChatControlProps {
|
|||||||
type ChatAssistantAction =
|
type ChatAssistantAction =
|
||||||
| 'create_profile'
|
| 'create_profile'
|
||||||
| 'update_profile'
|
| 'update_profile'
|
||||||
|
| 'recommend_profile_change'
|
||||||
| 'explain'
|
| 'explain'
|
||||||
| 'explain_position'
|
| 'explain_position'
|
||||||
|
| 'explain_waiting'
|
||||||
| 'explain_blocker'
|
| 'explain_blocker'
|
||||||
| 'summarize_reconciliation';
|
| 'summarize_reconciliation';
|
||||||
|
|
||||||
@ -100,8 +103,8 @@ export const normalizeProfileForApply = (profileData: any) => ({
|
|||||||
is_active: profileData?.is_active !== false,
|
is_active: profileData?.is_active !== false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isProfileMutationAction = (action?: ChatAssistantAction): action is 'create_profile' | 'update_profile' =>
|
const isProfileMutationAction = (action?: ChatAssistantAction): action is 'create_profile' | 'update_profile' | 'recommend_profile_change' =>
|
||||||
action === 'create_profile' || action === 'update_profile';
|
action === 'create_profile' || action === 'update_profile' || action === 'recommend_profile_change';
|
||||||
|
|
||||||
const summarizeRuntimeContext = (botState: BotState) => ({
|
const summarizeRuntimeContext = (botState: BotState) => ({
|
||||||
signalContexts: Object.entries(botState.symbols ?? {})
|
signalContexts: Object.entries(botState.symbols ?? {})
|
||||||
@ -388,6 +391,7 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
|
|||||||
content: data.summary || data.reasoning || 'Profile configuration generated.',
|
content: data.summary || data.reasoning || 'Profile configuration generated.',
|
||||||
profileData: data.profile || null,
|
profileData: data.profile || null,
|
||||||
action: data.action as ChatAssistantAction | undefined,
|
action: data.action as ChatAssistantAction | undefined,
|
||||||
|
nextActions: Array.isArray(data.nextActions) ? data.nextActions.map((entry: unknown) => String(entry)) : undefined,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -412,14 +416,17 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
|
|||||||
if (msg.profileData && isProfileMutationAction(msg.action)) {
|
if (msg.profileData && isProfileMutationAction(msg.action)) {
|
||||||
const activeDraft = draftProfiles[msg.id] || msg.profileData;
|
const activeDraft = draftProfiles[msg.id] || msg.profileData;
|
||||||
const payload = normalizeProfileForApply(activeDraft);
|
const payload = normalizeProfileForApply(activeDraft);
|
||||||
const result = await onApplyProfile(msg.action, payload);
|
const applyAction = msg.action === 'recommend_profile_change'
|
||||||
|
? (payload.id ? 'update_profile' : 'create_profile')
|
||||||
|
: msg.action;
|
||||||
|
const result = await onApplyProfile(applyAction, payload);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setAppliedIds(prev => new Set(prev).add(msg.id));
|
setAppliedIds(prev => new Set(prev).add(msg.id));
|
||||||
closeDraftEditor(msg.id);
|
closeDraftEditor(msg.id);
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: msg.action === 'create_profile'
|
content: applyAction === 'create_profile'
|
||||||
? `Profile "${payload.name}" has been created successfully! It's now visible in Strategy Clusters and the bot will pick it up on next sync (~60s).`
|
? `Profile "${payload.name}" has been created successfully! It's now visible in Strategy Clusters and the bot will pick it up on next sync (~60s).`
|
||||||
: `Profile "${payload.name}" has been updated successfully!`,
|
: `Profile "${payload.name}" has been updated successfully!`,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
@ -428,7 +435,7 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
|
|||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: `Failed to ${msg.action === 'create_profile' ? 'create' : 'update'} profile: ${result.error || 'Unknown error'}. Please try again or check your permissions.`,
|
content: `Failed to ${applyAction === 'create_profile' ? 'create' : 'update'} profile: ${result.error || 'Unknown error'}. Please try again or check your permissions.`,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
@ -638,6 +645,32 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
|
|||||||
{msg.content}
|
{msg.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{msg.nextActions && msg.nextActions.length > 0 ? (
|
||||||
|
<div className="mt-2 rounded-xl px-3 py-2.5" style={{
|
||||||
|
background: 'var(--card-elevated)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||||
|
Suggested next actions
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{msg.nextActions.map((nextAction, index) => (
|
||||||
|
<div
|
||||||
|
key={`${msg.id}-next-${index}`}
|
||||||
|
className="rounded-lg px-2.5 py-2 text-[11px] leading-relaxed"
|
||||||
|
style={{
|
||||||
|
background: 'var(--accent-soft)',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1}. {nextAction}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Profile preview card */}
|
{/* Profile preview card */}
|
||||||
{msg.profileData && isProfileMutationAction(msg.action) && (() => {
|
{msg.profileData && isProfileMutationAction(msg.action) && (() => {
|
||||||
const activeProfileData = draftProfiles[msg.id] || msg.profileData;
|
const activeProfileData = draftProfiles[msg.id] || msg.profileData;
|
||||||
@ -659,7 +692,11 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Zap size={10} className="text-[var(--primary)]" />
|
<Zap size={10} className="text-[var(--primary)]" />
|
||||||
<span className="text-[10px] font-bold text-[var(--muted-foreground)] uppercase tracking-wider">
|
<span className="text-[10px] font-bold text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
{msg.action === 'create_profile' ? 'New Profile' : 'Update Profile'}
|
{msg.action === 'create_profile'
|
||||||
|
? 'New Profile'
|
||||||
|
: msg.action === 'recommend_profile_change'
|
||||||
|
? 'Suggested Profile Change'
|
||||||
|
: 'Update Profile'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user