feat(create): 创作首页 - 收窄输入区并整理模式切换与灵感入口

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-16 22:59:50 +08:00
parent 54f51c44ed
commit 4eaadef82d
7 changed files with 421 additions and 70 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
import { Check, ChevronDown, Coins } from "lucide-react";
import { Check, ChevronDown, ChevronLeft, Coins } from "lucide-react";
import { Popover } from "antd";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
@@ -8,7 +8,7 @@ import { formatPriceRange, modelQuoteRequest, normalizeTierResolution, priceTier
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { cn } from "@/lib/utils";
import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { ModelLogo } from "@/components/model-logo";
import { quoteLogicalModel, type LogicalModelQuote } from "@/services/api/logical-models";
@@ -49,9 +49,11 @@ export function ModelPicker({
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const pickerId = useId();
// 双保险:即使 store merge 写出非法 theme,这里也兜底到 dark,避免 "reading 'node'" 崩溃
const rawTheme = useThemeStore((state) => state.theme);
const rawTheme = useActiveTheme();
const theme = (canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark) as CanvasTheme;
const [open, setOpen] = useState(false);
const [activeGroupKey, setActiveGroupKey] = useState<string | null>(null);
const [previewedModel, setPreviewedModel] = useState("");
const [triggerWidth, setTriggerWidth] = useState<number | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
@@ -132,6 +134,10 @@ export function ModelPicker({
const setPickerOpen = (nextOpen: boolean) => {
if (nextOpen && !options.length) onMissingConfig?.();
if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId }));
if (nextOpen) {
setPreviewedModel(current || options[0] || "");
setActiveGroupKey(null);
}
setOpen(nextOpen);
};
const focusMenuOption = (last = false) => {
@@ -166,7 +172,12 @@ export function ModelPicker({
<div
ref={menuRef}
data-canvas-no-zoom
className={cn("canvas-model-picker-menu max-w-[calc(100vw-24px)]", creationVariant ? "creation-model-picker-menu w-[360px]" : "w-[var(--panel-width-compact)]")}
className={cn(
"canvas-model-picker-menu max-w-[calc(100vw-24px)]",
creationVariant
? cn("creation-model-picker-menu", activeGroupKey === null ? "is-brand-list" : "is-model-list")
: "w-[var(--panel-width-compact)]",
)}
style={
{
background: theme.node.panel,
@@ -180,22 +191,35 @@ export function ModelPicker({
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{creationVariant ? (
<div className="creation-model-picker-heading">
<span>选择模型</span>
{current ? <strong>{pickerModelDisplayName(config, current, showConfiguredModelName)}</strong> : null}
</div>
) : null}
{optionGroups.length ? (
optionGroups.map((group) => (
<section key={group.key} className="canvas-model-picker-group min-w-0 overflow-hidden">
<div className="canvas-model-picker-group-label" style={{ color: theme.node.muted }}>
<span className="truncate">{group.label}</span>
{group.scope ? (
<span className="shrink-0" style={{ color: theme.node.muted }}>
{group.scope}
</span>
) : null}
activeGroupKey === null ? (
<div className="canvas-model-picker-brands" aria-label="选择模型品牌">
{optionGroups.map((group) => {
const groupCurrent = group.models.find((item) => item.models.includes(current));
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
return <button key={group.key} type="button" className="canvas-model-picker-brand" onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); }}>
<span className="canvas-model-picker-brand-icon"><ModelIcon config={config} model={firstModel} /></span>
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个模型{group.scope ? ` · ${group.scope}` : ""}</small></span>
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
</button>;
})}
</div>
) : <div className="canvas-model-picker-two-pane">
<div className="canvas-model-picker-brand-rail" aria-label="模型品牌">
{optionGroups.map((group) => {
const groupCurrent = group.models.find((item) => item.models.includes(current));
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
return <button key={group.key} type="button" className={cn("canvas-model-picker-brand", activeGroupKey === group.key && "is-active")} aria-pressed={activeGroupKey === group.key} onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); }}>
<span className="canvas-model-picker-brand-icon"><ModelIcon config={config} model={firstModel} /></span>
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个模型{group.scope ? ` · ${group.scope}` : ""}</small></span>
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
</button>;
})}
</div>
{optionGroups.filter((group) => group.key === activeGroupKey).map((group) => <section key={group.key} className="canvas-model-picker-group canvas-model-picker-model-pane min-w-0 overflow-hidden">
<div className="canvas-model-picker-secondary-head">
<button type="button" className="canvas-model-picker-back" onClick={() => setActiveGroupKey(null)} aria-label="返回品牌列表"><ChevronLeft /></button>
<span><strong>{group.label}</strong>{group.scope ? <small>{group.scope}</small> : null}</span>
</div>
<div className="grid min-w-0 gap-1">
{group.models.map((modelGroup) => {
@@ -212,8 +236,10 @@ export function ModelPicker({
aria-disabled={Boolean(disabledReason)}
disabled={Boolean(disabledReason)}
title={disabledReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)}
className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45"
className={cn("canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45", previewedModel === displayModel && "is-previewed")}
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
onMouseEnter={() => setPreviewedModel(displayModel)}
onFocus={() => setPreviewedModel(displayModel)}
onClick={() => {
if (!model) return;
onChange(model);
@@ -230,14 +256,15 @@ export function ModelPicker({
showConfiguredModelName={showConfiguredModelName}
showPrice={showOptionPrices && creditsEnabled}
disabledReason={disabledReason}
showDescription={selected || previewedModel === displayModel}
/>
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
</button>
);
})}
</div>
</section>
))
</section>)}
</div>
) : (
<div className="canvas-model-picker-empty" style={{ color: theme.node.muted }}>
{emptyModelLabel(config, capability)}
@@ -300,6 +327,7 @@ function ModelLabel({
showConfiguredModelName,
showPrice,
disabledReason,
showDescription,
}: {
config: AiConfig;
model: string;
@@ -309,6 +337,7 @@ function ModelLabel({
showConfiguredModelName: boolean;
showPrice: boolean;
disabledReason?: string;
showDescription: boolean;
}) {
const meta = modelMenuMeta(model, capability);
const channel = resolveModelChannel(config, model);
@@ -326,7 +355,7 @@ function ModelLabel({
</span>
<span className="min-w-0 flex-1 overflow-hidden">
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{pickerModelDisplayName(config, model, showConfiguredModelName)}</span>
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={capabilitySummary}>
<span className={cn("canvas-model-picker-description mt-1 block truncate text-[var(--fs-tiny)]", showDescription && "is-visible")} style={{ color: theme.node.muted }} title={capabilitySummary}>
{capabilitySummary}
</span>
</span>