Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
222 lines
8.1 KiB
TypeScript
222 lines
8.1 KiB
TypeScript
// FileSource — the control behind a file/image/record/video param. The bot runs
|
|
// on the SERVER, so a browser-local path is meaningless to it; this offers three
|
|
// honest sources: a URL the bot fetches, a path that already exists on the
|
|
// server, or a browser upload that we stream to a server temp path (and then
|
|
// feed that path to the action). Upload progress flows through the app-level
|
|
// task registry so it survives tab switches.
|
|
import { useId, useState } from 'react';
|
|
import { FileUp, Link2, Loader2, ServerCog, X } from 'lucide-react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { useApi } from '@/lib/api';
|
|
import { useDebugTasks } from '@/contexts/DebugTaskContext';
|
|
import { cn } from '@/lib/utils';
|
|
import type { FieldRole } from '@/types';
|
|
|
|
type Mode = 'url' | 'server' | 'upload';
|
|
|
|
const ACCEPT: Partial<Record<FieldRole, string>> = {
|
|
image: 'image/*,.png,.jpg,.jpeg,.gif,.webp,.bmp,.heic,.heif',
|
|
record: 'audio/*,.silk,.amr,.mp3,.wav,.m4a,.ogg',
|
|
video: 'video/*,.mp4,.mov,.mkv,.webm',
|
|
};
|
|
|
|
function Seg({
|
|
active,
|
|
onClick,
|
|
icon,
|
|
label,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors',
|
|
active
|
|
? 'bg-card text-foreground shadow-sm ring-1 ring-border/60'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function FileSource({
|
|
value,
|
|
onChange,
|
|
role,
|
|
placeholder,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
role?: FieldRole;
|
|
placeholder?: string;
|
|
}) {
|
|
const api = useApi();
|
|
const { start, update, finish } = useDebugTasks();
|
|
const [mode, setMode] = useState<Mode>('url');
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadName, setUploadName] = useState<string | null>(null);
|
|
const [uploadErr, setUploadErr] = useState<string | null>(null);
|
|
const inputId = useId();
|
|
const accept = role ? ACCEPT[role] : undefined;
|
|
|
|
const doUpload = async (file: File) => {
|
|
setUploadErr(null);
|
|
setUploadName(file.name);
|
|
setUploading(true);
|
|
const ac = new AbortController();
|
|
const taskId = start({
|
|
kind: 'upload',
|
|
label: `上传 ${file.name}`,
|
|
progress: 0,
|
|
cancel: () => ac.abort(),
|
|
});
|
|
|
|
try {
|
|
const res = await api.debug.upload(file, {
|
|
signal: ac.signal,
|
|
onProgress: (f) => update(taskId, { progress: f }),
|
|
});
|
|
|
|
if (!res.path) {
|
|
throw new Error(res.message || '上传失败');
|
|
}
|
|
|
|
onChange(res.path);
|
|
finish(taskId, 'done', res.path);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : '上传失败';
|
|
setUploadErr(msg);
|
|
finish(taskId, ac.signal.aborted ? 'canceled' : 'failed', msg);
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="inline-flex w-fit gap-0.5 rounded-xl bg-muted/70 p-0.5">
|
|
<Seg
|
|
active={mode === 'url'}
|
|
onClick={() => setMode('url')}
|
|
icon={<Link2 className="h-3.5 w-3.5" />}
|
|
label="URL"
|
|
/>
|
|
<Seg
|
|
active={mode === 'server'}
|
|
onClick={() => setMode('server')}
|
|
icon={<ServerCog className="h-3.5 w-3.5" />}
|
|
label="服务器路径"
|
|
/>
|
|
<Seg
|
|
active={mode === 'upload'}
|
|
onClick={() => setMode('upload')}
|
|
icon={<FileUp className="h-3.5 w-3.5" />}
|
|
label="上传"
|
|
/>
|
|
</div>
|
|
|
|
{mode === 'url' && (
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder || 'https://…'}
|
|
/>
|
|
)}
|
|
|
|
{mode === 'server' && (
|
|
<div className="flex flex-col gap-1">
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder="/path/on/server/file.jpg"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">使用服务器上的文件路径</span>
|
|
</div>
|
|
)}
|
|
|
|
{mode === 'upload' && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<input
|
|
id={inputId}
|
|
type="file"
|
|
accept={accept}
|
|
className="sr-only"
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
|
|
if (f) {
|
|
void doUpload(f);
|
|
}
|
|
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
<label
|
|
htmlFor={inputId}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const file = e.dataTransfer.files[0];
|
|
|
|
if (file && !uploading) {
|
|
void doUpload(file);
|
|
}
|
|
}}
|
|
className={cn(
|
|
'flex items-center justify-center gap-2 rounded-xl border border-dashed border-border/70 px-3 py-3 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground',
|
|
uploading && 'pointer-events-none opacity-60',
|
|
)}
|
|
>
|
|
{uploading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" /> 上传中…
|
|
</>
|
|
) : (
|
|
<>
|
|
<FileUp className="h-4 w-4" /> 选择或拖入文件
|
|
</>
|
|
)}
|
|
</label>
|
|
{value && uploadName && !uploading && (
|
|
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-2.5 py-1.5 text-meta">
|
|
<span className="truncate text-muted-foreground">
|
|
已上传:{uploadName}
|
|
</span>
|
|
<code
|
|
className="ml-auto max-w-[55%] truncate font-mono text-muted-foreground/80"
|
|
title={value}
|
|
>
|
|
{value}
|
|
</code>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onChange('');
|
|
setUploadName(null);
|
|
}}
|
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
{uploadErr && <span className="text-xs text-destructive">{uploadErr}</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|