接入轻输入参考图原生导入
创作 Agent 轻输入参考图在原生壳内优先走宿主图片导入 移动壳声明拍摄能力时展示拍摄参考图入口 同步 HostBridge 测试、协议文档和共享记忆
This commit is contained in:
@@ -2,15 +2,45 @@
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
captureHostImageFile,
|
||||
importHostImageFile,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { CreativeAgentInputComposer } from './CreativeAgentInputComposer';
|
||||
|
||||
vi.mock('../../services/puzzleReferenceImage', () => ({
|
||||
readPuzzleReferenceImageAsDataUrl: vi.fn(),
|
||||
vi.mock('../../services/host-bridge/hostBridge', () => ({
|
||||
canUseNativeHostCapability: vi.fn(() => false),
|
||||
captureHostImageFile: vi.fn(),
|
||||
importHostImageFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzleReferenceImage', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/puzzleReferenceImage')
|
||||
>('../../services/puzzleReferenceImage');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
readPuzzleReferenceImageAsDataUrl: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability);
|
||||
const captureHostImageFileMock = vi.mocked(captureHostImageFile);
|
||||
const importHostImageFileMock = vi.mocked(importHostImageFile);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
canUseNativeHostCapabilityMock.mockReturnValue(false);
|
||||
captureHostImageFileMock.mockResolvedValue(false);
|
||||
importHostImageFileMock.mockResolvedValue(false);
|
||||
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockReset();
|
||||
});
|
||||
|
||||
test('submits typed text with uploaded reference image', async () => {
|
||||
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
|
||||
'data:image/png;base64,reference',
|
||||
@@ -56,6 +86,97 @@ test('submits typed text with uploaded reference image', async () => {
|
||||
expect(screen.queryByText('参考图.png')).toBeNull();
|
||||
});
|
||||
|
||||
test('imports reference image through native HostBridge', async () => {
|
||||
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
|
||||
'data:image/png;base64,host-reference',
|
||||
);
|
||||
canUseNativeHostCapabilityMock.mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
importHostImageFileMock.mockResolvedValue({
|
||||
action: 'selected',
|
||||
fileName: '宿主参考图.png',
|
||||
base64Data: 'aG9zdA==',
|
||||
mimeType: 'image/png',
|
||||
bytes: 4,
|
||||
});
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
render(<CreativeAgentInputComposer isBusy={false} onSubmit={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加参考图' }));
|
||||
|
||||
expect(await screen.findByText('宿主参考图.png')).toBeTruthy();
|
||||
expect(importHostImageFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(readPuzzleReferenceImageAsDataUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '宿主参考图.png',
|
||||
type: 'image/png',
|
||||
size: 4,
|
||||
}),
|
||||
);
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
inputClickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps native image cancellation inside shell flow', async () => {
|
||||
canUseNativeHostCapabilityMock.mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
importHostImageFileMock.mockResolvedValue(false);
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
render(<CreativeAgentInputComposer isBusy={false} onSubmit={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加参考图' }));
|
||||
|
||||
await waitFor(() => expect(importHostImageFileMock).toHaveBeenCalledTimes(1));
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
expect(readPuzzleReferenceImageAsDataUrl).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
inputClickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('captures reference image through native HostBridge', async () => {
|
||||
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
|
||||
'data:image/png;base64,captured-reference',
|
||||
);
|
||||
canUseNativeHostCapabilityMock.mockImplementation(
|
||||
(capability) => capability === 'file.captureImage',
|
||||
);
|
||||
captureHostImageFileMock.mockResolvedValue({
|
||||
action: 'captured',
|
||||
fileName: '拍摄参考图.png',
|
||||
base64Data: 'Y2FtZXJh',
|
||||
mimeType: 'image/png',
|
||||
bytes: 6,
|
||||
});
|
||||
|
||||
render(<CreativeAgentInputComposer isBusy={false} onSubmit={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '拍摄参考图' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '拍摄参考图' }));
|
||||
|
||||
expect(await screen.findByText('拍摄参考图.png')).toBeTruthy();
|
||||
expect(captureHostImageFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(readPuzzleReferenceImageAsDataUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '拍摄参考图.png',
|
||||
type: 'image/png',
|
||||
size: 6,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('removes selected reference image before submit', async () => {
|
||||
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
|
||||
'data:image/png;base64,reference',
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { ArrowUp, ImagePlus, Loader2, Plus } from 'lucide-react';
|
||||
import { type ChangeEvent, useState } from 'react';
|
||||
import { ArrowUp, Camera, ImagePlus, Loader2, Plus } from 'lucide-react';
|
||||
import { type ChangeEvent, useRef, useState } from 'react';
|
||||
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
captureHostImageFile,
|
||||
type HostFileImportImageResult,
|
||||
importHostImageFile,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import {
|
||||
puzzleReferenceImageDataUrlToFile,
|
||||
readPuzzleReferenceImageAsDataUrl,
|
||||
} from '../../services/puzzleReferenceImage';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
@@ -33,15 +42,18 @@ export function CreativeAgentInputComposer({
|
||||
const [text, setText] = useState('');
|
||||
const [image, setImage] = useState<CreativeAgentComposerImage | null>(null);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const canImportHostImage = canUseNativeHostCapability('file.importImage');
|
||||
const canCaptureHostImage = canUseNativeHostCapability('file.captureImage');
|
||||
const canSubmit = !isBusy && Boolean(text.trim() || image);
|
||||
|
||||
const handleImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const hostImageImportResultToFile = (result: HostFileImportImageResult) =>
|
||||
puzzleReferenceImageDataUrlToFile(
|
||||
`data:${result.mimeType};base64,${result.base64Data}`,
|
||||
result.fileName,
|
||||
);
|
||||
|
||||
const setReferenceImageFromFile = async (file: File) => {
|
||||
try {
|
||||
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
|
||||
setImage({
|
||||
@@ -57,6 +69,55 @@ export function CreativeAgentInputComposer({
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
await setReferenceImageFromFile(file);
|
||||
};
|
||||
|
||||
const openReferenceImagePicker = () => {
|
||||
if (isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canImportHostImage) {
|
||||
imageInputRef.current?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const importedImageFile = await importHostImageFile();
|
||||
if (!importedImageFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
await setReferenceImageFromFile(
|
||||
hostImageImportResultToFile(importedImageFile),
|
||||
);
|
||||
})();
|
||||
};
|
||||
|
||||
const captureReferenceImage = () => {
|
||||
if (isBusy || !canCaptureHostImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const capturedImageFile = await captureHostImageFile();
|
||||
if (!capturedImageFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
await setReferenceImageFromFile(
|
||||
hostImageImportResultToFile(capturedImageFile),
|
||||
);
|
||||
})();
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
@@ -76,10 +137,11 @@ export function CreativeAgentInputComposer({
|
||||
<>
|
||||
<div className="flex items-end gap-2">
|
||||
<PlatformIconButton
|
||||
asChild="label"
|
||||
className={`h-11 w-11 shrink-0 ${floating ? 'creative-agent-composer__media-button' : ''} ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
|
||||
label={image ? '更换参考图' : '添加参考图'}
|
||||
title={image ? '更换参考图' : '添加参考图'}
|
||||
disabled={isBusy}
|
||||
onClick={openReferenceImagePicker}
|
||||
icon={
|
||||
<>
|
||||
{floating ? (
|
||||
@@ -88,9 +150,11 @@ export function CreativeAgentInputComposer({
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
)}
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isBusy}
|
||||
aria-label={image ? '更换参考图' : '添加参考图'}
|
||||
onChange={(event) => {
|
||||
void handleImageChange(event);
|
||||
}}
|
||||
@@ -99,6 +163,16 @@ export function CreativeAgentInputComposer({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{canCaptureHostImage ? (
|
||||
<PlatformIconButton
|
||||
className={`h-11 w-11 shrink-0 ${floating ? 'creative-agent-composer__media-button' : ''}`}
|
||||
label={image ? '重新拍摄参考图' : '拍摄参考图'}
|
||||
title={image ? '重新拍摄参考图' : '拍摄参考图'}
|
||||
disabled={isBusy}
|
||||
onClick={captureReferenceImage}
|
||||
icon={<Camera className="h-4 w-4" />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
|
||||
Reference in New Issue
Block a user