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

创作 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
@@ -2654,3 +2654,10 @@
- 决策:`PlatformEntryFlowShellImpl` 的儿童动作热身入口在 `native_app` 且宿主声明 `navigation.openNativePage` 时必须优先调用 `navigateHostNativePage('/child-motion-demo')`;宿主未声明、返回失败、普通浏览器或小程序运行态才回退原浏览器跳转。`/child-motion-demo` 仍是固定内置 H5 体验,不走代码包下载流程,也不新增真实原生页面。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、宿主壳能力统一协议文档。
- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "native app opens child motion demo through host navigation bridge"``npm run check:native-shells``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-06-19 创作 Agent 轻输入参考图接入原生壳图片导入
- 背景:`CreativeAgentInputComposer` 仍通过隐藏浏览器文件输入读取参考图;在 Expo / Tauri 壳中会绕过已经落地的受控 `file.importImage` 能力,移动壳也无法复用真实 `file.captureImage` 拍摄能力。
- 决策:轻输入 composer 在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走 `readPuzzleReferenceImageAsDataUrl` 的图片类型、大小、压缩和 data URL 预览链路;移动壳声明 `file.captureImage` 时额外展示拍摄参考图入口,调用 `captureHostImageFile()` 后复用同一链路。用户取消宿主选择或拍摄时停留在壳流程内,不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。
- 影响范围:`src/components/creative-agent/CreativeAgentInputComposer.tsx``src/components/creative-agent/CreativeAgentInputComposer.test.tsx`、宿主壳能力统一协议文档。
- 验证方式:`npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx``npm run typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
@@ -219,6 +219,7 @@ npm run check:native-shells
该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、完整相对路径文档反查、H5 HostBridge 事件订阅双能力门控反查、H5 `navigation.canGoBack` 消费 hook 与直达二级页返回锚点测试、移动端和桌面端单端源码清单门禁、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 页面内导航保留完整原生宿主上下文和 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` 校验链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。
创作 Agent 轻输入 composer 的参考图按钮在原生壳声明 `file.importImage` 时必须优先走宿主图片导入;移动壳声明 `file.captureImage` 时才显示拍摄参考图入口,并把宿主图片同样转为 `File` 后复用 `readPuzzleReferenceImageAsDataUrl` 的类型、大小、压缩和预览链路。
反馈页上传凭证在原生壳声明 `file.importImage` 时必须优先走宿主图片导入;移动壳声明 `file.captureImage` 时才显示拍摄凭证入口,并把拍摄图片同样转为 `File` 后复用反馈页原有数量、大小、MIME、data URL 预览和提交 payload 校验。
固定内置 H5 体验入口在原生壳声明 `navigation.openNativePage` 时必须优先走 `navigateHostNativePage()`;例如儿童动作热身 Demo 从平台首页进入 `/child-motion-demo` 时应由 HostBridge 发出 `navigation.openNativePage`,宿主不可用时才回退浏览器跳转。
汪汪声浪结果页玩家 / 对手 / UI 背景三图槽位上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给 `uploadBarkBattleAsset` 与当前槽位写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。
@@ -72,7 +72,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 DocumentPickerTauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `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` 回调;创作 Agent 工作台参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传、汪汪声浪结果页三图槽位上传和视觉小说结果页图片素材上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路,视觉小说结果页继续把图片转换成浏览器 `File` 后交给 `uploadVisualNovelAsset` 上传和当前封面 / 角色 / 场景素材写回链路;反馈页在移动壳声明 `file.captureImage` 时额外展示拍摄凭证入口,并把拍摄结果复用同一凭证校验与提交链路。在桌面壳同时声明 `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 工作台参考图上传、轻输入 composer 参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传、汪汪声浪结果页三图槽位上传和视觉小说结果页图片素材上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 工作台继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,轻输入 composer 继续复用 `readPuzzleReferenceImageAsDataUrl` 的图片类型、大小、压缩和 data URL 预览链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路,视觉小说结果页继续把图片转换成浏览器 `File` 后交给 `uploadVisualNovelAsset` 上传和当前封面 / 角色 / 场景素材写回链路;反馈页和轻输入 composer 在移动壳声明 `file.captureImage` 时额外展示拍摄入口,并把拍摄结果复用同一图片校验与提交链路。在桌面壳同时声明 `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(...)`,防止后续事件订阅绕过双能力门控。
@@ -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"