Files
zhimingge/components/modes/liuyao-form.tsx
T
dekun 0f3bc2c50a Fix AI stream by returning stream.value directly from Server Actions.
createStreamableValue must be created and returned in the action itself; wrapping in { data } or a helper return caused production RSC serialization errors.

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

250 lines
7.2 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 { readStreamableValue } from "ai/rsc";
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 { getLiuyaoAnswer } from "@/app/actions/liuyao";
import type { GuaResult } from "@/lib/calc/hexagram";
import { ERROR_PREFIX } from "@/lib/constant";
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 {
const stream = await getLiuyaoAnswer({
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,
});
let ret = "";
for await (const delta of readStreamableValue(stream)) {
if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) {
setError(delta.slice(ERROR_PREFIX.length));
return;
}
ret += delta ?? "";
setCompletion(ret);
}
} 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>
);
}