Files
CollegeAIcenter/web/src/app/student/clinical/page.tsx
T

244 lines
7.6 KiB
TypeScript

"use client";
/**
* 临床情景对话对练:输入情景标识发起会话 → 逐轮对话 → 结束生成三维评估报告。
*/
import { useState } from "react";
import { Send, Stethoscope } from "lucide-react";
import {
CredibilityBadge,
RawDetails,
ScoreBar,
StatTile,
} from "@/components/display";
import { ErrorBanner, Loading, PageHeading } from "@/components/feedback";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { clinicalApi } from "@/lib/services";
import type { DialogueReport } from "@/lib/types";
import { cn } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
interface ChatTurn {
role: "student" | "system";
text: string;
}
export default function ClinicalPage() {
const [scenarioId, setScenarioId] = useState("");
const [session, setSession] = useState<any>(null);
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [input, setInput] = useState("");
const [report, setReport] = useState<DialogueReport | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run<T>(fn: () => Promise<T>, after?: (v: T) => void) {
setError(null);
setBusy(true);
try {
after?.(await fn());
} catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败");
} finally {
setBusy(false);
}
}
function extractSystemReply(v: any): string {
return (
v?.systemResponse ??
v?.reply ??
v?.message ??
v?.turn?.systemResponse ??
JSON.stringify(v)
);
}
function sendTurn() {
if (!input || busy || !session) return;
const text = input;
setTurns((t) => [...t, { role: "student", text }]);
setInput("");
run(
() => clinicalApi.sendTurn(session.id, { studentInput: text }),
(v: any) =>
setTurns((t) => [...t, { role: "system", text: extractSystemReply(v) }]),
);
}
return (
<div className="space-y-6">
<PageHeading
icon={<Stethoscope className="size-5" />}
title="临床模拟"
description="模拟真实问诊、查体与医患沟通情景,与 AI 患者交互后获得多维评估反馈。"
/>
<UsageGuide
steps={[
{ title: "输入情景标识", detail: "如「胸痛分诊」,选择要演练的临床情景。" },
{ title: "开始对话", detail: "点击「开始对话」进入情景,AI 扮演患者 / 同行角色。" },
{ title: "逐轮问诊", detail: "在输入框输入问诊或处置内容,回车或「发送」推进对话。" },
{ title: "结束并评估", detail: "点击「结束并评估」获得沟通、临床思维等三维评分与点评。" },
]}
tip="尽量像真实问诊一样有条理地展开;评估报告含可信度标注,注意核验 AI 给出的信息。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[220px] flex-1">
<Label></Label>
<Input
value={scenarioId}
onChange={(e) => setScenarioId(e.target.value)}
placeholder="如:胸痛分诊"
/>
</div>
<Button
disabled={!scenarioId || busy}
onClick={() =>
run(
() => clinicalApi.start(scenarioId),
(v: any) => {
setSession(v);
setTurns([]);
setReport(null);
},
)
}
>
</Button>
</div>
{error && (
<div className="mt-4">
<ErrorBanner message={error} />
</div>
)}
</CardContent>
</Card>
{session && !report && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="scrollbar-thin mb-4 max-h-96 space-y-3 overflow-auto">
{turns.length === 0 && (
<p className="text-sm text-muted-foreground">
</p>
)}
{turns.map((t, i) => (
<div
key={i}
className={cn(
"flex",
t.role === "student" ? "justify-end" : "justify-start",
)}
>
<div
className={cn(
"max-w-[80%] rounded-2xl px-4 py-2 text-sm",
t.role === "student"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground",
)}
>
{t.text}
</div>
</div>
))}
</div>
{busy && <Loading />}
<div className="flex gap-3">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入对话内容…"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendTurn();
}
}}
/>
<Button disabled={!input || busy} onClick={sendTurn}>
<Send />
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(
() => clinicalApi.finish(session.id),
(v: any) => setReport(v),
)
}
>
</Button>
</div>
</CardContent>
</Card>
)}
{report && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-4">
<StatTile
label="综合评分"
value={report.overallScore?.toFixed?.(0) ?? report.overallScore}
tone="primary"
/>
<div className="text-sm text-muted-foreground">
{report.turnCount}
</div>
</div>
<div className="space-y-3">
{report.dimensions?.map((d) => (
<div key={d.dimension}>
<ScoreBar
label={d.dimensionName}
score={d.score}
tone={d.score >= 60 ? "success" : "warning"}
/>
{d.comment && (
<p className="mt-1 text-xs text-muted-foreground">{d.comment}</p>
)}
</div>
))}
</div>
<div className="mt-4">
<CredibilityBadge annotation={report.annotation} />
</div>
<RawDetails data={report} />
</CardContent>
</Card>
)}
</div>
);
}