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>
|
|||
|
|
);
|
|||
|
|
}
|