feat(create): 创作首页 - 收窄输入区并整理模式切换与灵感入口
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user