feat(chat): glass-themed preview for uploaded & AI-generated docx/xlsx/pdf (#513)

This commit is contained in:
matevip 2026-07-13 18:00:54 +08:00
parent a466f609cf
commit c9cc5b4f6f
22 changed files with 2315 additions and 20 deletions

View File

@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@ -65,6 +66,7 @@ public class ChatController {
private final ConversationCompletionPublisher completionPublisher;
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver;
private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService;
// 使用虚拟线程池处理 SSEJava 17+ 兼容Java 21 可用 Executors.newVirtualThreadPerTaskExecutor()
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
@ -1142,21 +1144,7 @@ public class ChatController {
return ResponseEntity.status(403).build();
}
// Check every candidate root (workspace-scoped dir + legacy default dir)
// so attachments written before the workspace-aware relocation, and the
// current workspace-scoped ones, are both servable. Each candidate keeps
// its own startsWith traversal guard.
Path filePath = null;
// Sanitized-then-raw candidate dirs so both new writes (sanitized) and
// legacy Linux uploads (raw ':' dir) resolve.
for (Path conversationDir : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
Path normDir = conversationDir.normalize();
Path candidate = normDir.resolve(storedName).normalize();
if (Files.exists(candidate) && candidate.startsWith(normDir)) {
filePath = candidate;
break;
}
}
Path filePath = resolveUploadedFile(conversationId, storedName);
if (filePath == null) {
return ResponseEntity.notFound().build();
}
@ -1183,6 +1171,62 @@ public class ChatController {
.body(resource);
}
@Operation(summary = "生成聊天附件的 PDF 预览office 格式soffice 转换)")
@GetMapping("/files/{conversationId}/{storedName:.+}/preview")
public ResponseEntity<byte[]> previewUploadedFile(
@PathVariable String conversationId,
@PathVariable String storedName,
Authentication auth) {
// Same ownership gate as the raw file endpoint.
String username = auth != null ? auth.getName() : "anonymous";
if (!conversationService.isConversationOwner(conversationId, username)) {
return ResponseEntity.status(403).build();
}
// 415: the client asked to preview a format this endpoint won't convert.
if (!officePreviewService.isConvertible(storedName)) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build();
}
// 501: no soffice on this host the UI degrades to a download link.
if (!officePreviewService.isAvailable()) {
return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build();
}
Path filePath = resolveUploadedFile(conversationId, storedName);
if (filePath == null) {
return ResponseEntity.notFound().build();
}
try {
byte[] pdf = officePreviewService.renderPdf(filePath);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline")
.body(pdf);
} catch (IOException e) {
log.warn("[ChatController] office preview conversion failed for {}: {}", storedName, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Resolve an uploaded attachment to its on-disk path, probing every
* candidate conversation dir (workspace-scoped + legacy default, sanitized +
* raw id) with a per-candidate path-traversal guard. Returns {@code null}
* when no candidate holds the file.
*/
private Path resolveUploadedFile(String conversationId, String storedName) {
for (Path conversationDir : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
Path normDir = conversationDir.normalize();
Path candidate = normDir.resolve(storedName).normalize();
if (Files.exists(candidate) && candidate.startsWith(normDir)) {
return candidate;
}
}
return null;
}
/**
* Build the {@link vip.mate.agent.context.ChatOrigin} that drives per-owner
* memory isolation for a web request. When {@code endUserId} is supplied

View File

@ -0,0 +1,194 @@
package vip.mate.tool.document.preview;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.tool.document.pdf.PdfProperties;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Converts office documents (pptx / ppt / doc / xls / odt / ods / odp / rtf / )
* to PDF for in-browser preview, using a {@code soffice --convert-to pdf}
* subprocess. Formats that the frontend can render directly (pdf / docx / xlsx /
* html / text) never reach this service it is the fallback path for the ones
* no client-side library covers.
*
* <p>Reuses the LibreOffice availability contract from {@link PdfProperties}:
* when {@code soffice} is absent or disabled, {@link #isAvailable()} returns
* false and the controller answers {@code 501}, letting the UI degrade to a
* download link. No LibreOffice install is required for the rest of preview to
* work.
*
* <p>Converted PDFs are cached next to the source under a hidden
* {@code .preview/} directory, keyed by the source file's last-modified time,
* so repeated opens of the same attachment convert only once. The cache lives
* inside the conversation directory and is removed wholesale when the
* conversation's attachments are cleaned up.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OfficePreviewService {
/** Hidden sub-directory (per conversation dir) holding converted preview PDFs. */
public static final String PREVIEW_DIR = ".preview";
private static final long CONVERT_TIMEOUT_SECONDS = 90;
/**
* Extensions this service will convert. Kept in sync with the frontend
* {@code OFFICE_CONVERT_EXTS} set in {@code previewKind.ts}. Formats the
* browser renders natively (pdf/docx/xlsx/csv/html/text) are intentionally
* excluded they never hit this endpoint.
*/
private static final Set<String> CONVERTIBLE_EXTS = Set.of(
"ppt", "pptx", "doc", "xls", "odt", "ods", "odp", "rtf", "wps");
private final PdfProperties properties;
/** Whether a usable {@code soffice} binary is present (probed each call, cheap). */
public boolean isAvailable() {
if (!properties.libreoffice().enabled()) return false;
try {
ProcessBuilder pb = new ProcessBuilder(properties.libreoffice().binary(), "--version");
pb.redirectErrorStream(true);
Process p = pb.start();
p.getInputStream().readAllBytes();
boolean finished = p.waitFor(5, TimeUnit.SECONDS);
if (!finished) {
p.destroyForcibly();
return false;
}
return p.exitValue() == 0;
} catch (Exception e) {
log.debug("[OfficePreview] soffice probe failed: {}", e.getMessage());
return false;
}
}
/** Whether {@code filename}'s extension is one this service can convert. */
public boolean isConvertible(String filename) {
return CONVERTIBLE_EXTS.contains(extensionOf(filename));
}
/**
* Return the preview PDF bytes for {@code source}, converting via soffice on
* a cache miss. Callers must have already verified {@link #isConvertible}
* and {@link #isAvailable}.
*
* @param source an existing, readable office document
* @return converted PDF bytes
* @throws IOException conversion failed or produced no output
*/
public byte[] renderPdf(Path source) throws IOException {
Path cached = cachePathFor(source);
if (isCacheFresh(cached, source)) {
return Files.readAllBytes(cached);
}
byte[] pdf = convert(source);
writeCache(cached, pdf);
return pdf;
}
// ==================== internals ====================
private Path cachePathFor(Path source) {
Path dir = source.getParent().resolve(PREVIEW_DIR);
return dir.resolve(source.getFileName().toString() + ".pdf");
}
private boolean isCacheFresh(Path cached, Path source) {
try {
if (!Files.isRegularFile(cached)) return false;
// Fresh only when the cached PDF is at least as new as the source,
// so a re-uploaded/overwritten source invalidates the stale preview.
return Files.getLastModifiedTime(cached).toMillis()
>= Files.getLastModifiedTime(source).toMillis();
} catch (IOException e) {
return false;
}
}
private void writeCache(Path cached, byte[] pdf) {
try {
Files.createDirectories(cached.getParent());
Files.write(cached, pdf);
} catch (IOException e) {
// Non-fatal: a failed cache write just means the next open reconverts.
log.debug("[OfficePreview] failed to cache preview {}: {}", cached, e.getMessage());
}
}
private byte[] convert(Path source) throws IOException {
Path tempDir = Files.createTempDirectory("mc_preview_");
try {
ProcessBuilder pb = new ProcessBuilder(
properties.libreoffice().binary(),
"--headless",
"--convert-to", "pdf",
"--outdir", tempDir.toString(),
source.toString());
pb.redirectErrorStream(true);
Process p = pb.start();
byte[] stderr = p.getInputStream().readAllBytes();
boolean finished;
try {
finished = p.waitFor(CONVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
p.destroyForcibly();
Thread.currentThread().interrupt();
throw new IOException("soffice conversion interrupted", e);
}
if (!finished) {
p.destroyForcibly();
throw new IOException("soffice conversion timed out after " + CONVERT_TIMEOUT_SECONDS + "s");
}
if (p.exitValue() != 0) {
throw new IOException("soffice exit " + p.exitValue() + ": " + new String(stderr).strip());
}
// soffice names the output after the input basename, extension swapped to .pdf.
String base = stripExtension(source.getFileName().toString());
Path pdfFile = tempDir.resolve(base + ".pdf");
if (!Files.isRegularFile(pdfFile)) {
throw new IOException("soffice produced no PDF (stderr: " + new String(stderr).strip() + ")");
}
return Files.readAllBytes(pdfFile);
} finally {
cleanup(tempDir);
}
}
private void cleanup(Path tempDir) {
try (var stream = Files.walk(tempDir)) {
List<Path> entries = stream.sorted(Comparator.reverseOrder()).toList();
for (Path entry : entries) {
try {
Files.deleteIfExists(entry);
} catch (IOException ignored) {
// Best-effort; the OS reclaims java.io.tmpdir on reboot.
}
}
} catch (IOException ignored) {
// ditto
}
}
private static String extensionOf(String filename) {
if (filename == null) return "";
int idx = filename.lastIndexOf('.');
return idx >= 0 ? filename.substring(idx + 1).toLowerCase(Locale.ROOT) : "";
}
private static String stripExtension(String filename) {
int idx = filename.lastIndexOf('.');
return idx >= 0 ? filename.substring(0, idx) : filename;
}
}

View File

@ -0,0 +1,130 @@
package vip.mate.channel.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import vip.mate.agent.AgentService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.memory.identity.MemoryOwnerResolver;
import vip.mate.tool.document.preview.OfficePreviewService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Route + gating tests for {@code GET /files/{conversationId}/{storedName}/preview}.
*
* <p>Verifies the preview route is registered as its own handler (not swallowed
* by the raw {@code /files/{conversationId}/{storedName:.+}} mapping) and that
* every gate ownership, convertibility, converter availability, file presence
* maps to the intended status code. Standalone MockMvc so no Spring context
* or running server is needed.
*/
class ChatControllerPreviewRouteTest {
private ConversationService conversationService;
private ChatUploadLocationResolver uploadLocationResolver;
private OfficePreviewService officePreviewService;
private MockMvc mockMvc;
private static final String CONV = "wecom:someone";
private static final String STORED = "1777_deck.pptx";
private static final String URL = "/api/v1/chat/files/" + CONV + "/" + STORED + "/preview";
@BeforeEach
void setUp() {
conversationService = mock(ConversationService.class);
uploadLocationResolver = mock(ChatUploadLocationResolver.class);
officePreviewService = mock(OfficePreviewService.class);
ChatController controller = new ChatController(
mock(AgentService.class),
conversationService,
mock(ApprovalWorkflowService.class),
mock(ChatStreamTracker.class),
new ObjectMapper(),
mock(vip.mate.memory.event.ConversationCompletionPublisher.class),
mock(MemoryOwnerResolver.class),
uploadLocationResolver,
officePreviewService);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
private UsernamePasswordAuthenticationToken admin() {
return new UsernamePasswordAuthenticationToken("admin", "n/a", List.of());
}
@Test
void notOwner_returns403() throws Exception {
when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(false);
mockMvc.perform(MockMvcRequestBuilders.get(URL).principal(admin()))
.andExpect(status().isForbidden());
}
@Test
void nonConvertibleFormat_returns415() throws Exception {
when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true);
when(officePreviewService.isConvertible(STORED)).thenReturn(false);
mockMvc.perform(MockMvcRequestBuilders.get(URL).principal(admin()))
.andExpect(status().isUnsupportedMediaType());
}
@Test
void converterUnavailable_returns501() throws Exception {
when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true);
when(officePreviewService.isConvertible(STORED)).thenReturn(true);
when(officePreviewService.isAvailable()).thenReturn(false);
mockMvc.perform(MockMvcRequestBuilders.get(URL).principal(admin()))
.andExpect(status().isNotImplemented());
}
@Test
void fileMissing_returns404() throws Exception {
when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true);
when(officePreviewService.isConvertible(STORED)).thenReturn(true);
when(officePreviewService.isAvailable()).thenReturn(true);
when(uploadLocationResolver.resolveCandidateConversationDirs(CONV)).thenReturn(List.of());
mockMvc.perform(MockMvcRequestBuilders.get(URL).principal(admin()))
.andExpect(status().isNotFound());
}
@Test
void convertibleAndPresent_returns200Pdf() throws Exception {
Path dir = Files.createTempDirectory("mc_preview_route_");
try {
Path src = dir.resolve(STORED);
Files.write(src, new byte[]{1, 2, 3});
when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true);
when(officePreviewService.isConvertible(STORED)).thenReturn(true);
when(officePreviewService.isAvailable()).thenReturn(true);
when(uploadLocationResolver.resolveCandidateConversationDirs(CONV)).thenReturn(List.of(dir));
byte[] pdf = "%PDF-1.4 fake".getBytes();
when(officePreviewService.renderPdf(any(Path.class))).thenReturn(pdf);
mockMvc.perform(MockMvcRequestBuilders.get(URL).principal(admin()))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF));
} finally {
Files.deleteIfExists(dir.resolve(STORED));
Files.deleteIfExists(dir);
}
}
}

View File

@ -0,0 +1,90 @@
package vip.mate.tool.document.preview;
import org.junit.jupiter.api.Test;
import vip.mate.tool.document.pdf.PdfProperties;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link OfficePreviewService} covering the pure decision logic
* (convertible-extension gate, availability gate, cache freshness) without
* requiring a real {@code soffice} install on the build host.
*/
class OfficePreviewServiceTest {
private OfficePreviewService serviceWith(boolean enabled, String binary) {
PdfProperties props = new PdfProperties(null, null, new PdfProperties.Libreoffice(enabled, binary));
return new OfficePreviewService(props);
}
@Test
void isConvertible_acceptsOfficeExtensions_caseInsensitive() {
OfficePreviewService svc = serviceWith(true, "soffice");
assertThat(svc.isConvertible("deck.pptx")).isTrue();
assertThat(svc.isConvertible("REPORT.PPT")).isTrue();
assertThat(svc.isConvertible("legacy.doc")).isTrue();
assertThat(svc.isConvertible("book.xls")).isTrue();
assertThat(svc.isConvertible("notes.odt")).isTrue();
}
@Test
void isConvertible_rejectsFrontendRenderedAndUnknownFormats() {
OfficePreviewService svc = serviceWith(true, "soffice");
// These are handled client-side and must never hit the converter.
assertThat(svc.isConvertible("a.pdf")).isFalse();
assertThat(svc.isConvertible("a.docx")).isFalse();
assertThat(svc.isConvertible("a.xlsx")).isFalse();
assertThat(svc.isConvertible("a.html")).isFalse();
assertThat(svc.isConvertible("a.png")).isFalse();
assertThat(svc.isConvertible("noext")).isFalse();
}
@Test
void isAvailable_falseWhenDisabled() {
// Disabled short-circuits before any process spawn.
OfficePreviewService svc = serviceWith(false, "soffice");
assertThat(svc.isAvailable()).isFalse();
}
@Test
void isAvailable_falseWhenBinaryMissing() {
// A binary that cannot be exec'd probes to unavailable, not an exception.
OfficePreviewService svc = serviceWith(true, "definitely-not-a-real-soffice-binary-xyz");
assertThat(svc.isAvailable()).isFalse();
}
@Test
void renderPdf_servesFreshCacheWithoutInvokingConverter() throws Exception {
// A cached PDF newer than the source must be returned verbatim, proving
// the cache short-circuits before any soffice call (host has none).
Path dir = Files.createTempDirectory("mc_preview_test_");
try {
Path source = dir.resolve("deck.pptx");
Files.write(source, new byte[]{1, 2, 3});
Path cacheDir = dir.resolve(OfficePreviewService.PREVIEW_DIR);
Files.createDirectories(cacheDir);
Path cached = cacheDir.resolve("deck.pptx.pdf");
byte[] cachedPdf = "%PDF-1.4 cached".getBytes();
Files.write(cached, cachedPdf);
// Ensure the cache is at least as new as the source.
Files.setLastModifiedTime(cached, Files.getLastModifiedTime(source));
OfficePreviewService svc = serviceWith(true, "definitely-not-a-real-soffice-binary-xyz");
assertThat(svc.renderPdf(source)).isEqualTo(cachedPdf);
} finally {
deleteRecursively(dir);
}
}
private static void deleteRecursively(Path dir) throws Exception {
try (var walk = Files.walk(dir)) {
walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
try { Files.deleteIfExists(p); } catch (Exception ignored) { }
});
}
}
}

View File

@ -27,15 +27,18 @@
"cytoscape-cose-bilkent": "^4.1.0",
"dagre": "^0.8.5",
"dayjs": "^1.11.13",
"docx-preview": "^0.4.0",
"dompurify": "^3.3.3",
"echarts": "^6.0.0",
"element-plus": "^2.9.1",
"exceljs": "^4.4.0",
"highlight.js": "^11.11.1",
"katex": "^0.16.45",
"marked": "^15.0.6",
"marked-highlight": "^2.2.3",
"mermaid": "^11.14.0",
"monaco-editor": "^0.55.1",
"pdfjs-dist": "^6.1.200",
"pinia": "^3.0.1",
"pixelarticons": "2.1.0",
"vue": "^3.5.13",

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,10 @@
<!-- Mounted once at the app root so mcConfirm() can pop a dialog
from anywhere without each caller wiring its own host. -->
<McConfirmHost />
<!-- Single global file-preview dialog. Attachment cards and generated-file
links open it via the previewBus window event, so both user-uploaded
and AI-generated docx/xlsx/pdf preview in-place. -->
<FilePreviewDialog global />
</el-config-provider>
</template>
@ -18,6 +22,7 @@ import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick'
import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick'
import McConfirmHost from '@/components/common/McConfirmHost.vue'
import FilePreviewDialog from '@/components/chat/preview/FilePreviewDialog.vue'
// Initialize theme applies .dark class to <html> immediately
useThemeStore()

View File

@ -365,11 +365,16 @@
:key="attachment.storedName"
class="message-attachment"
type="button"
@click="downloadFile(attachment)"
@click="openFileAttachment(attachment)"
>
<el-icon class="message-attachment__icon"><Document /></el-icon>
<span class="message-attachment__name">{{ attachment.name }}</span>
<span class="message-attachment__meta">{{ formatFileSize(attachment.size) }}</span>
<el-icon
class="message-attachment__download"
:title="$t('chat.preview.download')"
@click.stop="downloadFile(attachment)"
><Download /></el-icon>
</button>
</div>
</div>
@ -539,6 +544,7 @@ import {
CloseBold,
CopyDocument,
Document,
Download,
InfoFilled,
Loading,
Microphone,
@ -557,6 +563,8 @@ import { useToolLabel } from '@/composables/useToolLabel'
import { http } from '@/api'
import { copyToClipboard } from '@/utils/clipboard'
import TypingCursor from './TypingCursor.vue'
import { previewKindOf } from './preview/previewKind'
import { openFilePreview } from './preview/previewBus'
import BrowserTimeline from './BrowserTimeline.vue'
import ToolCallSegment from './ToolCallSegment.vue'
import ThinkingSegment from './ThinkingSegment.vue'
@ -575,6 +583,16 @@ const { t, locale } = useI18n()
const { getToolLabel } = useToolLabel()
const { blobUrls, loadAllImages, loadAllVideos, loadAllAudios, loadAllModels, downloadFile, openImage, getDisplayUrl, revokeAll } = useAuthenticatedAttachment()
// Document attachments: preview in the global dialog when the format is
// supported, otherwise fall back to the legacy download behavior.
function openFileAttachment(attachment: ChatAttachment) {
if (previewKindOf(attachment)) {
openFilePreview(attachment)
} else {
void downloadFile(attachment)
}
}
interface Props {
message: Message
isLast?: boolean
@ -2451,6 +2469,16 @@ watch(isGenerating, (generating) => {
opacity: 0.76;
}
.message-attachment__download {
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.15s;
}
.message-attachment__download:hover {
opacity: 1;
}
/* ==================== Markdown 样式 ==================== */
.markdown-body :deep(p) {
margin: 0 0 10px;

View File

@ -0,0 +1,69 @@
<template>
<div class="docx-preview-wrap">
<div v-if="error" class="docx-preview-wrap__error">{{ $t('chat.preview.failed') }}</div>
<PreviewSpinner v-else-if="loading" :label="$t('chat.preview.loading')" />
<div v-show="!loading && !error" ref="containerEl" class="docx-preview-wrap__body" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import PreviewSpinner from './PreviewSpinner.vue'
const props = defineProps<{
/** Raw .docx bytes (already fetched with auth). */
data: ArrayBuffer
}>()
const loading = ref(true)
const error = ref(false)
const containerEl = ref<HTMLElement | null>(null)
onMounted(async () => {
try {
const { renderAsync } = await import('docx-preview')
if (!containerEl.value) return
await renderAsync(props.data, containerEl.value, undefined, {
inWrapper: true,
ignoreLastRenderedPageBreak: true,
// Embedded fonts come from the document author; keep them but never
// execute anything active.
experimental: false,
})
loading.value = false
} catch (e) {
console.error('[DocxPreview] render failed:', e)
error.value = true
loading.value = false
}
})
</script>
<style scoped>
.docx-preview-wrap {
height: 100%;
overflow-y: auto;
background: var(--mc-bg-sunken, #ebe3db);
}
.docx-preview-wrap__body {
padding: 20px 12px;
}
/* docx-preview renders fixed-width "pages"; keep them responsive and floating
on the warm desk like sheets of paper. */
.docx-preview-wrap__body :deep(.docx-wrapper) {
background: transparent;
padding: 0;
}
.docx-preview-wrap__body :deep(.docx-wrapper > section.docx) {
max-width: 100%;
background: #fff;
box-shadow: var(--mc-shadow-soft, 0 10px 30px rgba(58, 32, 19, 0.08));
border-radius: var(--mc-radius-sm, 6px);
margin: 0 auto 16px;
}
.docx-preview-wrap__error {
padding: 48px;
text-align: center;
color: var(--mc-text-secondary, #665245);
}
</style>

View File

@ -0,0 +1,301 @@
<template>
<el-dialog
v-model="visible"
:title="attachment?.name || ''"
class="file-preview-dialog"
:class="{ 'file-preview-dialog--fullscreen': fullscreen }"
:fullscreen="fullscreen"
width="min(960px, 94vw)"
top="4vh"
destroy-on-close
append-to-body
@closed="reset"
>
<template #header>
<div class="file-preview-dialog__header">
<span class="file-preview-dialog__title" :title="attachment?.name">{{ attachment?.name }}</span>
<button
class="file-preview-dialog__ghost"
type="button"
:title="fullscreen ? $t('chat.preview.exitFullscreen') : $t('chat.preview.fullscreen')"
:aria-label="fullscreen ? $t('chat.preview.exitFullscreen') : $t('chat.preview.fullscreen')"
@click="fullscreen = !fullscreen"
>
<el-icon><FullScreen v-if="!fullscreen" /><Aim v-else /></el-icon>
</button>
<button
class="file-preview-dialog__ghost file-preview-dialog__ghost--labeled"
type="button"
@click="attachment && downloadFile(attachment)"
>
<el-icon><Download /></el-icon>
<span>{{ $t('chat.preview.download') }}</span>
</button>
</div>
</template>
<div class="file-preview-dialog__body">
<PreviewSpinner
v-if="state === 'loading'"
:label="$t('chat.preview.loading')"
/>
<div v-else-if="state === 'unsupported'" class="file-preview-dialog__status">
<p>{{ $t('chat.preview.unsupported') }}</p>
<button class="file-preview-dialog__cta" type="button" @click="attachment && downloadFile(attachment)">
{{ $t('chat.preview.download') }}
</button>
</div>
<div v-else-if="state === 'error'" class="file-preview-dialog__status">
<p>{{ $t('chat.preview.failed') }}</p>
<button class="file-preview-dialog__cta" type="button" @click="attachment && downloadFile(attachment)">
{{ $t('chat.preview.download') }}
</button>
</div>
<component
:is="previewComponent"
v-else-if="state === 'ready' && previewComponent && bytes"
:data="bytes"
:filename="attachment?.name || ''"
/>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
import { Aim, Download, FullScreen } from '@element-plus/icons-vue'
import { fetchAuthenticatedBlob } from '@/api/index'
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
import type { ChatAttachment } from '@/types'
import { previewKindOf, type PreviewKind } from './previewKind'
import { OPEN_FILE_PREVIEW_EVENT, type PreviewTarget } from './previewBus'
import PreviewSpinner from './PreviewSpinner.vue'
// When mounted as the single app-root instance, this dialog listens on the
// window bus so attachment cards and generated-file links can open it without
// prop-drilling a ref.
const props = defineProps<{ global?: boolean }>()
// Preview renderers are lazy chunks pdfjs/docx-preview/exceljs stay out of
// the main bundle until a matching file is actually opened.
const PdfPreview = defineAsyncComponent(() => import('./PdfPreview.vue'))
const DocxPreview = defineAsyncComponent(() => import('./DocxPreview.vue'))
const SheetPreview = defineAsyncComponent(() => import('./SheetPreview.vue'))
const TextPreview = defineAsyncComponent(() => import('./TextPreview.vue'))
const HtmlPreview = defineAsyncComponent(() => import('./HtmlPreview.vue'))
const { downloadFile } = useAuthenticatedAttachment()
const visible = ref(false)
const fullscreen = ref(false)
/** Increments per open() call; the latest wins when async fetches race. */
let openSeq = 0
const state = ref<'loading' | 'ready' | 'error' | 'unsupported'>('loading')
const attachment = ref<ChatAttachment | null>(null)
const bytes = shallowRef<ArrayBuffer | null>(null)
/** Effective render kind ('office' resolves to 'pdf' after server conversion). */
const renderKind = ref<Exclude<PreviewKind, 'office'> | null>(null)
const previewComponent = computed(() => {
switch (renderKind.value) {
case 'pdf': return PdfPreview
case 'docx': return DocxPreview
case 'sheet': return SheetPreview
case 'text': return TextPreview
case 'html': return HtmlPreview
default: return null
}
})
function reset() {
state.value = 'loading'
attachment.value = null
bytes.value = null
renderKind.value = null
fullscreen.value = false
}
/** Open the dialog for an attachment or a bare {name,url} generated-file target. */
async function open(target: PreviewTarget) {
// Normalize into a ChatAttachment shape so download/render paths are uniform.
const att: ChatAttachment = {
name: target.name,
url: target.url,
storedName: target.storedName ?? target.name,
contentType: target.contentType ?? '',
size: target.size ?? 0,
path: target.path ?? target.url,
}
// Monotonic token guards against a stale fetch resolving after the dialog
// was closed or reopened for a different file. (Object-identity comparison
// is unreliable: assigning to a deep `ref` reactive-wraps the value, so
// `attachment.value === att` would never hold for a freshly built object.)
const reqId = ++openSeq
attachment.value = att
state.value = 'loading'
bytes.value = null
visible.value = true
const kind = previewKindOf(att)
if (!kind) {
state.value = 'unsupported'
return
}
try {
if (kind === 'office') {
// Server-side officePDF conversion; 501 means no converter installed.
const blob = await fetchAuthenticatedBlob(att.url.replace(/\/?$/, '') + '/preview')
bytes.value = await blob.arrayBuffer()
renderKind.value = 'pdf'
} else {
const blob = await fetchAuthenticatedBlob(att.url)
bytes.value = await blob.arrayBuffer()
renderKind.value = kind
}
// Ignore if the dialog was closed or reopened for another file meanwhile.
if (visible.value && reqId === openSeq) {
state.value = 'ready'
}
} catch (e) {
console.warn('[FilePreviewDialog] preview load failed:', att.name, e)
if (visible.value && reqId === openSeq) {
state.value = kind === 'office' ? 'unsupported' : 'error'
}
}
}
// Global instance: open on window-bus events from attachment cards and the
// generated-file link interceptor.
function onPreviewEvent(e: Event) {
const detail = (e as CustomEvent<PreviewTarget>).detail
if (detail?.url && detail?.name) void open(detail)
}
onMounted(() => {
if (props.global) window.addEventListener(OPEN_FILE_PREVIEW_EVENT, onPreviewEvent)
})
onBeforeUnmount(() => {
if (props.global) window.removeEventListener(OPEN_FILE_PREVIEW_EVENT, onPreviewEvent)
})
defineExpose({ open })
</script>
<style scoped>
.file-preview-dialog__header {
display: flex;
align-items: center;
gap: 8px;
padding-right: 32px;
min-width: 0;
}
.file-preview-dialog__title {
flex: 1;
font-weight: 600;
font-size: var(--mc-text-base, 15px);
color: var(--mc-text-primary, #1d1612);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Ghost buttons (fullscreen toggle, download) quiet until hovered, then
lift in terracotta. Icon-only by default; the labeled variant adds text. */
.file-preview-dialog__ghost {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px;
border: none;
border-radius: var(--mc-radius-md, 12px);
background: transparent;
color: var(--mc-text-secondary, #665245);
font: inherit;
font-size: var(--mc-text-sm, 13px);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.file-preview-dialog__ghost--labeled {
padding: 6px 12px;
}
.file-preview-dialog__ghost:hover {
background: var(--mc-primary-bg, #f6e2d7);
color: var(--mc-primary, #d96d46);
}
.file-preview-dialog__body {
height: 74vh;
overflow: hidden;
background: var(--mc-bg-sunken, #ebe3db);
}
/* Fullscreen: let the desk fill the whole viewport below the header. */
.file-preview-dialog--fullscreen .file-preview-dialog__body {
height: calc(100vh - 56px);
}
.file-preview-dialog__status {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: var(--mc-text-secondary, #665245);
font-size: var(--mc-text-sm, 13px);
}
.file-preview-dialog__cta {
padding: 8px 20px;
border: none;
border-radius: var(--mc-radius-md, 12px);
background: var(--mc-primary, #d96d46);
color: var(--mc-text-inverse, #fff);
font: inherit;
font-size: var(--mc-text-sm, 13px);
cursor: pointer;
transition: background 0.15s ease;
}
.file-preview-dialog__cta:hover {
background: var(--mc-primary-hover, #bb4f27);
}
</style>
<style>
/* Glass shell: the dialog IS the frosted glass, matching .mc-page-frame,
instead of a plain white box dropped onto the warm theme. */
.file-preview-dialog.el-dialog {
background: var(--mc-surface-overlay, rgba(255, 255, 255, 0.72));
backdrop-filter: blur(12px) saturate(1.1);
border: 1px solid var(--mc-border, #d9cec2);
border-radius: var(--mc-radius-xl, 20px);
box-shadow: var(--mc-shadow-strong, 0 24px 70px rgba(58, 32, 19, 0.16));
overflow: hidden;
padding: 0;
}
/* Fullscreen fills the viewport edge-to-edge: no radius, no border, no lift. */
.file-preview-dialog.el-dialog.is-fullscreen {
border: none;
border-radius: 0;
box-shadow: none;
}
.file-preview-dialog .el-dialog__header {
margin: 0;
padding: 16px 20px 8px;
}
.file-preview-dialog .el-dialog__headerbtn {
top: 14px;
right: 12px;
}
.file-preview-dialog .el-dialog__headerbtn .el-dialog__close {
color: var(--mc-text-tertiary, #9b7d6c);
}
.file-preview-dialog .el-dialog__headerbtn:hover .el-dialog__close {
color: var(--mc-primary, #d96d46);
}
/* Kill the inner box: body renders the content directly on the warm desk. */
.file-preview-dialog .el-dialog__body {
padding: 0;
}
/* Warm-black overlay instead of neutral black. */
.file-preview-dialog + .el-overlay,
.el-overlay:has(.file-preview-dialog) {
background: rgba(29, 22, 18, 0.32);
}
</style>

View File

@ -0,0 +1,64 @@
<template>
<div class="html-preview">
<div class="html-preview__notice">
<el-icon><InfoFilled /></el-icon>
<span>{{ $t('chat.preview.htmlSandboxNotice') }}</span>
</div>
<!--
Security boundary: user-uploaded / agent-generated HTML must NEVER run
in the app origin. `allow-scripts` WITHOUT `allow-same-origin` gives the
frame an opaque origin scripts execute (interactive pages, charts)
but cannot touch the app's localStorage/cookies (JWT). Content is
injected via srcdoc so the frame has no same-origin URL of its own.
Never add allow-same-origin here: combined with allow-scripts it would
fully escape the sandbox.
-->
<iframe
class="html-preview__frame"
sandbox="allow-scripts"
:srcdoc="html"
:title="filename"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { InfoFilled } from '@element-plus/icons-vue'
const props = defineProps<{
/** Raw HTML bytes (already fetched with auth). */
data: ArrayBuffer
filename: string
}>()
const html = computed(() => new TextDecoder().decode(props.data))
</script>
<style scoped>
.html-preview {
height: 100%;
display: flex;
flex-direction: column;
/* Arbitrary HTML assumes an opaque white canvas; give it one so page
content doesn't composite over the translucent glass behind it. */
background: #fff;
}
.html-preview__notice {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
font-size: var(--mc-text-xs, 11px);
color: var(--mc-text-secondary, #665245);
background: var(--mc-bg-muted, #f1e8df);
border-bottom: 1px solid var(--mc-border-light, #ebe3db);
}
.html-preview__frame {
flex: 1;
width: 100%;
border: none;
background: #fff;
}
</style>

View File

@ -0,0 +1,136 @@
<template>
<div class="pdf-preview">
<div v-if="error" class="pdf-preview__error">{{ $t('chat.preview.failed') }}</div>
<PreviewSpinner v-else-if="loading" :label="$t('chat.preview.loading')" />
<div v-else ref="pagesEl" class="pdf-preview__pages">
<canvas
v-for="page in pageCount"
:key="page"
:ref="el => setCanvasRef(page, el as HTMLCanvasElement | null)"
class="pdf-preview__page"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from 'pdfjs-dist'
import PreviewSpinner from './PreviewSpinner.vue'
const props = defineProps<{
/** Raw PDF bytes (already fetched with auth). */
data: ArrayBuffer
}>()
const loading = ref(true)
const error = ref(false)
const pageCount = ref(0)
const pagesEl = ref<HTMLElement | null>(null)
let loadingTask: PDFDocumentLoadingTask | null = null
let pdfDoc: PDFDocumentProxy | null = null
let observer: IntersectionObserver | null = null
const canvasRefs = new Map<number, HTMLCanvasElement>()
const renderedPages = new Set<number>()
function setCanvasRef(page: number, el: HTMLCanvasElement | null) {
if (el) {
canvasRefs.set(page, el)
el.dataset.page = String(page)
observer?.observe(el)
} else {
canvasRefs.delete(page)
}
}
async function renderPage(pageNum: number) {
if (!pdfDoc || renderedPages.has(pageNum)) return
const canvas = canvasRefs.get(pageNum)
if (!canvas) return
renderedPages.add(pageNum)
try {
const page = await pdfDoc.getPage(pageNum)
// Fit the page to the container width, capped at 2x for retina crispness.
const containerWidth = pagesEl.value?.clientWidth || 800
const baseViewport = page.getViewport({ scale: 1 })
const scale = Math.min((containerWidth / baseViewport.width) * (window.devicePixelRatio || 1), 3)
const viewport = page.getViewport({ scale })
canvas.width = viewport.width
canvas.height = viewport.height
canvas.style.width = '100%'
const ctx = canvas.getContext('2d')
if (!ctx) return
await page.render({ canvas, canvasContext: ctx, viewport }).promise
} catch (e) {
renderedPages.delete(pageNum)
console.warn('[PdfPreview] page render failed:', pageNum, e)
}
}
onMounted(async () => {
try {
const pdfjs = await import('pdfjs-dist')
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString()
// pdfjs transfers the buffer to its worker hand it a copy so the parent
// dialog can still reuse the original bytes (e.g. for download).
loadingTask = pdfjs.getDocument({ data: props.data.slice(0) })
pdfDoc = await loadingTask.promise
pageCount.value = pdfDoc.numPages
loading.value = false
// Lazy-render pages as they scroll into view (first pages render eagerly
// because they start inside the viewport).
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const page = Number((entry.target as HTMLElement).dataset.page)
if (page) void renderPage(page)
}
}
}, { rootMargin: '400px' })
} catch (e) {
console.error('[PdfPreview] failed to load document:', e)
error.value = true
loading.value = false
}
})
onBeforeUnmount(() => {
observer?.disconnect()
void loadingTask?.destroy()
loadingTask = null
pdfDoc = null
})
</script>
<style scoped>
.pdf-preview {
height: 100%;
overflow-y: auto;
background: var(--mc-bg-sunken, #ebe3db);
}
.pdf-preview__pages {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 12px;
max-width: 900px;
margin: 0 auto;
}
/* White paper floating on the warm desk. */
.pdf-preview__page {
display: block;
background: #fff;
box-shadow: var(--mc-shadow-soft, 0 10px 30px rgba(58, 32, 19, 0.08));
border-radius: var(--mc-radius-sm, 6px);
}
.pdf-preview__error {
padding: 48px;
text-align: center;
color: var(--mc-text-secondary, #665245);
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<div class="preview-spinner" role="status" :aria-label="label">
<span class="preview-spinner__ring" />
<span v-if="label" class="preview-spinner__label">{{ label }}</span>
</div>
</template>
<script setup lang="ts">
// Self-drawn spinner in the app's terracotta accent replaces Element Plus's
// blue v-loading directive so preview surfaces stay on-theme (and drops the
// "Failed to resolve directive: loading" warning).
defineProps<{ label?: string }>()
</script>
<style scoped>
.preview-spinner {
height: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
}
.preview-spinner__ring {
width: 34px;
height: 34px;
border-radius: 50%;
border: 3px solid var(--mc-primary-bg, #f6e2d7);
border-top-color: var(--mc-primary, #d96d46);
animation: preview-spin 0.7s linear infinite;
}
.preview-spinner__label {
font-size: var(--mc-text-sm, 13px);
color: var(--mc-text-secondary, #665245);
}
@keyframes preview-spin {
to { transform: rotate(360deg); }
}
</style>

View File

@ -0,0 +1,195 @@
<template>
<div class="sheet-preview">
<div v-if="error" class="sheet-preview__error">{{ $t('chat.preview.failed') }}</div>
<PreviewSpinner v-else-if="loading" :label="$t('chat.preview.loading')" />
<template v-else>
<el-tabs v-if="sheets.length > 1" v-model="activeSheet" class="sheet-preview__tabs">
<el-tab-pane
v-for="sheet in sheets"
:key="sheet.name"
:label="sheet.name"
:name="sheet.name"
/>
</el-tabs>
<div class="sheet-preview__table-wrap">
<table v-if="currentSheet" class="sheet-preview__table">
<tbody>
<tr v-for="(row, ri) in currentSheet.rows" :key="ri">
<td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
</tr>
</tbody>
</table>
<div v-if="currentSheet?.truncated" class="sheet-preview__truncated">
{{ $t('chat.preview.truncated', { max: MAX_ROWS }) }}
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import PreviewSpinner from './PreviewSpinner.vue'
const props = defineProps<{
/** Raw .xlsx or .csv bytes (already fetched with auth). */
data: ArrayBuffer
/** Original filename — decides xlsx vs csv parsing. */
filename: string
}>()
interface ParsedSheet {
name: string
rows: string[][]
truncated: boolean
}
/** Hard cap so a million-row export can't freeze the dialog. */
const MAX_ROWS = 500
const loading = ref(true)
const error = ref(false)
const sheets = ref<ParsedSheet[]>([])
const activeSheet = ref('')
const currentSheet = computed(() =>
sheets.value.find(s => s.name === activeSheet.value) || sheets.value[0] || null,
)
function cellText(value: unknown): string {
if (value == null) return ''
if (typeof value === 'object') {
// exceljs rich values: dates, formulas ({result}), rich text ({richText}), hyperlinks ({text})
const v = value as Record<string, unknown>
if (value instanceof Date) return value.toISOString().slice(0, 10)
if (v.result != null) return cellText(v.result)
if (Array.isArray(v.richText)) return (v.richText as Array<{ text?: string }>).map(t => t.text || '').join('')
if (v.text != null) return String(v.text)
return String(value)
}
return String(value)
}
function parseCsv(text: string): string[][] {
// Minimal RFC-4180 parser: quoted fields, escaped quotes, CRLF/LF rows.
const rows: string[][] = []
let row: string[] = []
let field = ''
let inQuotes = false
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') { field += '"'; i++ } else { inQuotes = false }
} else {
field += ch
}
} else if (ch === '"') {
inQuotes = true
} else if (ch === ',') {
row.push(field); field = ''
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++
row.push(field); field = ''
rows.push(row); row = []
if (rows.length > MAX_ROWS) return rows
} else {
field += ch
}
}
if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row) }
return rows
}
onMounted(async () => {
try {
if (props.filename.toLowerCase().endsWith('.csv')) {
const text = new TextDecoder().decode(props.data)
const rows = parseCsv(text)
const truncated = rows.length > MAX_ROWS
sheets.value = [{ name: 'CSV', rows: rows.slice(0, MAX_ROWS), truncated }]
} else {
const ExcelJS = await import('exceljs')
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.load(props.data)
const parsed: ParsedSheet[] = []
workbook.eachSheet((ws) => {
const rows: string[][] = []
let truncated = false
ws.eachRow({ includeEmpty: false }, (row, rowNumber) => {
if (rowNumber > MAX_ROWS) { truncated = true; return }
const cells: string[] = []
// row.values is 1-based with an empty slot 0
const values = row.values as unknown[]
for (let c = 1; c < values.length; c++) cells.push(cellText(values[c]))
rows.push(cells)
})
parsed.push({ name: ws.name, rows, truncated })
})
sheets.value = parsed
}
activeSheet.value = sheets.value[0]?.name || ''
loading.value = false
} catch (e) {
console.error('[SheetPreview] parse failed:', e)
error.value = true
loading.value = false
}
})
</script>
<style scoped>
.sheet-preview {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--mc-bg-sunken, #ebe3db);
}
.sheet-preview__tabs {
flex-shrink: 0;
padding: 0 16px;
}
.sheet-preview__table-wrap {
flex: 1;
overflow: auto;
margin: 12px 16px 16px;
background: var(--mc-bg-elevated, #fff);
border-radius: var(--mc-radius-md, 12px);
box-shadow: var(--mc-shadow-soft, 0 10px 30px rgba(58, 32, 19, 0.08));
}
.sheet-preview__table {
border-collapse: collapse;
font-size: var(--mc-text-sm, 13px);
width: 100%;
}
.sheet-preview__table td {
border: 1px solid var(--mc-border-light, #ebe3db);
padding: 5px 12px;
white-space: nowrap;
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--mc-text-primary, #1d1612);
}
.sheet-preview__table tr:first-child td {
font-weight: 600;
background: var(--mc-bg-muted, #f1e8df);
color: var(--mc-text-secondary, #665245);
position: sticky;
top: 0;
}
.sheet-preview__table tr:not(:first-child):hover td {
background: var(--mc-primary-bg, #f6e2d7);
}
.sheet-preview__truncated {
padding: 8px 16px 12px;
color: var(--mc-text-tertiary, #9b7d6c);
font-size: var(--mc-text-xs, 11px);
}
.sheet-preview__error {
padding: 48px;
text-align: center;
color: var(--mc-text-secondary, #665245);
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<div class="text-preview">
<div
v-if="flavor === 'markdown'"
class="text-preview__markdown markdown-body"
v-html="renderedMarkdown"
/>
<pre v-else class="text-preview__code"><code v-html="highlighted" /></pre>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import hljs from 'highlight.js'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { extensionOf, textFlavorOf } from './previewKind'
const props = defineProps<{
/** Raw text bytes (already fetched with auth). */
data: ArrayBuffer
filename: string
}>()
/** Guard: a 50MB log file must not lock the tab. */
const MAX_CHARS = 500_000
const { renderMarkdown } = useMarkdownRenderer()
const text = computed(() => {
const raw = new TextDecoder().decode(props.data)
return raw.length > MAX_CHARS ? raw.slice(0, MAX_CHARS) + '\n…' : raw
})
const flavor = computed(() => textFlavorOf(props.filename))
const renderedMarkdown = computed(() =>
flavor.value === 'markdown' ? renderMarkdown(text.value) : '',
)
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
const highlighted = computed(() => {
if (flavor.value !== 'code') return escapeHtml(text.value)
const lang = extensionOf(props.filename)
try {
if (hljs.getLanguage(lang)) {
return hljs.highlight(text.value, { language: lang }).value
}
} catch { /* fall through to plain */ }
return escapeHtml(text.value)
})
</script>
<style scoped>
.text-preview {
height: 100%;
overflow-y: auto;
background: var(--mc-bg-sunken, #ebe3db);
}
.text-preview__markdown {
padding: 24px 28px;
max-width: 860px;
margin: 20px auto;
background: var(--mc-bg-elevated, #fff);
border-radius: var(--mc-radius-md, 12px);
box-shadow: var(--mc-shadow-soft, 0 10px 30px rgba(58, 32, 19, 0.08));
}
.text-preview__code {
margin: 20px auto;
max-width: 900px;
padding: 18px 20px;
background: var(--mc-code-bg, #faf6f1);
border: 1px solid var(--mc-border-light, #ebe3db);
border-radius: var(--mc-radius-md, 12px);
font-family: var(--mc-font-mono, ui-monospace, monospace);
font-size: var(--mc-text-sm, 13px);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
color: var(--mc-text-primary, #1d1612);
}
</style>

View File

@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import { previewKindOf, textFlavorOf, extensionOf } from '../previewKind'
describe('previewKindOf', () => {
it('routes PDF by extension and by MIME', () => {
expect(previewKindOf({ name: 'report.pdf', contentType: '' })).toBe('pdf')
expect(previewKindOf({ name: 'nomatch', contentType: 'application/pdf' })).toBe('pdf')
})
it('routes docx to the client-side docx renderer', () => {
expect(previewKindOf({ name: 'a.docx', contentType: '' })).toBe('docx')
})
it('routes xlsx and csv to the sheet renderer', () => {
expect(previewKindOf({ name: 'a.xlsx', contentType: '' })).toBe('sheet')
expect(previewKindOf({ name: 'data.csv', contentType: '' })).toBe('sheet')
})
it('routes html to the sandboxed html renderer', () => {
expect(previewKindOf({ name: 'page.html', contentType: '' })).toBe('html')
expect(previewKindOf({ name: 'page.htm', contentType: '' })).toBe('html')
})
it('routes markdown / code / text / text-MIME to the text renderer', () => {
expect(previewKindOf({ name: 'notes.md', contentType: '' })).toBe('text')
expect(previewKindOf({ name: 'app.ts', contentType: '' })).toBe('text')
expect(previewKindOf({ name: 'log.txt', contentType: '' })).toBe('text')
expect(previewKindOf({ name: 'weird', contentType: 'text/plain' })).toBe('text')
})
it('routes legacy/binary office formats to server-side conversion', () => {
for (const ext of ['pptx', 'ppt', 'doc', 'xls', 'odt', 'ods', 'odp', 'rtf', 'wps']) {
expect(previewKindOf({ name: `f.${ext}`, contentType: '' })).toBe('office')
}
})
it('returns null (download-only) for unknown binary formats', () => {
expect(previewKindOf({ name: 'archive.zip', contentType: 'application/zip' })).toBeNull()
expect(previewKindOf({ name: 'firmware.bin', contentType: '' })).toBeNull()
expect(previewKindOf({ name: 'noextension', contentType: '' })).toBeNull()
})
it('does not treat images/video/audio/model as document previews (handled elsewhere)', () => {
// These carry image/* etc. MIME and are rendered by MessageBubble's own
// branches; previewKindOf only sees the fileAttachments residue, but guard
// anyway that a stray image name is not mis-routed to a doc kind.
expect(previewKindOf({ name: 'pic.png', contentType: 'image/png' })).toBeNull()
})
})
describe('textFlavorOf', () => {
it('classifies markdown, code, and plain text', () => {
expect(textFlavorOf('a.md')).toBe('markdown')
expect(textFlavorOf('a.ts')).toBe('code')
expect(textFlavorOf('a.json')).toBe('code')
expect(textFlavorOf('a.txt')).toBe('plain')
expect(textFlavorOf('a.unknown')).toBe('plain')
})
})
describe('extensionOf', () => {
it('lowercases and handles dotless names', () => {
expect(extensionOf('Report.PDF')).toBe('pdf')
expect(extensionOf('name.tar.gz')).toBe('gz')
expect(extensionOf('noext')).toBe('')
expect(extensionOf(undefined)).toBe('')
})
})

View File

@ -0,0 +1,18 @@
import type { ChatAttachment } from '@/types'
/**
* Minimal window-event bus that lets any part of the app open the single
* global {@link FilePreviewDialog} without prop-drilling a ref. Used by chat
* attachment cards and by the global generated-file link interceptor
* (useGlobalFileDownloadClick) so AI-generated docx/xlsx/pdf links preview
* in-place instead of downloading.
*/
export const OPEN_FILE_PREVIEW_EVENT = 'mateclaw:open-file-preview'
/** The subset of {@link ChatAttachment} a preview needs. */
export type PreviewTarget = Pick<ChatAttachment, 'name' | 'url'>
& Partial<Pick<ChatAttachment, 'contentType' | 'storedName' | 'size' | 'path'>>
export function openFilePreview(target: PreviewTarget): void {
window.dispatchEvent(new CustomEvent<PreviewTarget>(OPEN_FILE_PREVIEW_EVENT, { detail: target }))
}

View File

@ -0,0 +1,75 @@
import type { ChatAttachment } from '@/types'
/**
* In-browser preview strategy for a chat attachment.
*
* - 'pdf' rendered client-side with pdfjs-dist
* - 'docx' rendered client-side with docx-preview
* - 'sheet' xlsx/csv parsed client-side with exceljs
* - 'text' markdown / code / plain text, reuses the chat markdown renderer
* - 'html' rendered inside a fully sandboxed iframe (no scripts, no origin)
* - 'office' needs server-side conversion to PDF (soffice); the frontend
* requests `{url}/preview` and renders the result as 'pdf'.
* Falls back to download when the server has no converter (501).
*/
export type PreviewKind = 'pdf' | 'docx' | 'sheet' | 'text' | 'html' | 'office'
/** Extensions rendered as markdown (full rich rendering). */
const MARKDOWN_EXTS = new Set(['md', 'markdown'])
/** Extensions rendered as syntax-highlighted code. */
const CODE_EXTS = new Set([
'json', 'yaml', 'yml', 'toml', 'xml', 'sql', 'sh', 'bash', 'zsh',
'js', 'ts', 'jsx', 'tsx', 'vue', 'py', 'java', 'kt', 'go', 'rs',
'rb', 'c', 'cpp', 'h', 'cs', 'php', 'lua', 'css', 'scss', 'less',
'properties', 'ini', 'conf', 'gradle', 'dockerfile',
])
/** Extensions rendered as plain preformatted text. */
const PLAIN_TEXT_EXTS = new Set(['txt', 'log', 'csv-report', 'text'])
/** Extensions the server-side office→PDF converter accepts. */
const OFFICE_CONVERT_EXTS = new Set([
'ppt', 'pptx', 'doc', 'xls', 'odt', 'ods', 'odp', 'rtf', 'wps',
])
export function extensionOf(name: string | undefined): string {
if (!name) return ''
const idx = name.lastIndexOf('.')
return idx >= 0 ? name.slice(idx + 1).toLowerCase() : ''
}
/** Sub-flavor for the text preview so it can pick a rendering mode. */
export function textFlavorOf(name: string | undefined): 'markdown' | 'code' | 'plain' {
const ext = extensionOf(name)
if (MARKDOWN_EXTS.has(ext)) return 'markdown'
if (CODE_EXTS.has(ext)) return 'code'
return 'plain'
}
/**
* Decide how (and whether) an attachment can be previewed in-browser.
* Returns null when the only sensible action is download.
*/
export function previewKindOf(attachment: Pick<ChatAttachment, 'name' | 'contentType'>): PreviewKind | null {
const ext = extensionOf(attachment.name)
const mime = attachment.contentType || ''
if (ext === 'pdf' || mime === 'application/pdf') return 'pdf'
if (ext === 'docx'
|| mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
return 'docx'
}
if (ext === 'xlsx' || ext === 'csv'
|| mime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|| mime === 'text/csv') {
return 'sheet'
}
if (ext === 'html' || ext === 'htm' || mime === 'text/html') return 'html'
if (MARKDOWN_EXTS.has(ext) || CODE_EXTS.has(ext) || PLAIN_TEXT_EXTS.has(ext)
|| mime.startsWith('text/')) {
return 'text'
}
if (OFFICE_CONVERT_EXTS.has(ext)) return 'office'
return null
}

View File

@ -23,6 +23,8 @@ import { onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { fetchAuthenticatedBlob } from '@/api/index'
import { mcToast } from '@/composables/useMcToast'
import { previewKindOf } from '@/components/chat/preview/previewKind'
import { openFilePreview } from '@/components/chat/preview/previewBus'
// Matches every backend-served file path: in-memory generated files
// (`/api/v1/files/generated/<id>`) and conversation-scoped media/attachments
@ -64,22 +66,36 @@ export function useGlobalFileDownloadClick() {
const anchor = target.closest<HTMLAnchorElement>('a[href]')
if (!anchor) return
// Only same-origin file-API links; leave everything else to the browser.
// Match our backend file-API links by PATH, on any origin. Generated-file
// tools mint absolute URLs against the backend base (in dev that is a
// different port than the SPA); matching by path catches those too. We
// always act on the RELATIVE path below, so the fetch hits our own origin
// (prod) or the vite proxy (dev) — never a foreign host.
let url: URL
try {
url = new URL(anchor.href, window.location.href)
} catch {
return
}
if (url.origin !== window.location.origin || !FILE_PATH_RE.test(url.pathname)) return
if (!FILE_PATH_RE.test(url.pathname)) return
// From here the link is ours: never let it become a full-page navigation.
e.preventDefault()
e.stopPropagation()
const name = filenameFor(anchor, url.pathname)
const relPath = url.pathname + url.search
// Previewable formats (AI-generated docx/xlsx/pdf/…) open in the global
// preview dialog instead of downloading. Unknown formats fall through to
// the authenticated download below.
if (previewKindOf({ name, contentType: '' })) {
openFilePreview({ name, url: relPath })
return
}
try {
const blob = await fetchAuthenticatedBlob(url.href)
const blob = await fetchAuthenticatedBlob(relPath)
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl

View File

@ -450,6 +450,17 @@ export default {
reportCopied: 'Error details copied to clipboard',
reportFailed: 'Copy failed — check browser permissions',
},
// Attachment inline preview dialog
preview: {
download: 'Download',
fullscreen: 'Fullscreen',
exitFullscreen: 'Exit fullscreen',
loading: 'Loading preview…',
failed: 'Preview failed to load — please download the file instead',
unsupported: 'This format cannot be previewed inline — please download it',
truncated: 'Showing first {max} rows — download for the full content',
htmlSandboxNotice: 'HTML renders in an isolated sandbox with no access to your session',
},
// Approval bar
approvalAllow: 'Allow',
approvalExecute: 'to execute?',

View File

@ -450,6 +450,17 @@ export default {
reportCopied: '错误详情已复制到剪贴板',
reportFailed: '复制失败,请检查浏览器权限',
},
// 附件在线预览弹窗
preview: {
download: '下载',
fullscreen: '全屏',
exitFullscreen: '退出全屏',
loading: '正在加载预览…',
failed: '预览加载失败,请下载后查看',
unsupported: '该格式暂不支持在线预览,请下载后查看',
truncated: '仅显示前 {max} 行,完整内容请下载查看',
htmlSandboxNotice: 'HTML 在隔离沙箱中渲染,无法访问你的登录状态',
},
// 审批栏
approvalAllow: '允许',
approvalExecute: '执行?',

View File

@ -29,7 +29,8 @@ declare module 'vue' {
ElSkeleton: typeof import('element-plus/es/components/skeleton/index')['ElSkeleton']
ElTable: typeof import('element-plus/es/components/table/index')['ElTable']
ElTableColumn: typeof import('element-plus/es/components/table/index')['ElTableColumn']
ElTag: typeof import('element-plus/es/components/tag/index')['ElTag']
ElTabPane: typeof import('element-plus/es/components/tabs/index')['ElTabPane']
ElTabs: typeof import('element-plus/es/components/tabs/index')['ElTabs']
ElTooltip: typeof import('element-plus/es/components/tooltip/index')['ElTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']