ai 전략 기능 추가
This commit is contained in:
@@ -47,6 +47,7 @@ import {
|
||||
} from '../utils/strategyStartNodes';
|
||||
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
|
||||
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
|
||||
import AiStrategyPanel, { type AiStrategyContext } from './strategyEditor/AiStrategyPanel';
|
||||
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
|
||||
import TrendLinePaletteTab from './strategyEditor/TrendLinePaletteTab';
|
||||
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
|
||||
@@ -320,7 +321,7 @@ export default function StrategyEditorPage({
|
||||
const [buyCondition, setBuyCondition] = useState<LogicNode | null>(null);
|
||||
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
|
||||
const [signalTab, setSignalTab] = useState<SignalTab>('buy');
|
||||
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
|
||||
const [rightTab, setRightTab] = useState<'indicators' | 'templates' | 'ai'>('indicators');
|
||||
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range' | 'trendline'>('auxiliary');
|
||||
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
|
||||
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
|
||||
@@ -1463,6 +1464,45 @@ export default function StrategyEditorPage({
|
||||
[templateRows, templateSearch],
|
||||
);
|
||||
|
||||
const buildAiContext = useCallback((): AiStrategyContext => {
|
||||
const buyEncoded = encodeConditionForSave(buyEditorState);
|
||||
const sellEncoded = encodeConditionForSave(sellEditorState);
|
||||
return {
|
||||
buyCondition: buyEncoded,
|
||||
sellCondition: sellEncoded,
|
||||
signalTab: buySellTab(signalTab),
|
||||
candleType: currentEditorState.startMeta[START_NODE_ID]?.candleType,
|
||||
};
|
||||
}, [buyEditorState, sellEditorState, signalTab, currentEditorState]);
|
||||
|
||||
const applyAiStrategy = useCallback((buyDsl: LogicNode | null, sellDsl: LogicNode | null) => {
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
|
||||
const buyDecoded = decodeConditionForEditor(buyDsl);
|
||||
const sellDecoded = decodeConditionForEditor(sellDsl);
|
||||
|
||||
setBuyCondition(buyDecoded.root);
|
||||
setSellCondition(sellDecoded.root);
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
setSelectedNodeId(null);
|
||||
setBuyLayout(prev => ({
|
||||
...prev,
|
||||
startCombineOp: normalizeStartCombineOp(buyDecoded.startCombineOp),
|
||||
}));
|
||||
setSellLayout(prev => ({
|
||||
...prev,
|
||||
startCombineOp: normalizeStartCombineOp(sellDecoded.startCombineOp),
|
||||
}));
|
||||
|
||||
resetFlowLayout(layoutStrategyKey, 'buy');
|
||||
resetFlowLayout(layoutStrategyKey, 'sell');
|
||||
|
||||
const targetTab: BuySellTab = buyDsl ? 'buy' : (sellDsl ? 'sell' : buySellTab(signalTab));
|
||||
if (targetTab !== signalTab) setSignalTab(targetTab);
|
||||
scheduleStrategyPersist();
|
||||
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
|
||||
|
||||
const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => {
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
|
||||
@@ -2418,6 +2458,7 @@ export default function StrategyEditorPage({
|
||||
<div className="se-right-tabs">
|
||||
<button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}>지표</button>
|
||||
<button type="button" className={rightTab === 'templates' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('templates')}>템플릿</button>
|
||||
<button type="button" className={rightTab === 'ai' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('ai')}>AI 전략</button>
|
||||
</div>
|
||||
<div className="se-right-body">
|
||||
{rightTab === 'indicators' && (
|
||||
@@ -2593,6 +2634,12 @@ export default function StrategyEditorPage({
|
||||
onDelete={handleTemplateToolbarDelete}
|
||||
/>
|
||||
)}
|
||||
{rightTab === 'ai' && (
|
||||
<AiStrategyPanel
|
||||
getContext={buildAiContext}
|
||||
onApplyStrategy={applyAiStrategy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { LlmAppSettings } from '../../utils/llmSettings';
|
||||
import { buildLlmEndpointUrl, buildLlmBaseUrl } from '../../utils/llmSettings';
|
||||
import { fetchLlmConnectionTest } from '../../utils/backendApi';
|
||||
import { fetchLlmConnectionTest, fetchLlmModels } from '../../utils/backendApi';
|
||||
|
||||
interface Props {
|
||||
settings: LlmAppSettings;
|
||||
@@ -34,6 +34,9 @@ const SettingSection: React.FC<{ title: string; children: React.ReactNode }> = (
|
||||
const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [modelsMessage, setModelsMessage] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
const patch = (p: Partial<LlmAppSettings>) => {
|
||||
setTestResult(null);
|
||||
@@ -57,6 +60,36 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchModels = async () => {
|
||||
setLoadingModels(true);
|
||||
setModelsMessage(null);
|
||||
try {
|
||||
const res = await fetchLlmModels({
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
useHttps: settings.useHttps,
|
||||
modelsPath: settings.modelsPath,
|
||||
});
|
||||
setModels(res.models ?? []);
|
||||
setModelsMessage({ ok: res.ok, message: res.message ?? (res.ok ? '조회 완료' : '조회 실패') });
|
||||
if (res.ok && res.models?.length && !res.models.includes(settings.model)) {
|
||||
patch({ model: res.models[0] });
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '모델 조회 실패';
|
||||
setModels([]);
|
||||
setModelsMessage({ ok: false, message: msg });
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
const set = new Set(models);
|
||||
if (settings.model) set.add(settings.model);
|
||||
return Array.from(set);
|
||||
}, [models, settings.model]);
|
||||
|
||||
return (
|
||||
<div className="llm-settings-panel">
|
||||
<SettingSection title="LLM 사용">
|
||||
@@ -114,6 +147,15 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
|
||||
placeholder="/v1/chat/completions"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="모델 목록 경로" desc="OpenAI 호환 모델 목록 엔드포인트 (드롭다운 조회용)">
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="text"
|
||||
value={settings.modelsPath}
|
||||
onChange={e => patch({ modelsPath: e.target.value })}
|
||||
placeholder="/v1/models"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="연결 URL 미리보기" desc="저장 후 백엔드가 이 주소로 LLM에 요청합니다.">
|
||||
<code className="stg-code-preview">{endpointPreview}</code>
|
||||
</SettingRow>
|
||||
@@ -137,7 +179,36 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection title="모델·생성 옵션">
|
||||
<SettingRow label="모델" desc="LLM 서버에 등록된 모델 ID">
|
||||
<SettingRow label="모델" desc="URL 입력 후 '모델 조회'로 목록을 받아 선택하세요. 조회가 안 되면 직접 입력할 수 있습니다.">
|
||||
<div className="stg-row-inline">
|
||||
<select
|
||||
className="stg-input stg-input--wide"
|
||||
value={settings.model}
|
||||
onChange={e => patch({ model: e.target.value })}
|
||||
>
|
||||
{modelOptions.length === 0 && <option value="">모델 없음 — 조회하세요</option>}
|
||||
{modelOptions.map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="stg-btn-test"
|
||||
disabled={loadingModels || !settings.host}
|
||||
onClick={handleFetchModels}
|
||||
>
|
||||
{loadingModels ? '조회 중…' : '모델 조회'}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
{modelsMessage && (
|
||||
<SettingRow label="" desc="">
|
||||
<span className={`stg-badge ${modelsMessage.ok ? 'stg-badge--ok' : 'stg-badge--err'}`}>
|
||||
● {modelsMessage.message}
|
||||
</span>
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow label="모델 직접 입력" desc="목록에 없는 모델 ID를 직접 입력할 때 사용">
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="text"
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import {
|
||||
requestStrategyAgentChat,
|
||||
type StrategyAgentChatMessage,
|
||||
} from '../../utils/strategyAgentChat';
|
||||
|
||||
export interface AiStrategyContext {
|
||||
buyCondition: LogicNode | null;
|
||||
sellCondition: LogicNode | null;
|
||||
signalTab: 'buy' | 'sell';
|
||||
candleType?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 전송·스냅샷 시점의 현재 편집기 상태를 반환 */
|
||||
getContext: () => AiStrategyContext;
|
||||
/** 매수·매도 조건을 편집기에 교체 적용 (null = 해당 측 비움) */
|
||||
onApplyStrategy: (buy: LogicNode | null, sell: LogicNode | null) => void;
|
||||
}
|
||||
|
||||
type Turn = {
|
||||
id: number;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
action?: 'ask' | 'build';
|
||||
/** build 턴 — 적용 직전 스냅샷(되돌리기용) */
|
||||
snapshot?: { buy: LogicNode | null; sell: LogicNode | null };
|
||||
/** build 턴 — 적용된 결과 */
|
||||
produced?: { buy: LogicNode | null; sell: LogicNode | null };
|
||||
reverted?: boolean;
|
||||
};
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'전환선이 기준선을 상향돌파하면 매수',
|
||||
'26봉 전 후행스팬이 종가를 상향돌파하고 전환선이 기준선을 상향돌파하면 매수',
|
||||
'RSI 30 이하에서 상향돌파하면 매수, 70 이상이면 매도',
|
||||
'지금 전략을 더 엄격하게 만들어줘',
|
||||
];
|
||||
|
||||
const AiStrategyPanel: React.FC<Props> = ({ getContext, onApplyStrategy }) => {
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const idRef = useRef(0);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const nextId = () => ++idRef.current;
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const history = useMemo<StrategyAgentChatMessage[]>(
|
||||
() => turns.map(t => ({ role: t.role, content: t.content })),
|
||||
[turns],
|
||||
);
|
||||
|
||||
const send = useCallback(async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || busy) return;
|
||||
|
||||
setError(null);
|
||||
const userTurn: Turn = { id: nextId(), role: 'user', content: trimmed };
|
||||
const outgoing: StrategyAgentChatMessage[] = [...history, { role: 'user', content: trimmed }];
|
||||
setTurns(prev => [...prev, userTurn]);
|
||||
setInput('');
|
||||
setBusy(true);
|
||||
scrollToBottom();
|
||||
|
||||
const ctx = getContext();
|
||||
try {
|
||||
const res = await requestStrategyAgentChat(outgoing, ctx);
|
||||
|
||||
if (res.action === 'build' && res.hasStrategy) {
|
||||
const snapshot = { buy: ctx.buyCondition, sell: ctx.sellCondition };
|
||||
// AI가 생략한 측은 기존 조건 유지
|
||||
const produced = {
|
||||
buy: res.buyCondition ?? ctx.buyCondition,
|
||||
sell: res.sellCondition ?? ctx.sellCondition,
|
||||
};
|
||||
onApplyStrategy(produced.buy, produced.sell);
|
||||
setTurns(prev => [...prev, {
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
content: res.reply || '요청하신 조건으로 전략을 구성했습니다.',
|
||||
action: 'build',
|
||||
snapshot,
|
||||
produced,
|
||||
}]);
|
||||
} else {
|
||||
setTurns(prev => [...prev, {
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
content: res.reply || '요청을 더 구체적으로 알려주세요.',
|
||||
action: 'ask',
|
||||
}]);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'AI 전략 생성 중 오류가 발생했습니다.';
|
||||
setError(msg);
|
||||
setTurns(prev => [...prev, {
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
content: `오류: ${msg}`,
|
||||
action: 'ask',
|
||||
}]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [busy, history, getContext, onApplyStrategy, scrollToBottom]);
|
||||
|
||||
const handleRevert = useCallback((turnId: number) => {
|
||||
setTurns(prev => prev.map(t => {
|
||||
if (t.id !== turnId || !t.snapshot) return t;
|
||||
onApplyStrategy(t.snapshot.buy, t.snapshot.sell);
|
||||
return { ...t, reverted: true };
|
||||
}));
|
||||
}, [onApplyStrategy]);
|
||||
|
||||
const handleReapply = useCallback((turnId: number) => {
|
||||
setTurns(prev => prev.map(t => {
|
||||
if (t.id !== turnId || !t.produced) return t;
|
||||
onApplyStrategy(t.produced.buy, t.produced.sell);
|
||||
return { ...t, reverted: false };
|
||||
}));
|
||||
}, [onApplyStrategy]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (busy) return;
|
||||
setTurns([]);
|
||||
setError(null);
|
||||
}, [busy]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send(input);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="se-ai-panel">
|
||||
<div className="se-ai-head">
|
||||
<span className="se-ai-title">AI 전략 에이전트</span>
|
||||
<button
|
||||
type="button"
|
||||
className="se-ai-reset"
|
||||
onClick={handleReset}
|
||||
disabled={busy || turns.length === 0}
|
||||
>
|
||||
대화 초기화
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="se-ai-messages" ref={scrollRef}>
|
||||
{turns.length === 0 && (
|
||||
<div className="se-ai-empty">
|
||||
<p>자연어로 매매 전략을 설명하면 조건을 자동 구성해 편집기에 적용합니다.</p>
|
||||
<p className="se-ai-empty-sub">정보가 부족하면 먼저 질문하고, 이어지는 대화로 전략을 다듬습니다.</p>
|
||||
<div className="se-ai-suggestions">
|
||||
{SUGGESTIONS.map(s => (
|
||||
<button key={s} type="button" className="se-ai-suggestion" onClick={() => void send(s)} disabled={busy}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{turns.map(t => (
|
||||
<div key={t.id} className={`se-ai-msg se-ai-msg--${t.role}`}>
|
||||
<div className="se-ai-bubble">
|
||||
{t.content.split('\n').map((line, i) => <p key={i}>{line || '\u00A0'}</p>)}
|
||||
</div>
|
||||
{t.action === 'build' && t.produced && (
|
||||
<div className="se-ai-apply-row">
|
||||
{t.reverted ? (
|
||||
<>
|
||||
<span className="se-ai-tag se-ai-tag--reverted">되돌림</span>
|
||||
<button type="button" className="se-ai-mini-btn" onClick={() => handleReapply(t.id)} disabled={busy}>
|
||||
다시 적용
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="se-ai-tag se-ai-tag--applied">편집기에 적용됨</span>
|
||||
{t.snapshot && (
|
||||
<button type="button" className="se-ai-mini-btn" onClick={() => handleRevert(t.id)} disabled={busy}>
|
||||
되돌리기
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{busy && (
|
||||
<div className="se-ai-msg se-ai-msg--assistant">
|
||||
<div className="se-ai-bubble se-ai-bubble--loading">생성 중…</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="se-ai-error">{error}</div>}
|
||||
|
||||
<div className="se-ai-input-row">
|
||||
<textarea
|
||||
className="se-ai-input"
|
||||
placeholder="예) 전환선이 기준선을 상향돌파하면 매수 (Enter 전송 · Shift+Enter 줄바꿈)"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={2}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="se-ai-send"
|
||||
onClick={() => void send(input)}
|
||||
disabled={busy || !input.trim()}
|
||||
>
|
||||
전송
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiStrategyPanel;
|
||||
@@ -1914,6 +1914,131 @@
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── AI 전략 에이전트 탭 ───────────────────────────────────── */
|
||||
.se-ai-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.se-ai-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
}
|
||||
.se-ai-title { font-size: 0.78rem; font-weight: 600; color: var(--se-text); }
|
||||
.se-ai-reset {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--se-input-border);
|
||||
background: var(--se-input-bg);
|
||||
color: var(--se-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.se-ai-reset:disabled { opacity: 0.5; cursor: default; }
|
||||
.se-ai-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.se-ai-empty { color: var(--se-text-muted); font-size: 0.76rem; }
|
||||
.se-ai-empty p { margin: 0 0 6px; line-height: 1.5; }
|
||||
.se-ai-empty-sub { opacity: 0.8; }
|
||||
.se-ai-suggestions { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
||||
.se-ai-suggestion {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-input-border);
|
||||
background: var(--se-input-bg);
|
||||
color: var(--se-text);
|
||||
font-size: 0.73rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.se-ai-suggestion:hover { border-color: var(--se-tab-active); }
|
||||
.se-ai-suggestion:disabled { opacity: 0.5; cursor: default; }
|
||||
.se-ai-msg { display: flex; flex-direction: column; max-width: 92%; }
|
||||
.se-ai-msg--user { align-self: flex-end; align-items: flex-end; }
|
||||
.se-ai-msg--assistant { align-self: flex-start; align-items: flex-start; }
|
||||
.se-ai-bubble {
|
||||
padding: 8px 11px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.se-ai-bubble p { margin: 0; }
|
||||
.se-ai-msg--user .se-ai-bubble {
|
||||
background: var(--se-tab-active);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.se-ai-msg--assistant .se-ai-bubble {
|
||||
background: var(--se-input-bg);
|
||||
border: 1px solid var(--se-input-border);
|
||||
color: var(--se-text);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.se-ai-bubble--loading { opacity: 0.7; font-style: italic; }
|
||||
.se-ai-apply-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
|
||||
.se-ai-tag { font-size: 0.68rem; padding: 2px 7px; border-radius: 999px; }
|
||||
.se-ai-tag--applied { background: color-mix(in srgb, #16a34a 18%, transparent); color: #16a34a; }
|
||||
.se-ai-tag--reverted { background: color-mix(in srgb, #d97706 18%, transparent); color: #d97706; }
|
||||
.se-ai-mini-btn {
|
||||
font-size: 0.68rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--se-input-border);
|
||||
background: transparent;
|
||||
color: var(--se-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.se-ai-mini-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.se-ai-error {
|
||||
margin: 0 10px 6px;
|
||||
padding: 6px 9px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.72rem;
|
||||
background: color-mix(in srgb, #dc2626 14%, transparent);
|
||||
color: #dc2626;
|
||||
}
|
||||
.se-ai-input-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--se-border);
|
||||
}
|
||||
.se-ai-input {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-input-border);
|
||||
background: var(--se-input-bg);
|
||||
color: var(--se-text);
|
||||
font-size: 0.76rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.se-ai-send {
|
||||
align-self: stretch;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--se-tab-active);
|
||||
color: #fff;
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.se-ai-send:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.se-palette-search {
|
||||
margin: 10px 10px 6px;
|
||||
padding: 8px 10px;
|
||||
|
||||
@@ -1835,6 +1835,63 @@ export async function fetchLlmConnectionTest(): Promise<LlmConnectionTestRespons
|
||||
);
|
||||
}
|
||||
|
||||
export interface LlmModelsQuery {
|
||||
host: string;
|
||||
port: number;
|
||||
useHttps: boolean;
|
||||
modelsPath?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface LlmModelsResult {
|
||||
ok: boolean;
|
||||
models: string[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** 설정 화면 — 입력한(미저장) 서버에서 사용 가능한 모델 목록 조회 */
|
||||
export async function fetchLlmModels(query: LlmModelsQuery): Promise<LlmModelsResult> {
|
||||
return requestOrThrowWithTimeout<LlmModelsResult>(
|
||||
'/strategy/evaluation/llm-models',
|
||||
{
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify(query),
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
}
|
||||
|
||||
export interface StrategyAgentChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface StrategyAgentChatApiResponse {
|
||||
reply: string;
|
||||
action: 'ask' | 'build';
|
||||
buyCondition?: unknown | null;
|
||||
sellCondition?: unknown | null;
|
||||
model?: string;
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
/** AI 전략 에이전트 — 멀티턴 대화로 전략 DSL 생성/수정 */
|
||||
export async function postStrategyAgentChat(
|
||||
messages: StrategyAgentChatMessage[],
|
||||
context: unknown,
|
||||
): Promise<StrategyAgentChatApiResponse> {
|
||||
return requestOrThrowWithTimeout<StrategyAgentChatApiResponse>(
|
||||
'/strategy/agent/chat',
|
||||
{
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ messages, context }),
|
||||
},
|
||||
620_000,
|
||||
);
|
||||
}
|
||||
|
||||
/** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */
|
||||
export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> {
|
||||
if (!hasRegisteredUser()) return ['1m'];
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface LlmAppSettings {
|
||||
useHttps: boolean;
|
||||
/** OpenAI 호환 chat completions 경로 */
|
||||
chatPath: string;
|
||||
/** OpenAI 호환 모델 목록 경로 (드롭다운 조회용) */
|
||||
modelsPath: string;
|
||||
/** 모델 ID */
|
||||
model: string;
|
||||
/** 최대 생성 토큰 */
|
||||
@@ -29,6 +31,7 @@ export const DEFAULT_LLM_APP_SETTINGS: LlmAppSettings = {
|
||||
port: 16000,
|
||||
useHttps: false,
|
||||
chatPath: '/v1/chat/completions',
|
||||
modelsPath: '/v1/models',
|
||||
model: 'mlx-community/Qwen2.5-7B-Instruct-4bit',
|
||||
maxTokens: 1024,
|
||||
temperature: 0.1,
|
||||
@@ -56,6 +59,9 @@ export function resolveLlmAppSettings(
|
||||
const chatPath = typeof raw.chatPath === 'string' && raw.chatPath.trim()
|
||||
? (raw.chatPath.startsWith('/') ? raw.chatPath.trim() : `/${raw.chatPath.trim()}`)
|
||||
: d.chatPath;
|
||||
const modelsPath = typeof raw.modelsPath === 'string' && raw.modelsPath.trim()
|
||||
? (raw.modelsPath.startsWith('/') ? raw.modelsPath.trim() : `/${raw.modelsPath.trim()}`)
|
||||
: d.modelsPath;
|
||||
const model = typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : d.model;
|
||||
return {
|
||||
enabled: raw.enabled === undefined ? d.enabled : Boolean(raw.enabled),
|
||||
@@ -63,6 +69,7 @@ export function resolveLlmAppSettings(
|
||||
port: clampInt(raw.port, 1, 65535, d.port),
|
||||
useHttps: raw.useHttps === undefined ? d.useHttps : Boolean(raw.useHttps),
|
||||
chatPath,
|
||||
modelsPath,
|
||||
model,
|
||||
maxTokens: clampInt(raw.maxTokens, 64, 8192, d.maxTokens),
|
||||
temperature: clampFloat(raw.temperature, 0, 2, d.temperature),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* AI 전략 에이전트 — 멀티턴 대화 API 클라이언트 + 응답 DSL 검증·정규화.
|
||||
*/
|
||||
import {
|
||||
postStrategyAgentChat,
|
||||
type StrategyAgentChatMessage,
|
||||
type StrategyAgentChatApiResponse,
|
||||
} from './backendApi';
|
||||
import { INDICATOR_CONDITIONS, generateNodeId, type ConditionDSL, type LogicNode, type LogicNodeType } from './strategyTypes';
|
||||
|
||||
export type { StrategyAgentChatMessage } from './backendApi';
|
||||
|
||||
export interface StrategyAgentResult {
|
||||
reply: string;
|
||||
action: 'ask' | 'build';
|
||||
/** 검증·정규화된 매수 조건 (build이고 유효할 때만) */
|
||||
buyCondition: LogicNode | null;
|
||||
/** 검증·정규화된 매도 조건 */
|
||||
sellCondition: LogicNode | null;
|
||||
/** action=build인데 DSL이 모두 비어 검증 후 사라진 경우 */
|
||||
hasStrategy: boolean;
|
||||
model?: string;
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
const NODE_TYPES: ReadonlySet<LogicNodeType> = new Set<LogicNodeType>([
|
||||
'AND', 'OR', 'NOT', 'CONDITION', 'TIMEFRAME',
|
||||
]);
|
||||
|
||||
const WINDOW_MODES = new Set(['EXISTS_IN', 'NOT_EXISTS_IN']);
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | null {
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
/** LLM이 생성한 LogicNode 트리를 편집기/백엔드가 처리 가능한 형태로 검증·정규화. */
|
||||
export function sanitizeAiLogicNode(raw: unknown): LogicNode | null {
|
||||
const obj = asRecord(raw);
|
||||
if (!obj) return null;
|
||||
|
||||
const type = obj.type as LogicNodeType;
|
||||
if (!NODE_TYPES.has(type)) return null;
|
||||
|
||||
const id = typeof obj.id === 'string' && obj.id.trim() ? obj.id.trim() : generateNodeId();
|
||||
|
||||
if (type === 'CONDITION') {
|
||||
const cond = asRecord(obj.condition);
|
||||
if (!cond) return null;
|
||||
const indicatorType = typeof cond.indicatorType === 'string' ? cond.indicatorType.trim() : '';
|
||||
const conditionType = typeof cond.conditionType === 'string' ? cond.conditionType.trim() : '';
|
||||
if (!indicatorType || !conditionType) return null;
|
||||
// 알려진 지표면 conditionType 매트릭스로 검증, 모르는 지표는 백엔드 폴백에 위임
|
||||
const allowed = INDICATOR_CONDITIONS[indicatorType];
|
||||
if (allowed && !allowed.includes(conditionType)) return null;
|
||||
|
||||
const condition = { ...cond, indicatorType, conditionType } as unknown as ConditionDSL;
|
||||
// 윈도우 모드일 때 candleRange 보정
|
||||
if (condition.candleRangeMode && WINDOW_MODES.has(condition.candleRangeMode)) {
|
||||
const n = Number(condition.candleRange);
|
||||
condition.candleRange = Number.isFinite(n) && n >= 2 ? Math.floor(n) : 4;
|
||||
}
|
||||
return { id, type, condition };
|
||||
}
|
||||
|
||||
// AND / OR / NOT / TIMEFRAME
|
||||
const children = Array.isArray(obj.children)
|
||||
? (obj.children.map(sanitizeAiLogicNode).filter(Boolean) as LogicNode[])
|
||||
: [];
|
||||
|
||||
if (type === 'NOT') {
|
||||
if (children.length === 0) return null;
|
||||
return { id, type, children: [children[0]] } as LogicNode;
|
||||
}
|
||||
|
||||
if (type === 'TIMEFRAME') {
|
||||
if (children.length === 0) return null;
|
||||
const node: LogicNode = { id, type, children };
|
||||
if (typeof obj.candleType === 'string') node.candleType = obj.candleType;
|
||||
if (Array.isArray(obj.candleTypes)) {
|
||||
node.candleTypes = obj.candleTypes.filter((c): c is string => typeof c === 'string');
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// AND / OR — 자식이 없으면 무의미하므로 제거, 하나뿐이면 그대로 승격하지 않고 유지
|
||||
if (children.length === 0) return null;
|
||||
return { id, type, children } as LogicNode;
|
||||
}
|
||||
|
||||
/** 멀티턴 대화 요청 → 응답 파싱·DSL 검증 */
|
||||
export async function requestStrategyAgentChat(
|
||||
messages: StrategyAgentChatMessage[],
|
||||
context: unknown,
|
||||
): Promise<StrategyAgentResult> {
|
||||
const res: StrategyAgentChatApiResponse = await postStrategyAgentChat(messages, context);
|
||||
const action = res.action === 'build' ? 'build' : 'ask';
|
||||
const buyCondition = action === 'build' ? sanitizeAiLogicNode(res.buyCondition) : null;
|
||||
const sellCondition = action === 'build' ? sanitizeAiLogicNode(res.sellCondition) : null;
|
||||
return {
|
||||
reply: res.reply ?? '',
|
||||
action,
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
hasStrategy: Boolean(buyCondition || sellCondition),
|
||||
model: res.model,
|
||||
latencyMs: res.latencyMs,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user