mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
3117 lines
108 KiB
TypeScript
3117 lines
108 KiB
TypeScript
// Lovart-style centered hero for the entry Home view.
|
||
//
|
||
// The prompt textarea is the canonical creation surface: the user
|
||
// either types freely or selects a type below to reveal matching
|
||
// starters, then presses Run / Enter to spawn a project. The hero is
|
||
// kept dependency-free (no plugin list / project list) so it can be
|
||
// composed with the recent-projects strip and plugins section
|
||
// without owning their data lifecycles.
|
||
|
||
import {
|
||
forwardRef,
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
import type {
|
||
CSSProperties,
|
||
ClipboardEvent as ReactClipboardEvent,
|
||
DragEvent as ReactDragEvent,
|
||
ForwardedRef,
|
||
KeyboardEvent as ReactKeyboardEvent,
|
||
ReactNode,
|
||
RefObject,
|
||
} from 'react';
|
||
import type {
|
||
ConnectorDetail,
|
||
InputFieldSpec,
|
||
InstalledPluginRecord,
|
||
McpServerConfig,
|
||
} from '@open-design/contracts';
|
||
import { useI18n, useT } from '../i18n';
|
||
import {
|
||
localizePluginBriefTemplate,
|
||
localizePluginDisplayValue,
|
||
localizePluginInputLabel,
|
||
localizePluginInputValues,
|
||
localizePluginPlaceholder,
|
||
} from '../i18n/plugin-content';
|
||
import type { SkillSummary } from '../types';
|
||
import { isImeComposing } from '../utils/imeComposing';
|
||
import { Icon, type IconName } from './Icon';
|
||
import { PluginInputsForm } from './PluginInputsForm';
|
||
import { useAnalytics } from '../analytics/provider';
|
||
import { trackHomeChatComposerClick } from '../analytics/events';
|
||
import {
|
||
chipsForGroup,
|
||
type ChipGroup,
|
||
type HomeHeroChip,
|
||
} from './home-hero/chips';
|
||
import {
|
||
buildInlineMentionParts,
|
||
inlineMentionToken,
|
||
type InlineMentionEntity,
|
||
} from '../utils/inlineMentions';
|
||
import type { Locale } from '../i18n/types';
|
||
import {
|
||
localizeSkillDescription,
|
||
localizeSkillName,
|
||
} from '../i18n/content';
|
||
import { PreviewSurface } from './plugins-home/cards/PreviewSurface';
|
||
import { curatedPluginPriorityForChip } from './plugins-home/curatedPriority';
|
||
import { inferPluginPreview } from './plugins-home/preview';
|
||
|
||
export interface HomeHeroSubmitHandler {
|
||
(): void;
|
||
}
|
||
|
||
interface Props {
|
||
prompt: string;
|
||
onPromptChange: (value: string) => void;
|
||
onSubmit: HomeHeroSubmitHandler;
|
||
activePluginTitle: string | null;
|
||
activePluginRecord?: InstalledPluginRecord | null;
|
||
activeChipId: string | null;
|
||
onClearActivePlugin: () => void;
|
||
onClearActiveChip?: () => void;
|
||
activeSkillId?: string | null;
|
||
activeSkillTitle?: string | null;
|
||
onClearActiveSkill?: () => void;
|
||
selectedPluginContexts?: InstalledPluginRecord[];
|
||
onRemovePluginContext?: (pluginId: string) => void;
|
||
onOpenPluginDetails?: (record: InstalledPluginRecord) => void;
|
||
pluginInputFields?: InputFieldSpec[];
|
||
pluginInputValues?: Record<string, unknown>;
|
||
pluginInputTemplate?: string | null;
|
||
onPluginInputValuesChange?: (values: Record<string, unknown>) => void;
|
||
onPluginInputValidityChange?: (valid: boolean) => void;
|
||
inlineEditableInputNames?: string[];
|
||
showPluginInputsForm?: boolean;
|
||
footerInputNames?: string[];
|
||
designSystemOptions?: HomeHeroDesignSystemOption[];
|
||
stagedFiles?: File[];
|
||
onAddFiles?: (files: File[]) => void;
|
||
onRemoveFile?: (index: number) => void;
|
||
pluginOptions: InstalledPluginRecord[];
|
||
pluginsLoading: boolean;
|
||
skillOptions?: SkillSummary[];
|
||
skillsLoading?: boolean;
|
||
mcpOptions?: McpServerConfig[];
|
||
mcpLoading?: boolean;
|
||
connectorOptions?: ConnectorDetail[];
|
||
pendingPluginId: string | null;
|
||
pendingChipId: string | null;
|
||
submitDisabled?: boolean;
|
||
onPickPlugin: (record: InstalledPluginRecord, nextPrompt: string | null) => void;
|
||
onPickExamplePlugin?: (record: InstalledPluginRecord, chipId: string, promptText: string) => void;
|
||
onPickSkill?: (skill: SkillSummary, nextPrompt: string | null) => void;
|
||
onPickMcp?: (server: McpServerConfig, nextPrompt: string) => void;
|
||
onPickConnector?: (connector: ConnectorDetail, nextPrompt: string) => void;
|
||
onPickChip: (chip: HomeHeroChip) => void;
|
||
contextItemCount: number;
|
||
error: string | null;
|
||
showActivePluginChip?: boolean;
|
||
}
|
||
|
||
interface HomeHeroDesignSystemOption {
|
||
id: string;
|
||
title: string;
|
||
isDefault?: boolean;
|
||
auto?: boolean;
|
||
group?: string;
|
||
category?: string;
|
||
summary?: string;
|
||
swatches?: string[];
|
||
logoUrl?: string;
|
||
}
|
||
|
||
type HomeMentionTab = 'all' | 'plugins' | 'skills' | 'mcp' | 'connectors';
|
||
|
||
interface HomeMentionOption {
|
||
id: string;
|
||
icon: IconName;
|
||
title: string;
|
||
description: string;
|
||
meta: string;
|
||
pluginRecord?: InstalledPluginRecord;
|
||
disabled?: boolean;
|
||
onPick: () => void;
|
||
}
|
||
|
||
interface HomeMentionSection {
|
||
id: Exclude<HomeMentionTab, 'all'>;
|
||
label: string;
|
||
options: HomeMentionOption[];
|
||
}
|
||
|
||
interface SelectedPromptExample {
|
||
label: string;
|
||
promptText: string;
|
||
}
|
||
|
||
export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero(
|
||
{
|
||
prompt,
|
||
onPromptChange,
|
||
onSubmit,
|
||
activePluginTitle,
|
||
activePluginRecord = null,
|
||
activeSkillId = null,
|
||
activeSkillTitle = null,
|
||
activeChipId,
|
||
onClearActivePlugin,
|
||
onClearActiveChip = onClearActivePlugin,
|
||
onClearActiveSkill = () => undefined,
|
||
selectedPluginContexts = [],
|
||
onRemovePluginContext = () => undefined,
|
||
onOpenPluginDetails = () => undefined,
|
||
pluginInputFields = [],
|
||
pluginInputValues = {},
|
||
pluginInputTemplate = null,
|
||
onPluginInputValuesChange = () => undefined,
|
||
onPluginInputValidityChange = () => undefined,
|
||
inlineEditableInputNames = [],
|
||
showPluginInputsForm = true,
|
||
footerInputNames = [],
|
||
designSystemOptions = [],
|
||
stagedFiles = [],
|
||
onAddFiles = () => undefined,
|
||
onRemoveFile = () => undefined,
|
||
pluginOptions,
|
||
pluginsLoading,
|
||
skillOptions = [],
|
||
skillsLoading = false,
|
||
mcpOptions = [],
|
||
mcpLoading = false,
|
||
connectorOptions = [],
|
||
pendingPluginId,
|
||
pendingChipId,
|
||
submitDisabled = false,
|
||
onPickPlugin,
|
||
onPickExamplePlugin = () => undefined,
|
||
onPickSkill = () => undefined,
|
||
onPickMcp = () => undefined,
|
||
onPickConnector = () => undefined,
|
||
onPickChip,
|
||
contextItemCount,
|
||
error,
|
||
showActivePluginChip = true,
|
||
},
|
||
ref,
|
||
) {
|
||
const { locale, t } = useI18n();
|
||
const analytics = useAnalytics();
|
||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||
const [mentionTab, setMentionTab] = useState<HomeMentionTab>('all');
|
||
const [hoveredPlugin, setHoveredPlugin] = useState<InstalledPluginRecord | null>(null);
|
||
const [promptScrollTop, setPromptScrollTop] = useState(0);
|
||
const [dragActive, setDragActive] = useState(false);
|
||
const [openInlineInputName, setOpenInlineInputName] = useState<string | null>(null);
|
||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||
const [selectedPromptExample, setSelectedPromptExample] = useState<SelectedPromptExample | null>(null);
|
||
const composingRef = useRef(false);
|
||
const inputElementRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const shortcutsMenuRef = useRef<HTMLDivElement>(null);
|
||
const canSubmit = (prompt.trim().length > 0 || stagedFiles.length > 0) && !submitDisabled;
|
||
const placeholder = activePluginTitle || activeSkillTitle
|
||
? t('homeHero.placeholderActive')
|
||
: t('homeHero.placeholder');
|
||
const mention = getContextMention(prompt);
|
||
const mentionActive = Boolean(mention);
|
||
const mentionQuery = mention?.query ?? '';
|
||
const pluginMatches = useMemo(
|
||
() =>
|
||
mentionActive
|
||
? pluginOptions.filter((plugin) => pluginMatchesQuery(plugin, mentionQuery)).slice(0, 6)
|
||
: [],
|
||
[mentionActive, mentionQuery, pluginOptions],
|
||
);
|
||
const skillMatches = useMemo(
|
||
() =>
|
||
mentionActive
|
||
? skillOptions.filter((skill) => skillMatchesQuery(skill, mentionQuery)).slice(0, 6)
|
||
: [],
|
||
[mentionActive, mentionQuery, skillOptions],
|
||
);
|
||
const mcpMatches = useMemo(
|
||
() =>
|
||
mentionActive
|
||
? mcpOptions.filter((server) => mcpServerMatchesQuery(server, mentionQuery)).slice(0, 6)
|
||
: [],
|
||
[mcpOptions, mentionActive, mentionQuery],
|
||
);
|
||
const connectorMatches = useMemo(
|
||
() =>
|
||
mentionActive
|
||
? connectorOptions.filter((connector) => connectorMatchesQuery(connector, mentionQuery)).slice(0, 6)
|
||
: [],
|
||
[connectorOptions, mentionActive, mentionQuery],
|
||
);
|
||
const pickerOpen = mentionActive;
|
||
const tabs: Array<{ id: HomeMentionTab; label: string; count: number }> = [
|
||
{ id: 'all', label: t('common.all'), count: pluginMatches.length + skillMatches.length + mcpMatches.length + connectorMatches.length },
|
||
{ id: 'plugins', label: t('entry.navPlugins'), count: pluginMatches.length },
|
||
{ id: 'skills', label: t('homeHero.skills'), count: skillMatches.length },
|
||
{ id: 'mcp', label: 'MCP', count: mcpMatches.length },
|
||
{ id: 'connectors', label: t('entry.tabConnectors'), count: connectorMatches.length },
|
||
];
|
||
const showPlugins = mentionTab === 'all' || mentionTab === 'plugins';
|
||
const showSkills = mentionTab === 'all' || mentionTab === 'skills';
|
||
const showMcp = mentionTab === 'all' || mentionTab === 'mcp';
|
||
const showConnectors = mentionTab === 'all' || mentionTab === 'connectors';
|
||
const visibleSections: HomeMentionSection[] = [
|
||
showPlugins
|
||
? {
|
||
id: 'plugins',
|
||
label: t('entry.navPlugins'),
|
||
options: pluginMatches.map((plugin) => ({
|
||
id: `plugin-${plugin.id}`,
|
||
icon: 'sparkles',
|
||
title: plugin.title,
|
||
description: plugin.manifest?.description ?? plugin.id,
|
||
meta: pendingPluginId === plugin.id ? t('homeHero.applying') : getPluginSourceLabel(plugin, t),
|
||
pluginRecord: plugin,
|
||
disabled: pendingPluginId !== null,
|
||
onPick: () => pickPlugin(plugin),
|
||
})),
|
||
}
|
||
: null,
|
||
showSkills
|
||
? {
|
||
id: 'skills',
|
||
label: t('homeHero.skills'),
|
||
options: skillMatches.map((skill) => ({
|
||
id: `skill-${skill.id}`,
|
||
icon: skill.id === activeSkillId ? 'check' : 'file',
|
||
title: localizeSkillName(locale, skill),
|
||
description: localizeSkillDescription(locale, skill) || skill.id,
|
||
meta: skill.id === activeSkillId ? t('common.active') : skill.mode,
|
||
onPick: () => pickSkill(skill),
|
||
})),
|
||
}
|
||
: null,
|
||
showMcp
|
||
? {
|
||
id: 'mcp',
|
||
label: 'MCP',
|
||
options: mcpMatches.map((server) => ({
|
||
id: `mcp-${server.id}`,
|
||
icon: 'link',
|
||
title: server.label || server.id,
|
||
description: server.url || server.command || server.id,
|
||
meta: server.transport,
|
||
onPick: () => pickMcp(server),
|
||
})),
|
||
}
|
||
: null,
|
||
showConnectors
|
||
? {
|
||
id: 'connectors',
|
||
label: t('entry.tabConnectors'),
|
||
options: connectorMatches.map((connector) => ({
|
||
id: `connector-${connector.id}`,
|
||
icon: 'link',
|
||
title: connector.name,
|
||
description: connector.description || connector.provider || connector.id,
|
||
meta: connector.accountLabel ?? connector.provider,
|
||
onPick: () => pickConnector(connector),
|
||
})),
|
||
}
|
||
: null,
|
||
].filter((section): section is HomeMentionSection => Boolean(section?.options.length));
|
||
const visiblePickerOptions = visibleSections.flatMap((section) => section.options);
|
||
const visibleLoading =
|
||
(mentionTab === 'all' && (pluginsLoading || skillsLoading || mcpLoading)) ||
|
||
(mentionTab === 'plugins' && pluginsLoading) ||
|
||
(mentionTab === 'skills' && skillsLoading) ||
|
||
(mentionTab === 'mcp' && mcpLoading);
|
||
const promptMentionEntities = useMemo(
|
||
() =>
|
||
buildHomeMentionEntities({
|
||
activePluginRecord,
|
||
activeSkillId,
|
||
activeSkillTitle,
|
||
mcpOptions,
|
||
pluginOptions,
|
||
connectorOptions,
|
||
selectedPluginContexts,
|
||
skillOptions,
|
||
t,
|
||
}),
|
||
[
|
||
activePluginRecord,
|
||
activeSkillId,
|
||
activeSkillTitle,
|
||
mcpOptions,
|
||
pluginOptions,
|
||
connectorOptions,
|
||
selectedPluginContexts,
|
||
skillOptions,
|
||
t,
|
||
],
|
||
);
|
||
const pluginByMentionId = useMemo(() => {
|
||
const map = new Map<string, InstalledPluginRecord>();
|
||
for (const plugin of pluginOptions) map.set(plugin.id, plugin);
|
||
for (const plugin of selectedPluginContexts) map.set(plugin.id, plugin);
|
||
if (activePluginRecord) map.set(activePluginRecord.id, activePluginRecord);
|
||
return map;
|
||
}, [activePluginRecord, pluginOptions, selectedPluginContexts]);
|
||
const localizedPluginInputTemplate = useMemo(
|
||
() => localizePluginBriefTemplate(locale, pluginInputTemplate),
|
||
[locale, pluginInputTemplate],
|
||
);
|
||
const localizedPluginInputValues = useMemo(
|
||
() => localizePluginInputValues(locale, pluginInputValues, pluginInputFields),
|
||
[locale, pluginInputFields, pluginInputValues],
|
||
);
|
||
const promptOverlayParts = useMemo(
|
||
() => buildPromptOverlayParts(
|
||
localizedPluginInputTemplate,
|
||
localizedPluginInputValues,
|
||
prompt,
|
||
promptMentionEntities,
|
||
),
|
||
[localizedPluginInputTemplate, localizedPluginInputValues, prompt, promptMentionEntities],
|
||
);
|
||
const promptMentionRanges = useMemo(
|
||
() => buildPromptMentionRanges(promptOverlayParts),
|
||
[promptOverlayParts],
|
||
);
|
||
const fieldByName = useMemo(
|
||
() => new Map(pluginInputFields.map((field) => [field.name, field])),
|
||
[pluginInputFields],
|
||
);
|
||
const editableInputNames = useMemo(
|
||
() => new Set(inlineEditableInputNames),
|
||
[inlineEditableInputNames],
|
||
);
|
||
const footerInputNameSet = useMemo(
|
||
() => new Set(footerInputNames),
|
||
[footerInputNames],
|
||
);
|
||
const openInlineInputField = openInlineInputName
|
||
? fieldByName.get(openInlineInputName) ?? null
|
||
: null;
|
||
// Filter out inputs whose values are already shown inline in the
|
||
// prompt template, plus fields promoted into the compact footer.
|
||
const templateFieldKeys = useMemo(() => {
|
||
if (!pluginInputTemplate) return new Set<string>();
|
||
const keys = new Set<string>();
|
||
INPUT_PLACEHOLDER_PATTERN.lastIndex = 0;
|
||
let match: RegExpExecArray | null;
|
||
while ((match = INPUT_PLACEHOLDER_PATTERN.exec(pluginInputTemplate)) !== null) {
|
||
if (match[1]) keys.add(match[1]);
|
||
}
|
||
return keys;
|
||
}, [pluginInputTemplate]);
|
||
const footerInputFields = useMemo(
|
||
() => footerInputNames
|
||
.map((name) => fieldByName.get(name))
|
||
.filter((field): field is InputFieldSpec => Boolean(field)),
|
||
[fieldByName, footerInputNames],
|
||
);
|
||
const remainingInputFields = useMemo(
|
||
() => pluginInputFields.filter(
|
||
(field) => !templateFieldKeys.has(field.name) && !footerInputNameSet.has(field.name),
|
||
),
|
||
[footerInputNameSet, pluginInputFields, templateFieldKeys],
|
||
);
|
||
const activeCreateChip = useMemo(
|
||
() => activeChipId
|
||
? chipsForGroup('create').find((chip) => chip.id === activeChipId) ?? null
|
||
: null,
|
||
[activeChipId],
|
||
);
|
||
const activeExamplePlugins = useMemo(
|
||
() =>
|
||
activeChipId
|
||
? homeHeroExamplePluginsForChip(activeChipId, pluginOptions, locale)
|
||
: [],
|
||
[activeChipId, locale, pluginOptions],
|
||
);
|
||
const activePromptExamples = useMemo(
|
||
() => activeChipId && activeExamplePlugins.length === 0
|
||
? homeHeroChipPromptExamples(activeChipId, locale)
|
||
: [],
|
||
[activeChipId, activeExamplePlugins.length, locale],
|
||
);
|
||
const authoringLayoutActive =
|
||
activeChipId === 'create-plugin' || pendingChipId === 'create-plugin';
|
||
const promptMaxHeight = authoringLayoutActive
|
||
? HOME_HERO_AUTHORING_PROMPT_MAX_HEIGHT
|
||
: HOME_HERO_PROMPT_MAX_HEIGHT;
|
||
const inputCardStyle = {
|
||
'--home-hero-prompt-max-height': `${promptMaxHeight}px`,
|
||
} as CSSProperties;
|
||
|
||
useEffect(() => {
|
||
if (selectedIndex >= visiblePickerOptions.length) setSelectedIndex(0);
|
||
}, [selectedIndex, visiblePickerOptions.length]);
|
||
|
||
useEffect(() => {
|
||
if (!pickerOpen) setHoveredPlugin(null);
|
||
}, [pickerOpen]);
|
||
|
||
useEffect(() => {
|
||
setOpenInlineInputName(null);
|
||
setSelectedPromptExample(null);
|
||
}, [activeChipId]);
|
||
|
||
useEffect(() => {
|
||
if (!shortcutsOpen) return;
|
||
const closeOnPointer = (event: PointerEvent) => {
|
||
const target = event.target;
|
||
if (target instanceof Node && shortcutsMenuRef.current?.contains(target)) return;
|
||
setShortcutsOpen(false);
|
||
};
|
||
const closeOnEscape = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') setShortcutsOpen(false);
|
||
};
|
||
document.addEventListener('pointerdown', closeOnPointer);
|
||
document.addEventListener('keydown', closeOnEscape);
|
||
return () => {
|
||
document.removeEventListener('pointerdown', closeOnPointer);
|
||
document.removeEventListener('keydown', closeOnEscape);
|
||
};
|
||
}, [shortcutsOpen]);
|
||
|
||
useEffect(() => {
|
||
setPromptScrollTop(inputElementRef.current?.scrollTop ?? 0);
|
||
}, [prompt, promptOverlayParts]);
|
||
|
||
// Auto-grow the prompt textarea until it reaches the composer cap.
|
||
// Beyond that, the textarea scrolls internally so a long preset
|
||
// prompt does not push the rest of Home off screen.
|
||
useLayoutEffect(() => {
|
||
const el = inputElementRef.current;
|
||
if (!el) return;
|
||
el.style.height = 'auto';
|
||
const nextHeight = Math.min(el.scrollHeight, promptMaxHeight);
|
||
el.style.height = `${nextHeight}px`;
|
||
el.style.overflowY = el.scrollHeight > promptMaxHeight ? 'auto' : 'hidden';
|
||
if (el.scrollHeight <= promptMaxHeight && el.scrollTop !== 0) {
|
||
el.scrollTop = 0;
|
||
setPromptScrollTop(0);
|
||
} else {
|
||
setPromptScrollTop(el.scrollTop);
|
||
}
|
||
}, [pluginInputValues, prompt, promptMaxHeight, promptOverlayParts]);
|
||
|
||
const setInputRef = useCallback(
|
||
(node: HTMLTextAreaElement | null) => {
|
||
inputElementRef.current = node;
|
||
assignForwardedRef(ref, node);
|
||
},
|
||
[ref],
|
||
);
|
||
|
||
function pickPlugin(record: InstalledPluginRecord) {
|
||
const nextPrompt = mention
|
||
? replaceMentionTokenWithText(prompt, mention, pluginMentionText(record))
|
||
: prompt;
|
||
onPickPlugin(record, nextPrompt);
|
||
}
|
||
|
||
function pickSkill(skill: SkillSummary) {
|
||
const nextPrompt = mention
|
||
? replaceMentionTokenWithText(prompt, mention, inlineMentionToken(skill.name))
|
||
: prompt;
|
||
onPickSkill(skill, nextPrompt);
|
||
}
|
||
|
||
function pickMcp(server: McpServerConfig) {
|
||
const nextPrompt = mention
|
||
? replaceMentionTokenWithText(
|
||
prompt,
|
||
mention,
|
||
inlineMentionToken(server.label || server.id),
|
||
)
|
||
: prompt;
|
||
onPickMcp(server, nextPrompt);
|
||
}
|
||
|
||
function pickConnector(connector: ConnectorDetail) {
|
||
const nextPrompt = mention
|
||
? replaceMentionTokenWithText(
|
||
prompt,
|
||
mention,
|
||
inlineMentionToken(connector.name),
|
||
)
|
||
: prompt;
|
||
onPickConnector(connector, nextPrompt);
|
||
}
|
||
|
||
function updatePluginInput(name: string, value: unknown) {
|
||
onPluginInputValuesChange({ ...pluginInputValues, [name]: value });
|
||
}
|
||
|
||
function handleFiles(files: File[]) {
|
||
if (files.length === 0) return;
|
||
onAddFiles(files);
|
||
}
|
||
|
||
function clearSelectedPromptExample() {
|
||
if (selectedPromptExample) {
|
||
onPromptChange('');
|
||
}
|
||
setSelectedPromptExample(null);
|
||
}
|
||
|
||
function usePromptExample(example: string) {
|
||
setSelectedPromptExample({
|
||
label: promptExampleChipLabel(example),
|
||
promptText: example,
|
||
});
|
||
onPromptChange(example);
|
||
setSelectedIndex(0);
|
||
requestAnimationFrame(() => {
|
||
const input = inputElementRef.current;
|
||
if (!input) return;
|
||
input.focus();
|
||
const position = example.length;
|
||
input.setSelectionRange(position, position);
|
||
input.scrollTop = input.scrollHeight;
|
||
});
|
||
}
|
||
|
||
function pickExamplePluginPreset(record: InstalledPluginRecord, chipId: string, promptText: string) {
|
||
setSelectedPromptExample({
|
||
label: record.title,
|
||
promptText,
|
||
});
|
||
onPickExamplePlugin(record, chipId, promptText);
|
||
}
|
||
|
||
function handlePaste(event: ReactClipboardEvent<HTMLTextAreaElement>) {
|
||
const files = filesFromClipboard(event.clipboardData);
|
||
if (files.length === 0) return;
|
||
event.preventDefault();
|
||
handleFiles(files);
|
||
}
|
||
|
||
function normalizeMentionSelection(input: HTMLTextAreaElement) {
|
||
const nextSelection = mentionSafeSelection(
|
||
input.selectionStart,
|
||
input.selectionEnd,
|
||
promptMentionRanges,
|
||
);
|
||
if (!nextSelection) return;
|
||
requestAnimationFrame(() => {
|
||
if (document.activeElement !== input) return;
|
||
input.setSelectionRange(nextSelection.start, nextSelection.end);
|
||
});
|
||
}
|
||
|
||
function deleteMentionTokenFromKey(event: ReactKeyboardEvent<HTMLTextAreaElement>): boolean {
|
||
if (event.key !== 'Backspace' && event.key !== 'Delete') return false;
|
||
const input = event.currentTarget;
|
||
if (input.selectionStart !== input.selectionEnd) return false;
|
||
const caret = input.selectionStart;
|
||
const range = promptMentionRanges.find((item) => (
|
||
event.key === 'Backspace'
|
||
? caret > item.start && caret <= item.end
|
||
: caret >= item.start && caret < item.end
|
||
));
|
||
if (!range) return false;
|
||
event.preventDefault();
|
||
const nextPrompt = `${prompt.slice(0, range.start)}${prompt.slice(range.end)}`;
|
||
onPromptChange(nextPrompt);
|
||
requestAnimationFrame(() => {
|
||
const nextInput = inputElementRef.current;
|
||
if (!nextInput) return;
|
||
nextInput.focus();
|
||
nextInput.setSelectionRange(range.start, range.start);
|
||
});
|
||
return true;
|
||
}
|
||
|
||
function handleDrop(event: ReactDragEvent<HTMLDivElement>) {
|
||
const files = Array.from(event.dataTransfer.files ?? []);
|
||
if (files.length === 0) return;
|
||
event.preventDefault();
|
||
setDragActive(false);
|
||
handleFiles(files);
|
||
}
|
||
|
||
function openActivePluginDetails() {
|
||
if (activePluginRecord) onOpenPluginDetails(activePluginRecord);
|
||
}
|
||
|
||
const showActiveContextRow =
|
||
(showActivePluginChip && activePluginTitle) ||
|
||
activeSkillTitle ||
|
||
selectedPromptExample ||
|
||
selectedPluginContexts.length > 0;
|
||
|
||
let optionRenderIndex = 0;
|
||
|
||
return (
|
||
<section className="home-hero" data-testid="home-hero">
|
||
<div className="home-hero__brand" aria-hidden>
|
||
<span className="home-hero__brand-mark">
|
||
<img src="/app-icon.svg" alt="" draggable={false} />
|
||
</span>
|
||
<span className="home-hero__brand-name">Open Design</span>
|
||
</div>
|
||
<h1 className="home-hero__title">{t('homeHero.title')}</h1>
|
||
<p className="home-hero__subtitle">
|
||
{t('homeHero.subtitlePrefix')}
|
||
</p>
|
||
|
||
<div
|
||
className={`home-hero__input-card${
|
||
authoringLayoutActive ? ' home-hero__input-card--compact-authoring' : ''
|
||
}${dragActive ? ' is-drag-active' : ''}`}
|
||
style={inputCardStyle}
|
||
onDragEnter={(event) => {
|
||
if (event.dataTransfer.types.includes('Files')) setDragActive(true);
|
||
}}
|
||
onDragOver={(event) => {
|
||
if (!event.dataTransfer.types.includes('Files')) return;
|
||
event.preventDefault();
|
||
setDragActive(true);
|
||
}}
|
||
onDragLeave={(event) => {
|
||
const nextTarget = event.relatedTarget;
|
||
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
|
||
setDragActive(false);
|
||
}}
|
||
onDrop={handleDrop}
|
||
>
|
||
{showActiveContextRow ? (
|
||
<div className="home-hero__active">
|
||
{selectedPluginContexts.map((plugin) => (
|
||
<span
|
||
key={plugin.id}
|
||
className="home-hero__active-chip home-hero__active-chip--context"
|
||
data-testid={`home-hero-context-plugin-${plugin.id}`}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="home-hero__active-chip-body"
|
||
onClick={() => onOpenPluginDetails(plugin)}
|
||
title={t('homeHero.pluginTitle', { title: plugin.title })}
|
||
>
|
||
<span className="home-hero__active-dot" aria-hidden />
|
||
<span>{plugin.title}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="home-hero__active-clear"
|
||
onClick={() => onRemovePluginContext(plugin.id)}
|
||
aria-label={t('homeHero.removePluginAria', { title: plugin.title })}
|
||
title={t('homeHero.removePlugin')}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
{showActivePluginChip && activePluginTitle ? (
|
||
<span className="home-hero__active-chip" data-testid="home-hero-active-plugin">
|
||
<button
|
||
type="button"
|
||
className="home-hero__active-chip-body"
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
openActivePluginDetails();
|
||
}}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
openActivePluginDetails();
|
||
}}
|
||
onClick={openActivePluginDetails}
|
||
disabled={!activePluginRecord}
|
||
title={activePluginRecord ? t('homeHero.pluginTitle', { title: activePluginRecord.title }) : undefined}
|
||
>
|
||
<span className="home-hero__active-dot" aria-hidden />
|
||
<span>{activePluginTitle}</span>
|
||
</button>
|
||
{activeCreateChip ? null : (
|
||
<button
|
||
type="button"
|
||
className="home-hero__active-clear"
|
||
onClick={onClearActivePlugin}
|
||
aria-label={t('homeHero.clearActivePlugin')}
|
||
title={t('homeHero.clearActivePlugin')}
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</span>
|
||
) : null}
|
||
{activeSkillTitle ? (
|
||
<span
|
||
className="home-hero__active-chip home-hero__active-chip--skill"
|
||
data-testid="home-hero-active-skill"
|
||
>
|
||
<span className="home-hero__active-dot" aria-hidden />
|
||
<span>{t('homeHero.skillPrefix', { title: activeSkillTitle })}</span>
|
||
<button
|
||
type="button"
|
||
className="home-hero__active-clear"
|
||
onClick={onClearActiveSkill}
|
||
aria-label={t('homeHero.clearActiveSkill')}
|
||
title={t('homeHero.clearActiveSkill')}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
) : null}
|
||
{selectedPromptExample ? (
|
||
<span
|
||
className="home-hero__active-chip home-hero__active-chip--example"
|
||
data-testid="home-hero-active-example"
|
||
>
|
||
<span className="home-hero__active-dot" aria-hidden />
|
||
<span>{t('homeHero.promptExamples')}: {selectedPromptExample.label}</span>
|
||
<button
|
||
type="button"
|
||
className="home-hero__active-clear"
|
||
onClick={clearSelectedPromptExample}
|
||
aria-label={t('common.close')}
|
||
title={t('common.close')}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<div className="home-hero__prompt-surface">
|
||
<div
|
||
className={`home-hero__prompt-editor${
|
||
promptOverlayParts ? ' home-hero__prompt-editor--highlighted' : ''
|
||
}`}
|
||
>
|
||
{promptOverlayParts ? (
|
||
<div
|
||
className="home-hero__prompt-highlight"
|
||
data-testid="home-hero-prompt-highlight"
|
||
style={{ ['--home-hero-prompt-scroll' as string]: `${promptScrollTop}px` }}
|
||
>
|
||
<div className="home-hero__prompt-highlight-inner">
|
||
{promptOverlayParts.map((part, index) => (
|
||
part.kind === 'slot' ? (
|
||
part.key && footerInputNameSet.has(part.key) ? (
|
||
<span key={`footer-slot-${part.key}-${index}`} aria-hidden>
|
||
{formatPromptInputValue(
|
||
locale,
|
||
fieldByName.get(part.key) ?? null,
|
||
pluginInputValues[part.key],
|
||
part.text,
|
||
t,
|
||
)}
|
||
</span>
|
||
) : (
|
||
<InlinePromptInput
|
||
key={`${part.key}-${index}`}
|
||
field={part.key ? fieldByName.get(part.key) ?? null : null}
|
||
name={part.key ?? ''}
|
||
value={part.key ? pluginInputValues[part.key] : undefined}
|
||
fallbackText={part.text}
|
||
filled={part.filled === true}
|
||
editable={Boolean(part.key && editableInputNames.has(part.key))}
|
||
open={part.key === openInlineInputName}
|
||
locale={locale}
|
||
onOpenChange={(open) => setOpenInlineInputName(open ? part.key ?? null : null)}
|
||
/>
|
||
)
|
||
) : (
|
||
part.kind === 'mention' ? (
|
||
<InlineMentionToken
|
||
key={`${part.entity.kind}-${part.entity.id}-${index}`}
|
||
entity={part.entity}
|
||
pluginRecord={pluginByMentionId.get(part.entity.id) ?? null}
|
||
text={part.text}
|
||
onOpenPluginDetails={onOpenPluginDetails}
|
||
/>
|
||
) : (
|
||
<span key={`text-${index}`} aria-hidden>
|
||
{part.text}
|
||
</span>
|
||
)
|
||
)
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<textarea
|
||
ref={setInputRef}
|
||
className="home-hero__input"
|
||
data-testid="home-hero-input"
|
||
value={prompt}
|
||
spellCheck={false}
|
||
onChange={(e) => {
|
||
onPromptChange(e.target.value);
|
||
if (selectedPromptExample && e.target.value !== selectedPromptExample.promptText) {
|
||
setSelectedPromptExample(null);
|
||
}
|
||
setSelectedIndex(0);
|
||
}}
|
||
onPaste={handlePaste}
|
||
onScroll={(event) => {
|
||
setPromptScrollTop(event.currentTarget.scrollTop);
|
||
}}
|
||
onSelect={(event) => {
|
||
normalizeMentionSelection(event.currentTarget);
|
||
}}
|
||
onCompositionStart={() => {
|
||
composingRef.current = true;
|
||
}}
|
||
onCompositionEnd={() => {
|
||
composingRef.current = false;
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (isImeComposing(e, composingRef.current)) return;
|
||
if (deleteMentionTokenFromKey(e)) return;
|
||
if (pickerOpen && visiblePickerOptions.length > 0) {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setSelectedIndex((idx) => (idx + 1) % visiblePickerOptions.length);
|
||
return;
|
||
}
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
setSelectedIndex(
|
||
(idx) => (idx - 1 + visiblePickerOptions.length) % visiblePickerOptions.length,
|
||
);
|
||
return;
|
||
}
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
const selected = visiblePickerOptions[selectedIndex] ?? visiblePickerOptions[0];
|
||
if (selected && !selected.disabled) selected.onPick();
|
||
return;
|
||
}
|
||
}
|
||
if (
|
||
e.key === 'Enter' &&
|
||
!e.shiftKey &&
|
||
!e.metaKey &&
|
||
!e.ctrlKey &&
|
||
!e.altKey
|
||
) {
|
||
e.preventDefault();
|
||
if (pickerOpen && visiblePickerOptions.length > 0) {
|
||
const selected = visiblePickerOptions[selectedIndex] ?? visiblePickerOptions[0];
|
||
if (selected && !selected.disabled) selected.onPick();
|
||
return;
|
||
}
|
||
if (canSubmit) onSubmit();
|
||
}
|
||
}}
|
||
placeholder={placeholder}
|
||
rows={3}
|
||
aria-controls={pickerOpen ? 'home-hero-context-picker' : undefined}
|
||
aria-expanded={pickerOpen}
|
||
/>
|
||
</div>
|
||
{openInlineInputField ? (
|
||
<InlinePromptOptionPopover
|
||
field={openInlineInputField}
|
||
value={pluginInputValues[openInlineInputField.name]}
|
||
locale={locale}
|
||
onChange={(value) => {
|
||
onPluginInputValuesChange({
|
||
...pluginInputValues,
|
||
[openInlineInputField.name]: value,
|
||
});
|
||
if (openInlineInputField.type !== 'string') {
|
||
setOpenInlineInputName(null);
|
||
}
|
||
}}
|
||
/>
|
||
) : null}
|
||
{showPluginInputsForm && remainingInputFields.length > 0 ? (
|
||
<PluginInputsForm
|
||
fields={remainingInputFields}
|
||
values={pluginInputValues}
|
||
onChange={onPluginInputValuesChange}
|
||
onValidityChange={onPluginInputValidityChange}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
{stagedFiles.length > 0 ? (
|
||
<div className="home-hero__attachments" data-testid="home-hero-staged-files">
|
||
{stagedFiles.map((file, index) => (
|
||
<span
|
||
key={homeFileKey(file, index)}
|
||
className="home-hero__attachment-chip"
|
||
title={`${file.name} · ${formatFileSize(file.size)}`}
|
||
>
|
||
<span className="home-hero__attachment-icon" aria-hidden>
|
||
<Icon name={isImageFile(file) ? 'image' : 'file'} size={13} />
|
||
</span>
|
||
<span className="home-hero__attachment-name">{file.name}</span>
|
||
<span className="home-hero__attachment-size">
|
||
{formatFileSize(file.size)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="home-hero__attachment-remove"
|
||
onClick={() => onRemoveFile(index)}
|
||
aria-label={t('chat.removeAria', { name: file.name })}
|
||
title={t('homeHero.removeFile')}
|
||
>
|
||
<Icon name="close" size={10} />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{pickerOpen ? (
|
||
<div
|
||
id="home-hero-context-picker"
|
||
className="home-hero__plugin-picker"
|
||
role="listbox"
|
||
aria-label={t('homeHero.contextSearchResults')}
|
||
data-testid="home-hero-plugin-picker"
|
||
>
|
||
<div className="home-hero__mention-tabs" role="tablist" aria-label={t('homeHero.contextSurfaces')}>
|
||
{tabs.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={mentionTab === item.id}
|
||
className={`home-hero__mention-tab${mentionTab === item.id ? ' is-active' : ''}`}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => {
|
||
setMentionTab(item.id);
|
||
setSelectedIndex(0);
|
||
}}
|
||
>
|
||
<span>{item.label}</span>
|
||
{item.count > 0 ? <span>{item.count}</span> : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{visibleLoading && visiblePickerOptions.length === 0 ? (
|
||
<div className="home-hero__plugin-picker-empty">{t('homeHero.loadingContext')}</div>
|
||
) : null}
|
||
{!visibleLoading && visiblePickerOptions.length === 0 ? (
|
||
<div className="home-hero__plugin-picker-empty">
|
||
{mentionQuery ? (
|
||
<>{t('homeHero.noResults', { query: mentionQuery })}</>
|
||
) : (
|
||
<>{t('homeHero.searchPrompt')}</>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
{visibleSections.map((section) => (
|
||
<div key={section.id} className="home-hero__mention-section">
|
||
<div className="home-hero__mention-section-label">{section.label}</div>
|
||
{section.options.map((item) => {
|
||
const optionIndex = optionRenderIndex;
|
||
optionRenderIndex += 1;
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={optionIndex === selectedIndex}
|
||
className={`home-hero__plugin-option${
|
||
optionIndex === selectedIndex ? ' is-active' : ''
|
||
}`}
|
||
onMouseEnter={() => {
|
||
setSelectedIndex(optionIndex);
|
||
setHoveredPlugin(item.pluginRecord ?? null);
|
||
}}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
if (!item.disabled) item.onPick();
|
||
}}
|
||
disabled={item.disabled}
|
||
>
|
||
<span className="home-hero__plugin-option-icon" aria-hidden>
|
||
<Icon name={item.icon} size={13} />
|
||
</span>
|
||
<span className="home-hero__plugin-option-main">
|
||
<span>{item.title}</span>
|
||
<span>{item.description}</span>
|
||
</span>
|
||
<span className="home-hero__plugin-option-meta">
|
||
{item.meta}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
{hoveredPlugin ? (
|
||
<div
|
||
className="home-hero__plugin-hover-card"
|
||
data-testid="home-hero-plugin-hover-card"
|
||
>
|
||
<div>
|
||
<span className="home-hero__plugin-hover-kicker">
|
||
{getPluginSourceLabel(hoveredPlugin, t)}
|
||
</span>
|
||
<strong>{hoveredPlugin.title}</strong>
|
||
<p>{hoveredPlugin.manifest?.description ?? hoveredPlugin.id}</p>
|
||
</div>
|
||
<div className="home-hero__plugin-hover-meta">
|
||
<span>{t('homeHero.parameters', { n: (hoveredPlugin.manifest?.od?.inputs ?? []).length })}</span>
|
||
{getPluginQueryPreview(hoveredPlugin) ? (
|
||
<span>{getPluginQueryPreview(hoveredPlugin)}</span>
|
||
) : null}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => onOpenPluginDetails(hoveredPlugin)}
|
||
>
|
||
{t('homeHero.details')}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<div className="home-hero__input-foot">
|
||
<input
|
||
ref={fileInputRef}
|
||
data-testid="home-hero-file-input"
|
||
type="file"
|
||
multiple
|
||
style={{ display: 'none' }}
|
||
onChange={(event) => {
|
||
const files = Array.from(event.target.files ?? []);
|
||
handleFiles(files);
|
||
event.target.value = '';
|
||
}}
|
||
/>
|
||
<div className="home-hero__foot-left">
|
||
<button
|
||
type="button"
|
||
className="home-hero__attach"
|
||
data-testid="home-hero-attach"
|
||
onClick={() => {
|
||
trackHomeChatComposerClick(analytics.track, {
|
||
page_name: 'home',
|
||
area: 'chat_composer',
|
||
element: 'attachment',
|
||
});
|
||
fileInputRef.current?.click();
|
||
}}
|
||
title={t('chat.attachAria')}
|
||
aria-label={t('chat.attachAria')}
|
||
>
|
||
<Icon name="attach" size={15} />
|
||
</button>
|
||
{activeCreateChip ? (
|
||
<ActiveTypeChip chip={activeCreateChip} onClear={onClearActiveChip} />
|
||
) : null}
|
||
{footerInputFields.length > 0 ? (
|
||
<div className="home-hero__footer-options" data-testid="home-hero-footer-options">
|
||
{footerInputFields.map((field) => (
|
||
<FooterInputOption
|
||
key={field.name}
|
||
field={field}
|
||
value={pluginInputValues[field.name]}
|
||
designSystemOptions={designSystemOptions}
|
||
onChange={(value) => {
|
||
onPluginInputValuesChange({
|
||
...pluginInputValues,
|
||
[field.name]: value,
|
||
});
|
||
}}
|
||
t={t}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="home-hero__submit"
|
||
data-testid="home-hero-submit"
|
||
onClick={onSubmit}
|
||
disabled={!canSubmit}
|
||
title={canSubmit ? t('homeHero.run') : t('homeHero.typeSomethingToRun')}
|
||
aria-label={t('homeHero.run')}
|
||
>
|
||
<Icon name="arrow-up" size={17} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{activeCreateChip ? null : (
|
||
<RailGroup
|
||
group="create"
|
||
activeChipId={activeChipId}
|
||
pendingChipId={pendingChipId}
|
||
pendingPluginId={pendingPluginId}
|
||
pluginsLoading={pluginsLoading}
|
||
onPickChip={onPickChip}
|
||
variant="tabs"
|
||
>
|
||
<ShortcutsMenu
|
||
activeChipId={activeChipId}
|
||
pendingChipId={pendingChipId}
|
||
pendingPluginId={pendingPluginId}
|
||
pluginsLoading={pluginsLoading}
|
||
open={shortcutsOpen}
|
||
refNode={shortcutsMenuRef}
|
||
onOpenChange={setShortcutsOpen}
|
||
onPickChip={(chip) => {
|
||
setShortcutsOpen(false);
|
||
onPickChip(chip);
|
||
}}
|
||
/>
|
||
</RailGroup>
|
||
)}
|
||
|
||
{activeExamplePlugins.length > 0 && activeChipId ? (
|
||
<PluginPromptPresets
|
||
chipId={activeChipId}
|
||
plugins={activeExamplePlugins}
|
||
activePluginId={activePluginRecord?.id ?? null}
|
||
pendingPluginId={pendingPluginId}
|
||
locale={locale}
|
||
onPick={pickExamplePluginPreset}
|
||
/>
|
||
) : activePromptExamples.length > 0 ? (
|
||
<div
|
||
className="home-hero__prompt-examples"
|
||
data-testid="home-hero-prompt-examples"
|
||
>
|
||
<div className="home-hero__prompt-examples-title">
|
||
{t('homeHero.promptExamples')}
|
||
</div>
|
||
<div className="home-hero__prompt-examples-grid">
|
||
{activePromptExamples.map((example) => (
|
||
<button
|
||
key={example}
|
||
type="button"
|
||
className="home-hero__prompt-example"
|
||
data-testid="home-hero-prompt-example"
|
||
onClick={() => usePromptExample(example)}
|
||
>
|
||
<span>{example}</span>
|
||
<Icon name="external-link" size={14} aria-hidden />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{error ? (
|
||
<div role="alert" className="home-hero__error">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
});
|
||
|
||
function PluginPromptPresets({
|
||
activePluginId,
|
||
chipId,
|
||
locale,
|
||
onPick,
|
||
pendingPluginId,
|
||
plugins,
|
||
}: {
|
||
activePluginId: string | null;
|
||
chipId: string;
|
||
locale: Locale;
|
||
onPick: (record: InstalledPluginRecord, chipId: string, promptText: string) => void;
|
||
pendingPluginId: string | null;
|
||
plugins: InstalledPluginRecord[];
|
||
}) {
|
||
const { t } = useI18n();
|
||
return (
|
||
<div
|
||
className="home-hero__prompt-examples home-hero__plugin-presets-wrap"
|
||
data-testid="home-hero-plugin-presets"
|
||
>
|
||
<div className="home-hero__prompt-examples-title">
|
||
{t('homeHero.promptExamples')}
|
||
</div>
|
||
<div className="home-hero__plugin-presets" role="list">
|
||
{plugins.map((record) => (
|
||
<PluginPromptPresetCard
|
||
key={record.id}
|
||
chipId={chipId}
|
||
locale={locale}
|
||
record={record}
|
||
active={activePluginId === record.id}
|
||
pending={pendingPluginId === record.id}
|
||
disabled={pendingPluginId !== null}
|
||
onPick={onPick}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PluginPromptPresetCard({
|
||
active,
|
||
chipId,
|
||
disabled,
|
||
locale,
|
||
onPick,
|
||
pending,
|
||
record,
|
||
}: {
|
||
active: boolean;
|
||
chipId: string;
|
||
disabled: boolean;
|
||
locale: Locale;
|
||
onPick: (record: InstalledPluginRecord, chipId: string, promptText: string) => void;
|
||
pending: boolean;
|
||
record: InstalledPluginRecord;
|
||
}) {
|
||
const preview = useMemo(() => inferPluginPreview(record), [record]);
|
||
const promptPreview = pluginPresetPromptPreview(record, locale, chipId);
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`home-hero__plugin-preset${active ? ' is-active' : ''}${pending ? ' is-pending' : ''}`}
|
||
data-testid="home-hero-plugin-preset"
|
||
data-plugin-id={record.id}
|
||
role="listitem"
|
||
disabled={disabled}
|
||
onClick={() => onPick(record, chipId, promptPreview)}
|
||
>
|
||
<span className="home-hero__plugin-preset-preview" aria-hidden>
|
||
<PreviewSurface
|
||
pluginId={record.id}
|
||
pluginTitle={record.title}
|
||
preview={preview}
|
||
/>
|
||
</span>
|
||
<span className="home-hero__plugin-preset-body">
|
||
<span className="home-hero__plugin-preset-title">
|
||
{record.title}
|
||
</span>
|
||
<span className="home-hero__plugin-preset-prompt">
|
||
{promptPreview}
|
||
</span>
|
||
</span>
|
||
<Icon name={active ? 'check' : 'external-link'} size={13} aria-hidden />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function promptExampleChipLabel(example: string): string {
|
||
const normalized = example.replace(/\s+/g, ' ').trim();
|
||
const [beforeDash] = normalized.split(/\s[—-]\s/u, 1);
|
||
const candidate = beforeDash?.trim() || normalized;
|
||
return candidate.length > 64 ? `${candidate.slice(0, 61).trimEnd()}...` : candidate;
|
||
}
|
||
|
||
interface ContextMention {
|
||
start: number;
|
||
end: number;
|
||
query: string;
|
||
}
|
||
|
||
function assignForwardedRef<T>(forwardedRef: ForwardedRef<T>, value: T | null) {
|
||
if (typeof forwardedRef === 'function') {
|
||
forwardedRef(value);
|
||
return;
|
||
}
|
||
if (forwardedRef) {
|
||
forwardedRef.current = value;
|
||
}
|
||
}
|
||
|
||
function filesFromClipboard(data: DataTransfer): File[] {
|
||
const files: File[] = [];
|
||
for (const item of Array.from(data.items ?? [])) {
|
||
if (item.kind !== 'file') continue;
|
||
const file = item.getAsFile();
|
||
if (file) files.push(file);
|
||
}
|
||
return files;
|
||
}
|
||
|
||
function homeFileKey(file: File, index: number): string {
|
||
return `${file.name}-${file.size}-${file.lastModified}-${index}`;
|
||
}
|
||
|
||
function isImageFile(file: File): boolean {
|
||
return file.type.startsWith('image/') || /\.(png|jpe?g|gif|webp|svg|avif|bmp)$/i.test(file.name);
|
||
}
|
||
|
||
function formatFileSize(bytes: number): string {
|
||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
const units = ['KB', 'MB', 'GB'];
|
||
let value = bytes / 1024;
|
||
for (const unit of units) {
|
||
if (value < 1024 || unit === units[units.length - 1]) {
|
||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`;
|
||
}
|
||
value /= 1024;
|
||
}
|
||
return `${bytes} B`;
|
||
}
|
||
|
||
type PromptOverlayPart =
|
||
| {
|
||
kind: 'text';
|
||
text: string;
|
||
}
|
||
| {
|
||
kind: 'slot';
|
||
text: string;
|
||
key?: string;
|
||
filled?: boolean;
|
||
}
|
||
| {
|
||
kind: 'mention';
|
||
entity: InlineMentionEntity;
|
||
text: string;
|
||
};
|
||
|
||
interface PromptMentionRange {
|
||
start: number;
|
||
end: number;
|
||
}
|
||
|
||
interface PromptHighlightPart {
|
||
kind: 'text' | 'slot';
|
||
text: string;
|
||
key?: string;
|
||
filled?: boolean;
|
||
}
|
||
|
||
const INPUT_PLACEHOLDER_PATTERN = /\{\{\s*([a-zA-Z_][\w-]*)\s*\}\}/g;
|
||
const HOME_HERO_PROMPT_MAX_HEIGHT = 180;
|
||
const HOME_HERO_AUTHORING_PROMPT_MAX_HEIGHT = 132;
|
||
|
||
function buildPromptHighlightParts(
|
||
template: string | null,
|
||
values: Record<string, unknown>,
|
||
prompt: string,
|
||
): PromptHighlightPart[] | null {
|
||
if (!template) return null;
|
||
INPUT_PLACEHOLDER_PATTERN.lastIndex = 0;
|
||
const parts: PromptHighlightPart[] = [];
|
||
let rendered = '';
|
||
let lastIndex = 0;
|
||
let slotCount = 0;
|
||
let match: RegExpExecArray | null;
|
||
while ((match = INPUT_PLACEHOLDER_PATTERN.exec(template)) !== null) {
|
||
const placeholder = match[0];
|
||
const key = match[1];
|
||
if (!key) continue;
|
||
const literal = template.slice(lastIndex, match.index);
|
||
if (literal) {
|
||
parts.push({ kind: 'text', text: literal });
|
||
rendered += literal;
|
||
}
|
||
const replacement = stringifyTemplateValue(values[key], placeholder);
|
||
parts.push({
|
||
kind: 'slot',
|
||
key,
|
||
text: replacement.text,
|
||
filled: replacement.filled,
|
||
});
|
||
rendered += replacement.text;
|
||
slotCount += 1;
|
||
lastIndex = match.index + placeholder.length;
|
||
}
|
||
const tail = template.slice(lastIndex);
|
||
if (tail) {
|
||
parts.push({ kind: 'text', text: tail });
|
||
rendered += tail;
|
||
}
|
||
if (slotCount === 0 || rendered !== prompt) return null;
|
||
return parts;
|
||
}
|
||
|
||
function buildPromptOverlayParts(
|
||
template: string | null,
|
||
values: Record<string, unknown>,
|
||
prompt: string,
|
||
mentionEntities: InlineMentionEntity[],
|
||
): PromptOverlayPart[] | null {
|
||
const templateParts = buildPromptHighlightParts(template, values, prompt);
|
||
const baseParts: PromptOverlayPart[] = templateParts ?? [{ kind: 'text', text: prompt }];
|
||
const withMentions = injectMentionParts(baseParts, mentionEntities);
|
||
if (templateParts || withMentions.some((part) => part.kind === 'mention')) {
|
||
return withMentions;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function injectMentionParts(
|
||
parts: PromptOverlayPart[],
|
||
mentionEntities: InlineMentionEntity[],
|
||
): PromptOverlayPart[] {
|
||
return parts.flatMap((part) => {
|
||
if (part.kind !== 'text') return [part];
|
||
const mentionParts = buildInlineMentionParts(part.text, mentionEntities);
|
||
return mentionParts
|
||
? mentionParts.map((mentionPart): PromptOverlayPart => {
|
||
if (mentionPart.kind === 'mention') {
|
||
return {
|
||
kind: 'mention',
|
||
entity: mentionPart.entity,
|
||
text: mentionPart.text,
|
||
};
|
||
}
|
||
return { kind: 'text', text: mentionPart.text };
|
||
})
|
||
: [part];
|
||
});
|
||
}
|
||
|
||
function buildPromptMentionRanges(parts: PromptOverlayPart[] | null): PromptMentionRange[] {
|
||
if (!parts) return [];
|
||
const ranges: PromptMentionRange[] = [];
|
||
let offset = 0;
|
||
for (const part of parts) {
|
||
const length = part.text.length;
|
||
if (part.kind === 'mention') {
|
||
ranges.push({ start: offset, end: offset + length });
|
||
}
|
||
offset += length;
|
||
}
|
||
return ranges;
|
||
}
|
||
|
||
function mentionSafeSelection(
|
||
selectionStart: number,
|
||
selectionEnd: number,
|
||
ranges: PromptMentionRange[],
|
||
): PromptMentionRange | null {
|
||
if (ranges.length === 0) return null;
|
||
if (selectionStart === selectionEnd) {
|
||
for (const range of ranges) {
|
||
if (selectionStart > range.start && selectionStart < range.end) {
|
||
const before = selectionStart - range.start;
|
||
const after = range.end - selectionStart;
|
||
const caret = before < after ? range.start : range.end;
|
||
return { start: caret, end: caret };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
let start = selectionStart;
|
||
let end = selectionEnd;
|
||
for (const range of ranges) {
|
||
const intersects = end > range.start && start < range.end;
|
||
if (!intersects) continue;
|
||
if (start > range.start && start < range.end) start = range.start;
|
||
if (end > range.start && end < range.end) end = range.end;
|
||
}
|
||
return start === selectionStart && end === selectionEnd ? null : { start, end };
|
||
}
|
||
|
||
function pluginMentionText(record: InstalledPluginRecord): string {
|
||
return inlineMentionToken(record.title);
|
||
}
|
||
|
||
function stringifyTemplateValue(
|
||
value: unknown,
|
||
placeholder: string,
|
||
): { text: string; filled: boolean } {
|
||
if (value === undefined || value === null || value === '') {
|
||
return { text: placeholder, filled: false };
|
||
}
|
||
return { text: String(value), filled: true };
|
||
}
|
||
|
||
function buildHomeMentionEntities({
|
||
activePluginRecord,
|
||
activeSkillId,
|
||
activeSkillTitle,
|
||
connectorOptions,
|
||
mcpOptions,
|
||
pluginOptions,
|
||
selectedPluginContexts,
|
||
skillOptions,
|
||
t,
|
||
}: {
|
||
activePluginRecord: InstalledPluginRecord | null;
|
||
activeSkillId: string | null;
|
||
activeSkillTitle: string | null;
|
||
connectorOptions: ConnectorDetail[];
|
||
mcpOptions: McpServerConfig[];
|
||
pluginOptions: InstalledPluginRecord[];
|
||
selectedPluginContexts: InstalledPluginRecord[];
|
||
skillOptions: SkillSummary[];
|
||
t: ReturnType<typeof useT>;
|
||
}): InlineMentionEntity[] {
|
||
const entities: InlineMentionEntity[] = [];
|
||
const pluginSeen = new Set<string>();
|
||
for (const plugin of [...selectedPluginContexts, ...pluginOptions]) {
|
||
if (pluginSeen.has(plugin.id)) continue;
|
||
pluginSeen.add(plugin.id);
|
||
entities.push({
|
||
id: plugin.id,
|
||
kind: 'plugin',
|
||
label: plugin.title,
|
||
token: pluginMentionText(plugin),
|
||
title: t('homeHero.pluginPrefix', { title: plugin.title }),
|
||
});
|
||
}
|
||
if (activePluginRecord && !pluginSeen.has(activePluginRecord.id)) {
|
||
entities.push({
|
||
id: activePluginRecord.id,
|
||
kind: 'plugin',
|
||
label: activePluginRecord.title,
|
||
token: pluginMentionText(activePluginRecord),
|
||
title: t('homeHero.pluginPrefix', { title: activePluginRecord.title }),
|
||
});
|
||
}
|
||
const skillSeen = new Set<string>();
|
||
for (const skill of skillOptions) {
|
||
if (skillSeen.has(skill.id)) continue;
|
||
skillSeen.add(skill.id);
|
||
entities.push({
|
||
id: skill.id,
|
||
kind: 'skill',
|
||
label: skill.name,
|
||
token: inlineMentionToken(skill.name),
|
||
title: t('homeHero.skillPrefix', { title: skill.name }),
|
||
});
|
||
if (skill.id !== skill.name) {
|
||
entities.push({
|
||
id: skill.id,
|
||
kind: 'skill',
|
||
label: skill.id,
|
||
token: inlineMentionToken(skill.id),
|
||
title: t('homeHero.skillPrefix', { title: skill.name }),
|
||
});
|
||
}
|
||
}
|
||
if (activeSkillId && activeSkillTitle && !skillSeen.has(activeSkillId)) {
|
||
entities.push({
|
||
id: activeSkillId,
|
||
kind: 'skill',
|
||
label: activeSkillTitle,
|
||
token: inlineMentionToken(activeSkillTitle),
|
||
title: t('homeHero.skillPrefix', { title: activeSkillTitle }),
|
||
});
|
||
}
|
||
for (const server of mcpOptions) {
|
||
const label = server.label || server.id;
|
||
entities.push({
|
||
id: server.id,
|
||
kind: 'mcp',
|
||
label,
|
||
token: inlineMentionToken(label),
|
||
title: `MCP: ${label}`,
|
||
});
|
||
if (server.id !== label) {
|
||
entities.push({
|
||
id: server.id,
|
||
kind: 'mcp',
|
||
label: server.id,
|
||
token: inlineMentionToken(server.id),
|
||
title: `MCP: ${label}`,
|
||
});
|
||
}
|
||
}
|
||
for (const connector of connectorOptions) {
|
||
entities.push({
|
||
id: connector.id,
|
||
kind: 'connector',
|
||
label: connector.name,
|
||
token: inlineMentionToken(connector.name),
|
||
title: `Connector: ${connector.name}`,
|
||
});
|
||
if (connector.id !== connector.name) {
|
||
entities.push({
|
||
id: connector.id,
|
||
kind: 'connector',
|
||
label: connector.id,
|
||
token: inlineMentionToken(connector.id),
|
||
title: `Connector: ${connector.name}`,
|
||
});
|
||
}
|
||
}
|
||
return entities;
|
||
}
|
||
|
||
function InlineMentionToken({
|
||
entity,
|
||
pluginRecord,
|
||
text,
|
||
onOpenPluginDetails,
|
||
}: {
|
||
entity: InlineMentionEntity;
|
||
pluginRecord: InstalledPluginRecord | null;
|
||
text: string;
|
||
onOpenPluginDetails: (record: InstalledPluginRecord) => void;
|
||
}) {
|
||
const t = useT();
|
||
if (entity.kind === 'plugin' && pluginRecord) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="home-hero__prompt-mention"
|
||
data-plugin-id={pluginRecord.id}
|
||
data-testid={`home-hero-prompt-plugin-${pluginRecord.id}`}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => onOpenPluginDetails(pluginRecord)}
|
||
title={entity.title ?? t('homeHero.pluginPrefix', { title: pluginRecord.title })}
|
||
>
|
||
{text}
|
||
</button>
|
||
);
|
||
}
|
||
return (
|
||
<span
|
||
className="home-hero__prompt-mention home-hero__prompt-mention--static"
|
||
data-mention-kind={entity.kind}
|
||
title={entity.title ?? text}
|
||
>
|
||
{text}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
interface InlinePromptInputProps {
|
||
field: InputFieldSpec | null;
|
||
name: string;
|
||
value: unknown;
|
||
fallbackText: string;
|
||
filled: boolean;
|
||
editable?: boolean;
|
||
open?: boolean;
|
||
onOpenChange?: (open: boolean) => void;
|
||
locale: ReturnType<typeof useI18n>['locale'];
|
||
}
|
||
|
||
// Render plugin-input placeholders as read-only styled spans. Earlier
|
||
// revisions used <input>/<select> here, but their CSS widths (min 8ch,
|
||
// `displayValue.length + 1` in ch units, select dropdown padding) did
|
||
// not match the proportional-font width of the corresponding substring
|
||
// in the underlying textarea — so clicking on prose text in the overlay
|
||
// landed the caret several characters off, and the misalignment grew
|
||
// with every slot on the line. A span renders the exact same glyphs as
|
||
// the textarea segment it sits on top of, so the two layouts stay in
|
||
// lock-step and clicks land where the user expects. Editing happens in
|
||
// the PluginInputsForm below.
|
||
function InlinePromptInput({
|
||
field,
|
||
name,
|
||
value,
|
||
fallbackText,
|
||
filled,
|
||
editable = false,
|
||
open = false,
|
||
onOpenChange = () => undefined,
|
||
locale,
|
||
}: InlinePromptInputProps) {
|
||
const label = field ? localizePluginInputLabel(locale, field) : name;
|
||
const displayValue = formatPromptInputValue(locale, field, value, fallbackText);
|
||
// No aria-label here: the editable control with this label lives in
|
||
// the PluginInputsForm below, and findByLabelText must resolve to one
|
||
// element. The span is decorative — it just highlights where the
|
||
// substituted value appears in the prompt the textarea already reads
|
||
// out.
|
||
const hint = filled ? `${label}: ${displayValue}` : label;
|
||
if (editable && field) {
|
||
return (
|
||
<span className="home-hero__prompt-option-shell">
|
||
<button
|
||
type="button"
|
||
className="home-hero__prompt-slot home-hero__prompt-slot--button"
|
||
data-field-name={name}
|
||
data-filled={filled ? 'true' : 'false'}
|
||
data-testid={`home-hero-prompt-slot-${name}`}
|
||
title={hint}
|
||
aria-label={`${label}: ${displayValue}`}
|
||
aria-expanded={open}
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
onOpenChange(!open);
|
||
}}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={(event) => {
|
||
if (event.detail === 0) onOpenChange(!open);
|
||
}}
|
||
>
|
||
{displayValue}
|
||
</button>
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span
|
||
className="home-hero__prompt-slot"
|
||
data-field-name={name}
|
||
data-filled={filled ? 'true' : 'false'}
|
||
data-testid={`home-hero-prompt-slot-${name}`}
|
||
title={hint}
|
||
aria-hidden
|
||
>
|
||
{displayValue}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function InlinePromptOptionPopover({
|
||
field,
|
||
value,
|
||
onChange,
|
||
locale,
|
||
}: {
|
||
field: InputFieldSpec;
|
||
value: unknown;
|
||
onChange: (value: unknown) => void;
|
||
locale: ReturnType<typeof useI18n>['locale'];
|
||
}) {
|
||
const label = localizePluginInputLabel(locale, field);
|
||
const note = fieldPopoverNote(field);
|
||
return (
|
||
<div
|
||
className="home-hero__prompt-option-popover"
|
||
data-testid={`home-hero-prompt-option-${field.name}`}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
>
|
||
<span className="home-hero__prompt-option-label">{label}</span>
|
||
{renderInlinePromptEditor(field, value, onChange, locale)}
|
||
{note ? (
|
||
<span
|
||
className="home-hero__prompt-option-note"
|
||
data-tone={fieldPopoverNoteTone(field)}
|
||
data-testid={`home-hero-prompt-option-${field.name}-note`}
|
||
>
|
||
{localizePluginPlaceholder(locale, note)}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FooterInputOption({
|
||
field,
|
||
value,
|
||
designSystemOptions,
|
||
onChange,
|
||
t,
|
||
}: {
|
||
field: InputFieldSpec;
|
||
value: unknown;
|
||
designSystemOptions: HomeHeroDesignSystemOption[];
|
||
onChange: (value: unknown) => void;
|
||
t: ReturnType<typeof useT>;
|
||
}) {
|
||
const label = footerInputLabel(field, t);
|
||
if (field.name === 'speakerNotes') {
|
||
const checked = footerSpeakerNotesEnabled(value);
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`home-hero__footer-switch${checked ? ' is-on' : ''}`}
|
||
aria-label={label}
|
||
aria-pressed={checked}
|
||
data-testid="home-hero-footer-option-speakerNotes"
|
||
onClick={() => onChange(checked ? 'no speaker notes' : 'include speaker notes')}
|
||
>
|
||
<span>{t('homeHero.footer.speakerNotes')}</span>
|
||
<i aria-hidden />
|
||
</button>
|
||
);
|
||
}
|
||
if (field.name === 'designSystem' && designSystemOptions.length > 0) {
|
||
const selectedValue = value === undefined || value === null ? '' : String(value);
|
||
const selectedOption = selectedValue.length > 0
|
||
? designSystemOptions.find((option) => option.title === selectedValue || option.id === selectedValue)
|
||
: undefined;
|
||
const currentValue = selectedOption?.id ?? designSystemOptions[0]?.id ?? '';
|
||
return (
|
||
<FooterSelectOption
|
||
fieldName={field.name}
|
||
label={label}
|
||
value={currentValue}
|
||
options={designSystemOptions.map((option) => ({
|
||
value: option.id,
|
||
submitValue: option.title,
|
||
label: option.isDefault ? `${option.title} (${t('ds.badgeDefault')})` : option.title,
|
||
group: option.group,
|
||
icon: option.auto ? 'sparkles' : undefined,
|
||
description: option.summary,
|
||
meta: option.category,
|
||
preview: option.auto
|
||
? undefined
|
||
: {
|
||
title: option.title,
|
||
swatches: option.swatches,
|
||
logoUrl: option.logoUrl,
|
||
},
|
||
}))}
|
||
searchable
|
||
searchPlaceholder={t('ds.searchPlaceholder')}
|
||
onChange={onChange}
|
||
/>
|
||
);
|
||
}
|
||
if (field.type === 'select' && Array.isArray(field.options)) {
|
||
return (
|
||
<FooterSelectOption
|
||
fieldName={field.name}
|
||
label={label}
|
||
value={value === undefined || value === null ? '' : String(value)}
|
||
options={[
|
||
...(field.placeholder ? [{ value: '', label: field.placeholder }] : []),
|
||
...field.options.map((option) => ({
|
||
value: option,
|
||
label: footerInputValueLabel(field, option, t),
|
||
icon: footerInputValueIcon(field, option),
|
||
modelIcon: field.name === 'model' ? modelOptionIcon(option, footerInputValueLabel(field, option, t)) : undefined,
|
||
ratioIcon: field.name === 'ratio' ? ratioOptionIcon(option) : undefined,
|
||
})),
|
||
]}
|
||
onChange={onChange}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<label className="home-hero__footer-option home-hero__footer-option--text" data-field-name={field.name}>
|
||
<span>{label}</span>
|
||
<input
|
||
value={value === undefined || value === null ? '' : String(value)}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={field.placeholder ?? ''}
|
||
aria-label={label}
|
||
data-testid={`home-hero-footer-option-${field.name}`}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function FooterSelectOption({
|
||
fieldName,
|
||
label,
|
||
value,
|
||
options,
|
||
searchable = false,
|
||
searchPlaceholder,
|
||
onChange,
|
||
}: {
|
||
fieldName: string;
|
||
label: string;
|
||
value: string;
|
||
options: FooterSelectItemOption[];
|
||
searchable?: boolean;
|
||
searchPlaceholder?: string;
|
||
onChange: (value: unknown) => void;
|
||
}) {
|
||
const t = useT();
|
||
const [open, setOpen] = useState(false);
|
||
const [search, setSearch] = useState('');
|
||
const ref = useRef<HTMLDivElement | null>(null);
|
||
const selected = options.find((option) => option.value === value) ?? options[0];
|
||
const visibleOptions = useMemo(() => {
|
||
const query = search.trim().toLowerCase();
|
||
if (!query) return options;
|
||
return options.filter((option) => (
|
||
option.label.toLowerCase().includes(query) ||
|
||
option.value.toLowerCase().includes(query) ||
|
||
(option.description ?? '').toLowerCase().includes(query) ||
|
||
(option.meta ?? '').toLowerCase().includes(query) ||
|
||
(option.group ?? '').toLowerCase().includes(query)
|
||
));
|
||
}, [options, search]);
|
||
const groupedOptions = useMemo(() => {
|
||
const groups: { label: string | null; options: FooterSelectItemOption[] }[] = [];
|
||
for (const option of visibleOptions) {
|
||
const groupLabel = option.group ?? null;
|
||
const last = groups[groups.length - 1];
|
||
if (last && last.label === groupLabel) {
|
||
last.options.push(option);
|
||
} else {
|
||
groups.push({ label: groupLabel, options: [option] });
|
||
}
|
||
}
|
||
return groups;
|
||
}, [visibleOptions]);
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const closeOnPointer = (event: PointerEvent) => {
|
||
const target = event.target;
|
||
if (target instanceof Node && ref.current?.contains(target)) return;
|
||
setOpen(false);
|
||
};
|
||
const closeOnEscape = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') setOpen(false);
|
||
};
|
||
document.addEventListener('pointerdown', closeOnPointer);
|
||
document.addEventListener('keydown', closeOnEscape);
|
||
return () => {
|
||
document.removeEventListener('pointerdown', closeOnPointer);
|
||
document.removeEventListener('keydown', closeOnEscape);
|
||
};
|
||
}, [open]);
|
||
useEffect(() => {
|
||
if (!open) setSearch('');
|
||
}, [open]);
|
||
|
||
return (
|
||
<div
|
||
ref={ref}
|
||
className={`home-hero__footer-option home-hero__footer-option--select${open ? ' is-open' : ''}`}
|
||
data-field-name={fieldName}
|
||
>
|
||
<span>{label}</span>
|
||
<button
|
||
type="button"
|
||
className="home-hero__footer-select-trigger"
|
||
aria-label={label}
|
||
aria-haspopup="listbox"
|
||
aria-expanded={open}
|
||
data-testid={`home-hero-footer-option-${fieldName}`}
|
||
onClick={() => setOpen((prev) => !prev)}
|
||
>
|
||
{selected?.preview ? <DesignSystemOptionPreview option={selected.preview} compact /> : null}
|
||
{selected?.icon ? <FooterOptionIcon name={selected.icon} compact /> : null}
|
||
{selected?.modelIcon ? <ModelOptionIcon icon={selected.modelIcon} compact /> : null}
|
||
{selected?.ratioIcon ? <RatioOptionIcon icon={selected.ratioIcon} compact /> : null}
|
||
<span className="home-hero__footer-select-label">{selected?.label ?? value}</span>
|
||
<Icon name="chevron-down" size={12} aria-hidden />
|
||
</button>
|
||
{open ? (
|
||
<div
|
||
className={`home-hero__footer-select-menu${searchable ? ' home-hero__footer-select-menu--searchable' : ''}`}
|
||
role="listbox"
|
||
data-testid={`home-hero-footer-option-${fieldName}-menu`}
|
||
>
|
||
{searchable ? (
|
||
<div className="home-hero__footer-select-search">
|
||
<input
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
placeholder={searchPlaceholder}
|
||
aria-label={searchPlaceholder ?? label}
|
||
autoFocus
|
||
data-testid={`home-hero-footer-option-${fieldName}-search`}
|
||
/>
|
||
<div className="home-hero__footer-select-count">
|
||
{t('homeHero.footer.availableCount', { n: visibleOptions.length })}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{groupedOptions.length === 0 ? (
|
||
<div className="home-hero__footer-select-empty">{t('homeHero.footer.noMatches')}</div>
|
||
) : (
|
||
groupedOptions.map((group) => (
|
||
<div className="home-hero__footer-select-group" key={group.label ?? 'ungrouped'}>
|
||
{group.label ? (
|
||
<div className="home-hero__footer-select-group-label">{group.label}</div>
|
||
) : null}
|
||
{group.options.map((option) => (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={option.value === value}
|
||
className={`home-hero__footer-select-item${option.value === value ? ' is-selected' : ''}`}
|
||
onClick={() => {
|
||
onChange(option.submitValue ?? option.value);
|
||
setOpen(false);
|
||
}}
|
||
>
|
||
{option.preview ? <DesignSystemOptionPreview option={option.preview} /> : null}
|
||
{option.icon ? <FooterOptionIcon name={option.icon} /> : null}
|
||
{option.modelIcon ? <ModelOptionIcon icon={option.modelIcon} /> : null}
|
||
{option.ratioIcon ? <RatioOptionIcon icon={option.ratioIcon} /> : null}
|
||
<span className="home-hero__footer-select-copy">
|
||
<span className="home-hero__footer-select-label">{option.label}</span>
|
||
{option.description ? (
|
||
<span className="home-hero__footer-select-description">{option.description}</span>
|
||
) : null}
|
||
</span>
|
||
{option.meta ? <span className="home-hero__footer-select-meta">{option.meta}</span> : null}
|
||
{option.value === value ? <Icon name="check" size={14} aria-hidden /> : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface FooterSelectItemOption {
|
||
value: string;
|
||
submitValue?: string;
|
||
label: string;
|
||
group?: string;
|
||
icon?: IconName;
|
||
description?: string;
|
||
meta?: string;
|
||
modelIcon?: ModelOptionIconSpec;
|
||
ratioIcon?: RatioOptionIconSpec;
|
||
preview?: {
|
||
title: string;
|
||
swatches?: string[];
|
||
logoUrl?: string;
|
||
};
|
||
}
|
||
|
||
interface ModelOptionIconSpec {
|
||
label: string;
|
||
tone:
|
||
| 'openai'
|
||
| 'dalle'
|
||
| 'seed'
|
||
| 'sense'
|
||
| 'grok'
|
||
| 'google'
|
||
| 'router'
|
||
| 'flux'
|
||
| 'elevenlabs'
|
||
| 'fishaudio'
|
||
| 'minimax'
|
||
| 'suno'
|
||
| 'audio'
|
||
| 'custom';
|
||
src?: string;
|
||
}
|
||
|
||
interface RatioOptionIconSpec {
|
||
width: number;
|
||
height: number;
|
||
tone: 'square' | 'wide' | 'tall' | 'standard' | 'portrait' | 'custom';
|
||
}
|
||
|
||
function FooterOptionIcon({
|
||
name,
|
||
compact = false,
|
||
}: {
|
||
name: IconName;
|
||
compact?: boolean;
|
||
}) {
|
||
return (
|
||
<span
|
||
className={`home-hero__footer-option-icon${compact ? ' home-hero__footer-option-icon--compact' : ''}`}
|
||
aria-hidden
|
||
>
|
||
<Icon name={name} size={13} />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ModelOptionIcon({
|
||
icon,
|
||
compact = false,
|
||
}: {
|
||
icon: ModelOptionIconSpec;
|
||
compact?: boolean;
|
||
}) {
|
||
return (
|
||
<span
|
||
className={`home-hero__model-option-icon home-hero__model-option-icon--${icon.tone}${compact ? ' home-hero__model-option-icon--compact' : ''}`}
|
||
aria-hidden
|
||
>
|
||
{icon.src ? <img src={icon.src} alt="" draggable={false} /> : icon.label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function RatioOptionIcon({
|
||
icon,
|
||
compact = false,
|
||
}: {
|
||
icon: RatioOptionIconSpec;
|
||
compact?: boolean;
|
||
}) {
|
||
return (
|
||
<span
|
||
className={`home-hero__ratio-option-icon home-hero__ratio-option-icon--${icon.tone}${compact ? ' home-hero__ratio-option-icon--compact' : ''}`}
|
||
aria-hidden
|
||
>
|
||
<i style={{ width: icon.width, height: icon.height }} />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function DesignSystemOptionPreview({
|
||
option,
|
||
compact = false,
|
||
}: {
|
||
option: { title: string; swatches?: string[]; logoUrl?: string };
|
||
compact?: boolean;
|
||
}) {
|
||
const swatches = (option.swatches ?? []).filter(Boolean).slice(0, compact ? 2 : 3);
|
||
const initial = option.title.trim().charAt(0).toUpperCase() || 'D';
|
||
return (
|
||
<span
|
||
className={`home-hero__ds-option-preview${compact ? ' home-hero__ds-option-preview--compact' : ''}`}
|
||
aria-hidden
|
||
>
|
||
{option.logoUrl ? (
|
||
<img src={option.logoUrl} alt="" loading="lazy" />
|
||
) : swatches.length > 0 ? (
|
||
swatches.map((swatch, index) => (
|
||
<i key={`${swatch}-${index}`} style={{ background: swatch }} />
|
||
))
|
||
) : (
|
||
<b>{initial}</b>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function footerInputLabel(field: InputFieldSpec, t: ReturnType<typeof useT>): string {
|
||
switch (field.name) {
|
||
case 'designSystem':
|
||
return t('homeHero.footer.designSystem');
|
||
case 'fidelity':
|
||
return t('newproj.fidelityLabel');
|
||
case 'speakerNotes':
|
||
return t('homeHero.footer.speakerNotes');
|
||
case 'model':
|
||
return t('newproj.modelLabel');
|
||
case 'ratio':
|
||
return t('homeHero.footer.ratio');
|
||
case 'duration':
|
||
return t('homeHero.footer.duration');
|
||
case 'resolution':
|
||
return t('homeHero.footer.resolution');
|
||
default:
|
||
return field.label ?? field.name;
|
||
}
|
||
}
|
||
|
||
function footerInputValueLabel(field: InputFieldSpec, value: string, t: ReturnType<typeof useT>): string {
|
||
if (field.name === 'fidelity') {
|
||
if (value === 'wireframe') return t('newproj.fidelityWireframe');
|
||
if (value === 'high-fidelity') return t('newproj.fidelityHigh');
|
||
}
|
||
if (field.name === 'speakerNotes') {
|
||
return footerSpeakerNotesEnabled(value) ? t('homeHero.footer.speakerNotes') : t('homeHero.footer.noSpeakerNotes');
|
||
}
|
||
return optionLabelMap(field)[value] ?? value;
|
||
}
|
||
|
||
function footerSpeakerNotesEnabled(value: unknown): boolean {
|
||
if (typeof value === 'boolean') return value;
|
||
if (value === undefined || value === null) return false;
|
||
const normalized = String(value).trim().toLowerCase();
|
||
if (!normalized) return false;
|
||
return !(
|
||
normalized === 'false' ||
|
||
normalized === 'no' ||
|
||
normalized === 'none' ||
|
||
normalized.includes('no speaker')
|
||
);
|
||
}
|
||
|
||
function footerInputValueIcon(field: InputFieldSpec, value: string): IconName | undefined {
|
||
if (field.name === 'fidelity') {
|
||
if (value === 'wireframe') return 'grid';
|
||
if (value === 'high-fidelity') return 'sparkles';
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function modelOptionIcon(value: string, label: string): ModelOptionIconSpec {
|
||
const normalized = `${value} ${label}`.toLowerCase();
|
||
if (normalized.includes('dall-e')) return { label: 'OpenAI', tone: 'dalle', src: '/model-icons/openai.svg' };
|
||
if (normalized.includes('gpt-image') || normalized.includes('openai') || normalized.includes('sora')) {
|
||
return { label: 'OpenAI', tone: 'openai', src: '/model-icons/openai.svg' };
|
||
}
|
||
if (normalized.includes('seedream') || normalized.includes('seededit') || normalized.includes('seedance') || normalized.includes('doubao') || normalized.includes('bytedance')) {
|
||
return { label: 'ByteDance', tone: 'seed', src: '/model-icons/bytedance.svg' };
|
||
}
|
||
if (normalized.includes('senseaudio')) return { label: 'SA', tone: 'sense' };
|
||
if (normalized.includes('grok') || normalized.includes('xai') || normalized.includes('xai/')) {
|
||
return { label: 'xAI', tone: 'grok', src: '/model-icons/x.svg' };
|
||
}
|
||
if (normalized.includes('gemini') || normalized.includes('imagen') || normalized.includes('veo') || normalized.includes('google') || normalized.includes('nano-banana')) {
|
||
return { label: 'Google Gemini', tone: 'google', src: '/model-icons/google-gemini.svg' };
|
||
}
|
||
if (normalized.includes('flux') || normalized.includes('bfl') || normalized.includes('black-forest')) {
|
||
return { label: 'FLUX', tone: 'flux', src: '/model-icons/flux.svg' };
|
||
}
|
||
if (normalized.includes('openrouter')) return { label: 'OpenRouter', tone: 'router', src: '/model-icons/openrouter.svg' };
|
||
if (normalized.includes('imagerouter') || normalized.includes('/')) return { label: 'IR', tone: 'router' };
|
||
if (normalized.includes('eleven')) {
|
||
return { label: 'ElevenLabs', tone: 'elevenlabs', src: '/model-icons/elevenlabs.svg' };
|
||
}
|
||
if (normalized.includes('fish')) {
|
||
return { label: 'Fish Audio', tone: 'fishaudio', src: '/model-icons/fishaudio.svg' };
|
||
}
|
||
if (normalized.includes('minimax')) {
|
||
return { label: 'MiniMax', tone: 'minimax', src: '/model-icons/minimax.svg' };
|
||
}
|
||
if (normalized.includes('suno')) return { label: 'Suno', tone: 'suno', src: '/model-icons/suno.svg' };
|
||
if (
|
||
normalized.includes('udio') ||
|
||
normalized.includes('audio') ||
|
||
normalized.includes('voice')
|
||
) {
|
||
return { label: modelInitials(label), tone: 'audio' };
|
||
}
|
||
return { label: modelInitials(label || value), tone: 'custom' };
|
||
}
|
||
|
||
function modelInitials(input: string): string {
|
||
const cleaned = input
|
||
.replace(/^[^a-z0-9]+/i, '')
|
||
.replace(/^(gpt|model)[-_ ]*/i, '')
|
||
.trim();
|
||
const parts = cleaned.split(/[^a-z0-9]+/i).filter(Boolean);
|
||
const initials = parts.length >= 2
|
||
? `${parts[0]?.[0] ?? ''}${parts[1]?.[0] ?? ''}`
|
||
: (parts[0] ?? cleaned).slice(0, 2);
|
||
return initials.toUpperCase() || 'M';
|
||
}
|
||
|
||
function ratioOptionIcon(value: string): RatioOptionIconSpec {
|
||
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*[:x/]\s*(\d+(?:\.\d+)?)$/i);
|
||
const rawWidth = Number(match?.[1] ?? 1);
|
||
const rawHeight = Number(match?.[2] ?? 1);
|
||
const ratioWidth = Number.isFinite(rawWidth) && rawWidth > 0 ? rawWidth : 1;
|
||
const ratioHeight = Number.isFinite(rawHeight) && rawHeight > 0 ? rawHeight : 1;
|
||
const maxEdge = 17;
|
||
const scale = maxEdge / Math.max(ratioWidth, ratioHeight);
|
||
const width = Math.max(8, Math.round(ratioWidth * scale));
|
||
const height = Math.max(8, Math.round(ratioHeight * scale));
|
||
const normalized = `${ratioWidth}:${ratioHeight}`;
|
||
const tone = (() => {
|
||
if (normalized === '1:1') return 'square';
|
||
if (normalized === '16:9') return 'wide';
|
||
if (normalized === '9:16') return 'tall';
|
||
if (normalized === '4:3') return 'standard';
|
||
if (normalized === '3:4') return 'portrait';
|
||
return ratioWidth > ratioHeight ? 'wide' : ratioHeight > ratioWidth ? 'tall' : 'custom';
|
||
})();
|
||
return { width, height, tone };
|
||
}
|
||
|
||
function renderInlinePromptEditor(
|
||
field: InputFieldSpec,
|
||
value: unknown,
|
||
onChange: (value: unknown) => void,
|
||
locale: ReturnType<typeof useI18n>['locale'],
|
||
) {
|
||
const label = localizePluginInputLabel(locale, field);
|
||
if (field.type === 'select' && Array.isArray(field.options)) {
|
||
const optionLabels = optionLabelMap(field);
|
||
return (
|
||
<select
|
||
className="home-hero__prompt-option-input"
|
||
value={value === undefined || value === null ? '' : String(value)}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
data-testid={`home-hero-prompt-option-${field.name}-select`}
|
||
aria-label={label}
|
||
>
|
||
{field.placeholder ? (
|
||
<option value="">{localizePluginPlaceholder(locale, field.placeholder)}</option>
|
||
) : null}
|
||
{field.options.map((option) => (
|
||
<option key={option} value={option}>
|
||
{localizePluginDisplayValue(locale, optionLabels[option] ?? option)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
}
|
||
return (
|
||
<input
|
||
className="home-hero__prompt-option-input"
|
||
value={value === undefined || value === null ? '' : String(value)}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
data-testid={`home-hero-prompt-option-${field.name}-input`}
|
||
aria-label={label}
|
||
placeholder={localizePluginPlaceholder(locale, field.placeholder)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function formatPromptInputValue(
|
||
locale: ReturnType<typeof useI18n>['locale'],
|
||
field: InputFieldSpec | null,
|
||
value: unknown,
|
||
fallbackText: string,
|
||
t?: ReturnType<typeof useT>,
|
||
): string {
|
||
if (value === undefined || value === null || value === '') {
|
||
return field ? localizePluginPlaceholder(locale, field.placeholder, fallbackText) : fallbackText;
|
||
}
|
||
const raw = String(value);
|
||
const optionLabel = field ? optionLabelMap(field)[raw] : undefined;
|
||
const label = field && t ? footerInputValueLabel(field, raw, t) : optionLabel ?? raw;
|
||
return localizePluginDisplayValue(locale, label);
|
||
}
|
||
|
||
function optionLabelMap(field: InputFieldSpec): Record<string, string> {
|
||
const labels = (field as { optionLabels?: unknown }).optionLabels;
|
||
return labels && typeof labels === 'object' && !Array.isArray(labels)
|
||
? labels as Record<string, string>
|
||
: {};
|
||
}
|
||
|
||
function fieldPopoverNote(field: InputFieldSpec): string {
|
||
const note = (field as { popoverNote?: unknown }).popoverNote;
|
||
return typeof note === 'string' ? note : '';
|
||
}
|
||
|
||
function fieldPopoverNoteTone(field: InputFieldSpec): string {
|
||
const tone = (field as { popoverNoteTone?: unknown }).popoverNoteTone;
|
||
return tone === 'warning' ? 'warning' : 'info';
|
||
}
|
||
|
||
function getContextMention(value: string): ContextMention | null {
|
||
const match = /(^|\s)@([^\s@]*)$/.exec(value);
|
||
if (!match) return null;
|
||
const prefix = match[1] ?? '';
|
||
const query = match[2] ?? '';
|
||
const start = match.index + prefix.length;
|
||
return {
|
||
start,
|
||
end: value.length,
|
||
query,
|
||
};
|
||
}
|
||
|
||
function replaceMentionTokenWithText(
|
||
value: string,
|
||
mention: ContextMention,
|
||
replacement: string,
|
||
): string {
|
||
const before = value.slice(0, mention.start).trimEnd();
|
||
const after = value.slice(mention.end).trimStart();
|
||
return [before, replacement.trim(), after].filter(Boolean).join(' ').trim();
|
||
}
|
||
|
||
function pluginMatchesQuery(plugin: InstalledPluginRecord, query: string): boolean {
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return true;
|
||
return [
|
||
plugin.title,
|
||
plugin.id,
|
||
plugin.sourceKind,
|
||
plugin.manifest?.description ?? '',
|
||
...(plugin.manifest?.tags ?? []),
|
||
]
|
||
.join(' ')
|
||
.toLowerCase()
|
||
.includes(q);
|
||
}
|
||
|
||
function skillMatchesQuery(skill: SkillSummary, query: string): boolean {
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return true;
|
||
return [
|
||
skill.id,
|
||
skill.name,
|
||
skill.description,
|
||
skill.mode,
|
||
skill.surface ?? '',
|
||
...skill.triggers,
|
||
]
|
||
.join(' ')
|
||
.toLowerCase()
|
||
.includes(q);
|
||
}
|
||
|
||
function mcpServerMatchesQuery(server: McpServerConfig, query: string): boolean {
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return true;
|
||
return [
|
||
server.id,
|
||
server.label ?? '',
|
||
server.transport,
|
||
server.url ?? '',
|
||
server.command ?? '',
|
||
]
|
||
.join(' ')
|
||
.toLowerCase()
|
||
.includes(q);
|
||
}
|
||
|
||
function connectorMatchesQuery(connector: ConnectorDetail, query: string): boolean {
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return true;
|
||
return [
|
||
connector.id,
|
||
connector.name,
|
||
connector.provider,
|
||
connector.category,
|
||
connector.description ?? '',
|
||
connector.accountLabel ?? '',
|
||
]
|
||
.join(' ')
|
||
.toLowerCase()
|
||
.includes(q);
|
||
}
|
||
|
||
function getPluginSourceLabel(plugin: InstalledPluginRecord, t: ReturnType<typeof useT>): string {
|
||
return plugin.sourceKind === 'bundled' ? t('homeHero.official') : t('homeHero.myPlugin');
|
||
}
|
||
|
||
function getPluginQueryPreview(plugin: InstalledPluginRecord): string {
|
||
const raw = plugin.manifest?.od?.useCase?.query;
|
||
const value =
|
||
typeof raw === 'string'
|
||
? raw
|
||
: raw && typeof raw === 'object' && !Array.isArray(raw)
|
||
? raw.en ?? raw['zh-CN'] ?? Object.values(raw).find((entry): entry is string => (
|
||
typeof entry === 'string' && entry.length > 0
|
||
)) ?? ''
|
||
: '';
|
||
const trimmed = value.replace(/\s+/g, ' ').trim();
|
||
return trimmed.length > 96 ? `${trimmed.slice(0, 96)}…` : trimmed;
|
||
}
|
||
|
||
interface RailGroupProps {
|
||
group: ChipGroup;
|
||
activeChipId: string | null;
|
||
pendingChipId: string | null;
|
||
pendingPluginId: string | null;
|
||
pluginsLoading: boolean;
|
||
onPickChip: (chip: HomeHeroChip) => void;
|
||
variant?: 'rail' | 'tabs';
|
||
children?: ReactNode;
|
||
}
|
||
|
||
function RailGroup({
|
||
group,
|
||
activeChipId,
|
||
pendingChipId,
|
||
pendingPluginId,
|
||
pluginsLoading,
|
||
onPickChip,
|
||
variant = 'rail',
|
||
children,
|
||
}: RailGroupProps) {
|
||
const t = useT();
|
||
const chips = useMemo(() => chipsForGroup(group), [group]);
|
||
const isTabs = variant === 'tabs';
|
||
return (
|
||
<div
|
||
className={
|
||
isTabs
|
||
? `home-hero__type-tabs home-hero__type-tabs--${group}`
|
||
: `home-hero__rail-group home-hero__rail-group--${group}`
|
||
}
|
||
data-testid={isTabs ? 'home-hero-type-tabs' : undefined}
|
||
data-rail-group={group}
|
||
role={isTabs ? 'tablist' : undefined}
|
||
aria-label={isTabs ? t('homeHero.railAria') : undefined}
|
||
>
|
||
{chips.map((chip) => {
|
||
const isActive = activeChipId === chip.id;
|
||
const isPending = pendingChipId === chip.id;
|
||
const cls = isTabs
|
||
? ['home-hero__type-tab', `home-hero__type-tab--${group}`]
|
||
: ['home-hero__rail-chip', `home-hero__rail-chip--${group}`];
|
||
const label = homeHeroChipLabel(chip.id, t);
|
||
if (isActive) cls.push('is-active');
|
||
if (isPending) cls.push('is-pending');
|
||
return (
|
||
<button
|
||
key={chip.id}
|
||
type="button"
|
||
className={cls.join(' ')}
|
||
data-chip-id={chip.id}
|
||
data-testid={`home-hero-rail-${chip.id}`}
|
||
onClick={() => onPickChip(chip)}
|
||
disabled={pluginsLoading || isPending || pendingPluginId !== null}
|
||
role={isTabs ? 'tab' : undefined}
|
||
aria-selected={isTabs ? isActive : undefined}
|
||
aria-pressed={isTabs ? undefined : isActive}
|
||
title={homeHeroChipTitle(chip, t)}
|
||
>
|
||
<Icon
|
||
name={chip.icon}
|
||
size={14}
|
||
className={isTabs ? 'home-hero__type-tab-icon' : 'home-hero__rail-chip-icon'}
|
||
/>
|
||
<span className={isTabs ? 'home-hero__type-tab-label' : 'home-hero__rail-chip-label'}>
|
||
{label}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ActiveTypeChip({ chip, onClear }: { chip: HomeHeroChip; onClear: () => void }) {
|
||
const t = useT();
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="home-hero__active-type-chip"
|
||
data-testid="home-hero-active-type-chip"
|
||
data-chip-id={chip.id}
|
||
title={homeHeroChipTitle(chip, t)}
|
||
aria-label={`${homeHeroChipLabel(chip.id, t)} ${t('common.delete')}`}
|
||
onClick={onClear}
|
||
>
|
||
<span className="home-hero__active-type-chip-icon" aria-hidden>
|
||
<Icon name={chip.icon} size={13} />
|
||
</span>
|
||
<span>{homeHeroChipLabel(chip.id, t)}</span>
|
||
<Icon name="close" size={12} className="home-hero__active-type-chip-close" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
interface ShortcutsMenuProps {
|
||
activeChipId: string | null;
|
||
pendingChipId: string | null;
|
||
pendingPluginId: string | null;
|
||
pluginsLoading: boolean;
|
||
open: boolean;
|
||
refNode: RefObject<HTMLDivElement>;
|
||
onOpenChange: (open: boolean) => void;
|
||
onPickChip: (chip: HomeHeroChip) => void;
|
||
}
|
||
|
||
function ShortcutsMenu({
|
||
activeChipId,
|
||
pendingChipId,
|
||
pendingPluginId,
|
||
pluginsLoading,
|
||
open,
|
||
refNode,
|
||
onOpenChange,
|
||
onPickChip,
|
||
}: ShortcutsMenuProps) {
|
||
const t = useT();
|
||
const shortcuts = useMemo(() => chipsForGroup('migrate'), []);
|
||
const disabled = pluginsLoading || pendingPluginId !== null;
|
||
const hasActiveShortcut = shortcuts.some((chip) => chip.id === activeChipId);
|
||
const hasPendingShortcut = shortcuts.some((chip) => chip.id === pendingChipId);
|
||
const triggerClass = [
|
||
'home-hero__type-tab',
|
||
'home-hero__type-tab--more',
|
||
hasActiveShortcut ? 'is-active' : '',
|
||
hasPendingShortcut ? 'is-pending' : '',
|
||
].filter(Boolean).join(' ');
|
||
return (
|
||
<div
|
||
ref={refNode}
|
||
className="home-hero__shortcut-menu"
|
||
data-testid="home-hero-shortcuts"
|
||
data-rail-group="migrate"
|
||
>
|
||
<button
|
||
type="button"
|
||
className={triggerClass}
|
||
data-testid="home-hero-shortcuts-trigger"
|
||
disabled={disabled}
|
||
aria-haspopup="menu"
|
||
aria-expanded={open}
|
||
aria-label={t('homeHero.moreShortcuts')}
|
||
title={t('homeHero.moreShortcuts')}
|
||
onClick={() => onOpenChange(!open)}
|
||
>
|
||
<Icon name="more-horizontal" size={16} className="home-hero__type-tab-icon" />
|
||
</button>
|
||
{open ? (
|
||
<div
|
||
className="home-hero__shortcut-menu-panel"
|
||
role="menu"
|
||
aria-label={t('homeHero.moreShortcuts')}
|
||
data-testid="home-hero-shortcuts-menu"
|
||
>
|
||
{shortcuts.map((chip) => {
|
||
const isActive = activeChipId === chip.id;
|
||
const isPending = pendingChipId === chip.id;
|
||
const cls = ['home-hero__shortcut-menu-item'];
|
||
if (isActive) cls.push('is-active');
|
||
if (isPending) cls.push('is-pending');
|
||
return (
|
||
<button
|
||
key={chip.id}
|
||
type="button"
|
||
role="menuitem"
|
||
className={cls.join(' ')}
|
||
data-chip-id={chip.id}
|
||
data-testid={`home-hero-rail-${chip.id}`}
|
||
disabled={pluginsLoading || isPending || pendingPluginId !== null}
|
||
title={homeHeroChipTitle(chip, t)}
|
||
onClick={() => onPickChip(chip)}
|
||
>
|
||
<Icon name={chip.icon} size={14} className="home-hero__shortcut-menu-icon" />
|
||
<span>{homeHeroChipLabel(chip.id, t)}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function homeHeroChipLabel(chipId: string, t: ReturnType<typeof useT>): string {
|
||
switch (chipId) {
|
||
case 'prototype': return t('homeHero.chip.prototype');
|
||
case 'live-artifact': return t('homeHero.chip.liveArtifact');
|
||
case 'deck': return t('homeHero.chip.deck');
|
||
case 'image': return t('homeHero.chip.image');
|
||
case 'video': return t('homeHero.chip.video');
|
||
case 'hyperframes': return t('homeHero.chip.hyperframes');
|
||
case 'audio': return t('homeHero.chip.audio');
|
||
case 'create-plugin': return t('homeHero.chip.createPlugin');
|
||
case 'figma': return t('homeHero.chip.figma');
|
||
case 'template': return t('homeHero.chip.template');
|
||
default: return chipId;
|
||
}
|
||
}
|
||
|
||
function homeHeroChipTitle(chip: HomeHeroChip, t: ReturnType<typeof useT>): string {
|
||
switch (chip.id) {
|
||
case 'live-artifact': return t('homeHero.chip.liveArtifactHint');
|
||
case 'hyperframes': return t('homeHero.chip.hyperframesHint');
|
||
case 'create-plugin': return t('homeHero.chip.createPluginHint');
|
||
case 'figma': return t('homeHero.chip.figmaHint');
|
||
case 'template': return t('homeHero.chip.templateHint');
|
||
default: return homeHeroChipLabel(chip.id, t);
|
||
}
|
||
}
|
||
|
||
function homeHeroExamplePluginsForChip(
|
||
chipId: string,
|
||
plugins: InstalledPluginRecord[],
|
||
locale: Locale,
|
||
): InstalledPluginRecord[] {
|
||
const presets = plugins
|
||
.filter((plugin) => (
|
||
pluginMatchesExampleChip(plugin, chipId) ||
|
||
curatedPluginPriorityForChip(plugin, chipId) !== null
|
||
))
|
||
.filter((plugin) => (
|
||
Boolean(pluginPresetQuery(plugin, locale)) ||
|
||
curatedPluginPriorityForChip(plugin, chipId) !== null
|
||
))
|
||
.sort((a, b) => comparePluginPresetOrder(a, b, chipId))
|
||
.slice(0, 18);
|
||
if (chipId === 'image') {
|
||
return movePluginPresetToEnd(presets, 'example-hatch-pet');
|
||
}
|
||
return presets;
|
||
}
|
||
|
||
function comparePluginPresetOrder(
|
||
a: InstalledPluginRecord,
|
||
b: InstalledPluginRecord,
|
||
chipId: string,
|
||
): number {
|
||
const aCurated = curatedPluginPriorityForChip(a, chipId);
|
||
const bCurated = curatedPluginPriorityForChip(b, chipId);
|
||
if (aCurated !== null || bCurated !== null) {
|
||
if (aCurated !== null && bCurated === null) return -1;
|
||
if (aCurated === null && bCurated !== null) return 1;
|
||
if (aCurated !== bCurated) return (aCurated ?? 0) - (bCurated ?? 0);
|
||
}
|
||
const rankDelta = pluginPresetRank(b, chipId) - pluginPresetRank(a, chipId);
|
||
if (rankDelta !== 0) return rankDelta;
|
||
return (a.title || a.id).localeCompare(b.title || b.id);
|
||
}
|
||
|
||
function movePluginPresetToEnd(
|
||
records: InstalledPluginRecord[],
|
||
pluginId: string,
|
||
): InstalledPluginRecord[] {
|
||
const index = records.findIndex((record) => record.id === pluginId);
|
||
if (index < 0 || index === records.length - 1) return records;
|
||
const record = records[index]!;
|
||
return [
|
||
...records.slice(0, index),
|
||
...records.slice(index + 1),
|
||
record,
|
||
];
|
||
}
|
||
|
||
function pluginMatchesExampleChip(record: InstalledPluginRecord, chipId: string): boolean {
|
||
const slugs = pluginRecordSlugs(record);
|
||
const has = (...values: string[]) => values.some((value) => slugs.has(value));
|
||
const hasPart = (...values: string[]) => {
|
||
const all = [...slugs];
|
||
return values.some((value) =>
|
||
all.some((slug) => slug === value || slug.includes(value) || slug.split('-').includes(value)),
|
||
);
|
||
};
|
||
switch (chipId) {
|
||
case 'prototype':
|
||
return has('prototype') || hasPart('web-prototype');
|
||
case 'deck':
|
||
return has('deck', 'slides', 'slide-deck') || hasPart('slide', 'deck');
|
||
case 'hyperframes':
|
||
return hasPart('hyperframes', 'hyperframe');
|
||
case 'live-artifact':
|
||
return has('live-artifact') || hasPart('live-artifact');
|
||
case 'image':
|
||
return (has('image') || hasPart('image-template')) && !hasPart('video', 'audio', 'live-artifact');
|
||
case 'video':
|
||
return (has('video') || hasPart('video-template')) && !hasPart('hyperframes', 'audio');
|
||
case 'audio':
|
||
return has('audio') || hasPart('audio');
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function pluginPresetRank(record: InstalledPluginRecord, chipId: string): number {
|
||
const slugs = pluginRecordSlugs(record);
|
||
let score = 0;
|
||
if (record.sourceKind === 'bundled') score += 20;
|
||
if (record.id.startsWith('example-')) score += 12;
|
||
if (record.id.includes('template')) score += 8;
|
||
if (inferPluginPreview(record).kind !== 'text') score += 6;
|
||
if (slugs.has(chipId)) score += 4;
|
||
if (record.manifest?.od?.preview) score += 3;
|
||
return score;
|
||
}
|
||
|
||
function pluginRecordSlugs(record: InstalledPluginRecord): Set<string> {
|
||
const od = record.manifest?.od ?? {};
|
||
const rawValues = [
|
||
record.id,
|
||
record.title,
|
||
record.manifest?.name,
|
||
record.manifest?.title,
|
||
fieldString(od, 'mode'),
|
||
fieldString(od, 'surface'),
|
||
fieldString(od, 'scenario'),
|
||
fieldString(od, 'taskKind'),
|
||
...(record.manifest?.tags ?? []),
|
||
];
|
||
return new Set(rawValues.map((value) => slugifyHomeValue(value ?? '')).filter(Boolean));
|
||
}
|
||
|
||
function fieldString(source: Record<string, unknown>, key: string): string | undefined {
|
||
const value = source[key];
|
||
return typeof value === 'string' ? value : undefined;
|
||
}
|
||
|
||
function slugifyHomeValue(value: string): string {
|
||
return value
|
||
.toLowerCase()
|
||
.normalize('NFKD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/(^-|-$)+/g, '');
|
||
}
|
||
|
||
function pluginPresetPromptPreview(
|
||
record: InstalledPluginRecord,
|
||
locale: Locale,
|
||
chipId: string,
|
||
): string {
|
||
const query = pluginPresetQuery(record, locale);
|
||
const rendered = query ? renderPluginPresetQuery(record, query) : record.manifest?.description ?? '';
|
||
return textPromptForPluginPreset(record, rendered, chipId, locale);
|
||
}
|
||
|
||
function pluginPresetQuery(record: InstalledPluginRecord, locale: Locale): string | null {
|
||
const query = record.manifest?.od?.useCase?.query;
|
||
if (typeof query === 'string') return query;
|
||
if (query && typeof query === 'object') {
|
||
const localized = query as Record<string, unknown>;
|
||
const exact = localized[locale];
|
||
if (typeof exact === 'string') return exact;
|
||
const language = locale.split('-')[0];
|
||
const languageMatch = Object.entries(localized).find(([key, value]) => (
|
||
key.toLowerCase().startsWith(`${language}-`) && typeof value === 'string'
|
||
));
|
||
if (typeof languageMatch?.[1] === 'string') return languageMatch[1];
|
||
for (const key of ['zh-CN', 'en', 'default']) {
|
||
if (typeof localized[key] === 'string') return localized[key];
|
||
}
|
||
const first = Object.values(localized).find((value) => typeof value === 'string');
|
||
if (typeof first === 'string') return first;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function renderPluginPresetQuery(record: InstalledPluginRecord, query: string): string {
|
||
const fields = record.manifest?.od?.inputs ?? [];
|
||
const valueByName = new Map<string, string>();
|
||
for (const field of fields) {
|
||
const value = field.default ?? field.placeholder ?? field.label ?? field.name;
|
||
valueByName.set(field.name, String(value));
|
||
}
|
||
return query
|
||
.replace(
|
||
HOME_ESCAPED_ARGUMENT_PLACEHOLDER_PATTERN,
|
||
(_placeholder, _name: string | undefined, defaultValue: string | undefined) => defaultValue ?? '',
|
||
)
|
||
.replace(
|
||
HOME_ARGUMENT_PLACEHOLDER_PATTERN,
|
||
(
|
||
_placeholder,
|
||
_doubleName: string | undefined,
|
||
_singleName: string | undefined,
|
||
doubleDefault: string | undefined,
|
||
singleDefault: string | undefined,
|
||
) => doubleDefault ?? singleDefault ?? '',
|
||
)
|
||
.replace(INPUT_PLACEHOLDER_PATTERN, (_placeholder, key: string) => (
|
||
valueByName.get(key) ?? key
|
||
));
|
||
}
|
||
|
||
function textPromptForPluginPreset(
|
||
record: InstalledPluginRecord,
|
||
prompt: string,
|
||
chipId: string,
|
||
locale: Locale,
|
||
): string {
|
||
const cleaned = prompt.trim();
|
||
const structured = parseStructuredPresetPrompt(cleaned);
|
||
if (structured !== null) {
|
||
return describeStructuredPresetPrompt(record, structured, chipId, locale);
|
||
}
|
||
if (cleaned.length > 0) return cleaned;
|
||
return fallbackPluginPresetPrompt(record, chipId, locale);
|
||
}
|
||
|
||
function parseStructuredPresetPrompt(value: string): unknown | null {
|
||
const trimmed = value.trim();
|
||
if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) return null;
|
||
try {
|
||
return JSON.parse(trimmed);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function describeStructuredPresetPrompt(
|
||
record: InstalledPluginRecord,
|
||
structured: unknown,
|
||
chipId: string,
|
||
locale: Locale,
|
||
): string {
|
||
const zh = isChineseLocale(locale);
|
||
const artifact = pluginPresetArtifactLabel(chipId, zh);
|
||
const title = record.title.trim();
|
||
const strings = collectStructuredPromptStrings(structured);
|
||
const main =
|
||
strings.find((item) => isMainPromptField(item.key) && item.value.length >= 8)?.value ??
|
||
strings.find((item) => item.value.length >= 16)?.value ??
|
||
record.manifest?.description ??
|
||
title;
|
||
const detailValues = uniquePromptStrings(
|
||
strings
|
||
.filter((item) => item.value !== main)
|
||
.filter((item) => isUsefulPromptDetail(item.value))
|
||
.map((item) => item.value),
|
||
).slice(0, 4);
|
||
if (zh) {
|
||
const details = detailValues.length > 0
|
||
? `重点包含:${detailValues.join(';')}。`
|
||
: '';
|
||
return `使用「${title}」插件生成${artifact}。${main}${sentenceEnd(main)}${details}`;
|
||
}
|
||
const details = detailValues.length > 0
|
||
? ` Include ${detailValues.join('; ')}.`
|
||
: '';
|
||
return `Create ${englishArticle(artifact)} ${artifact} with the "${title}" preset. ${main}${englishSentenceEnd(main)}${details}`;
|
||
}
|
||
|
||
function collectStructuredPromptStrings(
|
||
value: unknown,
|
||
path: string[] = [],
|
||
): Array<{ key: string; value: string }> {
|
||
if (typeof value === 'string') {
|
||
const text = value.trim();
|
||
if (!text) return [];
|
||
return [{ key: path[path.length - 1] ?? '', value: text }];
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return value.flatMap((item, index) => collectStructuredPromptStrings(item, [...path, String(index)]));
|
||
}
|
||
if (value && typeof value === 'object') {
|
||
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) =>
|
||
collectStructuredPromptStrings(child, [...path, key]),
|
||
);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function isMainPromptField(key: string): boolean {
|
||
return [
|
||
'instruction',
|
||
'prompt',
|
||
'description',
|
||
'subject',
|
||
'brief',
|
||
'goal',
|
||
].includes(key.toLowerCase());
|
||
}
|
||
|
||
function isUsefulPromptDetail(value: string): boolean {
|
||
if (value.length < 8) return false;
|
||
if (/^l\d+:/iu.test(value)) return false;
|
||
return true;
|
||
}
|
||
|
||
function uniquePromptStrings(values: string[]): string[] {
|
||
const seen = new Set<string>();
|
||
const result: string[] = [];
|
||
for (const value of values) {
|
||
const normalized = value.toLowerCase();
|
||
if (seen.has(normalized)) continue;
|
||
seen.add(normalized);
|
||
result.push(value);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function sentenceEnd(value: string): string {
|
||
return /[.!?。!?]$/u.test(value.trim()) ? '' : '。';
|
||
}
|
||
|
||
function englishSentenceEnd(value: string): string {
|
||
return /[.!?。!?]$/u.test(value.trim()) ? '' : '.';
|
||
}
|
||
|
||
function pluginPresetArtifactLabel(chipId: string, zh: boolean): string {
|
||
if (zh) {
|
||
switch (chipId) {
|
||
case 'prototype': return '一个交互原型';
|
||
case 'deck': return '一套 PPT slide';
|
||
case 'image': return '一张图片';
|
||
case 'video': return '一段视频';
|
||
case 'hyperframes': return '一段 HyperFrames 动效视频';
|
||
case 'audio': return '一段音频';
|
||
default: return '一个设计产物';
|
||
}
|
||
}
|
||
switch (chipId) {
|
||
case 'prototype': return 'interactive prototype';
|
||
case 'deck': return 'PPT slide deck';
|
||
case 'image': return 'image';
|
||
case 'video': return 'video';
|
||
case 'hyperframes': return 'HyperFrames motion video';
|
||
case 'audio': return 'audio clip';
|
||
default: return 'design artifact';
|
||
}
|
||
}
|
||
|
||
function englishArticle(noun: string): 'a' | 'an' {
|
||
return /^[aeiou]/iu.test(noun) ? 'an' : 'a';
|
||
}
|
||
|
||
function fallbackPluginPresetPrompt(
|
||
record: InstalledPluginRecord,
|
||
chipId: string,
|
||
locale: Locale,
|
||
): string {
|
||
const zh = isChineseLocale(locale);
|
||
const artifact = pluginPresetArtifactLabel(chipId, zh);
|
||
const description = record.manifest?.description?.trim();
|
||
if (zh) {
|
||
return `使用「${record.title}」插件生成${artifact}${description ? `,方向是:${description}` : ''}。`;
|
||
}
|
||
return `Create ${englishArticle(artifact)} ${artifact} with the "${record.title}" preset${description ? `: ${description}` : '.'}`;
|
||
}
|
||
|
||
const HOME_ESCAPED_ARGUMENT_PLACEHOLDER_PATTERN =
|
||
/\{argument\s+name=\\"([^"]+)\\"\s+default=\\"([^"]*)\\"[^}]*\}/g;
|
||
|
||
const HOME_ARGUMENT_PLACEHOLDER_PATTERN =
|
||
/\{argument\s+name=(?:"([^"]+)"|'([^']+)')\s+default=(?:"([^"]*)"|'([^']*)')[^}]*\}/g;
|
||
|
||
function homeHeroChipPromptExamples(chipId: string, locale: Locale): string[] {
|
||
const zh = isChineseLocale(locale);
|
||
switch (chipId) {
|
||
case 'prototype':
|
||
return zh
|
||
? [
|
||
'为 AI CRM 设计一个高转化官网,包含首屏、功能卖点、客户案例和清晰的试用入口',
|
||
'为团队知识库做一个桌面端仪表盘,突出搜索、最近更新、权限状态和协作入口',
|
||
'重构金融 SaaS 的 onboarding 流程,让新用户能快速完成开户、连接数据和看到首个洞察',
|
||
'设计一个移动端健身教练 App 原型,覆盖目标设定、训练计划、打卡反馈和进度复盘',
|
||
]
|
||
: [
|
||
'Design a high-converting website for an AI CRM with a clear hero, feature story, proof points, and trial CTA',
|
||
'Create a desktop dashboard for a team knowledge base with search, recent updates, permissions, and collaboration entry points',
|
||
'Redesign onboarding for a financial SaaS product so new users can connect data, finish setup, and see first value fast',
|
||
'Prototype a mobile fitness coaching app covering goal setup, weekly plans, workout check-ins, and progress review',
|
||
];
|
||
case 'deck':
|
||
return zh
|
||
? [
|
||
'研究一个新产品发布的市场机会,输出竞品格局、目标用户、定价假设和上市叙事',
|
||
'生成每周团队状态报告,汇总进展、风险、关键指标变化和下周优先级',
|
||
'设计一份投资者推介材料,包含市场规模、增长模型、产品优势和三年预测数据',
|
||
'创建战略业务复盘演示文稿,讲清本季度表现、问题原因、机会判断和下一步行动',
|
||
]
|
||
: [
|
||
'Research the market opportunity for a product launch, including competitors, target users, pricing hypotheses, and launch narrative',
|
||
'Generate a weekly team status report with progress, risks, metric changes, and next-week priorities',
|
||
'Design an investor pitch with market sizing, growth model, product advantage, and three-year forecast data',
|
||
'Create a strategic business review deck covering quarterly performance, root causes, opportunities, and next actions',
|
||
];
|
||
case 'image':
|
||
return zh
|
||
? [
|
||
'生成一张玻璃质感 AI 工作台海报,画面包含多屏协作、柔和光影和高级产品发布氛围',
|
||
'为新款无线耳机做一张电商首屏主图,突出材质细节、佩戴场景和核心卖点',
|
||
'设计一张极简科技发布会 KV,用干净构图、强主视觉和少量文字表达新品发布',
|
||
'做一套社媒新品预热视觉,包含倒计时、局部特写、卖点揭示和发布日主图',
|
||
]
|
||
: [
|
||
'Generate a glassmorphism AI workspace poster with multi-screen collaboration, soft lighting, and a premium launch mood',
|
||
'Create an ecommerce hero image for new wireless headphones that highlights material detail, lifestyle context, and core benefits',
|
||
'Design a minimalist tech launch key visual with a clean composition, strong product focus, and restrained launch copy',
|
||
'Make a social teaser set for a product drop, including countdown, close-up detail, benefit reveal, and launch-day visual',
|
||
];
|
||
case 'video':
|
||
return zh
|
||
? [
|
||
'做一个 8 秒产品 reveal 短片,从暗场轮廓推进到完整产品特写,结尾出现品牌标识',
|
||
'生成一段 App 功能演示视频,按用户操作路径展示核心流程、关键状态和结果反馈',
|
||
'制作竖屏品牌开场动画,用节奏化文字、产品局部和 logo 收束,适合短视频开头',
|
||
'把一个网站转成 15 秒社媒广告,提炼首屏卖点、交互亮点和明确行动号召',
|
||
]
|
||
: [
|
||
'Make an 8-second product reveal film that moves from silhouette to close-up detail and ends on the brand mark',
|
||
'Generate an app feature demo video that follows the user journey, key states, and final outcome',
|
||
'Create a vertical brand opener with rhythmic typography, product close-ups, and a clean logo ending for short-form video',
|
||
'Turn a website into a 15-second social ad by extracting the hero claim, interaction highlights, and a clear CTA',
|
||
];
|
||
case 'hyperframes':
|
||
return zh
|
||
? [
|
||
'做一个带字幕的产品发布短片,包含标题卡、功能镜头、节奏转场和结尾 CTA',
|
||
'生成一段音频响应数据可视化,让柱状图、粒子和标题随旁白节奏变化',
|
||
'制作 logo outro 动效,用线条收束、轻微弹性和品牌色完成 3 秒结尾动画',
|
||
'做一个航线地图动态演示,展示城市节点、路径增长、里程数据和最终汇总画面',
|
||
]
|
||
: [
|
||
'Build a captioned product launch short with title cards, feature shots, rhythmic transitions, and an ending CTA',
|
||
'Generate an audio-reactive data visualization where bars, particles, and titles respond to narration beats',
|
||
'Create a 3-second logo outro using line convergence, subtle elasticity, and the brand color system',
|
||
'Make an animated flight-route map showing city nodes, route growth, mileage data, and a final summary frame',
|
||
];
|
||
case 'audio':
|
||
return zh
|
||
? [
|
||
'生成一段产品启动音效,听起来轻盈、可信、带一点未来感,适合桌面 App 打开时播放',
|
||
'制作 20 秒播客片头音乐,包含温暖前奏、清晰节拍和适合人声进入的收尾',
|
||
'做一个冥想 App 的环境音循环,使用柔和自然声、低频铺底和无缝循环结构',
|
||
'生成一组品牌通知提示音,区分成功、提醒和错误状态,但保持同一声音识别度',
|
||
]
|
||
: [
|
||
'Generate a product startup sound that feels light, trustworthy, slightly futuristic, and suitable for a desktop app launch',
|
||
'Create a 20-second podcast intro bed with a warm opening, clear pulse, and a clean handoff into voiceover',
|
||
'Make a seamless ambient loop for a meditation app using soft nature textures, low-frequency warmth, and calm pacing',
|
||
'Generate a branded notification sound set for success, reminder, and error states while keeping one sonic identity',
|
||
];
|
||
default:
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function isChineseLocale(locale: Locale): boolean {
|
||
return locale === 'zh-CN' || locale === 'zh-TW';
|
||
}
|