273 lines
9.1 KiB
TypeScript
273 lines
9.1 KiB
TypeScript
import { useSyncExternalStore } from 'react'
|
|
import { api, type PriceLevel, type LevelType } from './api'
|
|
|
|
/**
|
|
* AI 个股分析 —— 全局任务/报告 store(与 aiReportStore 解耦、并行存在)。
|
|
*
|
|
* 与财务分析 store 的区别:
|
|
* - 独立的 activeTasks / history 状态池(不共享 3 并发上限)
|
|
* - meta 额外带 levels(关键价位),供图表回放
|
|
* - 状态文案在胶囊组件里用「蓝」色系区分(财务用紫)
|
|
*
|
|
* 设计同 aiReportStore:流式接收逻辑在此,与弹窗解耦 → 关闭弹窗后台照常累积。
|
|
*/
|
|
|
|
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
|
|
|
export interface ActiveTask {
|
|
id: string
|
|
symbol: string
|
|
name: string
|
|
focus: string
|
|
phase: Phase
|
|
content: string
|
|
error: string
|
|
meta: {
|
|
summary?: string
|
|
levels?: Record<LevelType, PriceLevel[]>
|
|
close?: number | null
|
|
} | null
|
|
createdAt: number
|
|
savedReportId?: string
|
|
doneAt?: number
|
|
dismissed?: boolean
|
|
}
|
|
|
|
export interface HistoryReport {
|
|
id: string
|
|
symbol: string
|
|
name: string
|
|
focus: string
|
|
content: string
|
|
summary?: string
|
|
close?: number | null
|
|
levels?: Record<LevelType, PriceLevel[]>
|
|
created_at: string
|
|
}
|
|
|
|
const MAX_ACTIVE = 3
|
|
|
|
let activeTasks: ActiveTask[] = []
|
|
let history: HistoryReport[] = []
|
|
let historyLoaded = false
|
|
const listeners = new Set<() => void>()
|
|
|
|
let activeDialogTaskId: string | null = null
|
|
let dialogMinimized = false
|
|
|
|
function emit() { listeners.forEach(fn => fn()) }
|
|
function subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn) } }
|
|
|
|
function normalizeAiError(msg: string) {
|
|
return msg.includes('API Key') || msg.includes('api_key')
|
|
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
|
|
: msg
|
|
}
|
|
|
|
let _activeSnap: ActiveTask[] = []
|
|
let _historySnap: HistoryReport[] = []
|
|
interface DialogSnap { taskId: string | null; minimized: boolean }
|
|
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
|
|
|
function rebuildSnap() {
|
|
_activeSnap = activeTasks
|
|
_historySnap = history
|
|
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
|
}
|
|
|
|
function getActiveSnapshot() { return _activeSnap }
|
|
function getHistorySnapshot() { return _historySnap }
|
|
function getDialogSnapshot() { return _dialogSnap }
|
|
|
|
function patchTask(id: string, patch: Partial<ActiveTask>) {
|
|
activeTasks = activeTasks.map(t => {
|
|
if (t.id !== id) return t
|
|
const next = { ...t, ...patch }
|
|
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
|
|
next.doneAt = Date.now()
|
|
}
|
|
return next
|
|
})
|
|
rebuildSnap()
|
|
emit()
|
|
}
|
|
|
|
// ===== 查询 hooks =====
|
|
|
|
export function useBubbleTasks(): ActiveTask[] {
|
|
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
|
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
|
const ds = _dialogSnap
|
|
return all.filter(t => {
|
|
if (t.phase === 'loading' || t.phase === 'streaming') {
|
|
return !(ds.taskId === t.id && !ds.minimized)
|
|
}
|
|
if (t.dismissed) return false
|
|
if (!ds.minimized && ds.taskId === t.id) return false
|
|
return true
|
|
})
|
|
}
|
|
|
|
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
|
|
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
|
return { reports, loaded: historyLoaded }
|
|
}
|
|
|
|
export function useDialogState() {
|
|
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
|
}
|
|
|
|
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
|
|
const ds = useDialogState()
|
|
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
|
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
|
if (!ds.taskId) return { task: null, mode: null }
|
|
if (ds.taskId.startsWith('history:')) {
|
|
const rid = ds.taskId.slice('history:'.length)
|
|
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
|
|
}
|
|
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
|
|
}
|
|
|
|
// ===== 动作 =====
|
|
|
|
export async function loadHistory(): Promise<void> {
|
|
try {
|
|
const res = await api.stockAnalysisReportsList()
|
|
history = res.reports ?? []
|
|
historyLoaded = true
|
|
rebuildSnap()
|
|
emit()
|
|
} catch { /* 静默 */ }
|
|
}
|
|
|
|
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
|
|
if (!historyLoaded) await loadHistory()
|
|
return history.find(r => r.symbol === symbol) ?? null
|
|
}
|
|
|
|
/**
|
|
* 查询某只股票【当日】是否已生成过分析报告(用于二次确认)。
|
|
* 判断依据:created_at 的日期部分 == 本地今天。
|
|
* @returns 当天最近一条报告,或 null
|
|
*/
|
|
export async function findTodayReport(symbol: string): Promise<HistoryReport | null> {
|
|
if (!historyLoaded) await loadHistory()
|
|
const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
|
|
return history.find(r => r.symbol === symbol && (r.created_at ?? '').slice(0, 10) === today) ?? null
|
|
}
|
|
|
|
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
|
|
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
|
|
if (existing) {
|
|
activeDialogTaskId = existing.id
|
|
dialogMinimized = false
|
|
rebuildSnap()
|
|
emit()
|
|
return { id: existing.id }
|
|
}
|
|
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
|
|
if (ongoing.length >= MAX_ACTIVE) {
|
|
return { error: `同时进行的个股分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
|
|
}
|
|
|
|
const id = `stask_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
|
const task: ActiveTask = {
|
|
id, symbol, name, focus,
|
|
phase: 'loading', content: '', error: '',
|
|
meta: null, createdAt: Date.now(),
|
|
}
|
|
activeTasks = [...activeTasks, task]
|
|
activeDialogTaskId = id
|
|
dialogMinimized = false
|
|
rebuildSnap()
|
|
emit()
|
|
|
|
runStream(id, symbol, name, focus)
|
|
return { id }
|
|
}
|
|
|
|
async function runStream(id: string, symbol: string, _name: string, focus: string) {
|
|
try {
|
|
let firstDelta = true
|
|
for await (const chunk of api.stockAnalyzeStream(symbol, focus)) {
|
|
const cur = activeTasks.find(t => t.id === id)
|
|
if (!cur) return
|
|
switch (chunk.type) {
|
|
case 'meta':
|
|
patchTask(id, { meta: { summary: chunk.summary, levels: chunk.levels, close: chunk.close } })
|
|
break
|
|
case 'delta':
|
|
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
|
|
patchTask(id, { content: cur.content + (chunk.content ?? '') })
|
|
break
|
|
case 'error':
|
|
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
|
|
return
|
|
case 'done':
|
|
patchTask(id, { phase: 'done' })
|
|
break
|
|
}
|
|
}
|
|
const final = activeTasks.find(t => t.id === id)
|
|
if (final && final.phase !== 'error') {
|
|
// 兜底:流正常结束但从未收到 delta(后端在生成内容前异常断流)→ 标记失败,避免卡死
|
|
if (!final.content) {
|
|
patchTask(id, { phase: 'error', error: '分析未返回内容(后端可能异常中断),请重试' })
|
|
return
|
|
}
|
|
try {
|
|
const res = await api.stockAnalysisReportSave({
|
|
symbol: final.symbol, name: final.name, focus: final.focus,
|
|
content: final.content, summary: final.meta?.summary ?? '',
|
|
close: final.meta?.close ?? null, levels: final.meta?.levels,
|
|
})
|
|
if (res.report) {
|
|
patchTask(id, { savedReportId: res.report.id })
|
|
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
|
|
historyLoaded = true
|
|
rebuildSnap()
|
|
emit()
|
|
}
|
|
} catch { /* 持久化失败不影响展示 */ }
|
|
}
|
|
} catch (e: any) {
|
|
const msg = String(e?.message ?? '分析失败')
|
|
patchTask(id, {
|
|
phase: 'error',
|
|
error: normalizeAiError(msg),
|
|
})
|
|
}
|
|
}
|
|
|
|
export function openDialog(taskId: string) {
|
|
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
|
|
}
|
|
export function minimizeDialog() {
|
|
dialogMinimized = true; rebuildSnap(); emit()
|
|
}
|
|
export function closeDialog() {
|
|
activeDialogTaskId = null; dialogMinimized = false; rebuildSnap(); emit()
|
|
}
|
|
export function restoreDialog(taskId: string) {
|
|
const t = activeTasks.find(x => x.id === taskId)
|
|
if (t && (t.phase === 'done' || t.phase === 'error')) {
|
|
patchTask(taskId, { dismissed: true })
|
|
}
|
|
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
|
|
}
|
|
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
|
|
return startAnalysis(task.symbol, task.name, task.focus)
|
|
}
|
|
export async function deleteReport(reportId: string): Promise<void> {
|
|
try {
|
|
await api.stockAnalysisReportDelete(reportId)
|
|
history = history.filter(r => r.id !== reportId)
|
|
rebuildSnap()
|
|
emit()
|
|
} catch { /* 静默 */ }
|
|
}
|
|
export function openHistoryReport(reportId: string) {
|
|
activeDialogTaskId = `history:${reportId}`; dialogMinimized = false; rebuildSnap(); emit()
|
|
}
|