diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 8e388dc5b..7414f94f5 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -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(null); + const [previewedModel, setPreviewedModel] = useState(""); const [triggerWidth, setTriggerWidth] = useState(null); const menuRef = useRef(null); const triggerRef = useRef(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({
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > - {creationVariant ? ( -
- 选择模型 - {current ? {pickerModelDisplayName(config, current, showConfiguredModelName)} : null} -
- ) : null} {optionGroups.length ? ( - optionGroups.map((group) => ( -
-
- {group.label} - {group.scope ? ( - - {group.scope} - - ) : null} + activeGroupKey === null ? ( +
+ {optionGroups.map((group) => { + const groupCurrent = group.models.find((item) => item.models.includes(current)); + const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || ""; + return ; + })} +
+ ) :
+
+ {optionGroups.map((group) => { + const groupCurrent = group.models.find((item) => item.models.includes(current)); + const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || ""; + return ; + })} +
+ {optionGroups.filter((group) => group.key === activeGroupKey).map((group) =>
+
+ + {group.label}{group.scope ? {group.scope} : null}
{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 ? : null} ); })}
-
- )) +
)} +
) : (
{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({ {pickerModelDisplayName(config, model, showConfiguredModelName)} - + {capabilitySummary} diff --git a/web/src/pages/create/creation-agent-entry.tsx b/web/src/pages/create/creation-agent-entry.tsx new file mode 100644 index 000000000..3d2701452 --- /dev/null +++ b/web/src/pages/create/creation-agent-entry.tsx @@ -0,0 +1,53 @@ +import { useRef, useState } from "react"; +import { App, Button } from "antd"; +import { ArrowUpRight, FolderOpen, Sparkles } from "lucide-react"; +import { useNavigate } from "react-router"; +import { FluidOrb } from "@/components/ui/fluid-orb"; +import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; +import { useUserStore } from "@/stores/use-user-store"; +import { createCanvasProjectWithRemoteSync, hasRemoteUserDataSyncSession, saveRemoteUserDataNow } from "@/services/user-data-sync"; + +/** Agent 依赖真实画布 ID,先完成服务端保存,再进入同一个画布 Agent。 */ +export function CreationAgentEntry() { + const navigate = useNavigate(); + const { message } = App.useApp(); + const hydrated = useCanvasStore((state) => state.hydrated); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const lock = useRef(false); + const created = useRef<{ id: string; userId: string } | null>(null); + const start = async () => { + if (lock.current) return; + lock.current = true; + setBusy(true); + setError(""); + const userId = useUserStore.getState().user?.id; + try { + if (!userId || !hasRemoteUserDataSyncSession()) throw new Error("登录或同步会话尚未就绪,请稍后重试。"); + if (!hydrated) throw new Error("画布还在恢复中,请稍后重试。"); + if (created.current?.userId !== userId) created.current = null; + if (!created.current) { + const result = await createCanvasProjectWithRemoteSync("Agent 创作"); + if (!result.id) throw new Error("画布创建未返回有效 ID,请重试。"); + created.current = { id: result.id, userId }; + if (result.syncError) throw new Error("画布已缓存在本机,但云端尚未保存。请重试同步后再启动 Agent。"); + } else { + await saveRemoteUserDataNow(); + } + if (useUserStore.getState().user?.id !== userId) return; + navigate(`/canvas/${encodeURIComponent(created.current.id)}?agent=1`); + } catch (cause) { + if (useUserStore.getState().user?.id === userId) { + const detail = cause instanceof Error ? cause.message : "启动失败,请重试。"; + setError(detail); + message.error(detail); + } + } finally { lock.current = false; setBusy(false); } + }; + return
+
+
CANVAS AGENT

不止回答,把想法落到画布上

使用画布里的同一个 Agent,结合素材、技能与创作上下文协作。生成与修改仍需经过原有审批和额度检查。

+
+ {error ?

{error}

: null} +
; +} diff --git a/web/src/pages/create/creation-inspirations.ts b/web/src/pages/create/creation-inspirations.ts new file mode 100644 index 000000000..73ca04b48 --- /dev/null +++ b/web/src/pages/create/creation-inspirations.ts @@ -0,0 +1,66 @@ +import type { CreationMode } from "./creation-types"; + +export type CreationInspiration = { title: string; description: string; image: string; mode: CreationMode; prompt: string; featured?: boolean; source?: string }; + +export const creationFeaturedWorks: CreationInspiration[] = [ + { + title: "雨夜霓虹 · 电影感开场", + description: "宽银幕构图、环境反光与缓慢推进镜头", + image: "/short-drama-styles/cyberpunk-neon.jpg", + mode: "video", + prompt: "雨夜城市街口,霓虹灯倒映在湿润路面,主角撑伞穿过人群,镜头从远景缓慢推进到侧脸特写,电影感光影,16:9", + featured: true, + }, + { + title: "港风天台重逢", + description: "复古色彩与克制的人物情绪", + image: "/short-drama-styles/retro-hong-kong.jpg", + mode: "video", + prompt: "九十年代港风天台,黄昏逆光,两位多年未见的旧友隔着晾衣绳对望,风吹动衣角,镜头轻微手持并缓慢靠近", + }, + { + title: "暗巷悬疑追逐", + description: "高反差黑色电影与紧张节奏", + image: "/short-drama-styles/suspense-noir.jpg", + mode: "video", + prompt: "黑白悬疑电影风格,侦探在狭窄暗巷中追逐神秘人,路灯切割出强烈明暗,低机位跟拍,节奏逐渐加快", + }, + { + title: "东方幻想角色设定", + description: "角色概念图与细腻材质表达", + image: "/short-drama-styles/fantasy-3d.jpg", + mode: "image", + prompt: "东方幻想世界的年轻女剑客角色设定,银白发饰、层叠轻甲与红色披风,山雾背景,细腻材质,电影级概念设计", + }, + { + title: "温暖室内对话", + description: "生活化场景与柔和叙事氛围", + image: "/welcome/wing-it/barn.webp", + mode: "text", + prompt: "请帮我写一场发生在暖色灯光客厅里的母女对话戏:女儿准备离开家乡,母亲表面平静但一直在整理旧物,用动作和潜台词推进情绪。", + }, + { title: "森林里的最后一封信", description: "童话叙事 · 从角色愿望建立冲突", image: "/short-drama-styles/storybook-fantasy.jpg", mode: "text", source: "Storyteller", prompt: "作为故事讲述者,为亲子观众写一个关于坚持的森林童话。主角是一只替动物送信的小狐狸,它必须在第一场雪之前送出一封没有地址的信。先给出角色的愿望与阻碍,再设计三个递进的事件,最后用一个可见的行动完成情绪转折。避免说教。" }, + { title: "末班车上的陌生人", description: "短剧编剧 · 让对白藏住真正目的", image: "/short-drama-styles/urban-live-action.jpg", mode: "text", source: "Screenwriter", prompt: "作为编剧,设计一段发生在末班公交车上的三分钟短剧。两位陌生人都声称自己丢失了一本笔记,但只有一人说真话。先定义人物动机、场景限制和各自隐瞒的信息,再写动作、对白与反转结尾。全剧限制两个角色、一个场景,适合低成本实拍。" }, + { title: "未来城市的记忆修补师", description: "科幻大纲 · 世界规则与人物代价", image: "/short-drama-styles/future-tech.jpg", mode: "text", source: "Novelist", prompt: "作为小说家,构思一部关于记忆修补师的未来都市短篇。城市居民能删除痛苦记忆,但主角发现每次删除都会遗失一种颜色。建立三条世界规则、主人公的核心缺陷、五个转折和付出代价的结局。先交付故事大纲,不直接铺写长篇。" }, + { title: "把告别写成一场雨", description: "诗性旁白 · 少解释,多感官细节", image: "/short-drama-styles/nature-healing.jpg", mode: "text", source: "Poet", prompt: "作为诗人,为一段雨中离乡的影像写四组中文旁白,每组不超过四十字。分别使用气味、触感、声音与光线表达告别,不直接使用悲伤、不舍或想念等情绪词。用朴素、可朗读的语言,给每组留一个停顿。" }, + { title: "一瓶饮料的夏日故事", description: "广告策划 · 人群、主张与三个镜头", image: "/short-drama-styles/real-life.jpg", mode: "text", source: "Advertiser", prompt: "作为广告策划,为面向年轻通勤者的无糖气泡饮料设计十五秒广告。定义目标人群、核心场景和一句传播主张,再给出三个镜头的画面、时长、音效与字幕。不要虚构营养或功效数据,用下班后一口清爽的体验讲故事。" }, + { title: "独立咖啡店的视觉语言", description: "品牌创意 · 让配色和语气讲同一件事", image: "/short-drama-styles/retro-hong-kong.jpg", mode: "text", source: "Creative Branding Strategist", prompt: "作为品牌策略师,为社区里一家安静的独立咖啡店提出三种视觉方向。品牌重视慢节奏、邻里关系和手作。每种方向包含色彩、摄影氛围、文字语气、三条社交内容主题和差异化理由。不要模仿已有连锁品牌的标识。" }, + { title: "给剧本做一次镜头诊断", description: "创作复盘 · 从叙事到声音提出修改", image: "/short-drama-styles/black-white-noir.jpg", mode: "text", source: "Film Critic", prompt: "请以电影评论者的视角审阅我接下来提供的剧本或分镜,从叙事、人物、构图、运镜和声音五个维度提出反馈。每条意见都引用我提供的片段,并给出一个能实际拍摄的修改方案。还没有收到材料时,先向我索取,不要假装看过影片。" }, + { title: "旧城街景的时代考据", description: "历史场景 · 区分史实、推测和虚构", image: "/short-drama-styles/period-live-action.jpg", mode: "text", source: "Historian", prompt: "作为历史研究者,协助我设计民国早期城市街景的影视场景。先问清具体年份、地点和人物阶层,再列出服饰、交通、招牌、建筑和日常用语的考据清单。明确区分有来源的史实、合理推测与戏剧性虚构,不编造文献。" }, + { title: "月面独行", description: "远景到特写 · 太空探索的孤独感", image: "/short-drama-styles/space-opera.jpg", mode: "video", prompt: "月球背面的荒原,宇航员独自走向一座废弃天线。镜头从极远景缓慢推进,低角度阳光在月尘上拉出长影,步伐沉重,头盔反射星光,克制冷色,宽银幕电影构图,无字幕。" }, + { title: "手作星球早餐", description: "定格动画 · 柔软材质与小动作", image: "/short-drama-styles/clay-stop-motion.jpg", mode: "video", prompt: "黏土定格动画风格,一位小小宇航员在圆形厨房煎一颗星星形状的鸡蛋,鸡蛋轻轻跳起。手作指纹质感、暖奶油色、柔和侧光,固定中景,动作简短连续,可爱但不夸张。" }, + { title: "山水之间的一叶舟", description: "水墨留白 · 横向移镜与轻雾", image: "/short-drama-styles/ink-narrative.jpg", mode: "video", prompt: "中国水墨动画,一叶小舟缓缓穿过层叠远山,薄雾留白,舟上人抬手触碰落下的花瓣。镜头平稳横移,墨色有自然晕染,少量朱红点缀,画面安静,动作节奏舒缓。" }, + { title: "雨后的玻璃花房", description: "治愈场景 · 前景水滴与逆光", image: "/short-drama-styles/nature-healing.jpg", mode: "video", prompt: "清晨雨后的玻璃花房,镜头透过挂着水滴的叶片缓缓推向盛开的白花,阳光从侧后方进入,浅景深,真实植物纹理,空气中薄薄水汽,宁静自然,无人物和文字。" }, + { title: "都市少女角色转面", description: "角色概念 · 统一服装与形体比例", image: "/short-drama-styles/chinese-2d.jpg", mode: "image", prompt: "原创二维动画都市少女角色设定,短发、宽松工装夹克和帆布包,正面、侧面与背面三视图,服装细节和身高比例一致,干净线稿配平涂阴影,暖灰背景,不添加文字,不模仿已有动漫角色。" }, + { title: "梦境里的倒置房间", description: "超现实概念 · 重力反转与柔光", image: "/short-drama-styles/surreal-dream.jpg", mode: "image", prompt: "超现实电影场景概念图,一间漂浮在云海中的倒置卧室,床和书桌悬在天花板上,一扇门通向浅粉色天空。低饱和奶油与雾蓝色,柔和漫反射,精确透视,细腻材质,无人物,无文字。" }, + { title: "海边旅店的黄昏", description: "实拍场景 · 用道具留下人物痕迹", image: "/short-drama-styles/urban-live-action.jpg", mode: "image", prompt: "沿海小城旧旅店房间的电影置景概念图,窗外是黄昏海面,风吹动米白窗帘,桌上有两杯没有喝完的茶和一本翻开的书。暖橙与浅蓝对比,自然胶片颗粒,眼平中广角,没有人物和可读文字。" }, + { title: "鲜亮漫画封面", description: "平面构成 · 大色块与戏剧性透视", image: "/short-drama-styles/comic-pop.jpg", mode: "image", prompt: "原创漫画封面插画,一位骑单车的城市信使跃过雨后积水,戏剧性低角度透视,青蓝、珊瑚红与暖黄色大色块,半调网点,清晰黑色轮廓,背景简洁,为顶部标题留白但不生成文字。" }, + { title: "烘焙店的秘密帮手", description: "三维角色 · 柔和材质与亲切表情", image: "/short-drama-styles/three-d-cartoon.jpg", mode: "image", prompt: "原创三维动画角色概念图,一只穿着面粉围裙的小浣熊站在烘焙台前,鼻尖沾着面粉,手里举着刚烤好的面包。柔软毛发、温暖侧光、自然可爱的比例,橘棕色和奶油白配色,背景轻微虚化。" }, +]; + +export const inspirationSource = { + repository: "https://github.com/f/awesome-chatgpt-prompts", + revision: "f78a1c5136fa080155d928e0d7e2b4a41ddef03e", + license: "CC0-1.0", + notice: "8 条文本模板依据公开领域提示词翻译改编;其余为本项目编写。封面复用项目已有示意图,不代表实际生成结果。", +}; diff --git a/web/src/pages/create/creation-product.css b/web/src/pages/create/creation-product.css new file mode 100644 index 000000000..c2aabe7d4 --- /dev/null +++ b/web/src/pages/create/creation-product.css @@ -0,0 +1,112 @@ +/* Creation-specific geometry. Shared controls still use the workspace tokens. */ +.creation-mode-tabs { position: relative; isolation: isolate; display: grid; width: min(456px, 100%); margin: 0 auto 20px; padding: 4px; border-radius: 18px; background: var(--user-surface-muted); } +.creation-mode-indicator { position: absolute; z-index: -1; top: 4px; left: 4px; bottom: 4px; border-radius: 14px; background: var(--user-surface); box-shadow: 0 2px 8px color-mix(in srgb, var(--user-ink) 5%, transparent); } +.creation-mode-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 0; height: 44px; padding: 0 12px; border: 0; border-radius: 14px; background: transparent; color: var(--user-ink-muted); font-size: 14px; font-weight: 600; cursor: pointer; transition: color var(--user-motion), transform var(--user-motion); } +.creation-mode-button svg { width: 18px; height: 18px; stroke-width: 1.8; } +.creation-mode-button[data-mode="video"] svg { color: var(--user-accent); } +.creation-mode-button[data-mode="image"] svg { color: var(--product-accent-green); } +.creation-mode-button[data-mode="text"] svg { color: var(--product-accent-orange); } +.creation-mode-button[data-mode="agent"] svg { color: var(--product-accent-blue); } +.creation-mode-button[aria-pressed="true"], .creation-mode-button:hover { color: var(--user-ink); } +.creation-mode-button:active { transform: scale(.97); } +.creation-mode-button:focus-visible { outline: 2px solid var(--user-accent); outline-offset: 2px; } +.creation-mode-spark { width: 5px; height: 5px; border-radius: 50%; background: var(--user-accent); box-shadow: 0 0 0 3px var(--user-accent-soft); } +.creation-mode-tabs[data-active-mode="agent"] .creation-mode-indicator { background: linear-gradient(115deg, var(--user-accent-soft), color-mix(in srgb, var(--product-accent-blue) 12%, var(--user-surface))); } +.creation-mode-tabs[data-active-mode="video"] .creation-mode-indicator { background: linear-gradient(115deg, var(--user-surface), var(--user-accent-soft)); } +.creation-mode-tabs[data-active-mode="image"] .creation-mode-indicator { background: color-mix(in srgb, var(--product-accent-green) 9%, var(--user-surface)); } +.creation-mode-tabs[data-active-mode="text"] .creation-mode-indicator { background: color-mix(in srgb, var(--product-accent-orange) 9%, var(--user-surface)); } +.creation-agent-entry { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 16px 24px; padding: 28px; margin-bottom: 20px; border-radius: var(--user-radius-card); background: radial-gradient(ellipse at top right, var(--user-accent-soft), transparent 60%), var(--user-surface); animation: product-card-enter var(--product-enter) var(--product-ease) backwards; } +.creation-agent-orb { grid-row: span 2; } +.creation-agent-eyebrow { font-size: 10px; font-weight: 650; letter-spacing: .14em; color: var(--user-accent); } +.creation-agent-copy h2 { margin: 4px 0 8px; font-size: 21px; font-weight: 650; color: var(--user-ink); } +.creation-agent-copy p { margin: 0; max-width: 62ch; font-size: 13px; line-height: 1.8; color: var(--user-ink-muted); } +.creation-agent-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.creation-agent-error { grid-column: 1 / -1; color: var(--status-error); font-size: 12px; margin: 0; } +.creation-home .creation-empty-composer[hidden] { display: none; } +.creation-submit { margin-left: auto; flex: 0 0 auto; display: inline-flex; gap: 8px; } +.creation-submit-cost { display: inline-flex; align-items: center; gap: 4px; color: var(--user-ink-muted); font-size: 12px; padding-inline: 8px; } +.creation-submit-cost svg { width: 14px; height: 14px; } +@layer utilities { + .app-product-workspace .creation-home .creation-submit.ant-btn { height: auto; min-height: 40px; padding: 0; border: 0; background: transparent !important; box-shadow: none; border-radius: 999px; opacity: 1; } + .app-product-workspace .creation-home .creation-submit.ant-btn .creation-submit-action { position: relative; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; gap: 8px; width: auto; height: 40px; padding: 0 18px; border-radius: 999px; background: var(--user-action-gradient); color: var(--user-action-foreground); transition: background var(--user-motion), box-shadow var(--user-motion), transform var(--user-motion); } + .app-product-workspace .creation-home .creation-submit.ant-btn .creation-submit-action::after { content: ""; position: absolute; inset: 0; background: linear-gradient(105deg, transparent 28%, rgb(255 255 255 / .28) 48%, transparent 68%); transform: translateX(-120%); pointer-events: none; } + .app-product-workspace .creation-home .creation-submit.ant-btn .creation-submit-action span { font-size: 13px; font-weight: 600; } + .app-product-workspace .creation-home .creation-submit.ant-btn .creation-submit-action svg { width: 17px; height: 17px; color: currentColor; transition: transform var(--user-motion); } + .app-product-workspace .creation-home .creation-submit.ant-btn:disabled .creation-submit-action { background: var(--user-surface-hover); color: var(--user-ink-muted); } + .app-product-workspace .creation-home .creation-submit.ant-btn:not(:disabled):hover .creation-submit-action { box-shadow: 0 8px 22px color-mix(in srgb, var(--user-accent) 28%, transparent); transform: translateY(-1px); } + .app-product-workspace .creation-home .creation-submit.ant-btn:not(:disabled):hover .creation-submit-action::after { animation: creation-submit-sheen 640ms cubic-bezier(.16, 1, .3, 1); } + .app-product-workspace .creation-home .creation-submit.ant-btn:not(:disabled):hover .creation-submit-action svg { transform: translateY(-1px); } + .app-product-workspace .creation-home .creation-submit.ant-btn:not(:disabled):active .creation-submit-action { transform: scale(.97); } +} +.creation-inspiration-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; } +.creation-inspiration-filters button { display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; min-height: 36px; border: 0; border-radius: 999px; color: var(--user-ink-muted); background: transparent; font-size: 13px; transition: background var(--user-motion), color var(--user-motion); } +.creation-inspiration-filters button span { font-size: 11px; opacity: .65; font-variant-numeric: tabular-nums; } +.creation-inspiration-filters button:hover { background: var(--user-surface-hover); } +.creation-inspiration-filters button[aria-pressed="true"] { background: var(--user-ink); color: var(--user-surface); } +.creation-inspiration-filters button:focus-visible { outline: 2px solid var(--user-accent); outline-offset: 2px; } +.creation-inspiration-overlay { position: absolute; inset: auto 12px 12px; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 12px; border-radius: var(--user-radius-control); background: var(--user-surface); color: var(--user-ink); font-size: 12px; transform: translateY(6px); opacity: 0; transition: opacity var(--user-motion), transform var(--user-motion); } +.creation-inspiration-overlay svg { width: 14px; height: 14px; transform: rotate(45deg); } +.creation-featured-card:hover .creation-inspiration-overlay, .creation-featured-card:focus-visible .creation-inspiration-overlay { opacity: 1; transform: translateY(0); } +.creation-inspiration-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-top: 24px; color: var(--user-ink-muted); font-size: 12px; } +.creation-inspiration-footer details { max-width: 60ch; } +.creation-inspiration-footer summary { cursor: pointer; padding-block: 8px; } +.creation-inspiration-footer p { line-height: 1.7; margin: 8px 0; } +.creation-inspiration-footer a { color: var(--user-accent); } +@media (max-width: 620px) { + .creation-mode-button { padding-inline: 6px; font-size: 12px; gap: 6px; } + .creation-agent-entry { grid-template-columns: 1fr; padding: 20px; gap: 12px; } + .creation-agent-orb { display: none; } + .creation-agent-copy h2 { font-size: 18px; } + .creation-agent-actions > .ant-btn { width: 100%; } + .creation-inspiration-filters { gap: 4px; } + .creation-inspiration-filters button { padding-inline: 10px; } + .creation-inspiration-footer { align-items: flex-start; flex-direction: column; } + .creation-home .creation-chat-dock { flex-wrap: wrap; gap: 12px; } + .creation-home .creation-chat-controls { flex: 1 1 100%; width: 100%; } +} +.app-user-workspace .creation-voice-trigger.ant-btn { + width: 36px !important; + min-width: 36px !important; + height: 36px !important; + border-radius: 999px !important; + background: color-mix(in srgb, var(--product-accent-blue) 12%, var(--user-surface-muted)) !important; + color: var(--product-accent-blue) !important; + transition: background var(--user-motion), color var(--user-motion), transform var(--user-motion), box-shadow var(--user-motion) !important; +} +.app-user-workspace .creation-voice-trigger.ant-btn:hover, +.app-user-workspace .creation-voice-trigger.ant-btn:focus-visible { + background: color-mix(in srgb, var(--product-accent-blue) 20%, var(--user-surface)) !important; + color: var(--product-accent-blue) !important; + transform: translateY(-1px); + box-shadow: 0 8px 18px color-mix(in srgb, var(--product-accent-blue) 18%, transparent); +} +.app-user-workspace .creation-chat-control > svg:first-child, +.app-user-workspace .creation-model-picker .canvas-model-picker-trigger-icon { transition: transform var(--user-motion), color var(--user-motion); } +.app-user-workspace .creation-chat-control:hover > svg:first-child, +.app-user-workspace .creation-model-picker:hover .canvas-model-picker-trigger-icon { transform: scale(1.08); } +.app-user-workspace .creation-chat-control:has(.lucide-wand-sparkles) > svg:first-child { color: var(--product-accent-violet); } +.app-user-workspace .creation-chat-control:has(.lucide-clock-3) > svg:first-child { color: var(--product-accent-orange); } +.app-user-workspace .creation-chat-control:has(.lucide-sliders-horizontal) > svg:first-child { color: var(--product-accent-green); } +.app-user-workspace .creation-chat-control:has(.lucide-waves) > svg:first-child { color: var(--product-accent-cyan); } +.app-user-workspace .creation-chat-control:has(.lucide-brain) > svg:first-child { color: var(--product-accent-blue); } +.app-user-workspace .creation-chat-control.is-clear > svg:first-child { color: #c45b5b; } +.app-user-workspace .creation-chat-control.is-clear:hover { background: color-mix(in srgb, #c45b5b 12%, var(--user-surface)) !important; color: #c45b5b !important; } +.app-user-workspace .creation-model-picker .canvas-model-picker-trigger-icon { color: var(--product-accent-orange); } +.app-user-workspace .creation-launchpad .creation-reference-add-button:hover:not(:disabled) { + border-style: solid; + border-color: var(--user-accent); + background: color-mix(in srgb, var(--user-accent) 12%, var(--user-surface)); + color: var(--user-accent); + transform: translateY(-1px); +} +.app-user-workspace .creation-launchpad .creation-reference-add-button:active:not(:disabled) { transform: scale(.97); } +@keyframes creation-submit-sheen { to { transform: translateX(120%); } } +@media (prefers-reduced-motion: reduce) { + .creation-mode-button:active { transform: none; } + .creation-agent-entry { animation: none; } + .app-user-workspace .creation-chat-composer, + .app-user-workspace .creation-chat-control, + .app-user-workspace .creation-voice-trigger.ant-btn, + .app-user-workspace .creation-submit-action { transition: none !important; transform: none !important; } + .app-product-workspace .creation-home .creation-submit.ant-btn:not(:disabled):hover .creation-submit-action::after { animation: none; } +} diff --git a/web/src/pages/create/creation-workspace.tsx b/web/src/pages/create/creation-workspace.tsx index 8604870fe..7387c8e00 100644 --- a/web/src/pages/create/creation-workspace.tsx +++ b/web/src/pages/create/creation-workspace.tsx @@ -5,7 +5,7 @@ import { App, Button, Dropdown, Popover } from "antd"; import { AppDrawer } from "@/components/ui/product/app-drawer"; import { AppModal } from "@/components/ui/product/app-modal"; import { Tooltip } from "@/components/ui/base/tooltip"; -import { Reorder } from "motion/react"; +import { Reorder, motion, useReducedMotion } from "motion/react"; import { ArrowDown, ArrowUp, Brain, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; @@ -38,6 +38,8 @@ import { displayCreationPrompt, type CreationReference } from "./creation-refere import { creationAttachmentKind, creationMediaAspectRatio, removeCreationAttachment, type CreationAttachment, type CreationMode } from "./creation-assets"; import { conversationTimestamp, isImageAttachment, isVideoAttachment } from "./creation-conversations"; import { conversationTimeFormatter, countOptions, historyDayFormatter, messageTimeFormatter, modeLabels, qualityOptions, ratioOptions, resolutionOptions, shotScriptLabels, type CreationConversation, type CreationMessage, type CreationShotRailEntry, type CreationStatus } from "./creation-types"; +import "./creation-product.css"; +import { creationFeaturedWorks, inspirationSource } from "./creation-inspirations"; const CanvasPromptOptimizerDrawer = lazy(() => import("@/components/canvas/canvas-prompt-optimizer-drawer").then((module) => ({ default: module.CanvasPromptOptimizerDrawer }))); @@ -353,7 +355,6 @@ export function CreationComposer(props: ComposerProps) { const [trackState, setTrackState] = useState({ canScrollLeft: false, canScrollRight: false, isExpanded: true, isDragging: false }); const previousAttachmentCountRef = useRef(0); const interactionBusy = props.busy || props.referenceReplacementBusy; - const hasWork = interactionBusy || props.generationActive; const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy; const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const priceChannel = resolveModelChannel(props.config, props.model); @@ -482,6 +483,7 @@ export function CreationComposer(props: ComposerProps) { return undefined; }; const composer =
+ {props.variant === "thread" ?
: null}
{ const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} /> @@ -554,10 +556,10 @@ export function CreationComposer(props: ComposerProps) {
props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)} /> - {canOptimizePrompt ?
setPreviewUrl("")} /> @@ -623,22 +623,31 @@ export function CreationComposer(props: ComposerProps) { ); } -function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) { +export function CreationModeTabs({ mode, onModeChange, agentActive = false, onAgentSelect, orientation = "horizontal" }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void; agentActive?: boolean; onAgentSelect?: () => void; orientation?: "horizontal" | "vertical" }) { + const reducedMotion = useReducedMotion(); const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [ { mode: "video", icon: , label: "视频" }, { mode: "image", icon: , label: "图片" }, { mode: "text", icon: , label: "文本" }, ]; - return
+ const activeIndex = agentActive ? 3 : items.findIndex((item) => item.mode === mode); + const vertical = orientation === "vertical"; + return
+ {items.map((item) => ( - ))} + {onAgentSelect ? : null}
; } +function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) { + return ; +} + function GenerationSettingsMenu(props: ComposerProps) { const [open, setOpen] = useState(false); const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" }); @@ -726,29 +735,47 @@ const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapper ]; export function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) { - const [main, ...rest] = creationEmptySuggestions; - const MainIcon = main.icon; - return
- -
{rest.map((item) => { + return
+ {creationEmptySuggestions.map((item) => { const Icon = item.icon; - return ; - })}
+ })}
; } + +export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode: CreationMode, prompt: string) => void }) { + const [filter, setFilter] = useState<"all" | CreationMode>("all"); + const [limit, setLimit] = useState(12); + const filtered = creationFeaturedWorks.filter((item) => filter === "all" || item.mode === filter); + return
+
+
+

{creationFeaturedWorks.length} 个创意起点 · 点击填入提示词,不自动生成

+
+
+ {(["all", "video", "image", "text"] as const).map((value) => )} +
+
+ {filtered.slice(0, limit).map((item, index) => )} +
+
{limit < filtered.length ? : 已展示全部 {filtered.length} 个创意}
模板与封面来源

{inspirationSource.notice}

awesome-chatgpt-prompts · CC0
+
; +} + type CreationThinking = { title: string; hint: string; steps: string[]; activity: string }; function thinkingFor(mode: CreationMode, brandName: string): CreationThinking { diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 359f900ed..1590ffc4e 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1,7 +1,8 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { App, Spin } from "antd"; import { Tooltip } from "@/components/ui/base/tooltip"; -import { History } from "lucide-react"; +import { History, Sparkles, Maximize2 } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useNavigate } from "react-router"; import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal"; @@ -20,6 +21,7 @@ import { resolveModelChannel, selectableModelsByCapability, useConfigStore, useE import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; import { useAppearanceStore } from "@/stores/use-appearance-store"; +import { cn } from "@/lib/utils"; import { useUserStore } from "@/stores/use-user-store"; import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types"; import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; @@ -29,7 +31,8 @@ import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreation import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; import { modeLabels, type CreationConversation, type CreationMessage, type CreationMode, type CreationRetryContext, type CreationSettings, type CreationShotRailEntry, type CreationStatus } from "./creation-types"; import { attachCreationTaskContexts, completedCreationGenerationTask, conversationTimestamp, creationShotRail, creationVideoShotOrdinal, isImageAttachment, isVideoAttachment, materializeCreationTaskResults, newConversation, newMessage, reconcileCreationTaskMessages } from "./creation-conversations"; -import { CreationComposer, CreationEmptyBanner, CreationEmptySuggest, CreationHistoryDrawer, CreationMessageView, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace"; +import { CreationComposer, CreationEmptySuggest, CreationFeaturedWorks, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace"; +import { CreationAgentEntry } from "./creation-agent-entry"; const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal }))); const loadCreationRuntime = () => import("./creation-runtime"); @@ -54,6 +57,7 @@ function writeComposerPref(key: string, value: boolean) { } export default function CreatePage() { + const [agentMode, setAgentMode] = useState(false); const { message: toast, modal } = App.useApp(); const navigate = useNavigate(); const [openingCanvas, setOpeningCanvas] = useState(false); @@ -102,6 +106,9 @@ export default function CreatePage() { const abortRef = useRef(null); const composerFocusRef = useRef(null); const threadScrollRef = useRef(null); + const launchpadRef = useRef(null); + const reducedMotion = useReducedMotion(); + const [launchpadCondensed, setLaunchpadCondensed] = useState(false); const followLatestMessageRef = useRef(true); const taskSyncWarningRef = useRef(false); const activeGenerationTaskIdsRef = useRef(new Set()); @@ -149,6 +156,15 @@ export default function CreatePage() { }, [attachments]); const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]); const isEmpty = !activeConversation?.messages.length; + + // 空首页从顶部开始;有消息的对话由跟随消息逻辑管理滚动。 + useLayoutEffect(() => { + if (!hydrated || !isEmpty) return; + const frame = window.requestAnimationFrame(() => { + threadScrollRef.current?.scrollTo({ top: 0, behavior: "auto" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [activeId, hydrated, isEmpty]); const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]); const recoveryTaskKey = useMemo(() => pendingTaskIds.filter((id) => !activeGenerationTaskIdsRef.current.has(id)).join("|"), [pendingTaskIds]); const videoShots = useMemo(() => creationShotRail(activeConversation?.messages || []), [activeConversation]); @@ -305,13 +321,13 @@ export default function CreatePage() { }, []); useEffect(() => { - if (!followLatestMessageRef.current) return; + if (isEmpty || !followLatestMessageRef.current) return; const frame = window.requestAnimationFrame(() => { const container = threadScrollRef.current; if (container) container.scrollTop = container.scrollHeight; }); return () => window.cancelAnimationFrame(frame); - }, [activeConversation?.id, activeConversation?.messages]); + }, [activeConversation?.id, activeConversation?.messages, isEmpty]); const updateActive = useCallback((updater: (conversation: CreationConversation) => CreationConversation) => { const next = updateCreationConversationSnapshot(conversationsRef.current, activeId, updater); @@ -882,6 +898,15 @@ export default function CreatePage() { const container = threadScrollRef.current; if (!container) return; followLatestMessageRef.current = container.scrollHeight - container.scrollTop - container.clientHeight <= 160; + if (isEmpty) { + // Keep the original editor in flow: changing its height here feeds + // scroll anchoring back into this handler and causes flicker. + const bottom = launchpadRef.current?.getBoundingClientRect().bottom; + if (bottom === undefined) return; + const remaining = bottom - container.getBoundingClientRect().top; + if (remaining < -16) setLaunchpadCondensed(true); + else if (remaining > 24 || container.scrollTop <= 24) setLaunchpadCondensed(false); + } }; @@ -940,19 +965,36 @@ export default function CreatePage() {
+ + {launchpadCondensed && !agentMode ? + : null} +
- -
-
:
diff --git a/web/test/creation-inspirations.test.ts b/web/test/creation-inspirations.test.ts new file mode 100644 index 000000000..0e934362a --- /dev/null +++ b/web/test/creation-inspirations.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { creationFeaturedWorks, inspirationSource } from "../src/pages/create/creation-inspirations"; + +describe("curated creation inspirations", () => { + test("all templates have unique titles, usable prompts and local cover assets", () => { + expect(creationFeaturedWorks.length).toBe(22); + expect(new Set(creationFeaturedWorks.map((item) => item.title)).size).toBe(22); + for (const item of creationFeaturedWorks) { + expect(["image", "video", "text"]).toContain(item.mode); + expect(item.prompt.length).toBeGreaterThan(35); + expect(existsSync(resolve(import.meta.dir, "../public", item.image.slice(1)))).toBe(true); + } + }); + test("adapted prompts retain provenance and the data license", () => { + expect(creationFeaturedWorks.filter((item) => item.source).length).toBe(8); + expect(inspirationSource.license).toBe("CC0-1.0"); + expect(inspirationSource.revision).toMatch(/^[a-f0-9]{40}$/); + expect(inspirationSource.notice).toContain("不代表实际生成结果"); + }); +});