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

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

View File

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

View File

@@ -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 <section className="creation-agent-entry" aria-label="画布 Agent 模式">
<div className="creation-agent-orb" aria-hidden><FluidOrb size={88} /></div>
<div className="creation-agent-copy"><span className="creation-agent-eyebrow">CANVAS AGENT</span><h2>不止回答,把想法落到画布上</h2><p>使用画布里的同一个 Agent,结合素材、技能与创作上下文协作。生成与修改仍需经过原有审批和额度检查。</p></div>
<div className="creation-agent-actions"><Button type="primary" icon={<Sparkles />} loading={busy} disabled={!hydrated} onClick={() => void start()}>{created.current ? "重试同步并进入" : "新建画布,与 Agent 创作"}<ArrowUpRight /></Button><Button icon={<FolderOpen />} disabled={busy} onClick={() => navigate("/canvas?agent=1")}>在已有画布中继续</Button></div>
{error ? <p className="creation-agent-error" role="alert">{error}</p> : null}
</section>;
}

View File

@@ -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 条文本模板依据公开领域提示词翻译改编;其余为本项目编写。封面复用项目已有示意图,不代表实际生成结果。",
};

View File

@@ -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; }
}

View File

@@ -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 = <section className={`creation-chat-composer is-${props.variant}`}>
{props.variant === "thread" ? <div className="creation-composer-mode-row"><ModePicker mode={props.mode} onModeChange={props.onModeChange} /></div> : null}
<div className="creation-chat-writing-surface">
<div className="creation-chat-editor">
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter onFocus={props.onPromptFocus} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能;回车发送,Shift+回车换行" spellCheck disabled={interactionBusy} activeDropReferenceId={dropTargetReferenceId} onReferenceFilesDrop={(reference, files) => { 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) {
<footer className="creation-chat-dock">
<div className="creation-chat-controls">
<VoiceRecordingButton
className="creation-voice-trigger"
disabled={interactionBusy}
onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)}
/>
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
{canOptimizePrompt ? <Tooltip title="用 AI 优化提示词">
<button
type="button"
@@ -582,21 +584,19 @@ export function CreationComposer(props: ComposerProps) {
</div>
<Button
type="text"
className={`canvas-node-composer-submit ${showCost ? "has-cost" : ""}`}
className={`creation-submit ${showCost ? "has-cost" : ""}`}
disabled={interactionBusy || !canSubmit}
style={{
position: "relative",
color: !hasWork && !canSubmit ? "var(--creation-faint)" : "var(--creation-text)",
"--canvas-composer-submit-action": !hasWork && !canSubmit ? "var(--creation-surface-hover)" : "var(--creation-text)",
"--canvas-composer-submit-action-fg": !hasWork && !canSubmit ? "var(--creation-faint)" : "var(--creation-bg)",
color: "var(--user-ink)",
} as CSSProperties}
onClick={interactionBusy ? undefined : props.onSubmit}
aria-label={actionLabel}
title={actionLabel}
title={!canSubmit && !interactionBusy ? "输入创作想法后即可生成" : actionLabel}
>
{showWorkingGlow ? <WorkingGlow active color="var(--creation-text)" radius="999px" /> : null}
{showCost ? <span className="canvas-node-composer-submit-cost"><CreditSymbol /><span>{formattedCredits}</span></span> : null}
<span className="canvas-node-composer-submit-action" aria-hidden>{showWorkingSpinner ? <LoaderCircle className="size-3 animate-spin" /> : <ArrowUp className="size-3" />}</span>
{showCost ? <span className="creation-submit-cost"><CreditSymbol /><span>{formattedCredits}</span></span> : null}
<span className="creation-submit-action" aria-hidden>{showWorkingSpinner ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}<span>{showWorkingSpinner ? "生成中" : "开始创作"}</span></span>
</Button>
</footer>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => 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: <Film />, label: "视频" },
{ mode: "image", icon: <ImageIcon />, label: "图片" },
{ mode: "text", icon: <MessageSquareText />, label: "文本" },
];
return <div className="creation-mode-capsule" role="group" aria-label="生成类型">
const activeIndex = agentActive ? 3 : items.findIndex((item) => item.mode === mode);
const vertical = orientation === "vertical";
return <div className="creation-mode-tabs" role="group" aria-label="创作模式" data-active-mode={agentActive ? "agent" : mode} data-orientation={orientation} style={{ gridTemplateColumns: vertical ? "minmax(0, 1fr)" : `repeat(${onAgentSelect ? 4 : 3}, minmax(0, 1fr))` }}>
<motion.span aria-hidden className="creation-mode-indicator" initial={false} animate={vertical ? { y: `${activeIndex * 100}%` } : { x: `${activeIndex * 100}%` }} transition={{ duration: reducedMotion ? 0 : .3, ease: [.16, 1, .3, 1] }} style={vertical ? { width: "calc(100% - 8px)", height: `calc((100% - 8px) / ${onAgentSelect ? 4 : 3})`, bottom: "auto" } : { width: onAgentSelect ? "calc((100% - 8px) / 4)" : "calc((100% - 8px) / 3)" }} />
{items.map((item) => (
<button key={item.mode} type="button" className="creation-chat-control" aria-pressed={item.mode === mode} aria-label={`${item.label}生成`} onClick={() => onModeChange(item.mode)}>
<button key={item.mode} type="button" className="creation-mode-button" data-mode={item.mode} aria-pressed={!agentActive && item.mode === mode} aria-label={`${item.label}生成`} onClick={() => onModeChange(item.mode)}>
{item.icon}
<span>{item.label}</span>
</button>
))}
{onAgentSelect ? <button type="button" className="creation-mode-button" data-mode="agent" aria-pressed={agentActive} onClick={onAgentSelect}><Brain /><span>Agent</span><i className="creation-mode-spark" aria-hidden /></button> : null}
</div>;
}
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
return <CreationModeTabs mode={mode} onModeChange={onModeChange} orientation="vertical" />;
}
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 <div className="creation-empty-suggest">
<button type="button" className="creation-hero-prompt" onClick={() => { if (main.openLibrary) onOpenLibrary(); else onStartPrompt(main.mode, main.prompt); }}>
<span className="creation-hero-prompt-ray" aria-hidden="true" />
<span className={`library-icon-tile suggest-icon is-${main.mode}`}><MainIcon size={16} strokeWidth={2} /></span>
<span className="creation-hero-prompt-copy">
<strong>{main.title}</strong>
<span className="creation-hero-prompt-hint">{main.hint}</span>
<em className="creation-hero-prompt-text">“{main.prompt}”</em>
</span>
<Sparkles className="creation-hero-prompt-spark" aria-hidden="true" />
</button>
<div className="creation-empty-suggest-grid">{rest.map((item) => {
return <div className="creation-empty-suggest" aria-label="快捷创作入口">
{creationEmptySuggestions.map((item) => {
const Icon = item.icon;
return <button key={item.title} type="button" className="suggest-card" onClick={() => { if (item.openLibrary) onOpenLibrary(); else onStartPrompt(item.mode, item.prompt); }}>
<span className={`library-icon-tile suggest-icon is-${item.mode}`}><Icon size={16} strokeWidth={2} /></span>
<span className="suggest-copy"><strong>{item.title}</strong><span>{item.hint}</span></span>
const start = () => {
if (item.openLibrary) onOpenLibrary();
else onStartPrompt(item.mode, item.prompt);
};
return <button key={item.title} type="button" className="suggest-card" onClick={start}>
<span className={`library-icon-tile suggest-icon is-${item.mode}`}><Icon size={18} strokeWidth={2} /></span>
<span className="suggest-copy">
<strong>{item.title}</strong>
<span>{item.hint}</span>
</span>
</button>;
})}</div>
})}
</div>;
}
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 <section className="creation-featured-works" aria-labelledby="creation-featured-title">
<div className="creation-featured-heading">
<div><h2 id="creation-featured-title">精选灵感</h2></div>
<p>{creationFeaturedWorks.length} 个创意起点 · 点击填入提示词,不自动生成</p>
</div>
<div className="creation-inspiration-filters" role="group" aria-label="灵感类型">
{(["all", "video", "image", "text"] as const).map((value) => <button key={value} type="button" aria-pressed={filter === value} onClick={() => { setFilter(value); setLimit(12); }}>{value === "all" ? "全部灵感" : modeLabels[value]}<span>{creationFeaturedWorks.filter((item) => value === "all" || item.mode === value).length}</span></button>)}
</div>
<div className="creation-featured-layout">
{filtered.slice(0, limit).map((item, index) => <button key={item.title} type="button" className={`product-collection-card creation-featured-card ${index === 0 ? "is-featured-hero" : ""}`} onClick={() => onStartPrompt(item.mode, item.prompt)}>
<span className="creation-featured-media"><img src={item.image} alt="" loading="lazy" /><span className="creation-inspiration-overlay"><ArrowUp />使用这个创意</span></span>
<span className="creation-featured-copy"><strong>{item.title}</strong><span>{item.description}</span><em><Sparkles />{item.source ? "开源改编 · CC0" : "原创提示词"} · {modeLabels[item.mode]}</em></span>
</button>)}
</div>
<footer className="creation-inspiration-footer">{limit < filtered.length ? <Button onClick={() => setLimit((count) => count + 12)}>展开更多灵感<ChevronDown /></Button> : <span>已展示全部 {filtered.length} 个创意</span>}<details><summary>模板与封面来源</summary><p>{inspirationSource.notice}</p><a href={inspirationSource.repository} target="_blank" rel="noreferrer">awesome-chatgpt-prompts · CC0</a></details></footer>
</section>;
}
type CreationThinking = { title: string; hint: string; steps: string[]; activity: string };
function thinkingFor(mode: CreationMode, brandName: string): CreationThinking {

View File

@@ -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<AbortController | null>(null);
const composerFocusRef = useRef<HTMLTextAreaElement>(null);
const threadScrollRef = useRef<HTMLElement>(null);
const launchpadRef = useRef<HTMLElement>(null);
const reducedMotion = useReducedMotion();
const [launchpadCondensed, setLaunchpadCondensed] = useState(false);
const followLatestMessageRef = useRef(true);
const taskSyncWarningRef = useRef(false);
const activeGenerationTaskIdsRef = useRef(new Set<string>());
@@ -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() {
<div className="creation-top-actions">
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" aria-expanded={historyOpen} className="creation-top-action" onClick={() => setHistoryOpen(true)}><History /></button></Tooltip>
</div>
<AnimatePresence>
{launchpadCondensed && !agentMode ? <motion.div className="creation-floating-prompt" key="floating-prompt"
style={{ x: "-50%" }}
initial={{ opacity: 0, y: -12, scale: .97 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -8, scale: .98 }}
transition={reducedMotion ? { duration: 0 } : { type: "spring", stiffness: 360, damping: 32, mass: .8 }}>
<Sparkles aria-hidden="true" />
<input aria-label="快捷编辑提示词" placeholder="继续描述你的创作想法…" value={prompt} disabled={busy || referenceReplacementBusy} onChange={(event) => setPrompt(event.target.value)} />
<Tooltip title="展开完整创作区"><button type="button" aria-label="展开完整创作区" onClick={() => {
threadScrollRef.current?.scrollTo({ top: 0, behavior: reducedMotion ? "auto" : "smooth" });
composerFocusRef.current?.focus({ preventScroll: true });
}}><Maximize2 /></button></Tooltip>
</motion.div> : null}
</AnimatePresence>
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-empty-workspace creation-scrollbar">
<CreationEmptyBanner />
<div className="creation-chat-intro">
<span className="creation-intro-signal" aria-hidden="true" />
<p>{brandName} · AI 影视创作工作台</p>
<h1>把脑海里的画面,<span className="creation-intro-emphasis"><span className="is-pink">交给{brandName}</span><span className="is-blue">拍出来</span></span></h1>
<div className="creation-home-heading">
<h1>和{brandName}聊聊创作想法</h1>
<p>从一个画面、一个角色或一句话开始,继续你的创作。</p>
</div>
<div className="creation-empty-composer">
<CreationComposer {...composerProps} variant="empty" />
</div>
<CreationEmptySuggest
onStartPrompt={(nextMode, prompt) => { selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
onOpenLibrary={() => { selectMode("image"); setLibraryOpen(true); }}
<section ref={launchpadRef} className="creation-launchpad" aria-label="开始创作">
<div className={cn("creation-composer-stage is-home-mode", agentMode && "is-agent-mode")}>
<CreationModeTabs mode={mode} agentActive={agentMode} onAgentSelect={() => setAgentMode(true)} onModeChange={(next) => { setAgentMode(false); selectMode(next); }} />
{agentMode ? <CreationAgentEntry /> : <div className="creation-empty-composer"><CreationComposer {...composerProps} variant="empty" /></div>}
</div>
<CreationEmptySuggest
onStartPrompt={(nextMode, prompt) => { setAgentMode(false); selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
onOpenLibrary={() => { setAgentMode(false); selectMode("image"); setLibraryOpen(true); }}
/>
</section>
<CreationFeaturedWorks
onStartPrompt={(nextMode, prompt) => { setAgentMode(false); selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
/>
</main>
</> : <div className="creation-thread-workbench">

View File

@@ -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("不代表实际生成结果");
});
});