sync: complete remaining files (source ji)
This commit is contained in:
1 parent
e602d88695
commit
b8ad8fda94
80 files changed
+3420
-924
No files matched your search
@@ -34,8 +34,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { PromptInputFilePart } from "@/core/uploads";
|
||||
import { splitUnsupportedUploadFiles } from "@/core/uploads";
|
||||
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
|
||||
import {
|
||||
DEFAULT_UPLOAD_POLICY,
|
||||
splitUnsupportedUploadFiles,
|
||||
useUploadPolicy,
|
||||
validateChatUploadFiles,
|
||||
} from "@/core/uploads";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatStatus } from "ai";
|
||||
@@ -143,73 +148,94 @@ const useOptionalProviderAttachments = () =>
|
||||
useContext(ProviderAttachmentsContext);
|
||||
const usePromptInputValidation = () => useContext(PromptInputValidationContext);
|
||||
|
||||
export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
}>;
|
||||
export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
uploadPolicy?: UploadPolicy;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Optional global provider that lifts PromptInput state outside of PromptInput.
|
||||
* If you don't use it, PromptInput stays fully self-managed.
|
||||
*/
|
||||
export function PromptInputProvider({
|
||||
initialInput: initialTextInput = "",
|
||||
children,
|
||||
}: PromptInputProviderProps) {
|
||||
// ----- textInput state
|
||||
export function PromptInputProvider({
|
||||
initialInput: initialTextInput = "",
|
||||
uploadPolicy,
|
||||
children,
|
||||
}: PromptInputProviderProps) {
|
||||
const { data: fetchedUploadPolicy } = useUploadPolicy();
|
||||
const effectiveUploadPolicy =
|
||||
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
||||
// ----- textInput state
|
||||
const [textInput, setTextInput] = useState(initialTextInput);
|
||||
const clearInput = useCallback(() => setTextInput(""), []);
|
||||
|
||||
// ----- attachments state (global when wrapped)
|
||||
const [attachmentFiles, setAttachmentFiles] = useState<
|
||||
(PromptInputFilePart & { id: string })[]
|
||||
>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const openRef = useRef<() => void>(() => {});
|
||||
|
||||
const add = useCallback((files: File[] | FileList) => {
|
||||
const incoming = Array.from(files);
|
||||
>([]);
|
||||
const attachmentsRef = useRef(attachmentFiles);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const openRef = useRef<() => void>(() => {});
|
||||
|
||||
const add = useCallback((files: File[] | FileList) => {
|
||||
const incoming = Array.from(files);
|
||||
if (incoming.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAttachmentFiles((prev) =>
|
||||
prev.concat(
|
||||
incoming.map((file) => ({
|
||||
id: nanoid(),
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setAttachmentFiles((prev) => {
|
||||
const found = prev.find((f) => f.id === id);
|
||||
if (found?.url) {
|
||||
URL.revokeObjectURL(found.url);
|
||||
}
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setAttachmentFiles((prev) => {
|
||||
for (const f of prev) {
|
||||
if (f.url) {
|
||||
URL.revokeObjectURL(f.url);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||||
const attachmentsRef = useRef(attachmentFiles);
|
||||
attachmentsRef.current = attachmentFiles;
|
||||
return;
|
||||
}
|
||||
|
||||
const current = attachmentsRef.current;
|
||||
const existing = current.map((item) => ({
|
||||
name: item.filename ?? "attachment",
|
||||
size: item.file?.size ?? 0,
|
||||
type: item.mediaType ?? "",
|
||||
}));
|
||||
const validation = validateChatUploadFiles(
|
||||
incoming,
|
||||
existing,
|
||||
effectiveUploadPolicy,
|
||||
);
|
||||
for (const message of new Set(
|
||||
validation.issues.map((issue) => issue.message),
|
||||
)) {
|
||||
toast.error(message);
|
||||
}
|
||||
if (validation.accepted.length === 0) return;
|
||||
|
||||
const next = current.concat(
|
||||
validation.accepted.map((file) => ({
|
||||
id: nanoid(),
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
);
|
||||
attachmentsRef.current = next;
|
||||
setAttachmentFiles(next);
|
||||
}, [effectiveUploadPolicy]);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
const current = attachmentsRef.current;
|
||||
const found = current.find((f) => f.id === id);
|
||||
if (found?.url) URL.revokeObjectURL(found.url);
|
||||
const next = current.filter((f) => f.id !== id);
|
||||
attachmentsRef.current = next;
|
||||
setAttachmentFiles(next);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
for (const f of attachmentsRef.current) {
|
||||
if (f.url) {
|
||||
URL.revokeObjectURL(f.url);
|
||||
}
|
||||
}
|
||||
attachmentsRef.current = [];
|
||||
setAttachmentFiles([]);
|
||||
}, []);
|
||||
|
||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||||
attachmentsRef.current = attachmentFiles;
|
||||
|
||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
@@ -457,10 +483,17 @@ export type PromptInputProps = Omit<
|
||||
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
|
||||
syncHiddenInput?: boolean;
|
||||
// Minimal constraints
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // bytes
|
||||
onError?: (err: {
|
||||
code: "max_files" | "max_file_size" | "accept" | "unsupported_package";
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // bytes
|
||||
uploadPolicy?: UploadPolicy;
|
||||
onError?: (err: {
|
||||
code:
|
||||
| "max_files"
|
||||
| "max_file_size"
|
||||
| "max_total_size"
|
||||
| "unsupported_type"
|
||||
| "accept"
|
||||
| "unsupported_package";
|
||||
message: string;
|
||||
}) => void;
|
||||
onSubmit: (
|
||||
@@ -476,14 +509,25 @@ export const PromptInput = ({
|
||||
multiple,
|
||||
globalDrop,
|
||||
syncHiddenInput,
|
||||
maxFiles,
|
||||
maxFileSize,
|
||||
maxFiles,
|
||||
maxFileSize,
|
||||
uploadPolicy,
|
||||
onError,
|
||||
onSubmit,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputProps) => {
|
||||
// Try to use a provider controller if present
|
||||
}: PromptInputProps) => {
|
||||
const { data: fetchedUploadPolicy } = useUploadPolicy();
|
||||
const effectiveUploadPolicy =
|
||||
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
||||
const policyAccept = useMemo(
|
||||
() =>
|
||||
Object.values(effectiveUploadPolicy.groups)
|
||||
.flatMap((group) => group.extensions)
|
||||
.join(","),
|
||||
[effectiveUploadPolicy],
|
||||
);
|
||||
// Try to use a provider controller if present
|
||||
const controller = useOptionalPromptInputController();
|
||||
const usingProvider = !!controller;
|
||||
|
||||
@@ -516,8 +560,11 @@ export const PromptInput = ({
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern.endsWith("/*")) {
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern.startsWith(".")) {
|
||||
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
|
||||
}
|
||||
if (pattern.endsWith("/*")) {
|
||||
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
|
||||
return f.type.startsWith(prefix);
|
||||
}
|
||||
@@ -527,57 +574,72 @@ export const PromptInput = ({
|
||||
[accept],
|
||||
);
|
||||
|
||||
const addLocal = useCallback(
|
||||
(fileList: File[] | FileList) => {
|
||||
const incoming = Array.from(fileList);
|
||||
const accepted = incoming.filter((f) => matchesAccept(f));
|
||||
if (incoming.length && accepted.length === 0) {
|
||||
onError?.({
|
||||
code: "accept",
|
||||
message: "No files match the accepted types.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const withinSize = (f: File) =>
|
||||
maxFileSize ? f.size <= maxFileSize : true;
|
||||
const sized = accepted.filter(withinSize);
|
||||
if (accepted.length > 0 && sized.length === 0) {
|
||||
onError?.({
|
||||
code: "max_file_size",
|
||||
message: "All files exceed the maximum size.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) => {
|
||||
const capacity =
|
||||
typeof maxFiles === "number"
|
||||
? Math.max(0, maxFiles - prev.length)
|
||||
: undefined;
|
||||
const capped =
|
||||
typeof capacity === "number" ? sized.slice(0, capacity) : sized;
|
||||
if (typeof capacity === "number" && sized.length > capacity) {
|
||||
onError?.({
|
||||
code: "max_files",
|
||||
message: "Too many files. Some were not added.",
|
||||
});
|
||||
}
|
||||
const next: (PromptInputFilePart & { id: string })[] = [];
|
||||
for (const file of capped) {
|
||||
next.push({
|
||||
id: nanoid(),
|
||||
type: "file",
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
});
|
||||
}
|
||||
return prev.concat(next);
|
||||
});
|
||||
},
|
||||
[matchesAccept, maxFiles, maxFileSize, onError],
|
||||
);
|
||||
const addLocal = useCallback(
|
||||
(fileList: File[] | FileList) => {
|
||||
const incoming = Array.from(fileList);
|
||||
const accepted = incoming.filter((f) => matchesAccept(f));
|
||||
if (accepted.length < incoming.length) {
|
||||
const error = {
|
||||
code: "accept",
|
||||
message: "部分文件不符合当前输入框允许的格式。",
|
||||
} as const;
|
||||
onError?.(error);
|
||||
if (!onError) toast.error(error.message);
|
||||
}
|
||||
const localPolicy: UploadPolicy = {
|
||||
...effectiveUploadPolicy,
|
||||
max_files_per_message:
|
||||
typeof maxFiles === "number"
|
||||
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
|
||||
: effectiveUploadPolicy.max_files_per_message,
|
||||
groups: Object.fromEntries(
|
||||
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
|
||||
key,
|
||||
{
|
||||
...group,
|
||||
max_bytes:
|
||||
typeof maxFileSize === "number"
|
||||
? Math.min(group.max_bytes, maxFileSize)
|
||||
: group.max_bytes,
|
||||
},
|
||||
]),
|
||||
) as UploadPolicy["groups"],
|
||||
};
|
||||
const existing = items.map((item) => ({
|
||||
name: item.filename ?? "attachment",
|
||||
size: item.file?.size ?? 0,
|
||||
type: item.mediaType ?? "",
|
||||
}));
|
||||
const validation = validateChatUploadFiles(
|
||||
accepted,
|
||||
existing,
|
||||
localPolicy,
|
||||
);
|
||||
for (const issue of Array.from(
|
||||
new Map(
|
||||
validation.issues.map((item) => [item.message, item]),
|
||||
).values(),
|
||||
)) {
|
||||
onError?.({ code: issue.code, message: issue.message });
|
||||
if (!onError) toast.error(issue.message);
|
||||
}
|
||||
if (validation.accepted.length === 0) return;
|
||||
|
||||
setItems((prev) =>
|
||||
prev.concat(
|
||||
validation.accepted.map((file) => ({
|
||||
id: nanoid(),
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
),
|
||||
);
|
||||
},
|
||||
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
|
||||
);
|
||||
|
||||
const removeLocal = useCallback(
|
||||
(id: string) =>
|
||||
@@ -832,7 +894,7 @@ export const PromptInput = ({
|
||||
const inner = (
|
||||
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
|
||||
<input
|
||||
accept={accept}
|
||||
accept={accept ?? policyAccept}
|
||||
aria-label="Upload files"
|
||||
className="hidden"
|
||||
multiple={multiple}
|
||||
|
||||
Reference in new issue
Block a user