mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
@ -65,6 +66,7 @@ public class ChatController {
|
|||||||
private final ConversationCompletionPublisher completionPublisher;
|
private final ConversationCompletionPublisher completionPublisher;
|
||||||
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
||||||
private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver;
|
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())
|
// 使用虚拟线程池处理 SSE(Java 17+ 兼容,Java 21 可用 Executors.newVirtualThreadPerTaskExecutor())
|
||||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||||
@ -1142,21 +1144,7 @@ public class ChatController {
|
|||||||
return ResponseEntity.status(403).build();
|
return ResponseEntity.status(403).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check every candidate root (workspace-scoped dir + legacy default dir)
|
Path filePath = resolveUploadedFile(conversationId, storedName);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (filePath == null) {
|
if (filePath == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
@ -1183,6 +1171,62 @@ public class ChatController {
|
|||||||
.body(resource);
|
.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
|
* Build the {@link vip.mate.agent.context.ChatOrigin} that drives per-owner
|
||||||
* memory isolation for a web request. When {@code endUserId} is supplied
|
* 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",
|
"cytoscape-cose-bilkent": "^4.1.0",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"docx-preview": "^0.4.0",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"element-plus": "^2.9.1",
|
"element-plus": "^2.9.1",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.45",
|
"katex": "^0.16.45",
|
||||||
"marked": "^15.0.6",
|
"marked": "^15.0.6",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"mermaid": "^11.14.0",
|
"mermaid": "^11.14.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
|
"pdfjs-dist": "^6.1.200",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
"pixelarticons": "2.1.0",
|
"pixelarticons": "2.1.0",
|
||||||
"vue": "^3.5.13",
|
"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
|
<!-- Mounted once at the app root so mcConfirm() can pop a dialog
|
||||||
from anywhere without each caller wiring its own host. -->
|
from anywhere without each caller wiring its own host. -->
|
||||||
<McConfirmHost />
|
<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>
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -18,6 +22,7 @@ import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
|
|||||||
import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick'
|
import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick'
|
||||||
import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick'
|
import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick'
|
||||||
import McConfirmHost from '@/components/common/McConfirmHost.vue'
|
import McConfirmHost from '@/components/common/McConfirmHost.vue'
|
||||||
|
import FilePreviewDialog from '@/components/chat/preview/FilePreviewDialog.vue'
|
||||||
|
|
||||||
// Initialize theme — applies .dark class to <html> immediately
|
// Initialize theme — applies .dark class to <html> immediately
|
||||||
useThemeStore()
|
useThemeStore()
|
||||||
|
|||||||
@ -365,11 +365,16 @@
|
|||||||
:key="attachment.storedName"
|
:key="attachment.storedName"
|
||||||
class="message-attachment"
|
class="message-attachment"
|
||||||
type="button"
|
type="button"
|
||||||
@click="downloadFile(attachment)"
|
@click="openFileAttachment(attachment)"
|
||||||
>
|
>
|
||||||
<el-icon class="message-attachment__icon"><Document /></el-icon>
|
<el-icon class="message-attachment__icon"><Document /></el-icon>
|
||||||
<span class="message-attachment__name">{{ attachment.name }}</span>
|
<span class="message-attachment__name">{{ attachment.name }}</span>
|
||||||
<span class="message-attachment__meta">{{ formatFileSize(attachment.size) }}</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -539,6 +544,7 @@ import {
|
|||||||
CloseBold,
|
CloseBold,
|
||||||
CopyDocument,
|
CopyDocument,
|
||||||
Document,
|
Document,
|
||||||
|
Download,
|
||||||
InfoFilled,
|
InfoFilled,
|
||||||
Loading,
|
Loading,
|
||||||
Microphone,
|
Microphone,
|
||||||
@ -557,6 +563,8 @@ import { useToolLabel } from '@/composables/useToolLabel'
|
|||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
import { copyToClipboard } from '@/utils/clipboard'
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
import TypingCursor from './TypingCursor.vue'
|
import TypingCursor from './TypingCursor.vue'
|
||||||
|
import { previewKindOf } from './preview/previewKind'
|
||||||
|
import { openFilePreview } from './preview/previewBus'
|
||||||
import BrowserTimeline from './BrowserTimeline.vue'
|
import BrowserTimeline from './BrowserTimeline.vue'
|
||||||
import ToolCallSegment from './ToolCallSegment.vue'
|
import ToolCallSegment from './ToolCallSegment.vue'
|
||||||
import ThinkingSegment from './ThinkingSegment.vue'
|
import ThinkingSegment from './ThinkingSegment.vue'
|
||||||
@ -575,6 +583,16 @@ const { t, locale } = useI18n()
|
|||||||
const { getToolLabel } = useToolLabel()
|
const { getToolLabel } = useToolLabel()
|
||||||
const { blobUrls, loadAllImages, loadAllVideos, loadAllAudios, loadAllModels, downloadFile, openImage, getDisplayUrl, revokeAll } = useAuthenticatedAttachment()
|
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 {
|
interface Props {
|
||||||
message: Message
|
message: Message
|
||||||
isLast?: boolean
|
isLast?: boolean
|
||||||
@ -2451,6 +2469,16 @@ watch(isGenerating, (generating) => {
|
|||||||
opacity: 0.76;
|
opacity: 0.76;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-attachment__download {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-attachment__download:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==================== Markdown 样式 ==================== */
|
/* ==================== Markdown 样式 ==================== */
|
||||||
.markdown-body :deep(p) {
|
.markdown-body :deep(p) {
|
||||||
margin: 0 0 10px;
|
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 { useI18n } from 'vue-i18n'
|
||||||
import { fetchAuthenticatedBlob } from '@/api/index'
|
import { fetchAuthenticatedBlob } from '@/api/index'
|
||||||
import { mcToast } from '@/composables/useMcToast'
|
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
|
// Matches every backend-served file path: in-memory generated files
|
||||||
// (`/api/v1/files/generated/<id>`) and conversation-scoped media/attachments
|
// (`/api/v1/files/generated/<id>`) and conversation-scoped media/attachments
|
||||||
@ -64,22 +66,36 @@ export function useGlobalFileDownloadClick() {
|
|||||||
const anchor = target.closest<HTMLAnchorElement>('a[href]')
|
const anchor = target.closest<HTMLAnchorElement>('a[href]')
|
||||||
if (!anchor) return
|
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
|
let url: URL
|
||||||
try {
|
try {
|
||||||
url = new URL(anchor.href, window.location.href)
|
url = new URL(anchor.href, window.location.href)
|
||||||
} catch {
|
} catch {
|
||||||
return
|
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.
|
// From here the link is ours: never let it become a full-page navigation.
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const name = filenameFor(anchor, url.pathname)
|
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 {
|
try {
|
||||||
const blob = await fetchAuthenticatedBlob(url.href)
|
const blob = await fetchAuthenticatedBlob(relPath)
|
||||||
const objectUrl = URL.createObjectURL(blob)
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = objectUrl
|
a.href = objectUrl
|
||||||
|
|||||||
@ -450,6 +450,17 @@ export default {
|
|||||||
reportCopied: 'Error details copied to clipboard',
|
reportCopied: 'Error details copied to clipboard',
|
||||||
reportFailed: 'Copy failed — check browser permissions',
|
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
|
// Approval bar
|
||||||
approvalAllow: 'Allow',
|
approvalAllow: 'Allow',
|
||||||
approvalExecute: 'to execute?',
|
approvalExecute: 'to execute?',
|
||||||
|
|||||||
@ -450,6 +450,17 @@ export default {
|
|||||||
reportCopied: '错误详情已复制到剪贴板',
|
reportCopied: '错误详情已复制到剪贴板',
|
||||||
reportFailed: '复制失败,请检查浏览器权限',
|
reportFailed: '复制失败,请检查浏览器权限',
|
||||||
},
|
},
|
||||||
|
// 附件在线预览弹窗
|
||||||
|
preview: {
|
||||||
|
download: '下载',
|
||||||
|
fullscreen: '全屏',
|
||||||
|
exitFullscreen: '退出全屏',
|
||||||
|
loading: '正在加载预览…',
|
||||||
|
failed: '预览加载失败,请下载后查看',
|
||||||
|
unsupported: '该格式暂不支持在线预览,请下载后查看',
|
||||||
|
truncated: '仅显示前 {max} 行,完整内容请下载查看',
|
||||||
|
htmlSandboxNotice: 'HTML 在隔离沙箱中渲染,无法访问你的登录状态',
|
||||||
|
},
|
||||||
// 审批栏
|
// 审批栏
|
||||||
approvalAllow: '允许',
|
approvalAllow: '允许',
|
||||||
approvalExecute: '执行?',
|
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']
|
ElSkeleton: typeof import('element-plus/es/components/skeleton/index')['ElSkeleton']
|
||||||
ElTable: typeof import('element-plus/es/components/table/index')['ElTable']
|
ElTable: typeof import('element-plus/es/components/table/index')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es/components/table/index')['ElTableColumn']
|
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']
|
ElTooltip: typeof import('element-plus/es/components/tooltip/index')['ElTooltip']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user