feat: 支持智能体文档附件与轻量读取

- 建立文档上传、异步读取、对象存储、补偿与聊天绑定闭环

- 按智能体 20K 上下文预算选择文档片段并保留稳定引用

- 统一聊天文件卡片、类型图标、草稿恢复与可靠下载
This commit is contained in:
2026-07-29 01:32:19 +08:00
parent fceedd02cd
commit d45c67a317
72 changed files with 5876 additions and 237 deletions

View File

@@ -0,0 +1,461 @@
<script setup lang="ts">
import type { ChatDocumentAttachment, ChatDocumentLoader } from './types';
import { computed, ref } from 'vue';
type DocumentVisualType =
| 'generic'
| 'markdown'
| 'pdf'
| 'presentation'
| 'spreadsheet'
| 'text'
| 'word';
const props = withDefaults(
defineProps<{
compact?: boolean;
documentLoader?: ChatDocumentLoader;
items: ChatDocumentAttachment[];
removable?: boolean;
retryable?: boolean;
}>(),
{
compact: false,
documentLoader: undefined,
removable: false,
retryable: false,
},
);
const emit = defineEmits<{
remove: [item: ChatDocumentAttachment];
retry: [item: ChatDocumentAttachment];
}>();
const DOCUMENT_TYPES_BY_EXTENSION: Record<string, DocumentVisualType> = {
doc: 'word',
docx: 'word',
md: 'markdown',
pdf: 'pdf',
ppt: 'presentation',
pptx: 'presentation',
txt: 'text',
xls: 'spreadsheet',
xlsx: 'spreadsheet',
};
const DOCUMENT_TYPE_LABELS: Record<DocumentVisualType, string> = {
generic: '文档',
markdown: 'Markdown',
pdf: 'PDF',
presentation: 'PowerPoint',
spreadsheet: 'Excel',
text: 'TXT',
word: 'Word',
};
const downloadingKey = ref('');
const downloadErrorKey = ref('');
const downloadErrorMessage = ref('');
const visibleItems = computed(() => props.items.filter(Boolean));
function itemKey(item: ChatDocumentAttachment) {
return (
item.uploadId ||
item.attachmentRef ||
item.localId ||
`${item.name}-${item.size || 0}`
);
}
function documentType(item: ChatDocumentAttachment): DocumentVisualType {
const extension = item.name.trim().toLowerCase().split('.').pop() || '';
const extensionType = DOCUMENT_TYPES_BY_EXTENSION[extension];
if (extensionType) return extensionType;
const mimeType = String(item.mimeType || '').toLowerCase();
if (mimeType.includes('pdf')) return 'pdf';
if (mimeType.includes('word')) return 'word';
if (mimeType.includes('sheet') || mimeType.includes('excel')) {
return 'spreadsheet';
}
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) {
return 'presentation';
}
if (mimeType.includes('markdown')) return 'markdown';
if (mimeType.startsWith('text/')) return 'text';
return 'generic';
}
function formatSize(value?: number) {
const bytes = Number(value || 0);
if (bytes <= 0) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function stateText(item: ChatDocumentAttachment) {
if (item.status === 'uploading') return '上传中';
if (item.status === 'reading') return '读取中';
if (item.status === 'error') return item.error || '读取失败';
return [DOCUMENT_TYPE_LABELS[documentType(item)], formatSize(item.size)]
.filter(Boolean)
.join(' · ');
}
function displayedStateText(item: ChatDocumentAttachment) {
const key = itemKey(item);
if (downloadingKey.value === key) return '下载中';
if (downloadErrorKey.value === key) return downloadErrorMessage.value;
return stateText(item);
}
function errorMessage(error: unknown) {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return '下载失败,请重试';
}
async function download(item: ChatDocumentAttachment) {
if (
item.status !== 'ready' ||
!item.downloadUrl ||
!props.documentLoader ||
downloadingKey.value
) {
return;
}
const key = itemKey(item);
downloadingKey.value = key;
downloadErrorKey.value = '';
downloadErrorMessage.value = '';
try {
await props.documentLoader(item);
} catch (error) {
downloadErrorKey.value = key;
downloadErrorMessage.value = errorMessage(error);
} finally {
downloadingKey.value = '';
}
}
</script>
<template>
<div
v-if="visibleItems.length > 0"
class="chat-document-attachments"
:class="{ 'is-compact': compact }"
>
<div
v-for="item in visibleItems"
:key="itemKey(item)"
class="chat-document-attachments__item"
:class="[
`is-${item.status || 'ready'}`,
{ 'has-download-error': downloadErrorKey === itemKey(item) },
]"
:data-document-type="documentType(item)"
>
<button
type="button"
class="chat-document-attachments__main"
:disabled="
item.status !== 'ready' ||
!item.downloadUrl ||
!documentLoader ||
Boolean(downloadingKey)
"
:title="item.status === 'ready' ? `下载 ${item.name}` : stateText(item)"
@click="download(item)"
>
<span
class="chat-document-attachments__icon-box"
:class="`is-${documentType(item)}`"
aria-hidden="true"
>
<span
v-if="
item.status === 'uploading' ||
item.status === 'reading' ||
downloadingKey === itemKey(item)
"
class="chat-document-attachments__spinner"
></span>
<svg
v-else
class="chat-document-attachments__icon"
viewBox="0 0 24 24"
>
<g v-if="documentType(item) === 'pdf'">
<path d="M6.5 3.5h7l4 4v13h-11v-17Zm7 0v4h4" />
<path d="m9 17 3-7 3 7M10.1 14.5h3.8" />
</g>
<g v-else-if="documentType(item) === 'word'">
<rect x="4.5" y="4.5" width="15" height="15" rx="2.5" />
<path d="m7.5 8 2 8 2.5-5 2.5 5 2-8" />
</g>
<g v-else-if="documentType(item) === 'spreadsheet'">
<rect x="4.5" y="4.5" width="15" height="15" rx="2.5" />
<path d="M4.5 9.5h15M10 4.5v15M10 14.5h9.5" />
</g>
<g v-else-if="documentType(item) === 'presentation'">
<rect x="4" y="4.5" width="16" height="13" rx="2.5" />
<path d="M8 20h8M12 17.5V20M8 13v-3M12 13V8M16 13v-1.5" />
</g>
<g v-else-if="documentType(item) === 'markdown'">
<path d="M5 16V8l3.5 3.5L12 8v8M17 8v8M14.5 13.5 17 16l2.5-2.5" />
</g>
<g v-else-if="documentType(item) === 'text'">
<path d="M6 6.5h12M6 10.5h12M6 14.5h9M6 18.5h6" />
</g>
<g v-else>
<path d="M6.5 3.5h7l4 4v13h-11v-17Zm7 0v4h4" />
<path d="M9.5 12h5M9.5 15.5h5" />
</g>
</svg>
</span>
<span class="chat-document-attachments__meta">
<span class="chat-document-attachments__name" :title="item.name">
{{ item.name }}
</span>
<span class="chat-document-attachments__state">
{{ displayedStateText(item) }}
</span>
</span>
</button>
<div
v-if="(retryable && item.status === 'error') || removable"
class="chat-document-attachments__actions"
>
<button
v-if="retryable && item.status === 'error'"
type="button"
class="chat-document-attachments__action"
aria-label="重新读取文档"
title="重新读取"
@click.stop="emit('retry', item)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 11a8 8 0 1 0-2.35 5.65M20 5v6h-6" />
</svg>
</button>
<button
v-if="removable"
type="button"
class="chat-document-attachments__action"
aria-label="移除文档"
title="移除文档"
@click.stop="emit('remove', item)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m7 7 10 10M17 7 7 17" />
</svg>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.chat-document-attachments {
display: flex;
flex-wrap: wrap;
gap: var(--space-2, 8px);
align-items: stretch;
width: 100%;
}
.chat-document-attachments__item {
display: flex;
flex: 0 0 280px;
width: 280px;
min-width: 0;
max-width: 100%;
height: 56px;
overflow: hidden;
background: hsl(var(--surface-elevated));
border: 0;
border-radius: var(--radius-toolbar, 12px);
box-shadow: inset 0 0 0 1px hsl(var(--line-subtle));
transition:
background-color var(--motion-duration-fast, 120ms)
var(--motion-ease-standard, ease),
box-shadow var(--motion-duration-fast, 120ms)
var(--motion-ease-standard, ease);
}
.chat-document-attachments__item.is-ready:hover {
background: hsl(var(--surface-subtle));
box-shadow: inset 0 0 0 1px hsl(var(--border));
}
.chat-document-attachments__item.is-error,
.chat-document-attachments__item.has-download-error {
box-shadow: inset 0 0 0 1px hsl(var(--destructive) / 46%);
}
.chat-document-attachments__main {
display: flex;
flex: 1;
gap: var(--space-2, 8px);
align-items: center;
min-width: 0;
padding: var(--space-2, 8px);
color: inherit;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
}
.chat-document-attachments__main:disabled {
cursor: default;
}
.chat-document-attachments__main:active:not(:disabled) {
background: hsl(var(--surface-contrast-soft));
}
.chat-document-attachments__main:focus-visible,
.chat-document-attachments__action:focus-visible {
outline: 2px solid hsl(var(--primary));
outline-offset: -2px;
}
.chat-document-attachments__icon-box {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: hsl(var(--document-icon-foreground));
background: hsl(var(--document-icon-generic));
border-radius: var(--radius-control, 10px);
}
.chat-document-attachments__icon-box.is-pdf {
background: hsl(var(--document-icon-pdf));
}
.chat-document-attachments__icon-box.is-word {
background: hsl(var(--document-icon-word));
}
.chat-document-attachments__icon-box.is-spreadsheet {
background: hsl(var(--document-icon-spreadsheet));
}
.chat-document-attachments__icon-box.is-presentation {
background: hsl(var(--document-icon-presentation));
}
.chat-document-attachments__icon-box.is-text {
background: hsl(var(--document-icon-text));
}
.chat-document-attachments__icon-box.is-markdown {
background: hsl(var(--document-icon-markdown));
}
.chat-document-attachments__icon {
width: 22px;
height: 22px;
fill: none;
stroke: currentcolor;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-document-attachments__spinner {
width: 18px;
height: 18px;
border: 2px solid hsl(var(--document-icon-foreground) / 38%);
border-top-color: hsl(var(--document-icon-foreground));
border-radius: 50%;
animation: chat-document-spin 0.9s linear infinite;
}
.chat-document-attachments__meta {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.chat-document-attachments__name {
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-family);
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: hsl(var(--text-strong));
white-space: nowrap;
}
.chat-document-attachments__state {
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-family);
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: hsl(var(--text-muted));
white-space: nowrap;
}
.is-error .chat-document-attachments__state,
.has-download-error .chat-document-attachments__state {
color: hsl(var(--destructive));
}
.chat-document-attachments__actions {
display: flex;
flex: 0 0 auto;
gap: var(--space-1, 4px);
align-items: center;
padding-right: var(--space-2, 8px);
}
.chat-document-attachments__action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
color: hsl(var(--text-muted));
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--radius-control, 10px);
}
.chat-document-attachments__action svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentcolor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-document-attachments__action:hover {
color: hsl(var(--text-strong));
background: hsl(var(--surface-contrast-soft));
}
@keyframes chat-document-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import type {
ChatTimelineItem as ChatTimelineItemType,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineItem as ChatTimelineItemType,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
} from './types';
@@ -14,6 +15,7 @@ const props = defineProps<{
approvalLoading?: boolean;
copyable?: (item: ChatTimelineMessageItem) => boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
documentLoader?: ChatDocumentLoader;
emptyText?: string;
imageLoader?: ChatImageLoader;
items: ChatTimelineItemType[];
@@ -152,6 +154,7 @@ watch(
:key="item.id"
:assistant-actions-visible="isAssistantActionAnchor(item)"
:item="item"
:document-loader="documentLoader"
:image-loader="imageLoader"
:approval-loading="approvalLoading"
:copy-action="copyAction"

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import type {
ChatTimelineItem,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineItem,
ChatTimelineMessageItem,
ChatTimelineMessagePart,
ChatTimelineToolApprovalPayload,
@@ -10,9 +11,10 @@ import type {
import { computed, ref } from 'vue';
import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue';
import ChatDocumentAttachments from './ChatDocumentAttachments.vue';
import ChatErrorNotice from './ChatErrorNotice.vue';
import ChatKnowledgeCard from './ChatKnowledgeCard.vue';
import ChatImageAttachments from './ChatImageAttachments.vue';
import ChatKnowledgeCard from './ChatKnowledgeCard.vue';
import ChatMessageToolbar from './ChatMessageToolbar.vue';
import ChatTextBlock from './ChatTextBlock.vue';
import ChatTimelineStatusRow from './ChatTimelineStatusRow.vue';
@@ -20,13 +22,14 @@ import ChatToolCard from './ChatToolCard.vue';
const props = defineProps<{
approvalLoading?: boolean;
assistantActionsVisible?: boolean;
copyable?: boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
regenerable?: boolean;
regenerateDisabled?: boolean;
assistantActionsVisible?: boolean;
documentLoader?: ChatDocumentLoader;
imageLoader?: ChatImageLoader;
item: ChatTimelineItem;
regenerable?: boolean;
regenerateDisabled?: boolean;
variantLoading?: boolean;
}>();
@@ -161,6 +164,12 @@ function handleCopyAction() {
:image-loader="imageLoader"
compact
/>
<ChatDocumentAttachments
v-if="messageItem.documents?.length"
:items="messageItem.documents"
:document-loader="documentLoader"
compact
/>
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
<ChatThinkingBlock
v-if="part.type === 'thinking'"
@@ -237,16 +246,16 @@ function handleCopyAction() {
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(78%, 680px);
min-width: 0;
max-width: min(78%, 680px);
}
.chat-timeline-item__message {
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(78%, 680px);
min-width: 0;
max-width: min(78%, 680px);
}
.chat-timeline-item__message :deep(.chat-text-block),

View File

@@ -0,0 +1,127 @@
import type { ChatDocumentAttachment } from '../types';
import { flushPromises, mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import ChatDocumentAttachments from '../ChatDocumentAttachments.vue';
const document: ChatDocumentAttachment = {
attachmentRef: 'attachment-1',
downloadUrl: '/api/v1/agent/media/document/content?reference=attachment-1',
name: '需求说明.docx',
size: 2048,
status: 'ready',
};
describe('chat document attachments', () => {
it('downloads a ready document through the authenticated loader', async () => {
const loader = vi.fn().mockResolvedValue(undefined);
const wrapper = mount(ChatDocumentAttachments, {
props: {
documentLoader: loader,
items: [document],
},
});
await wrapper.get('.chat-document-attachments__main').trigger('click');
expect(loader).toHaveBeenCalledWith(document);
expect(wrapper.text()).toContain('需求说明.docx');
expect(wrapper.text()).toContain('2 KB');
});
it('exposes retry and remove actions for a failed document', async () => {
const failed = {
...document,
error: '读取失败',
status: 'error' as const,
};
const wrapper = mount(ChatDocumentAttachments, {
props: {
items: [failed],
removable: true,
retryable: true,
},
});
await wrapper.get('[aria-label="重新读取文档"]').trigger('click');
await wrapper.get('[aria-label="移除文档"]').trigger('click');
expect(wrapper.emitted('retry')?.[0]).toEqual([failed]);
expect(wrapper.emitted('remove')?.[0]).toEqual([failed]);
expect(wrapper.text()).toContain('读取失败');
});
it('uses compact type-specific icons for supported document formats', () => {
const items: ChatDocumentAttachment[] = [
{ ...document, name: 'paper.pdf', mimeType: 'application/pdf' },
{ ...document, name: 'report.docx' },
{ ...document, name: 'table.xlsx' },
{ ...document, name: 'slides.pptx' },
{ ...document, name: 'notes.txt' },
{ ...document, name: 'readme.md' },
{ ...document, name: 'archive.bin' },
];
const wrapper = mount(ChatDocumentAttachments, {
props: { items },
});
const types = wrapper
.findAll('.chat-document-attachments__item')
.map((item) => item.attributes('data-document-type'));
expect(types).toEqual([
'pdf',
'word',
'spreadsheet',
'presentation',
'text',
'markdown',
'generic',
]);
expect(
wrapper
.find(
'[data-document-type="text"] .chat-document-attachments__icon-box',
)
.classes(),
).toContain('is-text');
expect(wrapper.text()).toContain('PDF · 2 KB');
expect(wrapper.text()).toContain('TXT · 2 KB');
});
it('preserves a long filename for the ellipsis tooltip', () => {
const longName =
'这是一个用于验证固定宽度附件卡片标题省略行为的超长产品需求说明文档.docx';
const wrapper = mount(ChatDocumentAttachments, {
props: {
items: [{ ...document, name: longName }],
},
});
const filename = wrapper.get('.chat-document-attachments__name');
expect(filename.attributes('title')).toBe(longName);
expect(filename.text()).toBe(longName);
});
it('shows a recoverable download error without triggering another file', async () => {
const loader = vi
.fn()
.mockRejectedValue(new Error('下载内容为空,请稍后重试'));
const wrapper = mount(ChatDocumentAttachments, {
props: {
documentLoader: loader,
items: [document],
},
});
await wrapper.get('.chat-document-attachments__main').trigger('click');
await flushPromises();
expect(wrapper.text()).toContain('下载内容为空,请稍后重试');
expect(wrapper.get('.chat-document-attachments__item').classes()).toContain(
'has-download-error',
);
});
});

View File

@@ -196,7 +196,7 @@ function removeStatusItem(items: ChatTimelineItem[], statusKey: string) {
const index = items.findIndex(
(item) => item.type === 'status' && item.statusKey === statusKey,
);
if (index >= 0) {
if (index !== -1) {
items.splice(index, 1);
}
}
@@ -341,7 +341,7 @@ export const ChatTimelineBuilder = {
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = normalizeText(content);
if (!text && !metadata?.images?.length) {
if (!text && !metadata?.images?.length && !metadata?.documents?.length) {
return;
}
const item: ChatTimelineMessageItem = {
@@ -605,7 +605,7 @@ export const ChatTimelineBuilder = {
item.role === 'assistant' &&
item.roundId === roundId,
);
if (targetIndex >= 0) {
if (targetIndex !== -1) {
items.splice(targetIndex, 1, message);
}
},

View File

@@ -1,7 +1,8 @@
export { ChatTimelineBuilder } from './builder';
export { default as ChatDocumentAttachments } from './ChatDocumentAttachments.vue';
export { default as ChatErrorNotice } from './ChatErrorNotice.vue';
export { default as ChatKnowledgeCard } from './ChatKnowledgeCard.vue';
export { default as ChatImageAttachments } from './ChatImageAttachments.vue';
export { default as ChatKnowledgeCard } from './ChatKnowledgeCard.vue';
export { default as ChatMessageToolbar } from './ChatMessageToolbar.vue';
export { default as ChatTextBlock } from './ChatTextBlock.vue';
export { default as ChatTimeline } from './ChatTimeline.vue';
@@ -11,9 +12,11 @@ export { default as ChatToolApprovalCard } from './ChatToolApprovalCard.vue';
export { default as ChatToolCard } from './ChatToolCard.vue';
export { default as ChatVariantNavigator } from './ChatVariantNavigator.vue';
export type {
ChatTimelineErrorItem,
ChatDocumentAttachment,
ChatDocumentLoader,
ChatImageAttachment,
ChatImageLoader,
ChatTimelineErrorItem,
ChatTimelineItem,
ChatTimelineItemStatus,
ChatTimelineKnowledgeHit,
@@ -21,12 +24,18 @@ export type {
ChatTimelineMessageItem,
ChatTimelineMessagePart,
ChatTimelineRole,
ChatTimelineThinkingStatus,
ChatTimelineStatusItem,
ChatTimelineStatusStatus,
ChatTimelineStatusTone,
ChatTimelineThinkingStatus,
ChatTimelineToolApprovalItem,
ChatTimelineToolApprovalPayload,
ChatTimelineToolItem,
ChatTimelineToolStatus,
} from './types';
export {
type ChatDocumentUploadApi,
type ChatDocumentUploadContext,
type ChatDocumentUploadView,
createChatDocumentUploads,
} from './useChatDocumentUploads';

View File

@@ -28,6 +28,24 @@ export interface ChatImageAttachment {
export type ChatImageLoader = (previewUrl: string) => Promise<string>;
export interface ChatDocumentAttachment {
attachmentRef?: string;
downloadUrl?: string;
error?: string;
errorCode?: string;
localId?: string;
mimeType?: string;
name: string;
readSnapshotId?: string;
size?: number;
status?: 'error' | 'reading' | 'ready' | 'uploading';
uploadId?: string;
}
export type ChatDocumentLoader = (
document: ChatDocumentAttachment,
) => Promise<void>;
export interface ChatTimelineToolApprovalPayload {
requestId: string;
resumeToken: string;
@@ -66,6 +84,7 @@ export interface ChatTimelineItemBase {
}
export interface ChatTimelineMessageItem extends ChatTimelineItemBase {
documents?: ChatDocumentAttachment[];
images?: ChatImageAttachment[];
knowledgeItems?: ChatTimelineKnowledgeHit[];
parts: ChatTimelineMessagePart[];
@@ -131,15 +150,15 @@ export type ChatTimelineItem =
| ChatTimelineToolItem;
export type ChatTimelineMessagePart =
| {
content: string;
id: string;
type: 'text';
}
| {
content: string;
expanded?: boolean;
id: string;
status: ChatTimelineThinkingStatus;
type: 'thinking';
}
| {
content: string;
id: string;
type: 'text';
};

View File

@@ -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,
};
}