Files
Genarrative/src/components/creative-agent/CreativeAgentInputComposer.tsx
T
kdletters 0cb75f14e5 接入轻输入参考图原生导入
创作 Agent 轻输入参考图在原生壳内优先走宿主图片导入

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

同步 HostBridge 测试、协议文档和共享记忆
2026-06-19 11:34:28 +08:00

257 lines
7.1 KiB
TypeScript

import { ArrowUp, Camera, ImagePlus, Loader2, Plus } from 'lucide-react';
import { type ChangeEvent, useRef, useState } from 'react';
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';
import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
export type CreativeAgentComposerImage = {
imageUrl: string;
thumbnailUrl: string;
label: string;
};
type CreativeAgentInputComposerProps = {
isBusy: boolean;
variant?: 'panel' | 'floating';
placeholder?: string;
onSubmit: (payload: {
text: string;
image: CreativeAgentComposerImage | null;
}) => void;
};
export function CreativeAgentInputComposer({
isBusy,
variant = 'panel',
placeholder = '想做成什么拼图?',
onSubmit,
}: CreativeAgentInputComposerProps) {
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 hostImageImportResultToFile = (result: HostFileImportImageResult) =>
puzzleReferenceImageDataUrlToFile(
`data:${result.mimeType};base64,${result.base64Data}`,
result.fileName,
);
const setReferenceImageFromFile = async (file: File) => {
try {
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
setImage({
imageUrl: dataUrl,
thumbnailUrl: dataUrl,
label: file.name.trim() || '参考图',
});
setImageError(null);
} catch (error) {
setImageError(
error instanceof Error ? error.message : '参考图读取失败,请重试。',
);
}
};
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;
}
onSubmit({
text: text.trim(),
image,
});
setText('');
setImage(null);
setImageError(null);
};
const floating = variant === 'floating';
const composerContent = (
<>
<div className="flex items-end gap-2">
<PlatformIconButton
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 ? (
<Plus className="h-5 w-5" />
) : (
<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);
}}
className="hidden"
/>
</>
}
/>
{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"
value={text}
disabled={isBusy}
rows={2}
size="xs"
density="roomy"
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
submit();
}
}}
className="min-h-11 flex-1"
placeholder={placeholder}
aria-label="智能创作输入"
/>
<PlatformIconButton
disabled={!canSubmit}
onClick={submit}
className="h-11 w-11 shrink-0"
label="发送"
title="发送"
icon={
isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)
}
/>
</div>
{image ? (
<PlatformUploadPreviewCard
layout="inline"
imageSrc={image.thumbnailUrl}
imageAlt="创作参考图"
caption={image.label}
removeLabel="移除参考图"
disabled={isBusy}
onRemove={() => setImage(null)}
className="mt-3"
removeButtonProps={{ title: '移除参考图' }}
/>
) : null}
{imageError ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-2"
>
{imageError}
</PlatformStatusMessage>
) : null}
</>
);
if (!floating) {
return (
<PlatformSubpanel
padding="sm"
radius="lg"
className="creative-agent-composer sm:p-4"
>
{composerContent}
</PlatformSubpanel>
);
}
return (
<section className="creative-agent-composer creative-agent-composer--floating">
{composerContent}
</section>
);
}