diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index e2c4cdc..632f54c 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -49,7 +49,7 @@ import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal' import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal'; import { buildChartBacktestReportModel } from '../utils/backtestReportModel'; import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup'; -import StrategyEvaluationAiVerifyModal from './strategyEvaluation/StrategyEvaluationAiVerifyModal'; +import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel'; import { buildStrategyEvaluationAiVerifyContext, requestStrategyEvaluationAiVerify, @@ -65,6 +65,7 @@ const LEFT_DEFAULT = 380; const RIGHT_DEFAULT = 440; type LeftTab = 'strategy' | 'market' | 'settings'; +type RightTab = 'signal' | 'ai'; function readMinWidth(key: string, fallback: number): number { return Math.max(readStoredSize(key, fallback), fallback); @@ -99,16 +100,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const [reportOpen, setReportOpen] = useState(false); const [reportModel, setReportModel] = useState(null); const [reportLoading, setReportLoading] = useState(false); - const [aiVerifyOpen, setAiVerifyOpen] = useState(false); + const [rightTab, setRightTab] = useState('signal'); + const [aiVerifyUnread, setAiVerifyUnread] = useState(false); const [aiVerifyRunning, setAiVerifyRunning] = useState(false); const [aiVerifyError, setAiVerifyError] = useState(null); const [aiVerifyResult, setAiVerifyResult] = useState(null); const [aiVerifyContext, setAiVerifyContext] = useState(null); const [aiVerifySummary, setAiVerifySummary] = useState(null); - const [aiVerifyCompleteNotice, setAiVerifyCompleteNotice] = useState(false); - const [aiVerifyErrorNotice, setAiVerifyErrorNotice] = useState(null); const aiVerifyGenRef = useRef(0); - const aiVerifyOpenRef = useRef(aiVerifyOpen); + const rightTabRef = useRef('signal'); const aiVerifyPollAbortRef = useRef(null); const reportCacheKeyRef = useRef(null); const reportGenRef = useRef(0); @@ -122,12 +122,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { }, []); useEffect(() => { - aiVerifyOpenRef.current = aiVerifyOpen; - if (aiVerifyOpen) { - setAiVerifyCompleteNotice(false); - setAiVerifyErrorNotice(null); + rightTabRef.current = rightTab; + if (rightTab === 'ai') { + setAiVerifyUnread(false); } - }, [aiVerifyOpen]); + }, [rightTab]); const getEvalParams = useMemo( () => mergeEvalGetParams(baseGetParams, appliedIndicatorParams), @@ -415,13 +414,13 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { aiVerifyPollAbortRef.current = pollAbort; const runId = ++aiVerifyGenRef.current; - setAiVerifyOpen(true); + setRightTab('ai'); + setRightOpen(true); setAiVerifyRunning(true); setAiVerifyError(null); setAiVerifyResult(null); setAiVerifyContext(null); - setAiVerifyCompleteNotice(false); - setAiVerifyErrorNotice(null); + setAiVerifyUnread(false); try { const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams); @@ -448,17 +447,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { }); if (runId !== aiVerifyGenRef.current) return; setAiVerifyResult(result); - if (!aiVerifyOpenRef.current) { - setAiVerifyCompleteNotice(true); + if (rightTabRef.current !== 'ai') { + setAiVerifyUnread(true); } } catch (e) { if (runId !== aiVerifyGenRef.current) return; if (e instanceof DOMException && e.name === 'AbortError') return; const msg = e instanceof Error ? e.message : 'AI 검증 요청 실패'; setAiVerifyError(msg); - if (!aiVerifyOpenRef.current) { - setAiVerifyErrorNotice(msg); - } + setAiVerifyUnread(true); } finally { if (runId === aiVerifyGenRef.current) { setAiVerifyRunning(false); @@ -482,17 +479,12 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { ]); const handleOpenAiVerify = useCallback(() => { - if (aiVerifyRunning) { - setAiVerifyOpen(true); - return; - } + setRightOpen(true); + setRightTab('ai'); + if (aiVerifyRunning) return; void runAiVerify(); }, [aiVerifyRunning, runAiVerify]); - const handleCloseAiVerify = useCallback(() => { - setAiVerifyOpen(false); - }, []); - const handleOpenReport = useCallback(async () => { if (reportDisabled || !selectedStrategyId || !selectedStrategy) return; @@ -779,6 +771,44 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { /> )} @@ -838,79 +889,6 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { onClose={() => setReportOpen(false)} model={reportModel} /> - - { void runAiVerify(); }} - /> - - {aiVerifyRunning && !aiVerifyOpen && ( -
- - AI 분석 진행 중… - -
- )} - - {(aiVerifyCompleteNotice || aiVerifyErrorNotice) && !aiVerifyOpen && ( -
- - {aiVerifyErrorNotice ?? 'AI 분석이 완료되었습니다.'} - -
- {!aiVerifyErrorNotice && ( - - )} - - {aiVerifyErrorNotice && ( - - )} -
-
- )} ); } diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyModal.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyPanel.tsx similarity index 65% rename from frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyModal.tsx rename to frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyPanel.tsx index eb57171..f530b94 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyModal.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyPanel.tsx @@ -1,8 +1,7 @@ /** - * 전략 평가 — AI 검증 결과 모달 + * 전략 평가 — 우측 패널 AI 검증 탭 */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; import type { AiVerifyFixPointsBundle, StrategyEvaluationAiVerifyContext, @@ -10,20 +9,6 @@ import type { import { buildAiVerifyFixPoints } from '../../utils/strategyEvaluationAiVerify'; import type { StrategyEvaluationAiVerifyResponse } from '../../utils/strategyEvaluationAiVerify'; -import type { Theme } from '../../types'; - -interface Props { - open: boolean; - onClose: () => void; - loading: boolean; - error: string | null; - contextSummary: string | null; - verifyContext: StrategyEvaluationAiVerifyContext | null; - result: StrategyEvaluationAiVerifyResponse | null; - onRetry?: () => void; - theme?: Theme; -} - function renderAnalysisMarkdown(text: string): React.ReactNode { const lines = text.split('\n'); const nodes: React.ReactNode[] = []; @@ -141,9 +126,7 @@ const Ta4jDiagnosticsSummary: React.FC<{ diagnostics: Record }> ); }; -const FixPointsPanel: React.FC<{ - bundle: AiVerifyFixPointsBundle; -}> = ({ bundle }) => { +const FixPointsPanel: React.FC<{ bundle: AiVerifyFixPointsBundle }> = ({ bundle }) => { const [copied, setCopied] = useState(false); const [expandedId, setExpandedId] = useState( bundle.points[0]?.id ?? null, @@ -167,7 +150,7 @@ const FixPointsPanel: React.FC<{ 수정 포인트 (Cursor AI)

- 로직 불일치가 감지되었습니다. 각 항목의 평가 상황·설정·결과를 Cursor에 붙여넣어 원인 분석 및 수정을 요청할 수 있습니다. + 로직 불일치가 감지되었습니다. 각 항목을 Cursor에 붙여넣어 원인 분석·수정을 요청할 수 있습니다.

{bundle.verdictHint && (

AI 판정: {bundle.verdictHint}

@@ -221,16 +204,24 @@ const FixPointsPanel: React.FC<{ ); }; -const StrategyEvaluationAiVerifyModal: React.FC = ({ - open, - onClose, +export interface StrategyEvaluationAiVerifyPanelProps { + loading: boolean; + error: string | null; + contextSummary: string | null; + verifyContext: StrategyEvaluationAiVerifyContext | null; + result: StrategyEvaluationAiVerifyResponse | null; + runDisabled?: boolean; + onRun: () => void; +} + +const StrategyEvaluationAiVerifyPanel: React.FC = ({ loading, error, contextSummary, verifyContext, result, - onRetry, - theme = 'dark', + runDisabled = false, + onRun, }) => { const bodyRef = useRef(null); @@ -242,115 +233,92 @@ const StrategyEvaluationAiVerifyModal: React.FC = ({ ); useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [open, onClose]); - - useEffect(() => { - if (open && result && bodyRef.current) { + if (result && bodyRef.current) { bodyRef.current.scrollTop = 0; } - }, [open, result]); + }, [result?.analysis]); - const handleBackdrop = useCallback((e: React.MouseEvent) => { - if (e.target === e.currentTarget) onClose(); - }, [onClose]); + const hasContent = loading || error || result; - if (!open) return null; - - const content = ( -
-
e.stopPropagation()} - > -
-
-

- AI 전략평가 검증 -

- {contextSummary && ( -

{contextSummary}

- )} -
- -
- -
- {loading && ( -
- -

LLM이 평가 결과와 차트 시그널을 분석 중입니다…

-

- Ta4j 재평가와 LLM 분석에 최대 2~3분 걸릴 수 있습니다. - 모달을 닫아도 백그라운드에서 분석이 계속됩니다. -

-
+ return ( +
+
+
+ {contextSummary && ( +

{contextSummary}

)} - - {!loading && error && ( -
-

{error}

- {onRetry && ( - - )} -
- )} - - {!loading && !error && result && ( - <> -
- {result.model} - {(result.latencyMs / 1000).toFixed(1)}초 -
-
- {renderAnalysisMarkdown(result.analysis)} -
- {result.ta4jDiagnostics && ( - - )} - {fixPoints && fixPoints.hasIssue && ( - - )} - + {!contextSummary && !hasContent && ( +

+ 차트 툴바의 AI 검증 버튼 또는 아래 실행으로 선택 봉 기준 LLM 분석을 시작합니다. +

)}
+ +
-
-

- {loading - ? '※ 분석 중 닫기를 눌러도 작업은 백그라운드에서 계속됩니다.' - : '※ AI 분석은 참고용입니다. 수정 포인트는 Cursor 등에 붙여넣어 코드 수정을 요청할 수 있습니다.'} -

- -
+
+ {loading && ( +
+ +

LLM이 평가 결과와 차트 시그널을 분석 중입니다…

+

+ Ta4j 재평가와 LLM 분석에 최대 2~3분 걸릴 수 있습니다. + 다른 탭·차트 조작은 계속 사용할 수 있습니다. +

+
+ )} + + {!loading && error && ( +
+

{error}

+ +
+ )} + + {!loading && !error && result && ( + <> +
+ {result.model} + {(result.latencyMs / 1000).toFixed(1)}초 +
+
+ {renderAnalysisMarkdown(result.analysis)} +
+ {result.ta4jDiagnostics && ( + + )} + {fixPoints && fixPoints.hasIssue && ( + + )} + + )}
+ +
); - - return createPortal(content, document.body); }; -export default StrategyEvaluationAiVerifyModal; +export default StrategyEvaluationAiVerifyPanel; diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index d5af6f8..1c0bd9e 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -118,6 +118,8 @@ border-left: 1px solid var(--btd-divider, var(--se-border)); } +.seval-right-wrap:not(.seval-right-wrap--open) .seval-right-sidebar.btd-right .seval-right-tabs, +.seval-right-wrap:not(.seval-right-wrap--open) .seval-right-sidebar.btd-right .seval-right-tab-panel, .seval-right-wrap:not(.seval-right-wrap--open) .seval-right-sidebar.btd-right .seval-signal-head, .seval-right-wrap:not(.seval-right-wrap--open) .seval-right-sidebar.btd-right .seval-signal-body, .seval-right-wrap:not(.seval-right-wrap--open) .seval-right-sidebar.btd-right .seval-signal-footer, @@ -1126,6 +1128,127 @@ min-height: 0; } +.seval-right-tabs { + flex-shrink: 0; + display: flex; + gap: 4px; + padding: 0 2px 8px; + border-bottom: 1px solid var(--se-border, var(--btd-divider)); + margin-bottom: 4px; +} + +.seval-right-tab { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; + font-size: 0.72rem; +} + +.seval-right-tab-spinner { + width: 12px; + height: 12px; + flex-shrink: 0; +} + +.seval-right-tab-badge { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--se-gold, #e6c200); + box-shadow: 0 0 6px color-mix(in srgb, var(--se-gold, #e6c200) 60%, transparent); + flex-shrink: 0; +} + +.seval-right-tab-panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.seval-right-tab-panel--ai { + overflow: hidden; +} + +/* ── AI 검증 우측 패널 탭 ── */ +.seval-ai-verify-panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.seval-ai-verify-panel-head { + flex-shrink: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + padding: 0 2px 8px; + border-bottom: 1px solid var(--se-border, rgba(255, 255, 255, 0.08)); +} + +.seval-ai-verify-panel-head-main { + flex: 1; + min-width: 0; +} + +.seval-ai-verify-panel-empty-hint { + margin: 0; + font-size: 0.72rem; + line-height: 1.45; + color: var(--se-text-muted, #8b949e); +} + +.seval-ai-verify-panel-run-btn { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--se-gold, #e6c200) 40%, var(--se-border)); + background: color-mix(in srgb, var(--se-gold, #e6c200) 10%, var(--bg3, #1f2335)); + color: var(--text, var(--se-text)); + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; +} + +.seval-ai-verify-panel-run-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--se-gold, #e6c200) 18%, var(--bg3)); +} + +.seval-ai-verify-panel-run-btn:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.seval-ai-verify-panel-run-spinner { + width: 14px; + height: 14px; +} + +.seval-ai-verify-panel-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 10px 2px 8px; +} + +.seval-ai-verify-panel-foot { + flex-shrink: 0; + padding: 8px 2px 0; + border-top: 1px solid var(--se-border, rgba(255, 255, 255, 0.08)); +} + +.seval-ai-verify-panel-body .seval-ai-verify-loading { + min-height: 120px; +} + .seval-signal-head { flex-shrink: 0; display: flex;