diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index c49a72a..09b9c38 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -12,9 +12,42 @@ interface DashboardProps { } const CATEGORY_COLORS: Record = { - '事业': 'bg-blue-50 text-blue-500', '感情': 'bg-pink-50 text-pink-500', '财富': 'bg-amber-50 text-amber-600', '运势': 'bg-purple-50 text-purple-500', '学业': 'bg-green-50 text-green-600', - 'Career': 'bg-blue-50 text-blue-500', 'Love': 'bg-pink-50 text-pink-500', 'Wealth': 'bg-amber-50 text-amber-600', 'Study': 'bg-green-50 text-green-600', + 'career': 'bg-blue-50 text-blue-500', + 'love': 'bg-pink-50 text-pink-500', + 'wealth': 'bg-amber-50 text-amber-600', + 'fortune': 'bg-purple-50 text-purple-500', + 'dream': 'bg-indigo-50 text-indigo-500', + 'health': 'bg-red-50 text-red-500', + 'study': 'bg-green-50 text-green-600', + 'search': 'bg-cyan-50 text-cyan-600', + 'other': 'bg-slate-100 text-slate-500', }; + +// 问题类型显示标签映射 +const CATEGORY_LABELS_ZH: Record = { + 'career': '事业', + 'love': '感情', + 'wealth': '财富', + 'fortune': '运势', + 'dream': '解梦', + 'health': '健康', + 'study': '学业', + 'search': '寻物', + 'other': '其他', +}; + +const CATEGORY_LABELS_EN: Record = { + 'career': 'Career', + 'love': 'Love', + 'wealth': 'Wealth', + 'fortune': 'Fortune', + 'dream': 'Dream', + 'health': 'Health', + 'study': 'Study', + 'search': 'Search', + 'other': 'Other', +}; + const RATING_COLORS: Record = { '上上签': 'bg-amber-50 text-amber-500', '上签': 'bg-amber-50 text-amber-500', '中上签': 'bg-violet-50 text-violet-600', '中签': 'bg-slate-100 text-slate-500', '下签': 'bg-red-50 text-red-500', }; @@ -122,7 +155,11 @@ export default function Dashboard({ locale, translations: i18n }: DashboardProps
{locale === 'en' ? 'No readings yet' : '暂无解卦记录'}
) : ( history.map((item) => ( -
+
@@ -132,12 +169,12 @@ export default function Dashboard({ locale, translations: i18n }: DashboardProps {item.created_at?.slice(0, 10) || ''}
- {item.category && {item.category}} + {item.category && {locale === 'en' ? (CATEGORY_LABELS_EN[item.category] || item.category) : (CATEGORY_LABELS_ZH[item.category] || item.category)}} {item.hexagram_name && {item.hexagram_name}} {item.rating && {item.rating}}
- +
)) )} diff --git a/web/src/components/DashboardApp.tsx b/web/src/components/DashboardApp.tsx index 8653b7d..9c43d15 100644 --- a/web/src/components/DashboardApp.tsx +++ b/web/src/components/DashboardApp.tsx @@ -4,7 +4,7 @@ import AppShell from './AppShell'; import Dashboard from './Dashboard'; import StorePage from './StorePage'; import HistoryListPage from './HistoryListPage'; -import HistoryResultPage from './HistoryResultPage'; +import DivinationResultPage from './DivinationResultPage'; import HistoryFollowUpPage from './HistoryFollowUpPage'; import NotificationPage from './NotificationPage'; import ProfileDetailPage from './ProfileDetailPage'; @@ -30,6 +30,7 @@ interface DashboardAppProps { divination: TranslationMap; general: TranslationMap; feedback: TranslationMap; + result: TranslationMap; }; } @@ -44,6 +45,7 @@ const APP_PATHS = [ '/settings/feedback', '/divination/manual', '/divination/auto', + '/divination/result', ]; function AppLinkInterceptor({ locale }: { locale: string }) { @@ -86,9 +88,9 @@ function DashboardRoutes({ locale, translations }: DashboardAppProps) { } /> } /> } /> - } /> + } /> } /> - } /> + } /> } /> } /> } /> @@ -97,6 +99,7 @@ function DashboardRoutes({ locale, translations }: DashboardAppProps) { } /> } /> } /> + } /> } /> diff --git a/web/src/components/DashboardAppPage.astro b/web/src/components/DashboardAppPage.astro index 4b9b6e2..c36f951 100644 --- a/web/src/components/DashboardAppPage.astro +++ b/web/src/components/DashboardAppPage.astro @@ -19,6 +19,7 @@ const translations = { divination: t(locale, 'divination'), general: t(locale, 'general'), feedback: t(locale, 'feedback'), + result: t(locale, 'result'), }; --- diff --git a/web/src/components/DivinationProcessingOverlay.tsx b/web/src/components/DivinationProcessingOverlay.tsx new file mode 100644 index 0000000..b5a7fac --- /dev/null +++ b/web/src/components/DivinationProcessingOverlay.tsx @@ -0,0 +1,375 @@ +/** + * DivinationProcessingOverlay - 起卦处理中蒙版 + * 显示翻牌动画和状态提示,等待后端返回结果 + * + * i18n: 使用 Flutter app 已有文本 + * - 简中/繁中:有完整翻译 + * - 英文:有翻译 + */ + +import { useEffect, useState, useCallback, useRef } from 'react'; +import { + enqueueDivinationRun, + streamDivinationEvents, + type YaoType, + type DivinationResultData, +} from '../lib/api'; + +// 八卦卡片数据 - 使用 Flutter 的文本 +const I_CHING_CARDS = { + zh: [ + { symbol: '☰', title: '乾 • 元亨利贞', quote: '天行健,君子以自强不息。' }, + { symbol: '☱', title: '兑 • 亨利贞', quote: '丽泽兑,君子以朋友讲习。' }, + { symbol: '☲', title: '离 • 明两作亨利贞', quote: '大人以继明照于四方。' }, + { symbol: '☳', title: '震 • 亨震来虩虩,笑言哑哑', quote: '震惊百里,惊远而惧迩也。' }, + { symbol: '☴', title: '巽 • 小亨利贞', quote: '随风,君子以申命行事。' }, + { symbol: '☵', title: '坎 • 习坎有孚维心亨', quote: '水流而不盈,行险而不失其信。' }, + { symbol: '☶', title: '艮 • 艮其背不获其身', quote: '时止则止,时行则行,动静不失其时。' }, + { symbol: '☷', title: '坤 • 元亨利牝马之贞', quote: '地势坤,君子以厚德载物。' }, + ], + zh_Hant: [ + { symbol: '☰', title: '乾 • 元亨利貞', quote: '天行健,君子以自強不息。' }, + { symbol: '☱', title: '兌 • 亨利貞', quote: '麗澤兌,君子以朋友講習。' }, + { symbol: '☲', title: '離 • 明兩作亨利貞', quote: '大人以繼明照於四方。' }, + { symbol: '☳', title: '震 • 亨震來虩虩,笑言啞啞', quote: '震驚百里,驚遠而懼邇也。' }, + { symbol: '☴', title: '巽 • 小亨利貞', quote: '隨風,君子以申命行事。' }, + { symbol: '☵', title: '坎 • 習坎有孚維心亨', quote: '水流而不盈,行險而不失其信。' }, + { symbol: '☶', title: '艮 • 艮其背不獲其身', quote: '時止則止,時行則行,動靜不失其時。' }, + { symbol: '☷', title: '坤 • 元亨利牝馬之貞', quote: '地勢坤,君子以厚德載物。' }, + ], + en: [ + { symbol: '☰', title: 'Qian • The Creative', quote: 'The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.' }, + { symbol: '☱', title: 'Dui • The Joyous', quote: 'Joy grounded in integrity brings openness, harmony, and right expression.' }, + { symbol: '☲', title: 'Li • The Clinging Fire', quote: 'With clear brilliance, the great one illumines all directions.' }, + { symbol: '☳', title: 'Zhen • The Arousing Thunder', quote: 'Shock awakens the heart; composure turns fear into growth.' }, + { symbol: '☴', title: 'Xun • The Gentle Wind', quote: 'Gentle penetration furthers progress and helps one meet the right people.' }, + { symbol: '☵', title: 'Kan • The Abysmal Water', quote: 'In danger, sincerity and disciplined action carry one through.' }, + { symbol: '☶', title: 'Gen • Keeping Still Mountain', quote: 'Stillness at the proper time keeps one centered and steady in place.' }, + { symbol: '☷', title: 'Kun • The Receptive Earth', quote: "The Earth's condition is devoted receptivity; the noble one carries all with broad virtue." }, + ], +} as const; + +// 处理步骤状态 +type ProcessingStep = 'preparing' | 'deriving' | 'done'; + +interface DivinationProcessingOverlayProps { + locale: string; + // 起卦参数 + params: { + method: 'manual' | 'auto'; + questionType: string; + question: string; + divinationTime: Date; + }; + // 六爻状态 + yaoStates: YaoType[]; + // 完成回调 + onComplete: (result: DivinationResultData | null) => void; + // 错误回调 + onError?: (error: Error) => void; +} + +const copy = { + zh: { + badge: '周易', + statusPreparing: '天机推演中', + statusDeriving: '正在解卦', + statusDone: '解卦完成\n点击查看', + }, + zh_Hant: { + badge: '周易', + statusPreparing: '天機推演中', + statusDeriving: '正在解卦', + statusDone: '解卦完成\n點擊查看', + }, + en: { + badge: 'I Ching', + statusPreparing: 'Deriving...', + statusDeriving: 'Analyzing...', + statusDone: 'Complete\nTap to view', + }, +} as const; + +// 动画时长 +const FLIP_INTERVAL = 2000; // 每2秒翻一次 +const FLIP_DURATION = 600; // 翻转动画600ms + +export default function DivinationProcessingOverlay({ + locale, + params, + yaoStates, + onComplete, + onError, +}: DivinationProcessingOverlayProps) { + const text = copy[locale as keyof typeof copy] ?? copy.zh; + const cards = I_CHING_CARDS[locale as keyof typeof I_CHING_CARDS] ?? I_CHING_CARDS.zh; + + const [step, setStep] = useState('preparing'); + const [cardIndex, setCardIndex] = useState(0); + const [flipAngle, setFlipAngle] = useState(0); + const [result, setResult] = useState(null); + const isFlippingRef = useRef(false); + + // 翻牌动画 + useEffect(() => { + if (step === 'done') return; + + const interval = setInterval(() => { + if (isFlippingRef.current) return; + + isFlippingRef.current = true; + setFlipAngle((prev) => prev + 180); + + setTimeout(() => { + setCardIndex((prev) => (prev + 1) % cards.length); + }, FLIP_DURATION / 2); + + setTimeout(() => { + isFlippingRef.current = false; + }, FLIP_DURATION); + }, FLIP_INTERVAL); + + return () => clearInterval(interval); + }, [step, cards.length]); + + // 后端 SSE 请求 + useEffect(() => { + let aborted = false; + + // 辅助函数:转换 yaoInfoList 到 YaoLineData[] + function parseYaoInfoList(list: unknown): DivinationResultData['yaoLines'] { + if (!Array.isArray(list)) return []; + return list.map((item, idx) => ({ + index: idx, + spirit: (item.spiritName as string) || '', + relation: (item.relationName as string) || '', + branch: (item.tiganName as string) || '', + element: (item.elementName as string) || '', + type: item.isYang ? 'youngYang' : 'youngYin' as DivinationResultData['yaoLines'][0]['type'], + mark: (item.specialMark as string) || '', + })); + } + + async function runDivination() { + try { + // 1. 提交起卦请求 + const { threadId, runId } = await enqueueDivinationRun(params, yaoStates); + + if (aborted) return; + + // 用于存储 derived 数据 + let ganzhi: DivinationResultData['ganzhi'] | null = null; + let guaName = ''; + let targetGuaName = ''; + let upperName = ''; + let lowerName = ''; + let binaryCode = ''; + let changedBinaryCode = ''; + let wuXingStatus: Record = {}; + let yaoLines: DivinationResultData['yaoLines'] = []; + let targetYaoLines: DivinationResultData['yaoLines'] = []; + + let signLevel = ''; + let conclusion = ''; + let focusPoints: string[] = []; + let advice: string[] = []; + let keywords: string[] = []; + let answer = ''; + let status: 'success' | 'failed' | 'refused' = 'success'; + + // 2. 监听 SSE 事件 + for await (const event of streamDivinationEvents(threadId, runId)) { + if (aborted) break; + + const { type, data } = event; + + if (type === 'DIVINATION_DERIVED') { + // 卦象推导完成 + const div = data.divination as Record | undefined; + if (div) { + ganzhi = { + yearGanZhi: (div.yearGanZhi as string) || '', + monthGanZhi: (div.monthGanZhi as string) || '', + dayGanZhi: (div.dayGanZhi as string) || '', + timeGanZhi: (div.timeGanZhi as string) || '', + yearKongWang: (div.yearKongWang as string) || '', + monthKongWang: (div.monthKongWang as string) || '', + dayKongWang: (div.dayKongWang as string) || '', + timeKongWang: (div.timeKongWang as string) || '', + yueJian: (div.yueJian as string) || '', + riChen: (div.riChen as string) || '', + yuePo: (div.yuePo as string) || '', + riChong: (div.riChong as string) || '', + }; + guaName = (div.guaName as string) || ''; + targetGuaName = (div.targetGuaName as string) || ''; + upperName = (div.upperName as string) || ''; + lowerName = (div.lowerName as string) || ''; + binaryCode = (div.binaryCode as string) || ''; + changedBinaryCode = (div.changedBinaryCode as string) || ''; + wuXingStatus = (div.wuXingStatuses as Record) || {}; + yaoLines = parseYaoInfoList(div.yaoInfoList); + targetYaoLines = parseYaoInfoList(div.targetYaoInfoList); + } + setStep('deriving'); + } else if (type === 'TEXT_MESSAGE_END') { + // 解卦结果 + signLevel = (data.sign_level as string) || ''; + conclusion = ((data.conclusion as string[]) || []).join('\n'); + focusPoints = (data.focus_points as string[]) || []; + advice = (data.advice as string[]) || []; + keywords = ((data.keywords as string[]) || []).join(' · '); + answer = (data.answer as string) || ''; + status = (data.status as 'success' | 'failed' | 'refused') || 'success'; + } else if (type === 'RUN_ERROR') { + const detail = (data.detail as string) || 'Unknown error'; + throw new Error(detail); + } else if (type === 'RUN_FINISHED') { + // 构建最终结果 + if (ganzhi) { + const result: DivinationResultData = { + threadId, + params, + binaryCode, + changedBinaryCode, + guaName, + targetGuaName, + upperName, + lowerName, + signType: signLevel, + keywords, + focusPoints, + conclusion, + analysis: answer, + suggestion: advice.join('\n'), + ganzhi, + wuXingStatus, + yaoLines, + targetYaoLines, + status, + }; + setResult(result); + } + setStep('done'); + break; + } + } + } catch (error) { + if (!aborted && onError) { + onError(error instanceof Error ? error : new Error(String(error))); + } + } + } + + runDivination(); + + return () => { + aborted = true; + }; + }, [params, yaoStates, onError]); + + const currentCard = cards[cardIndex]; + + const handleClick = useCallback(() => { + if (step === 'done') { + onComplete(result); + } + }, [step, onComplete, result]); + + return ( +
+ {/* 高斯模糊背景层 */} +
+ + {/* 暗色遮罩层 */} +
+ + {/* 中央内容区域 */} +
+ {/* 翻牌动画区域 */} +
+ {/* 背后的幻影牌 */} +
+
+
+ + {/* 主卡片 - Y轴翻转 */} +
+ {/* 正面 */} +
+
+ {text.badge} +
+ {currentCard.symbol} + {currentCard.title} + {currentCard.quote} +
+ + {/* 背面 */} +
+
+ {text.badge} +
+ {currentCard.symbol} + {currentCard.title} + {currentCard.quote} +
+
+
+ + {/* 状态区域 */} +
+ + {step === 'preparing' ? text.statusPreparing : step === 'deriving' ? text.statusDeriving : text.statusDone} + +
+
+
+ ); +} diff --git a/web/src/components/DivinationResultPage.tsx b/web/src/components/DivinationResultPage.tsx new file mode 100644 index 0000000..d4e0b29 --- /dev/null +++ b/web/src/components/DivinationResultPage.tsx @@ -0,0 +1,493 @@ +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { getAgentHistoryByThread, historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api'; +import Icon from './Icon'; + +interface Props { + locale: string; + translations: Record; +} + +const WU_XING = ['木', '火', '土', '金', '水']; + +function getSignTypeLabel(signType: string, t: Record): string { + const normalized = signType.trim(); + if (normalized.includes('上上')) return t.signTypeShangShang; + if (normalized.includes('中上')) return t.signTypeZhongShang; + if (normalized.includes('下下')) return t.signTypeXiaXia; + return t.signTypeZhongXia; +} + +function getSignImageSrc(signType: string): string { + const normalized = signType.trim(); + if (normalized.includes('上上')) return '/images/qigua/shangshang.jpg'; + if (normalized.includes('中上')) return '/images/qigua/zhongshang.jpg'; + if (normalized.includes('下下')) return '/images/qigua/xiaxia.jpg'; + return '/images/qigua/zhongxia.jpg'; +} + +function getQuestionTypeLabel(type: string, t: Record): string { + const map: Record = { + career: t.questionTypeCareer, + love: t.questionTypeLove, + wealth: t.questionTypeWealth, + fortune: t.questionTypeFortune, + dream: t.questionTypeDream, + health: t.questionTypeHealth, + study: t.questionTypeStudy, + search: t.questionTypeSearch, + other: t.questionTypeOther, + }; + return map[type] || type; +} + +function formatDivinationTime(date: Date, locale: string): string { + try { + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); + } catch { + return date.toLocaleString(); + } +} + +function abbreviateRelation(relation: string): string { + const map: Record = { + '子孙': '孙', '妻财': '财', '官鬼': '官', '兄弟': '兄', '父母': '父', + }; + return map[relation] || relation; +} + +function getChangeMark(type: YaoType): string { + if (type === 'oldYang') return '○'; + if (type === 'oldYin') return '×'; + return ''; +} + +function YaoGlyph({ type }: { type: YaoType }) { + const isYin = type === 'youngYin' || type === 'oldYin'; + if (!isYin) { + return
; + } + return ( +
+
+
+
+ ); +} + +function CopyButton({ text, label }: { text: string; label: string }) { + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + } catch { + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + }; + + return ( + + ); +} + +function AnalysisCard({ title, content, copyLabel }: { title: string; content: string; copyLabel: string }) { + return ( +
+
+

{title}

+ +
+

{content}

+
+ ); +} + +function FocusPointsCard({ points, title, copyLabel }: { points: string[]; title: string; copyLabel: string }) { + if (points.length === 0) return null; + const content = points.join('\n'); + + return ( +
+
+

{title}

+ +
+
+ {points.map((point, i) => ( +

{point}

+ ))} +
+
+ ); +} + +function WarningCard({ message }: { message: string }) { + return ( +
+ +

{message}

+
+ ); +} + +function InfoCard({ data, t, locale }: { data: DivinationResultData; t: Record; locale: string }) { + const methodLabel = data.params.method === 'auto' ? t.autoMethod : t.manualMethod; + + return ( +
+

{t.divinationInfo}

+
+
+ {t.divinationTime} + {formatDivinationTime(data.params.divinationTime, locale)} +
+
+ {t.divinationMethod} + {methodLabel} +
+
+ {t.questionType} + {getQuestionTypeLabel(data.params.questionType, t)} +
+
+ {t.question} + {data.params.question} +
+
+
+ ); +} + +function GanzhiCard({ ganzhi, wuXingStatus, t }: { ganzhi: DivinationResultData['ganzhi']; wuXingStatus: Record; t: Record }) { + return ( +
+

{t.ganZhiInfo}

+ +
+
+ {t.termYueJian}: + {ganzhi.yueJian} +
+
+ {t.termRiChen}: + {ganzhi.riChen} +
+
+ {t.termYuePo}: + {ganzhi.yuePo} +
+
+ {t.termRiChong}: + {ganzhi.riChong} +
+
+ +
+
{t.wuXingWangShuai}
+
+
+ {WU_XING.map((w, i) => ( +
+ {w} +
+ ))} +
+
+ {WU_XING.map((w, i) => ( +
+ {wuXingStatus[w] || ''} +
+ ))} +
+
+
+ +
+
{t.ganZhiKongWang}
+
+
+ {[t.pillarColumn, t.yearPillar, t.monthPillar, t.dayPillar, t.timePillar].map((h, i, arr) => ( +
+ {h} +
+ ))} +
+
+ {[t.ganZhiLabel, ganzhi.yearGanZhi, ganzhi.monthGanZhi, ganzhi.dayGanZhi, ganzhi.timeGanZhi].map((v, i, arr) => ( +
+ {v} +
+ ))} +
+
+ {[t.kongWangLabel, ganzhi.yearKongWang, ganzhi.monthKongWang, ganzhi.dayKongWang, ganzhi.timeKongWang].map((v, i, arr) => ( +
+ {v} +
+ ))} +
+
+
+
+ ); +} + +function YaoDetailRow({ line, target, showTarget }: { line: DivinationResultData['yaoLines'][0]; target: DivinationResultData['yaoLines'][0]; showTarget: boolean }) { + return ( +
+
+ {line.spirit} + {abbreviateRelation(line.relation)} + {line.branch} + {line.element} +
+ {getChangeMark(line.type)} + {line.mark} +
+ {showTarget && ( +
+ {target.spirit} + {abbreviateRelation(target.relation)} + {target.branch} + {target.element} +
+ {getChangeMark(target.type)} + +
+ )} +
+ ); +} + +function HexagramDetailCard({ data, t }: { data: DivinationResultData; t: Record }) { + const hasChangingYao = data.binaryCode !== data.changedBinaryCode; + + return ( +
+
+
{data.guaName}
+ {hasChangingYao && ( +
{data.targetGuaName}
+ )} +
+ +
+
+ {t.yaoColSpirit} + {t.yaoColRelation} + {t.yaoColBranch} + {t.yaoColElement} + + {t.yaoColChange} + {t.yaoColMark} +
+ {hasChangingYao && ( +
+ {t.yaoColSpirit} + {t.yaoColRelation} + {t.yaoColBranch} + {t.yaoColElement} + + {t.yaoColChange} + +
+ )} +
+ + {[5, 4, 3, 2, 1, 0].map((idx) => ( + + ))} +
+ ); +} + +function FollowUpPanel({ canFollowUp, onFollowUp, t }: { canFollowUp: boolean; onFollowUp?: () => void; t: Record }) { + return ( +
+

{canFollowUp ? t.followUpEntryHint : t.followUpQuotaUsed}

+ +
+ ); +} + +const RESULT_STORAGE_KEY = 'divination_result_data'; + +export default function DivinationResultPage({ locale, translations: t }: Props) { + const location = useLocation(); + const navigate = useNavigate(); + const { id: threadId } = useParams<{ id: string }>(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [canFollowUp, setCanFollowUp] = useState(true); + + useEffect(() => { + let alive = true; + + // 1. Try router state (from divination flow) + const state = location.state as { result?: DivinationResultData } | null; + if (state?.result) { + setData(state.result); + setLoading(false); + try { sessionStorage.setItem(RESULT_STORAGE_KEY, JSON.stringify(state.result)); } catch { /* ignore */ } + return; + } + + // 2. Try sessionStorage (backup for divination flow) + try { + const stored = sessionStorage.getItem(RESULT_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + setData(parsed); + setLoading(false); + return; + } + } catch { /* ignore */ } + + // 3. Fetch by threadId (from history flow) + if (threadId) { + setLoading(true); + getAgentHistoryByThread(threadId) + .then((snapshot) => { + if (!alive) return; + const assistantMsg = snapshot.messages.find((m) => m.role === 'assistant'); + if (!assistantMsg) return; + const resultData = historyMessageToResultData(assistantMsg); + if (resultData) setData(resultData); + const userCount = snapshot.messages.filter((m) => m.role === 'user').length; + setCanFollowUp(userCount < 2); + }) + .catch(() => { /* ignore */ }) + .finally(() => { if (alive) setLoading(false); }); + } else { + setLoading(false); + } + + return () => { alive = false; }; + }, [location.state, threadId]); + + // Redirect if no data and not loading + useEffect(() => { + if (!loading && data === null) { + const timer = setTimeout(() => { + navigate(`/${locale}/dashboard`); + }, 100); + return () => clearTimeout(timer); + } + }, [data, loading, navigate, locale]); + + const handleBackHome = () => { + navigate(`/${locale}/dashboard`); + }; + + const handleFollowUp = () => { + if (threadId) { + navigate(`/${locale}/history/${threadId}/followup`, { state: { result: data } }); + } + }; + + if (loading || data === null) { + return ( +
+
+
+ ); + } + + const isSuccess = data.status === 'success'; + + return ( +
+ {/* Header */} +
+ +

{t.screenTitle}

+
+ + {/* Body */} +
+ {/* Left: Analysis */} +
+ {/* Hero: sign image + gua name + question + tags */} +
+
+ {getSignTypeLabel(data.signType, +
+
+

+ {getSignTypeLabel(data.signType, t)} · {data.guaName} +

+

{data.params.question}

+
+ + {getQuestionTypeLabel(data.params.questionType, t)} + + + {data.params.method === 'auto' ? t.autoMethod : t.manualMethod} + +
+
+
+ {data.keywords && ( +
+

{data.keywords}

+
+ )} + + + + + +
+ + {/* Right side column */} +
+ {isSuccess && data.threadId && ( + + )} + + {isSuccess && ( + + )} + {isSuccess ? ( + + ) : ( +
+

+ {data.status === 'failed' ? t.hexagramDetailFailed : t.hexagramDetailRefused} +

+
+ )} +
+
+
+ ); +} diff --git a/web/src/components/HistoryFollowUpPage.tsx b/web/src/components/HistoryFollowUpPage.tsx index 111f19a..7b3d9f7 100644 --- a/web/src/components/HistoryFollowUpPage.tsx +++ b/web/src/components/HistoryFollowUpPage.tsx @@ -1,4 +1,13 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { + getAgentHistoryByThread, + historyMessageToResultData, + enqueueFollowUpRun, + streamDivinationEvents, + type DivinationResultData, +} from '../lib/api'; +import Icon from './Icon'; interface Props { locale: string; @@ -6,82 +15,376 @@ interface Props { history: { chatTitle: string; chatPlaceholder: string; sendBtn: string; followUpRules: string; followUpRule1: string; followUpRule2: string; relatedActions: string; newDivination: string; viewHistory: string; resultTitle: string }; } -const MOCK_MESSAGES = [ - { role: 'ai' as const, content: '您好,关于"今年转岗是否合适"的卦象解读已完成。如果您对某些方面还有疑问,可以进行一次追问。' }, - { role: 'user' as const, content: '请问什么时间转岗比较合适?' }, - { role: 'ai' as const, content: '根据天雷无妄卦的分析,结合当前时令,建议您关注秋季(农历七八月)的机会。届时天时更为有利,变动容易获得好的结果。目前阶段以积累和准备为主。' }, -]; +interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; +} + +function getSignImageSrc(signType: string): string { + const normalized = signType.trim(); + if (normalized.includes('上上')) return '/images/qigua/shangshang.jpg'; + if (normalized.includes('中上')) return '/images/qigua/zhongshang.jpg'; + if (normalized.includes('下下')) return '/images/qigua/xiaxia.jpg'; + return '/images/qigua/zhongxia.jpg'; +} + +function getQuestionTypeLabel(type: string): string { + const QUESTION_TYPE_ZH: Record = { + career: '事业', love: '情感', wealth: '财富', fortune: '运势', + dream: '解梦', health: '健康', study: '学业', search: '寻物', other: '其他', + '事业': '事业', '情感': '情感', '财富': '财富', '运势': '运势', + '解梦': '解梦', '健康': '健康', '学业': '学业', '寻物': '寻物', '其他': '其他', + }; + return QUESTION_TYPE_ZH[type] || type; +} + +const CATEGORY_COLORS: Record = { + 'career': 'bg-blue-50 text-blue-500', + 'love': 'bg-pink-50 text-pink-500', + 'wealth': 'bg-amber-50 text-amber-600', + 'fortune': 'bg-purple-50 text-purple-500', + 'dream': 'bg-indigo-50 text-indigo-500', + 'health': 'bg-red-50 text-red-500', + 'study': 'bg-green-50 text-green-600', + 'search': 'bg-cyan-50 text-cyan-600', + 'other': 'bg-slate-100 text-slate-500', + '事业': 'bg-blue-50 text-blue-500', + '情感': 'bg-pink-50 text-pink-500', + '财富': 'bg-amber-50 text-amber-600', + '运势': 'bg-purple-50 text-purple-500', + '解梦': 'bg-indigo-50 text-indigo-500', + '健康': 'bg-red-50 text-red-500', + '学业': 'bg-green-50 text-green-600', + '寻物': 'bg-cyan-50 text-cyan-600', + '其他': 'bg-slate-100 text-slate-500', +}; export default function HistoryFollowUpPage({ locale, history: h }: Props) { - const [message, setMessage] = useState(''); - const [messages, setMessages] = useState(MOCK_MESSAGES); + const location = useLocation(); + const navigate = useNavigate(); + const { id: threadId } = useParams<{ id: string }>(); - const handleSend = () => { - if (!message.trim()) return; - setMessages(prev => [...prev, { role: 'user', content: message }]); - setMessage(''); + const [resultData, setResultData] = useState(null); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [error, setError] = useState(''); + const [inputText, setInputText] = useState(''); + + const messagesEndRef = useRef(null); + + const scrollToBottom = useCallback(() => { + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 50); + }, []); + + // Load history messages and result data + useEffect(() => { + let alive = true; + + async function load() { + if (!threadId) { + setLoading(false); + return; + } + + try { + const snapshot = await getAgentHistoryByThread(threadId); + if (!alive) return; + + // Extract result data from first assistant message + const assistantMsg = snapshot.messages.find((m) => m.role === 'assistant'); + if (assistantMsg) { + const result = historyMessageToResultData(assistantMsg); + if (result) setResultData(result); + } + + // Convert all messages to chat format (user + assistant) + const chatMessages: ChatMessage[] = snapshot.messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ + id: m.id, + role: m.role as 'user' | 'assistant', + content: m.content || '', + })); + + setMessages(chatMessages); + } catch (err) { + if (!alive) return; + setError(err instanceof Error ? err.message : 'Failed to load history'); + } finally { + if (alive) { + setLoading(false); + scrollToBottom(); + } + } + } + + // Also try to get result from router state (passed from DivinationResultPage) + const state = location.state as { result?: DivinationResultData } | null; + if (state?.result) { + setResultData(state.result); + } + + load(); + return () => { alive = false; }; + }, [threadId, location.state, scrollToBottom]); + + // Follow-up quota: original user message + max 1 follow-up + const hasFollowUpQuota = (() => { + const userCount = messages.filter((m) => m.role === 'user').length; + return userCount < 2; + })(); + + const handleSend = useCallback(async () => { + const text = inputText.trim(); + if (!text || sending || !hasFollowUpQuota || !threadId || !resultData) return; + + setInputText(''); + setSending(true); + setError(''); + + const localUserMsg: ChatMessage = { + id: `local_user_${Date.now()}`, + role: 'user', + content: text, + }; + const localAssistantMsg: ChatMessage = { + id: `local_assistant_${Date.now()}`, + role: 'assistant', + content: '', + }; + + setMessages((prev) => [...prev, localUserMsg, localAssistantMsg]); + scrollToBottom(); + + try { + const { runId } = await enqueueFollowUpRun(threadId, text, resultData); + + let answer = ''; + for await (const event of streamDivinationEvents(threadId, runId)) { + if (event.type === 'TEXT_MESSAGE_END') { + answer = (event.data.answer as string) || ''; + } else if (event.type === 'RUN_ERROR') { + throw new Error((event.data.detail as string) || 'Follow-up failed'); + } else if (event.type === 'RUN_FINISHED') { + break; + } + } + + // Update assistant message with answer + setMessages((prev) => + prev.map((m) => + m.id === localAssistantMsg.id ? { ...m, content: answer } : m + ) + ); + + // Reload history to get server-side message IDs + const snapshot = await getAgentHistoryByThread(threadId); + const chatMessages: ChatMessage[] = snapshot.messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ + id: m.id, + role: m.role as 'user' | 'assistant', + content: m.content || '', + })); + setMessages(chatMessages); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send follow-up'); + // Reload history to restore correct state + try { + const snapshot = await getAgentHistoryByThread(threadId); + const chatMessages: ChatMessage[] = snapshot.messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ + id: m.id, + role: m.role as 'user' | 'assistant', + content: m.content || '', + })); + setMessages(chatMessages); + } catch { /* ignore */ } + } finally { + setSending(false); + scrollToBottom(); + } + }, [inputText, sending, hasFollowUpQuota, threadId, resultData, scrollToBottom]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } }; + const handleBack = () => { + navigate(-1); + }; + + const signImgSrc = resultData ? getSignImageSrc(resultData.signType) : ''; + const questionTypeLabel = resultData ? getQuestionTypeLabel(resultData.params.questionType) : ''; + const categoryColor = resultData ? (CATEGORY_COLORS[resultData.params.questionType] || 'bg-slate-100 text-slate-500') : ''; + return (
- {/* Chat panel */} -
- {/* Header */} -
+ {/* Chat panel */} +
+ {/* Header */} +
+
+

{h.chatTitle}

- 天雷无妄
+ {resultData && ( + {resultData.guaName} + )} +
- {/* Messages */} -
- {messages.map((msg, i) => ( -
-
- {msg.content} + {/* Messages */} +
+ {loading ? ( +
+
+
+ ) : messages.length === 0 ? ( +
+

+ {locale === 'en' ? 'No messages yet' : '暂无对话消息'} +

+
+ ) : ( + messages.map((msg) => ( +
+
+ {msg.content || ( + + . + . + . + + )}
- ))} -
+ )) + )} +
+
- {/* Composer */} -
-