feat(create): 创作首页 - 收窄输入区并整理模式切换与灵感入口
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
|
||||
import { Check, ChevronDown, Coins } from "lucide-react";
|
||||
import { Check, ChevronDown, ChevronLeft, Coins } from "lucide-react";
|
||||
import { Popover } from "antd";
|
||||
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
@@ -8,7 +8,7 @@ import { formatPriceRange, modelQuoteRequest, normalizeTierResolution, priceTier
|
||||
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { ModelLogo } from "@/components/model-logo";
|
||||
import { quoteLogicalModel, type LogicalModelQuote } from "@/services/api/logical-models";
|
||||
@@ -49,9 +49,11 @@ export function ModelPicker({
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const pickerId = useId();
|
||||
// 双保险:即使 store merge 写出非法 theme,这里也兜底到 dark,避免 "reading 'node'" 崩溃
|
||||
const rawTheme = useThemeStore((state) => state.theme);
|
||||
const rawTheme = useActiveTheme();
|
||||
const theme = (canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark) as CanvasTheme;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeGroupKey, setActiveGroupKey] = useState<string | null>(null);
|
||||
const [previewedModel, setPreviewedModel] = useState("");
|
||||
const [triggerWidth, setTriggerWidth] = useState<number | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -132,6 +134,10 @@ export function ModelPicker({
|
||||
const setPickerOpen = (nextOpen: boolean) => {
|
||||
if (nextOpen && !options.length) onMissingConfig?.();
|
||||
if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId }));
|
||||
if (nextOpen) {
|
||||
setPreviewedModel(current || options[0] || "");
|
||||
setActiveGroupKey(null);
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
};
|
||||
const focusMenuOption = (last = false) => {
|
||||
@@ -166,7 +172,12 @@ export function ModelPicker({
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-canvas-no-zoom
|
||||
className={cn("canvas-model-picker-menu max-w-[calc(100vw-24px)]", creationVariant ? "creation-model-picker-menu w-[360px]" : "w-[var(--panel-width-compact)]")}
|
||||
className={cn(
|
||||
"canvas-model-picker-menu max-w-[calc(100vw-24px)]",
|
||||
creationVariant
|
||||
? cn("creation-model-picker-menu", activeGroupKey === null ? "is-brand-list" : "is-model-list")
|
||||
: "w-[var(--panel-width-compact)]",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
background: theme.node.panel,
|
||||
@@ -180,22 +191,35 @@ export function ModelPicker({
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{creationVariant ? (
|
||||
<div className="creation-model-picker-heading">
|
||||
<span>选择模型</span>
|
||||
{current ? <strong>{pickerModelDisplayName(config, current, showConfiguredModelName)}</strong> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{optionGroups.length ? (
|
||||
optionGroups.map((group) => (
|
||||
<section key={group.key} className="canvas-model-picker-group min-w-0 overflow-hidden">
|
||||
<div className="canvas-model-picker-group-label" style={{ color: theme.node.muted }}>
|
||||
<span className="truncate">{group.label}</span>
|
||||
{group.scope ? (
|
||||
<span className="shrink-0" style={{ color: theme.node.muted }}>
|
||||
{group.scope}
|
||||
</span>
|
||||
) : null}
|
||||
activeGroupKey === null ? (
|
||||
<div className="canvas-model-picker-brands" aria-label="选择模型品牌">
|
||||
{optionGroups.map((group) => {
|
||||
const groupCurrent = group.models.find((item) => item.models.includes(current));
|
||||
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
|
||||
return <button key={group.key} type="button" className="canvas-model-picker-brand" onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); }}>
|
||||
<span className="canvas-model-picker-brand-icon"><ModelIcon config={config} model={firstModel} /></span>
|
||||
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个模型{group.scope ? ` · ${group.scope}` : ""}</small></span>
|
||||
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
) : <div className="canvas-model-picker-two-pane">
|
||||
<div className="canvas-model-picker-brand-rail" aria-label="模型品牌">
|
||||
{optionGroups.map((group) => {
|
||||
const groupCurrent = group.models.find((item) => item.models.includes(current));
|
||||
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
|
||||
return <button key={group.key} type="button" className={cn("canvas-model-picker-brand", activeGroupKey === group.key && "is-active")} aria-pressed={activeGroupKey === group.key} onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); }}>
|
||||
<span className="canvas-model-picker-brand-icon"><ModelIcon config={config} model={firstModel} /></span>
|
||||
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个模型{group.scope ? ` · ${group.scope}` : ""}</small></span>
|
||||
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
{optionGroups.filter((group) => group.key === activeGroupKey).map((group) => <section key={group.key} className="canvas-model-picker-group canvas-model-picker-model-pane min-w-0 overflow-hidden">
|
||||
<div className="canvas-model-picker-secondary-head">
|
||||
<button type="button" className="canvas-model-picker-back" onClick={() => setActiveGroupKey(null)} aria-label="返回品牌列表"><ChevronLeft /></button>
|
||||
<span><strong>{group.label}</strong>{group.scope ? <small>{group.scope}</small> : null}</span>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-1">
|
||||
{group.models.map((modelGroup) => {
|
||||
@@ -212,8 +236,10 @@ export function ModelPicker({
|
||||
aria-disabled={Boolean(disabledReason)}
|
||||
disabled={Boolean(disabledReason)}
|
||||
title={disabledReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)}
|
||||
className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45"
|
||||
className={cn("canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45", previewedModel === displayModel && "is-previewed")}
|
||||
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
|
||||
onMouseEnter={() => setPreviewedModel(displayModel)}
|
||||
onFocus={() => setPreviewedModel(displayModel)}
|
||||
onClick={() => {
|
||||
if (!model) return;
|
||||
onChange(model);
|
||||
@@ -230,14 +256,15 @@ export function ModelPicker({
|
||||
showConfiguredModelName={showConfiguredModelName}
|
||||
showPrice={showOptionPrices && creditsEnabled}
|
||||
disabledReason={disabledReason}
|
||||
showDescription={selected || previewedModel === displayModel}
|
||||
/>
|
||||
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
</section>)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="canvas-model-picker-empty" style={{ color: theme.node.muted }}>
|
||||
{emptyModelLabel(config, capability)}
|
||||
@@ -300,6 +327,7 @@ function ModelLabel({
|
||||
showConfiguredModelName,
|
||||
showPrice,
|
||||
disabledReason,
|
||||
showDescription,
|
||||
}: {
|
||||
config: AiConfig;
|
||||
model: string;
|
||||
@@ -309,6 +337,7 @@ function ModelLabel({
|
||||
showConfiguredModelName: boolean;
|
||||
showPrice: boolean;
|
||||
disabledReason?: string;
|
||||
showDescription: boolean;
|
||||
}) {
|
||||
const meta = modelMenuMeta(model, capability);
|
||||
const channel = resolveModelChannel(config, model);
|
||||
@@ -326,7 +355,7 @@ function ModelLabel({
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{pickerModelDisplayName(config, model, showConfiguredModelName)}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={capabilitySummary}>
|
||||
<span className={cn("canvas-model-picker-description mt-1 block truncate text-[var(--fs-tiny)]", showDescription && "is-visible")} style={{ color: theme.node.muted }} title={capabilitySummary}>
|
||||
{capabilitySummary}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
53
web/src/pages/create/creation-agent-entry.tsx
Normal file
53
web/src/pages/create/creation-agent-entry.tsx
Normal 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>;
|
||||
}
|
||||
66
web/src/pages/create/creation-inspirations.ts
Normal file
66
web/src/pages/create/creation-inspirations.ts
Normal 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 条文本模板依据公开领域提示词翻译改编;其余为本项目编写。封面复用项目已有示意图,不代表实际生成结果。",
|
||||
};
|
||||
112
web/src/pages/create/creation-product.css
Normal file
112
web/src/pages/create/creation-product.css
Normal 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; }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
22
web/test/creation-inspirations.test.ts
Normal file
22
web/test/creation-inspirations.test.ts
Normal 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("不代表实际生成结果");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user