350 lines
9.9 KiB
TypeScript
350 lines
9.9 KiB
TypeScript
import type { ChatDocumentAttachment } from './types';
|
|
|
|
import { computed, ref } from 'vue';
|
|
|
|
import { uuid } from '@easyflow/utils';
|
|
|
|
const MAX_DOCUMENTS = 3;
|
|
const MAX_TOTAL_BYTES = 30 * 1024 * 1024;
|
|
const MAX_OFFICE_BYTES = 20 * 1024 * 1024;
|
|
const MAX_EXCEL_BYTES = 10 * 1024 * 1024;
|
|
const MAX_TEXT_BYTES = 5 * 1024 * 1024;
|
|
const POLL_INTERVAL_MS = 750;
|
|
const MAX_POLL_ATTEMPTS = 80;
|
|
export const CHAT_DOCUMENT_SUPPORTED_FORMATS =
|
|
'PDF、Word、PPT、Excel、TXT、Markdown';
|
|
const SUPPORTED_EXTENSIONS = new Set([
|
|
'doc',
|
|
'docx',
|
|
'md',
|
|
'pdf',
|
|
'ppt',
|
|
'pptx',
|
|
'txt',
|
|
'xls',
|
|
'xlsx',
|
|
]);
|
|
const EXCEL_EXTENSIONS = new Set(['xls', 'xlsx']);
|
|
const TEXT_EXTENSIONS = new Set(['md', 'txt']);
|
|
|
|
export interface ChatDocumentUploadContext {
|
|
agentId: string;
|
|
mode: 'DRAFT' | 'FORMAL';
|
|
sessionId: string;
|
|
}
|
|
|
|
export interface ChatDocumentUploadView extends ChatDocumentAttachment {
|
|
expiresAt?: string;
|
|
status: 'error' | 'reading' | 'ready' | 'uploading';
|
|
uploadId: string;
|
|
}
|
|
|
|
interface RequestResult<T = any> {
|
|
data: T;
|
|
errorCode: number;
|
|
message?: string;
|
|
}
|
|
|
|
export interface ChatDocumentUploadApi {
|
|
delete: (uploadId: string) => Promise<RequestResult<void>>;
|
|
retry: (uploadId: string) => Promise<RequestResult<ChatDocumentUploadView>>;
|
|
status: (uploadId: string) => Promise<RequestResult<ChatDocumentUploadView>>;
|
|
upload: (
|
|
file: File,
|
|
context: ChatDocumentUploadContext,
|
|
uploadId: string,
|
|
) => Promise<RequestResult<ChatDocumentUploadView>>;
|
|
}
|
|
|
|
export interface ChatDocumentAddFilesResult {
|
|
rejectedCount: number;
|
|
unsupportedCount: number;
|
|
}
|
|
|
|
interface LocalDocument extends ChatDocumentAttachment {
|
|
file?: File;
|
|
}
|
|
|
|
function createLocalId() {
|
|
return `agent-document-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
}
|
|
|
|
function createUploadId() {
|
|
return uuid().replaceAll('-', '');
|
|
}
|
|
|
|
function extensionOf(file: File) {
|
|
return file.name.includes('.')
|
|
? file.name.split('.').pop()?.toLowerCase() || ''
|
|
: '';
|
|
}
|
|
|
|
function maxBytes(extension: string) {
|
|
if (EXCEL_EXTENSIONS.has(extension)) return MAX_EXCEL_BYTES;
|
|
if (TEXT_EXTENSIONS.has(extension)) return MAX_TEXT_BYTES;
|
|
return MAX_OFFICE_BYTES;
|
|
}
|
|
|
|
function sizeLimitMessage(extension: string) {
|
|
if (EXCEL_EXTENSIONS.has(extension)) {
|
|
return 'Excel 文档不能超过 10 MiB';
|
|
}
|
|
if (TEXT_EXTENSIONS.has(extension)) {
|
|
return '文本文件不能超过 5 MiB';
|
|
}
|
|
return '单份文档不能超过 20 MiB';
|
|
}
|
|
|
|
function errorMessage(error: unknown) {
|
|
const candidate = error as any;
|
|
return (
|
|
candidate?.response?.data?.message || candidate?.message || '文档上传失败'
|
|
);
|
|
}
|
|
|
|
function displayStatus(
|
|
value?: string,
|
|
): NonNullable<ChatDocumentAttachment['status']> {
|
|
const status = String(value || '').toUpperCase();
|
|
if (status === 'READY') return 'ready';
|
|
if (status === 'FAILED' || status === 'READ_FAILED' || status === 'EXPIRED') {
|
|
return 'error';
|
|
}
|
|
if (status === 'UPLOADING') return 'uploading';
|
|
return 'reading';
|
|
}
|
|
|
|
function applyServerView(target: LocalDocument, view: ChatDocumentUploadView) {
|
|
const status = displayStatus(view.status);
|
|
Object.assign(target, view, {
|
|
error: view.error || (view as any).errorMessage,
|
|
file: target.file,
|
|
localId: target.localId,
|
|
status,
|
|
});
|
|
return status;
|
|
}
|
|
|
|
function wait(milliseconds: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
}
|
|
|
|
export function createChatDocumentUploads(api: ChatDocumentUploadApi) {
|
|
const items = ref<LocalDocument[]>([]);
|
|
let lifecycle = 0;
|
|
|
|
const readyItems = computed(() =>
|
|
items.value.filter(
|
|
(item): item is ChatDocumentUploadView & LocalDocument =>
|
|
item.status === 'ready' && Boolean(item.uploadId),
|
|
),
|
|
);
|
|
const uploadIds = computed(() =>
|
|
readyItems.value.map((item) => item.uploadId),
|
|
);
|
|
const processing = computed(() =>
|
|
items.value.some(
|
|
(item) => item.status === 'uploading' || item.status === 'reading',
|
|
),
|
|
);
|
|
|
|
async function poll(item: LocalDocument, expectedLifecycle = lifecycle) {
|
|
if (!item.uploadId) return;
|
|
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
|
|
await wait(POLL_INTERVAL_MS);
|
|
if (expectedLifecycle !== lifecycle || !items.value.includes(item)) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await api.status(item.uploadId);
|
|
if (response.errorCode !== 0 || !response.data) {
|
|
throw new Error(response.message || '文档读取状态查询失败');
|
|
}
|
|
applyServerView(item, response.data);
|
|
if (item.status === 'ready' || item.status === 'error') {
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
item.status = 'error';
|
|
item.error = errorMessage(error);
|
|
return;
|
|
}
|
|
}
|
|
item.status = 'error';
|
|
item.error = '文档读取时间较长,请重试状态';
|
|
}
|
|
|
|
async function upload(
|
|
item: LocalDocument,
|
|
context: ChatDocumentUploadContext,
|
|
) {
|
|
if (!item.file) return;
|
|
item.status = 'uploading';
|
|
item.error = undefined;
|
|
const currentLifecycle = lifecycle;
|
|
try {
|
|
item.uploadId ||= createUploadId();
|
|
const response = await api.upload(item.file, context, item.uploadId);
|
|
if (response.errorCode !== 0 || !response.data) {
|
|
throw new Error(response.message || '文档上传失败');
|
|
}
|
|
if (currentLifecycle !== lifecycle || !items.value.includes(item)) {
|
|
await api.delete(response.data.uploadId);
|
|
return;
|
|
}
|
|
const status = applyServerView(item, response.data);
|
|
if (status !== 'ready' && status !== 'error') {
|
|
await poll(item, currentLifecycle);
|
|
}
|
|
} catch (error) {
|
|
if (items.value.includes(item)) {
|
|
item.status = 'error';
|
|
item.error = errorMessage(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function addFiles(files: File[], context: ChatDocumentUploadContext) {
|
|
const supportedFiles: File[] = [];
|
|
let unsupportedCount = 0;
|
|
for (const file of files) {
|
|
if (SUPPORTED_EXTENSIONS.has(extensionOf(file))) {
|
|
supportedFiles.push(file);
|
|
} else {
|
|
unsupportedCount++;
|
|
}
|
|
}
|
|
const available = Math.max(0, MAX_DOCUMENTS - items.value.length);
|
|
const accepted = supportedFiles.slice(0, available);
|
|
const rejectedCount = Math.max(0, supportedFiles.length - accepted.length);
|
|
const currentTotal = items.value.reduce(
|
|
(total, item) => total + Number(item.size || 0),
|
|
0,
|
|
);
|
|
let addedBytes = 0;
|
|
const uploads: Promise<void>[] = [];
|
|
for (const file of accepted) {
|
|
const extension = extensionOf(file);
|
|
const item: LocalDocument = {
|
|
file,
|
|
localId: createLocalId(),
|
|
mimeType: file.type,
|
|
name: file.name || '文档',
|
|
size: file.size,
|
|
status: 'uploading',
|
|
};
|
|
items.value.push(item);
|
|
const trackedItem = items.value[items.value.length - 1];
|
|
if (!trackedItem) continue;
|
|
if (file.size > maxBytes(extension)) {
|
|
trackedItem.status = 'error';
|
|
trackedItem.error = sizeLimitMessage(extension);
|
|
continue;
|
|
}
|
|
if (currentTotal + addedBytes + file.size > MAX_TOTAL_BYTES) {
|
|
trackedItem.status = 'error';
|
|
trackedItem.error = '本轮文档总大小不能超过 30 MiB';
|
|
continue;
|
|
}
|
|
addedBytes += file.size;
|
|
uploads.push(upload(trackedItem, context));
|
|
}
|
|
await Promise.all(uploads);
|
|
return {
|
|
rejectedCount,
|
|
unsupportedCount,
|
|
} satisfies ChatDocumentAddFilesResult;
|
|
}
|
|
|
|
async function remove(item: ChatDocumentAttachment) {
|
|
const index = items.value.findIndex(
|
|
(candidate) =>
|
|
candidate.localId === item.localId ||
|
|
(candidate.uploadId && candidate.uploadId === item.uploadId),
|
|
);
|
|
if (index === -1) return;
|
|
const selected = items.value[index];
|
|
if (selected?.uploadId) {
|
|
const response = await api.delete(selected.uploadId);
|
|
if (response.errorCode !== 0) {
|
|
throw new Error(response.message || '文档删除失败');
|
|
}
|
|
}
|
|
items.value.splice(index, 1);
|
|
}
|
|
|
|
async function retry(
|
|
item: ChatDocumentAttachment,
|
|
context: ChatDocumentUploadContext,
|
|
) {
|
|
const found = items.value.find(
|
|
(candidate) =>
|
|
candidate.localId === item.localId ||
|
|
(candidate.uploadId && candidate.uploadId === item.uploadId),
|
|
);
|
|
if (!found) return;
|
|
found.error = undefined;
|
|
if (!found.attachmentRef && found.file) {
|
|
await upload(found, context);
|
|
return;
|
|
}
|
|
if (!found.uploadId && found.file) {
|
|
await upload(found, context);
|
|
return;
|
|
}
|
|
if (!found.uploadId) return;
|
|
try {
|
|
const statusResponse = await api.status(found.uploadId);
|
|
if (statusResponse.errorCode !== 0 || !statusResponse.data) {
|
|
throw new Error(statusResponse.message || '文档状态查询失败');
|
|
}
|
|
const status = applyServerView(found, statusResponse.data);
|
|
if (status === 'ready') return;
|
|
if (status === 'error') {
|
|
const retryResponse = await api.retry(found.uploadId);
|
|
if (retryResponse.errorCode !== 0 || !retryResponse.data) {
|
|
throw new Error(retryResponse.message || '文档读取重试失败');
|
|
}
|
|
applyServerView(found, retryResponse.data);
|
|
}
|
|
if ((found.status as ChatDocumentAttachment['status']) !== 'ready') {
|
|
await poll(found);
|
|
}
|
|
} catch (error) {
|
|
found.status = 'error';
|
|
found.error = errorMessage(error);
|
|
}
|
|
}
|
|
|
|
function restore(restored: ChatDocumentUploadView[] = []) {
|
|
clear();
|
|
const currentLifecycle = lifecycle;
|
|
items.value = restored.slice(0, MAX_DOCUMENTS).map((item) => ({
|
|
...item,
|
|
status: displayStatus(item.status),
|
|
}));
|
|
for (const item of items.value) {
|
|
if (item.status === 'reading' || item.status === 'uploading') {
|
|
void poll(item, currentLifecycle);
|
|
}
|
|
}
|
|
}
|
|
|
|
function clear() {
|
|
lifecycle++;
|
|
items.value = [];
|
|
}
|
|
|
|
return {
|
|
addFiles,
|
|
clear,
|
|
items,
|
|
processing,
|
|
readyItems,
|
|
remove,
|
|
restore,
|
|
retry,
|
|
uploadIds,
|
|
};
|
|
}
|