mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 22:34:18 +08:00
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import { useSyncExternalStore } from 'react'
|
|
import { api } from './api'
|
|
|
|
/**
|
|
* AI 财务分析 —— 全局任务/报告 store(与 UI 解耦)。
|
|
*
|
|
* 设计要点:
|
|
* 1. 流式接收逻辑在这里,与弹窗组件解耦 → 用户关闭/最小化弹窗,后台流照常累积。
|
|
* 2. useSyncExternalStore 订阅 → 任意组件(弹窗、气泡、历史面板)实时同步。
|
|
* 3. "活跃任务"上限 MAX_ACTIVE=3:同时进行的任务最多 3 个,超出拒绝新建。
|
|
* (历史报告名额 MAX_REPORTS=20 在后端裁剪,与活跃任务名额分离)
|
|
* 4. 同 symbol 已有活跃任务 → 直接聚焦那个,不新建第 2 个。
|
|
* 5. 任务完成(收到 done 或 content 非空且流结束)→ 自动存后端 + 移入历史 + 弹窗可恢复为"历史模式"。
|
|
*/
|
|
|
|
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
|
|
|
export interface ActiveTask {
|
|
id: string // 任务 id(前端生成,与最终 report id 解耦)
|
|
symbol: string
|
|
name: string
|
|
focus: string
|
|
phase: Phase
|
|
content: string // 累积的 Markdown
|
|
error: string
|
|
meta: { summary?: string; periods?: number } | null
|
|
createdAt: number // ms 时间戳
|
|
savedReportId?: string // 完成后存到后端的报告 id
|
|
doneAt?: number // 进入 done/error 态的时间戳(用于气泡过期清理)
|
|
dismissed?: boolean // 用户已从气泡点击查看过 → 不再在气泡显示
|
|
}
|
|
|
|
export interface HistoryReport {
|
|
id: string
|
|
symbol: string
|
|
name: string
|
|
focus: string
|
|
content: string
|
|
periods?: number
|
|
summary?: string
|
|
created_at: string
|
|
}
|
|
|
|
const MAX_ACTIVE = 3
|
|
|
|
// ===== 全局状态 =====
|
|
let activeTasks: ActiveTask[] = []
|
|
let history: HistoryReport[] = []
|
|
let historyLoaded = false
|
|
const listeners = new Set<() => void>()
|
|
|
|
// 当前"前台"展示的任务:
|
|
// - 活跃任务 id(正在生成/刚完成,对话框打开)
|
|
// - 或 'history:<id>'(查看历史报告)
|
|
// - 或 null(对话框关闭/最小化)
|
|
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
|
|
}
|
|
|
|
// 快照必须返回稳定引用:只有内容真正变化时才返回新数组/对象。
|
|
// useSyncExternalStore 用 Object.is 比较,getSnapshot 必须缓存。
|
|
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 }
|
|
// 首次进入 done/error 态时记录 doneAt
|
|
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, () => [])
|
|
// 同时订阅对话框状态:最小化/打开/关闭会改变气泡可见性,需独立触发重渲染。
|
|
// (否则最小化时 activeTasks 引用未变,useSyncExternalStore 不会重渲染,胶囊不出现)
|
|
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
|
const ds = _dialogSnap
|
|
return all.filter(t => {
|
|
// 进行中:始终显示(dismissed 仅作用于完成态,不影响生成中的任务再次最小化)
|
|
if (t.phase === 'loading' || t.phase === 'streaming') {
|
|
// 除非对话框正打开看着它(非最小化)
|
|
return !(ds.taskId === t.id && !ds.minimized)
|
|
}
|
|
// 完成/失败态:常驻显示,直到用户主动点击查看(dismissed)。
|
|
// 不设自动过期 —— 胶囊是持续可见的状态指示器,历史报告列表是查看入口。
|
|
if (t.dismissed) return false // 用户已点击查看过 → 移除
|
|
if (!ds.minimized && ds.taskId === t.id) return false // 对话框正展示 → 不重复
|
|
return true
|
|
})
|
|
}
|
|
|
|
/** 兼容旧调用名(Layout 等处可能引用) */
|
|
export const useActiveTasks = useBubbleTasks
|
|
|
|
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 }))
|
|
}
|
|
|
|
/** 当前对话框要展示的任务(活跃或历史),null=未打开。 */
|
|
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.financialReportsList()
|
|
history = res.reports ?? []
|
|
historyLoaded = true
|
|
rebuildSnap()
|
|
emit()
|
|
} catch {
|
|
// 静默失败,列表会显示空
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 查询某只股票最近一次的历史分析报告(用于二次确认提示)。
|
|
* 若历史未加载,先触发拉取。
|
|
* @returns 最近一条报告,或 null
|
|
*/
|
|
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
|
|
if (!historyLoaded) await loadHistory()
|
|
// history 已按 created_at 降序,取第一条匹配
|
|
return history.find(r => r.symbol === symbol) ?? null
|
|
}
|
|
|
|
/**
|
|
* 启动一个新的 AI 分析任务。
|
|
* @returns 任务 id;若超出上限或已有活跃任务,返回 { error }。
|
|
*/
|
|
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
|
|
// 同 symbol 已有活跃任务 → 直接聚焦它
|
|
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 = `task_${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, focus)
|
|
return { id }
|
|
}
|
|
|
|
async function runStream(id: string, symbol: string, focus: string) {
|
|
try {
|
|
let firstDelta = true
|
|
for await (const chunk of api.financialAnalyzeStream(symbol, focus)) {
|
|
// 任务可能已被取消(不在列表里了)→ 终止
|
|
const cur = activeTasks.find(t => t.id === id)
|
|
if (!cur) return
|
|
switch (chunk.type) {
|
|
case 'meta':
|
|
patchTask(id, { meta: { summary: chunk.summary, periods: chunk.periods } })
|
|
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':
|
|
// 标记完成,稍后持久化(content 可能还在最后几个 delta 里,以 done 时为准)
|
|
patchTask(id, { phase: 'done' })
|
|
break
|
|
}
|
|
}
|
|
// 流正常结束 → 持久化报告
|
|
const final = activeTasks.find(t => t.id === id)
|
|
if (final && final.phase !== 'error' && final.content) {
|
|
try {
|
|
const res = await api.financialReportSave({
|
|
symbol: final.symbol,
|
|
name: final.name,
|
|
focus: final.focus,
|
|
content: final.content,
|
|
periods: final.meta?.periods,
|
|
summary: final.meta?.summary ?? '',
|
|
})
|
|
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()
|
|
}
|
|
|
|
/** 从气泡恢复对话框。
|
|
* 仅对已完成/失败的任务标记 dismissed(看过结果就不必再弹);
|
|
* 生成中的任务不标记 —— 用户再次最小化时气泡应重新出现。
|
|
*/
|
|
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.financialReportDelete(reportId)
|
|
history = history.filter(r => r.id !== reportId)
|
|
rebuildSnap()
|
|
emit()
|
|
} catch {
|
|
// 静默
|
|
}
|
|
}
|
|
|
|
/** 打开历史报告到对话框。 */
|
|
export function openHistoryReport(reportId: string) {
|
|
activeDialogTaskId = `history:${reportId}`
|
|
dialogMinimized = false
|
|
rebuildSnap()
|
|
emit()
|
|
}
|
|
|