187b08c3e1
Co-authored-by: Cursor <cursoragent@cursor.com>
140 lines
4.3 KiB
TypeScript
140 lines
4.3 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
||
import { Download, RotateCw } from "lucide-react";
|
||
import Markdown from "react-markdown";
|
||
import { Button } from "@/components/ui/button";
|
||
import { TaijiIcon } from "@/components/svg/taiji";
|
||
import { downloadMarkdown } from "@/lib/history/storage";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
function ResultAI({
|
||
completion,
|
||
isLoading,
|
||
onCompletion,
|
||
error,
|
||
warning,
|
||
panel = false,
|
||
emptyHint = "完成左侧填写并起卦/排盘后,点击「AI 解读」",
|
||
downloadFilename,
|
||
downloadPreamble,
|
||
}: {
|
||
completion: string;
|
||
isLoading: boolean;
|
||
onCompletion: () => void;
|
||
error: string;
|
||
warning?: string;
|
||
panel?: boolean;
|
||
emptyHint?: string;
|
||
downloadFilename?: string;
|
||
downloadPreamble?: string;
|
||
}) {
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
const [autoScroll, setAutoScroll] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setAutoScroll(isLoading);
|
||
}, [isLoading]);
|
||
|
||
useEffect(() => {
|
||
if (!autoScroll) {
|
||
return;
|
||
}
|
||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
|
||
}, [completion, autoScroll]);
|
||
|
||
function onScroll(e: HTMLElement) {
|
||
if (!isLoading) {
|
||
return;
|
||
}
|
||
const hitBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 15;
|
||
if (hitBottom !== autoScroll) {
|
||
setAutoScroll(hitBottom);
|
||
}
|
||
}
|
||
|
||
function handleDownload() {
|
||
if (!completion || !downloadFilename) {
|
||
return;
|
||
}
|
||
const body = downloadPreamble
|
||
? `${downloadPreamble}\n\n---\n\n${completion}`
|
||
: completion;
|
||
downloadMarkdown(body, downloadFilename);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"flex w-full flex-col",
|
||
panel ? "min-h-0 flex-1" : "gap-2",
|
||
)}
|
||
>
|
||
{isLoading && !panel && (
|
||
<div className="flex items-center text-sm text-muted-foreground">
|
||
<RotateCw size={16} className="animate-spin" />
|
||
<span className="ml-1">AI 分析中...</span>
|
||
</div>
|
||
)}
|
||
<div
|
||
ref={scrollRef}
|
||
onScroll={(e) => onScroll(e.currentTarget)}
|
||
className={cn(
|
||
"overflow-y-auto",
|
||
panel
|
||
? "min-h-0 flex-1"
|
||
: "min-h-[240px] max-h-[420px] rounded-md border bg-background p-3 shadow sm:p-5 dark:border-0 dark:bg-secondary/90",
|
||
)}
|
||
>
|
||
{isLoading && panel && (
|
||
<div className="mb-3 flex items-center text-sm text-muted-foreground">
|
||
<RotateCw size={16} className="animate-spin" />
|
||
<span className="ml-1">AI 分析中...</span>
|
||
</div>
|
||
)}
|
||
{error ? (
|
||
<div className="text-sm text-destructive">
|
||
<p className="font-medium">请求出错了</p>
|
||
<p className="mt-1 whitespace-pre-wrap">{error}</p>
|
||
</div>
|
||
) : completion ? (
|
||
<>
|
||
{warning && (
|
||
<div className="mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-300">
|
||
<p className="font-medium">解读已完成,但历史未保存</p>
|
||
<p className="mt-1 whitespace-pre-wrap">{warning}</p>
|
||
</div>
|
||
)}
|
||
<Markdown className="prose max-w-none text-sm dark:prose-invert prose-p:leading-relaxed">
|
||
{completion}
|
||
</Markdown>
|
||
</>
|
||
) : isLoading ? (
|
||
<p className="text-sm text-muted-foreground">正在等待 AI 响应...</p>
|
||
) : (
|
||
<div className="flex h-full min-h-[200px] flex-col items-center justify-center text-center text-muted-foreground">
|
||
<TaijiIcon className="h-16 w-16 opacity-25" />
|
||
<p className="mt-3 max-w-[220px] text-sm leading-relaxed">
|
||
{emptyHint}
|
||
</p>
|
||
</div>
|
||
)}
|
||
{!isLoading && (completion || error) && (
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
{completion && downloadFilename && (
|
||
<Button size="sm" variant="outline" onClick={handleDownload}>
|
||
<Download size={16} className="mr-1" />
|
||
下载 Markdown
|
||
</Button>
|
||
)}
|
||
<Button onClick={onCompletion} size="sm" variant="outline">
|
||
<RotateCw size={16} className="mr-1" />
|
||
重新生成
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default ResultAI;
|