Files
zhimingge/components/modes/liuyao-form.tsx
T
dekun dba0245cb1 Replace RSC streaming with /api/ai Route Handler for Docker reliability.
Server Actions + createStreamableValue kept failing in production; fetch-based text stream avoids RSC serialization issues and shows readable error messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 22:39:09 +08:00

244 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
import { BrainCircuit, ListRestart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import Result from "@/components/result";
import ResultAI from "@/components/result-ai";
import DateTimePicker, {
nowDateString,
nowTimeString,
} from "@/components/shared/datetime-picker";
import HexagramInput from "@/components/shared/hexagram-input";
import RegionSelect, {
useRegionLocation,
} from "@/components/shared/region-select";
import { streamAiCompletion } from "@/lib/ai/client-stream";
import type { GuaResult } from "@/lib/calc/hexagram";
import todayJson from "@/lib/data/today.json";
const todayData: string[] = todayJson;
export default function LiuyaoForm() {
const [question, setQuestion] = useState("");
const [provinceCode, setProvinceCode] = useState("");
const [cityCode, setCityCode] = useState("");
const [calcDate, setCalcDate] = useState(nowDateString);
const [calcTime, setCalcTime] = useState(nowTimeString);
const [castMode, setCastMode] = useState<"online" | "offline">("online");
const [guaData, setGuaData] = useState<GuaResult | null>(null);
const [completion, setCompletion] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [showAi, setShowAi] = useState(false);
const actionRef = useRef<HTMLDivElement>(null);
const location = useRegionLocation(provinceCode, cityCode);
const formReady = question.trim() !== "" && location !== null;
useEffect(() => {
if (guaData && actionRef.current) {
actionRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [guaData]);
function validate(): string | null {
if (!question.trim()) {
return "请输入问事";
}
if (!location) {
return "请选择起卦省份";
}
if (!guaData) {
return "请先完成 6 次起卦(线上摇卦或线下录入)";
}
return null;
}
async function handleAnalyze() {
const err = validate();
if (err) {
setError(err);
return;
}
setError("");
setCompletion("");
setIsLoading(true);
setShowAi(true);
try {
await streamAiCompletion(
{
mode: "liuyao",
payload: {
question,
calcDate,
calcTime,
locationName: location!.name,
longitude: location!.longitude,
guaMark: guaData!.result.guaMark,
guaTitle: guaData!.result.guaTitle,
guaResult: guaData!.result.guaResult,
guaChange: guaData!.result.guaChange,
},
},
setCompletion,
);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsLoading(false);
}
}
function handleReset() {
setQuestion("");
setProvinceCode("");
setCityCode("");
setCalcDate(nowDateString());
setCalcTime(nowTimeString());
setGuaData(null);
setCompletion("");
setError("");
setShowAi(false);
setIsLoading(false);
}
function handleGuaResult(data: GuaResult) {
setGuaData(data);
setShowAi(false);
setCompletion("");
setError("");
}
return (
<div className="flex flex-1 flex-col gap-4 px-4 py-6">
<div className="mx-auto w-full max-w-lg space-y-5">
<section className="space-y-2">
<label className="text-sm font-medium"></label>
<Textarea
placeholder="您想算点什么?"
value={question}
onChange={(e) => setQuestion(e.target.value)}
rows={3}
/>
<div className="flex flex-wrap gap-2">
{todayData.map((item, index) => (
<button
key={index}
type="button"
onClick={() => setQuestion(item)}
className="rounded-md border bg-secondary px-2 py-1 text-xs text-muted-foreground transition hover:bg-accent"
>
{item}
</button>
))}
</div>
</section>
<RegionSelect
label="起卦地域"
provinceCode={provinceCode}
cityCode={cityCode}
onProvinceChange={setProvinceCode}
onCityChange={setCityCode}
/>
<DateTimePicker
label="起卦时辰"
date={calcDate}
time={calcTime}
onDateChange={setCalcDate}
onTimeChange={setCalcTime}
/>
<section className="space-y-2">
<label className="text-sm font-medium"></label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={castMode === "online" ? "default" : "outline"}
onClick={() => {
setCastMode("online");
setGuaData(null);
setShowAi(false);
}}
>
线
</Button>
<Button
type="button"
size="sm"
variant={castMode === "offline" ? "default" : "outline"}
onClick={() => {
setCastMode("offline");
setGuaData(null);
setShowAi(false);
}}
>
线
</Button>
</div>
</section>
<HexagramInput
key={castMode}
mode={castMode}
enabled={formReady}
onResult={handleGuaResult}
onClear={() => setGuaData(null)}
/>
{guaData && (
<div className="space-y-3 rounded-lg border border-primary/30 bg-primary/5 p-4">
<p className="text-sm font-medium text-primary">
· AI
</p>
<Result {...guaData.result} />
</div>
)}
{!guaData && formReady && castMode === "online" && (
<p className="text-center text-xs text-muted-foreground">
线 6 AI
</p>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div ref={actionRef} className="flex gap-2">
<Button variant="outline" onClick={handleReset} className="flex-1">
<ListRestart size={16} className="mr-1" />
</Button>
<Button
onClick={handleAnalyze}
disabled={!guaData || isLoading}
className="flex-1"
>
<BrainCircuit size={16} className="mr-1" />
AI
</Button>
</div>
</div>
{showAi && (
<div className="mx-auto h-96 w-full max-w-lg flex-1">
<ResultAI
completion={completion}
isLoading={isLoading}
onCompletion={handleAnalyze}
error={error}
/>
</div>
)}
</div>
);
}