合并 master 最新更新
Project CI / Frontend tests (push) Failing after 24s
Project CI / Repository checks (push) Failing after 43s
Project CI / Backend tests (push) Successful in 3m5s
Project CI / Native shell tests (push) Failing after 6m49s

合入 master 的 BgFilter、CI、运维与现役平台改造。

保留 AI 游戏创作 Runtime、独立锁文件与原生壳验证链路。

修复共享充值账单组件、LLM 网关与退役 Agent 兼容边界。

同步冲突文档、锁文件和开发脚本。
This commit is contained in:
AIGameCreator App
2026-07-27 18:28:26 +08:00
1304 changed files with 170414 additions and 36096 deletions
+136
View File
@@ -0,0 +1,136 @@
import { useCallback, useEffect, useState } from 'react';
import { useAuthUi } from './components/auth/AuthUiContext';
import type { SelectionStage } from './components/platform-entry/platformEntryActiveTypes';
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
import { useHostNavigationCanGoBack } from './hooks/useHostNavigationCanGoBack';
import {
isAppHistoryState,
isKnownMainAppPagePath,
normalizeAppPath,
pushAppHistoryPath,
replaceAppHistoryPath,
resolveInitialSelectionStageFromPath,
resolvePathForSelectionStage,
shouldRedirectEditorCanvasWithoutProject,
} from './routing/activeAppPageRoutes';
import {
resolveAppTitleForSelectionStage,
syncAppTitle,
} from './services/activeAppTitle';
import {
refreshNativeAppHostRuntime,
subscribeHostRuntimeChange,
} from './services/host-bridge/hostBridge';
function resolveInitialAppSelectionStage() {
if (
shouldRedirectEditorCanvasWithoutProject(
window.location.pathname,
window.location.search,
)
) {
replaceAppHistoryPath('/project');
return 'project';
}
if (!isKnownMainAppPagePath(window.location.pathname)) {
replaceAppHistoryPath('/');
return 'platform';
}
return resolveInitialSelectionStageFromPath(window.location.pathname, false);
}
export default function App() {
const authUi = useAuthUi();
const hostNavigation = useHostNavigationCanGoBack();
const [, setHostRuntimeRevision] = useState(0);
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(
resolveInitialAppSelectionStage,
);
const setSelectionStage = useCallback(
(stage: SelectionStage, options?: { path?: string }) => {
setRawSelectionStage(stage);
pushAppHistoryPath(options?.path ?? resolvePathForSelectionStage(stage));
},
[],
);
useEffect(() => {
const unsubscribe = subscribeHostRuntimeChange(() => {
setHostRuntimeRevision((revision) => revision + 1);
});
void refreshNativeAppHostRuntime();
return unsubscribe;
}, []);
useEffect(() => {
const syncStageFromHistory = () => {
if (
shouldRedirectEditorCanvasWithoutProject(
window.location.pathname,
window.location.search,
)
) {
replaceAppHistoryPath('/project');
setRawSelectionStage('project');
return;
}
if (!isKnownMainAppPagePath(window.location.pathname)) {
replaceAppHistoryPath('/');
setRawSelectionStage('platform');
return;
}
setRawSelectionStage(
resolveInitialSelectionStageFromPath(window.location.pathname, false),
);
};
window.addEventListener('popstate', syncStageFromHistory);
return () => window.removeEventListener('popstate', syncStageFromHistory);
}, []);
useEffect(() => {
if (
!hostNavigation.isSupported ||
hostNavigation.canGoBack ||
selectionStage === 'platform' ||
isAppHistoryState(window.history.state)
) {
return;
}
const currentPath = normalizeAppPath(window.location.pathname);
const currentSearch = window.location.search;
replaceAppHistoryPath('/');
pushAppHistoryPath(`${currentPath}${currentSearch}`);
}, [hostNavigation.canGoBack, hostNavigation.isSupported, selectionStage]);
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const isImageEditorStage = selectionStage === 'image-editor';
const platformShellSurfaceClass = isImageEditorStage
? 'bg-white p-0'
: 'bg-[image:var(--platform-body-fill)] p-2 sm:p-4';
useEffect(() => {
syncAppTitle(resolveAppTitleForSelectionStage(selectionStage));
}, [selectionStage]);
return (
<div
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden ${platformShellSurfaceClass} font-sans text-[var(--platform-text-strong)]`}
>
<PlatformEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
/>
</div>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import App from './App';
import App from './ActiveApp';
import { AuthGate } from './components/auth/AuthGate';
export default function AuthenticatedApp() {
+52
View File
@@ -0,0 +1,52 @@
/* eslint-disable react-refresh/only-export-components */
import './index.css';
import { StrictMode, Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import { FloatingFeedbackEntry } from './components/common/FloatingFeedbackEntry';
import { stabilizeMobileViewportKeyboardFocus } from './mobileViewportKeyboardFocus';
import { lockMobileViewportZoom } from './mobileViewportZoomLock';
import { resolveAppRoute } from './routing/activeAppRoutes';
import {
getHostRuntime,
refreshNativeAppHostRuntime,
} from './services/host-bridge/hostBridge';
type AppRoot = ReturnType<typeof createRoot>;
declare global {
interface Window {
__tavernRealmsRoot__?: AppRoot;
}
}
const route = resolveAppRoute(window.location.pathname);
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Missing #root container');
}
function markWechatMiniProgramRuntime() {
if (getHostRuntime().kind === 'wechat_mini_program') {
document.documentElement.dataset.wechatMiniProgramRuntime = 'true';
}
}
const root = (window.__tavernRealmsRoot__ ??= createRoot(rootElement));
const RouteComponent = route.Component;
const routeElement = <RouteComponent />;
lockMobileViewportZoom();
stabilizeMobileViewportKeyboardFocus();
markWechatMiniProgramRuntime();
void refreshNativeAppHostRuntime();
root.render(
<StrictMode>
<Suspense fallback={null}>{routeElement}</Suspense>
<FloatingFeedbackEntry />
</StrictMode>,
);
+26 -7
View File
@@ -39,10 +39,9 @@ const authMocks = vi.hoisted(() => ({
}));
vi.mock('../../services/apiClient', async () => {
const actual =
await vi.importActual<typeof import('../../services/apiClient')>(
'../../services/apiClient',
);
const actual = await vi.importActual<
typeof import('../../services/apiClient')
>('../../services/apiClient');
return {
...actual,
@@ -480,9 +479,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
const phoneInput = within(dialog).getByLabelText(
'手机号',
) as HTMLInputElement;
const codeInput = within(dialog).getByLabelText(
'验证码',
) as HTMLInputElement;
const codeInput = within(dialog).getByLabelText('验证码') as HTMLInputElement;
expect(phoneInput.className).toContain('platform-text-field');
expect(codeInput.className).toContain('platform-text-field');
@@ -937,6 +934,28 @@ test('auth gate shows sms send feedback in the login modal', async () => {
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
});
test('auth gate shows mainland China phone validation errors', async () => {
const user = userEvent.setup();
authMocks.sendPhoneLoginCode.mockRejectedValueOnce(
new Error('仅支持中国大陆手机号(+86'),
);
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '+12025550123');
await user.click(within(dialog).getByRole('button', { name: '获取验证码' }));
expect(
await within(dialog).findByText('仅支持中国大陆手机号(+86'),
).toBeTruthy();
});
test('login modal resets draft state every time it is reopened', async () => {
const user = userEvent.setup();
+3 -3
View File
@@ -44,12 +44,12 @@ test('绑定手机号表单展示当前身份并提交手机号验证码', async
expect(screen.getByText('手机号')).toBeTruthy();
expect(screen.getByText('当前登录身份:微信旅人')).toBeTruthy();
expect(screen.getByText('AI 美术创作平台')).toBeTruthy();
expect(screen.queryByText('视觉叙事 RPG')).toBeNull();
await user.type(phoneInput, '13800000000');
await user.type(codeInput, '123456');
await user.click(
screen.getByRole('button', { name: '绑定手机号并进入游戏' }),
);
await user.click(screen.getByRole('button', { name: '绑定手机号并继续' }));
expect(onSubmit).toHaveBeenCalledWith('13800000000', '123456');
});
+14 -7
View File
@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
import { BRAND_ASSETS } from '../../uiAssets';
import type {
AuthCaptchaChallenge,
AuthUser,
} from '../../services/authService';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PLATFORM_BRAND_ASSETS } from '../common/platformBrandAssets';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
@@ -63,14 +66,16 @@ export function BindPhoneScreen({
}, [cooldownSeconds]);
return (
<div className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}>
<div
className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}
>
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(204,117,76,0.18),rgba(240,203,169,0.16))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__lockup">
<img
src={BRAND_ASSETS.taonierProductIp}
src={PLATFORM_BRAND_ASSETS.taonierProductIp}
alt=""
aria-hidden="true"
draggable={false}
@@ -78,7 +83,9 @@ export function BindPhoneScreen({
/>
<div className="selection-hero-brand__title"></div>
</div>
<div className="selection-hero-brand__subtitle"> RPG</div>
<div className="selection-hero-brand__subtitle">
AI
</div>
</div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
@@ -87,7 +94,7 @@ export function BindPhoneScreen({
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]">
</p>
<PlatformSubpanel
as="div"
@@ -186,7 +193,7 @@ export function BindPhoneScreen({
disabled={binding || !phone.trim() || !code.trim()}
size="lg"
>
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
{binding ? '正在绑定...' : '绑定手机号并继续'}
</PlatformActionButton>
<PlatformActionButton
+6 -2
View File
@@ -530,7 +530,9 @@ function PhoneCodeForm({
tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()}
onClick={() => {
void onSendCode().catch(() => undefined);
}}
>
{sendingCode
? '发送中'
@@ -624,7 +626,9 @@ function PasswordResetPanel({
tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()}
onClick={() => {
void onSendCode().catch(() => undefined);
}}
>
{sendingCode
? '发送中'
@@ -8,7 +8,6 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { BRAND_ASSETS } from '../../uiAssets';
import {
clampFloatingFeedbackPosition,
FLOATING_FEEDBACK_DESKTOP_QUERY,
@@ -21,6 +20,7 @@ import {
resolveFloatingFeedbackContactPanelOffset,
resolveInitialFloatingFeedbackPosition,
} from './floatingFeedbackEntryModel';
import { PLATFORM_BRAND_ASSETS } from './platformBrandAssets';
type FloatingFeedbackDragState = {
pointerId: number;
@@ -45,8 +45,7 @@ function isSameFloatingFeedbackPointer(
function shouldUseMouseDragFallback() {
return (
typeof window !== 'undefined' &&
typeof window.PointerEvent !== 'function'
typeof window !== 'undefined' && typeof window.PointerEvent !== 'function'
);
}
@@ -295,12 +294,12 @@ export function FloatingFeedbackEntry() {
aria-hidden="true"
>
<img
src={BRAND_ASSETS.taonierFeedbackQq}
src={PLATFORM_BRAND_ASSETS.taonierFeedbackQq}
alt=""
draggable={false}
/>
<img
src={BRAND_ASSETS.taonierFeedbackWechat}
src={PLATFORM_BRAND_ASSETS.taonierFeedbackWechat}
alt=""
draggable={false}
/>
@@ -324,7 +323,7 @@ export function FloatingFeedbackEntry() {
onClick={openFeedbackForm}
>
<img
src={BRAND_ASSETS.taonierFeedbackEntry}
src={PLATFORM_BRAND_ASSETS.taonierFeedbackEntry}
alt=""
aria-hidden="true"
draggable={false}
@@ -17,7 +17,6 @@ type PlatformDangerConfirmDialogProps = {
showCloseButton?: boolean;
portal?: boolean;
size?: 'sm' | 'md';
variant?: 'platform' | 'pixel';
overlayClassName?: string;
panelClassName?: string;
footerClassName?: string;
@@ -43,7 +42,6 @@ export function PlatformDangerConfirmDialog({
showCloseButton = true,
portal = true,
size = 'sm',
variant = 'platform',
overlayClassName,
panelClassName,
footerClassName,
@@ -66,7 +64,6 @@ export function PlatformDangerConfirmDialog({
confirmTone="danger"
portal={portal}
size={size}
variant={variant}
overlayClassName={overlayClassName}
panelClassName={panelClassName}
footerClassName={footerClassName}
@@ -11,7 +11,6 @@ type PlatformModalCloseButtonVariant =
| 'floating'
| 'floatingPlain'
| 'platformIcon'
| 'pixel'
| 'editorDark';
type PlatformModalCloseButtonPlacement = 'absolute' | 'inline';
@@ -39,21 +38,12 @@ const PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT: Record<
'absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm',
floatingPlain:
'absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]',
platformIcon: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45',
pixel:
'flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 disabled:cursor-not-allowed disabled:opacity-45',
platformIcon:
'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45',
editorDark:
'platform-modal-close-button--editor-dark rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white',
};
const PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT: Record<
PlatformModalCloseButtonPlacement,
string
> = {
absolute: 'absolute right-4 top-3 sm:right-5 sm:top-4',
inline: 'relative shrink-0',
};
/**
* 平台弹窗关闭按钮。
* 收口个人中心和平台浮层里重复的关闭 aria、尺寸和视觉样式。
@@ -88,11 +78,7 @@ export function PlatformModalCloseButton({
onClick={handleClick}
className={[
PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT[variant],
variant === 'pixel'
? PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT[
placement
]
: null,
placement === 'inline' ? 'relative shrink-0' : null,
className,
]
.filter(Boolean)
@@ -4,7 +4,6 @@ import { PlatformActionButton } from './PlatformActionButton';
import { UnifiedModal } from './UnifiedModal';
type UnifiedConfirmDialogTone = 'primary' | 'danger';
type UnifiedConfirmDialogVariant = 'platform' | 'pixel';
type UnifiedConfirmDialogProps = {
open: boolean;
@@ -25,7 +24,6 @@ type UnifiedConfirmDialogProps = {
closeOnBackdrop?: boolean;
showCloseButton?: boolean;
portal?: boolean;
variant?: UnifiedConfirmDialogVariant;
size?: 'sm' | 'md';
overlayClassName?: string;
panelClassName?: string;
@@ -56,7 +54,6 @@ export function UnifiedConfirmDialog({
closeOnBackdrop = true,
showCloseButton = true,
portal = true,
variant = 'platform',
size = 'sm',
overlayClassName,
panelClassName,
@@ -77,7 +74,6 @@ export function UnifiedConfirmDialog({
closeOnBackdrop={closeOnBackdrop && !busy}
showCloseButton={showCloseButton}
portal={portal}
variant={variant}
size={size}
overlayClassName={overlayClassName}
panelClassName={panelClassName}
+25 -62
View File
@@ -8,10 +8,8 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
type UnifiedModalVariant = 'platform' | 'pixel';
type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
type UnifiedModalCloseVariant = NonNullable<
ComponentProps<typeof PlatformModalCloseButton>['variant']
@@ -29,7 +27,6 @@ type UnifiedModalProps = {
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
variant?: UnifiedModalVariant;
size?: UnifiedModalSize;
showHeader?: boolean;
closeDisabled?: boolean;
@@ -60,30 +57,10 @@ const PLATFORM_SIZE_CLASS: Record<UnifiedModalSize, string> = {
fullscreen: 'max-w-[min(100vw,76rem)] sm:h-[min(92vh,60rem)]',
};
const PIXEL_SIZE_CLASS: Record<UnifiedModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-3xl',
xl: 'max-w-5xl',
fullscreen: 'max-w-[min(96vw,64rem)]',
};
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
}
function getPanelStyle(
variant: UnifiedModalVariant,
panelStyle: CSSProperties | undefined,
function joinClassNames(
...classNames: Array<string | false | null | undefined>
) {
if (variant !== 'pixel') {
return panelStyle;
}
return {
...getNineSliceStyle(UI_CHROME.modalPanel),
...panelStyle,
};
return classNames.filter(Boolean).join(' ');
}
function UnifiedModalContent({
@@ -95,7 +72,6 @@ function UnifiedModalContent({
children,
footer,
onClose,
variant = 'platform',
size = 'md',
showHeader = true,
closeDisabled = false,
@@ -140,42 +116,29 @@ function UnifiedModalContent({
return null;
}
const isPixel = variant === 'pixel';
const sizeClassName = isPixel
? PIXEL_SIZE_CLASS[size]
: PLATFORM_SIZE_CLASS[size];
const overlayClasses = isPixel
? 'fixed inset-0 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4'
: 'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4';
const panelClasses = isPixel
? 'pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]'
: 'platform-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-t-[1.75rem] sm:rounded-[1.75rem]';
const headerClasses = isPixel
? 'flex items-start justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4'
: 'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
const titleClasses = isPixel
? 'truncate text-sm font-semibold text-white'
: 'text-base font-semibold text-[var(--platform-text-strong)]';
const descriptionClasses = isPixel
? 'mt-1 text-xs leading-5 text-zinc-400'
: 'mt-1 text-xs leading-5 text-[var(--platform-text-base)]';
const bodyClasses = isPixel
? 'min-h-0 flex-1 overflow-y-auto p-4 sm:p-5'
: 'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5';
const footerClasses = isPixel
? 'flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4'
: 'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
const sizeClassName = PLATFORM_SIZE_CLASS[size];
const overlayClasses =
'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4';
const panelClasses =
'platform-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-t-[1.75rem] sm:rounded-[1.75rem]';
const headerClasses =
'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
const titleClasses =
'text-base font-semibold text-[var(--platform-text-strong)]';
const descriptionClasses =
'mt-1 text-xs leading-5 text-[var(--platform-text-base)]';
const bodyClasses =
'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5';
const footerClasses =
'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
return (
<div
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
className={joinClassNames(
overlayClasses,
zIndexClassName,
overlayClassName,
)}
style={overlayStyle}
onPointerDownCapture={(event) => {
backdropPointerSequenceRef.current =
@@ -210,7 +173,7 @@ function UnifiedModalContent({
aria-label={ariaLabel ?? (!showHeader ? title : undefined)}
aria-describedby={description ? descriptionId : undefined}
className={joinClassNames(panelClasses, sizeClassName, panelClassName)}
style={getPanelStyle(variant, panelStyle)}
style={panelStyle}
onClick={(event) => event.stopPropagation()}
>
{showHeader ? (
@@ -239,7 +202,7 @@ function UnifiedModalContent({
label={closeLabel}
onClick={onClose}
disabled={closeDisabled}
variant={closeVariant ?? (isPixel ? 'pixel' : 'platformIcon')}
variant={closeVariant ?? 'platformIcon'}
icon={closeIcon}
/>
) : null}
@@ -0,0 +1,6 @@
export const PLATFORM_BRAND_ASSETS = {
taonierProductIp: '/branding/taonier-product-ip.png',
taonierFeedbackEntry: '/branding/taonier-feedback-entry.png',
taonierFeedbackQq: '/branding/taonier-feedback-qq.png',
taonierFeedbackWechat: '/branding/taonier-feedback-wechat.png',
} as const;
@@ -166,9 +166,7 @@ describe('CreationLandingView', () => {
expect(
screen.getByText('陶泥儿 Genarrative|游戏美术 AI 创作工具'),
).toBeTruthy();
const subtitle = screen.getByText(
/ AI /u,
);
const subtitle = screen.getByText(/ AI /u);
expect(subtitle.textContent).toContain('美术 Agent');
expect(subtitle.textContent).toContain('无限画布');
expect(subtitle.textContent).toContain('角色、场景、UI 与宣发素材');
@@ -222,6 +220,73 @@ describe('CreationLandingView', () => {
expect(onOpenProject).toHaveBeenCalledWith('project-newest');
});
it('filters recent projects by the shared search keyword', async () => {
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
render(
<AuthUiContext.Provider value={createAuthValue()}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
searchKeyword="最新"
/>
</AuthUiContext.Provider>,
);
expect(await screen.findByText('最新项目')).toBeTruthy();
expect(screen.queryByText('旧项目')).toBeNull();
});
it.each([
['森林角色', '森林角色', '海边场景'],
['阿蓝', '海边场景', '森林角色'],
['金色盔甲', '森林角色', '海边场景'],
])(
'filters featured assets by search keyword %s',
async (searchKeyword, expectedLabel, hiddenLabel) => {
listEditorProjectsMock.mockResolvedValueOnce([]);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'forest-character',
projectId: 'project-newest',
label: '森林角色',
imageSrc: '/generated-editor-images/forest-character.png',
width: 512,
height: 512,
sourceType: 'generated',
prompt: '金色盔甲',
authorDisplayName: '阿绿',
publicShowcaseEnabled: true,
},
{
resourceId: 'seaside-scene',
projectId: 'project-newest',
label: '海边场景',
imageSrc: '/generated-editor-images/seaside-scene.png',
width: 512,
height: 512,
sourceType: 'generated',
prompt: '夜晚月光',
authorDisplayName: '阿蓝',
publicShowcaseEnabled: true,
},
]);
render(
<AuthUiContext.Provider value={createAuthValue()}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
searchKeyword={searchKeyword}
/>
</AuthUiContext.Provider>,
);
expect(await screen.findByText(expectedLabel)).toBeTruthy();
expect(screen.queryByText(hiddenLabel)).toBeNull();
},
);
it('hides recent projects and opens login when an anonymous user starts creation', async () => {
const user = userEvent.setup();
const openLoginModal = vi.fn();
@@ -405,7 +470,7 @@ describe('CreationLandingView', () => {
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
});
it('likes a featured asset from the waterfall card action', async () => {
it('likes a featured asset from the list card action', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
@@ -619,7 +684,7 @@ describe('CreationLandingView', () => {
);
});
it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => {
it('uses Taonier featured as the generated project resource list instead of creation entries', async () => {
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
@@ -671,9 +736,13 @@ describe('CreationLandingView', () => {
expect(await screen.findByText('精选素材 A')).toBeTruthy();
expect(screen.getByText('精选素材 B')).toBeTruthy();
expect(screen.queryByText('上传素材')).toBeNull();
expect(screen.getByLabelText('用户素材瀑布流').className).toContain(
const showcaseList = screen.getByRole('list', {
name: '陶泥儿精选素材列表',
});
expect(showcaseList.className).toContain(
'creation-landing__asset-waterfall',
);
expect(within(showcaseList).getAllByRole('listitem')).toHaveLength(2);
const firstPreview = container.querySelector(
'.creation-landing__asset-preview',
) as HTMLElement | null;
@@ -686,6 +755,22 @@ describe('CreationLandingView', () => {
});
it('renders campaign card previews with centered contain media', async () => {
const campaignObjectKey =
'generated-character-drafts/editor/showcase-campaign/current/campaign.png';
const signedCampaignUrl = 'data:image/png;base64,Y2FtcGFpZ24=';
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
read: {
objectKey: campaignObjectKey,
signedUrl: signedCampaignUrl,
expiresAt: '2099-01-01T00:00:00Z',
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
listEditorProjectsMock.mockResolvedValue(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValue({
resources: [],
@@ -694,6 +779,7 @@ describe('CreationLandingView', () => {
enabled: true,
title: '活动精选',
imageSrc: '/campaign.png',
imageObjectKey: campaignObjectKey,
imageWidth: 900,
imageHeight: 1200,
prompt: '活动提示词',
@@ -712,9 +798,17 @@ describe('CreationLandingView', () => {
'creation-landing__asset-preview--campaign',
);
expect(campaignPreview?.style.aspectRatio).toBe('900 / 1200');
const campaignImage = await screen.findByRole('img', {
name: '活动精选',
});
expect((campaignImage as HTMLImageElement).src).toBe(signedCampaignUrl);
expect(fetchMock).toHaveBeenCalledWith(
`/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`,
expect.objectContaining({ method: 'GET' }),
);
});
it('loads more featured resources when the waterfall reaches the end', async () => {
it('loads more featured resources when the list reaches the end', async () => {
const observers = installIntersectionObserverMock();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock
@@ -808,10 +902,10 @@ describe('CreationLandingView', () => {
await screen.findByRole('button', { name: '读取更多素材失败,点击重试' }),
).toBeTruthy();
expect(screen.getByText('精选素材 A')).toBeTruthy();
expect(screen.getByLabelText('用户素材瀑布流')).toBeTruthy();
expect(screen.getByLabelText('陶泥儿精选素材列表')).toBeTruthy();
});
it('keeps the featured waterfall empty when project resources are empty', async () => {
it('keeps the featured list empty when project resources are empty', async () => {
renderCreationLanding({
authValue: createAuthValue({
user: null,
@@ -820,7 +914,7 @@ describe('CreationLandingView', () => {
});
expect(await screen.findByText('暂无素材')).toBeTruthy();
expect(screen.queryByLabelText('用户素材瀑布流')).toBeNull();
expect(screen.queryByLabelText('陶泥儿精选素材列表')).toBeNull();
expect(screen.queryByText('精选入口')).toBeNull();
});
});
@@ -1,5 +1,12 @@
import { Film, Image as ImageIcon, Music2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { ApiClientError } from '../../services/apiClient';
@@ -30,6 +37,10 @@ import {
type ShowcaseAssetPreview,
type ShowcaseTabId,
} from './creationShowcaseModel';
import {
applySequentialShowcaseMasonry,
resetSequentialShowcaseMasonry,
} from './showcaseMasonryLayout';
type CreationLandingViewProps = {
onOpenProject: (
@@ -38,6 +49,7 @@ type CreationLandingViewProps = {
) => void;
onOpenProjects: () => void;
onOpenCommunity?: () => void;
searchKeyword?: string;
};
type CreationFeatureTool =
@@ -170,6 +182,59 @@ function getPreviewAspectRatio(preview: ShowcaseAssetPreview) {
: undefined;
}
function useSequentialShowcaseMasonry(
itemOrderKey: string,
expectedItemCount: number,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const view = container.ownerDocument.defaultView;
let animationFrame: number | null = null;
const layout = () => {
animationFrame = null;
applySequentialShowcaseMasonry(container, { expectedItemCount });
};
const scheduleLayout = () => {
if (animationFrame !== null) {
return;
}
if (view?.requestAnimationFrame) {
animationFrame = view.requestAnimationFrame(layout);
return;
}
layout();
};
layout();
const ResizeObserverConstructor = globalThis.ResizeObserver;
const resizeObserver = ResizeObserverConstructor
? new ResizeObserverConstructor(scheduleLayout)
: null;
resizeObserver?.observe(container);
for (const card of container.querySelectorAll<HTMLElement>(
':scope > .creation-landing__asset-card',
)) {
resizeObserver?.observe(card);
}
return () => {
if (animationFrame !== null && view?.cancelAnimationFrame) {
view.cancelAnimationFrame(animationFrame);
}
resizeObserver?.disconnect();
resetSequentialShowcaseMasonry(container);
};
}, [expectedItemCount, itemOrderKey]);
return containerRef;
}
function getPreviewIcon(preview: ShowcaseAssetPreview) {
if (preview.mediaType === 'audio') {
return <Music2 aria-hidden="true" />;
@@ -424,9 +489,11 @@ export function CreationLandingView({
onOpenProject,
onOpenProjects,
onOpenCommunity,
searchKeyword = '',
}: CreationLandingViewProps) {
const authUi = useAuthUi();
const isAuthenticated = Boolean(authUi?.user);
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
const [recentProjects, setRecentProjects] = useState<EditorProjectSnapshot[]>(
[],
);
@@ -734,6 +801,31 @@ export function CreationLandingView({
}),
[activeShowcaseTab, projectShowcaseResources, showcaseCampaign],
);
const visibleShowcaseItems = useMemo(() => {
if (!normalizedSearchKeyword) {
return showcaseItems;
}
return showcaseItems.filter((item) =>
[item.label, item.author, item.prompt].some((value) =>
value.toLocaleLowerCase().includes(normalizedSearchKeyword),
),
);
}, [normalizedSearchKeyword, showcaseItems]);
const showcaseMasonryOrderKey = JSON.stringify(
visibleShowcaseItems.map((item) => item.id),
);
const showcaseMasonryRef = useSequentialShowcaseMasonry(
showcaseMasonryOrderKey,
visibleShowcaseItems.length,
);
const visibleRecentProjects = useMemo(() => {
if (!normalizedSearchKeyword) {
return recentProjects;
}
return recentProjects.filter((project) =>
project.title.toLocaleLowerCase().includes(normalizedSearchKeyword),
);
}, [normalizedSearchKeyword, recentProjects]);
const isShowcaseLoading = isLoadingShowcase;
const showcaseEmptyText = '暂无素材';
@@ -772,13 +864,15 @@ export function CreationLandingView({
</PlatformEmptyState>
);
}
if (showcaseItems.length === 0) {
if (visibleShowcaseItems.length === 0) {
return (
<>
<PlatformEmptyState surface="subpanel" size="inline">
{showcaseNextCursor || isLoadingMoreShowcase
? '正在读取更多素材'
: showcaseEmptyText}
: normalizedSearchKeyword
? '没有匹配素材'
: showcaseEmptyText}
</PlatformEmptyState>
{renderShowcaseLoadMoreSentinel()}
</>
@@ -788,10 +882,12 @@ export function CreationLandingView({
return (
<>
<div
ref={showcaseMasonryRef}
className="creation-landing__asset-waterfall"
aria-label="用户素材瀑布流"
aria-label="陶泥儿精选素材列表"
role="list"
>
{showcaseItems.map((item) => {
{visibleShowcaseItems.map((item) => {
const showcaseId = item.showcaseId?.trim();
const isLiked = Boolean(
showcaseId && likedShowcaseIds.has(showcaseId),
@@ -800,7 +896,11 @@ export function CreationLandingView({
showcaseId && pendingLikeShowcaseIds.has(showcaseId),
);
return (
<article key={item.id} className="creation-landing__asset-card">
<article
key={item.id}
className="creation-landing__asset-card"
role="listitem"
>
<button
type="button"
className="creation-landing__asset-card-open"
@@ -994,7 +1094,7 @@ export function CreationLandingView({
</PlatformEmptyState>
) : (
recentProjects.map((project) => {
visibleRecentProjects.map((project) => {
const coverSnapshot =
resolveProjectCoverSnapshotResource(project);
return (
@@ -1091,7 +1191,7 @@ export function CreationLandingView({
}
>
<img
src="/branding/mobile-home-welcome-taonier-ip.png"
src="/branding/taonier-product-ip.png"
alt=""
className="platform-mobile-home-welcome-dialog__icon"
/>
@@ -0,0 +1,153 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it } from 'vitest';
import {
applySequentialShowcaseMasonry,
createSequentialShowcaseMasonryLayout,
resetSequentialShowcaseMasonry,
resolveShowcaseMasonryColumnCount,
} from './showcaseMasonryLayout';
function createRect(width: number, height: number): DOMRect {
return {
bottom: height,
height,
left: 0,
right: width,
top: 0,
width,
x: 0,
y: 0,
toJSON: () => ({}),
};
}
describe('showcase masonry layout', () => {
afterEach(() => {
document.body.replaceChildren();
});
it('assigns every group to columns in stable round-robin order', () => {
const layout = createSequentialShowcaseMasonryLayout({
containerWidth: 1296,
gap: 16,
itemHeights: [600, 300, 450, 200, 400, 250],
});
expect(layout.columnCount).toBe(3);
expect(layout.items.map((item) => item.columnIndex)).toEqual([
0, 1, 2, 0, 1, 2,
]);
expect(layout.items.map((item) => item.top)).toEqual([
0, 0, 0, 616, 316, 466,
]);
expect(layout.columnHeights).toEqual([816, 716, 716]);
expect(layout.containerHeight).toBe(816);
});
it('derives three, two, or one column from the actual container width', () => {
expect(
resolveShowcaseMasonryColumnCount({
containerWidth: 590.71,
gap: 14.72,
}),
).toBe(1);
expect(
resolveShowcaseMasonryColumnCount({
containerWidth: 590.72,
gap: 14.72,
}),
).toBe(2);
expect(
resolveShowcaseMasonryColumnCount({
containerWidth: 893.43,
gap: 14.72,
}),
).toBe(2);
expect(
resolveShowcaseMasonryColumnCount({
containerWidth: 893.44,
gap: 14.72,
}),
).toBe(3);
expect(
resolveShowcaseMasonryColumnCount({ containerWidth: 1600, gap: 14.72 }),
).toBe(3);
});
it('applies and resets absolute card positions without changing DOM order', () => {
const container = document.createElement('div');
container.className = 'creation-landing__asset-waterfall';
container.style.columnGap = '16px';
container.getBoundingClientRect = () => createRect(900, 0);
const heights = [600, 300, 450, 200, 400, 250];
const cards = heights.map((height, index) => {
const card = document.createElement('article');
card.className = 'creation-landing__asset-card';
card.textContent = `card-${index + 1}`;
card.getBoundingClientRect = () =>
createRect(Number.parseFloat(card.style.width) || 0, height);
container.append(card);
return card;
});
document.body.append(container);
const layout = applySequentialShowcaseMasonry(container);
expect(layout?.columnCount).toBe(3);
expect(container.dataset.masonryColumns).toBe('3');
expect(cards.map((card) => card.dataset.masonryColumn)).toEqual([
'1',
'2',
'3',
'1',
'2',
'3',
]);
expect(cards.map((card) => card.style.top)).toEqual([
'0px',
'0px',
'0px',
'616px',
'316px',
'466px',
]);
expect(
Array.from(container.children).map((card) => card.textContent),
).toEqual(['card-1', 'card-2', 'card-3', 'card-4', 'card-5', 'card-6']);
resetSequentialShowcaseMasonry(container);
expect(container.dataset.masonryColumns).toBeUndefined();
expect(container.style.height).toBe('');
expect(cards.every((card) => card.style.position === '')).toBe(true);
expect(cards.every((card) => card.style.top === '')).toBe(true);
});
it('keeps the flow fallback until every expected card has a valid height', () => {
const container = document.createElement('div');
container.className = 'creation-landing__asset-waterfall';
container.getBoundingClientRect = () => createRect(900, 0);
const card = document.createElement('article');
card.className = 'creation-landing__asset-card';
card.getBoundingClientRect = () => createRect(288, 0);
container.append(card);
document.body.append(container);
expect(
applySequentialShowcaseMasonry(container, { expectedItemCount: 2 }),
).toBeNull();
expect(
container.classList.contains(
'creation-landing__asset-waterfall--masonry',
),
).toBe(false);
expect(
applySequentialShowcaseMasonry(container, { expectedItemCount: 1 }),
).toBeNull();
expect(container.style.height).toBe('');
expect(card.style.position).toBe('');
});
});
@@ -0,0 +1,190 @@
export const SHOWCASE_MASONRY_MAX_COLUMNS = 3;
export const SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX = 288;
export const SHOWCASE_MASONRY_FALLBACK_GAP_PX = 14.72;
export type ShowcaseMasonryItemLayout = {
columnIndex: number;
height: number;
left: number;
top: number;
width: number;
};
export type ShowcaseMasonryLayout = {
columnCount: number;
columnHeights: number[];
columnWidth: number;
containerHeight: number;
items: ShowcaseMasonryItemLayout[];
};
type CreateShowcaseMasonryLayoutOptions = {
containerWidth: number;
gap: number;
itemHeights: number[];
maxColumns?: number;
minColumnWidth?: number;
};
function normalizeFiniteNumber(value: number, fallback = 0) {
return Number.isFinite(value) ? value : fallback;
}
export function resolveShowcaseMasonryColumnCount({
containerWidth,
gap,
maxColumns = SHOWCASE_MASONRY_MAX_COLUMNS,
minColumnWidth = SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX,
}: Omit<CreateShowcaseMasonryLayoutOptions, 'itemHeights'>) {
const safeContainerWidth = Math.max(0, normalizeFiniteNumber(containerWidth));
const safeGap = Math.max(0, normalizeFiniteNumber(gap));
const safeMaxColumns = Math.max(
1,
Math.floor(normalizeFiniteNumber(maxColumns, 1)),
);
const safeMinColumnWidth = Math.max(
1,
normalizeFiniteNumber(minColumnWidth, 1),
);
const fittingColumns = Math.max(
1,
Math.floor((safeContainerWidth + safeGap) / (safeMinColumnWidth + safeGap)),
);
return Math.min(safeMaxColumns, fittingColumns);
}
export function createSequentialShowcaseMasonryLayout({
containerWidth,
gap,
itemHeights,
maxColumns = SHOWCASE_MASONRY_MAX_COLUMNS,
minColumnWidth = SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX,
}: CreateShowcaseMasonryLayoutOptions): ShowcaseMasonryLayout {
const safeContainerWidth = Math.max(0, normalizeFiniteNumber(containerWidth));
const safeGap = Math.max(0, normalizeFiniteNumber(gap));
const columnCount = resolveShowcaseMasonryColumnCount({
containerWidth: safeContainerWidth,
gap: safeGap,
maxColumns,
minColumnWidth,
});
const columnWidth = Math.max(
0,
(safeContainerWidth - safeGap * (columnCount - 1)) / columnCount,
);
const occupiedColumnHeights = Array.from<number>({
length: columnCount,
}).fill(0);
const items = itemHeights.map((rawHeight, index) => {
const height = Math.max(0, normalizeFiniteNumber(rawHeight));
const columnIndex = index % columnCount;
const top = occupiedColumnHeights[columnIndex] ?? 0;
const item = {
columnIndex,
height,
left: columnIndex * (columnWidth + safeGap),
top,
width: columnWidth,
};
occupiedColumnHeights[columnIndex] = top + height + safeGap;
return item;
});
const columnHeights = occupiedColumnHeights.map((height) =>
height > 0 ? height - safeGap : 0,
);
return {
columnCount,
columnHeights,
columnWidth,
containerHeight: Math.max(0, ...columnHeights),
items,
};
}
function resolveContainerGap(container: HTMLElement) {
const view = container.ownerDocument.defaultView;
const rawGap = view?.getComputedStyle(container).columnGap ?? '';
const gap = Number.parseFloat(rawGap);
return Number.isFinite(gap) ? gap : SHOWCASE_MASONRY_FALLBACK_GAP_PX;
}
function getMasonryCards(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(
':scope > .creation-landing__asset-card',
),
);
}
export function resetSequentialShowcaseMasonry(container: HTMLElement) {
container.classList.remove('creation-landing__asset-waterfall--masonry');
container.style.removeProperty('height');
delete container.dataset.masonryColumns;
for (const card of getMasonryCards(container)) {
card.style.removeProperty('position');
card.style.removeProperty('width');
card.style.removeProperty('left');
card.style.removeProperty('top');
delete card.dataset.masonryColumn;
}
}
export function applySequentialShowcaseMasonry(
container: HTMLElement,
{ expectedItemCount }: { expectedItemCount?: number } = {},
) {
const containerWidth = container.getBoundingClientRect().width;
const cards = getMasonryCards(container);
if (
!Number.isFinite(containerWidth) ||
containerWidth <= 0 ||
!cards.length ||
(expectedItemCount !== undefined && cards.length !== expectedItemCount)
) {
resetSequentialShowcaseMasonry(container);
return null;
}
const gap = resolveContainerGap(container);
const columnCount = resolveShowcaseMasonryColumnCount({
containerWidth,
gap,
});
const columnWidth = Math.max(
0,
(containerWidth - gap * (columnCount - 1)) / columnCount,
);
container.classList.add('creation-landing__asset-waterfall--masonry');
cards.forEach((card, index) => {
const columnIndex = index % columnCount;
card.style.position = 'absolute';
card.style.width = `${columnWidth}px`;
card.style.left = `${columnIndex * (columnWidth + gap)}px`;
card.style.top = '0px';
});
const itemHeights = cards.map((card) => card.getBoundingClientRect().height);
if (itemHeights.some((height) => !Number.isFinite(height) || height <= 0)) {
resetSequentialShowcaseMasonry(container);
return null;
}
const layout = createSequentialShowcaseMasonryLayout({
containerWidth,
gap,
itemHeights,
});
cards.forEach((card, index) => {
const item = layout.items[index];
if (!item) {
return;
}
card.style.left = `${item.left}px`;
card.style.top = `${item.top}px`;
card.dataset.masonryColumn = String(item.columnIndex + 1);
});
container.style.height = `${layout.containerHeight}px`;
container.dataset.masonryColumns = String(layout.columnCount);
return layout;
}
@@ -3,16 +3,33 @@ import { Image as ImageIcon, X } from 'lucide-react';
import type { EditorAgentAttachmentRef } from '@/packages/shared/src/contracts';
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
import type { RightClickMenuHandler } from './common.ts';
function AttachmentChip({
attachment,
onRemove,
onRightClickMenu,
}: {
attachment: EditorAgentAttachmentRef;
onRemove?: () => void;
onRightClickMenu?: RightClickMenuHandler;
}) {
const label = attachment.label?.trim() || attachment.referenceId;
return (
<span className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm">
<span
className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
...attachment,
kind: 'attachment',
mediaType: 'image',
suggestedFileName: label,
})
: undefined
}
>
<ImageIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{label}</span>
{onRemove ? (
@@ -0,0 +1,116 @@
import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx';
import { PlatformToolModalShell } from '@/src/components/common/PlatformToolModalShell.tsx';
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
import type {
AttachmentPickerTab,
EditorAgentAttachmentOption,
} from './useConversationAttachments.ts';
export function AttachmentPicker({
open,
tab,
canvasOptions,
libraryOptions,
selectedKeys,
attachmentError,
onTabChange,
onToggleKey,
onApply,
onClose,
}: {
open: boolean;
tab: AttachmentPickerTab;
canvasOptions: EditorAgentAttachmentOption[];
libraryOptions: EditorAgentAttachmentOption[];
selectedKeys: Set<string>;
attachmentError: string | null;
onTabChange: (tab: AttachmentPickerTab) => void;
onToggleKey: (key: string) => void;
onApply: () => void;
onClose: () => void;
}) {
const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions;
return (
<PlatformToolModalShell
open={open}
title="选择图片附件"
size="md"
onClose={onClose}
footer={
<>
<PlatformActionButton tone="ghost" size="sm" onClick={onClose}>
</PlatformActionButton>
<PlatformActionButton tone="primary" size="sm" onClick={onApply}>
</PlatformActionButton>
</>
}
>
<div className="flex min-h-[18rem] flex-col gap-3">
<div className="flex gap-2">
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
tab === 'canvas'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}
onClick={() => onTabChange('canvas')}
>
</button>
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
tab === 'library'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}
onClick={() => onTabChange('library')}
>
</button>
</div>
{attachmentError ? (
<div className="rounded-2xl bg-amber-50 px-3 py-2 text-sm text-amber-700">
{attachmentError}
</div>
) : null}
<div className="grid grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
{visibleOptions.map((option) => {
const label =
option.attachment.label?.trim() || option.attachment.referenceId;
return (
<label
key={option.key}
className="flex cursor-pointer flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-2 text-sm text-slate-700 shadow-sm"
>
<ResolvedAssetImage
src={
option.attachment.thumbnailSrc ?? option.attachment.imageSrc
}
objectKey={option.attachment.objectKey}
refreshKey={option.attachment.referenceId}
alt=""
className="aspect-square rounded-xl bg-slate-100 object-cover"
/>
<span className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`选择${option.sourceLabel}图片 ${label}`}
checked={selectedKeys.has(option.key)}
onChange={() => onToggleKey(option.key)}
/>
<span className="truncate">{label}</span>
</span>
</label>
);
})}
</div>
</div>
</PlatformToolModalShell>
);
}
@@ -9,22 +9,15 @@ import {
X,
} from 'lucide-react';
import {
type ClipboardEvent as ReactClipboardEvent,
type FormEvent,
useEffect,
useMemo,
useState,
type WheelEvent as ReactWheelEvent,
} from 'react';
import {
EDITOR_AGENT_MAX_ATTACHMENTS,
type EditorAgentAttachmentRef,
} from '@/packages/shared/src/contracts';
import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx';
import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDangerConfirmDialog.tsx';
import { UnifiedModal } from '@/src/components/common/UnifiedModal.tsx';
import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx';
import { AttachmentPicker } from '@/src/components/image-editor/EditorAgentConversation/AttachmentPicker.tsx';
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
import {
MessageBubble,
@@ -34,25 +27,14 @@ import type {
CanvasLayer,
EditorAsset,
} from '@/src/components/image-editor/ImageCanvasEditorTypes.ts';
import { probeImageFileDimensions } from '@/src/components/image-editor/ImageCanvasFileModel.ts';
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
import { uploadEditorMediaAssetFile } from '@/src/services/image-editor/editorMediaAssetUploadClient.ts';
import { createEditorProjectResource } from '@/src/services/image-editor/editorProjectClient.ts';
import { useImageCanvasContextStore } from '../useImageCanvasContextStore.ts';
import { useConversationAttachments } from './useConversationAttachments.ts';
import {
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation';
type AttachmentPickerTab = 'canvas' | 'library';
type EditorAgentAttachmentOption = {
key: string;
sourceLabel: string;
attachment: EditorAgentAttachmentRef;
};
type EditorAgentConversationPanelViewProps = {
open: boolean;
onToggleOpen: () => void;
@@ -68,170 +50,6 @@ function stopAgentPanelWheel(event: ReactWheelEvent<HTMLElement>) {
event.stopPropagation();
}
function isImageLayer(layer: CanvasLayer) {
return (
(layer.mediaType ?? 'image') === 'image' &&
Boolean(layer.resourceId?.trim()) &&
layer.src.trim()
);
}
function isImageAsset(asset: EditorAsset) {
return (asset.mediaType ?? 'image') === 'image' && asset.src.trim();
}
function createCanvasAttachmentOptions(
layers: CanvasLayer[] = [],
): EditorAgentAttachmentOption[] {
return layers.filter(isImageLayer).map((layer) => {
const attachment: EditorAgentAttachmentRef = {
source: 'canvas_resource',
referenceId: layer.resourceId || layer.id,
objectKey: layer.objectKey ?? null,
imageSrc: layer.src,
thumbnailSrc: layer.thumbnailSrc ?? null,
label: layer.title,
width: layer.width,
height: layer.height,
};
return {
key: attachmentKey(attachment),
sourceLabel: '画布',
attachment,
};
});
}
function createLibraryAttachmentOptions(
assets: EditorAsset[] = [],
): EditorAgentAttachmentOption[] {
return assets.filter(isImageAsset).map((asset) => {
const attachment: EditorAgentAttachmentRef = {
source: 'library_asset',
referenceId: asset.id,
objectKey: asset.objectKey ?? null,
imageSrc: asset.src,
thumbnailSrc: asset.thumbnailSrc ?? null,
label: asset.label,
width: asset.width,
height: asset.height,
};
return {
key: attachmentKey(attachment),
sourceLabel: '素材库',
attachment,
};
});
}
function AttachmentPickerModal({
open,
tab,
canvasOptions,
libraryOptions,
selectedKeys,
attachmentError,
onTabChange,
onToggleKey,
onApply,
onClose,
}: {
open: boolean;
tab: AttachmentPickerTab;
canvasOptions: EditorAgentAttachmentOption[];
libraryOptions: EditorAgentAttachmentOption[];
selectedKeys: Set<string>;
attachmentError: string | null;
onTabChange: (tab: AttachmentPickerTab) => void;
onToggleKey: (key: string) => void;
onApply: () => void;
onClose: () => void;
}) {
const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions;
return (
<UnifiedModal
open={open}
title="选择图片附件"
size="md"
onClose={onClose}
footer={
<>
<PlatformActionButton tone="ghost" size="sm" onClick={onClose}>
</PlatformActionButton>
<PlatformActionButton tone="primary" size="sm" onClick={onApply}>
</PlatformActionButton>
</>
}
>
<div className="flex min-h-[18rem] flex-col gap-3">
<div className="flex gap-2">
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
tab === 'canvas'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}
onClick={() => onTabChange('canvas')}
>
</button>
<button
type="button"
className={`rounded-full px-3 py-1.5 text-sm ${
tab === 'library'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}
onClick={() => onTabChange('library')}
>
</button>
</div>
{attachmentError ? (
<div className="rounded-2xl bg-amber-50 px-3 py-2 text-sm text-amber-700">
{attachmentError}
</div>
) : null}
<div className="grid grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
{visibleOptions.map((option) => {
const label =
option.attachment.label?.trim() || option.attachment.referenceId;
return (
<label
key={option.key}
className="flex cursor-pointer flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-2 text-sm text-slate-700 shadow-sm"
>
<ResolvedAssetImage
src={
option.attachment.thumbnailSrc ?? option.attachment.imageSrc
}
objectKey={option.attachment.objectKey}
refreshKey={option.attachment.referenceId}
alt=""
className="aspect-square rounded-xl bg-slate-100 object-cover"
/>
<span className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`选择${option.sourceLabel}图片 ${label}`}
checked={selectedKeys.has(option.key)}
onChange={() => onToggleKey(option.key)}
/>
<span className="truncate">{label}</span>
</span>
</label>
);
})}
</div>
</div>
</UnifiedModal>
);
}
export function EditorAgentConversationPanelView({
open,
onToggleOpen,
@@ -258,6 +76,7 @@ export function EditorAgentConversationPanelView({
isCreatingConversation,
isDeletingConversation,
isWaiting,
isPatienceNoticeVisible,
toolCallAction,
isToolCallActionPending,
errorMessage,
@@ -275,70 +94,31 @@ export function EditorAgentConversationPanelView({
onConfirmSent,
});
const [draftText, setDraftText] = useState('');
const [attachments, setAttachments] = useState<EditorAgentAttachmentRef[]>(
[],
);
const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false);
const [attachmentPickerTab, setAttachmentPickerTab] =
useState<AttachmentPickerTab>('canvas');
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [isPastingAttachment, setIsPastingAttachment] = useState(false);
const [draftAttachmentKeys, setDraftAttachmentKeys] = useState<Set<string>>(
() => new Set(),
);
const {
attachments,
attachmentError,
isPastingAttachment,
attachmentPickerOpen,
attachmentPickerTab,
draftAttachmentKeys,
canvasAttachmentOptions,
libraryAttachmentOptions,
setAttachmentPickerTab,
openAttachmentPicker,
closeAttachmentPicker,
toggleAttachmentKey,
applyAttachmentSelection,
referenceContextAsset,
handleInputPaste,
removeAttachment,
consumeAttachments,
restoreAttachments,
} = useConversationAttachments({ projectId, layers, assets });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const canvasAttachmentOptions = useMemo(
() => createCanvasAttachmentOptions(layers),
[layers],
);
const libraryAttachmentOptions = useMemo(
() => createLibraryAttachmentOptions(assets),
[assets],
);
const attachmentOptionsByKey = useMemo(() => {
const optionMap = new Map<string, EditorAgentAttachmentOption>();
[...canvasAttachmentOptions, ...libraryAttachmentOptions].forEach(
(option) => optionMap.set(option.key, option),
);
return optionMap;
}, [canvasAttachmentOptions, libraryAttachmentOptions]);
const hasProject = Boolean(projectId?.trim());
const isConversationBusy = isWaiting || isToolCallActionPending;
const openAttachmentPicker = () => {
setAttachmentError(null);
setDraftAttachmentKeys(new Set(attachments.map(attachmentKey)));
setAttachmentPickerOpen(true);
};
const toggleAttachmentKey = (key: string) => {
setDraftAttachmentKeys((currentKeys) => {
const nextKeys = new Set(currentKeys);
if (nextKeys.has(key)) {
nextKeys.delete(key);
} else {
nextKeys.add(key);
}
return nextKeys;
});
};
const applyAttachmentSelection = () => {
const nextAttachments = Array.from(draftAttachmentKeys)
.map((key) => attachmentOptionsByKey.get(key)?.attachment)
.filter((attachment): attachment is EditorAgentAttachmentRef =>
Boolean(attachment),
);
if (nextAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS}`);
return;
}
setAttachments(nextAttachments);
setAttachmentPickerOpen(false);
};
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isWaiting) {
@@ -352,139 +132,12 @@ export function EditorAgentConversationPanelView({
return;
}
setDraftText('');
const nextAttachments = attachments;
setAttachments([]);
const nextAttachments = consumeAttachments();
void sendMessage(text, nextAttachments).catch(() => {
setDraftText((currentText) => (currentText ? currentText : text));
setAttachments((currentAttachments) =>
currentAttachments.length ? currentAttachments : nextAttachments,
);
restoreAttachments(nextAttachments);
});
};
const appendAttachments = (nextAttachments: EditorAgentAttachmentRef[]) => {
function mergeAttachments(
currentAttachments: EditorAgentAttachmentRef[],
nextAttachments: EditorAgentAttachmentRef[],
) {
const merged = [...currentAttachments];
const existingKeys = new Set(currentAttachments.map(attachmentKey));
nextAttachments.forEach((attachment) => {
const key = attachmentKey(attachment);
if (!existingKeys.has(key)) {
existingKeys.add(key);
merged.push(attachment);
}
});
return merged;
}
const mergedAttachments = mergeAttachments(attachments, nextAttachments);
if (mergedAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS}`);
return false;
}
setAttachments(mergedAttachments);
setAttachmentError(null);
return true;
};
const createPastedAgentImageAttachment = async (
file: File,
): Promise<EditorAgentAttachmentRef> => {
if (!projectId?.trim()) {
throw new Error('缺少画布项目');
}
const [upload, dimensions] = await Promise.all([
uploadEditorMediaAssetFile(file, 'image', {
pathSegments: ['editor', 'agent-paste', 'image', `${Date.now()}`],
entityId: projectId,
metadata: {
source: 'agent-input-paste',
},
}),
probeImageFileDimensions(file),
]);
const width = dimensions?.width ?? 1;
const height = dimensions?.height ?? 1;
const resource = await createEditorProjectResource(projectId, {
imageSrc: upload.src,
objectKey: upload.objectKey,
assetObjectId: upload.assetObjectId,
width,
height,
sourceType: 'uploaded',
});
return {
source: 'canvas_resource',
referenceId: resource.resourceId,
objectKey: resource.objectKey ?? upload.objectKey,
imageSrc: resource.imageSrc,
thumbnailSrc: null,
label: resource.label ?? '粘贴图片',
width: resource.width,
height: resource.height,
};
};
function extractClipboardImageFiles(
clipboardData: DataTransfer | null,
): File[] {
if (!clipboardData) {
return [];
}
const fileItems = Array.from(clipboardData.files ?? []).filter((file) =>
file.type.startsWith('image/'),
);
return [...fileItems];
}
const handleInputPaste = (
event: ReactClipboardEvent<HTMLTextAreaElement>,
) => {
const imageFiles = extractClipboardImageFiles(event.clipboardData);
if (!imageFiles.length) {
return;
}
if (isPastingAttachment) {
return;
}
event.preventDefault();
if (attachments.length >= EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS}`);
return;
}
const remainingAttachmentSlots =
EDITOR_AGENT_MAX_ATTACHMENTS - attachments.length;
const uploadFiles = imageFiles.slice(0, remainingAttachmentSlots);
const hasOverflow = uploadFiles.length < imageFiles.length;
// TODO: deduplicate those existing assets
setIsPastingAttachment(true);
setAttachmentError('图片上传中');
void Promise.all(
uploadFiles.map((file) => createPastedAgentImageAttachment(file)),
)
.then((pastedAttachments) => {
if (appendAttachments(pastedAttachments) && hasOverflow) {
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS}`);
}
})
.catch(() => {
setAttachmentError('图片粘贴失败,请重试');
})
.finally(() => {
setIsPastingAttachment(false);
});
};
const removeAttachment = (targetAttachment: EditorAgentAttachmentRef) => {
const key = attachmentKey(targetAttachment);
setAttachments((currentAttachments) =>
currentAttachments.filter(
(attachment) => attachmentKey(attachment) !== key,
),
);
};
if (!open) {
return (
@@ -608,13 +261,16 @@ export function EditorAgentConversationPanelView({
}
onConfirmToolCall={confirmToolCall}
onCancelToolCall={cancelToolCall}
onReferenceImage={referenceContextAsset}
onJobCompleted={() => {
void refreshActiveConversation();
onCanvasRefreshRequested?.();
}}
/>
))}
{isWaiting ? <ThinkingBubble /> : null}
{isWaiting ? (
<ThinkingBubble showPatienceNotice={isPatienceNoticeVisible} />
) : null}
</>
) : (
<div className="rounded-3xl border border-dashed border-slate-200 bg-white/70 px-4 py-8 text-center text-sm text-slate-400">
@@ -686,7 +342,7 @@ export function EditorAgentConversationPanelView({
</div>
</form>
</aside>
<AttachmentPickerModal
<AttachmentPicker
open={attachmentPickerOpen}
tab={attachmentPickerTab}
canvasOptions={canvasAttachmentOptions}
@@ -696,7 +352,7 @@ export function EditorAgentConversationPanelView({
onTabChange={setAttachmentPickerTab}
onToggleKey={toggleAttachmentKey}
onApply={applyAttachmentSelection}
onClose={() => setAttachmentPickerOpen(false)}
onClose={closeAttachmentPicker}
/>
<PlatformDangerConfirmDialog
open={deleteConfirmOpen}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,16 @@ import {
type EditorAgentMessage,
} from '@/packages/shared/src/contracts';
import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx';
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
import {
attachmentKey,
type EditorAgentContextAsset,
} from '@/src/components/image-editor/EditorAgentConversation/common.ts';
import { PendingToolCall } from '@/src/components/image-editor/EditorAgentConversation/PendingToolCall.tsx';
import ToolCallView from '@/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx';
import { MessageBubbleRightClickMenu } from './MessageBubbleRightClickMenu.tsx';
import { useRightClickMenu } from './useRightClickMenu.ts';
function messageRoleLabel(role: EditorAgentMessage['role']) {
if (role === 'user') {
return '你';
@@ -14,9 +20,16 @@ function messageRoleLabel(role: EditorAgentMessage['role']) {
return 'Agent';
}
export function ThinkingBubble() {
export function ThinkingBubble({
showPatienceNotice = false,
}: {
showPatienceNotice?: boolean;
}) {
return (
<article className="flex justify-start" aria-label="Agent思考中">
<article
className="flex justify-start"
aria-label={showPatienceNotice ? 'Agent仍在处理中' : 'Agent思考中'}
>
<div className="max-w-[86%] rounded-3xl border border-slate-200 bg-white px-3.5 py-3 text-sm leading-6 shadow-sm">
<div className="flex items-center gap-1.5">
<span className="flex gap-0.5">
@@ -33,6 +46,9 @@ export function ThinkingBubble() {
style={{ animationDelay: '300ms' }}
/>
</span>
{showPatienceNotice ? (
<span className="text-slate-600"></span>
) : null}
</div>
</div>
</article>
@@ -45,6 +61,8 @@ type MessageBubbleProps = {
onConfirmToolCall: (messageId: number) => Promise<void>;
onCancelToolCall: (messageId: number) => Promise<void>;
onJobCompleted?: () => void;
// TODO prop drilling
onReferenceImage?: (asset: EditorAgentContextAsset) => boolean;
};
export function MessageBubble({
@@ -53,7 +71,14 @@ export function MessageBubble({
onConfirmToolCall,
onCancelToolCall,
onJobCompleted,
onReferenceImage,
}: MessageBubbleProps) {
const {
rightClickMenu,
openRightClickMenu,
closeRightClickMenu,
runRightClickAction,
} = useRightClickMenu({ onReferenceImage });
const systemErrorText =
message.role === 'system' &&
!message.toolCall &&
@@ -61,7 +86,16 @@ export function MessageBubble({
? message.text.slice(EDITOR_AGENT_ERROR_MESSAGE_PREFIX.length)
: null;
if (message.role === 'system' && !message.toolCall && systemErrorText === null) {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const isSystemError = systemErrorText !== null;
const visibleText = systemErrorText ?? (!isSystem ? message.text : '');
if (
message.role === 'system' &&
!message.toolCall &&
systemErrorText === null
) {
return null;
}
if (
@@ -81,56 +115,78 @@ export function MessageBubble({
);
}
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const isSystemError = systemErrorText !== null;
return (
<article
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
aria-label={
isSystemError
? 'Agent错误'
: isSystem
? 'Agent操作'
: `${messageRoleLabel(message.role)}消息`
}
>
<div
className={
isSystem && !isSystemError
? 'max-w-[86%]'
: `max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
isUser
? 'bg-slate-900 text-white'
: isSystemError || message.toolCall?.status === 'failed'
? 'border border-red-200 bg-red-50 text-red-700'
: 'border border-slate-200 bg-white text-slate-700'
}`
<>
<article
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
aria-label={
isSystemError
? 'Agent错误'
: isSystem
? 'Agent操作'
: `${messageRoleLabel(message.role)}消息`
}
onContextMenu={
visibleText.trim()
? (event) =>
openRightClickMenu(event, { kind: 'text', text: visibleText })
: undefined
}
>
{(!isSystem || isSystemError) && (systemErrorText ?? message.text) ? (
<div className="whitespace-pre-wrap break-words">
{systemErrorText ?? message.text}
</div>
) : null}
{!isSystem && message.attachments.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.attachments.map((attachment) => (
<AttachmentChip
key={attachmentKey(attachment)}
attachment={attachment}
/>
))}
</div>
) : null}
{message.toolCall ? (
<ToolCallView
toolCall={message.toolCall}
onJobCompleted={onJobCompleted}
/>
) : null}
</div>
</article>
<div
className={
isSystem && !isSystemError
? 'max-w-[86%]'
: `max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
isUser
? 'bg-slate-900 text-white'
: isSystemError || message.toolCall?.status === 'failed'
? 'border border-red-200 bg-red-50 text-red-700'
: 'border border-slate-200 bg-white text-slate-700'
}`
}
>
{(!isSystem || isSystemError) && (systemErrorText ?? message.text) ? (
<div className="whitespace-pre-wrap break-words">
{systemErrorText ?? message.text}
</div>
) : null}
{!isSystem && message.attachments.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.attachments.map((attachment) => (
<AttachmentChip
key={attachmentKey(attachment)}
attachment={attachment}
onRightClickMenu={(event, asset) =>
openRightClickMenu(event, { kind: 'asset', asset })
}
/>
))}
</div>
) : null}
{message.toolCall ? (
<ToolCallView
toolCall={message.toolCall}
onJobCompleted={onJobCompleted}
onRightClickMenu={(event, asset) =>
openRightClickMenu(event, { kind: 'asset', asset })
}
/>
) : null}
</div>
</article>
{rightClickMenu ? (
<MessageBubbleRightClickMenu
x={rightClickMenu.x}
y={rightClickMenu.y}
target={rightClickMenu.target}
pendingAction={rightClickMenu.pendingAction}
resultAction={rightClickMenu.resultAction}
result={rightClickMenu.result}
onAction={(action) => void runRightClickAction(action)}
onClose={closeRightClickMenu}
/>
) : null}
</>
);
}
@@ -0,0 +1,233 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
contextAssetMediaSrc,
type EditorAgentContextAsset,
EditorAgentRightClickAction,
type RightClickMenuTarget,
} from './common.ts';
type MessageBubbleRightClickMenuProps = {
x: number;
y: number;
target: RightClickMenuTarget;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
onAction: (action: EditorAgentRightClickAction) => void;
onClose: () => void;
};
const VIEWPORT_MARGIN = 8;
function actionLabel({
action,
idleLabel,
pendingAction,
resultAction,
result,
}: {
action: EditorAgentRightClickAction;
idleLabel: string;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
}) {
if (pendingAction === action) {
return action === EditorAgentRightClickAction.DownloadAsset
? '下载中'
: action === EditorAgentRightClickAction.ReferenceImage
? '引用中'
: '复制中';
}
if (resultAction !== action) {
return idleLabel;
}
if (result === 'success') {
return action === EditorAgentRightClickAction.DownloadAsset
? '已下载'
: action === EditorAgentRightClickAction.ReferenceImage
? '已引用'
: '已复制';
}
if (result === 'error') {
return action === EditorAgentRightClickAction.DownloadAsset
? '下载失败'
: action === EditorAgentRightClickAction.ReferenceImage
? '引用失败'
: '复制失败';
}
return idleLabel;
}
function assetDownloadLabel(asset: EditorAgentContextAsset) {
return `下载${
asset.mediaType === 'image'
? '图片'
: asset.mediaType === 'video'
? '视频'
: '音频'
}`;
}
export function MessageBubbleRightClickMenu({
x,
y,
target,
pendingAction,
resultAction,
result,
onAction,
onClose,
}: MessageBubbleRightClickMenuProps) {
const menuRef = useRef<HTMLDivElement | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null,
);
useLayoutEffect(() => {
const menu = menuRef.current;
if (!menu || typeof window === 'undefined') {
return;
}
const rect = menu.getBoundingClientRect();
setPosition({
x: Math.min(
Math.max(x, VIEWPORT_MARGIN),
Math.max(
VIEWPORT_MARGIN,
window.innerWidth - rect.width - VIEWPORT_MARGIN,
),
),
y: Math.min(
Math.max(y, VIEWPORT_MARGIN),
Math.max(
VIEWPORT_MARGIN,
window.innerHeight - rect.height - VIEWPORT_MARGIN,
),
),
});
}, [target.kind, x, y]);
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
onClose();
}
};
const handleContextMenu = (event: MouseEvent) => {
if (menuRef.current?.contains(event.target as Node)) {
event.preventDefault();
}
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('contextmenu', handleContextMenu, true);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', onClose, true);
window.addEventListener('resize', onClose);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('contextmenu', handleContextMenu, true);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', onClose, true);
window.removeEventListener('resize', onClose);
};
}, [onClose]);
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
ref={menuRef}
className="image-canvas-editor__context-menu"
role="menu"
aria-label={target.kind === 'text' ? '消息右键菜单' : '消息素材右键菜单'}
style={{
left: position?.x ?? x,
top: position?.y ?? y,
zIndex: 60,
}}
onContextMenu={(event) => event.preventDefault()}
>
{target.kind === 'text' ? (
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() => onAction(EditorAgentRightClickAction.CopyText)}
>
{actionLabel({
action: EditorAgentRightClickAction.CopyText,
idleLabel: '复制文本',
pendingAction,
resultAction,
result,
})}
</button>
) : (
<>
{target.asset.mediaType === 'image' ? (
<>
{contextAssetMediaSrc(target.asset).trim() ? (
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() =>
onAction(EditorAgentRightClickAction.ReferenceImage)
}
>
{actionLabel({
action: EditorAgentRightClickAction.ReferenceImage,
idleLabel: '引用',
pendingAction,
resultAction,
result,
})}
</button>
) : null}
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() => onAction(EditorAgentRightClickAction.CopyImage)}
>
{actionLabel({
action: EditorAgentRightClickAction.CopyImage,
idleLabel: '复制图片',
pendingAction,
resultAction,
result,
})}
</button>
</>
) : null}
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() => onAction(EditorAgentRightClickAction.DownloadAsset)}
>
{actionLabel({
action: EditorAgentRightClickAction.DownloadAsset,
idleLabel: assetDownloadLabel(target.asset),
pendingAction,
resultAction,
result,
})}
</button>
</>
)}
</div>,
document.body,
);
}
@@ -8,12 +8,16 @@ import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
import { ResolvedAssetVideo } from '@/src/components/ResolvedAssetVideo.tsx';
import { getExternalGenerationJobStatus } from '@/src/services/external-generation';
import type { RightClickMenuHandler } from './common.ts';
function ToolCallView({
toolCall,
onJobCompleted,
onRightClickMenu,
}: {
toolCall: EditorAgentToolCall;
onJobCompleted?: () => void;
onRightClickMenu?: RightClickMenuHandler;
}) {
const videos = toolCall.videos ?? [];
const audios = toolCall.audios ?? [];
@@ -69,6 +73,8 @@ function ToolCallView({
disposed = true;
if (timeoutId) clearTimeout(timeoutId);
};
// 轮询只允许新的 job/status source 重置;其余值通过当前 source 对应的闭包读取。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialDisplayError, initialDisplayStatus, jobId, shouldPoll]);
const isCancelled = displayStatus === 'cancelled';
const isCompleted = displayStatus === 'completed';
@@ -107,6 +113,18 @@ function ToolCallView({
<div
key={`${toolCall.toolName}-${image.resourceId ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
kind: 'generated_media',
mediaType: 'image',
mediaSrc: image.imageSrc,
objectKey: image.objectKey,
suggestedFileName: `Agent生成图片-${index + 1}`,
})
: undefined
}
>
<ResolvedAssetImage
src={image.thumbnailSrc ?? image.imageSrc}
@@ -125,6 +143,18 @@ function ToolCallView({
<div
key={`${toolCall.toolName}-${video.resourceId ?? video.objectKey ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
kind: 'generated_media',
mediaType: 'video',
mediaSrc: video.videoSrc,
objectKey: video.objectKey,
suggestedFileName: `Agent生成视频-${index + 1}`,
})
: undefined
}
>
<ResolvedAssetVideo
src={video.videoSrc}
@@ -148,6 +178,18 @@ function ToolCallView({
<div
key={`${toolCall.toolName}-${audio.resourceId ?? audio.objectKey ?? index}`}
className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 p-2"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
kind: 'generated_media',
mediaType: 'audio',
mediaSrc: audio.audioSrc,
objectKey: audio.objectKey,
suggestedFileName: `Agent生成音频-${index + 1}`,
})
: undefined
}
>
<Volume2
className="h-4 w-4 shrink-0 text-slate-500"

Some files were not shown because too many files have changed in this diff Show More