Files
EasyFlow/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatDocumentAttachments.vue
陈子默 d45c67a317 feat: 支持智能体文档附件与轻量读取
- 建立文档上传、异步读取、对象存储、补偿与聊天绑定闭环

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

- 统一聊天文件卡片、类型图标、草稿恢复与可靠下载
2026-07-29 01:32:19 +08:00

462 lines
12 KiB
Vue

<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>