feat: add AI knowledge analysis MVP

This commit is contained in:
2026-07-05 00:47:52 -07:00
parent 6aa72d3b43
commit e204974b57
32 changed files with 8705 additions and 4 deletions

View File

@@ -0,0 +1,226 @@
"use client";
import { useEffect, useState } from "react";
import { Brain, RefreshCw, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import type { ElderAiAnalysisHistoryItem } from "@/modules/ai/types";
import type { ApiResult } from "@/modules/core/server/api";
import type { Elder } from "@/modules/elders/types";
type ElderAiAnalysisDialogProps = {
elder: Elder | null;
open: boolean;
onClose: () => void;
};
const riskLabels: Record<string, string> = {
low: "低",
medium: "中",
high: "高",
critical: "紧急",
unknown: "未知",
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN");
}
export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisDialogProps): React.ReactElement {
const [history, setHistory] = useState<ElderAiAnalysisHistoryItem[]>([]);
const [message, setMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
async function loadHistory(target: Elder): Promise<void> {
setIsLoading(true);
setMessage("");
const response = await fetch(`/api/ai/elders/${target.id}/analyses`);
const result = (await response.json()) as ApiResult<{ history: ElderAiAnalysisHistoryItem[] }>;
setIsLoading(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setHistory(result.history);
}
useEffect(() => {
if (open && elder) {
void loadHistory(elder);
}
}, [elder, open]);
async function generate(): Promise<void> {
if (!elder) {
return;
}
setIsGenerating(true);
setMessage("");
const response = await fetch(`/api/ai/elders/${elder.id}/analyses`, { method: "POST" });
const result = (await response.json()) as ApiResult<{ analysis: ElderAiAnalysisHistoryItem }>;
setIsGenerating(false);
if (!result.success) {
setMessage(result.reason);
await loadHistory(elder);
return;
}
setHistory((current) => [result.analysis, ...current]);
setMessage(result.reason);
}
const latest = history[0];
const latestResult = latest && !latest.restricted ? latest.result : undefined;
return (
<Dialog
description={elder ? `${elder.name} 的授权数据范围内 AI 分析历史和最新结论。` : undefined}
onClose={onClose}
open={open}
title="AI 分析"
width="wide"
>
{elder ? (
<div className="grid gap-4">
<div className="flex flex-col gap-3 rounded-md border bg-secondary/30 p-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-lg font-semibold">{elder.name}</p>
<p className="text-sm text-muted-foreground">
{elder.age} · {elder.careLevel} · {elder.status} · {elder.room && elder.bed ? `${elder.room}-${elder.bed}` : "未绑定床位"}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={isLoading || isGenerating} onClick={() => void loadHistory(elder)} type="button" variant="outline">
<RefreshCw className="size-4" aria-hidden="true" />
</Button>
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
<Sparkles className="size-4" aria-hidden="true" />
{isGenerating ? "生成中" : "生成分析"}
</Button>
</div>
</div>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
{latestResult ? (
<Card>
<CardHeader className="gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription>{latest ? formatDateTime(latest.createdAt) : ""}</CardDescription>
</div>
<Badge variant={latestResult.overallRiskLevel === "critical" || latestResult.overallRiskLevel === "high" ? "destructive" : "secondary"}>
{riskLabels[latestResult.overallRiskLevel] ?? latestResult.overallRiskLevel}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-4">
<p className="text-sm leading-6">{latestResult.summary}</p>
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{latestResult.keyFindings.map((finding, index) => (
<div className="rounded-md border p-3 text-sm" key={`${finding.category}-${index}`}>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{finding.category}</span>
<Badge variant={finding.severity === "critical" ? "destructive" : "secondary"}>{finding.severity}</Badge>
</div>
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
</div>
))}
</div>
</section>
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{latestResult.recommendations.map((recommendation, index) => (
<div className="rounded-md border p-3 text-sm" key={`${recommendation.title}-${index}`}>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{recommendation.title}</span>
<Badge variant={recommendation.priority === "urgent" ? "destructive" : "secondary"}>{recommendation.priority}</Badge>
</div>
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
<p className="mt-1">{recommendation.suggestedNextStep}</p>
</div>
))}
</div>
</section>
{latestResult.dataGaps.length > 0 ? (
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<ul className="grid gap-1 text-sm text-muted-foreground">
{latestResult.dataGaps.map((gap) => (
<li key={gap}>{gap}</li>
))}
</ul>
</section>
) : null}
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{latestResult.citations.map((citation) => (
<div className="rounded-md bg-secondary/40 p-3 text-xs" key={citation.id}>
<p className="font-medium">
[{citation.id}] {citation.title}
</p>
<p className="mt-1 text-muted-foreground">{citation.excerpt}</p>
</div>
))}
</div>
</section>
</CardContent>
</Card>
) : latest ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>{formatDateTime(latest.createdAt)}</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
{isLoading ? "正在加载分析历史" : "暂无 AI 分析历史"}
</CardContent>
</Card>
)}
<section className="grid gap-2">
<h3 className="text-sm font-semibold"></h3>
<div className="grid gap-2">
{history.map((item) => (
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm sm:flex-row sm:items-center sm:justify-between" key={item.id}>
<div>
<p className="font-medium">{formatDateTime(item.createdAt)}</p>
<p className="text-xs text-muted-foreground">{item.dataScopes.join(", ") || "-"}</p>
</div>
<div className="flex items-center gap-2">
{item.restricted ? <Badge variant="secondary"></Badge> : null}
<Badge variant={item.status === "failed" ? "destructive" : "secondary"}>{item.status}</Badge>
</div>
</div>
))}
</div>
</section>
</div>
) : (
<div className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
<Brain className="mr-2 size-4" aria-hidden="true" />
</div>
)}
</Dialog>
);
}