fix(chat): choose the chart person before the first message

This commit is contained in:
jesse-ux
2026-09-22 12:57:04 +08:00
parent 6d06fca0d8
commit 2a3013f224
22 changed files with 973 additions and 27 deletions
@@ -0,0 +1,192 @@
"use client";
import { Popover } from "@base-ui/react/popover";
import { Check } from "lucide-react";
import { useId, useRef, useState } from "react";
import {
chatProfileChoice,
chatProfileDisplayName,
chatProfilePickerRows,
chatProfileSelectedId,
chatSessionSubjectLocked,
type ChatProfileCatalogStatus,
type ChatProfilePickerRow,
} from "@/lib/chat-profile-picker-model";
import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types";
export type ChatProfilePickerProps = {
readonly session: ChatSession;
readonly library: readonly ChartLibraryRecord[];
readonly status: ChatProfileCatalogStatus;
readonly requestInFlight: boolean;
readonly onBindEmpty: (chartProfileId: string) => Promise<boolean>;
readonly onOpenNew: (chartProfileId: string) => Promise<boolean>;
readonly onReload: () => void;
};
export function ChatProfilePicker({
session,
library,
status,
requestInFlight,
onBindEmpty,
onOpenNew,
onReload,
}: ChatProfilePickerProps) {
const [open, setOpen] = useState(false);
const [pendingId, setPendingId] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const listId = useId();
const label = chatProfileDisplayName({ session, status, library });
const selectedId = chatProfileSelectedId(session);
const rows = chatProfilePickerRows({ status, library, selectedId });
const locked = chatSessionSubjectLocked(session);
const deleted = status === "ready" && Boolean(selectedId) && selectedId !== "self" && !rows.some((row) => row.id === selectedId);
function closePicker() {
setOpen(false);
triggerRef.current?.focus();
}
async function choose(row: ChatProfilePickerRow) {
if (!row.complete || pendingId) return;
const choice = chatProfileChoice({
session,
chartProfileId: row.id,
requestInFlight,
libraryReady: status === "ready",
selectable: row.complete,
});
if (choice.action === "ignore") {
if (row.selected) closePicker();
return;
}
setPendingId(row.id);
try {
const ok = choice.action === "bind"
? await onBindEmpty(choice.chartProfileId)
: await onOpenNew(choice.chartProfileId);
if (ok) closePicker();
} finally {
setPendingId(null);
}
}
return (
<Popover.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) triggerRef.current?.focus();
}}
>
<Popover.Trigger
ref={triggerRef}
className="chat-header-chart chat-profile-picker-trigger"
type="button"
aria-expanded={open}
aria-controls={listId}
aria-label={`当前星盘:${label}`}
>
<span className="chat-profile-picker-name">{label}</span>
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner className="chat-profile-picker-positioner" side="bottom" align="start" sideOffset={8} collisionPadding={12}>
<Popover.Popup
id={listId}
className="chat-profile-picker-popup"
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.preventDefault();
closePicker();
}}
>
<Popover.Title className="chat-profile-picker-title">当前星盘</Popover.Title>
{status === "loading" ? <p className="chat-profile-picker-status" role="status">人物列表还在读</p> : null}
{status === "failed" ? (
<div className="chat-profile-picker-status" role="status">
<p>人物列表暂时读不出来</p>
<button className="chat-profile-picker-retry" type="button" onClick={onReload}>重试</button>
</div>
) : null}
{deleted ? <p className="chat-profile-picker-status">这份资料已经删除,这段对话只能看。</p> : null}
{rows.length > 0 ? (
<div className="chat-profile-picker-options" role="group" aria-label="当前星盘">
{rows.map((row) => (
<ProfileRow
key={row.id}
row={row}
locked={locked}
requestInFlight={requestInFlight}
pending={pendingId === row.id}
onChoose={() => void choose(row)}
/>
))}
</div>
) : null}
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
function ProfileRow({
row,
locked,
requestInFlight,
pending,
onChoose,
}: {
readonly row: ChatProfilePickerRow;
readonly locked: boolean;
readonly requestInFlight: boolean;
readonly pending: boolean;
readonly onChoose: () => void;
}) {
if (!row.complete) {
return (
<div className="chat-profile-picker-option" aria-disabled="true">
<span className="chat-profile-picker-copy">
<b>{row.name}</b>
<small>{row.detail}</small>
</span>
</div>
);
}
if (locked && !row.selected) {
return (
<button
className="chat-profile-picker-option"
type="button"
disabled={requestInFlight || pending}
aria-label={`${row.name},新建会话并使用此人物`}
onClick={onChoose}
>
<span className="chat-profile-picker-copy">
<b>{row.name}</b>
<small>{requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"}</small>
</span>
</button>
);
}
return (
<button
className="chat-profile-picker-option"
type="button"
aria-pressed={row.selected}
data-selected={row.selected ? "" : undefined}
disabled={pending}
onClick={onChoose}
>
<span className="chat-profile-picker-copy">
<b>{row.name}</b>
<small>{row.detail}</small>
</span>
<Check className="chat-profile-picker-check" aria-hidden="true" />
</button>
);
}