feat(chatui): add tabbed workspace and PDF previews

This commit is contained in:
Soulter
2026-09-10 17:31:39 +08:00
parent 8e0b5baac4
commit c62c4089af
12 changed files with 1428 additions and 330 deletions
+1
View File
@@ -35,6 +35,7 @@
"markstream-vue": "1.0.5-beta.0",
"mermaid": "^11.12.2",
"monaco-editor": "^0.52.2",
"pdfjs-dist": "6.3.289",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"qrcode": "^1.5.4",
+134
View File
@@ -69,6 +69,9 @@ importers:
monaco-editor:
specifier: ^0.52.2
version: 0.52.2
pdfjs-dist:
specifier: 6.3.289
version: 6.3.289
pinia:
specifier: 2.1.6
version: 2.1.6(typescript@5.1.6)(vue@3.3.4)
@@ -518,6 +521,81 @@ packages:
'@monaco-editor/loader@1.7.0':
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
'@napi-rs/canvas-android-arm64@1.0.9':
resolution: {integrity: sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@1.0.9':
resolution: {integrity: sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@1.0.9':
resolution: {integrity: sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.9':
resolution: {integrity: sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@1.0.9':
resolution: {integrity: sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@1.0.9':
resolution: {integrity: sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@1.0.9':
resolution: {integrity: sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@1.0.9':
resolution: {integrity: sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@1.0.9':
resolution: {integrity: sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@1.0.9':
resolution: {integrity: sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@1.0.9':
resolution: {integrity: sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@1.0.9':
resolution: {integrity: sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==}
engines: {node: '>= 10'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -2451,6 +2529,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdfjs-dist@6.3.289:
resolution: {integrity: sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==}
engines: {node: '>=22.13.0 || >=24'}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@@ -3518,6 +3600,54 @@ snapshots:
dependencies:
state-local: 1.0.7
'@napi-rs/canvas-android-arm64@1.0.9':
optional: true
'@napi-rs/canvas-darwin-arm64@1.0.9':
optional: true
'@napi-rs/canvas-darwin-x64@1.0.9':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.9':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@1.0.9':
optional: true
'@napi-rs/canvas-linux-arm64-musl@1.0.9':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@1.0.9':
optional: true
'@napi-rs/canvas-linux-x64-gnu@1.0.9':
optional: true
'@napi-rs/canvas-linux-x64-musl@1.0.9':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@1.0.9':
optional: true
'@napi-rs/canvas-win32-x64-msvc@1.0.9':
optional: true
'@napi-rs/canvas@1.0.9':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 1.0.9
'@napi-rs/canvas-darwin-arm64': 1.0.9
'@napi-rs/canvas-darwin-x64': 1.0.9
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.9
'@napi-rs/canvas-linux-arm64-gnu': 1.0.9
'@napi-rs/canvas-linux-arm64-musl': 1.0.9
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.9
'@napi-rs/canvas-linux-x64-gnu': 1.0.9
'@napi-rs/canvas-linux-x64-musl': 1.0.9
'@napi-rs/canvas-win32-arm64-msvc': 1.0.9
'@napi-rs/canvas-win32-x64-msvc': 1.0.9
optional: true
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -5594,6 +5724,10 @@ snapshots:
pathe@2.0.3: {}
pdfjs-dist@6.3.289:
optionalDependencies:
'@napi-rs/canvas': 1.0.9
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
+56
View File
@@ -0,0 +1,56 @@
import { readFileSync, readdirSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
const require = createRequire(import.meta.url);
const packagePath = require.resolve("pdfjs-dist/package.json");
const root = dirname(packagePath);
const { version } = JSON.parse(readFileSync(packagePath, "utf8"));
/** Serve the same versioned PDF resources in development and production. */
export function pdfjsAssets() {
const files = new Map([["LICENSE", join(root, "LICENSE")]]);
for (const directory of ["cmaps", "standard_fonts", "wasm", "iccs"]) {
for (const entry of readdirSync(join(root, directory), {
withFileTypes: true,
})) {
if (entry.isFile()) {
files.set(
`${directory}/${entry.name}`,
join(root, directory, entry.name),
);
}
}
}
return {
name: "pdfjs-assets",
configureServer(server) {
server.middlewares.use(
`${server.config.base}pdfjs/${version}/`,
(req, res, next) => {
const name = req.url?.split("?")[0].replace(/^\//, "");
const file = files.get(name);
if (!file) return next();
res.setHeader(
"Content-Type",
name.endsWith(".wasm")
? "application/wasm"
: /\.m?js$/.test(name)
? "text/javascript"
: "application/octet-stream",
);
res.end(readFileSync(file));
},
);
},
generateBundle() {
for (const [name, file] of files) {
this.emitFile({
type: "asset",
fileName: `pdfjs/${version}/${name}`,
source: readFileSync(file),
});
}
},
};
}
+2 -1
View File
@@ -944,11 +944,12 @@ export const chatApi = {
}),
);
},
downloadProjectWorkspaceFile(projectId: string, path: string) {
downloadProjectWorkspaceFile(projectId: string, path: string, signal?: AbortSignal) {
return openApiV1.downloadChatProjectWorkspaceFile({
path: { project_id: projectId },
query: { path },
responseType: 'blob',
signal,
}) as Promise<AxiosResponse<Blob>>;
},
addProjectSession(projectId: string, sessionId: string) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
<template>
<div
class="pdf-page"
:style="{ width: `${width}px`, height: `${height}px` }"
role="img"
:aria-label="tm('workspaceFiles.pdf.pageLabel', { page, total })"
>
<div v-if="error" class="pdf-placeholder pdf-error" role="alert">
{{ error }}
</div>
<div v-else-if="!rendered" class="pdf-placeholder" aria-hidden="true">
<v-progress-circular v-if="visible" indeterminate size="22" width="2" />
<span>{{ page }}</span>
</div>
<div ref="pageHost" class="pdf-canvas" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
import { useModuleI18n } from "@/i18n/composables";
const props = defineProps<{
document: PDFDocumentProxy;
page: number;
total: number;
width: number;
height: number;
scrollRoot: HTMLElement | null;
}>();
const { tm } = useModuleI18n("features/chat");
const pageHost = ref<HTMLElement | null>(null);
const visible = ref(false);
const rendered = ref(false);
const error = ref("");
let observer: IntersectionObserver | undefined;
onMounted(() => {
observer = new IntersectionObserver(
([entry]) => {
visible.value = entry.isIntersecting;
},
{
root: props.scrollRoot,
rootMargin: "600px 0px",
},
);
if (pageHost.value) observer.observe(pageHost.value);
});
onBeforeUnmount(() => observer?.disconnect());
watch(
[visible, () => props.document, () => props.width, () => props.page],
async ([nearViewport, document, width, pageNumber], _, onCleanup) => {
let cancelled = false;
let task: RenderTask | undefined;
let canvas: HTMLCanvasElement | undefined;
onCleanup(() => {
cancelled = true;
task?.cancel();
canvas?.remove();
// Release the raster buffer when a page leaves the nearby viewport.
if (canvas) canvas.width = canvas.height = 0;
});
rendered.value = false;
error.value = "";
if (!nearViewport || width <= 0) return;
try {
const page = await document.getPage(pageNumber);
if (cancelled) return;
const original = page.getViewport({ scale: 1 });
const view = page.getViewport({ scale: width / original.width });
const ratio = Math.min(
window.devicePixelRatio || 1,
2,
Math.sqrt(8_000_000 / (view.width * view.height)),
8192 / view.width,
8192 / view.height,
);
canvas = window.document.createElement("canvas");
canvas.width = Math.max(1, Math.floor(view.width * ratio));
canvas.height = Math.max(1, Math.floor(view.height * ratio));
canvas.style.width = `${view.width}px`;
canvas.style.height = `${view.height}px`;
canvas.setAttribute("aria-hidden", "true");
task = page.render({
canvas,
viewport: view,
transform: [ratio, 0, 0, ratio, 0, 0],
});
await task.promise;
if (cancelled) return;
pageHost.value?.replaceChildren(canvas);
rendered.value = true;
} catch (cause) {
if (
!cancelled &&
(cause as Error)?.name !== "RenderingCancelledException"
) {
error.value = tm("workspaceFiles.pdf.loadFailed");
}
}
},
{ flush: "post" },
);
</script>
<style scoped>
.pdf-page {
position: relative;
flex: 0 0 auto;
background: white;
box-shadow: 0 1px 6px #0002;
}
.pdf-canvas {
position: absolute;
inset: 0;
}
.pdf-canvas :deep(canvas) {
display: block;
}
.pdf-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
color: #888;
font-size: 13px;
}
.pdf-error {
padding: 24px;
text-align: center;
}
</style>
@@ -0,0 +1,272 @@
<template>
<div class="workspace-pdf">
<div
ref="viewport"
class="pdf-scroll"
:aria-busy="loading"
@scroll.passive="rememberPage"
>
<div v-if="error" class="pdf-state" role="alert">{{ error }}</div>
<div v-else-if="loading" class="pdf-state" role="status">
<v-progress-circular indeterminate size="24" width="2" />
<span>{{ tm("workspaceFiles.pdf.loading") }}</span>
</div>
<div v-else-if="pdf" class="pdf-pages">
<WorkspacePdfPage
v-for="(layout, index) in pageLayouts"
:key="index"
:document="pdf"
:page="index + 1"
:total="pdf.numPages"
:width="layout.width"
:height="layout.height"
:scroll-root="viewport"
/>
</div>
</div>
<div
v-if="pdf && !loading && !error"
class="pdf-zoom-hub"
role="group"
:aria-label="tm('workspaceFiles.pdf.zoomControls')"
>
<v-btn
icon
size="x-small"
variant="text"
:disabled="zoom <= 0.5"
:aria-label="tm('workspaceFiles.pdf.zoomOut')"
:title="tm('workspaceFiles.pdf.zoomOut')"
@click="emit('update:zoom', Math.max(0.5, zoom - 0.25))"
><Minus :size="16"
/></v-btn>
<v-btn
size="x-small"
variant="text"
class="pdf-zoom-value"
:title="tm('workspaceFiles.pdf.fitWidth')"
:aria-label="tm('workspaceFiles.pdf.fitWidth')"
@click="emit('update:zoom', 1)"
>{{ Math.round(zoom * 100) }}%</v-btn
>
<v-btn
icon
size="x-small"
variant="text"
:disabled="zoom >= 3"
:aria-label="tm('workspaceFiles.pdf.zoomIn')"
:title="tm('workspaceFiles.pdf.zoomIn')"
@click="emit('update:zoom', Math.min(3, zoom + 0.25))"
><Plus :size="16"
/></v-btn>
</div>
</div>
</template>
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
watch,
} from "vue";
import { Minus, Plus } from "@lucide/vue";
import {
getDocument,
GlobalWorkerOptions,
version,
type PDFDocumentProxy,
type PDFDocumentLoadingTask,
} from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { useModuleI18n } from "@/i18n/composables";
import WorkspacePdfPage from "./WorkspacePdfPage.vue";
const props = defineProps<{ file: Blob; page: number; zoom: number }>();
const emit = defineEmits<{
"update:page": [page: number];
"update:zoom": [zoom: number];
}>();
const { tm } = useModuleI18n("features/chat");
const pdf = shallowRef<PDFDocumentProxy | null>(null);
const viewport = ref<HTMLElement | null>(null);
const width = ref(0);
const loading = ref(true);
const error = ref("");
const pageSizes = shallowRef<Array<{ width: number; height: number }>>([]);
const pageLayouts = computed(() => {
const pageWidth = Math.max(1, width.value - 32) * props.zoom;
let top = 16;
return pageSizes.value.map((size) => {
const height = (pageWidth * size.height) / size.width;
const layout = { width: pageWidth, height, top };
top += height + 16;
return layout;
});
});
GlobalWorkerOptions.workerSrc = workerUrl;
const resourceBase = `${import.meta.env.BASE_URL}pdfjs/${version}/`;
const observer = new ResizeObserver(([entry]) => {
width.value = Math.floor(entry.contentRect.width);
});
onMounted(() => {
if (viewport.value) observer.observe(viewport.value);
});
onBeforeUnmount(() => observer.disconnect());
watch(
() => props.file,
async (file, _, onCleanup) => {
let cancelled = false;
let task: PDFDocumentLoadingTask | undefined;
onCleanup(() => {
cancelled = true;
void task?.destroy().catch(() => {});
});
pdf.value = null;
pageSizes.value = [];
loading.value = true;
error.value = "";
try {
const data = await file.arrayBuffer();
if (cancelled) return;
task = getDocument({
data,
cMapUrl: `${resourceBase}cmaps/`,
cMapPacked: true,
standardFontDataUrl: `${resourceBase}standard_fonts/`,
wasmUrl: `${resourceBase}wasm/`,
iccUrl: `${resourceBase}iccs/`,
});
const document = await task.promise;
if (cancelled) return;
// Read dimensions without rasterizing pages, keeping mixed-size page slots stable.
const sizes: Array<{ width: number; height: number }> = [];
for (let start = 1; start <= document.numPages; start += 20) {
const batch = await Promise.all(
Array.from(
{ length: Math.min(20, document.numPages - start + 1) },
async (_, index) => {
const page = await document.getPage(start + index);
const { width, height } = page.getViewport({ scale: 1 });
return { width, height };
},
),
);
if (cancelled) return;
sizes.push(...batch);
}
pdf.value = document;
pageSizes.value = sizes;
} catch (cause) {
if (cancelled) return;
error.value = tm(
(cause as Error)?.name === "PasswordException"
? "workspaceFiles.pdf.passwordProtected"
: "workspaceFiles.pdf.loadFailed",
);
} finally {
if (!cancelled) loading.value = false;
}
},
{ immediate: true },
);
watch(pageLayouts, async (layouts, previous, onCleanup) => {
let cancelled = false;
onCleanup(() => {
cancelled = true;
});
const container = viewport.value;
if (!container || !layouts.length) return;
const scrollTop = container.scrollTop;
const oldIndex = previous.findIndex(
(layout) => layout.top + layout.height > scrollTop,
);
const index = Math.min(
layouts.length - 1,
oldIndex >= 0 ? oldIndex : Math.max(0, props.page - 1),
);
const offset =
oldIndex >= 0
? (scrollTop - previous[index].top) / previous[index].height
: 0;
await nextTick();
if (cancelled) return;
// Keep the same position within the page when zooming or resizing the panel.
container.scrollTop = Math.max(
0,
layouts[index].top + offset * layouts[index].height,
);
});
function rememberPage() {
if (loading.value || !viewport.value) return;
const top = viewport.value.scrollTop + 16;
const index = pageLayouts.value.findIndex(
(layout) => layout.top + layout.height > top,
);
if (index >= 0 && index + 1 !== props.page) emit("update:page", index + 1);
}
</script>
<style scoped>
.workspace-pdf {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
}
.pdf-scroll {
overflow: auto;
overflow-anchor: none;
scrollbar-gutter: stable;
flex: 1;
min-height: 0;
background: rgba(var(--v-theme-on-surface), 0.04);
}
.pdf-pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: max-content;
min-width: 100%;
padding: 16px 16px 80px;
}
.pdf-zoom-hub {
position: absolute;
right: 20px;
bottom: 16px;
z-index: 1;
display: flex;
align-items: center;
gap: 2px;
padding: 5px 6px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 12px;
background: rgba(var(--v-theme-surface), 0.92);
color: rgb(var(--v-theme-on-surface));
box-shadow: 0 4px 20px #0002;
backdrop-filter: blur(12px);
}
.pdf-zoom-value {
min-width: 50px;
font-variant-numeric: tabular-nums;
}
.pdf-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px 16px;
text-align: center;
font-size: 13px;
color: rgba(var(--v-theme-on-surface), 0.65);
}
</style>
@@ -173,9 +173,9 @@
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
},
"workspaceFiles": {
"title": "Workspace Files",
"open": "Open workspace files",
"close": "Close workspace files",
"title": "Files",
"open": "Open files",
"close": "Close files",
"refresh": "Refresh file tree",
"filter": "Filter files...",
"clearFilter": "Clear file filter",
@@ -188,7 +188,23 @@
"downloadFailed": "Failed to download this file",
"dialogPreview": "Open larger preview",
"closePreview": "Close file preview",
"closeDialogPreview": "Close larger preview"
"closeDialogPreview": "Close larger preview",
"pdf": {
"zoomControls": "PDF zoom",
"zoomOut": "Zoom out",
"zoomIn": "Zoom in",
"fitWidth": "Fit page width",
"loading": "Loading PDF…",
"pageLabel": "Page {page} of {total}",
"passwordProtected": "This PDF is password protected. Download it to open it with a password.",
"loadFailed": "Unable to preview this PDF. You can still download the file.",
"tooLarge": "PDFs larger than 50 MB cannot be previewed. Download the file to view it."
},
"openTabs": "Open files",
"closeTab": "Close {name}",
"showTree": "Show file tree",
"hideTree": "Hide file tree",
"selectFile": "Select a file to preview"
},
"time": {
"today": "Today",
@@ -173,9 +173,9 @@
"confirmDelete": "プロジェクト「{title}」を削除しますか?プロジェクト内の会話は削除されません。"
},
"workspaceFiles": {
"title": "ワークスペースのファイル",
"open": "ワークスペースのファイルを開く",
"close": "ワークスペースのファイルを閉じる",
"title": "ファイル",
"open": "ファイルを開く",
"close": "ファイルパネルを閉じる",
"refresh": "ファイルツリーを再読み込み",
"filter": "ファイルを絞り込み…",
"clearFilter": "ファイルの絞り込みを解除",
@@ -188,7 +188,23 @@
"downloadFailed": "ファイルのダウンロードに失敗しました",
"dialogPreview": "拡大プレビューを開く",
"closePreview": "ファイルプレビューを閉じる",
"closeDialogPreview": "拡大プレビューを閉じる"
"closeDialogPreview": "拡大プレビューを閉じる",
"pdf": {
"zoomControls": "PDF のズーム",
"zoomOut": "縮小",
"zoomIn": "拡大",
"fitWidth": "ページ幅に合わせる",
"loading": "PDF を読み込み中…",
"pageLabel": "{total} ページ中 {page} ページ",
"passwordProtected": "この PDF はパスワードで保護されています。ダウンロードしてパスワードを入力してください。",
"loadFailed": "この PDF をプレビューできません。ファイルをダウンロードして確認してください。",
"tooLarge": "50 MB を超える PDF はプレビューできません。ダウンロードして確認してください。"
},
"openTabs": "開いているファイル",
"closeTab": "{name} を閉じる",
"showTree": "ファイルツリーを表示",
"hideTree": "ファイルツリーを隠す",
"selectFile": "ファイルを選択してプレビュー"
},
"time": {
"today": "今日",
@@ -173,9 +173,9 @@
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"workspaceFiles": {
"title": "Файлы рабочей области",
"open": "Открыть файлы рабочей области",
"close": "Закрыть файлы рабочей области",
"title": "Файлы",
"open": "Открыть файлы",
"close": "Закрыть панель файлов",
"refresh": "Обновить дерево файлов",
"filter": "Фильтр файлов...",
"clearFilter": "Очистить фильтр файлов",
@@ -188,7 +188,23 @@
"downloadFailed": "Не удалось скачать файл",
"dialogPreview": "Открыть увеличенный просмотр",
"closePreview": "Закрыть предпросмотр файла",
"closeDialogPreview": "Закрыть увеличенный просмотр"
"closeDialogPreview": "Закрыть увеличенный просмотр",
"pdf": {
"zoomControls": "Масштаб PDF",
"zoomOut": "Уменьшить",
"zoomIn": "Увеличить",
"fitWidth": "По ширине страницы",
"loading": "Загрузка PDF…",
"pageLabel": "Страница {page} из {total}",
"passwordProtected": "Этот PDF защищён паролем. Скачайте файл и откройте его с паролем.",
"loadFailed": "Не удалось открыть PDF для просмотра. Вы можете скачать файл.",
"tooLarge": "Предпросмотр PDF размером более 50 МБ недоступен. Скачайте файл для просмотра."
},
"openTabs": "Открытые файлы",
"closeTab": "Закрыть {name}",
"showTree": "Показать дерево файлов",
"hideTree": "Скрыть дерево файлов",
"selectFile": "Выберите файл для просмотра"
},
"time": {
"today": "Сегодня",
@@ -173,9 +173,9 @@
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
},
"workspaceFiles": {
"title": "工作区文件",
"open": "打开工作区文件",
"close": "关闭工作区文件",
"title": "文件",
"open": "打开文件",
"close": "关闭文件面板",
"refresh": "刷新文件树",
"filter": "筛选文件...",
"clearFilter": "清除文件筛选",
@@ -188,7 +188,23 @@
"downloadFailed": "下载文件失败",
"dialogPreview": "放大预览",
"closePreview": "关闭文件预览",
"closeDialogPreview": "关闭放大预览"
"closeDialogPreview": "关闭放大预览",
"pdf": {
"zoomControls": "PDF 缩放",
"zoomOut": "缩小",
"zoomIn": "放大",
"fitWidth": "适应页面宽度",
"loading": "正在加载 PDF…",
"pageLabel": "第 {page} 页,共 {total} 页",
"passwordProtected": "此 PDF 受密码保护,请下载后输入密码查看。",
"loadFailed": "无法预览此 PDF,仍可下载文件查看。",
"tooLarge": "超过 50 MB 的 PDF 暂不支持预览,请下载后查看。"
},
"openTabs": "已打开的文件",
"closeTab": "关闭 {name}",
"showTree": "展开文件树",
"hideTree": "收起文件树",
"selectFile": "选择文件开始预览"
},
"time": {
"today": "今天",
+3
View File
@@ -6,6 +6,8 @@ import vuetify from 'vite-plugin-vuetify';
import webfontDl from 'vite-plugin-webfont-dl';
// @ts-ignore — .mjs not in TS project scope; Vite resolves this at runtime
import { runMdiSubset } from './scripts/subset-mdi-font.mjs';
// @ts-ignore — .mjs not in TS project scope; Vite resolves this at runtime
import { pdfjsAssets } from './scripts/pdfjs-assets.mjs';
const t2iShikiRuntimePath = fileURLToPath(
new URL('../astrbot/core/utils/t2i/template/shiki_runtime.iife.js', import.meta.url)
@@ -61,6 +63,7 @@ export default defineConfig(({ command }) => ({
// Only run MDI subsetting during production builds, skip in dev server
...(command === 'build' ? [mdiSubset()] : []),
t2iShikiRuntimeAsset(),
pdfjsAssets(),
vue({
template: {
compilerOptions: {