462 lines
12 KiB
Vue
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>
|