diff --git a/.env.example b/.env.example index 88fc519..043d9d1 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,12 @@ JWT_SECRET=your-secret-key-at-least-32-chars BROKER_KEY_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 DEMO_USERNAME=admin DEMO_PASSWORD=changeme + +# LLM Provider (claude | openai) +QUANTPILOT_LLM_PROVIDER=claude +QUANTPILOT_LLM_MODEL= +ANTHROPIC_API_KEY= +OPENAI_API_KEY= + +# Mock data (true = use synthetic data, false = use Alpaca) +QUANTPILOT_USE_MOCK_DATA=false diff --git a/apps/api/package.json b/apps/api/package.json index e28c685..24cbcf3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,6 +7,7 @@ }, "dependencies": { "@hono/node-server": "^1.19.14", + "@quantpilot/llm-provider": "*", "hono": "^4.12.12", "jose": "^6.2.2" } diff --git a/apps/api/src/app/routes/hono/execution-hono-router.ts b/apps/api/src/app/routes/hono/execution-hono-router.ts index 09cae25..8a29be8 100644 --- a/apps/api/src/app/routes/hono/execution-hono-router.ts +++ b/apps/api/src/app/routes/hono/execution-hono-router.ts @@ -21,7 +21,7 @@ import { listExecutionPlans, listExecutionRuntimeEvents, } from '../../../domains/execution/services/query-service.js'; -import { hasPermission, writeForbiddenJson } from '../../../modules/auth/service.js'; +import { hasPermission } from '../../../modules/auth/service.js'; function requireApproval(c, action = '') { if (!hasPermission('execution:approve')) { diff --git a/apps/api/src/app/routes/routers/agent-router.ts b/apps/api/src/app/routes/routers/agent-router.ts index bba5dd8..e15b54b 100644 --- a/apps/api/src/app/routes/routers/agent-router.ts +++ b/apps/api/src/app/routes/routers/agent-router.ts @@ -70,28 +70,31 @@ export async function handleAgentRoutes({ req, reqUrl, res, readJsonBody, writeJ if (req.method === 'POST' && reqUrl.pathname === '/api/agent/tools/execute') { const body = await readJsonBody(req); - const result = executeAgentTool(body); + const result = await executeAgentTool(body); writeJson(res, result.ok ? 200 : 403, result); return true; } + // LLM-powered intent parsing (now async) if (req.method === 'POST' && reqUrl.pathname === '/api/agent/intent') { const body = await readJsonBody(req); - const result = parseAgentIntent(body); + const result = await parseAgentIntent(body); writeJson(res, result.ok ? 200 : 400, result); return true; } + // LLM-powered plan creation (now async) if (req.method === 'POST' && reqUrl.pathname === '/api/agent/plans') { const body = await readJsonBody(req); - const result = createAgentPlan(body); + const result = await createAgentPlan(body); writeJson(res, result.ok ? 200 : 400, result); return true; } + // LLM-powered analysis with tool-use loop (now async) if (req.method === 'POST' && reqUrl.pathname === '/api/agent/analysis-runs') { const body = await readJsonBody(req); - const result = runAgentAnalysis(body); + const result = await runAgentAnalysis(body); writeJson(res, result.ok ? 200 : 400, result); return true; } diff --git a/apps/api/src/app/routes/routers/risk-router.ts b/apps/api/src/app/routes/routers/risk-router.ts index bfea22b..a8392bb 100644 --- a/apps/api/src/app/routes/routers/risk-router.ts +++ b/apps/api/src/app/routes/routers/risk-router.ts @@ -1,6 +1,11 @@ // @ts-nocheck import { getRiskEvent, listRiskEvents } from '../../../domains/risk/services/feed-service.js'; +import { + getRiskParameters, + resetRiskParameters, + updateRiskParameters, +} from '../../../domains/risk/services/parameters-service.js'; import { runRiskPolicyAction } from '../../../domains/risk/services/policy-action-service.js'; import { getRiskWorkbench } from '../../../domains/risk/services/workbench-service.js'; import { writeForbiddenJson } from '../../../modules/auth/permission-catalog.js'; @@ -10,6 +15,32 @@ export async function handleRiskRoutes({ req, reqUrl, res, readJsonBody, writeJs const writeForbidden = (permission, action = '') => writeForbiddenJson(writeJson, res, permission, action); + if (req.method === 'GET' && reqUrl.pathname === '/api/risk/parameters') { + writeJson(res, 200, { ok: true, parameters: getRiskParameters() }); + return true; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/risk/parameters') { + if (!hasPermission('risk:review')) { + writeForbidden('risk:review', 'update risk parameters'); + return true; + } + const body = await readJsonBody(req); + const updated = updateRiskParameters(body); + writeJson(res, 200, { ok: true, parameters: updated }); + return true; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/risk/parameters/reset') { + if (!hasPermission('risk:review')) { + writeForbidden('risk:review', 'reset risk parameters'); + return true; + } + const reset = resetRiskParameters(); + writeJson(res, 200, { ok: true, parameters: reset }); + return true; + } + if (req.method === 'GET' && reqUrl.pathname === '/api/risk/events') { writeJson(res, 200, { ok: true, events: listRiskEvents() }); return true; diff --git a/apps/api/src/app/routes/routers/trading-router.ts b/apps/api/src/app/routes/routers/trading-router.ts index a685e03..dea4f29 100644 --- a/apps/api/src/app/routes/routers/trading-router.ts +++ b/apps/api/src/app/routes/routers/trading-router.ts @@ -3,7 +3,6 @@ import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; import { assessExecutionCandidate } from '../../../domains/risk/services/assessment-service.js'; import { getStrategyCatalogItem } from '../../../domains/strategy/services/catalog-service.js'; -import { buildStrategyExecutionCandidate } from '../../../domains/strategy/services/execution-candidate-service.js'; import { writeForbiddenJson } from '../../../modules/auth/permission-catalog.js'; import { hasPermission } from '../../../modules/auth/service.js'; diff --git a/apps/api/src/domains/agent/services/analysis-service.js b/apps/api/src/domains/agent/services/analysis-service.js new file mode 100644 index 0000000..b04cedd --- /dev/null +++ b/apps/api/src/domains/agent/services/analysis-service.js @@ -0,0 +1,462 @@ +// @ts-nocheck +import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; +import { createLLMProvider } from '../../../../../../packages/llm-provider/src/index.js'; +import { listActiveAgentInstructions } from './instruction-service.js'; +import { createAgentPlan } from './planning-service.js'; +import { executeAgentTool } from './tools-service.js'; +import { ANALYSIS_SYSTEM_PROMPT } from './prompts.js'; + +/** + * Tool definitions for LLM function/tool calling. + * These map to executeAgentTool() implementations. + */ +const LLM_TOOLS = [ + { + name: 'strategy_catalog_list', + description: 'List all trading strategies in the catalog with their current status and metrics.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'backtest_summary_get', + description: 'Get the backtest center summary: total runs, pending reviews, and health metrics.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'backtest_runs_list', + description: 'List recent backtest runs with performance metrics like Sharpe ratio and max drawdown.', + inputSchema: { + type: 'object', + properties: { + status: { type: 'string', description: 'Filter by status: needs_review, completed, failed', enum: ['needs_review', 'completed', 'failed'] }, + }, + required: [], + }, + }, + { + name: 'risk_events_list', + description: 'List recent risk events and alerts from the risk monitoring system.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max number of events to return (default 10)' }, + }, + required: [], + }, + }, + { + name: 'execution_plans_list', + description: 'List execution plans and their current approval/review status.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max number of plans to return (default 10)' }, + }, + required: [], + }, + }, + { + name: 'market_quotes_get', + description: 'Get current market quotes and price data for one or more stock symbols.', + inputSchema: { + type: 'object', + properties: { + symbols: { type: 'array', items: { type: 'string' }, description: 'List of ticker symbols e.g. ["AAPL", "NVDA"]' }, + }, + required: ['symbols'], + }, + }, + { + name: 'market_history_get', + description: 'Get historical OHLCV (Open/High/Low/Close/Volume) price data for a symbol.', + inputSchema: { + type: 'object', + properties: { + symbol: { type: 'string', description: 'Ticker symbol e.g. "AAPL"' }, + days: { type: 'number', description: 'Number of calendar days of history (default 30)' }, + }, + required: ['symbol'], + }, + }, +]; + +/** + * Map LLM tool name (underscore format) to executeAgentTool dot-format names. + */ +function llmToolNameToAgentTool(name) { + return name.replace(/_/g, '.').replace(/\.list$/, 's.list').replace(/\.get$/, '.get'); +} + +/** + * Execute a single tool call from LLM and return the result. + */ +async function executeLLMToolCall(toolName, toolInput) { + const dotName = (() => { + switch (toolName) { + case 'strategy_catalog_list': return 'strategy.catalog.list'; + case 'backtest_summary_get': return 'backtest.summary.get'; + case 'backtest_runs_list': return 'backtest.runs.list'; + case 'risk_events_list': return 'risk.events.list'; + case 'execution_plans_list': return 'execution.plans.list'; + case 'market_quotes_get': return 'market.quotes.get'; + case 'market_history_get': return 'market.history.get'; + default: return toolName; + } + })(); + + return executeAgentTool({ tool: dotName, args: toolInput || {} }); +} + +/** + * Serialize tool results for LLM context (keep it concise). + */ +function serializeToolResult(result) { + if (!result.ok) return `Error: ${result.summary}`; + const data = result.data || {}; + return JSON.stringify(data, null, 2).slice(0, 3000); +} + +/** + * Build the initial analysis prompt with intent context. + */ +function buildAnalysisPrompt(intent, dailyBias) { + const biasNote = dailyBias?.length + ? `\n\nActive daily bias instructions:\n${dailyBias.map((b) => `- ${b.body}`).join('\n')}` + : ''; + + return `Analyze the user's trading request and provide actionable recommendations. + +User's intent: ${intent.summary} +Intent kind: ${intent.kind} +${intent.extractedStrategy ? `Strategy description: ${intent.extractedStrategy.description}` : ''} +${intent.extractedTrade ? `Requested trade: ${intent.extractedTrade.side} ${intent.extractedTrade.symbol || 'unspecified'}` : ''} +${biasNote} + +Please use the available tools to gather relevant data, then provide your analysis in the required JSON format.`; +} + +/** + * Rule-based fallback narrative when LLM is unavailable. + */ +function buildFallbackNarrative(intent, toolResults = []) { + const resultMap = Object.fromEntries(toolResults.map((r) => [r.tool, r])); + const strategies = resultMap['strategy.catalog.list']?.data?.strategies || []; + const backtestSummary = resultMap['backtest.summary.get']?.data || {}; + const riskEvents = resultMap['risk.events.list']?.data?.events || []; + const executionPlans = resultMap['execution.plans.list']?.data?.plans || []; + + const elevatedRisk = riskEvents.some((e) => e.status === 'risk-off' || e.status === 'attention'); + const thesis = elevatedRisk + ? 'Risk posture is elevated. Review risk events before taking action.' + : `Analysis complete. Found ${strategies.length} strategies and ${Number(backtestSummary.completedRuns || 0)} completed backtests.`; + + return { + thesis, + rationale: [ + `${strategies.length} strategies available in catalog.`, + `${Number(backtestSummary.completedRuns || 0)} completed backtests tracked.`, + `${riskEvents.length} recent risk events checked.`, + ], + warnings: elevatedRisk ? ['Elevated risk events are active. Proceed with caution.'] : [], + recommendedNextStep: elevatedRisk + ? 'Review the risk console before requesting any action.' + : 'Refine your request for more specific analysis.', + requiresAction: false, + actionType: 'none', + }; +} + +/** + * Run the LLM tool-use loop: gather data → analyze → produce structured report. + * Max 5 tool call rounds to prevent runaway loops. + */ +async function runLLMAnalysisLoop(intent, dailyBias, sessionId) { + const llm = createLLMProvider(); + if (!llm) return null; + + const messages = [ + { role: 'user', content: buildAnalysisPrompt(intent, dailyBias) }, + ]; + + const toolCallLog = []; + const MAX_ROUNDS = 5; + + for (let round = 0; round < MAX_ROUNDS; round++) { + const response = await llm.chatWithTools(messages, LLM_TOOLS, { + systemPrompt: ANALYSIS_SYSTEM_PROMPT, + maxTokens: 4096, + temperature: 0.2, + }); + + if (!response.ok) { + console.error('[analysis-service] LLM error in round', round, response.error); + return null; + } + + // If LLM has tool calls, execute them and continue + if (response.stopReason === 'tool_use' && response.toolCalls?.length > 0) { + // Add assistant message with tool calls + const assistantContent = []; + if (response.content) { + assistantContent.push({ type: 'text', text: response.content }); + } + for (const tc of response.toolCalls) { + assistantContent.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input }); + } + messages.push({ role: 'assistant', content: assistantContent }); + + // Execute all tool calls and collect results + const toolResultContent = []; + for (const tc of response.toolCalls) { + const result = await executeLLMToolCall(tc.name, tc.input); + toolCallLog.push({ tool: tc.name, input: tc.input, result }); + + toolResultContent.push({ + type: 'tool_result', + tool_use_id: tc.id, + content: serializeToolResult(result), + }); + } + messages.push({ role: 'user', content: toolResultContent }); + continue; + } + + // LLM has stopped using tools — parse the final JSON response + const finalContent = response.content?.trim() || ''; + try { + // Handle markdown code blocks if LLM wraps JSON in ``` + const jsonStr = finalContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, ''); + const parsed = JSON.parse(jsonStr); + return { + narrative: { + thesis: parsed.thesis || 'Analysis complete.', + rationale: Array.isArray(parsed.rationale) ? parsed.rationale : [], + warnings: Array.isArray(parsed.warnings) ? parsed.warnings : [], + strategy: parsed.strategy || null, + recommendedNextStep: parsed.recommendedNextStep || '', + requiresAction: Boolean(parsed.requiresAction), + actionType: parsed.actionType || 'none', + }, + toolCallLog, + }; + } catch (parseErr) { + console.error('[analysis-service] Failed to parse LLM JSON response:', parseErr.message); + console.error('[analysis-service] Raw content:', finalContent.slice(0, 500)); + return null; + } + } + + console.error('[analysis-service] Exceeded max tool call rounds'); + return null; +} + +export async function runAgentAnalysis(payload = {}) { + const planned = payload.planId + ? { + ok: true, + session: payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null, + intent: payload.intent || null, + plan: controlPlaneRuntime.getAgentPlan(payload.planId), + } + : await createAgentPlan(payload); + + if (!planned.ok) return planned; + + const plan = planned.plan || controlPlaneRuntime.getAgentPlan(payload.planId); + const session = + planned.session || + (plan?.sessionId ? controlPlaneRuntime.getAgentSession(plan.sessionId) : null); + const intent = planned.intent || session?.latestIntent || null; + + if (!plan || !session || !intent) { + return { + ok: false, + error: 'missing_analysis_context', + message: 'Agent analysis requires a session, intent, and plan.', + }; + } + + // Mark session and plan as running + controlPlaneRuntime.updateAgentSession(session.id, { status: 'running' }); + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'system', + kind: 'analysis_status', + title: 'Analysis started', + body: 'Gathering data and reasoning with AI...', + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { agentPlanId: plan.id, status: 'running' }, + }); + controlPlaneRuntime.updateAgentPlan(plan.id, { + status: 'running', + steps: plan.steps.map((s) => ({ ...s, status: s.toolName ? 'running' : s.status })), + }); + + // Load daily bias instructions + const dailyBias = listActiveAgentInstructions({ sessionId: session.id, kind: 'daily_bias' }); + + // Run LLM analysis loop (with tool calls) + let analysisResult = await runLLMAnalysisLoop(intent, dailyBias, session.id); + + // Fallback: gather tool data the old way and use rule-based narrative + const toolResults = []; + if (!analysisResult) { + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'system', + kind: 'analysis_status', + title: 'Using rule-based analysis', + body: 'LLM unavailable. Using built-in analysis engine.', + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { agentPlanId: plan.id }, + }); + + for (const step of plan.steps) { + if (!step.toolName) continue; + const args = step.toolName === 'risk.events.list' ? { limit: 12 } + : step.toolName === 'execution.plans.list' ? { limit: 12 } + : step.toolName === 'backtest.runs.list' && intent.kind === 'request_backtest_review' ? { status: 'needs_review' } + : {}; + const result = await executeAgentTool({ tool: step.toolName, args }); + toolResults.push(result); + } + + analysisResult = { + narrative: buildFallbackNarrative(intent, toolResults), + toolCallLog: toolResults.map((r) => ({ tool: r.tool, input: {}, result: r })), + }; + } + + const { narrative, toolCallLog } = analysisResult; + + // Build tool call records for storage + const llmToolCalls = toolCallLog.map((entry) => ({ + tool: entry.tool, + status: entry.result?.ok ? 'completed' : 'failed', + summary: entry.result?.summary || '', + metadata: { dataKeys: Object.keys(entry.result?.data || {}) }, + })); + + const evidence = toolCallLog + .filter((entry) => entry.result?.ok) + .map((entry) => ({ + kind: 'tool_result', + title: entry.tool, + summary: entry.result?.summary || '', + source: entry.tool, + sourceId: entry.tool, + metadata: { keys: Object.keys(entry.result?.data || {}) }, + })); + + // Mark steps as completed + const finalizedSteps = plan.steps.map((step) => ({ + ...step, + status: 'completed', + outputSummary: step.kind === 'explain' ? narrative.thesis + : step.kind === 'request_action' ? narrative.recommendedNextStep + : llmToolCalls.find((tc) => tc.tool === step.toolName)?.summary || 'Completed.', + })); + + const planStatus = 'completed'; + const runStatus = 'completed'; + const completedAt = new Date().toISOString(); + + // Build the full explanation object (compatible with existing UI) + const explanation = { + thesis: narrative.thesis, + rationale: narrative.rationale, + warnings: narrative.warnings, + recommendedNextStep: narrative.recommendedNextStep, + strategy: narrative.strategy || null, + requiresAction: narrative.requiresAction, + actionType: narrative.actionType, + }; + + const run = controlPlaneRuntime.recordAgentAnalysisRun({ + sessionId: session.id, + planId: plan.id, + status: runStatus, + summary: narrative.thesis, + conclusion: narrative.thesis, + requestedBy: payload.requestedBy || session.requestedBy || 'operator', + toolCalls: llmToolCalls, + evidence, + explanation, + metadata: { + intentKind: intent.kind, + targetType: intent.targetType, + targetId: intent.targetId, + source: 'agent-analysis-llm', + hasStrategy: Boolean(narrative.strategy), + }, + completedAt, + }); + + const updatedPlan = controlPlaneRuntime.updateAgentPlan(plan.id, { + status: planStatus, + steps: finalizedSteps, + metadata: { latestAnalysisRunId: run.id }, + }); + const updatedSession = controlPlaneRuntime.updateAgentSession(session.id, { + status: 'completed', + latestAnalysisRunId: run.id, + metadata: { latestAnalysisCompletedAt: completedAt }, + }); + + // Record summarizing status before final result + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'system', + kind: 'analysis_status', + title: 'Summarizing findings', + body: 'Summarizing findings and preparing recommendations.', + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { agentPlanId: plan.id, agentAnalysisRunId: run.id }, + }); + + // Record the main assistant response message + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'assistant', + kind: 'analysis_result', + title: narrative.thesis, + body: [ + narrative.thesis, + ...(narrative.rationale || []), + ...(narrative.warnings || []), + narrative.recommendedNextStep ? `Next: ${narrative.recommendedNextStep}` : '', + ].filter(Boolean).join(' '), + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { + agentPlanId: plan.id, + agentAnalysisRunId: run.id, + status: runStatus, + toolCallCount: llmToolCalls.length, + hasStrategy: Boolean(narrative.strategy), + requiresAction: narrative.requiresAction, + actionType: narrative.actionType, + }, + }); + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'system', + kind: 'analysis_status', + title: plan.requiresApproval ? 'Ready for approval' : 'Analysis ready', + body: plan.requiresApproval + ? 'Ready for approval once you request a controlled handoff.' + : 'Analysis complete. Ask a follow-up or take action.', + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { + agentPlanId: plan.id, + agentAnalysisRunId: run.id, + requiresApproval: plan.requiresApproval, + }, + }); + + return { + ok: true, + session: updatedSession || session, + intent, + plan: updatedPlan || plan, + run, + }; +} diff --git a/apps/api/src/domains/agent/services/analysis-service.ts b/apps/api/src/domains/agent/services/analysis-service.ts deleted file mode 100644 index 09b2c2c..0000000 --- a/apps/api/src/domains/agent/services/analysis-service.ts +++ /dev/null @@ -1,392 +0,0 @@ -// @ts-nocheck -import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; -import { listActiveAgentInstructions } from './instruction-service.js'; -import { createAgentPlan } from './planning-service.js'; -import { executeAgentTool } from './tools-service.js'; - -function summarizeToolData(tool, data = {}) { - switch (tool) { - case 'strategy.catalog.list': - return `${Array.isArray(data.strategies) ? data.strategies.length : 0} strategy entries loaded.`; - case 'backtest.summary.get': - return `${Number(data.completedRuns || 0)} completed backtests and ${Number(data.reviewQueue || 0)} pending reviews.`; - case 'backtest.runs.list': - return `${Array.isArray(data.runs) ? data.runs.length : 0} backtest runs loaded.`; - case 'risk.events.list': - return `${Array.isArray(data.events) ? data.events.length : 0} risk events loaded.`; - case 'execution.plans.list': - return `${Array.isArray(data.plans) ? data.plans.length : 0} execution plans loaded.`; - default: - return 'Tool result loaded.'; - } -} - -function buildEvidenceFromToolResult(result) { - return { - kind: 'tool_result', - title: result.tool, - summary: result.summary, - source: result.tool, - sourceId: result.tool, - metadata: { - keys: Object.keys(result.data || {}), - }, - }; -} - -function buildAnalysisNarrative(intent, toolResults = []) { - const resultMap = Object.fromEntries(toolResults.map((item) => [item.tool, item])); - const backtestSummary = resultMap['backtest.summary.get']?.data || {}; - const backtestRuns = resultMap['backtest.runs.list']?.data?.runs || []; - const riskEvents = resultMap['risk.events.list']?.data?.events || []; - const executionPlans = resultMap['execution.plans.list']?.data?.plans || []; - const strategies = resultMap['strategy.catalog.list']?.data?.strategies || []; - - switch (intent.kind) { - case 'request_execution_prep': { - const targetStrategy = strategies.find((item) => item.id === intent.targetId) || null; - const existingPlans = executionPlans.filter((item) => item.strategyId === intent.targetId); - const reviewQueue = Number(backtestSummary.reviewQueue || 0); - const thesis = - existingPlans.length > 0 - ? 'Execution readiness needs review because the strategy already has persisted execution plans.' - : 'Execution readiness can be reviewed through the controlled action path.'; - const rationale = [ - targetStrategy - ? `Strategy ${targetStrategy.name || targetStrategy.id} is available in the strategy catalog.` - : 'No target strategy was matched from the prompt, so this remains a general execution-prep review.', - reviewQueue > 0 - ? `${reviewQueue} research items are still in review, so downstream execution should stay gated.` - : 'No outstanding research review queue was found in the backtest summary.', - existingPlans.length > 0 - ? `${existingPlans.length} execution plans already exist for this strategy.` - : 'No persisted execution plans were found for this strategy.', - ]; - const warnings = []; - if (reviewQueue > 0) - warnings.push( - 'Pending research reviews still need operator attention before action handoff.' - ); - if (existingPlans.length > 0) - warnings.push( - 'Avoid creating duplicate execution requests without reviewing existing plans.' - ); - return { - summary: thesis, - conclusion: thesis, - explanation: { - thesis, - rationale, - warnings, - recommendedNextStep: - 'If posture still looks acceptable, queue a controlled execution-plan request instead of direct execution.', - }, - }; - } - case 'request_risk_explanation': { - const elevatedEvents = riskEvents.filter( - (item) => item.status === 'risk-off' || item.status === 'attention' - ); - const thesis = - elevatedEvents.length > 0 - ? 'Risk posture is elevated and should be reviewed before any downstream action.' - : 'Risk posture looks stable from the current event feed.'; - return { - summary: thesis, - conclusion: thesis, - explanation: { - thesis, - rationale: [ - `${riskEvents.length} recent risk events were loaded from the control plane.`, - `${executionPlans.length} execution plans were checked for overlapping approval posture.`, - ], - warnings: - elevatedEvents.length > 0 - ? ['Recent elevated risk events are still active in the control plane.'] - : [], - recommendedNextStep: - elevatedEvents.length > 0 - ? 'Review the risk console and linked execution approvals before requesting action.' - : 'Continue with read-only review or prepare a controlled follow-up if new evidence appears.', - }, - }; - } - case 'request_backtest_review': { - const pendingRuns = backtestRuns.filter((item) => item.status === 'needs_review'); - const thesis = - pendingRuns.length > 0 - ? 'Backtest review backlog remains and should be cleared before promotion or execution prep.' - : 'Backtest posture looks stable from the current run summary.'; - return { - summary: thesis, - conclusion: thesis, - explanation: { - thesis, - rationale: [ - `${Number(backtestSummary.completedRuns || 0)} completed backtests are currently tracked.`, - `${pendingRuns.length} recent backtest runs still require manual review.`, - ], - warnings: pendingRuns.length > 0 ? ['Manual backtest review is still pending.'] : [], - recommendedNextStep: - pendingRuns.length > 0 - ? 'Review the pending run before promoting or preparing execution.' - : 'Use the result as supporting research context for the next controlled action.', - }, - }; - } - default: { - const thesis = - 'Read-only analysis completed using the current strategy and research context.'; - return { - summary: thesis, - conclusion: thesis, - explanation: { - thesis, - rationale: [ - `${strategies.length} strategies were available in the catalog snapshot.`, - `${Number(backtestSummary.completedRuns || 0)} completed backtests were visible in the summary feed.`, - ], - warnings: [], - recommendedNextStep: - 'Refine the prompt or create a more specific plan if a controlled follow-up is needed.', - }, - }; - } - } -} - -function resolveToolArgs(step = {}, intent = {}) { - if (step.toolName === 'risk.events.list') { - return { limit: 12 }; - } - if (step.toolName === 'execution.plans.list') { - return { limit: 12 }; - } - if (step.toolName === 'backtest.runs.list') { - return intent.kind === 'request_backtest_review' ? { status: 'needs_review' } : {}; - } - return {}; -} - -export function runAgentAnalysis(payload = {}) { - const planned = payload.planId - ? { - ok: true, - session: payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null, - intent: payload.intent || null, - plan: controlPlaneRuntime.getAgentPlan(payload.planId), - } - : createAgentPlan(payload); - - if (!planned.ok) { - return planned; - } - - const plan = planned.plan || controlPlaneRuntime.getAgentPlan(payload.planId); - const session = - planned.session || - (plan?.sessionId ? controlPlaneRuntime.getAgentSession(plan.sessionId) : null); - const intent = planned.intent || session?.latestIntent || null; - - if (!plan || !session || !intent) { - return { - ok: false, - error: 'missing_analysis_context', - message: 'Agent analysis requires a session, intent, and plan.', - }; - } - - const runningSteps = plan.steps.map((step) => ({ - ...step, - status: step.toolName ? 'running' : step.status, - })); - - controlPlaneRuntime.updateAgentSession(session.id, { - status: 'running', - }); - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'system', - kind: 'analysis_status', - title: 'Analysis started', - body: 'Intent parsed and plan execution started against allowlisted read-only tools.', - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - status: 'running', - }, - }); - controlPlaneRuntime.updateAgentPlan(plan.id, { - status: 'running', - steps: runningSteps, - }); - - const toolResults = []; - const completedSteps = runningSteps.map((step) => { - if (!step.toolName) { - return step; - } - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'system', - kind: 'analysis_status', - title: 'Reading tool context', - body: `Reading ${step.title}.`, - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - planStepId: step.id, - toolName: step.toolName, - }, - }); - const result = executeAgentTool({ - tool: step.toolName, - args: resolveToolArgs(step, intent), - }); - toolResults.push(result); - return { - ...step, - status: result.ok ? 'completed' : 'failed', - outputSummary: result.summary, - metadata: { - ...step.metadata, - executedTool: result.tool, - }, - }; - }); - - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'system', - kind: 'analysis_status', - title: 'Summarizing findings', - body: 'Summarizing tool findings into a structured recommendation.', - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - toolCallCount: toolResults.length, - }, - }); - - const narrative = buildAnalysisNarrative(intent, toolResults); - - const dailyBias = listActiveAgentInstructions({ sessionId: session.id, kind: 'daily_bias' }); - const biasSummary = dailyBias.map((item) => item.body).join(' '); - if (Array.isArray(narrative.explanation?.rationale)) { - narrative.explanation.rationale.push( - biasSummary - ? `Current daily bias: ${biasSummary}` - : 'No active daily bias is affecting this session.' - ); - } - const finalizedSteps = completedSteps.map((step) => { - if (step.kind === 'explain') { - return { - ...step, - status: 'completed', - outputSummary: narrative.explanation.thesis, - }; - } - if (step.kind === 'request_action') { - return { - ...step, - status: 'completed', - outputSummary: narrative.explanation.recommendedNextStep, - }; - } - return step; - }); - - const planStatus = finalizedSteps.some((step) => step.status === 'failed') - ? 'failed' - : 'completed'; - const runStatus = planStatus === 'failed' ? 'failed' : 'completed'; - const completedAt = new Date().toISOString(); - - const run = controlPlaneRuntime.recordAgentAnalysisRun({ - sessionId: session.id, - planId: plan.id, - status: runStatus, - summary: narrative.summary, - conclusion: narrative.conclusion, - requestedBy: payload.requestedBy || session.requestedBy || 'operator', - toolCalls: toolResults.map((item) => ({ - tool: item.tool, - status: item.ok ? 'completed' : 'failed', - summary: item.summary, - metadata: { - dataKeys: Object.keys(item.data || {}), - }, - })), - evidence: toolResults.map((item) => buildEvidenceFromToolResult(item)), - explanation: narrative.explanation, - metadata: { - intentKind: intent.kind, - targetType: intent.targetType, - targetId: intent.targetId, - source: 'agent-analysis-runner', - }, - completedAt, - }); - - const updatedPlan = controlPlaneRuntime.updateAgentPlan(plan.id, { - status: planStatus, - steps: finalizedSteps, - metadata: { - latestAnalysisRunId: run.id, - }, - }); - const updatedSession = controlPlaneRuntime.updateAgentSession(session.id, { - status: runStatus === 'completed' ? 'completed' : 'failed', - latestAnalysisRunId: run.id, - metadata: { - latestAnalysisCompletedAt: completedAt, - }, - }); - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'assistant', - kind: 'analysis_result', - title: narrative.explanation.thesis || 'Analysis completed', - body: [ - narrative.summary || '', - ...(Array.isArray(narrative.explanation?.rationale) ? narrative.explanation.rationale : []), - ...(Array.isArray(narrative.explanation?.warnings) ? narrative.explanation.warnings : []), - narrative.explanation?.recommendedNextStep - ? `Next step: ${narrative.explanation.recommendedNextStep}` - : '', - ] - .filter(Boolean) - .join(' '), - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - agentAnalysisRunId: run.id, - status: runStatus, - toolCallCount: toolResults.length, - }, - }); - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'system', - kind: 'analysis_status', - title: plan.requiresApproval ? 'Ready for approval' : 'Analysis ready', - body: plan.requiresApproval - ? 'Ready for approval once the operator requests a controlled handoff.' - : 'Analysis ready for the next follow-up question or read-only review.', - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - agentAnalysisRunId: run.id, - requiresApproval: plan.requiresApproval, - }, - }); - - return { - ok: true, - session: updatedSession || session, - intent, - plan: updatedPlan || plan, - run, - }; -} diff --git a/apps/api/src/domains/agent/services/intent-service.js b/apps/api/src/domains/agent/services/intent-service.js new file mode 100644 index 0000000..0450ea5 --- /dev/null +++ b/apps/api/src/domains/agent/services/intent-service.js @@ -0,0 +1,234 @@ +// @ts-nocheck +import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; +import { createLLMProvider } from '../../../../../../packages/llm-provider/src/index.js'; +import { INTENT_SYSTEM_PROMPT } from './prompts.js'; + +function normalizePrompt(prompt = '') { + return String(prompt || '') + .replace(/\s+/g, ' ') + .trim(); +} + +function createSessionTitle(prompt) { + const trimmed = normalizePrompt(prompt); + if (!trimmed) return 'Agent collaboration session'; + return trimmed.length > 72 ? `${trimmed.slice(0, 69)}...` : trimmed; +} + +/** + * Rule-based fallback intent inference (used when LLM is unavailable). + */ +function inferIntentFromRules(prompt, explicitTargetId = '') { + const normalized = prompt.toLowerCase(); + const urgency = /(urgent|immediately|asap|now|立刻|马上|尽快)/.test(normalized) + ? 'high' + : /(today|tomorrow|before open|开盘前|盘前)/.test(normalized) + ? 'normal' + : 'low'; + + if (/(buy|sell|购买|卖出|下单|买入|买|卖)/.test(normalized)) { + return { + kind: 'execute_trade', + summary: 'User wants to execute a trade.', + targetType: 'symbol', + targetId: explicitTargetId || '', + extractedTrade: { symbol: '', side: /sell|卖/.test(normalized) ? 'sell' : 'buy', sizeHint: 'unspecified' }, + urgency, + requiresApproval: false, + requestedMode: 'execute_paper', + confidence: 0.6, + metadata: { source: 'rule_fallback' }, + }; + } + + if (/(执行计划|准备执行|execution prep|prepare execution|execution plan|execution-prep|exec prep)/.test(normalized)) { + const requiresApproval = /(审批|approval|approve|需要审批|is.*approval|confirm.*approval)/.test(normalized); + // Try to extract a strategy ID from the prompt (kebab-case or dotted identifiers) + const idMatch = prompt.match(/[\w-]{4,}(?:[-.][\w-]+)+/); + const targetId = explicitTargetId || (idMatch ? idMatch[0] : ''); + return { + kind: 'request_execution_prep', + summary: 'Prepare an execution plan for a strategy.', + targetType: 'strategy', + targetId, + urgency, + requiresApproval, + requestedMode: requiresApproval ? 'request_live' : 'execute_paper', + confidence: 0.75, + metadata: { source: 'rule_fallback' }, + }; + } + + if (/(策略|strategy|构建|build|设计|create.*strat|momentum|value|mean reversion)/.test(normalized)) { + return { + kind: 'build_strategy', + summary: 'User wants to build a trading strategy.', + targetType: 'unknown', + targetId: explicitTargetId || '', + extractedStrategy: { description: prompt, symbols: [], style: 'general' }, + urgency, + requiresApproval: false, + requestedMode: 'read_only', + confidence: 0.6, + metadata: { source: 'rule_fallback' }, + }; + } + + if (/(回测|backtest|research|评估|evaluation|review run|review result)/.test(normalized)) { + return { + kind: 'request_backtest_review', + summary: 'Review research and backtest posture.', + targetType: 'backtest_run', + targetId: explicitTargetId || '', + urgency, + requiresApproval: false, + requestedMode: 'read_only', + confidence: 0.7, + metadata: { source: 'rule_fallback' }, + }; + } + + if (/(风控|risk|drawdown|回撤|compliance|explain risk|风险)/.test(normalized)) { + return { + kind: 'request_risk_explanation', + summary: 'Explain the current risk posture.', + targetType: 'unknown', + targetId: explicitTargetId || '', + urgency, + requiresApproval: false, + requestedMode: 'read_only', + confidence: 0.7, + metadata: { source: 'rule_fallback' }, + }; + } + + return { + kind: 'read_only_analysis', + summary: 'Read current platform context and summarize findings.', + targetType: 'unknown', + targetId: explicitTargetId || '', + urgency, + requiresApproval: false, + requestedMode: 'read_only', + confidence: 0.4, + metadata: { source: 'rule_fallback' }, + }; +} + +/** + * LLM-powered intent parsing with rule-based fallback. + */ +async function inferIntentWithLLM(prompt, explicitTargetId = '') { + const llm = createLLMProvider(); + if (!llm) { + return inferIntentFromRules(prompt, explicitTargetId); + } + + const contextNote = explicitTargetId + ? `\n\nContext: The user has pre-selected target ID: ${explicitTargetId}` + : ''; + + const response = await llm.chat( + [{ role: 'user', content: `User request: "${prompt}"${contextNote}` }], + { + systemPrompt: INTENT_SYSTEM_PROMPT, + maxTokens: 1024, + temperature: 0.1, + } + ); + + if (!response.ok) { + console.error('[intent-service] LLM error, falling back to rules:', response.error); + return inferIntentFromRules(prompt, explicitTargetId); + } + + try { + const parsed = JSON.parse(response.content.trim()); + return { + kind: parsed.kind || 'read_only_analysis', + summary: parsed.summary || prompt, + targetType: parsed.targetType || 'unknown', + targetId: parsed.targetId || explicitTargetId || '', + extractedStrategy: parsed.extractedStrategy || null, + extractedTrade: parsed.extractedTrade || null, + urgency: parsed.urgency || 'low', + requiresApproval: Boolean(parsed.requiresApproval), + requestedMode: parsed.requestedMode || 'read_only', + confidence: parsed.confidence || 0.8, + metadata: { source: 'llm', model: llm.model, provider: llm.provider }, + }; + } catch (parseErr) { + console.error('[intent-service] JSON parse error, falling back to rules:', parseErr.message); + return inferIntentFromRules(prompt, explicitTargetId); + } +} + +export async function parseAgentIntent(payload = {}) { + const prompt = normalizePrompt(payload.prompt); + if (!prompt) { + return { + ok: false, + error: 'missing_prompt', + message: 'Agent intent parsing requires a non-empty prompt.', + }; + } + + const requestedBy = payload.requestedBy || 'operator'; + const existingSession = payload.sessionId + ? controlPlaneRuntime.getAgentSession(payload.sessionId) + : null; + + const intent = await inferIntentWithLLM(prompt, payload.targetId || ''); + + const session = existingSession + ? controlPlaneRuntime.updateAgentSession(existingSession.id, { + prompt, + requestedBy, + title: existingSession.title || createSessionTitle(prompt), + status: 'ready', + latestIntent: intent, + metadata: { + intentParsedAt: new Date().toISOString(), + intentSource: intent.metadata?.source || 'unknown', + }, + }) + : controlPlaneRuntime.recordAgentSession({ + title: createSessionTitle(prompt), + prompt, + requestedBy, + status: 'ready', + latestIntent: intent, + metadata: { + source: 'agent-intent-parser', + intentSource: intent.metadata?.source || 'unknown', + }, + }); + + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'user', + kind: 'prompt', + title: 'Analysis request', + body: prompt, + requestedBy, + metadata: { source: 'agent-intent-parser' }, + }); + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'system', + kind: 'intent', + title: 'Intent parsed', + body: intent.summary, + requestedBy, + metadata: { + intentKind: intent.kind, + targetType: intent.targetType, + targetId: intent.targetId, + requestedMode: intent.requestedMode, + confidence: intent.confidence, + intentSource: intent.metadata?.source, + }, + }); + + return { ok: true, session, intent }; +} diff --git a/apps/api/src/domains/agent/services/intent-service.ts b/apps/api/src/domains/agent/services/intent-service.ts deleted file mode 100644 index 04652aa..0000000 --- a/apps/api/src/domains/agent/services/intent-service.ts +++ /dev/null @@ -1,296 +0,0 @@ -// @ts-nocheck -import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; -import { listBacktestRuns } from '../../backtest/services/runs-service.js'; -import { listExecutionPlans } from '../../execution/services/query-service.js'; -import { listRiskEvents } from '../../risk/services/feed-service.js'; -import { listStrategyCatalog } from '../../strategy/services/catalog-service.js'; - -function normalizePrompt(prompt = '') { - return String(prompt || '') - .replace(/\s+/g, ' ') - .trim(); -} - -function createSessionTitle(prompt) { - const trimmed = normalizePrompt(prompt); - if (!trimmed) return 'Agent collaboration session'; - return trimmed.length > 72 ? `${trimmed.slice(0, 69)}...` : trimmed; -} - -function findStrategyTarget(prompt, explicitTargetId = '') { - if (explicitTargetId) { - return { - targetType: 'strategy', - targetId: explicitTargetId, - }; - } - - const normalized = prompt.toLowerCase(); - const snapshot = listStrategyCatalog(); - const matched = snapshot.strategies.find( - (item) => - normalized.includes(item.id.toLowerCase()) || - normalized.includes(String(item.name || '').toLowerCase()) - ); - if (!matched) { - return { - targetType: 'unknown', - targetId: '', - }; - } - - return { - targetType: 'strategy', - targetId: matched.id, - }; -} - -function findBacktestTarget(prompt, explicitTargetId = '') { - if (explicitTargetId) { - return { - targetType: 'backtest_run', - targetId: explicitTargetId, - }; - } - - const normalized = prompt.toLowerCase(); - const snapshot = listBacktestRuns(); - const matched = snapshot.runs.find( - (item) => - normalized.includes(item.id.toLowerCase()) || - normalized.includes(String(item.strategyId || '').toLowerCase()) || - normalized.includes(String(item.strategyName || '').toLowerCase()) - ); - - return { - targetType: matched ? 'backtest_run' : 'unknown', - targetId: matched?.id || '', - }; -} - -function findExecutionTarget(prompt, explicitTargetId = '') { - if (explicitTargetId) { - return { - targetType: 'strategy', - targetId: explicitTargetId, - }; - } - - const fromStrategy = findStrategyTarget(prompt); - if (fromStrategy.targetId) { - return fromStrategy; - } - - const normalized = prompt.toLowerCase(); - const plans = listExecutionPlans(30); - const matchedPlan = plans.find( - (item) => - normalized.includes(item.id.toLowerCase()) || - normalized.includes(String(item.strategyId || '').toLowerCase()) || - normalized.includes(String(item.strategyName || '').toLowerCase()) - ); - if (matchedPlan) { - return { - targetType: 'execution_plan', - targetId: matchedPlan.id, - }; - } - - return { - targetType: 'unknown', - targetId: '', - }; -} - -function findRiskTarget(prompt, explicitTargetId = '') { - if (explicitTargetId) { - return { - targetType: 'risk_event', - targetId: explicitTargetId, - }; - } - - const normalized = prompt.toLowerCase(); - const events = listRiskEvents(30); - const matched = events.find( - (item) => - normalized.includes(item.id.toLowerCase()) || - normalized.includes(String(item.title || '').toLowerCase()) || - normalized.includes(String(item.message || '').toLowerCase()) - ); - if (matched) { - return { - targetType: 'risk_event', - targetId: matched.id, - }; - } - - return findStrategyTarget(prompt); -} - -function inferUrgency(prompt) { - const normalized = prompt.toLowerCase(); - if (/(urgent|immediately|asap|now|立刻|马上|尽快)/.test(normalized)) return 'high'; - if (/(today|tomorrow|before open|开盘前|盘前)/.test(normalized)) return 'normal'; - return 'low'; -} - -function inferIntentFromPrompt(prompt, explicitTargetId = '') { - const normalized = prompt.toLowerCase(); - const urgency = inferUrgency(prompt); - - if (/(回测|backtest|research|评估|evaluation|review run|review result)/.test(normalized)) { - const target = findBacktestTarget(prompt, explicitTargetId); - return { - kind: 'request_backtest_review', - summary: 'Review research and backtest posture before promoting or rerunning a strategy.', - targetType: target.targetType, - targetId: target.targetId, - urgency, - requiresApproval: false, - requestedMode: 'read_only', - metadata: { - matchedDomain: 'backtest', - }, - }; - } - - if ( - /(执行计划|execution plan|route|routing|下单|trade prep|prepare execution|执行准备|approve order)/.test( - normalized - ) - ) { - const target = findExecutionTarget(prompt, explicitTargetId); - return { - kind: 'request_execution_prep', - summary: - 'Prepare an execution-readiness review that can later become a controlled action request.', - targetType: target.targetType, - targetId: target.targetId, - urgency, - requiresApproval: true, - requestedMode: 'prepare_action', - metadata: { - matchedDomain: 'execution', - proposedActionRequestType: 'prepare_execution_plan', - }, - }; - } - - if (/(风控|risk|drawdown|回撤|compliance|explain risk|风险解释|风险说明)/.test(normalized)) { - const target = findRiskTarget(prompt, explicitTargetId); - return { - kind: 'request_risk_explanation', - summary: 'Explain the current risk posture and the control-plane signals behind it.', - targetType: target.targetType, - targetId: target.targetId, - urgency, - requiresApproval: false, - requestedMode: 'read_only', - metadata: { - matchedDomain: 'risk', - }, - }; - } - - if (/(scheduler|schedule|盘前|盘后|window|tick|调度|runbook)/.test(normalized)) { - return { - kind: 'request_scheduler_action', - summary: - 'Review scheduler posture and identify whether a controlled orchestration action is needed.', - targetType: explicitTargetId ? 'scheduler_window' : 'unknown', - targetId: explicitTargetId || '', - urgency, - requiresApproval: true, - requestedMode: 'prepare_action', - metadata: { - matchedDomain: 'scheduler', - }, - }; - } - - const target = findStrategyTarget(prompt, explicitTargetId); - return { - kind: 'read_only_analysis', - summary: 'Read current platform context and summarize the most relevant findings.', - targetType: target.targetType, - targetId: target.targetId, - urgency, - requiresApproval: false, - requestedMode: 'read_only', - metadata: { - matchedDomain: target.targetId ? 'strategy' : 'general', - }, - }; -} - -export function parseAgentIntent(payload = {}) { - const prompt = normalizePrompt(payload.prompt); - if (!prompt) { - return { - ok: false, - error: 'missing_prompt', - message: 'Agent intent parsing requires a non-empty prompt.', - }; - } - - const requestedBy = payload.requestedBy || 'operator'; - const existingSession = payload.sessionId - ? controlPlaneRuntime.getAgentSession(payload.sessionId) - : null; - const intent = inferIntentFromPrompt(prompt, payload.targetId || ''); - - const session = existingSession - ? controlPlaneRuntime.updateAgentSession(existingSession.id, { - prompt, - requestedBy, - title: existingSession.title || createSessionTitle(prompt), - status: 'ready', - latestIntent: intent, - metadata: { - intentParsedAt: new Date().toISOString(), - }, - }) - : controlPlaneRuntime.recordAgentSession({ - title: createSessionTitle(prompt), - prompt, - requestedBy, - status: 'ready', - latestIntent: intent, - metadata: { - source: 'agent-intent-parser', - }, - }); - - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'user', - kind: 'prompt', - title: 'Analysis request', - body: prompt, - requestedBy, - metadata: { - source: 'agent-intent-parser', - }, - }); - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'system', - kind: 'intent', - title: 'Intent parsed', - body: intent.summary, - requestedBy, - metadata: { - intentKind: intent.kind, - targetType: intent.targetType, - targetId: intent.targetId, - requestedMode: intent.requestedMode, - }, - }); - - return { - ok: true, - session, - intent, - }; -} diff --git a/apps/api/src/domains/agent/services/planning-service.js b/apps/api/src/domains/agent/services/planning-service.js new file mode 100644 index 0000000..6ffe150 --- /dev/null +++ b/apps/api/src/domains/agent/services/planning-service.js @@ -0,0 +1,192 @@ +// @ts-nocheck +import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; +import { createLLMProvider } from '../../../../../../packages/llm-provider/src/index.js'; +import { parseAgentIntent } from './intent-service.js'; +import { PLANNING_SYSTEM_PROMPT } from './prompts.js'; + +const AVAILABLE_TOOLS_DESCRIPTION = ` +Read tools: +- strategy.catalog.list: List all strategies +- backtest.summary.get: Get backtest statistics +- backtest.runs.list: List recent backtest runs +- risk.events.list: List risk events +- execution.plans.list: List execution plans +- market.quotes.get: Get current market quotes (args: {symbols: string[]}) +- market.history.get: Get historical OHLCV (args: {symbol: string, days: number}) + +Action tools: +- execution.paper.submit: Submit paper trade (args: {symbol, side, qty, orderType}) +- execution.live.request: Request live trade approval (args: {symbol, side, qty, rationale}) +- backtest.queue: Queue a backtest (args: {strategyDescription, symbols, days}) +`; + +/** + * Build hardcoded fallback steps when LLM is unavailable. + */ +function buildFallbackSteps(intent = {}) { + const baseReadSteps = [ + { kind: 'read', title: 'Load strategy catalog', toolName: 'strategy.catalog.list', description: 'Read strategy catalog.', outputSummary: '', metadata: { domain: 'strategy' } }, + { kind: 'read', title: 'Load backtest summary', toolName: 'backtest.summary.get', description: 'Check research posture.', outputSummary: '', metadata: { domain: 'backtest' } }, + ]; + + switch (intent.kind) { + case 'execute_trade': + return [ + { kind: 'read', title: 'Get market quote', toolName: 'market.quotes.get', description: 'Get current price for the target symbol.', outputSummary: '', metadata: { domain: 'market' } }, + { kind: 'read', title: 'Load risk events', toolName: 'risk.events.list', description: 'Check risk posture before execution.', outputSummary: '', metadata: { domain: 'risk' } }, + { kind: 'execute', title: 'Submit paper order', toolName: 'execution.paper.submit', description: 'Submit order to paper account.', outputSummary: '', metadata: { domain: 'execution' } }, + ]; + case 'build_strategy': + return [ + { kind: 'read', title: 'Load market context', toolName: 'market.quotes.get', description: 'Get current quotes for analysis.', outputSummary: '', metadata: { domain: 'market' } }, + { kind: 'read', title: 'Load strategy catalog', toolName: 'strategy.catalog.list', description: 'Check existing strategies.', outputSummary: '', metadata: { domain: 'strategy' } }, + { kind: 'explain', title: 'Build strategy plan', toolName: '', description: 'Generate strategy based on user description.', outputSummary: '', metadata: { deliverable: 'strategy-plan' } }, + ]; + case 'request_backtest_review': + return [ + { kind: 'read', title: 'Load backtest summary', toolName: 'backtest.summary.get', description: 'Read research summary.', outputSummary: '', metadata: { domain: 'backtest' } }, + { kind: 'read', title: 'Load recent runs', toolName: 'backtest.runs.list', description: 'Inspect run outcomes.', outputSummary: '', metadata: { domain: 'backtest' } }, + { kind: 'explain', title: 'Summarize research posture', toolName: '', description: 'Explain findings.', outputSummary: '', metadata: { deliverable: 'backtest-review' } }, + ]; + case 'request_risk_explanation': + return [ + { kind: 'read', title: 'Load risk events', toolName: 'risk.events.list', description: 'Inspect risk signals.', outputSummary: '', metadata: { domain: 'risk' } }, + { kind: 'read', title: 'Load execution posture', toolName: 'execution.plans.list', description: 'Correlate risk with execution.', outputSummary: '', metadata: { domain: 'execution' } }, + { kind: 'explain', title: 'Explain risk posture', toolName: '', description: 'Produce risk explanation.', outputSummary: '', metadata: { deliverable: 'risk-explanation' } }, + ]; + case 'request_execution_prep': + return [ + { kind: 'read', title: 'Load strategy catalog', toolName: 'strategy.catalog.list', description: 'Read strategy state and readiness.', outputSummary: '', metadata: { domain: 'strategy' } }, + { kind: 'read', title: 'Load execution plans', toolName: 'execution.plans.list', description: 'Check existing execution plans.', outputSummary: '', metadata: { domain: 'execution' } }, + { kind: 'read', title: 'Load risk events', toolName: 'risk.events.list', description: 'Review risk posture before prep.', outputSummary: '', metadata: { domain: 'risk' } }, + { kind: 'explain', title: 'Prepare execution readiness summary', toolName: '', description: 'Summarize execution readiness.', outputSummary: '', metadata: { deliverable: 'execution-prep' } }, + ]; + default: + return [ + ...baseReadSteps, + { kind: 'explain', title: 'Summarize findings', toolName: '', description: 'Prepare read-only analysis.', outputSummary: '', metadata: { deliverable: 'general-analysis' } }, + ]; + } +} + +/** + * Use LLM to generate dynamic plan steps based on intent. + */ +async function buildLLMSteps(intent = {}) { + const llm = createLLMProvider(); + if (!llm) { + return buildFallbackSteps(intent); + } + + const intentContext = ` +Intent kind: ${intent.kind} +Summary: ${intent.summary} +Target: ${intent.targetType} / ${intent.targetId || 'none'} +${intent.extractedStrategy ? `Strategy description: ${intent.extractedStrategy.description}` : ''} +${intent.extractedTrade ? `Trade: ${intent.extractedTrade.side} ${intent.extractedTrade.symbol || 'unspecified'}` : ''} +`; + + const response = await llm.chat( + [{ role: 'user', content: `Create execution steps for this intent:\n${intentContext}\n\nAvailable tools:\n${AVAILABLE_TOOLS_DESCRIPTION}` }], + { + systemPrompt: PLANNING_SYSTEM_PROMPT, + maxTokens: 1024, + temperature: 0.1, + } + ); + + if (!response.ok) { + console.error('[planning-service] LLM error, using fallback steps:', response.error); + return buildFallbackSteps(intent); + } + + try { + const rawSteps = JSON.parse(response.content.trim()); + if (!Array.isArray(rawSteps) || rawSteps.length === 0) { + return buildFallbackSteps(intent); + } + return rawSteps.map((step) => ({ + kind: step.kind || 'read', + title: step.title || 'Step', + toolName: step.toolName || '', + description: step.description || '', + outputSummary: '', + status: 'pending', + metadata: step.metadata || {}, + })); + } catch (parseErr) { + console.error('[planning-service] JSON parse error, using fallback steps:', parseErr.message); + return buildFallbackSteps(intent); + } +} + +function buildPlanSummary(intent = {}) { + switch (intent.kind) { + case 'execute_trade': return `Execute a ${intent.extractedTrade?.side || 'trade'} order${intent.extractedTrade?.symbol ? ` for ${intent.extractedTrade.symbol}` : ''}.`; + case 'build_strategy': return 'Build and analyze a new trading strategy based on user description.'; + case 'request_backtest_review': return 'Review recent research outputs and backtest posture.'; + case 'request_execution_prep': return 'Prepare execution readiness review for a controlled action request.'; + case 'request_risk_explanation': return 'Explain current risk posture using recent signals.'; + default: return 'Read current platform context and prepare a concise analysis.'; + } +} + +export async function createAgentPlan(payload = {}) { + const parsed = payload.intent + ? { ok: true, session: payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null, intent: payload.intent } + : await parseAgentIntent(payload); + + if (!parsed.ok) return parsed; + + const session = + parsed.session || + (payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null); + + if (!session) { + return { ok: false, error: 'missing_session', message: 'Agent planning requires a persisted session.' }; + } + + const intent = parsed.intent; + const steps = await buildLLMSteps(intent); + const requiresApproval = intent.requiresApproval || intent.requestedMode === 'request_live'; + + const plan = controlPlaneRuntime.recordAgentPlan({ + sessionId: session.id, + status: 'ready', + summary: buildPlanSummary(intent), + requiresApproval, + requestedBy: payload.requestedBy || session.requestedBy || 'operator', + steps: steps.map((s) => ({ ...s, status: 'pending' })), + metadata: { + intentKind: intent.kind, + targetType: intent.targetType, + targetId: intent.targetId, + requestedMode: intent.requestedMode, + source: 'agent-planner-llm', + confidence: intent.confidence, + }, + }); + + const updatedSession = controlPlaneRuntime.updateAgentSession(session.id, { + status: 'ready', + latestIntent: intent, + latestPlanId: plan.id, + metadata: { planCreatedAt: plan.createdAt }, + }); + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'assistant', + kind: 'plan', + title: 'Plan prepared', + body: buildPlanSummary(intent), + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { + agentPlanId: plan.id, + requiresApproval: plan.requiresApproval, + stepCount: plan.steps.length, + intentKind: intent.kind, + }, + }); + + return { ok: true, session: updatedSession || session, intent, plan }; +} diff --git a/apps/api/src/domains/agent/services/planning-service.ts b/apps/api/src/domains/agent/services/planning-service.ts deleted file mode 100644 index 58e6f26..0000000 --- a/apps/api/src/domains/agent/services/planning-service.ts +++ /dev/null @@ -1,291 +0,0 @@ -// @ts-nocheck -import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; -import { parseAgentIntent } from './intent-service.js'; - -function buildPlanSteps(intent = {}) { - switch (intent.kind) { - case 'request_backtest_review': - return [ - { - kind: 'read', - title: 'Load backtest center summary', - status: 'pending', - toolName: 'backtest.summary.get', - description: 'Read the research summary before inspecting individual runs.', - outputSummary: '', - metadata: { - domain: 'backtest', - }, - }, - { - kind: 'read', - title: 'Load recent backtest runs', - status: 'pending', - toolName: 'backtest.runs.list', - description: 'Inspect recent run outcomes and review posture.', - outputSummary: '', - metadata: { - domain: 'backtest', - }, - }, - { - kind: 'explain', - title: 'Summarize research posture', - status: 'pending', - toolName: '', - description: 'Explain what is healthy, blocked, or needs operator review.', - outputSummary: '', - metadata: { - deliverable: 'backtest-review-summary', - }, - }, - ]; - case 'request_execution_prep': - return [ - { - kind: 'read', - title: 'Load strategy catalog context', - status: 'pending', - toolName: 'strategy.catalog.list', - description: - 'Read lifecycle stage, readiness, and research posture for the target strategy.', - outputSummary: '', - metadata: { - domain: 'strategy', - }, - }, - { - kind: 'read', - title: 'Load backtest center summary', - status: 'pending', - toolName: 'backtest.summary.get', - description: 'Confirm recent research activity and pending reviews.', - outputSummary: '', - metadata: { - domain: 'backtest', - }, - }, - { - kind: 'read', - title: 'Load execution plan posture', - status: 'pending', - toolName: 'execution.plans.list', - description: 'Check whether the strategy already has active or blocked execution plans.', - outputSummary: '', - metadata: { - domain: 'execution', - }, - }, - { - kind: 'request_action', - title: 'Prepare controlled action handoff', - status: 'pending', - toolName: '', - description: - 'If posture is acceptable, recommend a gated action request instead of direct execution.', - outputSummary: '', - metadata: { - proposedActionRequestType: 'prepare_execution_plan', - }, - }, - ]; - case 'request_risk_explanation': - return [ - { - kind: 'read', - title: 'Load recent risk events', - status: 'pending', - toolName: 'risk.events.list', - description: 'Inspect the most recent risk signals and review-level alerts.', - outputSummary: '', - metadata: { - domain: 'risk', - }, - }, - { - kind: 'read', - title: 'Load execution plan posture', - status: 'pending', - toolName: 'execution.plans.list', - description: - 'Correlate active approvals and execution posture with current risk signals.', - outputSummary: '', - metadata: { - domain: 'execution', - }, - }, - { - kind: 'explain', - title: 'Explain current risk posture', - status: 'pending', - toolName: '', - description: 'Produce a concise explanation with warnings and next-step guidance.', - outputSummary: '', - metadata: { - deliverable: 'risk-explanation', - }, - }, - ]; - case 'request_scheduler_action': - return [ - { - kind: 'read', - title: 'Load recent risk signals', - status: 'pending', - toolName: 'risk.events.list', - description: 'Use current risk posture as context for scheduler review.', - outputSummary: '', - metadata: { - domain: 'risk', - }, - }, - { - kind: 'read', - title: 'Load execution plan posture', - status: 'pending', - toolName: 'execution.plans.list', - description: - 'Check whether approvals or blocked plans coincide with scheduler attention.', - outputSummary: '', - metadata: { - domain: 'execution', - }, - }, - { - kind: 'request_action', - title: 'Recommend scheduler runbook action', - status: 'pending', - toolName: '', - description: - 'Produce a reviewed scheduler recommendation instead of directly mutating scheduler state.', - outputSummary: '', - metadata: { - proposedActionRequestType: 'scheduler_review', - }, - }, - ]; - default: - return [ - { - kind: 'read', - title: 'Load strategy catalog context', - status: 'pending', - toolName: 'strategy.catalog.list', - description: 'Read the current strategy catalog and lifecycle posture.', - outputSummary: '', - metadata: { - domain: 'strategy', - }, - }, - { - kind: 'read', - title: 'Load backtest center summary', - status: 'pending', - toolName: 'backtest.summary.get', - description: 'Read current research coverage and backlog posture.', - outputSummary: '', - metadata: { - domain: 'backtest', - }, - }, - { - kind: 'explain', - title: 'Summarize findings', - status: 'pending', - toolName: '', - description: 'Prepare a concise read-only analysis with next-step suggestions.', - outputSummary: '', - metadata: { - deliverable: 'general-analysis', - }, - }, - ]; - } -} - -function buildPlanSummary(intent = {}) { - switch (intent.kind) { - case 'request_backtest_review': - return 'Review recent research outputs and explain whether a backtest needs operator attention.'; - case 'request_execution_prep': - return 'Check research, execution, and approval posture before preparing a controlled execution request.'; - case 'request_risk_explanation': - return 'Explain the current risk posture using recent risk and execution signals.'; - case 'request_scheduler_action': - return 'Review scheduler-adjacent posture and prepare a controlled recommendation.'; - default: - return 'Read current platform context and prepare a concise analysis plan.'; - } -} - -export function createAgentPlan(payload = {}) { - const parsed = payload.intent - ? { - ok: true, - session: payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null, - intent: payload.intent, - } - : parseAgentIntent(payload); - - if (!parsed.ok) { - return parsed; - } - - const session = - parsed.session || - (payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null); - if (!session) { - return { - ok: false, - error: 'missing_session', - message: 'Agent planning requires a persisted session.', - }; - } - - const intent = parsed.intent; - const plan = controlPlaneRuntime.recordAgentPlan({ - sessionId: session.id, - status: 'ready', - summary: buildPlanSummary(intent), - requiresApproval: intent.requiresApproval, - requestedBy: payload.requestedBy || session.requestedBy || 'operator', - steps: buildPlanSteps(intent), - metadata: { - intentKind: intent.kind, - targetType: intent.targetType, - targetId: intent.targetId, - requestedMode: intent.requestedMode, - source: 'agent-planner', - }, - }); - - const updatedSession = controlPlaneRuntime.updateAgentSession(session.id, { - status: 'ready', - latestIntent: intent, - latestPlanId: plan.id, - metadata: { - planCreatedAt: plan.createdAt, - }, - }); - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'assistant', - kind: 'plan', - title: 'Plan prepared', - body: buildPlanSummary(intent), - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - requiresApproval: plan.requiresApproval, - stepCount: plan.steps.length, - intentKind: intent.kind, - }, - }); - - return { - ok: true, - session: updatedSession || session, - intent, - plan, - }; -} diff --git a/apps/api/src/domains/agent/services/prompts.js b/apps/api/src/domains/agent/services/prompts.js new file mode 100644 index 0000000..1aeeeed --- /dev/null +++ b/apps/api/src/domains/agent/services/prompts.js @@ -0,0 +1,119 @@ +// @ts-nocheck +/** + * LLM system prompts for the Agent analysis pipeline. + * Centralized here for easy tuning and A/B testing. + */ + +export const INTENT_SYSTEM_PROMPT = `You are an AI assistant embedded in QuantPilot, a personal quantitative trading platform for individual investors. + +Your role is to understand what the user wants to do with their portfolio and classify their intent into structured actions. + +The user may NOT have professional finance or trading knowledge. Your job is to understand their natural language goals and translate them into platform actions. + +Available intent kinds: +- "build_strategy": User wants to create/define a new trading strategy using natural language description +- "execute_trade": User wants to buy or sell specific stocks directly +- "request_backtest_review": User wants to review backtest results or research analysis +- "request_execution_prep": User wants to prepare an execution plan for a strategy +- "request_risk_explanation": User wants to understand their current risk situation +- "read_only_analysis": General analysis, market overview, portfolio review + +Response format (JSON only, no markdown): +{ + "kind": "", + "summary": "", + "targetType": "", + "targetId": "", + "extractedStrategy": { + "description": "", + "symbols": [""], + "style": "" + }, + "extractedTrade": { + "symbol": "", + "side": "", + "sizeHint": "" + }, + "urgency": "", + "requiresApproval": , + "requestedMode": "", + "confidence": <0.0 to 1.0> +} + +Rules: +- requiresApproval: true for execute_trade (live), request_execution_prep. false for read-only intents. +- requestedMode: "execute_paper" for paper trading. "request_live" for live trading requests (needs approval). +- If the user says "buy" or "sell" without saying "paper" or "live", default to "execute_paper". +- confidence: your confidence in the classification (0.0-1.0). +- Always respond with valid JSON only.`; + +export const PLANNING_SYSTEM_PROMPT = `You are a trading AI assistant. Given a parsed user intent, create an execution plan with specific steps. + +Available read tools: +- strategy.catalog.list: List all strategies in the catalog +- backtest.summary.get: Get backtest center summary statistics +- backtest.runs.list: List recent backtest runs with metrics +- risk.events.list: List recent risk events and alerts +- execution.plans.list: List execution plans and approval status +- market.quotes.get: Get current market quotes for symbols +- market.history.get: Get historical OHLCV data for a symbol + +Available action tools (require approval for live): +- execution.paper.submit: Submit a paper trading order immediately +- execution.live.request: Request a live trading order (needs operator approval) +- backtest.queue: Queue a new backtest run for a strategy + +Rules for step planning: +- Always start with relevant read steps to gather context +- For build_strategy: include market.quotes.get and backtest.queue steps +- For execute_trade: include market.quotes.get for price check, then execution tool +- For analysis: only use read tools +- Keep steps minimal (2-4 steps) and focused + +Response format (JSON array only, no markdown): +[ + { + "kind": "", + "title": "", + "toolName": "", + "description": "", + "metadata": {} + } +]`; + +export const ANALYSIS_SYSTEM_PROMPT = `You are QuantPilot AI, a trading assistant for individual investors who may not have professional finance knowledge. + +Your job is to analyze the gathered data and produce clear, actionable insights. + +Guidelines: +- Use plain language. Avoid jargon. Explain what things mean. +- Be direct about risks. Do not sugarcoat danger signals. +- When suggesting trades, always mention the risk and that past performance doesn't guarantee future results. +- Structure your response as a clear recommendation. + +Response format (JSON only, no markdown): +{ + "thesis": "", + "rationale": [ + "", + "", + "" + ], + "warnings": [ + "" + ], + "strategy": { + "name": "", + "description": "", + "symbols": [""], + "riskLevel": "", + "suggestedPositionSizePercent": <1-10>, + "expectedHoldingPeriod": "" + }, + "recommendedNextStep": "", + "requiresAction": , + "actionType": "" +} + +The "strategy" field is only required for build_strategy and execute_trade intents. +For analysis/review intents, omit the strategy field.`; diff --git a/apps/api/src/domains/agent/services/tools-service.ts b/apps/api/src/domains/agent/services/tools-service.ts index 5fa5c65..abd8de2 100644 --- a/apps/api/src/domains/agent/services/tools-service.ts +++ b/apps/api/src/domains/agent/services/tools-service.ts @@ -1,12 +1,16 @@ // @ts-nocheck +import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; import { listBacktestRuns } from '../../backtest/services/runs-service.js'; import { getBacktestSummary } from '../../backtest/services/summary-service.js'; import { listExecutionPlans } from '../../execution/services/query-service.js'; +import { getHistoricalBars } from '../../market/services/market-data-service.js'; +import { assessExecutionCandidate } from '../../risk/services/assessment-service.js'; import { listRiskEvents } from '../../risk/services/feed-service.js'; import { listStrategyCatalog } from '../../strategy/services/catalog-service.js'; const AGENT_TOOLS = [ + // Read tools { name: 'strategy.catalog.list', category: 'strategy', @@ -37,25 +41,54 @@ const AGENT_TOOLS = [ description: 'Read persisted execution plans and their current review state.', access: 'read', }, + { + name: 'market.quotes.get', + category: 'market', + description: 'Get current market quotes for symbols.', + access: 'read', + }, + { + name: 'market.history.get', + category: 'market', + description: 'Get historical OHLCV data for a symbol.', + access: 'read', + }, + // Write tools (paper = auto-execute, live = requires approval) + { + name: 'execution.paper.submit', + category: 'execution', + description: 'Submit a paper trading order immediately (no approval required).', + access: 'write', + mode: 'paper', + }, + { + name: 'execution.live.request', + category: 'execution', + description: 'Request a live trading order (requires operator approval).', + access: 'write', + mode: 'live', + }, + { + name: 'backtest.queue', + category: 'backtest', + description: 'Queue a new backtest run for a strategy description.', + access: 'write', + }, ]; export function listAgentTools() { - return { - ok: true, - tools: AGENT_TOOLS, - }; + return { ok: true, tools: AGENT_TOOLS.filter((t) => t.access === 'read') }; } +// ─── Read Tools ─────────────────────────────────────────────────────────────── + function executeStrategyCatalogTool() { const snapshot = listStrategyCatalog(); return { ok: true, tool: 'strategy.catalog.list', summary: `Loaded ${snapshot.strategies.length} strategy catalog entries.`, - data: { - asOf: snapshot.asOf, - strategies: snapshot.strategies, - }, + data: { asOf: snapshot.asOf, strategies: snapshot.strategies }, }; } @@ -77,10 +110,7 @@ function executeBacktestRunsTool(args = {}) { ok: true, tool: 'backtest.runs.list', summary: `Loaded ${runs.length} backtest runs${status ? ` with status ${status}` : ''}.`, - data: { - asOf: snapshot.asOf, - runs, - }, + data: { asOf: snapshot.asOf, runs }, }; } @@ -91,9 +121,7 @@ function executeRiskEventsTool(args = {}) { ok: true, tool: 'risk.events.list', summary: `Loaded ${events.length} risk events.`, - data: { - events, - }, + data: { events }, }; } @@ -104,13 +132,271 @@ function executeExecutionPlansTool(args = {}) { ok: true, tool: 'execution.plans.list', summary: `Loaded ${plans.length} execution plans.`, + data: { plans }, + }; +} + +function executeMarketQuotesTool(args = {}) { + const symbols = Array.isArray(args.symbols) ? args.symbols : []; + if (symbols.length === 0) { + return { ok: false, tool: 'market.quotes.get', summary: 'No symbols provided.', data: {} }; + } + // Return from control plane state (real-time data from Worker sync) + const state = controlPlaneRuntime.getLatestSystemState + ? controlPlaneRuntime.getLatestSystemState() + : null; + const stockStates = state?.stockStates || []; + const quotes = symbols.map((sym) => { + const found = stockStates.find((s) => s.symbol?.toUpperCase() === sym.toUpperCase()); + if (found) { + return { + symbol: sym.toUpperCase(), + price: found.price || 0, + change: found.change || 0, + changePct: found.changePct || 0, + volume: found.volume || 0, + signal: found.signal || 'hold', + score: found.score || 0, + }; + } + return { + symbol: sym.toUpperCase(), + price: null, + signal: 'unknown', + note: 'Not in current universe', + }; + }); + return { + ok: true, + tool: 'market.quotes.get', + summary: `Loaded quotes for ${symbols.join(', ')}.`, + data: { quotes, asOf: new Date().toISOString() }, + }; +} + +async function executeMarketHistoryTool(args = {}) { + const symbol = args.symbol || ''; + const days = Math.min(Number(args.days) || 90, 365); + if (!symbol) { + return { ok: false, tool: 'market.history.get', summary: 'Symbol is required.', data: {} }; + } + const result = await getHistoricalBars(symbol, days); + return { + ok: result.ok, + tool: 'market.history.get', + summary: `Loaded ${result.bars?.length || 0} bars for ${symbol} (${days} days, source: ${result.source}).`, + data: { + symbol: result.symbol, + bars: result.bars || [], + source: result.source, + timeframe: result.timeframe || '1Day', + }, + }; +} + +// ─── Write Tools ────────────────────────────────────────────────────────────── + +function executePaperOrderTool(args = {}) { + const { symbol, side, qty, orderType = 'market', price = null, rationale = '' } = args; + + if (!symbol || !side || !qty || qty <= 0) { + return { + ok: false, + tool: 'execution.paper.submit', + summary: 'Missing required fields: symbol, side, qty.', + data: {}, + }; + } + + const normalizedSide = side.toLowerCase(); + if (!['buy', 'sell'].includes(normalizedSide)) { + return { + ok: false, + tool: 'execution.paper.submit', + summary: 'side must be buy or sell.', + data: {}, + }; + } + + const capital = price ? qty * price : qty * 100; + const candidate = { + strategyId: `agent-paper-${symbol.toLowerCase()}-${normalizedSide}`, + strategyName: `Agent Paper ${normalizedSide.toUpperCase()} ${symbol}`, + mode: 'paper', + capital, + status: 'paper', + metrics: { score: 60, expectedReturnPct: 8, maxDrawdownPct: 8, sharpe: 1.2 }, + orders: [ + { + symbol: symbol.toUpperCase(), + side: normalizedSide.toUpperCase(), + weight: 1.0, + qty: Number(qty), + price: price || null, + orderType, + rationale: rationale || `Agent paper ${normalizedSide} ${qty} ${symbol}`, + }, + ], + summary: `Agent Paper ${normalizedSide.toUpperCase()} ${qty} ${symbol}${price ? ` @ $${price}` : ''}`, + metadata: { source: 'agent-paper-submit', requestedBy: 'agent' }, + }; + + // Risk assessment before executing + const riskAssessment = assessExecutionCandidate(candidate); + + if (riskAssessment.riskStatus === 'blocked') { + return { + ok: false, + tool: 'execution.paper.submit', + summary: `Order blocked by risk assessment: ${riskAssessment.summary}`, + data: { riskStatus: 'blocked', riskSummary: riskAssessment.summary }, + }; + } + + const handoff = { + id: `handoff-agent-paper-${Date.now()}`, + strategyId: candidate.strategyId, + strategyName: candidate.strategyName, + mode: 'paper', + capital: candidate.capital, + orders: candidate.orders, + summary: candidate.summary, + riskStatus: riskAssessment.riskStatus, + approvalState: 'auto_approved', + riskSummary: riskAssessment.summary, + metadata: candidate.metadata, + createdAt: new Date().toISOString(), + }; + + controlPlaneRuntime.appendExecutionCandidateHandoff(handoff); + + return { + ok: true, + tool: 'execution.paper.submit', + summary: `Paper order submitted: ${candidate.summary}. Risk: ${riskAssessment.riskStatus}.`, + data: { + handoffId: handoff.id, + order: candidate.orders[0], + riskStatus: riskAssessment.riskStatus, + mode: 'paper', + }, + }; +} + +function executeLiveOrderRequestTool(args = {}) { + const { symbol, side, qty, orderType = 'market', price = null, rationale = '' } = args; + + if (!symbol || !side || !qty || qty <= 0) { + return { + ok: false, + tool: 'execution.live.request', + summary: 'Missing required fields: symbol, side, qty.', + data: {}, + }; + } + + const normalizedSide = side.toLowerCase(); + const capital = price ? qty * price : qty * 100; + + const candidate = { + strategyId: `agent-live-${symbol.toLowerCase()}-${normalizedSide}`, + strategyName: `Agent Live ${normalizedSide.toUpperCase()} ${symbol}`, + mode: 'live', + capital, + status: 'live', + metrics: { score: 60, expectedReturnPct: 8, maxDrawdownPct: 8, sharpe: 1.2 }, + orders: [ + { + symbol: symbol.toUpperCase(), + side: normalizedSide.toUpperCase(), + weight: 1.0, + qty: Number(qty), + price: price || null, + orderType, + rationale: rationale || `Agent live ${normalizedSide} ${qty} ${symbol}`, + }, + ], + summary: `Agent Live ${normalizedSide.toUpperCase()} ${qty} ${symbol}${price ? ` @ $${price}` : ''}`, + metadata: { source: 'agent-live-request', requestedBy: 'agent', requiresApproval: true }, + }; + + const riskAssessment = assessExecutionCandidate(candidate); + + // Live orders always go into the approval queue + const handoff = { + id: `handoff-agent-live-${Date.now()}`, + strategyId: candidate.strategyId, + strategyName: candidate.strategyName, + mode: 'live', + capital: candidate.capital, + orders: candidate.orders, + summary: candidate.summary, + riskStatus: riskAssessment.riskStatus, + approvalState: 'pending_approval', + riskSummary: riskAssessment.summary, + metadata: candidate.metadata, + createdAt: new Date().toISOString(), + }; + + controlPlaneRuntime.appendExecutionCandidateHandoff(handoff); + + return { + ok: true, + tool: 'execution.live.request', + summary: `Live order request submitted for operator approval: ${candidate.summary}.`, + data: { + handoffId: handoff.id, + order: candidate.orders[0], + approvalState: 'pending_approval', + mode: 'live', + message: + 'This order requires your manual approval before execution. Check the Execution page.', + }, + }; +} + +function executeBacktestQueueTool(args = {}) { + const { strategyDescription = '', symbols = [], days = 90 } = args; + if (!strategyDescription) { + return { + ok: false, + tool: 'backtest.queue', + summary: 'strategyDescription is required.', + data: {}, + }; + } + + const strategyId = `agent-backtest-${Date.now()}`; + // Queue a backtest workflow + controlPlaneRuntime.recordWorkflowRun?.({ + kind: 'task-orchestrator.backtest-run', + status: 'pending', + payload: { + strategyId, + strategyDescription, + symbols: symbols.length > 0 ? symbols : ['AAPL', 'MSFT', 'NVDA'], + days, + requestedBy: 'agent', + }, + }); + + return { + ok: true, + tool: 'backtest.queue', + summary: `Backtest queued for: "${strategyDescription.slice(0, 80)}".`, data: { - plans, + strategyId, + strategyDescription, + symbols: symbols.length > 0 ? symbols : ['AAPL', 'MSFT', 'NVDA'], + days, + status: 'queued', }, }; } -export function executeAgentTool(payload = {}) { +// ─── Main dispatcher ────────────────────────────────────────────────────────── + +export async function executeAgentTool(payload = {}) { const tool = payload.tool || ''; const args = payload.args || {}; @@ -125,11 +411,21 @@ export function executeAgentTool(payload = {}) { return executeRiskEventsTool(args); case 'execution.plans.list': return executeExecutionPlansTool(args); + case 'market.quotes.get': + return executeMarketQuotesTool(args); + case 'market.history.get': + return executeMarketHistoryTool(args); + case 'execution.paper.submit': + return executePaperOrderTool(args); + case 'execution.live.request': + return executeLiveOrderRequestTool(args); + case 'backtest.queue': + return executeBacktestQueueTool(args); default: return { ok: false, tool, - summary: `Tool ${tool || 'unknown'} is not allowed for Agent access.`, + summary: `Tool ${tool || 'unknown'} is not registered for Agent access.`, data: {}, }; } diff --git a/apps/api/src/domains/backtest/services/runs-service.ts b/apps/api/src/domains/backtest/services/runs-service.ts index 7bf8efd..c3aa33b 100644 --- a/apps/api/src/domains/backtest/services/runs-service.ts +++ b/apps/api/src/domains/backtest/services/runs-service.ts @@ -5,7 +5,7 @@ import { getStrategyCatalogItem } from '../../strategy/services/catalog-service. import { refreshBacktestSummary } from './summary-service.js'; function buildBacktestResultFromRun(run, patch = {}) { - if (!run || !run.completedAt) return null; + if (!run?.completedAt) return null; return controlPlaneRuntime.appendBacktestResult({ runId: run.id, workflowRunId: run.workflowRunId || '', diff --git a/apps/api/src/domains/market/services/market-data-service.js b/apps/api/src/domains/market/services/market-data-service.js new file mode 100644 index 0000000..5198de3 --- /dev/null +++ b/apps/api/src/domains/market/services/market-data-service.js @@ -0,0 +1,147 @@ +// @ts-nocheck +/** + * Market data service — fetches from Alpaca when configured, falls back to + * trading-engine synthetic data when QUANTPILOT_USE_MOCK_DATA=true or no credentials. + */ +import { generateHistoricalOhlcv } from '../../../../../../packages/trading-engine/src/backtest/data.js'; + +const ALPACA_DATA_BASE = 'https://data.alpaca.markets'; +const ALPACA_KEY_ID = () => process.env.ALPACA_KEY_ID || ''; +const ALPACA_SECRET_KEY = () => process.env.ALPACA_SECRET_KEY || ''; +const ALPACA_DATA_FEED = () => process.env.ALPACA_DATA_FEED || 'iex'; +const USE_MOCK = () => + process.env.QUANTPILOT_USE_MOCK_DATA === 'true' || + !ALPACA_KEY_ID() || + !ALPACA_SECRET_KEY(); + +function alpacaHeaders() { + return { + Accept: 'application/json', + 'APCA-API-KEY-ID': ALPACA_KEY_ID(), + 'APCA-API-SECRET-KEY': ALPACA_SECRET_KEY(), + }; +} + +function normalizeAlpacaBar(bar) { + return { + time: bar.t ? bar.t.split('T')[0] : '', + open: Number(bar.o || 0), + high: Number(bar.h || 0), + low: Number(bar.l || 0), + close: Number(bar.c || 0), + volume: Number(bar.v || 0), + }; +} + +/** + * Get historical OHLCV bars for a symbol. + * Uses Alpaca API when credentials are configured, otherwise synthetic data. + * + * @param {string} symbol - Ticker symbol e.g. "AAPL" + * @param {number} days - Number of calendar days of history + * @param {string} [timeframe] - "1Day" | "1Hour" | "15Min" (default: "1Day") + * @returns {Promise<{ok: boolean, symbol: string, bars: Array, source: string}>} + */ +export async function getHistoricalBars(symbol, days = 90, timeframe = '1Day') { + if (!symbol) { + return { ok: false, symbol: '', bars: [], source: 'none', error: 'symbol is required' }; + } + + const upperSymbol = symbol.toUpperCase(); + + if (USE_MOCK()) { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + const bars = generateHistoricalOhlcv( + upperSymbol, + startDate.toISOString().split('T')[0], + endDate.toISOString().split('T')[0] + ); + return { ok: true, symbol: upperSymbol, bars, source: 'synthetic', timeframe }; + } + + try { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + const url = new URL(`/v2/stocks/${encodeURIComponent(upperSymbol)}/bars`, ALPACA_DATA_BASE); + url.searchParams.set('timeframe', timeframe); + url.searchParams.set('start', startDate.toISOString().split('T')[0]); + url.searchParams.set('end', endDate.toISOString().split('T')[0]); + url.searchParams.set('limit', String(Math.min(days, 1000))); + url.searchParams.set('feed', ALPACA_DATA_FEED()); + url.searchParams.set('sort', 'asc'); + + const response = await fetch(url.toString(), { headers: alpacaHeaders() }); + + if (!response.ok) { + // Fallback to synthetic data on API error + console.warn(`[market-data] Alpaca bars error HTTP ${response.status} for ${upperSymbol}, using synthetic fallback`); + const endD = new Date(); + const startD = new Date(); + startD.setDate(startD.getDate() - days); + const bars = generateHistoricalOhlcv(upperSymbol, startD.toISOString().split('T')[0], endD.toISOString().split('T')[0]); + return { ok: true, symbol: upperSymbol, bars, source: 'synthetic_fallback', timeframe }; + } + + const payload = await response.json(); + const bars = Array.isArray(payload?.bars) ? payload.bars.map(normalizeAlpacaBar) : []; + + if (bars.length === 0) { + // Symbol might not be in Alpaca universe, use synthetic + const endD = new Date(); + const startD = new Date(); + startD.setDate(startD.getDate() - days); + const syntheticBars = generateHistoricalOhlcv(upperSymbol, startD.toISOString().split('T')[0], endD.toISOString().split('T')[0]); + return { ok: true, symbol: upperSymbol, bars: syntheticBars, source: 'synthetic_fallback', timeframe }; + } + + return { ok: true, symbol: upperSymbol, bars, source: 'alpaca', timeframe }; + } catch (err) { + console.error('[market-data] Error fetching bars:', err.message); + const endD = new Date(); + const startD = new Date(); + startD.setDate(startD.getDate() - days); + const bars = generateHistoricalOhlcv(upperSymbol, startD.toISOString().split('T')[0], endD.toISOString().split('T')[0]); + return { ok: true, symbol: upperSymbol, bars, source: 'synthetic_fallback', timeframe }; + } +} + +/** + * Get current market snapshots for multiple symbols. + * Returns from Alpaca when configured, otherwise returns empty (upstream from Worker sync). + */ +export async function getMarketQuotes(symbols = []) { + if (!symbols.length) return { ok: false, quotes: [], error: 'No symbols provided' }; + if (USE_MOCK()) return { ok: true, quotes: [], source: 'none', note: 'Mock mode: quotes come from Worker market sync' }; + + try { + const url = new URL('/v2/stocks/snapshots', ALPACA_DATA_BASE); + url.searchParams.set('symbols', symbols.join(',')); + url.searchParams.set('feed', ALPACA_DATA_FEED()); + + const response = await fetch(url.toString(), { headers: alpacaHeaders() }); + if (!response.ok) return { ok: false, quotes: [], error: `Alpaca snapshots HTTP ${response.status}` }; + + const payload = await response.json(); + const quotes = Object.entries(payload?.snapshots || {}).map(([sym, snap]) => { + const price = Number(snap?.minuteBar?.c ?? snap?.latestTrade?.p ?? snap?.dailyBar?.c ?? 0); + const prevClose = Number(snap?.prevDailyBar?.c ?? snap?.dailyBar?.o ?? price); + const change = price - prevClose; + const changePct = prevClose > 0 ? (change / prevClose) * 100 : 0; + return { + symbol: sym, + price, + change: parseFloat(change.toFixed(2)), + changePct: parseFloat(changePct.toFixed(2)), + volume: Number(snap?.dailyBar?.v ?? 0), + }; + }); + + return { ok: true, quotes, source: 'alpaca' }; + } catch (err) { + return { ok: false, quotes: [], error: err.message }; + } +} diff --git a/apps/api/src/domains/research/services/evaluation-service.ts b/apps/api/src/domains/research/services/evaluation-service.ts index 6664620..cb1c3af 100644 --- a/apps/api/src/domains/research/services/evaluation-service.ts +++ b/apps/api/src/domains/research/services/evaluation-service.ts @@ -28,7 +28,7 @@ function getNextStrategyStage(status) { return ''; } -function buildEvaluationForRun(run, result, strategy, payload = {}) { +function buildEvaluationForRun(_run, result, strategy, payload = {}) { const benchmarkGapPct = Number( (result.annualizedReturnPct - result.benchmarkReturnPct).toFixed(1) ); diff --git a/apps/api/src/domains/risk/services/assessment-service.ts b/apps/api/src/domains/risk/services/assessment-service.ts index baeca00..c36f788 100644 --- a/apps/api/src/domains/risk/services/assessment-service.ts +++ b/apps/api/src/domains/risk/services/assessment-service.ts @@ -3,12 +3,14 @@ import { listBacktestRuns } from '../../backtest/services/runs-service.js'; import { listExecutionPlans } from '../../execution/services/query-service.js'; import { getStrategyCatalogItem } from '../../strategy/services/catalog-service.js'; import { listRiskEvents } from './feed-service.js'; +import { getRiskParameters } from './parameters-service.js'; export function assessExecutionCandidate(candidate) { + const params = getRiskParameters(); const needsReview = candidate.mode === 'live' && candidate.status !== 'paper' && candidate.status !== 'live'; - const blockedByDrawdown = candidate.metrics.maxDrawdownPct > 12; - const blockedBySharpe = candidate.metrics.sharpe < 0.9; + const blockedByDrawdown = candidate.metrics.maxDrawdownPct > params.maxDrawdownPct; + const blockedBySharpe = candidate.metrics.sharpe < params.sharpeFloor; if (blockedByDrawdown || blockedBySharpe) { return { @@ -17,8 +19,10 @@ export function assessExecutionCandidate(candidate) { summary: 'Risk rejected the execution candidate because risk-adjusted quality is below the current floor.', reasons: [ - blockedByDrawdown ? `max drawdown ${candidate.metrics.maxDrawdownPct}% exceeds 12%` : '', - blockedBySharpe ? `sharpe ${candidate.metrics.sharpe} is below 0.9` : '', + blockedByDrawdown + ? `max drawdown ${candidate.metrics.maxDrawdownPct}% exceeds ${params.maxDrawdownPct}%` + : '', + blockedBySharpe ? `sharpe ${candidate.metrics.sharpe} is below ${params.sharpeFloor}` : '', ].filter(Boolean), }; } @@ -45,6 +49,7 @@ export function assessExecutionCandidate(candidate) { export function assessAgentActionRequestRisk(payload = {}) { if (payload.requestType === 'prepare_execution_plan') { + const params = getRiskParameters(); const strategy = getStrategyCatalogItem(payload.targetId); if (!strategy) { return { @@ -64,7 +69,7 @@ export function assessAgentActionRequestRisk(payload = {}) { reasons: ['archived strategy cannot be routed into execution planning'], }; } - if (strategy.maxDrawdownPct > 12 || strategy.sharpe < 0.9) { + if (strategy.maxDrawdownPct > params.maxDrawdownPct || strategy.sharpe < params.sharpeFloor) { return { riskStatus: 'blocked', approvalState: 'rejected', diff --git a/apps/api/src/domains/risk/services/parameters-service.js b/apps/api/src/domains/risk/services/parameters-service.js new file mode 100644 index 0000000..9837ba4 --- /dev/null +++ b/apps/api/src/domains/risk/services/parameters-service.js @@ -0,0 +1,48 @@ +// @ts-nocheck +/** + * Risk parameters service — manages user-configurable risk thresholds. + * Stored in memory (survives the process lifetime, resets on restart). + * Defaults match the platform's conservative initial setup. + */ + +export const DEFAULT_RISK_PARAMS = { + /** Maximum single-position weight as a fraction (0.05 = 5%) */ + maxPositionWeight: 0.05, + /** Maximum allowed drawdown % before blocking a candidate */ + maxDrawdownPct: 12, + /** Daily portfolio loss % that triggers a hard stop */ + dailyLossStopPct: 5, + /** Minimum Sharpe ratio required to pass risk gate */ + sharpeFloor: 0.9, + /** Whether live orders always require manual approval */ + liveOrderRequiresApproval: true, +}; + +let _params = { ...DEFAULT_RISK_PARAMS }; + +export function getRiskParameters() { + return { ...DEFAULT_RISK_PARAMS, ..._params }; +} + +export function updateRiskParameters(patch = {}) { + const allowed = new Set(Object.keys(DEFAULT_RISK_PARAMS)); + const updates = {}; + + for (const [key, value] of Object.entries(patch)) { + if (!allowed.has(key)) continue; + const defaultVal = DEFAULT_RISK_PARAMS[key]; + if (typeof defaultVal === 'number' && typeof value === 'number' && isFinite(value)) { + updates[key] = value; + } else if (typeof defaultVal === 'boolean' && typeof value === 'boolean') { + updates[key] = value; + } + } + + _params = { ..._params, ...updates }; + return getRiskParameters(); +} + +export function resetRiskParameters() { + _params = { ...DEFAULT_RISK_PARAMS }; + return getRiskParameters(); +} diff --git a/apps/api/src/gateways/alpaca.ts b/apps/api/src/gateways/alpaca.ts index 9a4294f..700b4fe 100644 --- a/apps/api/src/gateways/alpaca.ts +++ b/apps/api/src/gateways/alpaca.ts @@ -281,6 +281,59 @@ function normalizeAlpacaSnapshot(symbol, snapshot) { }; } +function normalizeAlpacaBar(bar) { + return { + time: bar.t ? bar.t.split('T')[0] : '', + open: Number(bar.o || 0), + high: Number(bar.h || 0), + low: Number(bar.l || 0), + close: Number(bar.c || 0), + volume: Number(bar.v || 0), + }; +} + +async function handleHistoricalBars(config, reqUrl, res) { + if (!ensureConfigured(config)) { + writeJson(res, 503, { message: 'Alpaca credentials are not configured.', bars: [] }); + return; + } + const symbol = reqUrl.searchParams.get('symbol') || ''; + const timeframe = reqUrl.searchParams.get('timeframe') || '1Day'; + const start = reqUrl.searchParams.get('start') || ''; + const end = reqUrl.searchParams.get('end') || ''; + const limit = reqUrl.searchParams.get('limit') || '252'; + + if (!symbol) { + writeJson(res, 400, { message: 'symbol is required', bars: [] }); + return; + } + + const upstream = new URL(`/v2/stocks/${encodeURIComponent(symbol)}/bars`, config.alpacaDataBase); + upstream.searchParams.set('timeframe', timeframe); + if (start) upstream.searchParams.set('start', start); + if (end) upstream.searchParams.set('end', end); + upstream.searchParams.set('limit', limit); + upstream.searchParams.set('feed', config.alpacaDataFeed); + upstream.searchParams.set('sort', 'asc'); + + const response = await fetch(upstream, { headers: alpacaHeaders(config, false) }); + if (!response.ok) { + writeJson(res, response.status, { + message: `Alpaca bars error: HTTP ${response.status}`, + bars: [], + }); + return; + } + const payload = await response.json(); + const bars = Array.isArray(payload?.bars) ? payload.bars.map(normalizeAlpacaBar) : []; + writeJson(res, 200, { + symbol, + timeframe, + bars, + message: `Loaded ${bars.length} bars for ${symbol}`, + }); +} + async function handleSnapshots(config, reqUrl, res) { if (!ensureConfigured(config)) { writeJson(res, 503, { @@ -552,6 +605,10 @@ export function createGatewayHandler(options = {}) { await handleSnapshots(config, reqUrl, res); return; } + if (req.method === 'GET' && reqUrl.pathname === '/api/alpaca/market/bars') { + await handleHistoricalBars(config, reqUrl, res); + return; + } if (req.method === 'POST' && reqUrl.pathname === '/api/alpaca/broker/orders') { await handleSubmitOrders(config, req, res); return; diff --git a/apps/api/src/modules/auth/service.ts b/apps/api/src/modules/auth/service.ts index 91d2d12..7f18be4 100644 --- a/apps/api/src/modules/auth/service.ts +++ b/apps/api/src/modules/auth/service.ts @@ -2,7 +2,7 @@ import { getUserAccount } from '../../../../../packages/control-plane-runtime/src/index.js'; import { verifyToken } from './jwt-service.js'; -export function getSession(authHeader) { +export function getSession(_authHeader) { const account = getUserAccount(); const defaultBrokerBinding = account.brokerBindings.find((binding) => binding.isDefault) || @@ -59,7 +59,7 @@ export function hasPermission(permission) { /** Validate a Bearer token and return JWT-based session fields if valid. */ export async function getSessionFromToken(authHeader) { - if (!authHeader || !authHeader.startsWith('Bearer ')) return null; + if (!authHeader?.startsWith('Bearer ')) return null; try { const token = authHeader.slice(7); const payload = await verifyToken(token); diff --git a/apps/web/src/app/api/controlPlane.ts b/apps/web/src/app/api/controlPlane.ts index f201395..56f8991 100644 --- a/apps/web/src/app/api/controlPlane.ts +++ b/apps/web/src/app/api/controlPlane.ts @@ -351,6 +351,41 @@ export async function fetchRiskEventDetail(eventId: string): Promise { + return fetchJson('/api/risk/parameters', { + headers: { Accept: 'application/json' }, + }); +} + +export async function saveRiskParameters( + patch: Partial +): Promise<{ ok: boolean; parameters: RiskParameters }> { + return fetchJson('/api/risk/parameters', { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify(patch), + }); +} + +export async function resetRiskParametersToDefaults(): Promise<{ + ok: boolean; + parameters: RiskParameters; +}> { + return fetchJson('/api/risk/parameters/reset', { + method: 'POST', + headers: jsonHeaders(), + body: '{}', + }); +} + type SchedulerTicksQuery = { hours?: number | null; limit?: number; diff --git a/apps/web/src/app/providers/AppProviders.tsx b/apps/web/src/app/providers/AppProviders.tsx index 024a016..a53ebe8 100644 --- a/apps/web/src/app/providers/AppProviders.tsx +++ b/apps/web/src/app/providers/AppProviders.tsx @@ -1,6 +1,11 @@ import type { PropsWithChildren } from 'react'; import { BrowserRouter } from 'react-router-dom'; +import { ToastProvider } from '../../components/toast/Toast.tsx'; export function AppProviders({ children }: PropsWithChildren) { - return {children}; + return ( + + {children} + + ); } diff --git a/apps/web/src/app/providers/marketData.ts b/apps/web/src/app/providers/marketData.ts index b6d63de..b7435e0 100644 --- a/apps/web/src/app/providers/marketData.ts +++ b/apps/web/src/app/providers/marketData.ts @@ -10,7 +10,7 @@ import { fetchJson } from '../api/http.ts'; function normalizeQuote( rawQuote: (Partial & { symbol?: string }) | null | undefined ): Quote | null { - if (!rawQuote || !rawQuote.symbol) return null; + if (!rawQuote?.symbol) return null; const price = Number(rawQuote.price); if (!Number.isFinite(price) || price <= 0) return null; return { diff --git a/apps/web/src/app/styles/base.css.ts b/apps/web/src/app/styles/base.css.ts index 5ee6615..1843d2f 100644 --- a/apps/web/src/app/styles/base.css.ts +++ b/apps/web/src/app/styles/base.css.ts @@ -28,7 +28,7 @@ globalStyle('::-webkit-scrollbar-thumb', { }); globalStyle('::-webkit-scrollbar-thumb:hover', { - background: 'rgba(0, 212, 255, 0.50)', + background: 'rgba(99, 102, 241, 0.50)', }); globalStyle('body', { @@ -40,7 +40,7 @@ globalStyle('body', { MozOsxFontSmoothing: 'grayscale', backgroundImage: [ 'radial-gradient(ellipse 130% 65% at 50% -5%, rgba(0, 100, 220, 0.20) 0%, transparent 65%)', - 'radial-gradient(ellipse 55% 45% at 90% 20%, rgba(0, 200, 255, 0.10) 0%, transparent 55%)', + 'radial-gradient(ellipse 55% 45% at 90% 20%, rgba(99, 102, 241, 0.10) 0%, transparent 55%)', 'radial-gradient(ellipse 45% 35% at 10% 80%, rgba(139, 92, 246, 0.10) 0%, transparent 55%)', ].join(', '), backgroundAttachment: 'fixed', diff --git a/apps/web/src/app/styles/chips.css.ts b/apps/web/src/app/styles/chips.css.ts index cbd93f5..f20b53a 100644 --- a/apps/web/src/app/styles/chips.css.ts +++ b/apps/web/src/app/styles/chips.css.ts @@ -47,8 +47,9 @@ globalStyle('.order-status', { } as any); globalStyle('.order-status-open', { - background: 'rgba(77, 166, 255, 0.12)', + background: 'rgba(99, 102, 241, 0.12)', color: 'var(--info)', + border: '1px solid rgba(99, 102, 241, 0.18)', } as any); globalStyle('.order-status-filled', { diff --git a/apps/web/src/app/styles/layout.css.ts b/apps/web/src/app/styles/layout.css.ts index 03702cc..04b3f51 100644 --- a/apps/web/src/app/styles/layout.css.ts +++ b/apps/web/src/app/styles/layout.css.ts @@ -27,7 +27,7 @@ globalStyle('.app-shell::before', { zIndex: 9998, pointerEvents: 'none', background: - 'linear-gradient(180deg, transparent 0%, rgba(0, 212, 255, 0.022) 50%, transparent 100%)', + 'linear-gradient(180deg, transparent 0%, rgba(99, 102, 241, 0.022) 50%, transparent 100%)', animation: 'scan-sweep 16s linear infinite', } as any); @@ -56,7 +56,7 @@ globalStyle('.sidebar::after', { width: '200%', height: '1px', background: - 'linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.65) 25%, rgba(255, 183, 0, 0.35) 55%, rgba(139, 92, 246, 0.25) 75%, transparent)', + 'linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.65) 25%, rgba(255, 183, 0, 0.35) 55%, rgba(139, 92, 246, 0.25) 75%, transparent)', animation: 'gradient-flow 5s linear infinite', pointerEvents: 'none', } as any); @@ -139,7 +139,7 @@ globalStyle('.sidebar-block', { } as any); globalStyle('.sidebar-block:hover', { - borderColor: 'rgba(40, 120, 220, 0.2)', + borderColor: 'rgba(99, 102, 241, 0.20)', } as any); globalStyle('.nav-stack', { @@ -197,7 +197,7 @@ globalStyle('.nav-link::after', { position: 'absolute', inset: 0, borderRadius: 'var(--radius)', - background: 'linear-gradient(90deg, rgba(0, 212, 255, 0.08), rgba(0, 212, 255, 0.02))', + background: 'linear-gradient(90deg, rgba(99, 102, 241, 0.08), rgba(99, 102, 241, 0.02))', transform: 'translateX(-100%)', transition: 'transform 200ms ease', pointerEvents: 'none', @@ -209,8 +209,8 @@ globalStyle('.nav-link:hover::after', { transform: 'translateX(0)' } as any); globalStyle('.nav-link.active', { color: 'var(--accent)', - borderColor: 'rgba(0, 212, 255, 0.16)', - background: 'rgba(0, 212, 255, 0.055)', + borderColor: 'rgba(99, 102, 241, 0.16)', + background: 'rgba(99, 102, 241, 0.055)', } as any); globalStyle('.nav-link.active::before', { @@ -277,7 +277,7 @@ globalStyle('.global-toolbar::before', { width: '80%', height: '1px', background: - 'linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.25), rgba(255, 183, 0, 0.12), transparent)', + 'linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.25), rgba(255, 183, 0, 0.12), transparent)', pointerEvents: 'none', } as any); @@ -350,9 +350,9 @@ globalStyle('.toolbar-pill-button', { } as any); globalStyle('.toolbar-pill-button:hover', { - borderColor: 'rgba(0, 212, 255, 0.28)', - boxShadow: '0 0 18px rgba(0, 212, 255, 0.1), inset 0 0 18px rgba(0, 212, 255, 0.02)', - background: 'rgba(0, 212, 255, 0.04)', + borderColor: 'rgba(99, 102, 241, 0.28)', + boxShadow: '0 0 18px rgba(99, 102, 241, 0.1), inset 0 0 18px rgba(99, 102, 241, 0.02)', + background: 'rgba(99, 102, 241, 0.04)', } as any); globalStyle('.toolbar-pill-button:hover::after', { @@ -418,12 +418,12 @@ globalStyle('.tone-warn .status-dot', { globalStyle('.tone-muted', { color: 'rgba(100, 140, 195, 0.65)', - borderColor: 'rgba(0, 212, 255, 0.1)', - background: 'rgba(0, 212, 255, 0.02)', + borderColor: 'rgba(99, 102, 241, 0.1)', + background: 'rgba(99, 102, 241, 0.02)', } as any); globalStyle('.tone-muted .status-dot', { - boxShadow: '0 0 6px rgba(0, 212, 255, 0.3)', + boxShadow: '0 0 6px rgba(99, 102, 241, 0.3)', } as any); /* ============================================================ @@ -444,8 +444,8 @@ globalStyle('.locale-trigger', { } as any); globalStyle('.locale-trigger:hover', { - borderColor: 'rgba(0, 212, 255, 0.22)', - boxShadow: '0 0 10px rgba(0, 212, 255, 0.08)', + borderColor: 'rgba(99, 102, 241, 0.22)', + boxShadow: '0 0 10px rgba(99, 102, 241, 0.08)', } as any); globalStyle('.locale-trigger strong', { @@ -494,8 +494,8 @@ globalStyle('.locale-option:first-of-type', { } as any); globalStyle('.locale-option:hover', { - borderColor: 'rgba(0, 212, 255, 0.22)', - background: 'rgba(0, 212, 255, 0.07)', + borderColor: 'rgba(99, 102, 241, 0.22)', + background: 'rgba(99, 102, 241, 0.07)', } as any); globalStyle('.locale-option span', { @@ -519,8 +519,8 @@ globalStyle('.locale-check', { } as any); globalStyle('.locale-option.active', { - borderColor: 'rgba(0, 212, 255, 0.28)', - background: 'rgba(0, 212, 255, 0.08)', + borderColor: 'rgba(99, 102, 241, 0.28)', + background: 'rgba(99, 102, 241, 0.08)', } as any); /* ============================================================ @@ -552,7 +552,7 @@ globalStyle('.topbar::before', { width: '200%', height: '1px', background: - 'linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.7) 20%, rgba(255, 183, 0, 0.45) 48%, rgba(139, 92, 246, 0.35) 65%, rgba(0, 212, 255, 0.5) 80%, transparent)', + 'linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.7) 20%, rgba(255, 183, 0, 0.45) 48%, rgba(139, 92, 246, 0.35) 65%, rgba(99, 102, 241, 0.5) 80%, transparent)', animation: 'gradient-flow 4s linear infinite', pointerEvents: 'none', } as any); @@ -622,14 +622,14 @@ globalStyle('.mode-pill', { globalStyle('.mode-pill:hover', { color: 'var(--text)', - background: 'rgba(0, 212, 255, 0.05)', + background: 'rgba(99, 102, 241, 0.05)', } as any); globalStyle('.mode-pill.active', { - borderColor: 'rgba(0, 212, 255, 0.28)', - background: 'rgba(0, 212, 255, 0.1)', + borderColor: 'rgba(99, 102, 241, 0.28)', + background: 'rgba(99, 102, 241, 0.1)', color: 'var(--text-strong)', - boxShadow: '0 0 12px rgba(0, 212, 255, 0.12), inset 0 1px 0 rgba(0, 212, 255, 0.1)', + boxShadow: '0 0 12px rgba(99, 102, 241, 0.12), inset 0 1px 0 rgba(99, 102, 241, 0.1)', } as any); /* ============================================================ @@ -666,15 +666,15 @@ globalStyle('.route-status, .panel-badge, .signal-chip, .log-tag', { globalStyle('.route-status', { padding: '6px 10px', - background: 'rgba(0, 212, 255, 0.08)', + background: 'rgba(99, 102, 241, 0.08)', color: 'var(--info)', borderRadius: 'var(--radius-sm)', } as any); globalStyle('.route-status.active', { - background: 'rgba(0, 212, 255, 0.14)', + background: 'rgba(99, 102, 241, 0.14)', color: 'var(--accent)', - boxShadow: '0 0 8px rgba(0, 212, 255, 0.2)', + boxShadow: '0 0 8px rgba(99, 102, 241, 0.2)', } as any); globalStyle('.route-copy', { @@ -701,8 +701,8 @@ globalStyle('@media (max-width: 1180px)', {} as any); Use the selector-based approach with media queries. */ /* eslint-disable @typescript-eslint/no-explicit-any */ -const mediaLarge = '@media (max-width: 1180px)'; -const mediaSmall = '@media (max-width: 720px)'; +const _mediaLarge = '@media (max-width: 1180px)'; +const _mediaSmall = '@media (max-width: 720px)'; globalStyle(`.app-shell`, { '@media': { diff --git a/apps/web/src/app/styles/panels.css.ts b/apps/web/src/app/styles/panels.css.ts index 0fd7f4e..867985e 100644 --- a/apps/web/src/app/styles/panels.css.ts +++ b/apps/web/src/app/styles/panels.css.ts @@ -22,7 +22,7 @@ globalStyle('.meta-card::before, .hero-card::before, .metric-tile::before, .pane width: '100%', height: '1px', background: - 'linear-gradient(90deg, rgba(0, 212, 255, 0.2), rgba(0, 212, 255, 0.06) 55%, transparent)', + 'linear-gradient(90deg, rgba(99, 102, 241, 0.22), rgba(99, 102, 241, 0.07) 55%, transparent)', pointerEvents: 'none', } as any); @@ -33,19 +33,19 @@ globalStyle('.panel::after', { right: '7px', width: '10px', height: '10px', - borderRight: '1px solid rgba(0, 212, 255, 0.12)', - borderBottom: '1px solid rgba(0, 212, 255, 0.12)', + borderRight: '1px solid rgba(99, 102, 241, 0.14)', + borderBottom: '1px solid rgba(99, 102, 241, 0.14)', pointerEvents: 'none', transition: 'border-color 200ms ease', } as any); globalStyle('.panel:hover', { - borderColor: 'rgba(40, 120, 220, 0.22)', + borderColor: 'rgba(99, 102, 241, 0.22)', boxShadow: 'var(--shadow-panel-hover)', } as any); globalStyle('.panel:hover::after', { - borderColor: 'rgba(0, 212, 255, 0.3)', + borderColor: 'rgba(99, 102, 241, 0.35)', } as any); globalStyle('.meta-card', { @@ -61,7 +61,7 @@ globalStyle('.meta-value', { globalStyle('.meta-value.accent', { color: 'var(--accent)', - textShadow: '0 0 20px rgba(0, 212, 255, 0.25)', + textShadow: '0 0 20px rgba(99, 102, 241, 0.30)', } as any); globalStyle('.panel', { @@ -255,29 +255,29 @@ globalStyle('.shortcut-surface', { } as any); globalStyle('.shortcut-surface:hover', { - borderColor: 'rgba(0, 212, 255, 0.28) !important', - boxShadow: '0 0 22px rgba(0, 212, 255, 0.1)', - background: 'rgba(0, 212, 255, 0.04) !important', + borderColor: 'rgba(99, 102, 241, 0.28) !important', + boxShadow: '0 0 22px rgba(99, 102, 241, 0.12)', + background: 'rgba(99, 102, 241, 0.05) !important', transform: 'translateY(-2px)', } as any); globalStyle('.toolbar-pill-button:hover, .status-row-button:hover, .inline-link:hover', { - borderColor: 'rgba(0, 212, 255, 0.28)', + borderColor: 'rgba(99, 102, 241, 0.30)', color: 'var(--text)', } as any); globalStyle('.toolbar-pill-button:hover, .shortcut-surface:hover', { - boxShadow: '0 0 20px rgba(0, 212, 255, 0.1)', + boxShadow: '0 0 20px rgba(99, 102, 241, 0.12)', } as any); globalStyle('.status-row-button:hover, .inline-link:hover', { - background: 'rgba(0, 212, 255, 0.035)', + background: 'rgba(99, 102, 241, 0.04)', } as any); globalStyle( '.shortcut-surface:focus-visible, .toolbar-pill-button:focus-visible, .status-row-button:focus-visible, .inline-link:focus-visible, .locale-trigger:focus-visible, .locale-option:focus-visible, .mode-pill:focus-visible', { - outline: '2px solid rgba(0, 212, 255, 0.65)', + outline: '2px solid rgba(99, 102, 241, 0.65)', outlineOffset: '2px', } as any ); @@ -288,7 +288,7 @@ globalStyle( globalStyle('.hero-card-primary', { background: [ - 'linear-gradient(135deg, rgba(0, 212, 255, 0.07) 0%, transparent 45%)', + 'linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, transparent 45%)', 'linear-gradient(220deg, rgba(139, 92, 246, 0.06) 0%, transparent 60%)', 'var(--panel)', ].join(', '), @@ -302,7 +302,7 @@ globalStyle('.hero-card-primary::after', { width: '200px', height: '200px', borderRadius: '50%', - background: 'radial-gradient(circle, rgba(0, 212, 255, 0.1), transparent 60%)', + background: 'radial-gradient(circle, rgba(99, 102, 241, 0.14), transparent 60%)', pointerEvents: 'none', filter: 'blur(16px)', opacity: 0.7, @@ -318,7 +318,7 @@ globalStyle('.hero-headline', { globalStyle('.hero-value', { font: '700 clamp(30px, 5vw, 52px)/0.88 var(--font-data)', letterSpacing: '-0.04em', - textShadow: '0 0 36px rgba(0, 212, 255, 0.22)', + textShadow: '0 0 36px rgba(99, 102, 241, 0.28)', animation: 'tick-up 320ms ease 150ms both', } as any); @@ -395,12 +395,12 @@ globalStyle('.metric-card::before', { left: 0, width: '55%', height: '1px', - background: 'linear-gradient(90deg, rgba(0, 212, 255, 0.28), transparent)', + background: 'linear-gradient(90deg, rgba(99, 102, 241, 0.30), transparent)', } as any); globalStyle('.metric-card:hover', { - borderColor: 'rgba(40, 120, 220, 0.24)', - boxShadow: '0 0 20px rgba(0, 212, 255, 0.07)', + borderColor: 'rgba(99, 102, 241, 0.28)', + boxShadow: '0 0 20px rgba(99, 102, 241, 0.09)', transform: 'translateY(-2px)', } as any); @@ -437,12 +437,12 @@ globalStyle('.terminal-tile', { } as any); globalStyle('.terminal-tile:hover', { - borderColor: 'rgba(40, 120, 220, 0.2)', - boxShadow: '0 0 16px rgba(0, 212, 255, 0.05)', + borderColor: 'rgba(99, 102, 241, 0.22)', + boxShadow: '0 0 16px rgba(99, 102, 241, 0.07)', } as any); globalStyle('.terminal-tile-primary', { - background: 'linear-gradient(135deg, rgba(0, 212, 255, 0.06), transparent 36%), var(--panel)', + background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.07), transparent 36%), var(--panel)', } as any); globalStyle('.metric-tile', { @@ -654,7 +654,7 @@ globalStyle('.overview-ops-cluster', { } as any); globalStyle('.overview-ops-cluster:hover', { - borderColor: 'rgba(40, 120, 220, 0.2)', + borderColor: 'rgba(99, 102, 241, 0.2)', } as any); globalStyle('.overview-blotter-grid', { gridTemplateColumns: '1.06fr 0.94fr' } as any); @@ -682,7 +682,7 @@ globalStyle('.status-row', { } as any); globalStyle('.status-row:hover', { - borderBottomColor: 'rgba(40, 120, 220, 0.2)', + borderBottomColor: 'rgba(99, 102, 241, 0.2)', } as any); globalStyle('.status-row-button, .inline-link', { diff --git a/apps/web/src/app/styles/settings.css.ts b/apps/web/src/app/styles/settings.css.ts index 59194d8..d2eaae8 100644 --- a/apps/web/src/app/styles/settings.css.ts +++ b/apps/web/src/app/styles/settings.css.ts @@ -36,9 +36,9 @@ globalStyle('.settings-field input, .settings-field select', { } as any); globalStyle('.settings-field input:focus, .settings-field select:focus', { - borderColor: 'rgba(0, 212, 255, 0.45)', - boxShadow: '0 0 14px rgba(0, 212, 255, 0.12), inset 0 0 10px rgba(0, 212, 255, 0.03)', - background: 'rgba(0, 212, 255, 0.03)', + borderColor: 'rgba(99, 102, 241, 0.45)', + boxShadow: '0 0 14px rgba(99, 102, 241, 0.12), inset 0 0 10px rgba(99, 102, 241, 0.03)', + background: 'rgba(99, 102, 241, 0.03)', } as any); globalStyle('.settings-field-wide', { @@ -68,9 +68,9 @@ globalStyle('.text-input, .detail-textarea', { } as any); globalStyle('.text-input:focus, .detail-textarea:focus', { - borderColor: 'rgba(0, 212, 255, 0.45)', - boxShadow: '0 0 14px rgba(0, 212, 255, 0.12), inset 0 0 10px rgba(0, 212, 255, 0.03)', - background: 'rgba(0, 212, 255, 0.03)', + borderColor: 'rgba(99, 102, 241, 0.45)', + boxShadow: '0 0 14px rgba(99, 102, 241, 0.12), inset 0 0 10px rgba(99, 102, 241, 0.03)', + background: 'rgba(99, 102, 241, 0.03)', } as any); globalStyle('.detail-textarea', { @@ -94,9 +94,9 @@ globalStyle('.settings-actions', { globalStyle('.settings-button', { position: 'relative', overflow: 'hidden', - border: '1px solid rgba(0, 212, 255, 0.22)', + border: '1px solid rgba(99, 102, 241, 0.22)', borderRadius: 'var(--radius)', - background: 'rgba(0, 212, 255, 0.055)', + background: 'rgba(99, 102, 241, 0.055)', color: 'var(--text)', padding: '10px 16px', cursor: 'pointer', @@ -111,15 +111,15 @@ globalStyle('.settings-button::after', { content: '""', position: 'absolute', inset: 0, - background: 'linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.12), transparent)', + background: 'linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.12), transparent)', transform: 'translateX(-100%)', transition: 'none', } as any); globalStyle('.settings-button:hover', { - borderColor: 'rgba(0, 212, 255, 0.5)', - boxShadow: '0 0 18px rgba(0, 212, 255, 0.22), inset 0 1px 0 rgba(0, 212, 255, 0.08)', - background: 'rgba(0, 212, 255, 0.08)', + borderColor: 'rgba(99, 102, 241, 0.5)', + boxShadow: '0 0 18px rgba(99, 102, 241, 0.22), inset 0 1px 0 rgba(99, 102, 241, 0.08)', + background: 'rgba(99, 102, 241, 0.08)', transform: 'translateY(-1px)', } as any); @@ -159,9 +159,9 @@ globalStyle('.policy-row-actions', { globalStyle('.settings-inline-button', { position: 'relative', overflow: 'hidden', - border: '1px solid rgba(0, 212, 255, 0.13)', + border: '1px solid rgba(99, 102, 241, 0.13)', borderRadius: 'var(--radius-sm)', - background: 'rgba(0, 212, 255, 0.035)', + background: 'rgba(99, 102, 241, 0.035)', color: 'var(--muted)', padding: '6px 10px', cursor: 'pointer', @@ -173,8 +173,8 @@ globalStyle('.settings-inline-button', { } as any); globalStyle('.settings-inline-button:hover', { - borderColor: 'rgba(0, 212, 255, 0.3)', - boxShadow: '0 0 10px rgba(0, 212, 255, 0.12)', + borderColor: 'rgba(99, 102, 241, 0.3)', + boxShadow: '0 0 10px rgba(99, 102, 241, 0.12)', color: 'var(--text)', transform: 'translateY(-1px)', } as any); @@ -204,9 +204,9 @@ globalStyle('.settings-chip-row', { } as any); globalStyle('.settings-chip', { - border: '1px solid rgba(0, 212, 255, 0.1)', + border: '1px solid rgba(99, 102, 241, 0.1)', borderRadius: 'var(--radius-sm)', - background: 'rgba(0, 212, 255, 0.03)', + background: 'rgba(99, 102, 241, 0.03)', color: 'var(--muted)', padding: '8px 12px', cursor: 'pointer', @@ -218,16 +218,16 @@ globalStyle('.settings-chip', { } as any); globalStyle('.settings-chip:hover', { - borderColor: 'rgba(0, 212, 255, 0.22)', - background: 'rgba(0, 212, 255, 0.07)', + borderColor: 'rgba(99, 102, 241, 0.22)', + background: 'rgba(99, 102, 241, 0.07)', color: 'var(--text)', } as any); globalStyle('.settings-chip.active', { - borderColor: 'rgba(0, 212, 255, 0.38)', - background: 'rgba(0, 212, 255, 0.1)', + borderColor: 'rgba(99, 102, 241, 0.38)', + background: 'rgba(99, 102, 241, 0.1)', color: 'var(--text)', - boxShadow: '0 0 10px rgba(0, 212, 255, 0.1)', + boxShadow: '0 0 10px rgba(99, 102, 241, 0.1)', } as any); globalStyle('.policy-card', { @@ -278,16 +278,16 @@ globalStyle('.agent-stage-header', { gap: '18px', alignItems: 'flex-start', padding: '14px 16px', - border: '1px solid rgba(0, 212, 255, 0.12)', + border: '1px solid rgba(99, 102, 241, 0.12)', borderRadius: 'var(--radius)', - background: 'rgba(0, 212, 255, 0.03)', + background: 'rgba(99, 102, 241, 0.03)', transition: 'border-color 160ms ease', flexShrink: 0, marginBottom: '12px', } as any); globalStyle('.agent-stage-header:hover', { - borderColor: 'rgba(40, 120, 220, 0.2)', + borderColor: 'rgba(99, 102, 241, 0.2)', } as any); globalStyle('.agent-stage-pills', { @@ -316,8 +316,8 @@ globalStyle('.agent-insight-card', { } as any); globalStyle('.agent-insight-card:hover', { - borderColor: 'rgba(40, 120, 220, 0.2)', - boxShadow: '0 0 16px rgba(0, 212, 255, 0.05)', + borderColor: 'rgba(99, 102, 241, 0.2)', + boxShadow: '0 0 16px rgba(99, 102, 241, 0.05)', } as any); globalStyle('.agent-insight-header', { @@ -344,7 +344,7 @@ globalStyle('.agent-pulse-item', { } as any); globalStyle('.agent-pulse-item:hover', { - borderColor: 'rgba(40, 120, 220, 0.22)', + borderColor: 'rgba(99, 102, 241, 0.22)', } as any); globalStyle('.agent-pulse-item span', { @@ -373,8 +373,8 @@ globalStyle('.agent-step-card', { } as any); globalStyle('.agent-step-card:hover', { - borderColor: 'rgba(40, 120, 220, 0.22)', - background: 'rgba(0, 212, 255, 0.02)', + borderColor: 'rgba(99, 102, 241, 0.22)', + background: 'rgba(99, 102, 241, 0.02)', } as any); globalStyle('.agent-step-top', { @@ -430,9 +430,9 @@ globalStyle('.agent-suggestion-button::before', { } as any); globalStyle('.agent-suggestion-button:hover', { - borderColor: 'rgba(0, 212, 255, 0.22)', - boxShadow: '0 0 14px rgba(0, 212, 255, 0.08)', - background: 'rgba(0, 212, 255, 0.03)', + borderColor: 'rgba(99, 102, 241, 0.22)', + boxShadow: '0 0 14px rgba(99, 102, 241, 0.08)', + background: 'rgba(99, 102, 241, 0.03)', transform: 'translateX(3px)', } as any); @@ -448,7 +448,7 @@ globalStyle('.agent-chat-transcript', { flex: 1, minHeight: 0, overflowY: 'auto', - border: '1px solid rgba(0, 212, 255, 0.16)', + border: '1px solid rgba(99, 102, 241, 0.16)', borderRadius: 'var(--radius-lg)', background: 'rgba(1, 3, 10, 0.92)', padding: '20px', @@ -457,7 +457,7 @@ globalStyle('.agent-chat-transcript', { gap: '12px', animation: 'fade-in 200ms ease', boxShadow: - 'inset 0 0 60px rgba(0, 0, 0, 0.5), 0 0 28px rgba(0, 212, 255, 0.06), 0 0 0 1px rgba(0, 212, 255, 0.04)', + 'inset 0 0 60px rgba(0, 0, 0, 0.5), 0 0 28px rgba(99, 102, 241, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.04)', } as any); globalStyle('.agent-chat-sidecar', { @@ -477,15 +477,15 @@ globalStyle('.agent-chat-message', { } as any); globalStyle('.agent-chat-message:hover', { - borderColor: 'rgba(40, 120, 220, 0.22)', + borderColor: 'rgba(99, 102, 241, 0.22)', } as any); globalStyle('.agent-chat-user', { justifySelf: 'end', - background: 'rgba(0, 212, 255, 0.06)', - borderColor: 'rgba(0, 212, 255, 0.18)', + background: 'rgba(99, 102, 241, 0.06)', + borderColor: 'rgba(99, 102, 241, 0.18)', borderLeft: '2px solid var(--accent)', - boxShadow: 'inset -4px 0 20px rgba(0, 212, 255, 0.05), 0 2px 8px rgba(0, 0, 0, 0.3)', + boxShadow: 'inset -4px 0 20px rgba(99, 102, 241, 0.05), 0 2px 8px rgba(0, 0, 0, 0.3)', } as any); globalStyle('.agent-chat-assistant', { @@ -532,7 +532,7 @@ globalStyle('.agent-chat-composer', { display: 'grid', gap: '12px', padding: '16px', - border: '1px solid rgba(0, 212, 255, 0.1)', + border: '1px solid rgba(99, 102, 241, 0.1)', borderRadius: 'var(--radius-lg)', background: 'rgba(4, 10, 24, 0.8)', flexShrink: 0, diff --git a/apps/web/src/app/styles/tables.css.ts b/apps/web/src/app/styles/tables.css.ts index caa87af..a6fd179 100644 --- a/apps/web/src/app/styles/tables.css.ts +++ b/apps/web/src/app/styles/tables.css.ts @@ -32,9 +32,9 @@ globalStyle('.focus-row::before', { } as any); globalStyle('.focus-row:hover', { - borderColor: 'rgba(0, 212, 255, 0.2)', - background: 'rgba(0, 212, 255, 0.035)', - boxShadow: '0 0 18px rgba(0, 212, 255, 0.06)', + borderColor: 'rgba(99, 102, 241, 0.2)', + background: 'rgba(99, 102, 241, 0.035)', + boxShadow: '0 0 18px rgba(99, 102, 241, 0.06)', transform: 'translateX(3px)', } as any); @@ -119,16 +119,16 @@ globalStyle('.panel-title', { globalStyle('.panel-badge', { padding: '6px 11px', - background: 'rgba(0, 212, 255, 0.07)', + background: 'rgba(99, 102, 241, 0.07)', color: 'var(--accent)', - border: '1px solid rgba(0, 212, 255, 0.14)', + border: '1px solid rgba(99, 102, 241, 0.14)', borderRadius: 'var(--radius-sm)', } as any); globalStyle('.panel-badge.accent', { - background: 'rgba(77, 166, 255, 0.09)', + background: 'rgba(99, 102, 241, 0.09)', color: 'var(--info)', - borderColor: 'rgba(77, 166, 255, 0.14)', + borderColor: 'rgba(99, 102, 241, 0.16)', } as any); globalStyle('.panel-badge.muted', { @@ -138,9 +138,9 @@ globalStyle('.panel-badge.muted', { } as any); globalStyle('.badge-info', { - background: 'rgba(77, 166, 255, 0.09)', + background: 'rgba(99, 102, 241, 0.09)', color: 'var(--info)', - borderColor: 'rgba(77, 166, 255, 0.14)', + borderColor: 'rgba(99, 102, 241, 0.16)', } as any); globalStyle('.badge-ok, .badge-success', { @@ -211,11 +211,11 @@ globalStyle('.table-row-hover', { } as any); globalStyle('.table-row-hover:hover, .table-row-hover:focus-within', { - background: 'rgba(0, 212, 255, 0.04)', + background: 'rgba(99, 102, 241, 0.04)', } as any); globalStyle('.table-row-hover:hover td, .table-row-hover:focus-within td', { - borderBottomColor: 'rgba(0, 212, 255, 0.1)', + borderBottomColor: 'rgba(99, 102, 241, 0.1)', } as any); globalStyle('.table-row-hover:hover td:first-child, .table-row-hover:focus-within td:first-child', { @@ -333,9 +333,9 @@ globalStyle('.log-item::before', { } as any); globalStyle('.log-item:hover', { - borderColor: 'rgba(0, 212, 255, 0.18)', - background: 'rgba(0, 212, 255, 0.03)', - boxShadow: '0 0 16px rgba(0, 212, 255, 0.05)', + borderColor: 'rgba(99, 102, 241, 0.18)', + background: 'rgba(99, 102, 241, 0.03)', + boxShadow: '0 0 16px rgba(99, 102, 241, 0.05)', transform: 'translateX(3px)', } as any); @@ -379,9 +379,9 @@ globalStyle('.log-tag.sell', { } as any); globalStyle('.log-tag.info', { - background: 'rgba(77, 166, 255, 0.1)', + background: 'rgba(99, 102, 241, 0.1)', color: 'var(--info)', - border: '1px solid rgba(77, 166, 255, 0.15)', + border: '1px solid rgba(99, 102, 241, 0.16)', } as any); /* ============================================================ diff --git a/apps/web/src/app/styles/theme.css.ts b/apps/web/src/app/styles/theme.css.ts index 068cbc4..da2dbcb 100644 --- a/apps/web/src/app/styles/theme.css.ts +++ b/apps/web/src/app/styles/theme.css.ts @@ -6,26 +6,28 @@ import { globalStyle } from '@vanilla-extract/css'; globalStyle(':root', { /* Canvas layers */ - '--bg-canvas': '#030818', + '--bg-canvas': '#05071a', '--bg': 'var(--bg-canvas)', - '--panel': '#091224', - '--panel-2': '#0d192e', - '--panel-3': '#122038', - '--panel-frame': 'rgba(0, 200, 255, 0.10)', + '--panel': '#0c0f28', + '--panel-2': '#101430', + '--panel-3': '#151a3a', + '--panel-frame': 'rgba(99, 102, 241, 0.10)', /* Lines & borders */ - '--line': 'rgba(60, 140, 240, 0.22)', - '--line-strong': 'rgba(60, 140, 240, 0.40)', - '--line-vivid': 'rgba(0, 210, 255, 0.55)', + '--line': 'rgba(99, 102, 241, 0.18)', + '--line-strong': 'rgba(99, 102, 241, 0.35)', + '--line-vivid': 'rgba(99, 102, 241, 0.55)', /* Typography */ - '--text': '#d8eaf8', - '--text-strong': '#f0f8ff', - '--muted': 'rgba(160, 195, 230, 0.82)', - '--muted-strong': 'rgba(190, 215, 245, 0.95)', + '--text': '#e2e4f3', + '--text-strong': '#f4f5ff', + '--muted': 'rgba(160, 162, 210, 0.82)', + '--muted-strong': 'rgba(190, 192, 235, 0.95)', - /* Accent palette */ - '--accent-live': '#00d4ff', + /* Accent palette — indigo primary */ + '--accent-indigo': '#6366f1', + '--accent-indigo-hover': '#4f46e5', + '--accent-live': '#6366f1', '--accent': 'var(--accent-live)', '--accent-2': '#ffb700', '--accent-3': '#8b5cf6', @@ -34,7 +36,7 @@ globalStyle(':root', { '--buy': '#00e89d', '--sell': '#ff3358', '--hold': '#ffb700', - '--info': '#4da6ff', + '--info': '#6366f1', /* Fonts */ '--font-display': '"Rajdhani", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif', @@ -47,11 +49,14 @@ globalStyle(':root', { '--shadow-panel': '0 2px 0 rgba(255, 255, 255, 0.025) inset, 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 0, 0, 0.3)', '--shadow-panel-hover': - '0 2px 0 rgba(255, 255, 255, 0.04) inset, 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 28px rgba(0, 212, 255, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.3)', + '0 2px 0 rgba(255, 255, 255, 0.04) inset, 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 28px rgba(99, 102, 241, 0.10), 0 0 0 1px rgba(0, 0, 0, 0.3)', /* Glows */ - '--glow-cyan': '0 0 14px rgba(0, 212, 255, 0.55)', - '--glow-cyan-strong': '0 0 24px rgba(0, 212, 255, 0.75), 0 0 48px rgba(0, 212, 255, 0.3)', + '--glow-indigo': '0 0 14px rgba(99, 102, 241, 0.55)', + '--glow-indigo-strong': '0 0 24px rgba(99, 102, 241, 0.75), 0 0 48px rgba(99, 102, 241, 0.3)', + /* Keep legacy cyan aliases for gradual migration */ + '--glow-cyan': '0 0 14px rgba(99, 102, 241, 0.55)', + '--glow-cyan-strong': '0 0 24px rgba(99, 102, 241, 0.75), 0 0 48px rgba(99, 102, 241, 0.3)', '--glow-green': '0 0 12px rgba(0, 232, 157, 0.55)', '--glow-amber': '0 0 12px rgba(255, 183, 0, 0.55)', '--glow-red': '0 0 12px rgba(255, 51, 88, 0.55)', diff --git a/apps/web/src/components/approval-drawer/ApprovalDrawer.css.ts b/apps/web/src/components/approval-drawer/ApprovalDrawer.css.ts new file mode 100644 index 0000000..fd6a636 --- /dev/null +++ b/apps/web/src/components/approval-drawer/ApprovalDrawer.css.ts @@ -0,0 +1,172 @@ +import { globalStyle, keyframes, style } from '@vanilla-extract/css'; + +const slideUp = keyframes({ + from: { transform: 'translateY(100%)', opacity: 0 }, + to: { transform: 'translateY(0)', opacity: 1 }, +}); + +export const drawerRoot = style({ + position: 'fixed', + bottom: 0, + left: '260px', // sidebar width + right: 0, + zIndex: 8000, + padding: '0 24px 20px', + animation: `${slideUp} 220ms cubic-bezier(0.16, 1, 0.3, 1) both`, + '@media': { + '(max-width: 900px)': { left: 0 }, + }, +} as any); + +export const drawerPanel = style({ + borderRadius: 'var(--radius-lg)', + border: '1px solid rgba(255, 183, 0, 0.28)', + background: 'rgba(10, 12, 30, 0.97)', + backdropFilter: 'blur(12px)', + boxShadow: + '0 -8px 40px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 30px rgba(255, 183, 0, 0.06)', + overflow: 'hidden', +}); + +export const drawerHead = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 18px 10px', + borderBottom: '1px solid rgba(255, 183, 0, 0.12)', + background: 'rgba(255, 183, 0, 0.03)', +}); + +export const drawerTitle = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', + font: '700 11px/1 var(--font-data)', + letterSpacing: '0.1em', + textTransform: 'uppercase', + color: 'var(--hold)', +}); + +export const drawerDot = style({ + width: '6px', + height: '6px', + borderRadius: '50%', + background: 'var(--hold)', + boxShadow: '0 0 8px rgba(255, 183, 0, 0.7)', + animation: 'pulse-glow 1.8s ease-in-out infinite', + flexShrink: 0, +}); + +export const drawerCount = style({ + minWidth: '20px', + height: '20px', + borderRadius: '10px', + background: 'rgba(255, 183, 0, 0.18)', + border: '1px solid rgba(255, 183, 0, 0.28)', + padding: '0 7px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + font: '700 10px/1 var(--font-data)', + color: 'var(--hold)', +}); + +export const drawerDismiss = style({ + background: 'transparent', + border: 'none', + color: 'rgba(160, 162, 210, 0.5)', + cursor: 'pointer', + font: '600 11px/1 var(--font-ui)', + padding: '4px 8px', + borderRadius: 'var(--radius-sm)', + transition: 'color 140ms ease', + ':hover': { color: 'var(--text)' }, +} as any); + +export const drawerBody = style({ + display: 'flex', + gap: '10px', + padding: '12px 18px', + overflowX: 'auto', +}); + +export const orderCard = style({ + flexShrink: 0, + display: 'grid', + gap: '10px', + minWidth: '200px', + padding: '12px 14px', + borderRadius: 'var(--radius)', + border: '1px solid rgba(255, 183, 0, 0.16)', + background: 'rgba(255, 183, 0, 0.04)', + transition: 'border-color 160ms ease', + ':hover': { borderColor: 'rgba(255, 183, 0, 0.28)' }, +} as any); + +export const orderMeta = style({ display: 'grid', gap: '4px' }); + +export const orderSymbol = style({ + font: '700 15px/1 var(--font-data)', + letterSpacing: '0.02em', + color: 'var(--text-strong)', +}); + +export const orderDetail = style({ + font: '600 11px/1 var(--font-data)', + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: 'rgba(160, 162, 210, 0.55)', +}); + +export const orderSideBuy = style({ color: 'var(--buy)' }); +export const orderSideSell = style({ color: 'var(--sell)' }); + +export const orderActions = style({ + display: 'flex', + gap: '8px', +}); + +const actionBase = style({ + flex: 1, + padding: '7px 0', + borderRadius: 'var(--radius-sm)', + border: '1px solid', + cursor: 'pointer', + font: '700 10px/1 var(--font-data)', + letterSpacing: '0.1em', + textTransform: 'uppercase', + transition: 'background 140ms ease, box-shadow 140ms ease', +}); + +export const approveBtn = style([ + actionBase, + { + background: 'rgba(0, 232, 157, 0.1)', + borderColor: 'rgba(0, 232, 157, 0.25)', + color: 'var(--buy)', + ':hover': { + background: 'rgba(0, 232, 157, 0.18)', + boxShadow: '0 0 12px rgba(0, 232, 157, 0.12)', + }, + } as any, +]); + +export const rejectBtn = style([ + actionBase, + { + background: 'rgba(255, 51, 88, 0.08)', + borderColor: 'rgba(255, 51, 88, 0.22)', + color: 'var(--sell)', + ':hover': { + background: 'rgba(255, 51, 88, 0.14)', + boxShadow: '0 0 12px rgba(255, 51, 88, 0.1)', + }, + } as any, +]); + +globalStyle(`${drawerBody}::-webkit-scrollbar`, { height: '4px' }); +globalStyle(`${drawerBody}::-webkit-scrollbar-track`, { background: 'transparent' }); +globalStyle(`${drawerBody}::-webkit-scrollbar-thumb`, { + background: 'rgba(255, 183, 0, 0.2)', + borderRadius: '2px', +}); diff --git a/apps/web/src/components/approval-drawer/ApprovalDrawer.tsx b/apps/web/src/components/approval-drawer/ApprovalDrawer.tsx new file mode 100644 index 0000000..1575759 --- /dev/null +++ b/apps/web/src/components/approval-drawer/ApprovalDrawer.tsx @@ -0,0 +1,100 @@ +import type { AppLocale, BrokerOrder } from '@shared-types/trading.ts'; +import { useState } from 'react'; +import { + approveBtn, + drawerBody, + drawerCount, + drawerDismiss, + drawerDot, + drawerHead, + drawerPanel, + drawerRoot, + drawerTitle, + orderActions, + orderCard, + orderDetail, + orderMeta, + orderSideBuy, + orderSideSell, + orderSymbol, + rejectBtn, +} from './ApprovalDrawer.css.ts'; + +type Props = { + locale: AppLocale; + queue: BrokerOrder[]; + onApprove: (clientOrderId: string) => void; + onReject: (clientOrderId: string) => void; +}; + +const copy = { + zh: { + title: '待审批实盘订单', + approve: '批准', + reject: '拒绝', + dismiss: '稍后处理', + qty: (qty: number) => `${qty} 股`, + }, + en: { + title: 'Pending Live Orders', + approve: 'Approve', + reject: 'Reject', + dismiss: 'Later', + qty: (qty: number) => `${qty} shares`, + }, +}; + +export function ApprovalDrawer({ locale, queue, onApprove, onReject }: Props) { + const [dismissed, setDismissed] = useState(false); + const t = copy[locale]; + + if (queue.length === 0 || dismissed) return null; + + return ( +
+
+
+
+ + {t.title} + {queue.length} +
+ +
+ +
+ {queue.map((order) => { + const key = order.clientOrderId ?? order.id ?? `${order.symbol}-${order.side}`; + const isBuy = order.side?.toUpperCase() === 'BUY'; + + return ( +
+
+
{order.symbol}
+
+ + {order.side?.toUpperCase()} + + {' · '} + {t.qty(order.qty)} + {order.account ? ` · ${order.account}` : ''} +
+
+
+ + +
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/src/components/charts/CandlestickChart.tsx b/apps/web/src/components/charts/CandlestickChart.tsx index 7e69eb9..55f45bc 100644 --- a/apps/web/src/components/charts/CandlestickChart.tsx +++ b/apps/web/src/components/charts/CandlestickChart.tsx @@ -21,25 +21,25 @@ export function CandlestickChart({ data, timeframe }: Props) { const chart = createChart(el, { layout: { background: { color: 'transparent' }, - textColor: 'rgba(100, 140, 195, 0.65)', + textColor: 'rgba(160, 162, 210, 0.65)', fontFamily: '"JetBrains Mono", monospace', fontSize: 10, }, grid: { - vertLines: { color: 'rgba(0, 180, 255, 0.04)', style: 1 }, - horzLines: { color: 'rgba(0, 180, 255, 0.06)' }, + vertLines: { color: 'rgba(99, 102, 241, 0.05)', style: 1 }, + horzLines: { color: 'rgba(99, 102, 241, 0.07)' }, }, rightPriceScale: { - borderColor: 'rgba(0, 180, 255, 0.1)', - textColor: 'rgba(100, 140, 195, 0.65)', + borderColor: 'rgba(99, 102, 241, 0.12)', + textColor: 'rgba(160, 162, 210, 0.65)', }, timeScale: { - borderColor: 'rgba(0, 180, 255, 0.1)', + borderColor: 'rgba(99, 102, 241, 0.12)', timeVisible: true, }, crosshair: { - vertLine: { color: 'rgba(0, 212, 255, 0.3)' }, - horzLine: { color: 'rgba(0, 212, 255, 0.3)' }, + vertLine: { color: 'rgba(99, 102, 241, 0.40)' }, + horzLine: { color: 'rgba(99, 102, 241, 0.40)' }, }, handleScroll: true, handleScale: true, @@ -56,7 +56,7 @@ export function CandlestickChart({ data, timeframe }: Props) { }); const volumeSeries = chart.addSeries(HistogramSeries, { - color: 'rgba(0, 212, 255, 0.2)', + color: 'rgba(99, 102, 241, 0.22)', priceScaleId: 'volume', priceLineVisible: false, lastValueVisible: false, diff --git a/apps/web/src/components/charts/EquityChart.tsx b/apps/web/src/components/charts/EquityChart.tsx index fd663e2..f00c65a 100644 --- a/apps/web/src/components/charts/EquityChart.tsx +++ b/apps/web/src/components/charts/EquityChart.tsx @@ -21,34 +21,34 @@ export function EquityChart({ paper, live }: Props) { const chart = createChart(el, { layout: { background: { color: 'transparent' }, - textColor: 'rgba(100, 140, 195, 0.65)', + textColor: 'rgba(160, 162, 210, 0.65)', fontFamily: '"JetBrains Mono", monospace', fontSize: 10, }, grid: { - vertLines: { color: 'rgba(0, 180, 255, 0.04)', style: 1 }, - horzLines: { color: 'rgba(0, 180, 255, 0.08)' }, + vertLines: { color: 'rgba(99, 102, 241, 0.05)', style: 1 }, + horzLines: { color: 'rgba(99, 102, 241, 0.08)' }, }, rightPriceScale: { - borderColor: 'rgba(0, 180, 255, 0.1)', - textColor: 'rgba(100, 140, 195, 0.65)', + borderColor: 'rgba(99, 102, 241, 0.12)', + textColor: 'rgba(160, 162, 210, 0.65)', }, timeScale: { - borderColor: 'rgba(0, 180, 255, 0.1)', + borderColor: 'rgba(99, 102, 241, 0.12)', timeVisible: false, }, crosshair: { - vertLine: { color: 'rgba(0, 212, 255, 0.3)' }, - horzLine: { color: 'rgba(0, 212, 255, 0.3)' }, + vertLine: { color: 'rgba(99, 102, 241, 0.40)' }, + horzLine: { color: 'rgba(99, 102, 241, 0.40)' }, }, handleScroll: false, handleScale: false, }); const paperSeries = chart.addSeries(AreaSeries, { - lineColor: '#00d4ff', - topColor: 'rgba(0, 212, 255, 0.09)', - bottomColor: 'rgba(0, 212, 255, 0)', + lineColor: '#6366f1', + topColor: 'rgba(99, 102, 241, 0.12)', + bottomColor: 'rgba(99, 102, 241, 0)', lineWidth: 2, priceLineVisible: false, lastValueVisible: true, diff --git a/apps/web/src/components/charts/SignalBarChart.tsx b/apps/web/src/components/charts/SignalBarChart.tsx index 943e165..74dbfc8 100644 --- a/apps/web/src/components/charts/SignalBarChart.tsx +++ b/apps/web/src/components/charts/SignalBarChart.tsx @@ -24,26 +24,26 @@ export function SignalBarChart({ buy, hold, sell }: Props) { const chart = createChart(el, { layout: { background: { color: 'transparent' }, - textColor: 'rgba(100, 140, 195, 0.65)', + textColor: 'rgba(160, 162, 210, 0.65)', fontFamily: '"JetBrains Mono", monospace', fontSize: 10, }, grid: { - vertLines: { color: 'rgba(0, 180, 255, 0.04)', style: 1 }, - horzLines: { color: 'rgba(0, 180, 255, 0.06)' }, + vertLines: { color: 'rgba(99, 102, 241, 0.05)', style: 1 }, + horzLines: { color: 'rgba(99, 102, 241, 0.07)' }, }, rightPriceScale: { - borderColor: 'rgba(0, 180, 255, 0.1)', - textColor: 'rgba(100, 140, 195, 0.65)', + borderColor: 'rgba(99, 102, 241, 0.12)', + textColor: 'rgba(160, 162, 210, 0.65)', visible: true, }, timeScale: { - borderColor: 'rgba(0, 180, 255, 0.1)', + borderColor: 'rgba(99, 102, 241, 0.12)', visible: false, }, crosshair: { vertLine: { visible: false }, - horzLine: { color: 'rgba(0, 212, 255, 0.3)' }, + horzLine: { color: 'rgba(99, 102, 241, 0.40)' }, }, handleScroll: false, handleScale: false, diff --git a/apps/web/src/components/command-palette/CommandPalette.css.ts b/apps/web/src/components/command-palette/CommandPalette.css.ts new file mode 100644 index 0000000..82caeab --- /dev/null +++ b/apps/web/src/components/command-palette/CommandPalette.css.ts @@ -0,0 +1,188 @@ +import { globalStyle, keyframes, style } from '@vanilla-extract/css'; + +const backdropIn = keyframes({ + from: { opacity: 0 }, + to: { opacity: 1 }, +}); + +const panelIn = keyframes({ + from: { opacity: 0, transform: 'translateY(-12px) scale(0.97)' }, + to: { opacity: 1, transform: 'translateY(0) scale(1)' }, +}); + +export const overlay = style({ + position: 'fixed', + inset: 0, + zIndex: 9000, + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + paddingTop: 'clamp(60px, 12vh, 140px)', + background: 'rgba(3, 4, 18, 0.75)', + backdropFilter: 'blur(6px)', + animation: `${backdropIn} 120ms ease both`, +}); + +export const panel = style({ + width: '100%', + maxWidth: '560px', + borderRadius: 'var(--radius-lg)', + border: '1px solid rgba(99, 102, 241, 0.22)', + background: 'rgba(12, 15, 40, 0.98)', + boxShadow: + '0 32px 80px rgba(0, 0, 0, 0.75), 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 60px rgba(99, 102, 241, 0.08)', + overflow: 'hidden', + animation: `${panelIn} 160ms cubic-bezier(0.16, 1, 0.3, 1) both`, +}); + +export const inputWrap = style({ + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: '14px 18px', + borderBottom: '1px solid rgba(99, 102, 241, 0.12)', +}); + +export const inputIcon = style({ + flexShrink: 0, + color: 'rgba(99, 102, 241, 0.65)', + fontSize: '16px', + lineHeight: 1, + fontFamily: 'var(--font-data)', + userSelect: 'none', +}); + +export const input = style({ + flex: 1, + background: 'transparent', + border: 'none', + outline: 'none', + color: 'var(--text)', + font: '500 15px/1 var(--font-ui)', + caretColor: 'var(--accent)', + '::placeholder': { + color: 'rgba(160, 162, 210, 0.38)', + }, +} as any); + +export const kbdHint = style({ + flexShrink: 0, + font: '600 10px/1 var(--font-data)', + letterSpacing: '0.08em', + color: 'rgba(160, 162, 210, 0.45)', + background: 'rgba(99, 102, 241, 0.06)', + border: '1px solid rgba(99, 102, 241, 0.14)', + borderRadius: 'var(--radius-sm)', + padding: '4px 7px', + userSelect: 'none', +}); + +export const results = style({ + maxHeight: '320px', + overflowY: 'auto', + padding: '6px', +}); + +export const sectionLabel = style({ + padding: '8px 12px 4px', + font: '600 10px/1 var(--font-data)', + letterSpacing: '0.12em', + textTransform: 'uppercase', + color: 'rgba(160, 162, 210, 0.45)', + userSelect: 'none', +}); + +export const resultItem = style({ + display: 'flex', + alignItems: 'center', + gap: '12px', + padding: '10px 12px', + borderRadius: 'var(--radius)', + cursor: 'pointer', + transition: 'background 100ms ease', + border: '1px solid transparent', +}); + +export const resultItemActive = style({ + background: 'rgba(99, 102, 241, 0.12)', + borderColor: 'rgba(99, 102, 241, 0.18)', +}); + +globalStyle(`${resultItem}:hover`, { + background: 'rgba(99, 102, 241, 0.08)', +}); + +export const resultIcon = style({ + flexShrink: 0, + width: '28px', + height: '28px', + borderRadius: 'var(--radius-sm)', + background: 'rgba(99, 102, 241, 0.1)', + border: '1px solid rgba(99, 102, 241, 0.16)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + font: '700 11px/1 var(--font-data)', + letterSpacing: '0.04em', + color: 'rgba(99, 102, 241, 0.9)', +}); + +export const resultText = style({ + flex: 1, + minWidth: 0, +}); + +export const resultName = style({ + font: '500 14px/1 var(--font-ui)', + color: 'var(--text)', +}); + +export const resultHint = style({ + marginTop: '3px', + font: '400 12px/1 var(--font-ui)', + color: 'rgba(160, 162, 210, 0.55)', +}); + +export const resultEnter = style({ + flexShrink: 0, + font: '600 10px/1 var(--font-data)', + color: 'rgba(160, 162, 210, 0.35)', + background: 'rgba(99, 102, 241, 0.06)', + border: '1px solid rgba(99, 102, 241, 0.12)', + borderRadius: 'var(--radius-sm)', + padding: '4px 7px', + userSelect: 'none', +}); + +export const emptyState = style({ + padding: '32px 20px', + textAlign: 'center', + color: 'rgba(160, 162, 210, 0.4)', + font: '400 13px/1.5 var(--font-ui)', +}); + +export const footer = style({ + display: 'flex', + gap: '16px', + padding: '10px 18px', + borderTop: '1px solid rgba(99, 102, 241, 0.10)', + background: 'rgba(99, 102, 241, 0.03)', +}); + +export const footerHint = style({ + display: 'flex', + alignItems: 'center', + gap: '6px', + font: '400 11px/1 var(--font-ui)', + color: 'rgba(160, 162, 210, 0.38)', +}); + +export const footerKbd = style({ + font: '600 9px/1 var(--font-data)', + letterSpacing: '0.06em', + color: 'rgba(160, 162, 210, 0.55)', + background: 'rgba(99, 102, 241, 0.07)', + border: '1px solid rgba(99, 102, 241, 0.14)', + borderRadius: 'var(--radius-sm)', + padding: '3px 6px', +}); diff --git a/apps/web/src/components/command-palette/CommandPalette.tsx b/apps/web/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 0000000..eab85b1 --- /dev/null +++ b/apps/web/src/components/command-palette/CommandPalette.tsx @@ -0,0 +1,231 @@ +import type { AppLocale } from '@shared-types/trading.ts'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { copy as i18n } from '../../modules/console/console.i18n.tsx'; +import { listConsoleRoutes } from '../../modules/console/console.routes.tsx'; +import { + emptyState, + footer, + footerHint, + footerKbd, + input, + inputIcon, + inputWrap, + kbdHint, + overlay, + panel, + resultEnter, + resultIcon, + resultItem, + resultItemActive, + resultName, + results, + resultText, + sectionLabel, +} from './CommandPalette.css.ts'; + +/* ── Types ────────────────────────────────────────────── */ + +type CommandItem = { + id: string; + label: string; + hint: string; + icon: string; + path?: string; + action?: () => void; +}; + +/* ── Icon map ─────────────────────────────────────────── */ + +const ICONS: Record = { + dashboard: 'DB', + market: 'MK', + trading: 'TR', + strategies: 'ST', + backtest: 'BT', + risk: 'RK', + execution: 'EX', + agent: 'AI', + notifications: 'NT', + settings: 'CF', +}; + +const HINTS_ZH: Record = { + dashboard: '总览你的资产、信号与待审批', + market: '查看实时行情与技术面', + trading: '交易终端与 OHLCV 图表', + strategies: '策略评分与买卖信号', + backtest: '运行历史回测', + risk: '风控参数与事件日志', + execution: '实盘委托与执行日志', + agent: 'AI Agent 分析与对话', + notifications: '系统通知与事件流', + settings: '平台配置与权限管理', +}; + +const HINTS_EN: Record = { + dashboard: 'NAV, signals and pending approvals at a glance', + market: 'Live quotes and technicals', + trading: 'Trading terminal with OHLCV charts', + strategies: 'Strategy scores and buy/sell signals', + backtest: 'Run historical backtests', + risk: 'Risk parameters and event log', + execution: 'Live orders and execution log', + agent: 'AI agent analysis and conversation', + notifications: 'System notifications and event stream', + settings: 'Platform config and permissions', +}; + +/* ── Scoring ──────────────────────────────────────────── */ + +function score(label: string, hint: string, query: string): number { + const q = query.toLowerCase(); + const l = label.toLowerCase(); + const h = hint.toLowerCase(); + if (l.startsWith(q)) return 3; + if (l.includes(q)) return 2; + if (h.includes(q)) return 1; + return 0; +} + +/* ── Component ────────────────────────────────────────── */ + +type Props = { + locale: AppLocale; + onClose: () => void; +}; + +export function CommandPalette({ locale, onClose }: Props) { + const navigate = useNavigate(); + const [query, setQuery] = useState(''); + const [activeIdx, setActiveIdx] = useState(0); + const inputRef = useRef(null); + + const navCopy = i18n[locale].nav as Record; + const hints = locale === 'zh' ? HINTS_ZH : HINTS_EN; + + const allItems: CommandItem[] = listConsoleRoutes() + .filter((r) => r.includeInSidebar !== false || r.id !== 'strategy-detail') + .map((r) => ({ + id: r.id, + label: navCopy[r.id] ?? r.id, + hint: hints[r.id] ?? '', + icon: ICONS[r.id] ?? r.id.slice(0, 2).toUpperCase(), + path: r.path, + })); + + const filtered = query.trim() + ? allItems + .map((item) => ({ item, s: score(item.label, item.hint, query) })) + .filter(({ s }) => s > 0) + .sort((a, b) => b.s - a.s) + .map(({ item }) => item) + : allItems; + + // Reset active index when filtered list changes + useEffect(() => { + setActiveIdx(0); + }, [query]); + + // Focus input on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const commit = useCallback( + (item: CommandItem) => { + if (item.path) navigate(item.path); + if (item.action) item.action(); + onClose(); + }, + [navigate, onClose] + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIdx((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIdx((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + const item = filtered[activeIdx]; + if (item) commit(item); + } else if (e.key === 'Escape') { + onClose(); + } + }; + + const placeholder = locale === 'zh' ? '搜索页面或功能…' : 'Search pages or actions…'; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: overlay backdrop closes palette on click +
e.target === e.currentTarget && onClose()} + onKeyDown={(e) => e.key === 'Escape' && onClose()} + > +
+ {/* Search input */} +
+ + setQuery(e.target.value)} + /> + ESC +
+ + {/* Results */} +
+ {filtered.length > 0 ? ( + <> +
{locale === 'zh' ? '页面' : 'Pages'}
+ {filtered.map((item, idx) => ( + + ))} + + ) : ( +
+ {locale === 'zh' ? `没有找到"${query}"相关结果` : `No results for "${query}"`} +
+ )} +
+ + {/* Footer hints */} +
+ + ↑↓ + {locale === 'zh' ? '选择' : 'navigate'} + + + + {locale === 'zh' ? '跳转' : 'go'} + + + ESC + {locale === 'zh' ? '关闭' : 'close'} + +
+
+
+ ); +} diff --git a/apps/web/src/components/layout/ConsoleChrome.tsx b/apps/web/src/components/layout/ConsoleChrome.tsx index f3930fd..edcb6d0 100644 --- a/apps/web/src/components/layout/ConsoleChrome.tsx +++ b/apps/web/src/components/layout/ConsoleChrome.tsx @@ -14,8 +14,10 @@ import { translateMode, } from '../../modules/console/console.utils.ts'; import { useTradingSystem } from '../../store/trading-system/TradingSystemProvider.tsx'; +import { ApprovalDrawer } from '../approval-drawer/ApprovalDrawer.tsx'; import { EquityChart } from '../charts/EquityChart.tsx'; import { SignalBarChart } from '../charts/SignalBarChart.tsx'; +import { CommandPalette } from '../command-palette/CommandPalette.tsx'; import { appShell, appShellCollapsed, @@ -333,9 +335,11 @@ export function EmptyState({ icon, message, detail }: EmptyStateProps) { export function Layout() { const location = useLocation(); const { locale } = useLocale(); + const { state, approveLiveIntent, rejectLiveIntent } = useTradingSystem(); const [collapsed, setCollapsed] = useState(() => { return window.localStorage.getItem('quantpilot-sidebar-collapsed') === 'true'; }); + const [cmdOpen, setCmdOpen] = useState(false); const handleToggle = () => { setCollapsed((prev) => { @@ -349,6 +353,17 @@ export function Layout() { document.title = getConsoleDocumentTitle(locale, location.pathname); }, [locale, location.pathname]); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setCmdOpen((prev) => !prev); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + return (
@@ -356,6 +371,13 @@ export function Layout() { + {cmdOpen && setCmdOpen(false)} />} +
); } diff --git a/apps/web/src/components/toast/Toast.css.ts b/apps/web/src/components/toast/Toast.css.ts new file mode 100644 index 0000000..64b5fc2 --- /dev/null +++ b/apps/web/src/components/toast/Toast.css.ts @@ -0,0 +1,99 @@ +import { globalStyle, keyframes, style } from '@vanilla-extract/css'; + +const slideIn = keyframes({ + from: { opacity: 0, transform: 'translateX(40px)' }, + to: { opacity: 1, transform: 'translateX(0)' }, +}); + +const slideOut = keyframes({ + from: { opacity: 1, transform: 'translateX(0)' }, + to: { opacity: 0, transform: 'translateX(40px)' }, +}); + +export const toastContainer = style({ + position: 'fixed', + bottom: '24px', + right: '24px', + zIndex: 9500, + display: 'flex', + flexDirection: 'column', + gap: '8px', + maxWidth: '360px', + width: 'calc(100vw - 48px)', + pointerEvents: 'none', +}); + +export const toast = style({ + display: 'flex', + alignItems: 'flex-start', + gap: '10px', + padding: '12px 14px', + borderRadius: 'var(--radius)', + border: '1px solid', + background: 'rgba(10, 12, 30, 0.97)', + backdropFilter: 'blur(8px)', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.55)', + pointerEvents: 'auto', + animation: `${slideIn} 200ms cubic-bezier(0.16, 1, 0.3, 1) both`, +}); + +export const toastExiting = style({ + animation: `${slideOut} 160ms ease forwards`, +}); + +export const toastSuccess = style({ + borderColor: 'rgba(0, 232, 157, 0.25)', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.55), 0 0 20px rgba(0, 232, 157, 0.06)', +}); + +export const toastError = style({ + borderColor: 'rgba(255, 51, 88, 0.25)', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.55), 0 0 20px rgba(255, 51, 88, 0.06)', +}); + +export const toastInfo = style({ + borderColor: 'rgba(99, 102, 241, 0.25)', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.55), 0 0 20px rgba(99, 102, 241, 0.06)', +}); + +export const toastWarn = style({ + borderColor: 'rgba(255, 183, 0, 0.25)', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.55), 0 0 20px rgba(255, 183, 0, 0.06)', +}); + +export const toastIcon = style({ + flexShrink: 0, + font: '700 13px/1 var(--font-data)', + marginTop: '1px', +}); + +export const toastBody = style({ flex: 1, minWidth: 0 }); + +export const toastTitle = style({ + font: '600 13px/1.3 var(--font-ui)', + color: 'var(--text)', +}); + +export const toastDetail = style({ + marginTop: '3px', + font: '400 12px/1.4 var(--font-ui)', + color: 'rgba(160, 162, 210, 0.65)', +}); + +export const toastClose = style({ + flexShrink: 0, + background: 'transparent', + border: 'none', + color: 'rgba(160, 162, 210, 0.4)', + cursor: 'pointer', + font: '16px/1 var(--font-ui)', + padding: '0 2px', + marginTop: '-1px', + transition: 'color 140ms ease', + ':hover': { color: 'var(--text)' }, +} as any); + +globalStyle(`${toastSuccess} ${toastIcon}`, { color: 'var(--buy)' }); +globalStyle(`${toastError} ${toastIcon}`, { color: 'var(--sell)' }); +globalStyle(`${toastInfo} ${toastIcon}`, { color: 'var(--accent)' }); +globalStyle(`${toastWarn} ${toastIcon}`, { color: 'var(--hold)' }); diff --git a/apps/web/src/components/toast/Toast.tsx b/apps/web/src/components/toast/Toast.tsx new file mode 100644 index 0000000..2cdda68 --- /dev/null +++ b/apps/web/src/components/toast/Toast.tsx @@ -0,0 +1,115 @@ +import { createContext, useCallback, useContext, useState } from 'react'; +import { + toast, + toastBody, + toastClose, + toastContainer, + toastDetail, + toastError, + toastExiting, + toastIcon, + toastInfo, + toastSuccess, + toastTitle, + toastWarn, +} from './Toast.css.ts'; + +/* ── Types ────────────────────────────────────────────── */ + +export type ToastKind = 'success' | 'error' | 'info' | 'warn'; + +export type ToastItem = { + id: string; + kind: ToastKind; + title: string; + detail?: string; + durationMs?: number; +}; + +type ToastContextValue = { + push: (item: Omit) => void; +}; + +/* ── Context ──────────────────────────────────────────── */ + +const ToastContext = createContext(null); + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used inside ToastProvider'); + return ctx; +} + +/* ── Icons ────────────────────────────────────────────── */ + +const ICONS: Record = { + success: '✓', + error: '✕', + info: 'ℹ', + warn: '⚠', +}; + +const KIND_CLASS: Record = { + success: toastSuccess, + error: toastError, + info: toastInfo, + warn: toastWarn, +}; + +/* ── ToastItem component ──────────────────────────────── */ + +function ToastEntry({ + item, + onRemove, +}: { + item: ToastItem & { exiting?: boolean }; + onRemove: (id: string) => void; +}) { + return ( +
+ {ICONS[item.kind]} +
+
{item.title}
+ {item.detail &&
{item.detail}
} +
+ +
+ ); +} + +/* ── Provider ─────────────────────────────────────────── */ + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [items, setItems] = useState>([]); + + const remove = useCallback((id: string) => { + // Mark as exiting first, then remove after animation + setItems((prev) => prev.map((t) => (t.id === id ? { ...t, exiting: true } : t))); + setTimeout(() => setItems((prev) => prev.filter((t) => t.id !== id)), 200); + }, []); + + const push = useCallback( + (item: Omit) => { + const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const duration = item.durationMs ?? 4000; + setItems((prev) => [...prev, { ...item, id }]); + setTimeout(() => remove(id), duration); + }, + [remove] + ); + + return ( + + {children} + {items.length > 0 && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/modules/agent/AgentPage.css.ts b/apps/web/src/modules/agent/AgentPage.css.ts index feb38d1..2b1c950 100644 --- a/apps/web/src/modules/agent/AgentPage.css.ts +++ b/apps/web/src/modules/agent/AgentPage.css.ts @@ -1,14 +1,137 @@ -import { globalStyle, style } from '@vanilla-extract/css'; +import { globalStyle, keyframes, style } from '@vanilla-extract/css'; -/* ── AGENT LAYOUT ───────────────────────────────────────── */ +/* ── AGENT PAGE LAYOUT ──────────────────────────────────── */ -export const agentDialogueSection = style({ marginTop: '28px' }); +export const agentPageHero = style({ + display: 'grid', + gap: '12px', + padding: '24px 0 4px', +}); + +export const agentHeroTitle = style({ + font: '700 22px/1.2 var(--font-ui)', + color: 'var(--text)', + letterSpacing: '-0.02em', +}); + +export const agentHeroSub = style({ + font: '400 14px/1.5 var(--font-ui)', + color: 'var(--muted-strong)', + marginTop: '2px', +}); + +/* ── QUICK CHIPS ────────────────────────────────────────── */ + +export const agentQuickChips = style({ + display: 'flex', + gap: '8px', + flexWrap: 'wrap', + marginBottom: '4px', +}); + +export const agentQuickChip = style({ + padding: '6px 14px', + borderRadius: '999px', + border: '1px solid var(--line)', + background: 'rgba(255,255,255,0.03)', + color: 'var(--muted-strong)', + font: '13px/1 var(--font-ui)', + cursor: 'pointer', + transition: 'border-color 140ms ease, background 140ms ease, color 140ms ease', + whiteSpace: 'nowrap', + ':hover': { + borderColor: 'rgba(99,102,241,0.5)', + background: 'rgba(99,102,241,0.08)', + color: 'var(--text)', + }, + ':active': { transform: 'scale(0.97)' }, +}); + +/* ── ANALYSIS STEPPER ───────────────────────────────────── */ + +const pulse = keyframes({ + '0%,100%': { opacity: 1 }, + '50%': { opacity: 0.35 }, +}); + +export const agentStepper = style({ + display: 'flex', + alignItems: 'center', + gap: 0, + padding: '14px 20px', + borderRadius: 'var(--radius)', + border: '1px solid var(--line)', + background: 'rgba(8,10,24,0.6)', + marginBottom: '16px', + overflow: 'hidden', +}); + +export const agentStepperItem = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', + flex: 1, + minWidth: 0, +}); + +export const agentStepperDot = style({ + width: '8px', + height: '8px', + borderRadius: '50%', + background: 'var(--muted)', + flexShrink: 0, + transition: 'background 200ms ease', +}); + +export const agentStepperDotActive = style({ + background: '#6366f1', + boxShadow: '0 0 8px rgba(99,102,241,0.6)', + animationName: pulse, + animationDuration: '1.2s', + animationIterationCount: 'infinite', +}); + +export const agentStepperDotDone = style({ + background: '#22c55e', + boxShadow: '0 0 6px rgba(34,197,94,0.4)', +}); + +export const agentStepperLabel = style({ + font: '600 11px/1 var(--font-data)', + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: 'var(--muted)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + transition: 'color 200ms ease', +}); + +export const agentStepperLabelActive = style({ + color: '#818cf8', +}); + +export const agentStepperLabelDone = style({ + color: '#4ade80', +}); + +export const agentStepperConnector = style({ + width: '32px', + height: '1px', + background: 'var(--line)', + flexShrink: 0, + margin: '0 8px', +}); + +/* ── MAIN DUAL VIEW ─────────────────────────────────────── */ + +export const agentDialogueSection = style({ marginTop: '0' }); export const agentDualViewPanel = style({ overflow: 'hidden' }); export const agentDualView = style({ display: 'grid', - gridTemplateColumns: 'minmax(0, 1.6fr) minmax(280px, 1fr)', + gridTemplateColumns: 'minmax(0, 1.55fr) minmax(300px, 1fr)', gap: '20px', alignItems: 'stretch', }); @@ -17,160 +140,391 @@ export const agentDialogueStage = style({ display: 'flex', flexDirection: 'column', minWidth: 0, - height: '680px', + height: '640px', }); -/* ── AGENT STAGE HEADER ─────────────────────────────────── */ +/* ── CHAT TRANSCRIPT ─────────────────────────────────────── */ -export const agentStageHeader = style({ +export const agentChatTranscript = style({ + flex: 1, + minHeight: 0, + overflowY: 'auto', + border: '1px solid var(--line)', + borderRadius: 'var(--radius-lg)', + background: 'rgba(4,5,14,0.9)', + padding: '20px', + display: 'flex', + flexDirection: 'column', + gap: '10px', + animation: 'fade-in 200ms ease', +}); + +export const agentChatMessage = style({ + maxWidth: 'min(88%, 660px)', + border: '1px solid var(--line)', + borderRadius: 'var(--radius)', + padding: '12px 15px', + animation: 'fade-up 200ms ease both', +}); + +export const agentChatUser = style({ + justifySelf: 'end', + background: 'rgba(99,102,241,0.08)', + borderColor: 'rgba(99,102,241,0.28)', + borderLeft: '2px solid #6366f1', +}); + +export const agentChatAssistant = style({ + justifySelf: 'start', + background: 'rgba(255,255,255,0.03)', + borderColor: 'rgba(255,255,255,0.08)', + borderLeft: '2px solid rgba(255,255,255,0.2)', +}); + +export const agentChatSystem = style({ + justifySelf: 'center', + width: '100%', + maxWidth: 'none', + background: 'rgba(8,10,24,0.5)', + borderLeft: '2px solid var(--muted)', + borderColor: 'rgba(255,255,255,0.06)', +}); + +export const agentChatMuted = style({ + opacity: 0.65, +}); + +export const agentChatWarn = style({ + borderColor: 'rgba(251,191,36,0.3)', + borderLeft: '2px solid #f59e0b', + background: 'rgba(251,191,36,0.05)', +}); + +export const agentChatMeta = style({ display: 'flex', justifyContent: 'space-between', - gap: '18px', - alignItems: 'flex-start', + gap: '12px', + marginBottom: '6px', + color: 'var(--muted)', + font: '11px/1 var(--font-data)', + letterSpacing: '0.07em', + textTransform: 'uppercase', +}); + +export const agentChatBody = style({ + color: 'var(--text)', + fontSize: '14px', + lineHeight: '1.7', +}); + +/* ── COMPOSER ────────────────────────────────────────────── */ + +export const agentChatComposer = style({ + display: 'grid', + gap: '10px', padding: '14px 16px', - border: '1px solid rgba(0, 212, 255, 0.12)', - borderRadius: 'var(--radius)', - background: 'rgba(0, 212, 255, 0.03)', - transition: 'border-color 160ms ease', + border: '1px solid var(--line)', + borderRadius: 'var(--radius-lg)', + background: 'rgba(8,10,24,0.7)', flexShrink: 0, - marginBottom: '12px', - ':hover': { borderColor: 'rgba(40, 120, 220, 0.2)' }, + marginTop: '10px', + transition: 'border-color 160ms ease', + ':focus-within': { + borderColor: 'rgba(99,102,241,0.35)', + }, }); -export const agentStagePills = style({ +export const agentChatComposerActions = style({ display: 'flex', - gap: '8px', - flexWrap: 'wrap', - justifyContent: 'flex-end', + alignItems: 'center', + justifyContent: 'space-between', + gap: '12px', +}); + +export const agentChatTextarea = style({ + width: '100%', + minHeight: '52px', + padding: '10px 13px', + border: '1px solid transparent', + borderRadius: 'var(--radius)', + background: 'transparent', + color: 'var(--text)', + font: '14px/1.6 var(--font-ui)', + resize: 'none', + outline: 'none', + '::placeholder': { color: 'var(--muted)' }, }); -/* ── AGENT INSIGHT RAIL ─────────────────────────────────── */ +export const agentSendButton = style({ + display: 'flex', + alignItems: 'center', + gap: '6px', + padding: '9px 20px', + borderRadius: 'var(--radius)', + border: 'none', + background: '#6366f1', + color: '#fff', + font: '600 13px/1 var(--font-ui)', + cursor: 'pointer', + flexShrink: 0, + transition: 'background 150ms ease, transform 120ms ease, opacity 150ms ease', + ':hover': { background: '#4f46e5' }, + ':active': { transform: 'scale(0.97)' }, + ':disabled': { opacity: 0.45, cursor: 'not-allowed', transform: 'none' }, +}); + +/* ── INSIGHT RAIL ────────────────────────────────────────── */ export const agentInsightRail = style({ display: 'grid', gap: '14px', alignContent: 'start', overflowY: 'auto', - maxHeight: '680px', + maxHeight: '640px', }); export const agentInsightCard = style({ display: 'grid', gap: '14px', - padding: '16px', + padding: '18px', border: '1px solid var(--line)', borderRadius: 'var(--radius)', - background: 'rgba(8, 18, 38, 0.85)', - transition: 'border-color 160ms ease, box-shadow 160ms ease', - ':hover': { - borderColor: 'rgba(40, 120, 220, 0.2)', - boxShadow: '0 0 16px rgba(0, 212, 255, 0.05)', - }, + background: 'rgba(8,10,24,0.7)', + transition: 'border-color 150ms ease', + ':hover': { borderColor: 'rgba(99,102,241,0.2)' }, }); export const agentInsightHeader = style({ display: 'flex', justifyContent: 'space-between', - gap: '14px', + gap: '12px', alignItems: 'flex-start', }); -/* ── AGENT PULSE GRID ───────────────────────────────────── */ +/* ── INSIGHT ANALYSIS RESULT ─────────────────────────────── */ + +export const agentThesis = style({ + font: '600 15px/1.5 var(--font-ui)', + color: 'var(--text)', + letterSpacing: '-0.01em', +}); + +export const agentRationaleList = style({ + display: 'grid', + gap: '6px', + paddingLeft: '0', + listStyle: 'none', +}); + +export const agentRationaleItem = style({ + display: 'flex', + alignItems: 'flex-start', + gap: '8px', + font: '13px/1.5 var(--font-ui)', + color: 'var(--muted-strong)', + '::before': { + content: '"·"', + color: '#6366f1', + fontWeight: 700, + flexShrink: 0, + marginTop: '1px', + }, +}); + +export const agentWarningItem = style({ + display: 'flex', + alignItems: 'flex-start', + gap: '8px', + padding: '8px 10px', + borderRadius: 'var(--radius)', + background: 'rgba(251,191,36,0.06)', + border: '1px solid rgba(251,191,36,0.15)', + font: '13px/1.5 var(--font-ui)', + color: '#fbbf24', + '::before': { + content: '"⚠"', + flexShrink: 0, + fontSize: '11px', + marginTop: '1px', + }, +}); + +export const agentNextStep = style({ + padding: '10px 12px', + borderRadius: 'var(--radius)', + background: 'rgba(99,102,241,0.06)', + border: '1px solid rgba(99,102,241,0.18)', + font: '13px/1.5 var(--font-ui)', + color: '#a5b4fc', +}); + +/* ── ACTION BUTTONS ──────────────────────────────────────── */ + +export const agentActionButtons = style({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: '8px', +}); + +export const agentActionBtn = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '4px', + padding: '12px 8px', + borderRadius: 'var(--radius)', + border: '1px solid var(--line)', + background: 'rgba(255,255,255,0.025)', + cursor: 'pointer', + transition: 'border-color 140ms ease, background 140ms ease, transform 120ms ease', + ':hover': { + borderColor: 'rgba(99,102,241,0.4)', + background: 'rgba(99,102,241,0.07)', + transform: 'translateY(-1px)', + }, + ':active': { transform: 'translateY(0) scale(0.98)' }, + ':disabled': { opacity: 0.4, cursor: 'not-allowed', transform: 'none' }, +}); + +export const agentActionBtnLabel = style({ + font: '600 11px/1 var(--font-data)', + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: 'var(--muted-strong)', +}); + +export const agentActionBtnSub = style({ + font: '11px/1 var(--font-data)', + color: 'var(--muted)', + textAlign: 'center', +}); + +export const agentActionBtnIcon = style({ + fontSize: '16px', + lineHeight: 1, + marginBottom: '2px', +}); + +/* ── PULSE GRID ──────────────────────────────────────────── */ export const agentPulseGrid = style({ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', - gap: '10px', + gap: '8px', }); export const agentPulseItem = style({ display: 'grid', gap: '4px', - padding: '12px 13px', + padding: '10px 12px', borderRadius: 'var(--radius)', border: '1px solid var(--line)', - background: 'rgba(2, 6, 18, 0.75)', - transition: 'border-color 150ms ease', - ':hover': { borderColor: 'rgba(40, 120, 220, 0.22)' }, + background: 'rgba(4,5,14,0.6)', + transition: 'border-color 140ms ease', + ':hover': { borderColor: 'rgba(99,102,241,0.2)' }, }); globalStyle(`${agentPulseItem} span`, { color: 'var(--muted)', font: '600 10px/1 var(--font-data)', - letterSpacing: '0.12em', + letterSpacing: '0.1em', textTransform: 'uppercase', }); globalStyle(`${agentPulseItem} strong`, { - font: '600 13px/1 var(--font-data)', + font: '600 12px/1.2 var(--font-data)', color: 'var(--text)', - animation: 'tick-up 200ms ease 100ms both', + wordBreak: 'break-all', }); -/* ── AGENT STEP STACK ───────────────────────────────────── */ +/* ── PLAN STEPS ──────────────────────────────────────────── */ -export const agentStepStack = style({ display: 'grid', gap: '10px' }); +export const agentStepStack = style({ display: 'grid', gap: '8px' }); export const agentStepCard = style({ display: 'grid', - gap: '8px', - padding: '12px 13px', + gap: '6px', + padding: '10px 12px', borderRadius: 'var(--radius)', border: '1px solid var(--line)', - background: 'rgba(2, 6, 18, 0.75)', - transition: 'border-color 150ms ease, background 150ms ease', - ':hover': { - borderColor: 'rgba(40, 120, 220, 0.22)', - background: 'rgba(0, 212, 255, 0.02)', - }, + background: 'rgba(4,5,14,0.6)', + transition: 'border-color 140ms ease', + ':hover': { borderColor: 'rgba(99,102,241,0.2)' }, }); export const agentStepTop = style({ display: 'flex', justifyContent: 'space-between', - gap: '10px', + gap: '8px', alignItems: 'flex-start', }); +globalStyle(`${agentStepTop} strong`, { + font: '600 12px/1.4 var(--font-ui)', + color: 'var(--text)', + flex: 1, +}); + globalStyle(`${agentStepTop} span`, { - color: 'var(--muted)', font: '600 10px/1 var(--font-data)', - letterSpacing: '0.12em', + letterSpacing: '0.08em', textTransform: 'uppercase', + color: 'var(--muted)', + flexShrink: 0, + paddingTop: '1px', }); export const agentStepCopy = style({ color: 'var(--muted-strong)', - fontSize: '13px', - lineHeight: '1.6', + fontSize: '12px', + lineHeight: '1.5', }); -/* ── AGENT HANDOFF & SUGGESTIONS ────────────────────────── */ +/* ── HANDOFF SECTION ─────────────────────────────────────── */ -export const agentHandoffActions = style({ display: 'grid', gap: '12px' }); -export const agentSuggestionList = style({ display: 'grid', gap: '8px' }); +export const agentHandoffActions = style({ display: 'grid', gap: '10px' }); + +export const agentRequestApprovalBtn = style({ + width: '100%', + padding: '11px', + borderRadius: 'var(--radius)', + border: '1px solid rgba(251,191,36,0.3)', + background: 'rgba(251,191,36,0.06)', + color: '#fbbf24', + font: '600 13px/1 var(--font-ui)', + cursor: 'pointer', + transition: 'border-color 140ms ease, background 140ms ease', + ':hover': { + borderColor: 'rgba(251,191,36,0.5)', + background: 'rgba(251,191,36,0.1)', + }, + ':disabled': { opacity: 0.4, cursor: 'not-allowed' }, +}); + +/* ── SUGGESTION LIST ─────────────────────────────────────── */ + +export const agentSuggestionList = style({ display: 'grid', gap: '6px' }); export const agentSuggestionButton = style({ width: '100%', textAlign: 'left', - padding: '12px 13px 12px 15px', + padding: '10px 12px 10px 14px', borderRadius: 'var(--radius)', border: '1px solid var(--line)', - background: 'rgba(2, 6, 18, 0.75)', - color: 'var(--text)', - font: 'inherit', + background: 'rgba(4,5,14,0.5)', + color: 'var(--muted-strong)', + font: '13px/1.4 var(--font-ui)', cursor: 'pointer', position: 'relative', overflow: 'hidden', - transition: - 'border-color 150ms ease, box-shadow 150ms ease, background 150ms ease, transform 120ms ease', + transition: 'border-color 130ms ease, background 130ms ease, color 130ms ease', ':hover': { - borderColor: 'rgba(0, 212, 255, 0.22)', - boxShadow: '0 0 14px rgba(0, 212, 255, 0.08)', - background: 'rgba(0, 212, 255, 0.03)', - transform: 'translateX(3px)', + borderColor: 'rgba(99,102,241,0.35)', + background: 'rgba(99,102,241,0.06)', + color: 'var(--text)', }, - ':active': { transform: 'translateX(2px) scale(0.99)' }, + ':active': { transform: 'scale(0.99)' }, }); globalStyle(`${agentSuggestionButton}::before`, { @@ -180,124 +534,39 @@ globalStyle(`${agentSuggestionButton}::before`, { top: 0, bottom: 0, width: '2px', - background: 'var(--accent)', + background: '#6366f1', transform: 'scaleY(0)', transformOrigin: 'center', - transition: 'transform 150ms ease', + transition: 'transform 130ms ease', }); globalStyle(`${agentSuggestionButton}:hover::before`, { transform: 'scaleY(1)', }); -/* ── AGENT CHAT TRANSCRIPT ──────────────────────────────── */ +/* ── SESSION STAGE HEADER ────────────────────────────────── */ -export const agentChatTranscript = style({ - flex: 1, - minHeight: 0, - overflowY: 'auto', - border: '1px solid rgba(0, 212, 255, 0.16)', - borderRadius: 'var(--radius-lg)', - background: 'rgba(1, 3, 10, 0.92)', - padding: '20px', +export const agentStageHeader = style({ display: 'flex', - flexDirection: 'column', - gap: '12px', - animation: 'fade-in 200ms ease', - boxShadow: - 'inset 0 0 60px rgba(0,0,0,.5), 0 0 28px rgba(0,212,255,.06), 0 0 0 1px rgba(0,212,255,.04)', -}); - -export const agentChatMessage = style({ - maxWidth: 'min(92%, 680px)', + justifyContent: 'space-between', + gap: '16px', + alignItems: 'flex-start', + padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 'var(--radius)', - padding: '14px 16px', - animation: 'fade-up 200ms ease both', - transition: 'border-color 150ms ease', - ':hover': { borderColor: 'rgba(40, 120, 220, 0.22)' }, -}); - -export const agentChatUser = style({ - justifySelf: 'end', - background: 'rgba(0, 212, 255, 0.06)', - borderColor: 'rgba(0, 212, 255, 0.18)', - borderLeft: '2px solid var(--accent)', - boxShadow: 'inset -4px 0 20px rgba(0,212,255,.05), 0 2px 8px rgba(0,0,0,.3)', -}); - -export const agentChatAssistant = style({ - justifySelf: 'start', - background: 'rgba(255, 183, 0, 0.04)', - borderColor: 'rgba(255, 183, 0, 0.12)', - borderLeft: '2px solid var(--accent-2)', - boxShadow: '0 2px 8px rgba(0,0,0,.3)', -}); - -export const agentChatSystem = style({ - justifySelf: 'center', - width: '100%', - maxWidth: 'none', - background: 'rgba(8, 18, 38, 0.8)', - borderLeft: '2px solid var(--muted)', -}); - -export const agentChatMuted = style({ borderColor: 'var(--line)' }); - -export const agentChatWarn = style({ - borderColor: 'rgba(255, 183, 0, 0.2)', - borderLeft: '2px solid var(--hold)', -}); - -export const agentChatMeta = style({ - display: 'flex', - justifyContent: 'space-between', - gap: '12px', - marginBottom: '8px', - color: 'var(--muted)', - font: '11px/1 var(--font-data)', - letterSpacing: '0.08em', - textTransform: 'uppercase', -}); - -export const agentChatBody = style({ - color: 'var(--text)', - fontSize: '14px', - lineHeight: '1.7', -}); - -export const agentChatComposer = style({ - display: 'grid', - gap: '12px', - padding: '16px', - border: '1px solid rgba(0, 212, 255, 0.1)', - borderRadius: 'var(--radius-lg)', - background: 'rgba(4, 10, 24, 0.8)', + background: 'rgba(8,10,24,0.5)', flexShrink: 0, - marginTop: '12px', + marginBottom: '10px', }); -export const agentChatComposerActions = style({ +export const agentStagePills = style({ display: 'flex', + gap: '6px', + flexWrap: 'wrap', + justifyContent: 'flex-end', alignItems: 'center', - justifyContent: 'space-between', - gap: '16px', }); -export const agentChatTextarea = style({ - width: '100%', - minHeight: '56px', - padding: '10px 13px', - border: '1px solid var(--line)', - borderRadius: 'var(--radius)', - background: 'rgba(2, 6, 18, 0.85)', - color: 'var(--text)', - font: '14px/1.6 var(--font-ui)', - resize: 'vertical', - outline: 'none', - transition: 'border-color 160ms ease, box-shadow 160ms ease', - ':focus': { - borderColor: 'rgba(0, 212, 255, 0.32)', - boxShadow: '0 0 0 2px rgba(0, 212, 255, 0.06)', - }, -}); +/* ── KEEP LEGACY EXPORTS for compatibility ───────────────── */ +// (used in governance / other panels that reference old names) +export const agentInsightRailLegacy = agentInsightRail; diff --git a/apps/web/src/modules/agent/AgentPage.tsx b/apps/web/src/modules/agent/AgentPage.tsx index 9867514..1508622 100644 --- a/apps/web/src/modules/agent/AgentPage.tsx +++ b/apps/web/src/modules/agent/AgentPage.tsx @@ -1,14 +1,14 @@ -import { useState } from 'react'; -import { - EmptyState, - SectionHeader, - TabPanel, - TopMeta, -} from '../../components/layout/ConsoleChrome.tsx'; +import { useRef, useState } from 'react'; +import { EmptyState, SectionHeader, TopMeta } from '../../components/layout/ConsoleChrome.tsx'; import { useTradingSystem } from '../../store/trading-system/TradingSystemProvider.tsx'; import { copy, useLocale } from '../console/console.i18n.tsx'; import { translateRiskLevel } from '../console/console.utils.ts'; import { + agentActionBtn, + agentActionBtnIcon, + agentActionBtnLabel, + agentActionBtnSub, + agentActionButtons, agentChatAssistant, agentChatBody, agentChatComposer, @@ -26,37 +26,151 @@ import { agentDualView, agentDualViewPanel, agentHandoffActions, + agentHeroSub, + agentHeroTitle, agentInsightCard, agentInsightHeader, agentInsightRail, + agentNextStep, + agentPageHero, agentPulseGrid, agentPulseItem, + agentQuickChip, + agentQuickChips, + agentRationaleItem, + agentRationaleList, + agentRequestApprovalBtn, + agentSendButton, agentStageHeader, agentStagePills, agentStepCard, agentStepCopy, + agentStepper, + agentStepperConnector, + agentStepperDot, + agentStepperDotActive, + agentStepperDotDone, + agentStepperItem, + agentStepperLabel, + agentStepperLabelActive, + agentStepperLabelDone, agentStepStack, agentStepTop, agentSuggestionButton, agentSuggestionList, + agentThesis, + agentWarningItem, } from './AgentPage.css.ts'; import { useAgentTools } from './useAgentTools.ts'; const promptSuggestions = { zh: [ + '帮我分析 AAPL 近期走势', '总结今天亏损原因', '给我一个更稳健的参数组合', '把回撤控制在 8% 内重算策略', '明天开盘前生成执行计划', ], en: [ + 'Analyze recent AAPL price action', "Summarize today's loss drivers", 'Suggest a more robust parameter set', - 'Recompute the strategy with max drawdown capped at 8%', - "Generate the execution plan before tomorrow's open", + 'Recompute strategy with max drawdown capped at 8%', + "Generate execution plan before tomorrow's open", ], }; +type StepperState = 'pending' | 'active' | 'done'; + +function AnalysisStepper({ + locale, + running, + sessionStatus, + planStatus, + analysisStatus, +}: { + locale: 'zh' | 'en'; + running: boolean; + sessionStatus: string; + planStatus: string; + analysisStatus: string; +}) { + const intentDone = Boolean(sessionStatus && sessionStatus !== ''); + const planDone = planStatus === 'completed'; + const analysisDone = analysisStatus === 'completed'; + + let intentState: StepperState = 'pending'; + let planState: StepperState = 'pending'; + let analysisState: StepperState = 'pending'; + + if (running) { + if (!intentDone) { + intentState = 'active'; + } else if (!planDone) { + intentState = 'done'; + planState = 'active'; + } else { + intentState = 'done'; + planState = 'done'; + analysisState = 'active'; + } + } else if (analysisDone) { + intentState = 'done'; + planState = 'done'; + analysisState = 'done'; + } else if (planDone) { + intentState = 'done'; + planState = 'done'; + } else if (intentDone) { + intentState = 'done'; + } + + const steps: { key: string; label: { zh: string; en: string }; state: StepperState }[] = [ + { key: 'intent', label: { zh: '意图解析', en: 'Intent' }, state: intentState }, + { key: 'plan', label: { zh: '制定计划', en: 'Planning' }, state: planState }, + { key: 'analysis', label: { zh: 'AI 分析', en: 'Analysis' }, state: analysisState }, + ]; + + return ( +
+ {steps.map((step, idx) => ( +
+
+
+ + {step.label[locale]} + +
+ {idx < steps.length - 1 &&
} +
+ ))} +
+ ); +} + function buildAgentConversation({ locale, prompt, @@ -116,16 +230,19 @@ function buildAgentConversation({ } const fallbackMessages = []; - fallbackMessages.push({ - key: 'system-session', - role: 'system', - label: locale === 'zh' ? '系统' : 'System', - body: - locale === 'zh' - ? `当前会话状态:${sessionStatus || '未选择'};最近 intent:${intentKind || '--'};plan:${planStatus || '--'}。` - : `Current session status: ${sessionStatus || 'unselected'}; latest intent: ${intentKind || '--'}; plan: ${planStatus || '--'}.`, - tone: 'muted', - }); + + if (sessionStatus) { + fallbackMessages.push({ + key: 'system-session', + role: 'system', + label: locale === 'zh' ? '系统' : 'System', + body: + locale === 'zh' + ? `会话状态:${sessionStatus};intent:${intentKind || '--'};plan:${planStatus || '--'}` + : `Session: ${sessionStatus}; intent: ${intentKind || '--'}; plan: ${planStatus || '--'}`, + tone: 'muted', + }); + } if (prompt.trim()) { fallbackMessages.push({ @@ -141,11 +258,11 @@ function buildAgentConversation({ fallbackMessages.push({ key: 'assistant-running', role: 'assistant', - label: locale === 'zh' ? 'Agent' : 'Agent', + label: 'Agent', body: locale === 'zh' - ? '正在解析意图、生成计划,并串行执行白名单只读工具。' - : 'Parsing intent, creating a plan, and running allowlisted read-only tools.', + ? '正在解析意图、生成计划,并调用工具收集数据中…' + : 'Parsing intent, building a plan, and gathering data with tools…', tone: 'muted', }); } @@ -159,12 +276,16 @@ function buildAgentConversation({ actionRequestSummary ) { const body = [ - thesis ? thesis : locale === 'zh' ? '本轮分析已经完成。' : 'This analysis run has completed.', - summary ? summary : null, - rationale.length ? `${locale === 'zh' ? '理由' : 'Rationale'}: ${rationale.join(' ')}` : null, - warnings.length ? `${locale === 'zh' ? '警告' : 'Warnings'}: ${warnings.join(' ')}` : null, + thesis || (locale === 'zh' ? '本轮分析已完成。' : 'Analysis complete.'), + summary || null, + rationale.length + ? `${locale === 'zh' ? '分析依据' : 'Rationale'}: ${rationale.join(' ')}` + : null, + warnings.length + ? `${locale === 'zh' ? '风险提示' : 'Warnings'}: ${warnings.join(' ')}` + : null, recommendedNextStep - ? `${locale === 'zh' ? '下一步' : 'Next step'}: ${recommendedNextStep}` + ? `${locale === 'zh' ? '建议下一步' : 'Next step'}: ${recommendedNextStep}` : null, actionRequestSummary ? `${locale === 'zh' ? '审批请求' : 'Action request'}: ${actionRequestSummary} (${actionRequestStatus || '--'})` @@ -176,7 +297,7 @@ function buildAgentConversation({ fallbackMessages.push({ key: 'assistant-summary', role: 'assistant', - label: locale === 'zh' ? 'Agent' : 'Agent', + label: 'Agent', body, tone: warnings.length ? 'warn' : 'default', }); @@ -186,7 +307,7 @@ function buildAgentConversation({ fallbackMessages.push({ key: 'system-error', role: 'system', - label: locale === 'zh' ? '工作台提示' : 'Workbench Alert', + label: locale === 'zh' ? '提示' : 'Notice', body: error, tone: 'warn', }); @@ -198,8 +319,9 @@ function buildAgentConversation({ export default function AgentPage() { const { state, session } = useTradingSystem(); const { locale } = useLocale(); + const transcriptRef = useRef(null); const { - tools, + tools: _tools, workbench, sessionDetail, selectedSessionId, @@ -212,7 +334,7 @@ export default function AgentPage() { requestAction, refresh, } = useAgentTools(); - const [prompt, setPrompt] = useState(promptSuggestions[locale][0]); + const [prompt, setPrompt] = useState(''); const summary = workbench?.summary; const authorityState = workbench?.authorityState || null; @@ -224,14 +346,6 @@ export default function AgentPage() { const recentSessions = Array.isArray(workbench?.queues.recentSessions) ? workbench?.queues.recentSessions : []; - const pendingRequests = Array.isArray(workbench?.queues.pendingActionRequests) - ? workbench?.queues.pendingActionRequests - : []; - const recentExplanations = Array.isArray(workbench?.recentExplanations) - ? workbench.recentExplanations - : []; - const timeline = Array.isArray(sessionDetail?.timeline) ? sessionDetail.timeline : []; - const runbook = Array.isArray(workbench?.runbook) ? workbench.runbook : []; const latestRationale = Array.isArray(latestExplanation?.rationale) ? latestExplanation.rationale : []; @@ -244,6 +358,7 @@ export default function AgentPage() { const evidence = Array.isArray(sessionDetail?.latestAnalysisRun?.evidence) ? sessionDetail.latestAnalysisRun.evidence : []; + const conversation = buildAgentConversation({ locale, prompt, @@ -261,6 +376,7 @@ export default function AgentPage() { actionRequestSummary: latestActionRequest?.summary || '', actionRequestStatus: latestActionRequest?.status || '', }); + const canRequestAction = Boolean( sessionDetail?.session.id && sessionDetail?.latestPlan?.requiresApproval && @@ -269,11 +385,21 @@ export default function AgentPage() { latestActionRequest?.status !== 'pending_review' ); + const hasAnalysis = Boolean(latestExplanation?.thesis); + const submitPrompt = async () => { - const result = await runPrompt(prompt, session?.user.id); + const trimmed = prompt.trim(); + if (!trimmed) return; + const result = await runPrompt(trimmed, session?.user.id); if (result?.session?.prompt) { - setPrompt(result.session.prompt); + setPrompt(''); } + // scroll transcript to bottom + requestAnimationFrame(() => { + if (transcriptRef.current) { + transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; + } + }); }; const submitActionRequest = async () => { @@ -303,199 +429,116 @@ export default function AgentPage() { value: String(summary?.runningSessions ?? 0), }, { - label: locale === 'zh' ? '待审批请求' : 'Pending Requests', + label: locale === 'zh' ? '待审批' : 'Pending', value: String(summary?.pendingActionRequests ?? 0), }, ]} /> -
-
-
{locale === 'zh' ? 'Workbench' : 'Workbench'}
-
- {locale === 'zh' - ? `${summary?.completedSessions ?? 0} 个会话已完成分析` - : `${summary?.completedSessions ?? 0} sessions completed analysis`} + {/* Hero */} +
+
+
+ {locale === 'zh' ? 'AI 投研助手' : 'AI Research Assistant'}
-
+
{locale === 'zh' - ? '这里现在已经是正式的 Agent 协作工作台:会话、解释、待审批请求和 operator trail 都来自后端 workbench 聚合,不再只是工具演示页。' - : 'This is now a real Agent collaboration workbench: sessions, explanations, pending requests, and the operator trail all come from backend workbench aggregation instead of a simple tool demo.'} + ? '用自然语言描述你的交易想法,Agent 负责分析、制定计划、并提供可执行建议。' + : 'Describe your trading idea in plain language. Agent analyzes, plans, and delivers actionable insights.'}
-
+
+ + {/* Quick prompt chips */} +
+ {promptSuggestions[locale].map((item) => ( - - {locale === 'zh' ? '最近会话' : 'Recent Sessions'} - - - {locale === 'zh' ? '解释详情' : 'Explanation Detail'} - - - {locale === 'zh' ? '轨迹时间线' : 'Operator Timeline'} - -
+ ))}
-
-
- {locale === 'zh' ? 'Latest Explanation' : 'Latest Explanation'} -
-
- {latestExplanation?.thesis || - (locale === 'zh' ? '等待新的分析结果' : 'Waiting for the next analysis result')} -
-
- {locale === 'zh' - ? latestExplanation?.recommendedNextStep || - '运行一次新的分析后,这里会显示结构化解释和建议的下一步动作。' - : latestExplanation?.recommendedNextStep || - 'Run a new analysis to surface a structured explanation and the recommended next action here.'} -
-
-
+
-
-
-
-
-
- {locale === 'zh' ? 'Agent Governance' : 'Agent Governance'} -
-
- {locale === 'zh' - ? '查看当前 Agent 授权模式(Authority Mode)和今日运营指令(Daily Bias),确保 Agent 行为在人工监督范围内。' - : 'Review the current Agent authority mode and active daily bias instructions to keep Agent behaviour within human-supervised bounds.'} -
-
- - {authorityState?.mode || 'manual_only'} - -
-
-
-
- {locale === 'zh' ? 'Authority Mode' : 'Authority Mode'} - - {authorityState?.reason || - (locale === 'zh' - ? '尚未配置 Agent 治理策略。' - : 'No agent governance policy configured.')} - -
-
- {locale === 'zh' ? '模式' : 'Mode'} - {authorityState?.mode || 'manual_only'} -
-
- {locale === 'zh' ? '策略数' : 'Policies'} - {authorityState?.policies?.length ?? 0} -
-
-
-
- {locale === 'zh' ? 'Daily Bias' : 'Daily Bias'} - - {dailyBiasInstructions.length - ? locale === 'zh' - ? `${dailyBiasInstructions.length} 条活跃的今日运营指令正在影响本次会话。` - : `${dailyBiasInstructions.length} active daily bias instruction${dailyBiasInstructions.length > 1 ? 's' : ''} affecting this session.` - : locale === 'zh' - ? '当前没有活跃的今日运营指令。' - : 'No active daily bias instructions for this session.'} - -
-
- {locale === 'zh' ? '条数' : 'Count'} - {dailyBiasInstructions.length} -
-
- {dailyBiasInstructions.map((item) => ( -
-
- {item.title} - {item.body} -
-
- {locale === 'zh' ? '有效至' : 'Active Until'} - {item.activeUntil ? item.activeUntil.slice(0, 10) : '--'} -
-
- ))} -
-
-
+ {/* Analysis stepper */} + + {/* Dual-panel main area */}
- {locale === 'zh' ? 'Agent Dialogue' : 'Agent Dialogue'} + {locale === 'zh' ? '对话工作台' : 'Agent Workspace'}
{locale === 'zh' - ? '左侧保持连续对话,右侧固定展示当前会话洞察、计划步骤和受控交接,让聊天和运营工作台同时成立。' - : 'Keep the running conversation on the left while the right rail stays anchored on session insight, plan steps, and controlled handoff.'} + ? '左侧保持连续对话,右侧展示当前会话的洞察卡、计划步骤和操作入口。' + : 'Continuous conversation on the left; session insight, plan steps, and actions on the right.'}
- {running ? 'RUNNING' : 'READY'} + {running + ? locale === 'zh' + ? '运行中' + : 'RUNNING' + : locale === 'zh' + ? '就绪' + : 'READY'}
+
+ {/* Left: chat */}
- {locale === 'zh' ? 'Conversation Thread' : 'Conversation Thread'} + {locale === 'zh' ? '对话记录' : 'Conversation'}
-
+
{locale === 'zh' - ? '把每一次请求、规划、读取工具和最终解释都沉淀成连续线程。' - : 'Capture every request, planning step, tool read, and final explanation as one continuous thread.'} + ? '每次请求、规划、工具调用和分析结果都沉淀在此。' + : 'Every request, plan, tool call, and analysis result is recorded here.'}
- {sessionDetail?.session.status || - (locale === 'zh' ? '未选择会话' : 'No session')} - - - {sessionDetail?.session.latestIntent.kind || '--'} + {sessionDetail?.session.status || (locale === 'zh' ? '无会话' : 'No session')} + {sessionDetail?.session.latestIntent.kind && ( + {sessionDetail.session.latestIntent.kind} + )}
-
- {!conversation.length ? ( + +
+ {!conversation.length && ( - ) : null} + )} {conversation.map((message) => (
))}
- + + {/* Composer */}