mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(chat): glass-themed preview for uploaded & AI-generated docx/xlsx/pdf (#513)
This commit is contained in:
parent
a466f609cf
commit
c9cc5b4f6f
@ -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;
|
||||
|
||||
// 使用虚拟线程池处理 SSE(Java 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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) { }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
712
mateclaw-ui/pnpm-lock.yaml
generated
712
mateclaw-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
|
||||
@ -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;
|
||||
|
||||
69
mateclaw-ui/src/components/chat/preview/DocxPreview.vue
Normal file
69
mateclaw-ui/src/components/chat/preview/DocxPreview.vue
Normal 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>
|
||||
301
mateclaw-ui/src/components/chat/preview/FilePreviewDialog.vue
Normal file
301
mateclaw-ui/src/components/chat/preview/FilePreviewDialog.vue
Normal 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 office→PDF 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>
|
||||
64
mateclaw-ui/src/components/chat/preview/HtmlPreview.vue
Normal file
64
mateclaw-ui/src/components/chat/preview/HtmlPreview.vue
Normal 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>
|
||||
136
mateclaw-ui/src/components/chat/preview/PdfPreview.vue
Normal file
136
mateclaw-ui/src/components/chat/preview/PdfPreview.vue
Normal 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>
|
||||
40
mateclaw-ui/src/components/chat/preview/PreviewSpinner.vue
Normal file
40
mateclaw-ui/src/components/chat/preview/PreviewSpinner.vue
Normal 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>
|
||||
195
mateclaw-ui/src/components/chat/preview/SheetPreview.vue
Normal file
195
mateclaw-ui/src/components/chat/preview/SheetPreview.vue
Normal 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>
|
||||
84
mateclaw-ui/src/components/chat/preview/TextPreview.vue
Normal file
84
mateclaw-ui/src/components/chat/preview/TextPreview.vue
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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>
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
18
mateclaw-ui/src/components/chat/preview/previewBus.ts
Normal file
18
mateclaw-ui/src/components/chat/preview/previewBus.ts
Normal 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 }))
|
||||
}
|
||||
75
mateclaw-ui/src/components/chat/preview/previewKind.ts
Normal file
75
mateclaw-ui/src/components/chat/preview/previewKind.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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?',
|
||||
|
||||
@ -450,6 +450,17 @@ export default {
|
||||
reportCopied: '错误详情已复制到剪贴板',
|
||||
reportFailed: '复制失败,请检查浏览器权限',
|
||||
},
|
||||
// 附件在线预览弹窗
|
||||
preview: {
|
||||
download: '下载',
|
||||
fullscreen: '全屏',
|
||||
exitFullscreen: '退出全屏',
|
||||
loading: '正在加载预览…',
|
||||
failed: '预览加载失败,请下载后查看',
|
||||
unsupported: '该格式暂不支持在线预览,请下载后查看',
|
||||
truncated: '仅显示前 {max} 行,完整内容请下载查看',
|
||||
htmlSandboxNotice: 'HTML 在隔离沙箱中渲染,无法访问你的登录状态',
|
||||
},
|
||||
// 审批栏
|
||||
approvalAllow: '允许',
|
||||
approvalExecute: '执行?',
|
||||
|
||||
3
mateclaw-ui/src/types/components.d.ts
vendored
3
mateclaw-ui/src/types/components.d.ts
vendored
@ -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']
|
||||
|
||||
Loading…
Reference in New Issue
Block a user