接入轻输入参考图原生导入

创作 Agent 轻输入参考图在原生壳内优先走宿主图片导入

移动壳声明拍摄能力时展示拍摄参考图入口

同步 HostBridge 测试、协议文档和共享记忆
This commit is contained in:
2026-06-19 11:34:28 +08:00
parent 3fad91337c
commit 0cb75f14e5
5 changed files with 217 additions and 14 deletions
@@ -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"