feat(canvas): 画布创作对话 - 统一流式 Markdown 展示与交互修复

This commit is contained in:
ddCat
2026-08-15 02:31:42 +08:00
parent a63535757e
commit abcf3e1787
16 changed files with 730 additions and 117 deletions

View File

@@ -4,7 +4,7 @@ import { Popover } from "antd";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
import { compatibleModelInGroup, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { cn } from "@/lib/utils";
import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -22,9 +22,10 @@ type ModelPickerProps = {
showSelectedPrice?: boolean;
variant?: "default" | "creation";
requirements?: ModelRequirements;
showConfiguredModelName?: boolean;
};
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default", requirements }: ModelPickerProps) {
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default", requirements, showConfiguredModelName = false }: ModelPickerProps) {
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const pickerId = useId();
// 双保险:即使 store merge 写出非法 theme,这里也兜底到 dark,避免 "reading 'node'" 崩溃
@@ -129,7 +130,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
{creationVariant ? (
<div className="creation-model-picker-heading">
<span>选择模型</span>
{current ? <strong>{modelDisplayName(config, current)}</strong> : null}
{current ? <strong>{pickerModelDisplayName(config, current, showConfiguredModelName)}</strong> : null}
</div>
) : null}
{optionGroups.length ? (
@@ -155,7 +156,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
aria-selected={selected}
aria-disabled={Boolean(disabledReason)}
disabled={Boolean(disabledReason)}
title={disabledReason || modelOptionLabel(config, displayModel)}
title={disabledReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)}
className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45"
style={{ background: "transparent", color: theme.node.text }}
onClick={() => {
@@ -165,7 +166,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
window.requestAnimationFrame(() => triggerRef.current?.focus());
}}
>
<ModelLabel config={config} model={displayModel} capability={capability} theme={theme} creationVariant={creationVariant} showPrice={creditsEnabled} disabledReason={disabledReason} />
<ModelLabel config={config} model={displayModel} capability={capability} theme={theme} creationVariant={creationVariant} showConfiguredModelName={showConfiguredModelName} showPrice={creditsEnabled} disabledReason={disabledReason} />
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
</button>
);
@@ -203,14 +204,14 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
aria-haspopup="listbox"
aria-expanded={open}
aria-label={placeholder}
title={current ? modelOptionLabel(config, current) : placeholder}
title={current ? pickerModelOptionLabel(config, current, showConfiguredModelName) : placeholder}
onKeyDown={handleTriggerKeyDown}
>
<span className="canvas-model-picker-label flex min-w-0 items-center gap-1.5">
<span className="canvas-model-picker-trigger-icon" style={{ background: theme.toolbar.itemHover }}>
<ModelIcon model={current} />
</span>
<span className="min-w-0 flex-1 truncate">{current ? (creationVariant ? modelDisplayName(config, current) : modelOptionLabel(config, current)) : placeholder}</span>
<span className="min-w-0 flex-1 truncate">{current ? (creationVariant ? pickerModelDisplayName(config, current, showConfiguredModelName) : pickerModelOptionLabel(config, current, showConfiguredModelName)) : placeholder}</span>
{showSelectedPrice && creditsEnabled ? <ModelPrice price={currentPrice} compact /> : null}
</span>
<ChevronDown className={cn("canvas-model-picker-chevron", open && "is-open")} aria-hidden="true" />
@@ -232,6 +233,7 @@ function ModelLabel({
capability,
theme,
creationVariant,
showConfiguredModelName,
showPrice,
disabledReason,
}: {
@@ -240,6 +242,7 @@ function ModelLabel({
capability?: ModelCapability;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
creationVariant: boolean;
showConfiguredModelName: boolean;
showPrice: boolean;
disabledReason?: string;
}) {
@@ -252,7 +255,7 @@ function ModelLabel({
<ModelIcon model={model} />
</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">{modelDisplayName(config, model)}</span>
<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}>
{capabilitySummary}
</span>
@@ -281,6 +284,14 @@ function modelMenuPrice(config: AiConfig, model: string): { value: number; unit:
return { value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
}
function pickerModelDisplayName(config: AiConfig, model: string, showConfiguredModelName: boolean) {
return showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model);
}
function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredModelName: boolean) {
return showConfiguredModelName ? `${configuredModelDisplayName(config, model)}(${resolveModelChannel(config, model).name})` : modelOptionLabel(config, model);
}
function ModelPrice({ price, compact = false }: { price: { value: number; unit: "次" | "秒" } | null | undefined; compact?: boolean }) {
if (price === undefined) return null;
if (price === null) return compact ? null : <span className="shrink-0 text-[var(--fs-tiny)] text-foreground/40">未配置</span>;