merge: 试验性合并 PR #193 (feat/ai-generate-signal) 到最新 main

This commit is contained in:
shy3130
2026-08-23 19:29:44 +08:00
18 changed files with 1254 additions and 21 deletions
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowRight, Plus, Save, Search, X } from 'lucide-react'
import { ArrowRight, Loader2, Plus, Save, Search, Sparkles, X } from 'lucide-react'
import { api, type CustomSignal, type CustomSignalCondition, type CustomSignalFieldGroup } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
@@ -28,6 +28,14 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose
const [draft, setDraft] = useState<CustomSignal>(() => emptySignal(defaultKind))
const [error, setError] = useState('')
// AI 生成条件
const [aiOpen, setAiOpen] = useState(false)
const [aiDesc, setAiDesc] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiError, setAiError] = useState('')
const [aiConfigured, setAiConfigured] = useState<boolean | null>(null)
const checkedAi = useRef(false)
const fields = options.data?.fields ?? []
const groups = options.data?.groups
const maxDays = options.data?.maxDays ?? 60
@@ -38,8 +46,37 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose
if (!open) return
setDraft(signal ? { ...signal, conditions: signal.conditions.map(c => ({ ...c })) } : emptySignal(defaultKind))
setError('')
setAiOpen(false); setAiDesc(''); setAiError(''); setAiLoading(false)
}, [open, signal, defaultKind])
// 打开时检查一次 AI 是否已配置(复用策略构建器逻辑)
useEffect(() => {
if (!open || checkedAi.current) return
checkedAi.current = true
api.strategyAiStatus()
.then(s => setAiConfigured(s.configured))
.catch(() => setAiConfigured(false))
}, [open])
const generateByAI = async () => {
const desc = aiDesc.trim()
if (!desc) { setAiError('请先描述信号思路'); return }
setAiLoading(true)
setAiError('')
try {
const res = await api.customSignalsAiGenerate(desc)
setDraft(d => ({
...d,
name: d.name.trim() ? d.name : res.name,
conditions: res.conditions.map(c => ({ ...c, leftDays: c.leftDays ?? 0, rightDays: c.rightDays ?? 0 })),
}))
} catch (err: any) {
setAiError(String(err?.message ?? err))
} finally {
setAiLoading(false)
}
}
const save = useMutation({
mutationFn: () => {
const d = draft
@@ -122,11 +159,19 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-muted"></span>
<button onClick={addCond} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => setAiOpen(o => !o)}
className={`inline-flex items-center gap-1 text-[11px] cursor-pointer transition-colors ${aiOpen ? 'text-amber-400' : 'text-amber-400/80 hover:text-amber-400'}`}
>
<Sparkles className="h-3 w-3" />AI
</button>
<button onClick={addCond} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
</div>
</div>
<div className="space-y-2 rounded-card border border-border/70 bg-base/50 p-3">
{draft.conditions.map((c, i) => (
@@ -155,6 +200,41 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose
</div>
))}
</div>
{aiOpen && (
<div className="rounded-card border border-amber-400/30 bg-amber-400/5 p-3 space-y-2">
<div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-amber-400 shrink-0" />
<span className="text-[11px] text-amber-300">AI </span>
</div>
{aiConfigured === false ? (
<div className="text-xs text-amber-400/80">
AI {' '}
<a href="/settings?tab=ai" className="underline hover:text-amber-300"> API Key</a>
</div>
) : (
<>
<textarea
value={aiDesc}
onChange={e => setAiDesc(e.target.value)}
placeholder="例如:收盘价回踩20日均线,且量比≥2 放量"
rows={2}
className="w-full rounded-btn border border-border bg-base px-3 py-2 text-xs text-foreground focus:outline-none focus:border-amber-400/50 resize-none"
/>
{aiError && <div className="text-xs text-danger">{aiError}</div>}
<div className="flex justify-end">
<button
onClick={generateByAI}
disabled={aiLoading}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-amber-500/90 text-base text-xs font-medium disabled:opacity-50 cursor-pointer"
>
{aiLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{aiLoading ? '生成中…' : '生成条件'}
</button>
</div>
</>
)}
</div>
)}
<p className="text-[10px] text-muted/60 px-1">
<span className="text-foreground/70"></span> N日( N ):收盘价() &gt; (1) = /,
</p>
+15 -2
View File
@@ -710,6 +710,11 @@ export interface CustomSignalOptions {
kinds: { key: string; label: string }[]
}
export interface CustomSignalAIGenerateResult {
name: string
conditions: CustomSignalCondition[]
}
// ===== Monitor (监控规则 + 触发记录) =====
export interface MonitorCondition {
field: string
@@ -1337,6 +1342,8 @@ export interface SettingsState {
ai_codex_command?: string
ai_codex_reasoning_effort?: string
ai_user_agent: string
ai_max_output_tokens?: number
ai_context_window?: number
}
/** 保存 TickFlow Key 的响应(先探后存) */
@@ -1553,8 +1560,8 @@ export const api = {
),
/** 保存 AI 配置 */
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; reasoning_effort?: string; codex_command?: string; codex_reasoning_effort?: string; user_agent?: string }) =>
request<{ ok: boolean; ai_provider?: string; ai_model?: string; ai_openai_model?: string; ai_reasoning_effort?: string; ai_codex_model?: string; ai_codex_command?: string; ai_codex_reasoning_effort?: string; ai_configured?: boolean }>('/api/settings/ai', {
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; reasoning_effort?: string; codex_command?: string; codex_reasoning_effort?: string; user_agent?: string; max_output_tokens?: number; context_window?: number }) =>
request<{ ok: boolean; ai_provider?: string; ai_model?: string; ai_openai_model?: string; ai_reasoning_effort?: string; ai_codex_model?: string; ai_codex_command?: string; ai_codex_reasoning_effort?: string; ai_configured?: boolean; ai_max_output_tokens?: number; ai_context_window?: number }>('/api/settings/ai', {
method: 'POST',
body: JSON.stringify(ai),
}),
@@ -2828,6 +2835,12 @@ export const api = {
customSignalDelete: (id: string) =>
request<{ ok: boolean }>(`/api/custom-signals/${encodeURIComponent(id)}`, { method: 'DELETE' }),
customSignalsAiGenerate: (description: string) =>
request<CustomSignalAIGenerateResult>('/api/custom-signals/ai/generate', {
method: 'POST',
body: JSON.stringify({ description }),
}),
// ===== Abnormal Moves (异动边缘) =====
abnormalOverview: (minCloseness = 0.5, limit = 200) =>
request<AbnormalOverview>(
+29
View File
@@ -13,6 +13,12 @@ import { QK } from '@/lib/queryKeys'
const INPUT_CLS =
'w-full h-9 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow'
// 空/非法输入 → undefined (后端保持原值), 合法正整数 → int
const toPositiveInt = (v: string) => {
const n = parseInt(v, 10)
return Number.isInteger(n) && n > 0 ? n : undefined
}
const CODEX_PROVIDER = 'codex_cli'
const OPENAI_PROVIDER = 'openai'
const OPENAI_COMPAT_PROVIDER = 'openai_compat'
@@ -79,6 +85,8 @@ export function SettingsAIPanel() {
const [codexCommand, setCodexCommand] = useState(CODEX_COMMAND)
const [customUa, setCustomUa] = useState(false)
const [userAgent, setUserAgent] = useState('')
const [maxOutputTokens, setMaxOutputTokens] = useState('')
const [contextWindow, setContextWindow] = useState('')
const [showKey, setShowKey] = useState(false)
const [saved, setSaved] = useState(false)
const [confirmClear, setConfirmClear] = useState(false)
@@ -149,6 +157,8 @@ export function SettingsAIPanel() {
const ua = s.ai_user_agent ?? ''
setCustomUa(!!ua)
setUserAgent(ua)
setMaxOutputTokens(String(s?.ai_max_output_tokens ?? 8192))
setContextWindow(String(s?.ai_context_window ?? 64000))
}, [s])
const payload = () => ({
@@ -160,6 +170,8 @@ export function SettingsAIPanel() {
codex_command: isCodexProvider ? CODEX_COMMAND : codexCommand,
codex_reasoning_effort: isCodexProvider ? codexReasoningEffort : '',
user_agent: customUa ? userAgent : '',
max_output_tokens: toPositiveInt(maxOutputTokens),
context_window: toPositiveInt(contextWindow),
})
const save = useMutation({
@@ -178,6 +190,8 @@ export function SettingsAIPanel() {
ai_codex_command: result.ai_codex_command ?? (isCodexProvider ? CODEX_COMMAND : codexCommand),
ai_codex_reasoning_effort: result.ai_codex_reasoning_effort ?? (isCodexProvider ? codexReasoningEffort : ''),
ai_configured: result.ai_configured ?? (isCodexProvider ? true : (apiKey ? true : prev.ai_configured)),
ai_max_output_tokens: result.ai_max_output_tokens ?? toPositiveInt(maxOutputTokens),
ai_context_window: result.ai_context_window ?? toPositiveInt(contextWindow),
...(apiKey ? {
has_ai_key: true,
ai_api_key_masked: `${apiKey.slice(0, 4)}......${apiKey.slice(-4)}`,
@@ -205,6 +219,8 @@ export function SettingsAIPanel() {
custom: { baseUrl: '', model: '' },
openai: { baseUrl: 'https://api.openai.com/v1', model: DEFAULT_OPENAI_MODEL },
}
setMaxOutputTokens('8192')
setContextWindow('64000')
setTestResult(null)
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
...prev,
@@ -216,6 +232,8 @@ export function SettingsAIPanel() {
ai_codex_model: '',
ai_codex_command: CODEX_COMMAND,
ai_codex_reasoning_effort: '',
ai_max_output_tokens: 8192,
ai_context_window: 64000,
has_ai_key: false,
ai_configured: false,
ai_api_key_masked: '',
@@ -445,6 +463,17 @@ export function SettingsAIPanel() {
</div>
</>
)}
<div className="border-t border-border/20 pt-4">
<div className="grid grid-cols-2 gap-4">
<Field label="输出上限 max_tokens" hint="所有 AI 任务的输出 token 上限, 任务请求会被钳制到此值; 默认 8192">
<input type="number" min={1} value={maxOutputTokens} onChange={e => setMaxOutputTokens(e.target.value)} placeholder="8192" className={INPUT_CLS} />
</Field>
<Field label="上下文窗口 (输入上限)" hint="输入估算超出此窗口时会报错并提示调大; 默认 64000">
<input type="number" min={1} value={contextWindow} onChange={e => setContextWindow(e.target.value)} placeholder="64000" className={INPUT_CLS} />
</Field>
</div>
</div>
</div>
</Card>