From 4968c961f7277d5c0ed5521b56dc405dee40219b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 17 Jul 2026 13:42:50 +0800 Subject: [PATCH] feat: add per-session model selector --- frontend/DESIGN.md | 8 + frontend/src/app/globals.css | 37 ++++- frontend/src/app/page.tsx | 141 +++++++++++++++--- frontend/src/components/model-selector.tsx | 92 ++++++++++++ frontend/src/lib/public-models.ts | 11 ++ .../20260717010000_chat_session_model.sql | 11 ++ frontend/tests/public-models.test.ts | 27 +++- 7 files changed, 300 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/model-selector.tsx create mode 100644 frontend/supabase/migrations/20260717010000_chat_session_model.sql diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 607ebf15..8aa6b1ef 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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. diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 920d2c13..50641c72 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 9620881d..d550661a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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; 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; 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 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 & { updated_at?: unknown }; - const messages = Array.isArray(session.messages) + const session = item as Partial & { 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 { 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(emptyProfile); const [profileDraft, setProfileDraft] = useState(emptyProfile); @@ -406,6 +436,7 @@ export default function Home() { const [redeeming, setRedeeming] = useState(false); const [signingOut, setSigningOut] = useState(false); const [sessions, setSessions] = useState([]); + const [modelCatalog, setModelCatalog] = useState(null); const [activeSessionId, setActiveSessionId] = useState(""); const [draft, setDraft] = useState(""); const [draftTheme, setDraftTheme] = useState(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() { - +