fix: refine Lovart-style canvas and home UI

This commit is contained in:
Your Name 2026-06-10 22:38:58 +08:00
parent 6d7f869e56
commit f02d18702a
13 changed files with 717 additions and 428 deletions

View File

@ -2071,7 +2071,7 @@ export function AdminSettingsClient() {
usageTimeMessage: "由壹点累了也是需要休息的",
defaultTheme: "system",
bannerEnabled: true,
bannerText: "升级会员, Nano Banana Pro 免费365天!",
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null,
toolbox1Name: "工具箱",
toolbox1Enabled: true,
@ -2108,7 +2108,7 @@ export function AdminSettingsClient() {
agentWorkflowDefaultModelId: null,
canvasDetailDefaultModelId: null,
canvasDetailAgentModelId: null,
lovartComposerPrefix: "让Buyi-ai",
lovartComposerPrefix: "让「由壹点AI」",
lovartComposerPreviewTexts: [
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
@ -2181,7 +2181,7 @@ export function AdminSettingsClient() {
defaultTheme: siteSettingsData.defaultTheme || "system",
bannerEnabled: siteSettingsData.bannerEnabled ?? true,
bannerText:
siteSettingsData.bannerText || "升级会员, Nano Banana Pro 免费365天!",
siteSettingsData.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: siteSettingsData.bannerLink || null,
toolbox1Name: siteSettingsData.toolbox1Name || "工具箱",
toolbox1Enabled: siteSettingsData.toolbox1Enabled ?? true,
@ -2269,7 +2269,7 @@ export function AdminSettingsClient() {
(siteSettingsData as any).canvasDetailAgentModelId || null,
lovartComposerPrefix:
String((siteSettingsData as any).lovartComposerPrefix || "").trim() ||
"让Buyi-ai",
"让「由壹点AI」",
lovartComposerPreviewTexts: Array.isArray(
siteSettingsData.lovartComposerPreviewTexts
)
@ -9905,7 +9905,7 @@ export function AdminSettingsClient() {
lovartComposerPrefix: e.target.value.slice(0, 40),
})
}
placeholder="让Buyi-ai"
placeholder="让「由壹点AI」"
value={siteSettings.lovartComposerPrefix}
/>
<div className="text-muted-foreground text-xs">

View File

@ -382,7 +382,7 @@ function normalizeLovartComposerPreviewTexts(value: unknown): string[] {
function normalizeLovartComposerPrefix(value: unknown): string {
const normalized = String(value || "").trim();
if (!normalized) {
return "让Buyi-ai";
return "让「由壹点AI」";
}
return normalized.slice(0, 40);
}
@ -538,7 +538,7 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
surpriseCategories: ["全部", "精选"],
canvasDefaultModelId: null,
lovartComposerPrefix: "让Buyi-ai",
lovartComposerPrefix: "让「由壹点AI」",
lovartComposerPreviewTexts: [],
lovartPromptTagVisibility: {},
contactConfig: normalizeContactConfig(undefined),
@ -844,7 +844,7 @@ async function getScopedPublicSiteSettings(
...createBrandingSettings(typedSettings),
bannerEnabled: typedSettings?.bannerEnabled ?? true,
bannerText:
typedSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!",
typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: typedSettings?.bannerLink || null,
lovartComposerPrefix: normalizeLovartComposerPrefix(
valueByKey.get(LOVART_COMPOSER_PREFIX_KEY)
@ -1376,7 +1376,7 @@ export async function GET(request: NextRequest) {
usageTimeEnd: "20:00",
usageTimeMessage: "由壹点累了也是需要休息的",
bannerEnabled: true,
bannerText: "升级会员, Nano Banana Pro 免费365天!",
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null,
toolbox1Name: "工具箱",
toolbox1Enabled: true,
@ -2959,7 +2959,7 @@ export async function PUT(request: NextRequest) {
usageTimeEnd: siteSettingsPayload.usageTimeEnd ?? "20:00",
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "由壹点累了也是需要休息的",
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
bannerText: siteSettingsPayload.bannerText ?? "升级会员, Nano Banana Pro 免费365天!",
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: siteSettingsPayload.bannerLink,
toolbox1Name: siteSettingsPayload.toolbox1Name ?? "工具箱",
toolbox1Enabled: siteSettingsPayload.toolbox1Enabled ?? true,

View File

@ -57,6 +57,7 @@ import {
ZoomOut,
} from "lucide-react";
import dynamic from "next/dynamic";
import { useTheme } from "next-themes";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import {
type ChangeEvent,
@ -244,6 +245,7 @@ import type {
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
const CANVAS_SURFACE_BACKGROUND = "#f4f4f5";
const fetchCanvasSiteSettings = async (url: string) => {
const separator = url.includes("?") ? "&" : "?";
@ -1122,7 +1124,7 @@ function normalizeImportedCanvasState(payload: unknown): {
};
}
const FLOATING_PANEL_WIDTH = 460;
const FLOATING_PANEL_WIDTH = 400;
const FLOATING_PANEL_INSET = 16;
const DOCKED_PANEL_INSET = 0;
const FLOATING_UI_GAP = 16;
@ -1710,6 +1712,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
? routeId
: "";
const router = useRouter();
const { resolvedTheme } = useTheme();
const mode = searchParams.get("mode");
const requestedWorkspaceType = searchParams.get("workspaceType");
const requestedAgentMode = mode === "agent";
@ -1719,6 +1722,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const activeCanvasWorkspaceType: CanvasWorkspaceType = isVideoMode
? "video"
: "canvas";
const canvasSurfaceBackground =
resolvedTheme === "dark" ? "#0a0a0c" : CANVAS_SURFACE_BACKGROUND;
const defaultCanvasTitle = isVideoMode ? "未命名视频画布" : "未命名画布";
const workspaceLabel = isVideoMode ? "视频画布" : "画布";
const createNewCanvasUrl = isVideoMode
@ -8952,7 +8957,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const brandEntry = (
<div
className="absolute top-4 left-4 z-20 rounded-full px-1 py-1 shadow-none"
style={{ backgroundColor: "var(--background)" }}
style={{ backgroundColor: canvasSurfaceBackground }}
>
<Button
className="h-9 gap-2 rounded-full border-0 bg-transparent px-2.5 shadow-none hover:bg-transparent hover:text-foreground"
@ -9365,7 +9370,15 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
return (
<TooltipProvider>
<div className="relative h-dvh w-full overflow-hidden overscroll-none bg-background">
<div
className="relative h-dvh w-full overflow-hidden overscroll-none"
style={
{
"--canvas-surface-background": canvasSurfaceBackground,
backgroundColor: canvasSurfaceBackground,
} as React.CSSProperties
}
>
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
<div
className="absolute inset-0 z-0"

View File

@ -24,9 +24,8 @@ import {
type UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
import useSWR, { useSWRConfig } from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot } from "lucide-react";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Collapsible,
CollapsibleContent,
@ -349,7 +348,7 @@ type VideoStoryboardStreamData =
// 建议卡片数据 - 已移除
const SUGGESTIONS: { title: string; desc: string; image: string }[] = [];
const CANVAS_COMPOSER_PREFIX_DEFAULT = "让Buyi-ai";
const CANVAS_COMPOSER_PREFIX_DEFAULT = "让「由壹点AI」";
const CANVAS_COMPOSER_PREVIEW_TEXTS_DEFAULT = [
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
@ -972,6 +971,7 @@ export function CanvasChatPanel({
const inFlightRawChatIdRef = useRef<string | null>(null);
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
const [isComposerModeMenuOpen, setIsComposerModeMenuOpen] = useState(false);
const [selectedSkillTagId, setSelectedSkillTagId] = useState<string | null>(null);
const [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
@ -2031,6 +2031,7 @@ export function CanvasChatPanel({
const [canvasTypedPreviewText, setCanvasTypedPreviewText] = useState("");
const [canvasPreviewCursorVisible, setCanvasPreviewCursorVisible] = useState(true);
const [greetingPeriodText, setGreetingPeriodText] = useState("中午好!");
const [isCanvasBannerDismissed, setIsCanvasBannerDismissed] = useState(false);
useEffect(() => {
const hour = new Date().getHours();
if (hour >= 5 && hour < 11) {
@ -2141,8 +2142,12 @@ export function CanvasChatPanel({
const canUseDetailGeneration = aiDetailGenerationEnabled && !isVideoWorkspace;
const shouldShowCanvasBanner =
!isDetailGenerationMode &&
!isCanvasBannerDismissed &&
(siteSettings?.bannerEnabled ?? true) &&
Boolean(siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!");
Boolean(siteSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!");
useEffect(() => {
setIsCanvasBannerDismissed(false);
}, [siteSettings?.bannerEnabled, siteSettings?.bannerText]);
const canvasSkillTagsEnabled = canvasSkillTagsData?.enabled !== false;
const canvasSkillTagsTitle =
String(canvasSkillTagsData?.title || "").trim() || DEFAULT_CANVAS_SKILL_TAGS_TITLE;
@ -2730,14 +2735,14 @@ export function CanvasChatPanel({
);
const activeModeLabel =
activeComposerMode === "video"
? getToolbarItem("chatModeVideoActive").label
? "视频"
: activeComposerMode === "detail"
? getToolbarItem("chatModeDetailActive").label
: activeComposerMode === "image"
? getToolbarItem("chatModeImageActive").label
? "图像"
: isPresetAgentSelection
? getToolbarItem("chatModePresetAgent").label
: getToolbarItem("chatModeAgentActive").label;
: "Agent";
const shouldMaskAgentRuntimeModelName =
activeComposerMode === "agent" || selectedModelId === "auto";
const getAgentSafeModelName = useCallback(
@ -3963,9 +3968,11 @@ export function CanvasChatPanel({
setMessages(sortCanvasMessagesByCreatedAt(dbMessages));
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
if (dbMessages.length > 0) {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
}
};
const reloadMessagesOnce = async () => {
@ -4149,19 +4156,21 @@ export function CanvasChatPanel({
setMessageViewportKey((prev) => prev + 1);
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature);
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
historyScrollTimerRef.current = setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
historyScrollTimerRef.current = null;
}, 160);
return () => {
if (historyScrollTimerRef.current) {
clearTimeout(historyScrollTimerRef.current);
if (historyMessages.length > 0) {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
historyScrollTimerRef.current = setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
historyScrollTimerRef.current = null;
}
};
}, 160);
return () => {
if (historyScrollTimerRef.current) {
clearTimeout(historyScrollTimerRef.current);
historyScrollTimerRef.current = null;
}
};
}
}, [historyData, rawChatId, setMessages, status]);
useEffect(() => {
@ -4625,6 +4634,7 @@ export function CanvasChatPanel({
// 滚动到底部
useEffect(() => {
if (messages.length === 0) return;
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
@ -7793,7 +7803,7 @@ export function CanvasChatPanel({
: selectedSkillTag?.placeholder?.trim() ||
(selectedSkillTag
? `请输入「${selectedSkillTag.name}」相关需求`
: canvasDynamicPlaceholder || "请输入你的设计需求,例如:绿色椅子 替换 Image");
: "请输入你的设计需求");
const detailProductCount = DETAIL_PRODUCT_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
const detailModelReady = DETAIL_MODEL_SLOT_INDEX >= 0 ? Boolean(detailSlots[DETAIL_MODEL_SLOT_INDEX]?.url) : false;
const detailBundleCount = DETAIL_BUNDLE_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
@ -7915,6 +7925,13 @@ export function CanvasChatPanel({
renderableVisibleMessages.length === 0 &&
!isLoading &&
!shouldShowImageLoading;
const isPlainCanvasEmptyState =
!isWorkspacePresentation &&
!isDetailGenerationMode &&
!isVideoWorkspace &&
renderableVisibleMessages.length === 0 &&
!isLoading &&
!shouldShowImageLoading;
const workspaceEmptyTitle =
activeComposerMode === "agent" && !isPresetAgentSelection
? "开始 Auto Agent 对话"
@ -7991,13 +8008,13 @@ export function CanvasChatPanel({
const renderModelSelectContent = () => (
<SelectContent
className={cn(
"w-[238px] max-h-[340px] overflow-hidden p-1.5",
"w-[170px] max-h-[320px] overflow-hidden rounded-[14px] border border-[#cfd4dc] bg-white p-0 text-[#252b33] shadow-[0_18px_44px_rgba(15,23,42,0.16)]",
isVideoWorkspace
? "rounded-[18px] border border-border/60 bg-background/95 shadow-[0_18px_40px_-24px_rgba(15,23,42,0.35)] backdrop-blur-xl"
: "rounded-[18px] border border-[#cfd4dc] bg-[#f3f4f6]/90 shadow-[0_8px_20px_rgba(15,23,42,0.13)] dark:border-white/10 dark:bg-zinc-900/95"
? "rounded-[14px] border border-border/60 bg-background/95 shadow-[0_14px_30px_-20px_rgba(15,23,42,0.35)] backdrop-blur-xl"
: "dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-100"
)}
>
<div className="max-h-[312px] space-y-0.5 overflow-y-auto pr-1">
<div className="max-h-[304px] space-y-0.5 overflow-y-auto p-1.5">
{selectableModels.map((model) => {
const discountLabel = getImageModelCreditDiscountLabel(
model.imageGenConfig
@ -8009,10 +8026,10 @@ export function CanvasChatPanel({
value={model.id}
disabled={model.selectable === false}
className={cn(
"h-[34px] rounded-xl px-2.5 [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full",
"h-8 rounded-[10px] px-2 text-[13px] font-medium [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full",
isVideoWorkspace
? "text-foreground focus:bg-background focus:text-foreground data-[state=checked]:bg-background data-[state=checked]:text-foreground"
: "text-[#2f3640] focus:bg-[#e7ebf0] focus:text-[#1f2937] data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937] dark:text-zinc-100 dark:focus:bg-white/10 dark:focus:text-white dark:data-[state=checked]:bg-white/10 dark:data-[state=checked]:text-white",
: "text-[#2f3640] focus:bg-[#f4f5f6] focus:text-[#1f2937] data-[state=checked]:bg-transparent data-[state=checked]:text-[#1f2937] dark:text-zinc-100 dark:focus:bg-white/10 dark:focus:text-white dark:data-[state=checked]:text-white",
model.selectable === false && "opacity-55"
)}
>
@ -8020,10 +8037,10 @@ export function CanvasChatPanel({
<span className="flex min-w-0 items-center gap-2">
<span
className={cn(
"h-5 w-5 overflow-hidden rounded-full",
"h-4 w-4 overflow-hidden rounded-full",
isVideoWorkspace
? "border border-border/60 bg-background"
: "border border-[#d4d9df] bg-white dark:border-white/20 dark:bg-white"
: "border border-transparent bg-white dark:border-white/20 dark:bg-white"
)}
>
{model.avatarUrl ? (
@ -8050,13 +8067,13 @@ export function CanvasChatPanel({
</span>
)}
</span>
<span className="truncate text-[12px] font-medium leading-none">{model.displayName}</span>
<span className="truncate text-[13px] font-medium leading-none">{model.displayName}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
<span className="flex shrink-0 items-center gap-1">
{discountLabel ? (
<span
className={cn(
"rounded-md border px-1.5 py-0.5 text-[10px] font-semibold leading-none",
"rounded-[5px] border px-1 py-0.5 text-[8px] font-semibold leading-none",
isVideoWorkspace
? "border-border/70 bg-muted/80 text-foreground/80"
: "border-[#d0d4dc] bg-[#eef1f5] text-[#5c6574] dark:border-white/15 dark:bg-white/10 dark:text-zinc-200"
@ -8068,7 +8085,7 @@ export function CanvasChatPanel({
{model.availabilityLabel ? (
<span
className={cn(
"rounded-md border px-1.5 py-0.5 text-[10px] font-semibold leading-none",
"rounded-[5px] border px-1 py-0.5 text-[8px] font-semibold leading-none",
model.availabilityStatus === "maintenance"
? isVideoWorkspace
? "border-amber-400/30 bg-amber-500/10 text-amber-300"
@ -8082,14 +8099,7 @@ export function CanvasChatPanel({
</span>
) : null}
{modelSelectValue === model.id ? (
<span
className={cn(
"text-sm font-semibold",
isVideoWorkspace ? "text-foreground" : "text-[#1f2937]"
)}
>
✓
</span>
<Check className={cn("h-3.5 w-3.5", isVideoWorkspace ? "text-foreground" : "text-[#1f2937]")} />
) : null}
</span>
</span>
@ -8099,7 +8109,7 @@ export function CanvasChatPanel({
{selectableModels.length === 0 ? (
<div
className={cn(
"px-2 py-2 text-xs",
"flex h-9 items-center px-2 text-[12px]",
isVideoWorkspace ? "text-muted-foreground" : "text-[#6b7280]"
)}
>
@ -8341,14 +8351,20 @@ export function CanvasChatPanel({
<div
key={`${activeChatId}-${messageViewportKey}`}
className={cn(
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden",
"flex-1 min-h-0 overflow-x-hidden",
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-y-auto",
isWorkspacePresentation ? "p-6" : "p-4",
isDetailGenerationMode && renderableVisibleMessages.length === 0
? "pt-10"
: "pt-4"
isPlainCanvasEmptyState
? "pt-0"
: isDetailGenerationMode && renderableVisibleMessages.length === 0
? "pt-10"
: "pt-4"
)}
>
<div className="space-y-6 overflow-x-hidden pb-24 pr-1">
<div className={cn(
"space-y-6 overflow-x-hidden pr-1",
isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24"
)}>
{!isDetailGenerationMode && renderableVisibleMessages.length === 0 ? (
isWorkspacePresentation ? (
<div className="flex h-full min-h-[360px] items-center justify-center">
@ -8390,7 +8406,13 @@ export function CanvasChatPanel({
</div>
</div>
) : (
<div className={cn("flex flex-col h-full", isDetailGenerationMode ? "pt-0" : "pt-4")}>
<div
className={cn(
"flex h-full flex-col",
isPlainCanvasEmptyState ? "min-h-0" : "min-h-[360px]",
isDetailGenerationMode ? "pt-0" : "pt-4"
)}
>
{isVideoWorkspace ? (
<div className="mb-8 rounded-[30px] border border-border/60 bg-background/82 p-5 shadow-[0_22px_50px_-32px_rgba(15,23,42,0.35)] backdrop-blur-xl">
<div className="flex items-start gap-3">
@ -8460,41 +8482,31 @@ export function CanvasChatPanel({
) : null}
</div>
) : (
<>
<div className={cn(isDetailGenerationMode ? "mb-4" : "mb-8")}>
<h1 className="mb-2 text-2xl font-bold">
<span>Hi</span>
<div className="flex flex-1 flex-col px-7 pt-12 pb-12">
<div className="text-left">
<h1 className="text-[24px] font-semibold leading-tight tracking-tight text-[#050505] dark:text-zinc-50">
Hi,{" "}
{greetingUserName ? (
<>
<span>, </span>
<span className="text-blue-600">{greetingUserName}</span>
<span> {greetingPeriodText}</span>
</>
) : (
<span> {greetingPeriodText}</span>
)}
<span className="text-[#2563ff]">{greetingUserName}</span>
) : null}{" "}
{greetingPeriodText}
</h1>
<p className="text-muted-foreground">
开始创作吧!
{Number.isFinite(totalGeneratedImages) ? (
<>
{" "}累计为你生成了{" "}
<span className="text-blue-600 font-semibold">
{totalGeneratedImages.toLocaleString()}
</span>{" "}
张图。
</>
) : null}
<p className="mt-3 text-[15px] font-normal leading-6 text-[#6b7280] dark:text-zinc-400">
开始创作吧! 累计为你生成了{" "}
<span className="font-semibold text-[#2563ff]">
{Number.isFinite(totalGeneratedImages) ? totalGeneratedImages : 0}
</span>{" "}
张图。
</p>
</div>
{canvasSkillTags.length > 0 && (
<div className="mb-8 rounded-3xl border border-[#eceef2] bg-white/90 p-4 dark:border-white/10 dark:bg-zinc-900/75">
<div className="mb-3 text-center">
<h3 className="text-[16px] font-semibold text-[#1f2937] dark:text-zinc-100">{canvasSkillTagsTitle}</h3>
</div>
<div className="grid grid-cols-3 gap-2">
{canvasSkillTags.map((tag) => {
{canvasSkillTags.length > 0 ? (
<div className="mt-8 rounded-[26px] border border-[#e4e7ec] bg-white px-5 py-6 text-center dark:border-white/10 dark:bg-zinc-950/70">
<h3 className="text-[16px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
预设快捷功能
</h3>
<div className="mt-6 flex flex-wrap justify-center gap-2">
{canvasSkillTags.slice(0, 4).map((tag) => {
const isActive = selectedSkillTag?.id === tag.id;
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
return (
@ -8507,17 +8519,17 @@ export function CanvasChatPanel({
editor?.focus();
}}
className={cn(
"inline-flex min-w-0 items-center justify-center gap-1.5 rounded-2xl border px-2 py-1.5 text-[13px] font-medium transition-colors",
"inline-flex h-[34px] max-w-full items-center justify-center gap-1.5 rounded-full border px-3.5 text-[13px] font-medium leading-none transition-colors",
isActive
? "border-[#6d7fff] bg-[#eef1ff] text-[#3341a9] dark:border-blue-400/40 dark:bg-blue-500/15 dark:text-blue-200"
: "border-[#e4e7ec] bg-white text-[#30343b] hover:border-[#d6dae3] hover:bg-[#fafbfc] dark:border-white/10 dark:bg-white/5 dark:text-zinc-200 dark:hover:border-white/20 dark:hover:bg-white/10"
? "border-[#d8dce4] bg-[#f7f8fa] text-[#1f2328] dark:border-blue-400/40 dark:bg-blue-500/15 dark:text-blue-200"
: "border-[#e5e7eb] bg-white text-[#1f2328] hover:border-[#d6dae3] hover:bg-[#fafbfc] dark:border-white/10 dark:bg-white/5 dark:text-zinc-200 dark:hover:border-white/20 dark:hover:bg-white/10"
)}
>
{tag.iconUrl ? (
<img
src={buildPreviewImageUrl(tag.iconUrl, 48)}
src={buildPreviewImageUrl(tag.iconUrl, 40)}
alt={tag.name}
className="h-3.5 w-3.5 rounded-sm object-cover"
className="h-4 w-4 rounded-sm object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
@ -8527,7 +8539,7 @@ export function CanvasChatPanel({
}}
/>
) : (
<TagIcon className="h-3.5 w-3.5 text-current opacity-70" />
<TagIcon className="h-4 w-4 text-current opacity-70" />
)}
<span className="truncate">{tag.name}</span>
</button>
@ -8535,8 +8547,8 @@ export function CanvasChatPanel({
})}
</div>
</div>
)}
</>
) : null}
</div>
)}
</div>
)
@ -9093,11 +9105,11 @@ export function CanvasChatPanel({
)}
{!isWorkspacePresentation && shouldShowCanvasBanner ? (
<div className="px-4 pb-2">
<div className="px-[10px] pb-0">
<div
className={cn(
"bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-300 p-3 rounded-xl text-sm flex items-center justify-between",
siteSettings?.bannerLink && "cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
"relative z-10 mb-0 flex h-[48px] items-center justify-between gap-2 rounded-[28px] bg-[#f4f4f5] px-4 py-0 text-[14px] font-medium leading-5 text-[#20242a] dark:bg-zinc-900 dark:text-zinc-100",
siteSettings?.bannerLink && "cursor-pointer transition-colors hover:bg-[#eeeeef] dark:hover:bg-zinc-800"
)}
onClick={() => {
if (siteSettings?.bannerLink) {
@ -9105,13 +9117,23 @@ export function CanvasChatPanel({
}
}}
>
<div className="flex items-center gap-2">
<div className="p-1 bg-blue-100 dark:bg-blue-800 rounded">
<Box className="h-4 w-4" />
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[9px] bg-[#e8ff38] text-[#111827]">
<Sparkles className="h-3.5 w-3.5 fill-current" />
</div>
<span>{siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!"}</span>
<span className="min-w-0 truncate">{siteSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!"}</span>
</div>
<X className="h-4 w-4 cursor-pointer opacity-50 hover:opacity-100" onClick={(e) => e.stopPropagation()} />
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[#20242a]/75 transition-colors hover:bg-black/5 hover:text-[#20242a] dark:text-zinc-100/75 dark:hover:bg-white/10 dark:hover:text-zinc-100"
aria-label="关闭优惠广告"
onClick={(event) => {
event.stopPropagation();
setIsCanvasBannerDismissed(true);
}}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
) : null}
@ -9119,14 +9141,16 @@ export function CanvasChatPanel({
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
<div
className={cn(
"border-t",
isWorkspacePresentation
? "bg-white/92 p-5 backdrop-blur-sm dark:bg-zinc-950/92"
: "bg-white p-4 dark:bg-zinc-950"
? "border-t bg-white/92 p-5 backdrop-blur-sm dark:bg-zinc-950/92"
: "bg-white px-[10px] pb-[10px] pt-0 dark:bg-zinc-950"
)}
>
<div
className="relative flex flex-col rounded-3xl border bg-card transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background dark:border-white/12 dark:bg-zinc-950 dark:focus-within:border-white/25 dark:focus-within:bg-zinc-950"
className={cn(
"relative flex flex-col overflow-hidden border border-[#e2e5ea] bg-white shadow-[0_22px_42px_-30px_rgba(15,23,42,0.48)] transition-all duration-200 focus-within:border-[#d8dde5] focus-within:shadow-[0_24px_48px_-30px_rgba(15,23,42,0.55)] dark:border-white/12 dark:bg-zinc-950 dark:focus-within:border-white/25",
"min-h-[184px] rounded-[28px]"
)}
data-canvas-chat-input-anchor="true"
>
{isWorkspacePresentation && !isDetailGenerationMode ? (
@ -9883,8 +9907,12 @@ export function CanvasChatPanel({
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div
className={cn(
"relative px-3 pt-3 pb-12",
isDetailGenerationMode ? "min-h-[72px]" : "min-h-[60px]"
"relative px-3 pb-0 pt-3 sm:px-4",
isDetailGenerationMode
? "min-h-[112px]"
: activeComposerMode === "image"
? "min-h-[118px]"
: "min-h-[132px]"
)}
>
{!isWorkspacePresentation && !isDetailGenerationMode && selectedSkillTag && (
@ -10207,14 +10235,56 @@ export function CanvasChatPanel({
) : null}
</div>
) : null}
<div className="rounded-[22px] border border-[#d7deeb] bg-[#f8fafc] px-4 py-2.5 shadow-[0_12px_24px_-18px_rgba(15,23,42,0.35)] transition-colors focus-within:border-[#a6c8ff] focus-within:bg-white dark:border-zinc-600 dark:bg-zinc-900/60 dark:focus-within:bg-zinc-900">
{!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image" ? (
<button
type="button"
className="mx-4 mt-3 flex h-16 w-16 shrink-0 flex-col items-center justify-center gap-1.5 overflow-hidden rounded-[18px] bg-[#f3f3f4] text-[#a8adb5] transition-colors hover:bg-[#ececee] hover:text-[#7d848e] dark:bg-white/8 dark:text-zinc-400 dark:hover:bg-white/12"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title="添加参考图"
>
{attachments[0]?.url ? (
<img
src={buildPreviewImageUrl(attachments[0].url, 180)}
alt={attachments[0].name || "参考图"}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== attachments[0]?.url) {
event.currentTarget.src = attachments[0]?.url || "";
}
}}
/>
) : (
<>
<ImagePlus className="h-5 w-5" />
<span className="text-[13px] font-medium leading-none">参考图</span>
</>
)}
</button>
) : null}
<div
className={cn(
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
? "px-1"
: "px-4 pt-3"
)}
>
<div
ref={textareaRef as any}
contentEditable={!isSubmitting}
suppressContentEditableWarning
className={cn(
"min-h-[40px] max-h-[120px] overflow-y-auto text-sm leading-6 outline-none empty:before:content-[attr(data-placeholder)]",
isVideoWorkspace ? "empty:before:text-muted-foreground/70" : "text-[#111827] empty:before:text-[#9aa1ad] dark:text-zinc-100 dark:empty:before:text-zinc-500"
"max-h-[128px] overflow-y-auto outline-none empty:before:content-[attr(data-placeholder)]",
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
? "min-h-[38px] text-[19px] leading-7 sm:text-[22px] sm:leading-8"
: activeComposerMode === "image"
? "min-h-[44px] text-[16px] leading-7 sm:text-[16px] sm:leading-7"
: "min-h-[64px] text-[16px] leading-7 sm:text-[17px] sm:leading-7",
isVideoWorkspace
? "text-foreground empty:before:text-muted-foreground/70"
: "text-[#1f2328] empty:before:text-[#a8abb2] dark:text-zinc-100 dark:empty:before:text-zinc-500"
)}
data-placeholder={composerPlaceholder}
onInput={(e) => {
@ -10281,7 +10351,7 @@ export function CanvasChatPanel({
</div>
{hasAgentUploadSlots && (
<div className="mb-11 px-3 pb-1">
<div className="mb-3 px-3 pb-1">
<div className="flex flex-row gap-2 overflow-x-auto scrollbar-hide">
{agentUploadSlots.map((slot, index) => {
const slotAttachment = attachments[index];
@ -10349,7 +10419,7 @@ export function CanvasChatPanel({
)}
{isVideoModel && videoFrameSlots.length > 0 && (
<div className="mb-11 px-3 pb-1">
<div className="mb-3 px-3 pb-1">
<div className="flex flex-row gap-2">
{videoFrameSlots.map((label, index) => {
const slotAttachment = attachments[index];
@ -10419,10 +10489,10 @@ export function CanvasChatPanel({
{/* Bottom Toolbar */}
<div
className={cn(
"absolute inset-x-2 bottom-2 z-10 flex items-center justify-between gap-3 rounded-[24px] border border-[#d7deeb] bg-[#fbfdff]/95 px-3 py-2 shadow-[0_10px_28px_-20px_rgba(15,23,42,0.32)] backdrop-blur-xl supports-[backdrop-filter]:bg-[#fbfdff]/90 dark:border-zinc-700 dark:bg-zinc-900/90 dark:shadow-[0_10px_28px_-20px_rgba(2,6,23,0.7)]"
"mt-auto flex min-h-[44px] items-center justify-between gap-1.5 px-3 pb-2 pt-1 dark:bg-zinc-950 sm:px-3 sm:pb-2"
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto pr-1 scrollbar-hide">
<input
ref={fileInputRef}
type="file"
@ -10459,56 +10529,75 @@ export function CanvasChatPanel({
}}
/>
))}
{!isWorkspacePresentation && !isDetailGenerationMode && activeComposerMode !== "image" && (!isVideoModel || showVideoAttachmentButton) && (
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 shrink-0 rounded-full border border-transparent text-[#20242a] transition-colors hover:bg-[#f3f4f6] hover:text-[#111418] dark:hover:border-zinc-500 dark:hover:bg-zinc-700/70",
isVideoWorkspace
? "bg-background"
: "dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12 dark:hover:text-white"
)}
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title={getToolbarItem("chatAttach").label}
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Plus}
item={getToolbarItem("chatAttach")}
/>
</Button>
)}
{!isWorkspacePresentation ? (
<DropdownMenu>
<DropdownMenu
open={isComposerModeMenuOpen}
onOpenChange={setIsComposerModeMenuOpen}
>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className={cn(
"relative h-8 cursor-pointer rounded-full px-3 text-sm font-medium",
isVideoWorkspace
? "border border-[#cfd8e6] bg-[#f3f7ff] text-[#2f4f9e] shadow-[0_2px_8px_-4px_rgba(47,79,158,0.35)] hover:bg-[#e8f0ff] dark:border-zinc-500/60 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
: activeComposerMode === "video"
? "border border-blue-500 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:border-blue-400/40 dark:bg-blue-500/15 dark:text-blue-200 dark:hover:bg-blue-500/20"
: activeComposerMode === "detail"
? "border border-violet-500 bg-violet-50 text-violet-700 hover:bg-violet-100 dark:border-violet-400/40 dark:bg-violet-500/15 dark:text-violet-200 dark:hover:bg-violet-500/20"
: activeComposerMode === "image"
? "border border-purple-500 bg-purple-50 text-purple-700 hover:bg-purple-100 dark:border-purple-400/40 dark:bg-purple-500/15 dark:text-purple-200 dark:hover:bg-purple-500/20"
: "border border-[#9eb3ff] bg-white text-zinc-700 hover:bg-zinc-50 dark:border-white/10 dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12"
<Button
type="button"
variant="ghost"
className={cn(
"relative h-9 max-w-[122px] shrink-0 cursor-pointer rounded-full px-2.5 text-[15px] font-medium outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
isVideoWorkspace
? "bg-muted/80 text-foreground hover:bg-muted dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
: "bg-[#eeeeef] text-[#20242a] hover:bg-[#e7e7e8] dark:bg-white/10 dark:text-zinc-100 dark:hover:bg-white/14"
)}
>
<span className="flex items-center">
<span className="flex min-w-0 items-center">
{activeComposerMode === "video" ? (
<CanvasToolbarIcon
className="mr-1 h-4 w-4"
className="mr-1.5 h-5 w-5 shrink-0"
fallback={Video}
item={getToolbarItem("chatModeVideoActive")}
/>
) : activeComposerMode === "detail" ? (
<CanvasToolbarIcon
className="mr-1 h-4 w-4"
className="mr-1.5 h-5 w-5 shrink-0"
fallback={LayoutGrid}
item={getToolbarItem("chatModeDetailActive")}
/>
) : activeComposerMode === "image" ? (
<CanvasToolbarIcon
className="mr-1 h-4 w-4"
className="mr-1.5 h-5 w-5 shrink-0"
fallback={ImagePlus}
item={getToolbarItem("chatModeImageActive")}
/>
) : (
<CanvasToolbarIcon
className="mr-1 h-4 w-4"
fallback={Sparkles}
item={getToolbarItem(
isPresetAgentSelection
? "chatModePresetAgent"
: "chatModeAgentActive"
)}
className="mr-1.5 h-5 w-5 shrink-0"
fallback={Bot}
item={null}
/>
)}
{activeModeLabel}
<span className="min-w-0 truncate">{activeModeLabel}</span>
{isComposerModeMenuOpen ? (
<ChevronUp className="ml-1.5 h-3.5 w-3.5 shrink-0" />
) : (
<ChevronDown className="ml-1.5 h-3.5 w-3.5 shrink-0" />
)}
</span>
</Button>
</DropdownMenuTrigger>
@ -10517,61 +10606,61 @@ export function CanvasChatPanel({
side="top"
sideOffset={10}
className={cn(
"w-[156px] rounded-xl p-1.5",
"w-[200px] rounded-[18px] border border-[#d8dce3] bg-white p-2 text-[#20242a] shadow-[0_22px_54px_rgba(15,23,42,0.16)]",
isVideoWorkspace
? "border border-border/60 bg-background/95 shadow-[0_18px_36px_-24px_rgba(15,23,42,0.35)] backdrop-blur-xl"
: "border border-[#d6d9df] bg-[#f4f5f7]/90 shadow-[0_8px_18px_rgba(15,23,42,0.14)] dark:border-white/10 dark:bg-zinc-900/95"
? "border border-border/60 bg-background/95 shadow-[0_14px_30px_-20px_rgba(15,23,42,0.35)] backdrop-blur-xl"
: "dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-100"
)}
>
{effectiveAgentEnabled ? (
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("agent")}
className={cn(
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium",
"h-11 cursor-pointer rounded-[12px] px-3 text-[16px] font-normal [&_svg]:!size-5",
isVideoWorkspace
? "text-foreground"
: "text-[#2e3238] dark:text-zinc-100",
activeComposerMode === "agent"
? isVideoWorkspace
? "bg-background"
: "bg-white/80 dark:bg-white/10"
: "bg-transparent dark:bg-white/10"
: isVideoWorkspace
? "hover:bg-background/80"
: "hover:bg-white/70 dark:hover:bg-white/8"
: "hover:bg-[#f5f6f7] dark:hover:bg-white/8"
)}
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
item={getToolbarItem("chatModeAgentMenu")}
/>
<span className="flex-1">
{getToolbarItem("chatModeAgentMenu").label}
</span>
{activeComposerMode === "agent" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
) : null}
</DropdownMenuItem>
>
<CanvasToolbarIcon
className="h-5 w-5"
fallback={Bot}
item={null}
/>
<span className="flex-1">
Agent
</span>
{activeComposerMode === "agent" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
) : null}
{canUseDetailGeneration ? (
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("detail")}
className={cn(
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium",
"h-11 cursor-pointer rounded-[12px] px-3 text-[16px] font-normal [&_svg]:!size-5",
isVideoWorkspace
? "text-foreground"
: "text-[#2e3238] dark:text-zinc-100",
activeComposerMode === "detail"
? isVideoWorkspace
? "bg-background"
: "bg-white/80 dark:bg-white/10"
: "bg-transparent dark:bg-white/10"
: isVideoWorkspace
? "hover:bg-background/80"
: "hover:bg-white/70 dark:hover:bg-white/8"
: "hover:bg-[#f5f6f7] dark:hover:bg-white/8"
)}
>
<CanvasToolbarIcon
className="h-4 w-4"
className="h-5 w-5"
fallback={LayoutGrid}
item={getToolbarItem("chatModeDetailMenu")}
/>
@ -10579,92 +10668,70 @@ export function CanvasChatPanel({
{getToolbarItem("chatModeDetailMenu").label}
</span>
{activeComposerMode === "detail" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("image")}
className={cn(
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium",
"h-11 cursor-pointer rounded-[12px] px-3 text-[16px] font-normal [&_svg]:!size-5",
isVideoWorkspace
? "text-foreground"
: "text-[#2e3238] dark:text-zinc-100",
activeComposerMode === "image"
? isVideoWorkspace
? "bg-background"
: "bg-white/80 dark:bg-white/10"
: "bg-transparent dark:bg-white/10"
: isVideoWorkspace
? "hover:bg-background/80"
: "hover:bg-white/70 dark:hover:bg-white/8"
: "hover:bg-[#f5f6f7] dark:hover:bg-white/8"
)}
>
<CanvasToolbarIcon
className="h-4 w-4"
className="h-5 w-5"
fallback={ImagePlus}
item={getToolbarItem("chatModeImageMenu")}
/>
<span className="flex-1">
{getToolbarItem("chatModeImageMenu").label}
图像
</span>
{activeComposerMode === "image" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("video")}
className={cn(
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium",
"h-11 cursor-pointer rounded-[12px] px-3 text-[16px] font-normal [&_svg]:!size-5",
isVideoWorkspace
? "text-foreground"
: "text-[#2e3238] dark:text-zinc-100",
activeComposerMode === "video"
? isVideoWorkspace
? "bg-background"
: "bg-white/80 dark:bg-white/10"
: "bg-transparent dark:bg-white/10"
: isVideoWorkspace
? "hover:bg-background/80"
: "hover:bg-white/70 dark:hover:bg-white/8"
: "hover:bg-[#f5f6f7] dark:hover:bg-white/8"
)}
>
<CanvasToolbarIcon
className="h-4 w-4"
className="h-5 w-5"
fallback={Video}
item={getToolbarItem("chatModeVideoMenu")}
/>
<span className="flex-1">
{getToolbarItem("chatModeVideoMenu").label}
视频
</span>
{activeComposerMode === "video" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{!isWorkspacePresentation && !isDetailGenerationMode && (!isVideoModel || showVideoAttachmentButton) && (
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 rounded-full text-muted-foreground transition-colors hover:text-foreground mr-1 border border-transparent hover:border-[#cfd8e6] hover:bg-[#eef4ff] dark:hover:border-zinc-500 dark:hover:bg-zinc-700/70",
isVideoWorkspace
? "bg-background"
: "hover:bg-muted dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12 dark:hover:text-white"
)}
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title={getToolbarItem("chatAttach").label}
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Paperclip}
item={getToolbarItem("chatAttach")}
/>
</Button>
)}
{isVideoModel ? (
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<div className="w-max pr-1 pl-1">
@ -10678,49 +10745,24 @@ export function CanvasChatPanel({
/>
</div>
</div>
) : (
<div className="min-w-0 shrink pl-2 border-l border-[#d9e1f3]/70 dark:border-white/10 ml-1">
<ImageParamsBar
modelName={activeComposerMode === "agent" ? agentDisplayName : currentModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline={true}
providerType={currentModel?.providerType}
imageGenConfig={currentModel?.imageGenConfig || undefined}
hideImageCount={activeComposerMode === "agent"}
hideAdvancedOptions={activeComposerMode === "image"}
/>
</div>
)}
) : null}
</div>
<div className="flex shrink-0 items-center gap-2 pl-2 border-l border-[#d9e1f3]/70 dark:border-white/10">
{!isWorkspacePresentation && activeComposerMode === "image" ? (
<Button
variant="ghost"
size="icon"
title={`${getToolbarItem("chatDeepThinking").label}:${deepThinkingEnabled ? "开" : "关"}`}
className={cn(
"h-8 w-8 shrink-0 rounded-full border-0 bg-transparent p-0 shadow-none transition-colors",
isVideoWorkspace
? deepThinkingEnabled
? "bg-primary/10 text-primary hover:bg-primary/12"
: "text-muted-foreground hover:bg-muted/70"
: deepThinkingEnabled
? "bg-[#eef4ff] text-[#3b5bcc] hover:bg-[#e5efff] dark:bg-blue-500/15 dark:text-blue-200 dark:hover:bg-blue-500/20"
: "text-[#6b7280] hover:bg-[#f4f6f8] dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12 dark:hover:text-white"
)}
onClick={() => setDeepThinkingEnabled((prev) => !prev)}
disabled={isSubmitting}
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Brain}
item={getToolbarItem("chatDeepThinking")}
/>
</Button>
<div className="flex shrink-0 items-center gap-1">
{!isVideoModel && !isWorkspacePresentation && activeComposerMode === "image" ? (
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline={true}
providerType={currentModel?.providerType}
imageGenConfig={currentModel?.imageGenConfig || undefined}
hideImageCount={false}
hideAdvancedOptions={true}
inlineTheme="minimal"
showInlineDivider={false}
/>
) : null}
{!isWorkspacePresentation && activeComposerMode !== "detail" ? (
<Select
value={modelSelectValue}
@ -10748,22 +10790,22 @@ export function CanvasChatPanel({
>
<SelectTrigger
className={cn(
"h-8 w-8 rounded-full p-0 justify-center [&>svg]:hidden",
"h-8 w-8 shrink-0 justify-center rounded-full p-0 outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&>svg]:hidden",
isVideoWorkspace
? "border border-[#cfd8e6] bg-white/85 text-zinc-700 hover:bg-white dark:border-white/20 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-100"
: "border border-[#cfd4dc] bg-white dark:border-white/20 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-100"
? "border border-[#cfd8e6] bg-white/85 text-zinc-700 hover:bg-white dark:border-white/12 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]"
: "border border-[#cfd4dc] bg-white text-[#20242a] dark:border-white/12 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]"
)}
title={getToolbarItem("chatModelSelector").label}
>
<span
className={cn(
"flex h-6 w-6 items-center justify-center overflow-hidden rounded-full",
isVideoWorkspace ? "bg-background" : "bg-white dark:bg-white"
"flex h-4 w-4 items-center justify-center overflow-hidden rounded-full",
isVideoWorkspace ? "bg-background dark:bg-transparent" : "bg-white dark:bg-transparent"
)}
>
{getToolbarItem("chatModelSelector").iconUrl ? (
<CanvasToolbarIcon
className="h-4 w-4"
className="h-3 w-3"
fallback={Bot}
item={getToolbarItem("chatModelSelector")}
/>
@ -10783,8 +10825,8 @@ export function CanvasChatPanel({
) : (
<span
className={cn(
"flex h-full w-full items-center justify-center text-[10px] font-medium",
isVideoWorkspace ? "text-foreground/70" : "text-[#4b5563] dark:text-zinc-950"
"flex h-full w-full items-center justify-center text-[9px] font-medium",
isVideoWorkspace ? "text-foreground/70 dark:text-zinc-100" : "text-[#4b5563] dark:text-zinc-100"
)}
>
{(selectDisplayModel?.displayName || "A").slice(0, 1)}
@ -10799,7 +10841,7 @@ export function CanvasChatPanel({
{estimatedPointsCost ? (
<div
className={cn(
"flex h-8 items-center gap-1.5 rounded-full px-2.5 text-xs ml-2",
"ml-0 flex h-7 max-w-[60px] shrink items-center gap-1 rounded-full px-2 text-[10px] sm:ml-1 sm:max-w-none sm:gap-1 sm:px-2 sm:text-[11px]",
isVideoWorkspace
? "border border-border/60 bg-background text-muted-foreground"
: "bg-muted text-muted-foreground"
@ -10807,14 +10849,14 @@ export function CanvasChatPanel({
title={estimatedPointsCost.title}
>
<CanvasToolbarIcon
className="h-3.5 w-3.5"
className="h-3 w-3"
fallback={Sparkles}
item={getToolbarItem("chatPoints")}
/>
<span className="font-medium text-foreground">
{estimatedPointsCost.cost}
</span>
<span>{getToolbarItem("chatPoints").label}</span>
<span className="hidden sm:inline">{getToolbarItem("chatPoints").label}</span>
</div>
) : null}
@ -10833,8 +10875,8 @@ export function CanvasChatPanel({
? "bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white shadow-md hover:shadow-lg hover:opacity-90 dark:from-violet-500 dark:to-fuchsia-500"
: "bg-muted text-muted-foreground hover:bg-muted/80"
: canSend
? "bg-[#2f80ff] text-white shadow-sm shadow-[#2f80ff]/30 hover:bg-[#2570e8]"
: "bg-muted text-muted-foreground hover:bg-muted/80"
? "bg-[#1f2328] text-white shadow-sm hover:bg-[#111418]"
: "bg-[#f1f1f2] text-[#b6bac2] hover:bg-[#ececee] dark:bg-[#242529] dark:text-[#777d88] dark:hover:bg-[#2c2d33]"
)}
onClick={handleSend}
disabled={isSubmitting || !canSend}
@ -10853,7 +10895,7 @@ export function CanvasChatPanel({
<>
<CanvasToolbarIcon
className={cn(
"h-4 w-4",
"h-3.5 w-3.5",
canSend &&
(isVideoWorkspace || isDetailGenerationMode
? "opacity-95"
@ -10873,7 +10915,7 @@ export function CanvasChatPanel({
<>
<CanvasToolbarIcon
className={cn(
"h-4 w-4",
"h-3.5 w-3.5",
canSend &&
(isVideoWorkspace || isDetailGenerationMode
? "opacity-95"

View File

@ -1845,6 +1845,11 @@ export function CanvasWorkspace({
tool === 'draw' && "cursor-crosshair",
isPanning && "cursor-grabbing"
)}
style={
isWorkflowCanvas
? undefined
: { backgroundColor: "var(--canvas-surface-background, #f4f4f5)" }
}
onClick={handleCanvasClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}

View File

@ -48,7 +48,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
@ -323,7 +322,7 @@ type LovartPromptTagKey = (typeof PROMPT_TAGS)[number]["key"];
type LovartPromptTagVisibility = Record<LovartPromptTagKey, boolean>;
const FEATURED_PAGE_SIZE = 24;
const COMPOSER_PREFIX_DEFAULT = "让Buyi-ai";
const COMPOSER_PREFIX_DEFAULT = "让「由壹点AI」";
function SidebarSvgIcon({
iconUrl,
@ -425,14 +424,6 @@ export function LovartHome({
}: LovartHomeProps = {}) {
const router = useRouter();
const { theme, resolvedTheme, setTheme } = useTheme();
const sidebarPopupBaseClass =
"rounded-[24px] border border-black/8 bg-white/96 p-2.5 text-[#111827] shadow-[0_24px_64px_rgba(15,23,42,0.12)] backdrop-blur-xl";
const lovartSidebarButtonClass = (active = false) =>
cn(
"group flex h-10 w-10 items-center justify-center rounded-xl text-[#6b7280] transition-all duration-200 hover:bg-black/[0.04] hover:text-[#111827]",
active &&
"bg-[#111827] text-white shadow-[0_10px_22px_rgba(15,23,42,0.18)]"
);
const [prompt, setPrompt] = useState("");
const [files, setFiles] = useState<UploadFileItem[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
@ -452,6 +443,7 @@ export function LovartHome({
imageSize: "2K",
imageCount: 1,
});
const [composerModelId, setComposerModelId] = useState("auto");
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [loginEmail, setLoginEmail] = useState("");
const [loginPassword, setLoginPassword] = useState("");
@ -697,8 +689,19 @@ export function LovartHome({
[modelsData?.agentModel?.avatarUrl, modelsData?.agentModel?.imageGenConfig]
);
const composerModelValue = "auto";
const triggerComposerModel = autoComposerModel;
const composerModelOptions = useMemo<ModelOption[]>(() => {
const seen = new Set<string>(["auto"]);
const imageModels = (modelsData?.models || []).filter((model) => {
if (!model?.id || seen.has(model.id)) return false;
if (model.supportsImageGen === false) return false;
seen.add(model.id);
return true;
});
return [autoComposerModel, ...imageModels];
}, [autoComposerModel, modelsData?.models]);
const selectedComposerModel =
composerModelOptions.find((model) => model.id === composerModelId) ||
autoComposerModel;
const isMembershipEnabled = siteSettingsData?.membershipConfig?.enabled !== false;
const discoveryTabs = useMemo(() => {
@ -938,15 +941,12 @@ export function LovartHome({
}
}, []);
const handleImageParamsChange = (nextParams: ImageGenerationParams) => {
setImageParams(nextParams);
if (typeof window === "undefined") return;
try {
localStorage.setItem("lovart-home-image-params", JSON.stringify(nextParams));
} catch (error) {
console.error("[LovartHome] Failed to save image params:", error);
useEffect(() => {
if (composerModelOptions.some((model) => model.id === composerModelId)) {
return;
}
};
setComposerModelId("auto");
}, [composerModelId, composerModelOptions]);
useEffect(() => {
if (!featuredLoadMoreRef.current || !hasMoreFeatured) return;
@ -1340,7 +1340,7 @@ export function LovartHome({
attachments: uploadedAttachments,
autoSend: true,
createdAt: Date.now(),
selectedModelId: "auto",
selectedModelId: selectedComposerModel.id || "auto",
imageParams,
})
);
@ -1461,11 +1461,15 @@ export function LovartHome({
imgClassName={cn(
sizeClassName,
"object-contain",
options?.active ? "brightness-0 invert opacity-100" : "brightness-0 opacity-60"
options?.active
? "brightness-0 opacity-85 dark:invert dark:opacity-95"
: "brightness-0 opacity-60 dark:invert dark:opacity-70"
)}
fallbackClassName={cn(
sizeClassName,
options?.active ? "text-white" : "text-[#6b7280]"
options?.active
? "text-[#111827] dark:text-white"
: "text-[#6b7280] dark:text-white/62"
)}
/>
);
@ -1590,66 +1594,41 @@ export function LovartHome({
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-[14px] border border-black/10 bg-white px-2.5 text-[12px] font-medium text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
className="inline-flex h-8 w-8 items-center justify-center rounded-[11px] border border-black/10 bg-white text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
title={`主题模式:${selectedThemeOption.label}`}
aria-label={`主题模式:${selectedThemeOption.label}`}
>
<SelectedThemeIcon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{selectedThemeOption.label}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-55" />
<SelectedThemeIcon className="h-[15px] w-[15px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={10}
className="w-[220px] rounded-2xl border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
className="!min-w-0 w-auto rounded-[18px] border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
>
<DropdownMenuLabel className="px-3 py-2 text-[12px] text-black/45 dark:text-white/45">
个人主题模式
</DropdownMenuLabel>
{THEME_MODE_OPTIONS.map((option) => {
const OptionIcon = option.icon;
const isSelected = selectedThemeMode === option.value;
<div className="flex items-center gap-1">
{THEME_MODE_OPTIONS.map((option) => {
const OptionIcon = option.icon;
const isSelected = selectedThemeMode === option.value;
return (
<DropdownMenuItem
key={option.value}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 text-left focus:bg-black/5 dark:focus:bg-white/8",
isSelected
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
: "text-black/78 dark:text-white/78"
)}
onSelect={() => setTheme(option.value)}
>
<span
return (
<DropdownMenuItem
key={option.value}
aria-label={option.label}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-xl",
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[10px] p-0 focus:bg-black/5 dark:focus:bg-white/8",
isSelected
? "bg-white/14 text-white dark:bg-black/8 dark:text-black"
: "bg-black/5 text-black/65 dark:bg-white/8 dark:text-white/65"
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
: "text-black/70 dark:text-white/70"
)}
onSelect={() => setTheme(option.value)}
title={option.label}
>
<OptionIcon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-semibold leading-4">
{option.label}
</span>
<span
className={cn(
"mt-0.5 block text-[11px] leading-4",
isSelected
? "text-white/70 dark:text-black/60"
: "text-black/45 dark:text-white/42"
)}
>
{option.description}
</span>
</span>
</DropdownMenuItem>
);
})}
<OptionIcon className="h-[15px] w-[15px]" />
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
@ -1779,7 +1758,6 @@ export function LovartHome({
onImageEditor={handleOpenImageEditor}
onNavLink={openNavLink}
onNewCanvas={openNewCanvas}
onProfile={() => toast.info("个人主页模块后续开放")}
onRoute={openNavLink}
onTutorial={handleOpenTutorial}
productPlanningEnabled={siteSettingsData?.productPlanningEnabled !== false}
@ -1798,6 +1776,7 @@ export function LovartHome({
tutorialEnabled={Boolean(siteSettingsData?.tutorialEnabled)}
tutorialVideos={tutorialVideos}
videoCanvasEnabled={siteSettingsData?.videoCanvasEnabled !== false}
tone={resolvedTheme === "dark" ? "dark" : "light"}
/>
<main className="mx-auto max-w-[1240px] px-6 pb-20 pt-[84px]">
@ -1807,15 +1786,16 @@ export function LovartHome({
displayLogo={displayLogo}
files={files}
handleAddFiles={handleAddFiles}
handleImageParamsChange={handleImageParamsChange}
heroPromoActionText={heroPromoActionText}
heroPromoText={heroPromoText}
heroSubtitleText={heroSubtitleText}
heroTitleText={heroTitleText}
homeAnimationData={homeAnimationData}
imageParams={imageParams}
isMembershipEnabled={isMembershipEnabled}
isSubmitting={isSubmitting}
modelOptions={composerModelOptions}
onModelChange={setComposerModelId}
onStartAgentMode={() => setComposerModelId("auto")}
onSubmit={createCanvasFromComposer}
openUpgradeDialog={() => setUpgradeDialogOpen(true)}
prompt={prompt}
@ -1823,7 +1803,7 @@ export function LovartHome({
setPrompt={setPrompt}
shouldInvertLogo={shouldInvertLogo}
siteName={siteName}
triggerComposerModel={triggerComposerModel}
selectedModelId={selectedComposerModel.id}
visiblePromptTags={visiblePromptTags}
/>
)}

View File

@ -1,8 +1,13 @@
import { ArrowUp, Loader2, Sparkles, Upload } from "lucide-react";
import { ArrowUp, Box, Lightbulb, Loader2, Plus, Sparkles } from "lucide-react";
import type { ChangeEvent } from "react";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { ModelOption, UploadFileItem } from "./types";
import type { PROMPT_TAGS } from "./utils";
@ -14,7 +19,6 @@ type LovartHeroComposerProps = {
displayLogo: string;
files: UploadFileItem[];
handleAddFiles: (files: FileList | null) => void;
handleImageParamsChange: (nextParams: ImageGenerationParams) => void;
heroPromoActionText?: string | null;
heroPromoText?: string | null;
heroSubtitleText?: string | null;
@ -24,7 +28,6 @@ type LovartHeroComposerProps = {
htmlContent?: string;
fileName?: string;
};
imageParams: ImageGenerationParams;
isMembershipEnabled: boolean;
isSubmitting: boolean;
onSubmit: () => void;
@ -34,7 +37,10 @@ type LovartHeroComposerProps = {
setPrompt: (value: string) => void;
shouldInvertLogo: boolean;
siteName: string;
triggerComposerModel: ModelOption;
modelOptions: ModelOption[];
onModelChange: (modelId: string) => void;
onStartAgentMode: () => void;
selectedModelId: string;
visiblePromptTags: PromptTag[];
};
@ -43,13 +49,11 @@ export function LovartHeroComposer({
displayLogo,
files,
handleAddFiles,
handleImageParamsChange,
heroPromoActionText,
heroPromoText,
heroSubtitleText,
heroTitleText,
homeAnimationData,
imageParams,
isMembershipEnabled,
isSubmitting,
onSubmit,
@ -59,7 +63,10 @@ export function LovartHeroComposer({
setPrompt,
shouldInvertLogo,
siteName,
triggerComposerModel,
modelOptions,
onModelChange,
onStartAgentMode,
selectedModelId,
visiblePromptTags,
}: LovartHeroComposerProps) {
return (
@ -127,9 +134,9 @@ export function LovartHeroComposer({
)}
<div className="flex items-center justify-between border-t border-black/8 pt-3 dark:border-white/8">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<label className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-black/10 text-black/60 hover:bg-black/5 dark:border-white/12 dark:text-white/60 dark:hover:bg-white/8">
<Upload className="h-4 w-4" />
<div className="flex min-w-0 flex-1 items-center gap-4">
<label className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full text-[#20242a] hover:bg-black/5 dark:text-white/72 dark:hover:bg-white/8">
<Plus className="h-5 w-5" />
<input
type="file"
accept="image/*"
@ -141,49 +148,66 @@ export function LovartHeroComposer({
}}
/>
</label>
<div className="inline-flex h-8 min-w-[130px] items-center gap-1.5 rounded-full border border-blue-100 bg-blue-50 px-2 text-xs text-blue-600 dark:border-sky-500/20 dark:bg-sky-500/12 dark:text-sky-200">
<span className="h-5 w-5 overflow-hidden rounded-full border border-blue-200 bg-white dark:border-sky-500/30 dark:bg-[#0d1117]">
{triggerComposerModel?.avatarUrl ? (
<img
src={triggerComposerModel.avatarUrl}
alt={triggerComposerModel.displayName || "模型头像"}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-blue-500">
{(triggerComposerModel?.displayName || "A").slice(0, 1)}
</span>
)}
</span>
<span className="max-w-[95px] truncate">
{triggerComposerModel?.displayName || "Agent模式"}
</span>
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<ImageParamsBar
modelName={triggerComposerModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline
providerType={triggerComposerModel?.providerType}
imageGenConfig={triggerComposerModel?.imageGenConfig || undefined}
hideImageCount
/>
</div>
</div>
<div className="flex items-center gap-1.5 pl-2">
<div className="flex items-center gap-6 pl-2">
<button
type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-[#20242a] hover:bg-black/5 dark:text-white/72 dark:hover:bg-white/8"
aria-label="开启 Agent 模式"
onClick={onStartAgentMode}
title="开启 Agent 模式"
>
<Lightbulb className="h-5 w-5" />
</button>
<Select value={selectedModelId} onValueChange={onModelChange}>
<SelectTrigger
aria-label="选择模型"
className="h-9 w-9 rounded-full border-0 bg-transparent p-0 text-[#20242a] shadow-none hover:bg-black/5 focus:ring-0 focus:ring-offset-0 dark:text-white/72 dark:hover:bg-white/8 [&>svg]:hidden"
title="选择模型"
>
<span className="inline-flex h-full w-full items-center justify-center">
<Box className="h-5 w-5" />
</span>
</SelectTrigger>
<SelectContent
align="end"
sideOffset={10}
className="w-[210px] rounded-[18px] border-black/10 bg-white p-1.5 text-[#20242a] shadow-[0_22px_54px_rgba(15,23,42,0.16)] dark:border-white/10 dark:bg-[#141416] dark:text-white"
>
{modelOptions.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="h-9 rounded-[12px] text-[13px] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full"
>
<span className="flex min-w-0 items-center gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full border border-black/10 bg-white text-[10px] font-medium text-[#6b7280] dark:border-white/15 dark:bg-white/8 dark:text-white/70">
{model.avatarUrl ? (
<img
alt=""
className="h-full w-full object-cover"
src={model.avatarUrl}
/>
) : (
(model.displayName || "A").slice(0, 1)
)}
</span>
<span className="min-w-0 flex-1 truncate">{model.displayName}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={onSubmit}
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
className="h-8 w-8 rounded-full bg-[#9ca3af] p-0 text-white hover:bg-black dark:bg-white/16 dark:hover:bg-white/26"
className="h-11 w-11 rounded-full bg-[#eeeeef] p-0 text-[#b9bec7] hover:bg-[#e7e7e8] hover:text-[#8f96a3] dark:bg-white/10 dark:text-white/40 dark:hover:bg-white/16"
>
{isSubmitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Loader2 className="h-4.5 w-4.5 animate-spin" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
<ArrowUp className="h-5 w-5" />
)}
</Button>
</div>

View File

@ -31,7 +31,6 @@ type LovartSidebarProps = {
onImageEditor: () => void;
onNavLink: (url: string) => void;
onNewCanvas: () => void;
onProfile: () => void;
onRoute: (url: string) => void;
onTutorial: (video: TutorialVideo) => void;
productPlanningEnabled: boolean;
@ -51,6 +50,7 @@ type LovartSidebarProps = {
tutorialEnabled: boolean;
tutorialVideos: TutorialVideo[];
videoCanvasEnabled: boolean;
tone?: "light" | "dark";
};
export function LovartSidebar({
@ -64,7 +64,6 @@ export function LovartSidebar({
onImageEditor,
onNavLink,
onNewCanvas,
onProfile,
onRoute,
onTutorial,
productPlanningEnabled,
@ -77,14 +76,15 @@ export function LovartSidebar({
tutorialEnabled,
tutorialVideos,
videoCanvasEnabled,
tone = "light",
}: LovartSidebarProps) {
const sidebarPopupBaseClass =
"rounded-[24px] border border-black/8 bg-white/96 p-2.5 text-[#111827] shadow-[0_24px_64px_rgba(15,23,42,0.12)] backdrop-blur-xl";
"rounded-[24px] border border-black/8 bg-white/96 p-2.5 text-[#111827] shadow-[0_24px_64px_rgba(15,23,42,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-[#141416]/96 dark:text-white dark:shadow-[0_24px_64px_rgba(0,0,0,0.42)]";
const sidebarButtonClass = (active = false) =>
[
"group flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-[#6b7280] transition-all duration-200 hover:bg-black/[0.04] hover:text-[#111827]",
"group flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[10px] text-[#6b7280] transition-all duration-200 hover:bg-black/[0.04] hover:text-[#111827] dark:text-white/62 dark:hover:bg-white/8 dark:hover:text-white",
active
? "bg-[#111827] text-white shadow-[0_10px_22px_rgba(15,23,42,0.18)]"
? "bg-[#f1f2f4] text-[#111827] shadow-[inset_0_0_0_1px_rgba(15,23,42,0.04)] dark:bg-white/12 dark:text-white"
: "",
]
.filter(Boolean)
@ -104,7 +104,10 @@ export function LovartSidebar({
onClick={onClick}
title={getItem(key).label}
>
{renderIcon(key, { active: options?.active })}
{renderIcon(key, {
active: options?.active,
sizeClassName: "h-[15px] w-[15px]",
})}
</button>
</TooltipTrigger>
<TooltipContent side="right">{getItem(key).label}</TooltipContent>
@ -124,7 +127,7 @@ export function LovartSidebar({
className={sidebarButtonClass()}
title={getItem(key).label}
>
{renderIcon(key)}
{renderIcon(key, { sizeClassName: "h-[15px] w-[15px]" })}
</button>
</HoverCardTrigger>
<HoverCardContent
@ -132,7 +135,7 @@ export function LovartSidebar({
align="start"
className={`w-[248px] ${sidebarPopupBaseClass}`}
>
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40">
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40 dark:text-white/42">
{getItem(key).label}
</div>
<div className="space-y-1">
@ -140,11 +143,11 @@ export function LovartSidebar({
<button
key={link.id}
type="button"
className="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-[13px] text-black/80 transition hover:bg-black/[0.04]"
className="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-[13px] text-black/80 transition hover:bg-black/[0.04] dark:text-white/80 dark:hover:bg-white/8"
onClick={() => onNavLink(link.url)}
>
<span className="line-clamp-1">{link.name}</span>
<ArrowRight className="h-3.5 w-3.5 text-black/30" />
<ArrowRight className="h-3.5 w-3.5 text-black/30 dark:text-white/35" />
</button>
))}
</div>
@ -153,7 +156,7 @@ export function LovartSidebar({
) : null;
return (
<aside className="fixed left-5 top-[88px] z-30 hidden flex-col items-center gap-3 lg:flex">
<aside className="fixed left-5 top-[88px] z-30 hidden flex-col items-center gap-2.5 lg:flex">
<TooltipProvider delayDuration={120}>
{isEnabled("newProject") ? (
<Tooltip>
@ -161,13 +164,12 @@ export function LovartSidebar({
<button
type="button"
onClick={onNewCanvas}
className="flex h-[56px] w-[56px] shrink-0 items-center justify-center rounded-[20px] border border-black/8 bg-white/96 shadow-[0_20px_46px_rgba(15,23,42,0.12)] backdrop-blur-xl transition-transform duration-200 hover:-translate-y-0.5 hover:bg-white"
className="flex h-[48px] w-[48px] shrink-0 items-center justify-center rounded-[17px] border border-black/8 bg-white/96 shadow-[0_18px_40px_rgba(15,23,42,0.11)] backdrop-blur-xl transition-transform duration-200 hover:-translate-y-0.5 hover:bg-white dark:border-white/10 dark:bg-[#17181d]/94 dark:shadow-[0_22px_60px_rgba(6,8,14,0.34)] dark:hover:bg-[#202127]"
title={getItem("newProject").label}
>
<div className="flex h-9 w-9 items-center justify-center rounded-[12px] bg-[#111827] text-white shadow-[0_10px_20px_rgba(15,23,42,0.18)]">
<div className="flex h-[31px] w-[31px] items-center justify-center rounded-[10px] bg-[#f1f2f4] text-[#111827] shadow-[inset_0_0_0_1px_rgba(15,23,42,0.04)] dark:bg-white/12 dark:text-white">
{renderIcon("newProject", {
filled: true,
sizeClassName: "h-4 w-4",
sizeClassName: "h-3.5 w-3.5",
})}
</div>
</button>
@ -175,10 +177,12 @@ export function LovartSidebar({
<TooltipContent side="right">{getItem("newProject").label}</TooltipContent>
</Tooltip>
) : null}
<FloatingSidebarRail className="max-h-[calc(100vh-172px)]" tone="light">
<FloatingSidebarRail
className="max-h-[calc(100vh-160px)] !w-[48px] !max-w-[48px] gap-1 !rounded-[20px] !px-[7px] !py-[7px]"
tone={tone}
>
{tooltipButton("home", onHome, { active: !isProjectsOnlyView })}
{tooltipButton("projects", onAllProjects, { active: isProjectsOnlyView })}
{tooltipButton("profile", onProfile)}
{tooltipButton("gallery", onGallery)}
{isEnabled("tutorial") && tutorialEnabled ? (
<HoverCard openDelay={80} closeDelay={100}>
@ -188,7 +192,7 @@ export function LovartSidebar({
className={sidebarButtonClass()}
title={getItem("tutorial").label}
>
{renderIcon("tutorial")}
{renderIcon("tutorial", { sizeClassName: "h-[15px] w-[15px]" })}
</button>
</HoverCardTrigger>
<HoverCardContent
@ -196,7 +200,7 @@ export function LovartSidebar({
align="start"
className={`w-[300px] ${sidebarPopupBaseClass}`}
>
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40">
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40 dark:text-white/42">
{getItem("tutorial").label}
</div>
<div className="max-h-[380px] space-y-1 overflow-y-auto">
@ -205,10 +209,10 @@ export function LovartSidebar({
<button
key={video.id}
type="button"
className="flex w-full items-center gap-2.5 rounded-2xl p-2.5 text-left transition hover:bg-black/[0.04]"
className="flex w-full items-center gap-2.5 rounded-2xl p-2.5 text-left transition hover:bg-black/[0.04] dark:hover:bg-white/8"
onClick={() => onTutorial(video)}
>
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-xl bg-black/[0.04]">
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-xl bg-black/[0.04] dark:bg-white/8">
{video.coverUrl ? (
<img
src={video.coverUrl}
@ -217,16 +221,16 @@ export function LovartSidebar({
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<PlayCircle className="h-4 w-4 text-black/35" />
<PlayCircle className="h-4 w-4 text-black/35 dark:text-white/45" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-black/85">
<div className="truncate text-[13px] font-medium text-black/85 dark:text-white/88">
{video.title}
</div>
{video.description ? (
<div className="truncate text-[11px] text-black/42">
<div className="truncate text-[11px] text-black/42 dark:text-white/42">
{video.description}
</div>
) : null}
@ -234,7 +238,7 @@ export function LovartSidebar({
</button>
))
) : (
<div className="px-2 py-3 text-[12px] text-black/42">暂无教程视频</div>
<div className="px-2 py-3 text-[12px] text-black/42 dark:text-white/42">暂无教程视频</div>
)}
</div>
</HoverCardContent>

View File

@ -98,7 +98,7 @@ export type LovartPromptTagVisibility = Record<LovartPromptTagKey, boolean>;
export const MASONRY_HEIGHTS = [420, 290, 340, 470, 300, 360, 430, 320];
export const FEATURED_PAGE_SIZE = 24;
export const COMPOSER_PREFIX_DEFAULT = "让Buyi-ai";
export const COMPOSER_PREFIX_DEFAULT = "让「由壹点AI」";
export function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
const trimmed = text.trim();

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
import { ChevronDown, ChevronUp, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
import { getGrsaiGptImageOutputSize } from "@/lib/ai/grsai-gpt-image-sizes";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
@ -34,7 +34,7 @@ interface ImageParamsBarProps {
providerType?: string; // 模型类型:gemini, volcano-vision, alibaba-image, ruxa-image 等
hideImageCount?: boolean; // 隐藏图片数量按钮(仅个别页面需要)
hideAdvancedOptions?: boolean; // 隐藏高级设置按钮
inlineTheme?: "light" | "dark";
inlineTheme?: "light" | "dark" | "minimal";
showInlineDivider?: boolean;
// 🆕 图片生成模型特性配置(从模型配置中读取)
imageGenConfig?: {
@ -122,6 +122,10 @@ export function ImageParamsBar({
};
const getImageCountOptions = () => {
if (inlineTheme === "minimal") {
return Array.from({ length: 10 }, (_, i) => i + 1);
}
if (imageGenConfig?.maxImages) {
return Array.from({ length: imageGenConfig.maxImages }, (_, i) => i + 1);
}
@ -186,17 +190,22 @@ export function ImageParamsBar({
const buildBaseParams = (source?: Partial<ImageGenerationParams>) =>
clampParamsByProvider({
aspectRatio: "auto",
aspectRatio: inlineTheme === "minimal" ? "1:1" : "auto",
imageSize: "2K",
imageCount: 1,
promptExtend: true,
watermark: false,
...source,
...(inlineTheme === "minimal" && (!source?.aspectRatio || source.aspectRatio === "auto")
? { aspectRatio: "1:1" }
: {}),
});
const [params, setParams] = useState<ImageGenerationParams>({
...buildBaseParams(initialParams),
});
const [minimalParamsOpen, setMinimalParamsOpen] = useState(false);
const [qualityLabel, setQualityLabel] = useState<"自动" | "高" | "中" | "低">("中");
const imageCountOptions = getImageCountOptions();
const showImageCountSelector = !hideImageCount && imageCountOptions.length > 1;
@ -300,16 +309,192 @@ export function ImageParamsBar({
}
};
const updateParams = (patch: Partial<ImageGenerationParams>) => {
const newParams = clampParamsByProvider({ ...params, ...patch });
setParams((prevParams) => (isSameParams(prevParams, newParams) ? prevParams : newParams));
if (!isSameParams(params, newParams)) {
onParamsChange?.(newParams);
}
};
// 内联模式:只返回参数按钮,嵌入工具栏
if (inline) {
const isDarkInline = inlineTheme === "dark";
const isMinimalInline = inlineTheme === "minimal";
const outputSize = getComputedOutputSize(params.aspectRatio, params.imageSize);
if (isMinimalInline) {
const sizeOptions = [
{ label: "1:1", aspectRatio: "1:1", imageSize: "1K" },
{ label: "3:2", aspectRatio: "3:2", imageSize: "1K" },
{ label: "2:3", aspectRatio: "2:3", imageSize: "1K" },
{ label: "4:3", aspectRatio: "4:3", imageSize: "1K" },
{ label: "3:4", aspectRatio: "3:4", imageSize: "1K" },
{ label: "9:16", aspectRatio: "9:16", imageSize: "1K" },
{ label: "1:1(2k)", aspectRatio: "1:1", imageSize: "2K" },
{ label: "16:9(2k)", aspectRatio: "16:9", imageSize: "2K" },
{ label: "9:16(2k)", aspectRatio: "9:16", imageSize: "2K" },
{ label: "16:9(4K)", aspectRatio: "16:9", imageSize: "4K" },
{ label: "9:16(4K)", aspectRatio: "9:16", imageSize: "4K" },
{ label: "auto", aspectRatio: "auto", imageSize: params.imageSize || "1K" },
] as const;
const imageCountOptions = Array.from({ length: 10 }, (_, index) => index + 1);
const selectedAspectLabel = params.aspectRatio === "auto" ? "auto" : params.aspectRatio;
const selectedImageCount = params.imageCount || 1;
const renderMinimalRatioGlyph = (ratioValue: string) => {
if (ratioValue === "auto") {
return <span className="text-[11px] leading-none">auto</span>;
}
const [wRaw, hRaw] = ratioValue.split(":").map((item) => Number(item));
if (!wRaw || !hRaw) {
return <Scan className="h-4 w-4" />;
}
const maxSide = 18;
let width = maxSide;
let height = maxSide;
if (wRaw >= hRaw) {
width = maxSide;
height = Math.max(7, Math.round((maxSide * hRaw) / wRaw));
} else {
height = maxSide;
width = Math.max(7, Math.round((maxSide * wRaw) / hRaw));
}
return (
<span
className="inline-block rounded-[3px] border border-current"
style={{ width, height }}
/>
);
};
return (
<Popover open={minimalParamsOpen} onOpenChange={setMinimalParamsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="h-8 shrink-0 rounded-full bg-[#f1f1f2] px-2.5 text-[13px] font-normal text-[#20242a] hover:bg-[#e9eaec] focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]"
title="生成参数"
>
{qualityLabel} · {selectedAspectLabel} · {selectedImageCount} img
{minimalParamsOpen ? (
<ChevronUp className="ml-1 h-3 w-3" />
) : (
<ChevronDown className="ml-1 h-3 w-3" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="center"
side="top"
sideOffset={8}
className="w-[304px] max-w-[calc(100vw-32px)] rounded-[18px] border border-[#c7ccd5] bg-white p-3.5 text-[#252b33] shadow-[0_22px_56px_rgba(15,23,42,0.16)] dark:border-white/12 dark:bg-[#141416] dark:text-zinc-100 dark:shadow-[0_24px_64px_rgba(0,0,0,0.42)]"
>
<div className="space-y-4">
<div>
<div className="mb-2 text-[14px] font-medium leading-none">质量</div>
<div className="grid h-9 grid-cols-4 rounded-full bg-[#f4f4f5] p-0.5 dark:bg-white/8">
{(["自动", "高", "中", "低"] as const).map((quality) => (
<button
key={quality}
type="button"
className={cn(
"rounded-full text-[13px] font-medium transition-colors",
qualityLabel === quality
? "border border-[#c8cdd5] bg-white shadow-[0_1px_4px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-white/16"
: "text-[#3d434d] hover:bg-white/55 dark:text-zinc-300 dark:hover:bg-white/10"
)}
onClick={() => setQualityLabel(quality)}
>
{quality}
</button>
))}
</div>
</div>
<div>
<div className="mb-2 text-[14px] font-medium leading-none">尺寸</div>
<div className="grid grid-cols-[1fr_20px_1fr] items-center gap-2">
<div className="flex h-10 items-center gap-2 rounded-[10px] bg-[#f3f3f4] px-3 text-[15px] dark:bg-white/8">
<span className="text-[#68707c] dark:text-zinc-400">W</span>
<span>{outputSize.width}</span>
</div>
<Link2 className="mx-auto h-3.5 w-3.5 text-[#8b929c]" />
<div className="flex h-10 items-center gap-2 rounded-[10px] bg-[#f3f3f4] px-3 text-[15px] dark:bg-white/8">
<span className="text-[#68707c] dark:text-zinc-400">H</span>
<span>{outputSize.height}</span>
</div>
</div>
</div>
<div>
<div className="mb-2 text-[14px] font-medium leading-none">Size</div>
<div className="grid grid-cols-4 gap-2">
{sizeOptions.map((option) => {
const selected =
params.aspectRatio === option.aspectRatio &&
(option.aspectRatio === "auto" || params.imageSize === option.imageSize);
return (
<button
key={`${option.aspectRatio}-${option.imageSize}-${option.label}`}
type="button"
className={cn(
"flex h-[66px] flex-col items-center justify-center gap-1.5 rounded-[10px] border text-[13px] transition-colors",
selected
? "border-[#bfc4cc] bg-[#e4e4e5] text-[#171b21] dark:border-white/18 dark:bg-white/16 dark:text-zinc-50"
: "border-[#d0d3d9] bg-white text-[#4c535d] hover:bg-[#f5f5f6] dark:border-white/12 dark:bg-transparent dark:text-zinc-300 dark:hover:bg-white/8"
)}
onClick={() =>
updateParams({
aspectRatio: option.aspectRatio,
imageSize: option.imageSize,
})
}
>
<span className="flex h-5 items-center justify-center text-[#30353c] dark:text-zinc-200">
{renderMinimalRatioGlyph(option.aspectRatio)}
</span>
<span>{option.label}</span>
</button>
);
})}
</div>
</div>
<div>
<div className="mb-2 text-[14px] font-medium leading-none">Image</div>
<div className="grid grid-cols-4 gap-2">
{imageCountOptions.map((count) => (
<button
key={count}
type="button"
className={cn(
"h-9 rounded-[9px] border text-[14px] transition-colors",
selectedImageCount === count
? "border-[#bfc4cc] bg-[#e4e4e5] text-[#171b21] dark:border-white/18 dark:bg-white/16 dark:text-zinc-50"
: "border-[#d0d3d9] bg-white text-[#4c535d] hover:bg-[#f5f5f6] dark:border-white/12 dark:bg-transparent dark:text-zinc-300 dark:hover:bg-white/8"
)}
onClick={() => updateParams({ imageCount: count })}
>
{count} img
</button>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
return (
<div
className={cn(
"ml-1 flex min-w-0 items-center gap-1 pl-1",
"flex min-w-0 items-center",
isMinimalInline ? "ml-0 gap-2 pl-0" : "ml-1 gap-1 pl-1",
showInlineDivider &&
!isMinimalInline &&
(isDarkInline ? "border-l border-white/10" : "border-l border-border/50")
)}
>
@ -318,10 +503,15 @@ export function ImageParamsBar({
<Button
variant="ghost"
className={cn(
"h-8 w-11 shrink-0 rounded-[12px] p-0",
isDarkInline
"shrink-0 p-0",
isMinimalInline
? "h-11 w-11 rounded-full border border-transparent bg-transparent text-[#555b66] hover:bg-[#f3f4f6] hover:text-[#20242a]"
: "h-8 w-11 rounded-[12px]",
!isMinimalInline && isDarkInline
? "border border-white/12 bg-white/[0.06] text-white hover:bg-white/[0.1]"
: "border border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#2f3b48] hover:bg-[#edeff2]/90"
: !isMinimalInline
? "border border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#2f3b48] hover:bg-[#edeff2]/90"
: ""
)}
aria-label={`比例:${params.aspectRatio},分辨率:${params.imageSize}`}
title={`比例:${params.aspectRatio},分辨率:${params.imageSize}`}
@ -329,7 +519,11 @@ export function ImageParamsBar({
<span
className={cn(
"flex h-5 w-5 items-center justify-center",
isDarkInline ? "text-white/80" : "text-[#20262f]"
isMinimalInline
? "text-current"
: isDarkInline
? "text-white/80"
: "text-[#20262f]"
)}
>
{renderRatioGlyph(params.aspectRatio)}
@ -444,15 +638,33 @@ export function ImageParamsBar({
<Button
variant="ghost"
className={cn(
"h-8 min-w-[56px] rounded-[12px] px-2.5 text-[12px] font-semibold focus-visible:ring-0 focus-visible:ring-offset-0",
isDarkInline
"text-[12px] font-semibold focus-visible:ring-0 focus-visible:ring-offset-0",
isMinimalInline
? "h-11 min-w-11 rounded-full border border-transparent bg-transparent px-0 text-[#555b66] hover:bg-[#f3f4f6] hover:text-[#20242a]"
: "h-8 min-w-[56px] rounded-[12px] px-2.5",
!isMinimalInline && isDarkInline
? "border border-white/12 bg-white/[0.06] text-white hover:bg-white/[0.1]"
: "border border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#2f3b48] hover:bg-[#edeff2]/90"
: !isMinimalInline
? "border border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#2f3b48] hover:bg-[#edeff2]/90"
: ""
)}
title={`数量:${params.imageCount || 1}张`}
>
<Sparkles className={cn("mr-1 h-3.5 w-3.5", isDarkInline ? "text-emerald-300" : "text-[#6b7280]")} />
{params.imageCount || 1}张
<Sparkles
className={cn(
isMinimalInline ? "h-5 w-5" : "mr-1 h-3.5 w-3.5",
isMinimalInline
? "text-current"
: isDarkInline
? "text-emerald-300"
: "text-[#6b7280]"
)}
/>
{isMinimalInline ? (
<span className="sr-only">{params.imageCount || 1}张</span>
) : (
`${params.imageCount || 1}张`
)}
</Button>
</PopoverTrigger>
<PopoverContent
@ -505,11 +717,21 @@ export function ImageParamsBar({
<Button
variant="ghost"
className={cn(
"h-8 w-8 shrink-0 rounded-[12px] p-0 focus-visible:ring-0 focus-visible:ring-offset-0",
isDarkInline
"shrink-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0",
isMinimalInline
? "h-11 w-11 rounded-full border border-transparent bg-transparent text-[#555b66] hover:bg-[#f3f4f6] hover:text-[#20242a]"
: "h-8 w-8 rounded-[12px]",
!isMinimalInline && isDarkInline
? "border border-white/12 bg-white/[0.06] text-white/70 hover:bg-white/[0.1]"
: "border border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#5f6b7a] hover:bg-[#edeff2]/90",
(params.promptExtend || params.negativePrompt) && (isDarkInline ? "text-emerald-300" : "text-primary")
: !isMinimalInline
? "border border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#5f6b7a] hover:bg-[#edeff2]/90"
: "",
(params.promptExtend || params.negativePrompt) &&
(isMinimalInline
? "text-[#20242a]"
: isDarkInline
? "text-emerald-300"
: "text-primary")
)}
title="高级设置"
>

View File

@ -2,7 +2,7 @@
-- These columns are referenced by /api/site-settings and admin settings UI.
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "bannerEnabled" boolean NOT NULL DEFAULT true;
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "bannerText" text DEFAULT '升级会员, Nano Banana Pro 免费365天!';
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "bannerText" text DEFAULT '限时特惠:升级会员最高立享 45% OFF!';
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "bannerLink" text;
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "tutorialVideoUrl" text;
@ -17,4 +17,3 @@ ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "canvasAutoModeDefaultModelI
INSERT INTO "SiteSettings" ("siteName")
SELECT 'AI Chatbot'
WHERE NOT EXISTS (SELECT 1 FROM "SiteSettings");

View File

@ -4727,7 +4727,7 @@ export async function getSiteSettings() {
favicon: null,
defaultTheme: "system",
bannerEnabled: true,
bannerText: "升级会员, Nano Banana Pro 免费365天!",
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null,
toolbox1Name: "工具箱",
toolbox1Enabled: true,

View File

@ -457,7 +457,7 @@ export const siteSettings = pgTable("SiteSettings", {
usageTimeMessage: text("usageTimeMessage").default("由壹点累了也是需要休息的"), // 超时提示词
// 广告横幅设置
bannerEnabled: boolean("bannerEnabled").notNull().default(true), // 是否显示广告横幅
bannerText: text("bannerText").default("升级会员, Nano Banana Pro 免费365天!"), // 广告横幅文字
bannerText: text("bannerText").default("限时特惠:升级会员最高立享 45% OFF!"), // 广告横幅文字
bannerLink: text("bannerLink"), // 广告横幅链接(可选)
// 工具箱设置
toolbox1Name: varchar("toolbox1Name", { length: 64 }).default("工具箱"), // 工具箱1名称