108 lines
3.7 KiB
TypeScript
108 lines
3.7 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { AppIcon } from "./PixelIcons";
|
||
import { Card, Tag, Loading } from "./ui";
|
||
import { api } from "../lib/api";
|
||
|
||
type Props = {
|
||
symbol: string;
|
||
scope: "stock" | "company" | "fund";
|
||
title?: string;
|
||
auto?: boolean;
|
||
aiFocus: { symbol: string; subPage: string; label: string; cardTitle?: string };
|
||
};
|
||
|
||
function verdictTone(v?: string): "up" | "down" | "gold" {
|
||
if (v === "偏正面") return "up";
|
||
if (v === "謹慎") return "down";
|
||
return "gold";
|
||
}
|
||
|
||
export default function AiSummaryCard({ symbol, scope, title = "AI 今日總結", auto = true, aiFocus }: Props) {
|
||
const qc = useQueryClient();
|
||
const q = useQuery({
|
||
queryKey: ["ai-summary", symbol, scope],
|
||
queryFn: () => api.aiSummary(symbol, scope, auto),
|
||
enabled: !!symbol && auto,
|
||
staleTime: 12 * 3600_000,
|
||
retry: 1,
|
||
});
|
||
|
||
const syncMut = useMutation({
|
||
mutationFn: () => api.syncAiSummary(symbol, scope),
|
||
onSuccess: (data) => {
|
||
qc.setQueryData(["ai-summary", symbol, scope], data);
|
||
},
|
||
});
|
||
|
||
const summary = q.data?.summary;
|
||
const loading = q.isLoading || syncMut.isPending;
|
||
|
||
return (
|
||
<Card
|
||
className="mt"
|
||
title={title}
|
||
ico={<AppIcon name="scroll" size={22} framed variant="hero" />}
|
||
ai
|
||
aiFocus={{ ...aiFocus, cardTitle: title }}
|
||
onRefresh={() => syncMut.mutate()}
|
||
refreshing={loading}
|
||
refreshLabel="重新分析"
|
||
>
|
||
<div className="ai-summary-toolbar">
|
||
<span className="small muted">
|
||
{q.data?.cached
|
||
? `今日已分析 · ${q.data?.summary?.day || ""} · 讀資料庫(省 token)`
|
||
: q.data?.mcpUsed
|
||
? "MCP + 本機資料已送 AI 總結"
|
||
: "整合本機財報/公司研究;可串 MCP 補充"}
|
||
</span>
|
||
</div>
|
||
|
||
{loading && !summary ? (
|
||
<Loading label="MCP 取資料並產生總結…" />
|
||
) : summary ? (
|
||
<>
|
||
<div className="fund-verdict-row">
|
||
{summary.verdict ? <Tag tone={verdictTone(summary.verdict)}>{summary.verdict}</Tag> : null}
|
||
{summary.provider ? <span className="small muted">via {summary.provider}</span> : null}
|
||
</div>
|
||
<div className="interpret-box">
|
||
<div className="interpret-label">AI 總結</div>
|
||
<p>{summary.summaryZh}</p>
|
||
{summary.bullets?.length ? (
|
||
<ul className="interpret-bullets">
|
||
{summary.bullets.map((b, i) => (
|
||
<li key={i}>{b}</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
{summary.risks?.length ? (
|
||
<div className="ai-summary-risks mt-s">
|
||
<h4 className="small" style={{ color: "var(--crimson)", margin: "0 0 6px" }}>
|
||
風險/待確認
|
||
</h4>
|
||
<ul className="interpret-bullets">
|
||
{summary.risks.map((r, i) => (
|
||
<li key={i}>{r}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
{summary.sources?.length ? (
|
||
<p className="small muted mt-s">來源:{summary.sources.join(" · ")}</p>
|
||
) : null}
|
||
{q.data?.skipReason ? <p className="small muted">{q.data.skipReason}</p> : null}
|
||
{q.data?.aiError ? <p className="small muted">AI 略過:{q.data.aiError}(已用規則備援)</p> : null}
|
||
</>
|
||
) : (
|
||
<p className="muted">
|
||
尚無今日總結。
|
||
<button type="button" className="btn-primary" style={{ marginLeft: 8 }} disabled={loading} onClick={() => syncMut.mutate()}>
|
||
產生 AI 總結
|
||
</button>
|
||
</p>
|
||
)}
|
||
</Card>
|
||
);
|
||
} |