From bda650e59868b382d4f04f423846b0df147c7676 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 09:57:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=88=9B=E4=BD=9C=E5=8F=82?= =?UTF-8?q?=E8=80=83=E5=9B=BE=E5=8E=9F=E7=94=9F=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creation Agent参考图上传优先复用file.importImage宿主能力 补充原生导入取消和浏览器回退测试 更新宿主壳协议、方案文档和共享记忆 --- .../shared-memory/decision-log.md | 7 + .../shared-memory/development-workflow.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 + ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- .../CreationAgentWorkspace.test.tsx | 148 ++++++++++++++++++ .../creation-agent/CreationAgentWorkspace.tsx | 72 +++++++-- 6 files changed, 222 insertions(+), 10 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ea2e9afc5..c70e5e0c1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2550,6 +2550,13 @@ - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/host_bridge/`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run test -- packages/shared/src/contracts/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-19 创作 Agent 参考图复用原生图片导入 + +- 背景:Creation Agent 的文档导入和会话导出已接入原生壳 HostBridge,但参考图上传仍只触发浏览器隐藏文件输入;在 Expo / Tauri 壳内这会绕开已实现的受控 `file.importImage` 系统选择器体验。 +- 决策:`CreationAgentWorkspace` 的参考图上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的 base64 图片副本转换成浏览器 `File` 后继续交给现有 `onReferenceImageChange` 校验、预览和上传链路。用户取消原生选择时停留在壳流程内,不连带弹出浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/creation-agent/CreationAgentWorkspace.tsx`、`src/services/host-bridge/hostBridge.ts`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 方洞结果页图片槽位接入原生壳图片导入 - 背景:方洞结果页的封面、背景、形状和洞口图片槽位已经支持浏览器文件输入、历史图选择、AI 生成和自动保存,但 Expo / Tauri 壳内点击上传仍只能触发 WebView 的浏览器文件输入,没有复用已落地的受控 `file.importImage` 能力。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index d6f2a1e29..753d30969 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -218,6 +218,7 @@ npm run check:native-shells 该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、完整相对路径文档反查、H5 HostBridge 事件订阅双能力门控反查、移动端和桌面端单端源码清单门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。 创作 Agent 原生壳文档导入优先走 `file.importDocument`,旧壳只声明 `file.importText` 时才回退文本导入;相关变更必须让根级和单端门禁覆盖共享 method、capability profile、文档 MIME / 5 MiB 上限、读取前 size 校验,以及 H5 base64 转 `File` 后继续走后端文档解析的链路。 +创作 Agent 参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给既有 `onReferenceImageChange` 校验链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。 该命令会反查微信小程序 `WECHAT_HOST_CAPABILITIES` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES` 一致;小程序生产代码继续保留 CommonJS 运行时镜像,不直接 import TypeScript shared 包。 该命令同时会运行微信小程序 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。 该命令还会反查微信小程序 `app.json.pages` 与 `host-bridge/protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、`WEB_VIEW_SOURCE_QUERY`、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段和 H5 / API base URL 格式,避免页面路由、来源标记、宿主上下文 query 或域名配置在微信壳、H5 HostBridge 与运行时配置之间分叉。生产 / 开发 H5 与 API 域名都必须显式配置为纯 HTTPS domain,运行时开发域名回退生产域名只作为异常兜底。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 1c2f44b1d..23cbf1100 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -391,6 +391,8 @@ GameBridge 禁止: 2026-06-19 追加:移动壳和桌面壳共同声明并实现 `file.importDocument`,作为创作 Agent 工作台的优先文档导入路径。Expo 通过 DocumentPicker、Tauri 通过系统文件选择框读取用户选择的文本类文档或 DOCX,单次不超过 5 MiB,读取 base64 前必须先完成可信 byte count 门禁;成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备 URI、本机绝对路径或通用文件系统。H5 把返回内容转换成浏览器 `File` 后继续调用 `/api/runtime/creation-agent/document-inputs/parse`,不在前端解析 DOCX,也不绕过后端解析、大小校验或错误口径;旧壳只声明 `file.importText` 时才回退到文本导入。 +2026-06-19 追加:创作 Agent 工作台的参考图上传接入移动壳和桌面壳共同声明的 `file.importImage`。H5 在 `native_app` 且宿主声明该能力时优先打开 Expo 相册选择器或 Tauri 系统图片选择框,把返回的 base64 图片副本转换成浏览器 `File` 后继续交给既有 `onReferenceImageChange` 校验、预览和上传链路;用户取消时停留在原生壳文件流程内,不再连带弹出浏览器文件输入。普通浏览器、小程序和未声明 `file.importImage` 的裁剪壳继续使用原隐藏文件输入。 + 2026-06-19 追加:桌面壳同样承接 H5 创作 Agent 会话 Markdown 导出。H5 侧只在宿主声明 `file.exportText` 且当前会话存在可导出内容时显示导出按钮,导出前完成 5 MiB UTF-8 byte 校验;Tauri 只负责系统保存对话框、文件名清洗、写入文本和返回文件名 / 字节数,不把 dialog、fs 或本机路径暴露给 H5。 2026-06-18 追加:桌面壳图片和音频导入 / 导出不再只信扩展名或 H5 声明的 MIME。Tauri Rust 侧会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 bytes 头部识别,导入时要求文件扩展名对应的允许 MIME 与真实 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index b9dddb6c1..07729f7d8 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -71,7 +71,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗 - `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取文本内容前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。 - `importHostDocumentFile()`:原生 App 宿主的受控文档导入入口。Expo 移动壳通过 Expo DocumentPicker,Tauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json`、`application/vnd.openxmlformats-officedocument.wordprocessingml.document` 或对应 `.txt` / `.md` / `.markdown` / `.csv` / `.json` / `.docx` 扩展名,单次不超过 5 MiB。成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI、本机绝对路径或通用文件系统能力;宿主必须在读取 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文档导入,把返回 base64 转换成现有浏览器 `File` 后继续调用 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才回退到文本导入,普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。该能力不在前端解析 DOCX,也不绕过后端文档解析、大小校验或错误口径。 - `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。 -- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png`、`image/jpeg`、`image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;反馈页上传凭证、个人资料头像上传和方洞结果页图片槽位上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 +- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png`、`image/jpeg`、`image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;创作 Agent 工作台的参考图上传、反馈页上传凭证、个人资料头像上传和方洞结果页图片槽位上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 - `scanHostQrCode()`:原生 App 宿主的受控二维码扫描入口。Expo 移动壳声明 `scanner.scanQrCode`,通过 `expo-camera` 的真实相机权限和 `CameraView` 扫描 QR code,成功只返回清洗后的二维码文本与 `qr_code` 格式,单次值最多保留 4096 字符且拒绝空值和控制字符;用户关闭或系统取消返回 `cancelled`,H5 不会继续连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中用于明确返回 `unsupported_method`,不声明 capability、不伪造桌面扫码。个人中心扫码入口在 `native_app` 且宿主声明该能力时优先调用原生扫码;宿主不支持、旧壳缺能力或扫码结果非法时继续打开现有浏览器摄像头扫码弹层,普通浏览器和小程序保持原有路径。 HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`;事件名必须同时进入 capability 白名单。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。H5 事件订阅入口必须同时要求 `host.events` 和对应事件 capability,不能仅凭 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 或 `file.imageDropped` 单项能力就绑定事件监听;旧壳或裁剪壳缺任一能力时订阅应返回空取消函数。`npm run check:native-shells` 会反查共享事件清单、H5 订阅 facade 和 `canUseNativeHostEventCapability(...)`,防止后续事件订阅绕过双能力门控。 diff --git a/src/components/creation-agent/CreationAgentWorkspace.test.tsx b/src/components/creation-agent/CreationAgentWorkspace.test.tsx index 82504e20b..a58a092ea 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.test.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.test.tsx @@ -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>( + async () => undefined, + ); + + try { + render( + {}} + 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( + {}} + 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( + {}} + 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(); diff --git a/src/components/creation-agent/CreationAgentWorkspace.tsx b/src/components/creation-agent/CreationAgentWorkspace.tsx index 1221ff1d7..f61513976 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.tsx @@ -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( 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) => { + 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 ? ( - {documentInputError || referenceImageError || error} + {documentInputError || + referenceImageInputError || + referenceImageError || + error} ) : 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) {