feat: 支持智能体文档附件与轻量读取
- 建立文档上传、异步读取、对象存储、补偿与聊天绑定闭环 - 按智能体 20K 上下文预算选择文档片段并保留稳定引用 - 统一聊天文件卡片、类型图标、草稿恢复与可靠下载
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
import type { ChatDocumentAttachment } from './types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
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;
|
||||
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>>;
|
||||
}
|
||||
|
||||
interface LocalDocument extends ChatDocumentAttachment {
|
||||
file?: File;
|
||||
}
|
||||
|
||||
function createLocalId() {
|
||||
return `agent-document-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function createUploadId() {
|
||||
return crypto.randomUUID().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.uploadId ||= createUploadId();
|
||||
item.status = 'uploading';
|
||||
item.error = undefined;
|
||||
const currentLifecycle = lifecycle;
|
||||
try {
|
||||
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 available = Math.max(0, MAX_DOCUMENTS - items.value.length);
|
||||
const accepted = files.slice(0, available);
|
||||
const rejectedCount = Math.max(0, files.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 (!SUPPORTED_EXTENSIONS.has(extension)) {
|
||||
trackedItem.status = 'error';
|
||||
trackedItem.error = '仅支持 PDF、Word、PPT、Excel、TXT、Markdown';
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user