Files
zhimingge/components/modes/combined-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

320 lines
9.9 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 { useState } from "react";
import { readStreamableValue } from "ai/rsc";
import { Compass } 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 BaziChartDisplay from "@/components/modes/bazi-chart";
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 { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
import type { GuaResult } from "@/lib/calc/hexagram";
import { getCombinedAnswer } from "@/app/actions/combined";
import { ERROR_PREFIX } from "@/lib/constant";
export default function CombinedForm() {
const [birthDate, setBirthDate] = useState("1990-01-01");
const [birthTime, setBirthTime] = useState("12:00");
const [unknownHour, setUnknownHour] = useState(false);
const [gender, setGender] = useState<"male" | "female">("male");
const [birthProvince, setBirthProvince] = useState("");
const [birthCity, setBirthCity] = useState("");
const [currentProvince, setCurrentProvince] = useState("");
const [currentCity, setCurrentCity] = useState("");
const [calcDate, setCalcDate] = useState(nowDateString);
const [calcTime, setCalcTime] = useState(nowTimeString);
const [question, setQuestion] = useState("");
const [withHexagram, setWithHexagram] = useState(false);
const [castMode, setCastMode] = useState<"online" | "offline">("online");
const [guaData, setGuaData] = useState<GuaResult | null>(null);
const [chart, setChart] = useState<BaziChart | null>(null);
const [completion, setCompletion] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [showAi, setShowAi] = useState(false);
const birthLocation = useRegionLocation(birthProvince, birthCity);
const currentLocation = useRegionLocation(currentProvince, currentCity);
const hexagramReady =
!withHexagram ||
(question.trim() !== "" && currentLocation !== null && guaData !== null);
function validate(): string | null {
if (!birthLocation) {
return "请选择出生地域";
}
if (!currentLocation) {
return "请选择当前所在地域";
}
if (!question.trim()) {
return "请输入问事内容";
}
if (withHexagram && !guaData) {
return "请完成六爻起卦,或取消附加六爻";
}
return null;
}
function handlePreview() {
const err = validate();
if (err) {
setError(err);
return;
}
setError("");
setChart(
calculateBazi({
date: birthDate,
time: unknownHour ? "12:00" : birthTime,
gender,
longitude: birthLocation!.longitude,
unknownHour,
}),
);
setShowAi(false);
setCompletion("");
}
async function handleAnalyze() {
const err = validate();
if (err) {
setError(err);
return;
}
if (!chart) {
setChart(
calculateBazi({
date: birthDate,
time: unknownHour ? "12:00" : birthTime,
gender,
longitude: birthLocation!.longitude,
unknownHour,
}),
);
}
setError("");
setCompletion("");
setIsLoading(true);
setShowAi(true);
try {
const stream = await getCombinedAnswer({
birth: {
date: birthDate,
time: unknownHour ? "12:00" : birthTime,
gender,
longitude: birthLocation!.longitude,
unknownHour,
},
birthPlaceName: birthLocation!.name,
currentPlaceName: currentLocation!.name,
currentLongitude: currentLocation!.longitude,
calcDate,
calcTime,
question,
hexagram: withHexagram && guaData
? {
guaMark: guaData.result.guaMark,
guaTitle: guaData.result.guaTitle,
guaResult: guaData.result.guaResult,
guaChange: guaData.result.guaChange,
}
: undefined,
});
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);
}
}
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-6">
<section className="space-y-3">
<h2 className="text-sm font-semibold text-primary"> · </h2>
<DateTimePicker
label="出生日期 / 时间"
date={birthDate}
time={birthTime}
timeDisabled={unknownHour}
onDateChange={setBirthDate}
onTimeChange={setBirthTime}
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={unknownHour}
onChange={(e) => setUnknownHour(e.target.checked)}
/>
</label>
<div className="flex gap-4">
{(["male", "female"] as const).map((g) => (
<label key={g} className="flex items-center gap-2 text-sm">
<input
type="radio"
name="combined-gender"
checked={gender === g}
onChange={() => setGender(g)}
/>
{g === "male" ? "男" : "女"}
</label>
))}
</div>
<RegionSelect
label="出生地域"
provinceCode={birthProvince}
cityCode={birthCity}
onProvinceChange={setBirthProvince}
onCityChange={setBirthCity}
/>
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold text-primary"> · </h2>
<RegionSelect
label="所在地域"
provinceCode={currentProvince}
cityCode={currentCity}
onProvinceChange={setCurrentProvince}
onCityChange={setCurrentCity}
/>
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold text-primary"> · </h2>
<DateTimePicker
label="日期 / 时间"
date={calcDate}
time={calcTime}
onDateChange={setCalcDate}
onTimeChange={setCalcTime}
/>
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold text-primary"></h2>
<Textarea
placeholder="具体所求..."
value={question}
onChange={(e) => setQuestion(e.target.value)}
rows={3}
/>
</section>
<section className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={withHexagram}
onChange={(e) => {
setWithHexagram(e.target.checked);
setGuaData(null);
setShowAi(false);
}}
/>
</label>
{withHexagram && (
<div className="space-y-3 rounded-md border p-3">
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={castMode === "online" ? "default" : "outline"}
onClick={() => {
setCastMode("online");
setGuaData(null);
}}
>
线
</Button>
<Button
type="button"
size="sm"
variant={castMode === "offline" ? "default" : "outline"}
onClick={() => {
setCastMode("offline");
setGuaData(null);
}}
>
线
</Button>
</div>
<HexagramInput
key={castMode}
mode={castMode}
enabled={question.trim() !== "" && currentLocation !== null}
onResult={setGuaData}
onClear={() => setGuaData(null)}
/>
{guaData && (
<div className="rounded-md border bg-card p-3">
<Result {...guaData.result} />
</div>
)}
</div>
)}
</section>
{error && !showAi && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={handlePreview} className="flex-1">
</Button>
<Button
onClick={handleAnalyze}
disabled={withHexagram && !hexagramReady}
className="flex-1"
>
<Compass size={16} className="mr-1" />
</Button>
</div>
</div>
{chart && (
<div className="mx-auto w-full max-w-lg">
<BaziChartDisplay chart={chart} />
</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>
);
}