接入创作参考图原生导入

Creation Agent参考图上传优先复用file.importImage宿主能力

补充原生导入取消和浏览器回退测试

更新宿主壳协议、方案文档和共享记忆
This commit is contained in:
2026-06-19 09:57:56 +08:00
parent f619d861c9
commit bda650e598
6 changed files with 222 additions and 10 deletions
@@ -1015,6 +1015,154 @@ test('creation agent workspace renders selected reference image with shared prev
expect(onClearReferenceImage).toHaveBeenCalledTimes(1);
});
test('creation agent workspace imports reference image through native HostBridge', async () => {
ensureScrollApis();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.importImage',
);
const importImageSpy = vi
.spyOn(hostBridgeServices, 'importHostImageFile')
.mockResolvedValue({
action: 'selected',
fileName: '设定参考图.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
});
const onReferenceImageChange = vi.fn<[File], Promise<void>>(
async () => undefined,
);
try {
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
onReferenceImageChange={onReferenceImageChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传参考图' }));
await waitFor(() => {
expect(onReferenceImageChange).toHaveBeenCalledTimes(1);
});
const importedFile = onReferenceImageChange.mock.calls[0]?.[0] as File;
expect(importImageSpy).toHaveBeenCalledTimes(1);
expect(importedFile.name).toBe('设定参考图.png');
expect(importedFile.type).toBe('image/png');
expect(importedFile.size).toBe(5);
expect(inputClickSpy).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
test('creation agent workspace keeps native image cancellation inside shell flow', async () => {
ensureScrollApis();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.spyOn(hostBridgeServices, 'importHostImageFile').mockResolvedValue(false);
const onReferenceImageChange = vi.fn();
try {
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
onReferenceImageChange={onReferenceImageChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传参考图' }));
await waitFor(() => {
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
});
expect(onReferenceImageChange).not.toHaveBeenCalled();
expect(inputClickSpy).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
test('creation agent workspace falls back to browser image picker without native image capability', () => {
ensureScrollApis();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockReturnValue(
false,
);
try {
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
onReferenceImageChange={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传参考图' }));
expect(inputClickSpy).toHaveBeenCalledTimes(1);
} finally {
inputClickSpy.mockRestore();
}
});
test('creation agent workspace shows document parse error near composer', async () => {
ensureScrollApis();
@@ -20,6 +20,7 @@ import {
canUseNativeHostCapability,
exportHostTextFile,
importHostDocumentFile,
importHostImageFile,
importHostTextFile,
} from '../../services/host-bridge/hostBridge';
import { PlatformActionButton } from '../common/PlatformActionButton';
@@ -489,6 +490,9 @@ export function CreationAgentWorkspace({
const [documentInputError, setDocumentInputError] = useState<string | null>(
null,
);
const [referenceImageInputError, setReferenceImageInputError] = useState<
string | null
>(null);
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false);
const [isExportingSessionText, setIsExportingSessionText] = useState(false);
@@ -592,6 +596,7 @@ export function CreationAgentWorkspace({
onSubmitText(text);
setDraftText('');
setDocumentInputError(null);
setReferenceImageInputError(null);
};
const appendDocumentInputText = (text: string) => {
@@ -671,6 +676,27 @@ export function CreationAgentWorkspace({
documentInputRef.current?.click();
};
const runReferenceImageTask = async (task: () => Promise<void> | void) => {
if (isBusy || isReadingReferenceImage || !onReferenceImageChange) {
return;
}
setIsReadingReferenceImage(true);
setReferenceImageInputError(null);
try {
await task();
} catch (referenceImageReadError) {
setReferenceImageInputError(
referenceImageReadError instanceof Error
? referenceImageReadError.message
: '读取参考图失败,请重新选择图片。',
);
} finally {
setIsReadingReferenceImage(false);
}
};
const exportSessionText = async () => {
if (
!sessionExportMarkdown ||
@@ -708,6 +734,28 @@ export function CreationAgentWorkspace({
};
const openReferenceImagePicker = () => {
if (isBusy || isReadingReferenceImage || !onReferenceImageChange) {
return;
}
if (canUseNativeHostCapability('file.importImage')) {
void runReferenceImageTask(async () => {
const importedImageFile = await importHostImageFile();
if (!importedImageFile) {
return;
}
await onReferenceImageChange(
base64DataToFile(
importedImageFile.base64Data,
importedImageFile.fileName,
importedImageFile.mimeType,
),
);
});
return;
}
referenceImageInputRef.current?.click();
};
@@ -732,13 +780,12 @@ export function CreationAgentWorkspace({
return;
}
setIsReadingReferenceImage(true);
await runReferenceImageTask(() => onReferenceImageChange(file));
};
try {
await onReferenceImageChange(file);
} finally {
setIsReadingReferenceImage(false);
}
const clearReferenceImage = () => {
setReferenceImageInputError(null);
onClearReferenceImage?.();
};
return (
@@ -880,19 +927,25 @@ export function CreationAgentWorkspace({
caption={referenceImageLabel || '已选择参考图'}
removeLabel="移除参考图"
disabled={isBusy || isReadingReferenceImage}
onRemove={onClearReferenceImage}
onRemove={clearReferenceImage}
className="mx-4 mb-3 bg-white/70"
removeButtonProps={{ title: '移除参考图' }}
/>
) : null}
{documentInputError || referenceImageError || error ? (
{documentInputError ||
referenceImageInputError ||
referenceImageError ||
error ? (
<PlatformStatusMessage
tone="error"
surface="platform"
className="mx-4 mb-3 rounded-[1rem]"
>
{documentInputError || referenceImageError || error}
{documentInputError ||
referenceImageInputError ||
referenceImageError ||
error}
</PlatformStatusMessage>
) : null}
@@ -973,6 +1026,7 @@ export function CreationAgentWorkspace({
onChange={(event) => {
setDraftText(event.target.value);
setDocumentInputError(null);
setReferenceImageInputError(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {