feat: add per-session model selector

This commit is contained in:
Jesse_Chen
2026-07-17 13:42:50 +08:00
parent beabbb65ea
commit 4968c961f7
7 changed files with 300 additions and 27 deletions
+8
View File
@@ -93,6 +93,14 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
- **States:** default, hover, focus with deep-brown ring, disabled, invalid, loading.
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
### Model selector
- **Structure:** a compact text trigger sits below the composer and opens an upward popover aligned to its left edge. The trigger names the active model; the popover gives each option a model name, short purpose, credit cost, and radio selection state.
- **Surface:** canvas trigger with no card treatment; the popover uses the elevated canvas recipe, warm hairlines, and one selected-surface row. The action color is reserved for the selected indicator and focus ring.
- **States:** closed, open, hover, focus-visible, selected, disabled, and unavailable catalog. Selecting a model closes the popover and only affects later messages in the current conversation.
- **Accessibility:** the trigger and every option meet the 44px touch target; options are a native radio group, with a small roving-focus fallback so Tab, arrow keys, Space, and screen readers consistently expose the selected model inside the popover.
- **Motion:** the popup enters over 120ms with opacity and a 4px vertical translation; reduced-motion removes the translation.
### Navigation item
- **Structure:** title, optional metadata, current-state marker.
+34 -3
View File
@@ -212,7 +212,7 @@ button:disabled { cursor: default; opacity: .45; }
.message p, .message-markdown { font-size: 16px; }
.message-user p { font-size: 14px; }
.composer { min-height: 56px; }
.composer-wrap > p { display: none; }
.composer-footer > p { display: none; }
.profile-overlay { align-items: flex-end; }
.profile-overlay.is-open .profile-dialog { transform: translateY(0); }
.account-actions { padding-bottom: max(0px, env(safe-area-inset-bottom)); }
@@ -231,6 +231,7 @@ button:disabled { cursor: default; opacity: .45; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; transition-delay: 0s !important; }
.model-selector-popup[data-starting-style], .model-selector-popup[data-ending-style] { transform: none; }
.auth-step { transform: none; transition: opacity 80ms linear !important; }
@starting-style { .auth-step { opacity: 0; transform: none; } }
}
@@ -339,8 +340,36 @@ button:disabled { cursor: default; opacity: .45; }
.composer button { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; border: 0; color: var(--color-on-dark); cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); background: var(--color-action); }
.composer .composer-stop { background: var(--color-ink); }
.composer .composer-stop:not(:disabled):hover { background: var(--color-ink-strong); }
.composer-wrap > p { width: min(760px, 100%); margin: 6px auto 0; color: var(--color-ink-tertiary); text-align: center; margin-top: var(--space-2); font-size: 12px; }
.composer-wrap > p.composer-notice { display: block; color: var(--color-action-hover); }
.composer-footer { width: min(760px, 100%); min-height: 44px; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; margin: var(--space-1) auto 0; }
.composer-footer > p { grid-column: 2; margin: 0; color: var(--color-ink-tertiary); text-align: center; font-size: var(--type-overline); }
.composer-footer > p.composer-notice { display: block; color: var(--color-action-hover); }
.model-selector-trigger { min-width: 0; max-width: 100%; min-height: 44px; display: inline-flex; grid-column: 1; align-items: center; justify-self: start; gap: var(--space-2); border: 0; padding: 0 var(--space-2); border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; transition: background-color 120ms ease-out, color 120ms ease-out; }
.model-selector-trigger > span { flex: 0 0 auto; color: var(--color-ink-tertiary); font-size: var(--type-overline); font-weight: 500; }
.model-selector-trigger > b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-caption); font-weight: 500; }
.model-selector-trigger svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--color-ink-tertiary); transition: transform 120ms ease-out; }
.model-selector-trigger[data-popup-open] svg { transform: rotate(180deg); }
.model-selector-positioner { z-index: 30; outline: 0; }
.model-selector-popup { width: min(360px, calc(100vw - var(--space-6))); border: 1px solid var(--color-border); padding: var(--space-3); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); }
.model-selector-popup[data-starting-style], .model-selector-popup[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)); }
.model-selector-title { margin: 0; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-sm); font-weight: 400; }
.model-selector-description { margin: var(--space-1) 0 var(--space-3); color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; }
.model-selector-options { display: grid; gap: var(--space-1); margin: 0; padding: 0; border: 0; }
.model-selector-option { min-height: 60px; display: grid; grid-template-columns: minmax(0, 1fr) auto 20px; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); color: var(--color-ink-secondary); cursor: pointer; transition: border-color 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; }
.model-selector-option[data-selected] { border-color: var(--color-border); background: var(--color-selected); color: var(--color-ink); }
.model-selector-option:has(input:focus-visible) { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; }
.model-selector-copy { min-width: 0; display: grid; gap: var(--space-1); }
.model-selector-copy b { overflow: hidden; color: inherit; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-body-sm); font-weight: 500; }
.model-selector-copy small { overflow: hidden; color: var(--color-ink-tertiary); text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-caption); }
.model-selector-cost { white-space: nowrap; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
.model-selector-check { width: 18px; height: 18px; opacity: 0; color: var(--color-action); }
.model-selector-option[data-selected] .model-selector-check { opacity: 1; }
@media (max-width: 900px) {
.composer-footer { grid-template-columns: minmax(0, 1fr); }
.composer-footer > p:not(.composer-notice) { display: none; }
.composer-footer > p.composer-notice { grid-column: 1; grid-row: 2; margin: 0 auto; }
.model-selector-trigger { max-width: 100%; grid-column: 1; grid-row: 1; }
}
.profile-overlay { position: fixed; z-index: 20; inset: 0; display: flex; justify-content: flex-end; opacity: 0; visibility: hidden; transition: opacity 180ms ease-out, visibility 0s linear 180ms; background: var(--color-scrim); }
.profile-dialog { height: 100dvh; overflow-y: auto; border-left: 1px solid var(--color-border); transform: translateX(24px); transition: transform 180ms var(--ease-out); width: min(560px, 100%); padding: var(--space-8); border-color: var(--color-border); background: var(--color-canvas); box-shadow: var(--shadow-elevated); }
@@ -404,6 +433,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
@media (hover: hover) {
.new-chat:not(:disabled):hover { background: var(--color-surface-dark-raised); }
.composer button:not(:disabled):hover, .button-primary:not(:disabled):hover { background: var(--color-action-hover); }
.model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); }
.model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
.starter-list button:not(:disabled):hover { background: var(--color-canvas-strong); }
.starter-list button:first-child:not(:disabled):hover { background: color-mix(in srgb, var(--color-action-soft) 72%, var(--color-canvas)); }
.composer-suggestions button:not(:disabled):hover { border-color: var(--color-action); background: var(--color-canvas); color: var(--color-action-hover); }
+118 -23
View File
@@ -5,11 +5,17 @@ import { ArrowUp, ArrowUpRight, ChevronRight, Menu, Minus, Plus, Sparkles, Squar
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ModelSelector } from "@/components/model-selector";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
import { parseAgentReply, type ReplyTheme } from "@/lib/agent-reply";
import { keepFocusWithin } from "@/lib/focus-trap";
import {
parsePublicModelCatalog,
resolveSessionModelId,
type PublicLanguageModelCatalog,
} from "@/lib/public-models";
import { createBrowserSupabaseClient } from "@/lib/supabase/client";
type Theme = ReplyTheme;
@@ -23,7 +29,7 @@ type Profile = {
cityCode: string;
districtCode: string;
};
type ChatSession = { id: string; title: string; theme: Theme; messages: Message[]; updatedAt: number };
type ChatSession = { id: string; title: string; theme: Theme; modelId: string; 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 };
@@ -32,6 +38,7 @@ type OnboardingSuggestion = { theme: Exclude<Theme, "general">; text: string };
type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[] };
type OnboardingStep = "name" | "birth" | "place";
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
type PendingConsultation = {
readonly requestId: string;
readonly sessionId: string;
@@ -54,6 +61,14 @@ const themes: Array<{ id: Exclude<Theme, "general">; label: string; prompt: stri
{ id: "timing", label: "时运", prompt: "未来哪些阶段值得把握?" },
];
const previewModelCatalog = parsePublicModelCatalog({
defaultModelId: "deepseek-pro",
models: [
{ id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "更适合复杂分析", creditCost: 1, isDefault: true },
{ id: "gpt-5-mini", label: "ChatGPT 5 Mini", description: "响应稳定、速度均衡", creditCost: 1, isDefault: false },
],
});
const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?";
const greetingVariants: Record<GreetingPeriod, Array<(name: string) => string>> = {
@@ -112,11 +127,12 @@ function timestamp() {
return Date.now();
}
function createSession(): ChatSession {
function createSession(modelId: string): ChatSession {
return {
id: globalThis.crypto.randomUUID(),
title: "新对话",
theme: "general",
modelId,
messages: [],
updatedAt: timestamp(),
};
@@ -240,12 +256,13 @@ function readProfile(value: unknown): Profile {
};
}
function readSessions(value: unknown): ChatSession[] {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult {
if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] };
const fallbackSessionIds: string[] = [];
const sessions = value.flatMap((item): ChatSession[] => {
if (!item || typeof item !== "object") return [];
const session = item as Partial<ChatSession> & { updated_at?: unknown };
const messages = Array.isArray(session.messages)
const session = item as Partial<ChatSession> & { model_id?: unknown; updated_at?: unknown };
const messages: Message[] = Array.isArray(session.messages)
? session.messages.flatMap((message) => (
message && typeof message === "object"
&& ((message as Message).role === "user" || (message as Message).role === "assistant")
@@ -259,20 +276,26 @@ function readSessions(value: unknown): ChatSession[] {
))
: [];
return typeof session.id === "string"
? [{
if (typeof session.id !== "string") return [];
const savedModelId = session.model_id ?? session.modelId;
const selection = catalog
? resolveSessionModelId(savedModelId, catalog)
: { modelId: typeof savedModelId === "string" ? savedModelId : "", fellBack: false };
if (catalog && selection.fellBack) fallbackSessionIds.push(session.id);
return [{
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",
modelId: selection.modelId,
messages,
updatedAt: typeof session.updatedAt === "number"
? session.updatedAt
: typeof session.updated_at === "string"
? Date.parse(session.updated_at)
: timestamp(),
}]
: [];
}];
});
return { sessions, fallbackSessionIds };
}
function BirthMomentFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
@@ -391,6 +414,13 @@ async function fetchAccount(signal?: AbortSignal): Promise<Account> {
return payload as Account;
}
async function fetchModelCatalog(signal?: AbortSignal) {
const response = await fetch("/api/models", { signal, cache: "no-store" });
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取可用模型"));
return parsePublicModelCatalog(payload);
}
export default function Home() {
const [profile, setProfile] = useState<Profile>(emptyProfile);
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
@@ -406,6 +436,7 @@ export default function Home() {
const [redeeming, setRedeeming] = useState(false);
const [signingOut, setSigningOut] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(null);
const [activeSessionId, setActiveSessionId] = useState("");
const [draft, setDraft] = useState("");
const [draftTheme, setDraftTheme] = useState<Theme | null>(null);
@@ -508,10 +539,12 @@ export default function Home() {
id: "preview-session",
title: previewMessages.length > 0 ? "未来半年是否适合换工作" : "新对话",
theme: "career",
modelId: previewModelCatalog.defaultModelId,
messages: previewMessages,
updatedAt: timestamp(),
};
setAccount({ user: { id: "preview-user", email: "preview@local.test" }, credits: 8, isAdmin: false });
setModelCatalog(previewModelCatalog);
setProfile(previewProfile);
setProfileDraft(previewProfile);
setOnboardingStep(missingProfileStep(previewProfile) ?? "name");
@@ -535,7 +568,16 @@ export default function Home() {
return;
}
const nextAccount = await fetchAccount(controller.signal);
const [nextAccount, modelCatalogResult] = await Promise.all([
fetchAccount(controller.signal),
fetchModelCatalog(controller.signal)
.then((catalog) => ({ catalog, unavailable: false }))
.catch((caught: unknown) => {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
return { catalog: null, unavailable: true };
}),
]);
const nextModelCatalog = modelCatalogResult.catalog;
const [profileResult, sessionsResult] = await Promise.all([
supabase
.from("profiles")
@@ -545,7 +587,7 @@ export default function Home() {
.maybeSingle(),
supabase
.from("chat_sessions")
.select("id,title,theme,messages,updated_at")
.select("id,title,theme,model_id,messages,updated_at")
.abortSignal(controller.signal)
.order("updated_at", { ascending: false }),
]);
@@ -553,10 +595,11 @@ export default function Home() {
if (profileResult.error) throw profileResult.error;
if (sessionsResult.error) throw sessionsResult.error;
let nextSessions = readSessions(sessionsResult.data);
const parsedSessions = readSessions(sessionsResult.data, nextModelCatalog);
let nextSessions = parsedSessions.sessions;
if (nextSessions.length === 0) {
if (controller.signal.aborted) return;
const initialSession = createSession();
const initialSession = createSession(nextModelCatalog?.defaultModelId ?? "");
const { error } = await supabase
.from("chat_sessions")
.insert({
@@ -564,6 +607,7 @@ export default function Home() {
user_id: nextAccount.user.id,
title: initialSession.title,
theme: initialSession.theme,
model_id: initialSession.modelId || null,
messages: initialSession.messages,
updated_at: new Date(initialSession.updatedAt).toISOString(),
})
@@ -572,15 +616,31 @@ export default function Home() {
nextSessions = [initialSession];
}
if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) {
const { error } = await supabase
.from("chat_sessions")
.update({ model_id: nextModelCatalog.defaultModelId })
.eq("user_id", nextAccount.user.id)
.in("id", parsedSessions.fallbackSessionIds)
.abortSignal(controller.signal);
if (error) throw error;
}
if (controller.signal.aborted) return;
const nextProfile = readProfile(profileResult.data);
setAccount(nextAccount);
setModelCatalog(nextModelCatalog);
setProfile(nextProfile);
setProfileDraft(nextProfile);
setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : "");
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
setSessions(nextSessions);
setActiveSessionId(nextSessions[0].id);
if (modelCatalogResult.unavailable) {
setComposerNotice("模型服务暂时不可用,当前无法发送问题。");
} else if (parsedSessions.fallbackSessionIds.length > 0) {
setComposerNotice("此前选择的模型已下线,已切换为默认模型。");
}
setAccountError("");
} catch (caught) {
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
@@ -719,6 +779,7 @@ export default function Home() {
const values = {
title: session.title,
theme: session.theme,
model_id: session.modelId,
messages: session.messages,
updated_at: new Date(session.updatedAt).toISOString(),
};
@@ -741,9 +802,9 @@ export default function Home() {
}
async function startNewChat() {
if (!account || creatingSession) return;
if (!account || !modelCatalog || creatingSession) return;
setMobileSidebarOpen(false);
const nextSession = createSession();
const nextSession = createSession(modelCatalog.defaultModelId);
const previousSessionId = activeSession?.id ?? "";
setCreatingSession(true);
setSessions((current) => [nextSession, ...current]);
@@ -766,6 +827,31 @@ export default function Home() {
}
}
async function selectSessionModel(modelId: string) {
if (!activeSession || !modelCatalog || pendingSessionId || cancellationPending || creatingSession) return;
const selectedModel = modelCatalog.models.find((model) => model.id === modelId);
if (!selectedModel || activeSession.modelId === modelId) return;
const nextSession: ChatSession = {
...activeSession,
modelId,
updatedAt: timestamp(),
};
updateSession(activeSession.id, () => nextSession);
setRequestError(null);
setComposerNotice(`已切换至 ${selectedModel.label},只影响之后的问题。`);
try {
await persistSession(nextSession);
} catch (caught) {
setComposerNotice(`已在当前页面切换至 ${selectedModel.label},但云端同步失败。`);
setRequestError({
sessionId: nextSession.id,
message: caught instanceof Error ? caught.message : "模型选择暂时无法同步到云端。",
});
}
}
function openAccount(showRedeem = false) {
setMobileSidebarOpen(false);
if (profileComplete) setProfileDraft(profile);
@@ -1059,7 +1145,7 @@ export default function Home() {
async function send(text: string, requestedTheme?: Theme) {
const originalQuestion = text;
const question = text.trim();
if (!question || !activeSession || pendingSessionId || cancellationInFlight.current || pendingConsultation.current || !account) return;
if (!question || !activeSession || !modelCatalog || pendingSessionId || cancellationInFlight.current || pendingConsultation.current || !account) return;
if (account.credits <= 0) {
openAccount(true);
@@ -1178,6 +1264,7 @@ export default function Home() {
headers: { "content-type": "application/json" },
body: JSON.stringify({
requestId,
modelId: currentSession.modelId,
name: profile.name,
year,
month,
@@ -1369,7 +1456,7 @@ export default function Home() {
<button className="sidebar-backdrop" tabIndex={-1} aria-label="关闭聊天记录" type="button" onClick={() => setMobileSidebarOpen(false)} />
<aside className="sidebar" ref={sidebar} id="chat-sidebar" aria-label="对话导航" inert={profileOpen}>
<div className="brand-row"><span className="brand-mark" aria-hidden="true" /><strong>Jyotisha</strong><button className="sidebar-close" ref={sidebarCloseButton} aria-label="关闭聊天记录" type="button" onClick={() => setMobileSidebarOpen(false)}><X aria-hidden="true" /></button></div>
<button className="new-chat" type="button" onClick={() => void startNewChat()} disabled={!hydrated || !account || creatingSession || Boolean(pendingSessionId) || cancellationPending}><Plus aria-hidden="true" /> {creatingSession ? "正在创建" : "新对话"}</button>
<button className="new-chat" type="button" onClick={() => void startNewChat()} disabled={!hydrated || !account || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}><Plus aria-hidden="true" /> {creatingSession ? "正在创建" : "新对话"}</button>
<nav className="session-nav" aria-label="聊天记录">
<span className="sidebar-label"></span>
<div className="session-list">
@@ -1470,7 +1557,7 @@ export default function Home() {
{(onboarding?.suggestions ?? themes.map((item) => ({ theme: item.id, text: item.prompt }))).map((item) => {
const theme = themes.find((candidate) => candidate.id === item.theme);
return (
<button key={`${item.theme}-${item.text}`} type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account} onClick={() => chooseSuggestedQuestion(item.text, item.theme)}>
<button key={`${item.theme}-${item.text}`} type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={() => chooseSuggestedQuestion(item.text, item.theme)}>
<span className="starter-content"><b>{theme?.label || "开始"}</b><span>{item.text}</span></span>
<ArrowUpRight className="starter-arrow" aria-hidden="true" />
</button>
@@ -1514,7 +1601,7 @@ export default function Home() {
{activeSuggestions.length > 0 && !draft.trim() && !isLoading && !cancellationPending && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{activeSuggestions.map((question) => (
<button key={question} type="button" disabled={!account || cancellationPending} onClick={() => chooseSuggestedQuestion(question)}>{question}</button>
<button key={question} type="button" disabled={!account || !modelCatalog || cancellationPending} onClick={() => chooseSuggestedQuestion(question)}>{question}</button>
))}
</div>
)}
@@ -1554,12 +1641,20 @@ export default function Home() {
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={!profileComplete ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || !account || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<Button aria-label={!profileComplete ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
<p className={composerNotice || consultationPhase === "undo" ? "composer-notice" : undefined} role={composerNotice || consultationPhase === "undo" ? "status" : undefined}>{composerNotice || (consultationPhase === "undo" ? "已加入发送队列,2.5 秒内可免费撤回。" : !profileComplete && onboardingStep === "name" ? "Enter 确认称呼" : "Enter 发送 · Shift + Enter 换行")}</p>
<div className="composer-footer">
<ModelSelector
models={modelCatalog?.models ?? []}
selectedModelId={activeSession?.modelId ?? ""}
disabled={!activeSession || isLoading || cancellationPending || creatingSession}
onSelect={(modelId) => void selectSessionModel(modelId)}
/>
<p className={composerNotice || consultationPhase === "undo" ? "composer-notice" : undefined} role={composerNotice || consultationPhase === "undo" ? "status" : undefined}>{composerNotice || (consultationPhase === "undo" ? "已加入发送队列,2.5 秒内可免费撤回。" : !profileComplete && onboardingStep === "name" ? "Enter 确认称呼" : "Enter 发送 · Shift + Enter 换行")}</p>
</div>
</div>
</section>
@@ -0,0 +1,92 @@
"use client";
import { Popover } from "@base-ui/react/popover";
import { Check, ChevronDown } from "lucide-react";
import { useId, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import type { PublicLanguageModel } from "@/lib/public-models";
type ModelSelectorProps = {
readonly models: readonly PublicLanguageModel[];
readonly selectedModelId: string;
readonly disabled?: boolean;
readonly onSelect: (modelId: string) => void;
};
export function ModelSelector({
models,
selectedModelId,
disabled = false,
onSelect,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const groupName = useId();
const radioRefs = useRef<Array<HTMLInputElement | null>>([]);
const selectedModel = models.find((model) => model.id === selectedModelId);
const unavailable = models.length === 0;
function moveRadioSelection(event: KeyboardEvent<HTMLInputElement>, index: number) {
const step = event.key === "ArrowDown" || event.key === "ArrowRight"
? 1
: event.key === "ArrowUp" || event.key === "ArrowLeft"
? -1
: 0;
if (step === 0 || models.length < 2) return;
event.preventDefault();
const nextIndex = (index + step + models.length) % models.length;
const nextModel = models[nextIndex];
if (!nextModel) return;
onSelect(nextModel.id);
window.requestAnimationFrame(() => radioRefs.current[nextIndex]?.focus());
}
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger
className="model-selector-trigger"
type="button"
disabled={disabled || unavailable}
aria-label={selectedModel ? `当前模型:${selectedModel.label},点击切换` : "模型暂不可用"}
>
<span></span>
<b>{selectedModel?.label ?? "暂不可用"}</b>
<ChevronDown aria-hidden="true" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner className="model-selector-positioner" side="top" align="start" sideOffset={8} collisionPadding={12}>
<Popover.Popup className="model-selector-popup">
<Popover.Title className="model-selector-title"></Popover.Title>
<Popover.Description className="model-selector-description">
1
</Popover.Description>
<fieldset className="model-selector-options">
<legend className="sr-only">使</legend>
{models.map((model, index) => (
<label className="model-selector-option" data-selected={model.id === selectedModelId ? "" : undefined} key={model.id}>
<input
ref={(element) => { radioRefs.current[index] = element; }}
className="sr-only"
type="radio"
name={groupName}
value={model.id}
checked={model.id === selectedModelId}
onChange={() => onSelect(model.id)}
onClick={() => setOpen(false)}
onKeyDown={(event) => moveRadioSelection(event, index)}
/>
<span className="model-selector-copy">
<b>{model.label}</b>
<small>{model.description || "通用分析模型"}</small>
</span>
<span className="model-selector-cost">{model.creditCost} </span>
<Check className="model-selector-check" aria-hidden="true" />
</label>
))}
</fieldset>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
+11
View File
@@ -38,3 +38,14 @@ export type PublicLanguageModelCatalog = {
export function parsePublicModelCatalog(value: unknown): PublicLanguageModelCatalog {
return publicLanguageModelCatalogSchema.parse(value);
}
export function resolveSessionModelId(
savedModelId: unknown,
catalog: PublicLanguageModelCatalog,
) {
const modelId = typeof savedModelId === "string" ? savedModelId : "";
const remainsAvailable = catalog.models.some((model) => model.id === modelId);
return remainsAvailable
? { modelId, fellBack: false } as const
: { modelId: catalog.defaultModelId, fellBack: true } as const;
}
@@ -0,0 +1,11 @@
begin;
alter table public.chat_sessions
add column if not exists model_id text;
grant insert (model_id)
on table public.chat_sessions to authenticated;
grant update (model_id)
on table public.chat_sessions to authenticated;
commit;
+26 -1
View File
@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parsePublicModelCatalog } from "../src/lib/public-models.ts";
import {
parsePublicModelCatalog,
resolveSessionModelId,
} from "../src/lib/public-models.ts";
const publicPayload = {
defaultModelId: "deepseek-pro",
@@ -66,3 +69,25 @@ test("rejects a default model that is absent from the public list", () => {
// Then
assert.throws(parse);
});
test("keeps a saved model that remains available", () => {
// Given
const catalog = parsePublicModelCatalog(publicPayload);
// When
const result = resolveSessionModelId("gpt-mini", catalog);
// Then
assert.deepEqual(result, { modelId: "gpt-mini", fellBack: false });
});
test("falls back to the configured default when a saved model is removed", () => {
// Given
const catalog = parsePublicModelCatalog(publicPayload);
// When
const result = resolveSessionModelId("removed-model", catalog);
// Then
assert.deepEqual(result, { modelId: "deepseek-pro", fellBack: true });
});