1105 lines
50 KiB
TypeScript
1105 lines
50 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from "react";
|
||
import { ChatMessageContent } from "@/components/chat-message-content";
|
||
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
|
||
import { createBrowserSupabaseClient } from "@/lib/supabase/client";
|
||
|
||
type Theme = "career" | "marriage" | "timing" | "general";
|
||
type Message = { role: "user" | "assistant"; text: string; suggestions?: string[] };
|
||
type Profile = {
|
||
name: string;
|
||
date: string;
|
||
time: string;
|
||
countryCode: "CN";
|
||
provinceCode: string;
|
||
cityCode: string;
|
||
districtCode: string;
|
||
};
|
||
type ChatSession = { id: string; title: string; theme: Theme; messages: Message[]; updatedAt: number };
|
||
type RequestError = { sessionId: string; message: string };
|
||
type StreamingReply = { sessionId: string; text: string };
|
||
type BirthPlace = { label: string; lat: number; lon: number; tz: number };
|
||
type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean };
|
||
type OnboardingSuggestion = { theme: Exclude<Theme, "general">; text: string };
|
||
type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[] };
|
||
type OnboardingStep = "name" | "birth" | "place";
|
||
const china = chinaLocations.country;
|
||
|
||
const themes: Array<{ id: Exclude<Theme, "general">; label: string; prompt: string }> = [
|
||
{ id: "career", label: "事业", prompt: "未来一年,我的事业和收入最值得关注什么?" },
|
||
{ id: "marriage", label: "关系", prompt: "我目前的感情模式和未来关系窗口是什么?" },
|
||
{ id: "timing", label: "时运", prompt: "未来十二个月有哪些重要时间窗口?" },
|
||
];
|
||
|
||
const presetOnboardingMessage = "你好,我是 Ayanam。开始前,我想先认识你。请问我该怎么称呼你?";
|
||
|
||
const emptyProfile: Profile = {
|
||
name: "",
|
||
date: "",
|
||
time: "",
|
||
countryCode: "CN",
|
||
provinceCode: "",
|
||
cityCode: "",
|
||
districtCode: "",
|
||
};
|
||
|
||
function timestamp() {
|
||
return Date.now();
|
||
}
|
||
|
||
function createSession(): ChatSession {
|
||
return {
|
||
id: globalThis.crypto.randomUUID(),
|
||
title: "新对话",
|
||
theme: "general",
|
||
messages: [],
|
||
updatedAt: timestamp(),
|
||
};
|
||
}
|
||
|
||
function findProvince(code: string) {
|
||
return china.provinces.find((province) => province.code === code);
|
||
}
|
||
|
||
function findCity(province: ProvinceNode | undefined, code: string) {
|
||
return province?.cities.find((city) => city.code === code);
|
||
}
|
||
|
||
function selectedBirthPlace(profile: Profile): BirthPlace | null {
|
||
const province = findProvince(profile.provinceCode);
|
||
const city = findCity(province, profile.cityCode);
|
||
if (!province || !city) return null;
|
||
|
||
const district = city.districts.find((item) => item.code === profile.districtCode);
|
||
if (city.districts.length > 0 && !district) return null;
|
||
|
||
const location = district ?? city;
|
||
const label = [china.name, province.name, city.name, district?.name]
|
||
.filter((name, index, names) => Boolean(name) && names.indexOf(name) === index)
|
||
.join(" · ");
|
||
|
||
return { label, lat: location.center[1], lon: location.center[0], tz: china.timezone };
|
||
}
|
||
|
||
function missingProfileStep(profile: Profile): OnboardingStep | null {
|
||
if (!profile.name.trim()) return "name";
|
||
if (!profile.date || !profile.time) return "birth";
|
||
if (!selectedBirthPlace(profile)) return "place";
|
||
return null;
|
||
}
|
||
|
||
function birthQuestion(name: string) {
|
||
return `${name},你好。接下来请告诉我你的出生日期和时间。时间越准确,后面的判断越可靠。`;
|
||
}
|
||
|
||
function formatBirthMoment(profile: Profile) {
|
||
const [year, month, day] = profile.date.split("-").map(Number);
|
||
return `${year}年${month}月${day}日 ${profile.time}`;
|
||
}
|
||
|
||
function placeQuestion(profile: Profile) {
|
||
return `记下了:${formatBirthMoment(profile)}。最后一个问题,你出生在哪里?`;
|
||
}
|
||
|
||
function completedOnboardingMessage(name: string) {
|
||
return `资料齐了,${name}。我们可以开始了。下面三个问题能帮你快速了解我能做什么,你也可以直接输入现在最想问的事。`;
|
||
}
|
||
|
||
function completedOnboardingTranscript(profile: Profile): Message[] {
|
||
const name = profile.name.trim();
|
||
const birthPlace = selectedBirthPlace(profile);
|
||
if (!name || !profile.date || !profile.time || !birthPlace) return [];
|
||
|
||
return [
|
||
{ role: "assistant", text: presetOnboardingMessage },
|
||
{ role: "user", text: name },
|
||
{ role: "assistant", text: birthQuestion(name) },
|
||
{ role: "user", text: formatBirthMoment(profile) },
|
||
{ role: "assistant", text: placeQuestion(profile) },
|
||
{ role: "user", text: birthPlace.label },
|
||
{ role: "assistant", text: completedOnboardingMessage(name) },
|
||
];
|
||
}
|
||
|
||
function readSuggestions(value: unknown) {
|
||
if (!Array.isArray(value)) return [];
|
||
return [...new Set(value
|
||
.filter((item): item is string => typeof item === "string")
|
||
.map((item) => item.replace(/\s+/g, " ").trim().slice(0, 80))
|
||
.filter(Boolean))].slice(0, 3);
|
||
}
|
||
|
||
function readOnboarding(value: unknown): OnboardingContent | null {
|
||
if (!value || typeof value !== "object") return null;
|
||
const payload = value as { greeting?: unknown; suggestions?: unknown };
|
||
if (typeof payload.greeting !== "string" || !Array.isArray(payload.suggestions)) return null;
|
||
|
||
const expectedThemes: OnboardingSuggestion["theme"][] = ["career", "marriage", "timing"];
|
||
const suggestions = payload.suggestions.flatMap((item, index): OnboardingSuggestion[] => {
|
||
if (!item || typeof item !== "object") return [];
|
||
const suggestion = item as { theme?: unknown; text?: unknown };
|
||
const theme = expectedThemes[index];
|
||
if (!theme || suggestion.theme !== theme || typeof suggestion.text !== "string") return [];
|
||
const text = suggestion.text.replace(/\s+/g, " ").trim().slice(0, 80);
|
||
return text ? [{ theme, text }] : [];
|
||
});
|
||
|
||
const greeting = payload.greeting.replace(/\s+/g, " ").trim().slice(0, 180);
|
||
return greeting.length >= 8 && suggestions.length === 3 ? { greeting, suggestions } : null;
|
||
}
|
||
|
||
function fallbackSuggestions(theme: Theme) {
|
||
if (theme === "career") return ["我更适合怎样的职业路径?", "未来一年事业上要避开什么?", "我该如何发挥自己的优势?"];
|
||
if (theme === "marriage") return ["我在关系里容易重复什么模式?", "怎样的伴侣更适合我?", "未来一年关系上要注意什么?"];
|
||
if (theme === "timing") return ["接下来最值得把握的阶段是什么?", "哪些时期更适合主动行动?", "我现在应该优先准备什么?"];
|
||
return themes.map((item) => item.prompt);
|
||
}
|
||
|
||
function parseAgentReply(value: string, theme: Theme) {
|
||
let suggestions: string[] = [];
|
||
const text = value.replace(/<!--AYANAM_SUGGESTIONS:(\[[\s\S]*?\])-->/g, (_, json: string) => {
|
||
try {
|
||
suggestions = readSuggestions(JSON.parse(json));
|
||
} catch {
|
||
suggestions = [];
|
||
}
|
||
return "";
|
||
}).trim();
|
||
return { text, suggestions: suggestions.length === 3 ? suggestions : fallbackSuggestions(theme) };
|
||
}
|
||
|
||
function readProfile(value: unknown): Profile {
|
||
if (!value || typeof value !== "object") return emptyProfile;
|
||
const profile = value as Partial<Profile> & {
|
||
birth_date?: unknown;
|
||
birth_time?: unknown;
|
||
country_code?: unknown;
|
||
province_code?: unknown;
|
||
city_code?: unknown;
|
||
district_code?: unknown;
|
||
};
|
||
const date = typeof profile.birth_date === "string" ? profile.birth_date : profile.date;
|
||
const time = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time;
|
||
const provinceCode = typeof profile.province_code === "string" ? profile.province_code : profile.provinceCode;
|
||
const cityCode = typeof profile.city_code === "string" ? profile.city_code : profile.cityCode;
|
||
const districtCode = typeof profile.district_code === "string" ? profile.district_code : profile.districtCode;
|
||
|
||
return {
|
||
name: typeof profile.name === "string" ? profile.name.slice(0, 80) : "",
|
||
date: typeof date === "string" ? date : "",
|
||
time: typeof time === "string" ? time : "",
|
||
countryCode: "CN",
|
||
provinceCode: typeof provinceCode === "string" ? provinceCode : "",
|
||
cityCode: typeof cityCode === "string" ? cityCode : "",
|
||
districtCode: typeof districtCode === "string" ? districtCode : "",
|
||
};
|
||
}
|
||
|
||
function readSessions(value: unknown): ChatSession[] {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.flatMap((item) => {
|
||
if (!item || typeof item !== "object") return [];
|
||
const session = item as Partial<ChatSession> & { updated_at?: unknown };
|
||
const messages = Array.isArray(session.messages)
|
||
? session.messages.flatMap((message) => (
|
||
message && typeof message === "object"
|
||
&& ((message as Message).role === "user" || (message as Message).role === "assistant")
|
||
&& typeof (message as Message).text === "string"
|
||
? [{
|
||
role: (message as Message).role,
|
||
text: (message as Message).text.slice(0, 12000),
|
||
suggestions: readSuggestions((message as Message).suggestions),
|
||
}]
|
||
: []
|
||
))
|
||
: [];
|
||
|
||
return typeof session.id === "string"
|
||
? [{
|
||
id: session.id,
|
||
title: typeof session.title === "string" ? session.title.slice(0, 36) : "新对话",
|
||
theme: session.theme === "career" || session.theme === "marriage" || session.theme === "timing" ? session.theme : "general",
|
||
messages,
|
||
updatedAt: typeof session.updatedAt === "number"
|
||
? session.updatedAt
|
||
: typeof session.updated_at === "string"
|
||
? Date.parse(session.updated_at)
|
||
: timestamp(),
|
||
}]
|
||
: [];
|
||
});
|
||
}
|
||
|
||
function BirthMomentFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
|
||
return (
|
||
<div className="profile-grid">
|
||
<label><span>出生日期</span><input required type="date" value={value.date} onChange={(event) => onChange({ ...value, date: event.target.value })} /></label>
|
||
<label><span>出生时间</span><input required type="time" value={value.time} onChange={(event) => onChange({ ...value, time: event.target.value })} /></label>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
|
||
const province = findProvince(value.provinceCode);
|
||
const cities = province?.cities ?? [];
|
||
const city = findCity(province, value.cityCode);
|
||
const districts = city?.districts ?? [];
|
||
|
||
return (
|
||
<fieldset className="location-fieldset">
|
||
<legend>出生地点</legend>
|
||
<div className="location-grid">
|
||
<label><span>国家 / 地区</span><select value={value.countryCode} onChange={() => onChange({ ...value, countryCode: "CN", provinceCode: "", cityCode: "", districtCode: "" })}><option value="CN">中国</option></select></label>
|
||
<label><span>省 / 自治区</span><select required value={value.provinceCode} onChange={(event) => onChange({ ...value, provinceCode: event.target.value, cityCode: "", districtCode: "" })}><option value="" disabled>请选择省 / 自治区</option>{china.provinces.map((item) => <option key={item.code} value={item.code}>{item.name}</option>)}</select></label>
|
||
<label><span>市 / 州</span><select required disabled={!province} value={value.cityCode} onChange={(event) => onChange({ ...value, cityCode: event.target.value, districtCode: "" })}><option value="" disabled>{province ? "请选择市 / 州" : "请先选择省 / 自治区"}</option>{cities.map((item) => <option key={item.code} value={item.code}>{item.name}</option>)}</select></label>
|
||
<label><span>县 / 区</span><select required={districts.length > 0} disabled={!city || districts.length === 0} value={value.districtCode} onChange={(event) => onChange({ ...value, districtCode: event.target.value })}><option value="" disabled>{districts.length > 0 ? "请选择县 / 区" : "该地区无需继续选择"}</option>{districts.map((item) => <option key={item.code} value={item.code}>{item.name}</option>)}</select></label>
|
||
</div>
|
||
</fieldset>
|
||
);
|
||
}
|
||
|
||
function ProfileFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
|
||
return (
|
||
<>
|
||
<label>
|
||
<span>如何称呼你</span>
|
||
<input required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
|
||
</label>
|
||
<BirthMomentFields value={value} onChange={onChange} />
|
||
<BirthLocationFields value={value} onChange={onChange} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
function OnboardingChatMessage({ role, text, streaming = false, length = text.length }: { role: Message["role"]; text: string; streaming?: boolean; length?: number }) {
|
||
const visibleText = streaming ? text.slice(0, length) : text;
|
||
return (
|
||
<article className={`message message-${role} onboarding-message`} aria-label={role === "assistant" ? "Ayanam" : "你"}>
|
||
<div className="message-content">
|
||
<div className="message-bubble">
|
||
{role === "assistant" ? (
|
||
streaming ? (
|
||
<>
|
||
<div className={`onboarding-stream ${length >= text.length ? "is-complete" : ""}`} aria-hidden="true"><ChatMessageContent text={visibleText} /></div>
|
||
<span className="sr-only" aria-live="polite">{length >= text.length ? text : ""}</span>
|
||
</>
|
||
) : <ChatMessageContent text={text} />
|
||
) : <p>{text}</p>}
|
||
</div>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function sessionTitle(question: string) {
|
||
const normalized = question.replace(/\s+/g, " ").trim();
|
||
return normalized.length > 22 ? `${normalized.slice(0, 22)}…` : normalized;
|
||
}
|
||
|
||
function isProfileComplete(profile: Profile) {
|
||
return missingProfileStep(profile) === null;
|
||
}
|
||
|
||
function friendlyError(message: string) {
|
||
return message.includes("Supabase") && (message.includes("配置") || message.includes("environment") || message.includes("URL"))
|
||
? "Supabase 尚未配置"
|
||
: message;
|
||
}
|
||
|
||
function payloadMessage(payload: unknown, fallback: string) {
|
||
if (!payload || typeof payload !== "object") return fallback;
|
||
const data = payload as Record<string, unknown>;
|
||
const message = [data.recovery, data.message, data.error].find((value) => typeof value === "string") as string | undefined;
|
||
return friendlyError(message || fallback);
|
||
}
|
||
|
||
async function fetchAccount(signal?: AbortSignal): Promise<Account> {
|
||
const response = await fetch("/api/account", { signal, cache: "no-store" });
|
||
if (response.status === 401) {
|
||
window.location.assign("/login");
|
||
throw new Error("请先登录");
|
||
}
|
||
const payload = await response.json().catch(() => null);
|
||
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取账户信息"));
|
||
return payload as Account;
|
||
}
|
||
|
||
export default function Home() {
|
||
const [profile, setProfile] = useState<Profile>(emptyProfile);
|
||
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
|
||
const [profileOpen, setProfileOpen] = useState(false);
|
||
const [profileNotice, setProfileNotice] = useState("");
|
||
const [account, setAccount] = useState<Account | null>(null);
|
||
const [accountError, setAccountError] = useState("");
|
||
const [redeemOpen, setRedeemOpen] = useState(false);
|
||
const [redeemCode, setRedeemCode] = useState("");
|
||
const [redeemError, setRedeemError] = useState("");
|
||
const [redeemMessage, setRedeemMessage] = useState("");
|
||
const [redeeming, setRedeeming] = useState(false);
|
||
const [signingOut, setSigningOut] = useState(false);
|
||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||
const [activeSessionId, setActiveSessionId] = useState("");
|
||
const [draft, setDraft] = useState("");
|
||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
|
||
const [requestError, setRequestError] = useState<RequestError | null>(null);
|
||
const [hydrated, setHydrated] = useState(false);
|
||
const [profileSaving, setProfileSaving] = useState(false);
|
||
const [creatingSession, setCreatingSession] = useState(false);
|
||
const [onboarding, setOnboarding] = useState<OnboardingContent | null>(null);
|
||
const [onboardingError, setOnboardingError] = useState("");
|
||
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep>("name");
|
||
const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false);
|
||
const [presetMessageLength, setPresetMessageLength] = useState(0);
|
||
const conversationEnd = useRef<HTMLDivElement>(null);
|
||
const accountTrigger = useRef<HTMLButtonElement>(null);
|
||
const closeButton = useRef<HTMLButtonElement>(null);
|
||
const redeemInput = useRef<HTMLInputElement>(null);
|
||
const composerInput = useRef<HTMLTextAreaElement>(null);
|
||
|
||
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
|
||
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
|
||
const isLoading = pendingSessionId === activeSession?.id;
|
||
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
|
||
const accountId = account?.user.id;
|
||
const profileComplete = isProfileComplete(profile);
|
||
const onboardingPending = profileComplete && !onboarding && !onboardingError;
|
||
const currentOnboardingMessage = onboardingJustCompleted
|
||
? completedOnboardingMessage(profileDraft.name.trim())
|
||
: onboardingStep === "birth"
|
||
? birthQuestion(profileDraft.name.trim())
|
||
: onboardingStep === "place"
|
||
? placeQuestion(profileDraft)
|
||
: presetOnboardingMessage;
|
||
const shouldStreamOnboarding = !profileComplete || onboardingJustCompleted;
|
||
const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length;
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
const supabase = createBrowserSupabaseClient();
|
||
|
||
async function loadCloudData() {
|
||
try {
|
||
const { data: authData, error: authError } = await supabase.auth.getSession();
|
||
if (authError) throw authError;
|
||
if (!authData.session) {
|
||
window.location.assign("/login");
|
||
return;
|
||
}
|
||
|
||
const nextAccount = await fetchAccount(controller.signal);
|
||
const [profileResult, sessionsResult] = await Promise.all([
|
||
supabase
|
||
.from("profiles")
|
||
.select("name,birth_date,birth_time,country_code,province_code,city_code,district_code")
|
||
.eq("id", nextAccount.user.id)
|
||
.maybeSingle(),
|
||
supabase
|
||
.from("chat_sessions")
|
||
.select("id,title,theme,messages,updated_at")
|
||
.order("updated_at", { ascending: false }),
|
||
]);
|
||
|
||
if (profileResult.error) throw profileResult.error;
|
||
if (sessionsResult.error) throw sessionsResult.error;
|
||
|
||
let nextSessions = readSessions(sessionsResult.data);
|
||
if (nextSessions.length === 0) {
|
||
const initialSession = createSession();
|
||
const { error } = await supabase.from("chat_sessions").insert({
|
||
id: initialSession.id,
|
||
user_id: nextAccount.user.id,
|
||
title: initialSession.title,
|
||
theme: initialSession.theme,
|
||
messages: initialSession.messages,
|
||
updated_at: new Date(initialSession.updatedAt).toISOString(),
|
||
});
|
||
if (error) throw error;
|
||
nextSessions = [initialSession];
|
||
}
|
||
|
||
if (controller.signal.aborted) return;
|
||
const nextProfile = readProfile(profileResult.data);
|
||
setAccount(nextAccount);
|
||
setProfile(nextProfile);
|
||
setProfileDraft(nextProfile);
|
||
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
|
||
setSessions(nextSessions);
|
||
setActiveSessionId(nextSessions[0].id);
|
||
setAccountError("");
|
||
} catch (caught) {
|
||
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
|
||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
|
||
}
|
||
} finally {
|
||
if (!controller.signal.aborted) setHydrated(true);
|
||
}
|
||
}
|
||
|
||
void loadCloudData();
|
||
return () => controller.abort();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!hydrated || !shouldStreamOnboarding) return;
|
||
|
||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||
const frame = window.requestAnimationFrame(() => setPresetMessageLength(currentOnboardingMessage.length));
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}
|
||
|
||
const timer = window.setInterval(() => {
|
||
setPresetMessageLength((current) => {
|
||
const next = Math.min(current + 1, currentOnboardingMessage.length);
|
||
if (next === currentOnboardingMessage.length) window.clearInterval(timer);
|
||
return next;
|
||
});
|
||
}, 26);
|
||
return () => window.clearInterval(timer);
|
||
}, [currentOnboardingMessage, hydrated, shouldStreamOnboarding]);
|
||
|
||
useEffect(() => {
|
||
if (!hydrated || !accountId || !profileComplete || onboarding || onboardingError) return;
|
||
const controller = new AbortController();
|
||
|
||
async function loadOnboarding() {
|
||
try {
|
||
const response = await fetch("/api/onboarding", {
|
||
method: "POST",
|
||
cache: "no-store",
|
||
signal: controller.signal,
|
||
});
|
||
const payload = await response.json().catch(() => null);
|
||
if (response.status === 401) {
|
||
window.location.assign("/login");
|
||
return;
|
||
}
|
||
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法准备初始问题"));
|
||
const content = readOnboarding(payload);
|
||
if (!content) throw new Error("Agent 返回的初始问题格式不正确");
|
||
setOnboarding(content);
|
||
setOnboardingError("");
|
||
} catch (caught) {
|
||
if ((caught as Error).name !== "AbortError") {
|
||
setOnboardingError(caught instanceof Error ? caught.message : "暂时无法准备初始问题");
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadOnboarding();
|
||
return () => controller.abort();
|
||
}, [accountId, hydrated, onboarding, onboardingError, profileComplete]);
|
||
|
||
useEffect(() => {
|
||
conversationEnd.current?.scrollIntoView({ behavior: isLoading ? "auto" : "smooth", block: "end" });
|
||
}, [activeSessionId, activeSession?.messages.length, activeStreamingText, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete]);
|
||
|
||
useEffect(() => {
|
||
if (!profileComplete && onboardingStep === "name" && presetMessageFinished && !profileOpen) {
|
||
composerInput.current?.focus();
|
||
}
|
||
}, [onboardingStep, presetMessageFinished, profileComplete, profileOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!profileOpen) return;
|
||
(redeemOpen ? redeemInput.current : closeButton.current)?.focus();
|
||
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
setProfileOpen(false);
|
||
window.requestAnimationFrame(() => accountTrigger.current?.focus());
|
||
}
|
||
};
|
||
window.addEventListener("keydown", closeOnEscape);
|
||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||
}, [profileOpen, redeemOpen]);
|
||
|
||
async function refreshAccount() {
|
||
try {
|
||
setAccount(await fetchAccount());
|
||
setAccountError("");
|
||
} catch (caught) {
|
||
setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息");
|
||
}
|
||
}
|
||
|
||
function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) {
|
||
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
|
||
}
|
||
|
||
async function persistSession(session: ChatSession) {
|
||
if (!account) throw new Error("账户尚未加载完成");
|
||
const supabase = createBrowserSupabaseClient();
|
||
const values = {
|
||
title: session.title,
|
||
theme: session.theme,
|
||
messages: session.messages,
|
||
updated_at: new Date(session.updatedAt).toISOString(),
|
||
};
|
||
const { data, error } = await supabase
|
||
.from("chat_sessions")
|
||
.update(values)
|
||
.eq("id", session.id)
|
||
.eq("user_id", account.user.id)
|
||
.select("id")
|
||
.maybeSingle();
|
||
if (error) throw new Error(`云端同步失败:${error.message}`);
|
||
if (data) return;
|
||
|
||
const { error: insertError } = await supabase.from("chat_sessions").insert({
|
||
id: session.id,
|
||
user_id: account.user.id,
|
||
...values,
|
||
});
|
||
if (insertError) throw new Error(`云端同步失败:${insertError.message}`);
|
||
}
|
||
|
||
async function startNewChat() {
|
||
if (!account || creatingSession) return;
|
||
const nextSession = createSession();
|
||
const previousSessionId = activeSession?.id ?? "";
|
||
setCreatingSession(true);
|
||
setSessions((current) => [nextSession, ...current]);
|
||
setActiveSessionId(nextSession.id);
|
||
setDraft("");
|
||
setRequestError(null);
|
||
try {
|
||
await persistSession(nextSession);
|
||
} catch (caught) {
|
||
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
|
||
setActiveSessionId(previousSessionId);
|
||
setRequestError({
|
||
sessionId: previousSessionId,
|
||
message: caught instanceof Error ? caught.message : "新对话未能保存到云端。",
|
||
});
|
||
} finally {
|
||
setCreatingSession(false);
|
||
}
|
||
}
|
||
|
||
function openAccount(showRedeem = false) {
|
||
setProfileDraft(profile);
|
||
setProfileNotice("");
|
||
setRedeemOpen(showRedeem);
|
||
setRedeemError("");
|
||
setRedeemMessage("");
|
||
setProfileOpen(true);
|
||
}
|
||
|
||
function closeAccount() {
|
||
setProfileOpen(false);
|
||
window.requestAnimationFrame(() => accountTrigger.current?.focus());
|
||
}
|
||
|
||
async function persistProfile(nextProfile: Profile) {
|
||
if (!account) throw new Error("账户尚未加载完成");
|
||
const birthPlace = selectedBirthPlace(nextProfile);
|
||
const { data, error } = await createBrowserSupabaseClient()
|
||
.from("profiles")
|
||
.update({
|
||
name: nextProfile.name.trim() || null,
|
||
birth_date: nextProfile.date || null,
|
||
birth_time: nextProfile.time || null,
|
||
country_code: nextProfile.countryCode,
|
||
province_code: nextProfile.provinceCode || null,
|
||
city_code: nextProfile.cityCode || null,
|
||
district_code: nextProfile.districtCode || null,
|
||
latitude: birthPlace?.lat ?? null,
|
||
longitude: birthPlace?.lon ?? null,
|
||
timezone_offset: birthPlace?.tz ?? null,
|
||
updated_at: new Date().toISOString(),
|
||
})
|
||
.eq("id", account.user.id)
|
||
.select("id")
|
||
.maybeSingle();
|
||
if (error) throw error;
|
||
if (!data) throw new Error("账户档案不存在,请重新登录后再试。");
|
||
}
|
||
|
||
async function saveProfile(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!isProfileComplete(profileDraft) || !account || profileSaving) return;
|
||
setProfileSaving(true);
|
||
setProfileNotice("");
|
||
setAccountError("");
|
||
try {
|
||
await persistProfile(profileDraft);
|
||
setProfile(profileDraft);
|
||
setProfileNotice("出生资料已保存到云端,可在同一账号的其他设备使用。");
|
||
} catch (caught) {
|
||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败"));
|
||
} finally {
|
||
setProfileSaving(false);
|
||
}
|
||
}
|
||
|
||
async function saveOnboardingName() {
|
||
const name = draft.replace(/\s+/g, " ").trim().slice(0, 80);
|
||
if (!name || !account || profileSaving) return;
|
||
const nextProfile = { ...profileDraft, name };
|
||
setProfileSaving(true);
|
||
setAccountError("");
|
||
try {
|
||
await persistProfile(nextProfile);
|
||
setProfile(nextProfile);
|
||
setProfileDraft(nextProfile);
|
||
setDraft("");
|
||
setPresetMessageLength(0);
|
||
const nextStep = missingProfileStep(nextProfile);
|
||
if (nextStep) setOnboardingStep(nextStep);
|
||
else setOnboardingJustCompleted(true);
|
||
} catch (caught) {
|
||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "称呼保存失败"));
|
||
} finally {
|
||
setProfileSaving(false);
|
||
}
|
||
}
|
||
|
||
async function saveOnboardingBirth(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!profileDraft.date || !profileDraft.time || !account || profileSaving) return;
|
||
setProfileSaving(true);
|
||
setAccountError("");
|
||
try {
|
||
await persistProfile(profileDraft);
|
||
setProfile(profileDraft);
|
||
setPresetMessageLength(0);
|
||
const nextStep = missingProfileStep(profileDraft);
|
||
if (nextStep) setOnboardingStep(nextStep);
|
||
else setOnboardingJustCompleted(true);
|
||
} catch (caught) {
|
||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生时间保存失败"));
|
||
} finally {
|
||
setProfileSaving(false);
|
||
}
|
||
}
|
||
|
||
async function saveOnboardingPlace(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!selectedBirthPlace(profileDraft) || !account || profileSaving) return;
|
||
setProfileSaving(true);
|
||
setAccountError("");
|
||
try {
|
||
await persistProfile(profileDraft);
|
||
setProfile(profileDraft);
|
||
setPresetMessageLength(0);
|
||
setOnboardingJustCompleted(true);
|
||
} catch (caught) {
|
||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败"));
|
||
} finally {
|
||
setProfileSaving(false);
|
||
}
|
||
}
|
||
|
||
async function redeem(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
const code = redeemCode.trim();
|
||
if (!code || redeeming) return;
|
||
setRedeeming(true);
|
||
setRedeemError("");
|
||
setRedeemMessage("");
|
||
try {
|
||
const response = await fetch("/api/redeem", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ code }),
|
||
});
|
||
const payload = await response.json().catch(() => null);
|
||
if (response.status === 401) {
|
||
window.location.assign("/login");
|
||
return;
|
||
}
|
||
if (!response.ok) throw new Error(payloadMessage(payload, "兑换码无效或已使用"));
|
||
const result = payload as { credits: number; message?: string };
|
||
setAccount((current) => current ? { ...current, credits: result.credits } : current);
|
||
setRedeemCode("");
|
||
setRedeemMessage(result.message || `兑换成功,当前余额 ${result.credits} 点。`);
|
||
} catch (caught) {
|
||
setRedeemError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试");
|
||
} finally {
|
||
setRedeeming(false);
|
||
}
|
||
}
|
||
|
||
async function signOut() {
|
||
if (signingOut) return;
|
||
setSigningOut(true);
|
||
setAccountError("");
|
||
try {
|
||
const { error } = await createBrowserSupabaseClient().auth.signOut();
|
||
if (error) throw error;
|
||
window.location.assign("/login");
|
||
} catch (caught) {
|
||
const message = caught instanceof Error ? caught.message : "退出失败";
|
||
setAccountError(friendlyError(message));
|
||
setSigningOut(false);
|
||
}
|
||
}
|
||
|
||
async function send(text: string, requestedTheme?: Theme) {
|
||
const question = text.trim();
|
||
if (!question || !activeSession || pendingSessionId || !account) return;
|
||
|
||
if (account.credits <= 0) {
|
||
openAccount(true);
|
||
return;
|
||
}
|
||
|
||
if (!isProfileComplete(profile)) {
|
||
setProfileDraft(profile);
|
||
setProfileNotice("请先补充出生资料,才能进行星盘计算。");
|
||
setRedeemOpen(false);
|
||
setProfileOpen(true);
|
||
return;
|
||
}
|
||
|
||
const birthPlace = selectedBirthPlace(profile);
|
||
if (!birthPlace) return;
|
||
|
||
const currentSession = activeSession;
|
||
const theme = requestedTheme ?? currentSession.theme;
|
||
const sessionId = currentSession.id;
|
||
const [year, month, day] = profile.date.split("-").map(Number);
|
||
const [hour, minute] = profile.time.split(":").map(Number);
|
||
|
||
const preservedMessages = onboardingJustCompleted && currentSession.messages.length === 0
|
||
? completedOnboardingTranscript(profile)
|
||
: currentSession.messages;
|
||
const userSession: ChatSession = {
|
||
...currentSession,
|
||
title: currentSession.messages.length === 0 ? sessionTitle(question) : currentSession.title,
|
||
theme,
|
||
messages: [...preservedMessages, { role: "user", text: question }],
|
||
updatedAt: timestamp(),
|
||
};
|
||
setRequestError(null);
|
||
setPendingSessionId(sessionId);
|
||
|
||
try {
|
||
await persistSession(userSession);
|
||
} catch (caught) {
|
||
setRequestError({
|
||
sessionId,
|
||
message: `${caught instanceof Error ? caught.message : "消息未能保存到云端。"} 输入内容已保留,可直接重新发送。`,
|
||
});
|
||
setPendingSessionId(null);
|
||
return;
|
||
}
|
||
|
||
setOnboardingJustCompleted(false);
|
||
updateSession(sessionId, () => userSession);
|
||
setDraft("");
|
||
setStreamingReply({ sessionId, text: "" });
|
||
try {
|
||
const response = await fetch("/api/consult", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: profile.name,
|
||
year,
|
||
month,
|
||
day,
|
||
hour,
|
||
minute,
|
||
city: birthPlace.label,
|
||
lat: birthPlace.lat,
|
||
lon: birthPlace.lon,
|
||
tz: birthPlace.tz,
|
||
theme,
|
||
question,
|
||
history: currentSession.messages.slice(-12).map((message) => ({
|
||
role: message.role,
|
||
text: message.text.slice(0, 4000),
|
||
})),
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
const contentType = response.headers.get("content-type") ?? "";
|
||
const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() };
|
||
if (response.status === 401) window.location.assign("/login");
|
||
if (response.status === 402) openAccount(true);
|
||
throw new Error(payloadMessage(errorPayload, "服务暂时不可用"));
|
||
}
|
||
if (!response.body) throw new Error("浏览器未收到可读取的回答流");
|
||
|
||
setAccount((current) => current ? { ...current, credits: Math.max(0, current.credits - 1) } : current);
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let answer = "";
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
answer += decoder.decode(value, { stream: true });
|
||
setStreamingReply({ sessionId, text: answer.split("<!--AYANAM_SUGGESTIONS:", 1)[0] });
|
||
}
|
||
answer += decoder.decode();
|
||
if (!answer.trim()) throw new Error("Agent 没有返回内容,请重试。");
|
||
const reply = parseAgentReply(answer, theme);
|
||
if (!reply.text) throw new Error("Agent 没有返回可显示的回答,请重试。");
|
||
|
||
const completedSession: ChatSession = {
|
||
...userSession,
|
||
messages: [...userSession.messages, { role: "assistant", text: reply.text, suggestions: reply.suggestions }],
|
||
updatedAt: timestamp(),
|
||
};
|
||
updateSession(sessionId, () => completedSession);
|
||
try {
|
||
await persistSession(completedSession);
|
||
} catch (caught) {
|
||
setRequestError({
|
||
sessionId,
|
||
message: `${caught instanceof Error ? caught.message : "云端同步失败"} 回答仍保留在当前页面,请复制保存后重试。`,
|
||
});
|
||
}
|
||
void refreshAccount();
|
||
} catch (caught) {
|
||
setDraft(question);
|
||
setRequestError({
|
||
sessionId,
|
||
message: `${caught instanceof Error ? caught.message : "服务暂时不可用,请稍后重试。"} 输入内容已保留,可直接重新发送。`,
|
||
});
|
||
void refreshAccount();
|
||
} finally {
|
||
setStreamingReply(null);
|
||
setPendingSessionId(null);
|
||
}
|
||
}
|
||
|
||
function submit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!profileComplete) {
|
||
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
|
||
return;
|
||
}
|
||
void send(draft);
|
||
}
|
||
|
||
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||
if (event.key === "Enter" && !event.shiftKey) {
|
||
event.preventDefault();
|
||
event.currentTarget.form?.requestSubmit();
|
||
}
|
||
}
|
||
|
||
return (
|
||
<main className="chat-app">
|
||
<aside className="sidebar" aria-label="对话导航">
|
||
<div className="brand-row"><span className="brand-mark" aria-hidden="true">अ</span><strong>Ayanam</strong></div>
|
||
<button className="new-chat" type="button" onClick={() => void startNewChat()} disabled={!hydrated || !account || creatingSession || Boolean(pendingSessionId)}><span aria-hidden="true">+</span> {creatingSession ? "正在创建" : "新对话"}</button>
|
||
<nav className="session-nav" aria-label="聊天记录">
|
||
<span className="sidebar-label">聊天记录</span>
|
||
<div className="session-list">
|
||
{sessions.map((session) => (
|
||
<button
|
||
className={session.id === activeSession?.id ? "is-active" : ""}
|
||
key={session.id}
|
||
type="button"
|
||
onClick={() => { setActiveSessionId(session.id); setDraft(""); }}
|
||
aria-current={session.id === activeSession?.id ? "page" : undefined}
|
||
>
|
||
<span>{session.title}</span>
|
||
{session.messages.length > 0 && <small>{session.messages.length} 条消息</small>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</nav>
|
||
<div className="sidebar-footer">
|
||
<button className="profile-trigger" ref={accountTrigger} type="button" onClick={() => openAccount()}>
|
||
<span className="profile-initial" aria-hidden="true">{profile.name.trim().slice(0, 1) || account?.user.email?.slice(0, 1).toUpperCase() || "你"}</span>
|
||
<span><b>{profile.name.trim() || account?.user.email || "账户"}</b><small>{account ? `${account.credits} 点余额` : accountError || "正在读取账户"}</small></span>
|
||
<span className="chevron" aria-hidden="true">›</span>
|
||
</button>
|
||
<p>解读仅供自我探索,不替代医疗、法律或投资建议。</p>
|
||
</div>
|
||
</aside>
|
||
|
||
<section className="chat-panel">
|
||
<header className="chat-header">
|
||
<div>
|
||
<strong>{activeSession?.title || "新对话"}</strong>
|
||
<span><i className={`status ${isLoading ? "status-loading" : "status-idle"}`} />{isLoading ? (activeStreamingText ? "正在回答" : "正在核对星盘信息") : "基于星盘证据回答"}</span>
|
||
</div>
|
||
<button className="credit-button" type="button" onClick={() => openAccount(account?.credits === 0)} aria-label="打开账户与兑换码">
|
||
{account ? `剩余 ${account.credits} 点` : accountError || "读取余额中"}
|
||
</button>
|
||
</header>
|
||
|
||
<div className={`conversation ${activeSession?.messages.length ? "" : "is-empty"}`}>
|
||
{!activeSession?.messages.length ? (
|
||
<div className="welcome">
|
||
{!profileComplete || onboardingJustCompleted ? (
|
||
<>
|
||
<OnboardingChatMessage role="assistant" text={presetOnboardingMessage} streaming={onboardingStep === "name" && !profileComplete} length={presetMessageLength} />
|
||
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={profileDraft.name.trim()} />}
|
||
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={birthQuestion(profileDraft.name.trim())} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
|
||
{(onboardingStep === "place" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
|
||
{(onboardingStep === "place" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
|
||
{profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
|
||
{profileComplete && onboardingJustCompleted && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
|
||
</>
|
||
) : (
|
||
<OnboardingChatMessage role="assistant" text={onboarding?.greeting
|
||
|| (onboardingPending
|
||
? `很高兴认识你,${profile.name.trim()}。我正在准备几个适合开始的问题。`
|
||
: `很高兴认识你,${profile.name.trim()}。你的出生资料已经准备好,我们可以从你此刻最关心的事情开始。`)} />
|
||
)}
|
||
|
||
{!profileComplete && onboardingStep === "birth" && presetMessageFinished && (
|
||
<div className="onboarding-card-reveal">
|
||
<div className="onboarding-card-reveal-inner">
|
||
<form className="profile-form onboarding-card onboarding-step-card" onSubmit={saveOnboardingBirth}>
|
||
<div className="onboarding-card-heading"><b>出生时间</b><small>请选择日期和尽量准确的时间</small></div>
|
||
<BirthMomentFields value={profileDraft} onChange={setProfileDraft} />
|
||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !profileDraft.date || !profileDraft.time}>{profileSaving ? "保存中" : "确定"}</button></div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!profileComplete && onboardingStep === "place" && presetMessageFinished && (
|
||
<div className="onboarding-card-reveal">
|
||
<div className="onboarding-card-reveal-inner">
|
||
<form className="profile-form onboarding-card onboarding-step-card" onSubmit={saveOnboardingPlace}>
|
||
<div className="onboarding-card-heading"><b>出生地点</b><small>目前先支持中国大陆地区</small></div>
|
||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</button></div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
|
||
|
||
{profileComplete && presetMessageFinished && (onboardingPending ? (
|
||
<div className="starter-loading" role="status">正在准备三个入门问题…</div>
|
||
) : (
|
||
<div className="starter-list" aria-label="Ayanam 推荐的初始问题">
|
||
{(onboarding?.suggestions ?? themes.map((item) => ({ theme: item.id, text: item.prompt }))).map((item, index) => {
|
||
const theme = themes.find((candidate) => candidate.id === item.theme);
|
||
return (
|
||
<button key={`${item.theme}-${item.text}`} type="button" disabled={!hydrated || Boolean(pendingSessionId) || !account} onClick={() => void send(item.text, item.theme)}>
|
||
<span className="starter-index">{String(index + 1).padStart(2, "0")}</span>
|
||
<span className="starter-content"><b>{theme?.label || "开始"}</b><span>{item.text}</span></span>
|
||
<span className="starter-arrow" aria-hidden="true">↗</span>
|
||
</button>
|
||
);
|
||
})}
|
||
{onboardingError && <p className="starter-note">Agent 的个性化入门问题暂时不可用,已显示安全的默认问题。</p>}
|
||
</div>
|
||
))}
|
||
<div ref={conversationEnd} />
|
||
</div>
|
||
) : (
|
||
<div className="message-list" aria-busy={isLoading}>
|
||
<span className="sr-only" aria-live="polite">{isLoading ? "Ayanam 正在回答" : ""}</span>
|
||
{activeSession.messages.map((message, index) => (
|
||
<article className={`message message-${message.role}`} key={`${message.role}-${index}`} aria-label={message.role === "assistant" ? "Ayanam" : "你"}>
|
||
<div className="message-content">
|
||
<div className="message-bubble">
|
||
{message.role === "assistant" ? <ChatMessageContent text={message.text} /> : <p>{message.text}</p>}
|
||
</div>
|
||
{message.role === "assistant" && index === activeSession.messages.length - 1 && message.suggestions && message.suggestions.length > 0 && (
|
||
<div className="suggestion-list" aria-label="推荐继续提问">
|
||
{message.suggestions.map((question) => (
|
||
<button key={question} type="button" disabled={Boolean(pendingSessionId) || !account} onClick={() => void send(question)}>{question}<span aria-hidden="true">↗</span></button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</article>
|
||
))}
|
||
{isLoading && (
|
||
<article className="message message-assistant" aria-label={activeStreamingText ? "Ayanam 正在回答" : "Ayanam 正在分析"}>
|
||
<div className="message-content">
|
||
<div className="message-bubble">
|
||
{activeStreamingText ? <ChatMessageContent text={activeStreamingText} /> : <div className="thinking"><i /><i /><i /></div>}
|
||
</div>
|
||
</div>
|
||
</article>
|
||
)}
|
||
{activeError && <p className="error-message">{activeError}</p>}
|
||
<div ref={conversationEnd} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="composer-wrap">
|
||
<form className="composer" onSubmit={submit}>
|
||
<textarea
|
||
ref={composerInput}
|
||
aria-label={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
|
||
placeholder={!account
|
||
? "正在读取账户…"
|
||
: !profileComplete
|
||
? onboardingStep === "name"
|
||
? presetMessageFinished ? "输入你的称呼" : "Ayanam 正在输入…"
|
||
: "请先完成上方资料"
|
||
: account.credits === 0
|
||
? "余额不足,发送时将打开兑换码"
|
||
: "例如:未来半年是否适合换工作?"}
|
||
rows={1}
|
||
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
|
||
disabled={!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving)}
|
||
value={draft}
|
||
onChange={(event) => setDraft(event.target.value)}
|
||
onKeyDown={handleComposerKeyDown}
|
||
/>
|
||
<button aria-label={!profileComplete ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || !hydrated || !account || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} type="submit">↑</button>
|
||
</form>
|
||
<p>{!profileComplete && onboardingStep === "name" ? "Enter 确认称呼" : "Enter 发送 · Shift + Enter 换行"}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<div className={`profile-overlay ${profileOpen ? "is-open" : ""}`} aria-hidden={!profileOpen} inert={!profileOpen} onMouseDown={closeAccount}>
|
||
<section className="profile-dialog" role="dialog" aria-modal="true" aria-labelledby="profile-title" onMouseDown={(event) => event.stopPropagation()}>
|
||
<header>
|
||
<div><span className="dialog-eyebrow">账户</span><h2 id="profile-title">账户与出生资料</h2></div>
|
||
<button className="dialog-close" ref={closeButton} aria-label="关闭" type="button" onClick={closeAccount}>×</button>
|
||
</header>
|
||
|
||
<section className="account-summary" aria-label="账户信息">
|
||
<div><span>邮箱</span><strong>{account?.user.email || "尚未读取"}</strong></div>
|
||
<div><span>剩余点数</span><strong>{account?.credits ?? "—"}</strong></div>
|
||
</section>
|
||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||
|
||
<section className="sheet-section">
|
||
<button className="section-toggle" type="button" aria-expanded={redeemOpen} onClick={() => setRedeemOpen((current) => !current)}>
|
||
<span><b>兑换点数</b><small>输入兑换码后余额会立即更新</small></span><span aria-hidden="true">{redeemOpen ? "−" : "+"}</span>
|
||
</button>
|
||
{redeemOpen && (
|
||
<form className="redeem-form" onSubmit={redeem}>
|
||
<label htmlFor="redeem-code">兑换码</label>
|
||
<div>
|
||
<input id="redeem-code" ref={redeemInput} autoComplete="off" value={redeemCode} onChange={(event) => { setRedeemCode(event.target.value); setRedeemError(""); setRedeemMessage(""); }} placeholder="输入完整兑换码" />
|
||
<button className="button-primary" type="submit" disabled={!redeemCode.trim() || redeeming}>{redeeming ? "兑换中" : "兑换"}</button>
|
||
</div>
|
||
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
|
||
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
|
||
</form>
|
||
)}
|
||
</section>
|
||
|
||
<section className="sheet-section birth-section">
|
||
<div className="section-heading"><b>出生资料</b><small>加密传输并保存到云端,用于此账号的所有对话</small></div>
|
||
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
|
||
<form className="profile-form" onSubmit={saveProfile}>
|
||
<ProfileFields value={profileDraft} onChange={setProfileDraft} />
|
||
<button className="button-primary save-profile" type="submit" disabled={profileSaving || !account}>{profileSaving ? "保存中" : "保存出生资料"}</button>
|
||
</form>
|
||
</section>
|
||
|
||
<footer className="account-actions">
|
||
{account?.isAdmin && <Link className="button-secondary" href="/admin/codes">管理兑换码</Link>}
|
||
<button className="button-secondary danger-button" type="button" onClick={() => void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "退出登录"}</button>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|