feat(assets): 优化资产预览性能并添加资源管理器连接状态提示

- 使用缩略图替代原图展示,通过 sharp 库生成缓存缩略图
- 优化资产分组逻辑,避免不必要的重计算
- 添加 WebSocket 连接状态提示到输入框
- 使用 `useCallback` 和 `useRef` 优化组件渲染性能
- 添加 AbortController 支持请求取消,防止内存泄漏
- 添加 `disconnected` 事件处理,自动重置会话状态
This commit is contained in:
2026-05-08 02:47:23 +08:00
parent a92379e20e
commit 7440ade66d
13 changed files with 680 additions and 76 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { RefreshCw, FolderOpen, Image as ImageIcon, Film } from 'lucide-react';
import { useAssets } from '@/hooks/useAssets';
import { useAccounts } from '@/hooks/useAccounts';
@@ -16,6 +16,13 @@ interface ManifestGroup {
latest_at: string;
}
interface GroupEntry {
manifestPath: string;
assets: Asset[];
title: string;
subtitle: string;
}
type TypeTab = 'all' | 'image' | 'video';
function AssetGroupSkeleton() {
@@ -34,18 +41,28 @@ function AssetGroupSkeleton() {
);
}
function getGroupName(manifestPath: string) {
const parts = manifestPath.replace(/\\/g, '/').split('/');
return parts.length >= 2 ? parts[parts.length - 2] : manifestPath;
}
export function AssetGallery() {
const [accountFilter, setAccountFilter] = useState('');
const [typeTab, setTypeTab] = useState<TypeTab>('all');
const [manifests, setManifests] = useState<ManifestGroup[]>([]);
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
const { accounts } = useAccounts();
const { assets, loading, remove } = useAssets({
const { assets, loading, refresh, remove } = useAssets({
accountId: accountFilter || undefined,
});
useEffect(() => {
fetch('/api/assets/manifests').then((r) => r.json()).then(setManifests).catch(() => {});
const controller = new AbortController();
fetch('/api/assets/manifests', { signal: controller.signal })
.then((r) => r.json())
.then(setManifests)
.catch(() => {});
return () => controller.abort();
}, []);
const filtered = useMemo(() => {
@@ -53,49 +70,55 @@ export function AssetGallery() {
return assets.filter((a) => a.type === typeTab);
}, [assets, typeTab]);
const grouped = useMemo(() => {
const imageCount = useMemo(() => assets.filter((a) => a.type === 'image').length, [assets]);
const videoCount = useMemo(() => assets.filter((a) => a.type === 'video').length, [assets]);
const groupEntries = useMemo(() => {
const map = new Map<string, Asset[]>();
for (const asset of filtered) {
const key = asset.manifest_path || 'ungrouped';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(asset);
}
return map;
}, [filtered]);
const imageCount = assets.filter((a) => a.type === 'image').length;
const videoCount = assets.filter((a) => a.type === 'video').length;
const entries: GroupEntry[] = [];
for (const [manifestPath, groupAssets] of map) {
// Filter by account
if (accountFilter && manifestPath !== 'ungrouped') {
const m = manifests.find((x) => x.manifest_path === manifestPath);
if (m?.account_id !== accountFilter) continue;
}
const handleScan = async () => {
await fetch('/api/assets/scan', { method: 'POST' });
window.location.reload();
};
const getGroupName = (manifestPath: string) => {
const parts = manifestPath.replace(/\\/g, '/').split('/');
const dir = parts.length >= 2 ? parts[parts.length - 2] : manifestPath;
return dir;
};
const getGroupSubtitle = (manifestPath: string) => {
const m = manifests.find((m) => m.manifest_path === manifestPath);
if (!m) return '';
const title = getGroupName(manifestPath);
const m = manifests.find((x) => x.manifest_path === manifestPath);
const parts: string[] = [];
if (m.image_count) parts.push(`${m.image_count}`);
if (m.video_count) parts.push(`${m.video_count} 视频`);
const acc = accounts.find((a) => a.id === m.account_id);
if (m?.image_count) parts.push(`${m.image_count}`);
if (m?.video_count) parts.push(`${m.video_count} 视频`);
const acc = accounts.find((a) => a.id === m?.account_id);
if (acc) parts.push(acc.name);
return parts.join(' · ');
};
const filteredGroups = useMemo(() => {
if (!accountFilter) return [...grouped.entries()];
return [...grouped.entries()].filter(([key]) => {
if (key === 'ungrouped') return true;
const m = manifests.find((m) => m.manifest_path === key);
return m?.account_id === accountFilter;
});
}, [grouped, manifests, accountFilter]);
entries.push({ manifestPath, assets: groupAssets, title, subtitle: parts.join(' · ') });
}
return entries;
}, [filtered, manifests, accounts, accountFilter]);
const handleScan = useCallback(async () => {
await fetch('/api/assets/scan', { method: 'POST' });
const controller = new AbortController();
fetch('/api/assets/manifests', { signal: controller.signal })
.then((r) => r.json())
.then(setManifests)
.catch(() => {});
refresh();
}, [refresh]);
const handlePreview = useCallback((asset: Asset) => setPreviewAsset(asset), []);
const handleClosePreview = useCallback(() => setPreviewAsset(null), []);
const previewIndex = useMemo(() => {
if (!previewAsset) return -1;
return filtered.findIndex((a) => a.id === previewAsset.id);
}, [previewAsset, filtered]);
const tabs: { key: TypeTab; label: string; icon: typeof ImageIcon; count: number }[] = [
{ key: 'all', label: '全部', icon: FolderOpen, count: assets.length },
@@ -151,7 +174,7 @@ export function AssetGallery() {
<div>
{Array.from({ length: 3 }).map((_, i) => <AssetGroupSkeleton key={i} />)}
</div>
) : filteredGroups.length === 0 ? (
) : groupEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center mt-16 text-zinc-300">
<div className="w-16 h-16 rounded-2xl bg-zinc-50 flex items-center justify-center mb-4">
<FolderOpen size={24} strokeWidth={1.5} className="text-zinc-300" />
@@ -160,20 +183,28 @@ export function AssetGallery() {
<p className="text-xs mt-1 text-zinc-300"> output </p>
</div>
) : (
filteredGroups.map(([manifestPath, groupAssets]) => (
groupEntries.map((entry) => (
<AssetProjectGroup
key={manifestPath}
title={getGroupName(manifestPath)}
subtitle={getGroupSubtitle(manifestPath)}
assets={groupAssets}
onPreview={setPreviewAsset}
key={entry.manifestPath}
title={entry.title}
subtitle={entry.subtitle}
assets={entry.assets}
onPreview={handlePreview}
onDelete={remove}
/>
))
)}
</div>
{previewAsset && <AssetPreview asset={previewAsset} allAssets={filtered} onClose={() => setPreviewAsset(null)} onNavigate={setPreviewAsset} />}
{previewAsset && (
<AssetPreview
asset={previewAsset}
allAssets={filtered}
currentIndex={previewIndex}
onClose={handleClosePreview}
onNavigate={handlePreview}
/>
)}
</div>
);
}

View File

@@ -1,23 +1,25 @@
import { useEffect, useCallback } from 'react';
import { useEffect, useCallback, useRef } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
import type { Asset } from '@/types';
interface Props {
asset: Asset;
allAssets: Asset[];
currentIndex: number;
onClose: () => void;
onNavigate: (asset: Asset) => void;
}
export function AssetPreview({ asset, allAssets, onClose, onNavigate }: Props) {
const currentIndex = allAssets.findIndex((a) => a.id === asset.id);
export function AssetPreview({ asset, allAssets, currentIndex, onClose, onNavigate }: Props) {
const lengthRef = useRef(allAssets.length);
lengthRef.current = allAssets.length;
const goPrev = useCallback(() => {
if (currentIndex > 0) onNavigate(allAssets[currentIndex - 1]);
}, [currentIndex, allAssets, onNavigate]);
const goNext = useCallback(() => {
if (currentIndex < allAssets.length - 1) onNavigate(allAssets[currentIndex + 1]);
if (currentIndex < lengthRef.current - 1) onNavigate(allAssets[currentIndex + 1]);
}, [currentIndex, allAssets, onNavigate]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {

View File

@@ -1,3 +1,4 @@
import { memo, useMemo } from 'react';
import { Trash2, Film, Image as ImageIcon } from 'lucide-react';
import type { Asset } from '@/types';
@@ -9,9 +10,9 @@ interface Props {
onDelete: (id: string) => void;
}
export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete }: Props) {
const images = assets.filter((a) => a.type === 'image');
const videos = assets.filter((a) => a.type === 'video');
export const AssetProjectGroup = memo(function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete }: Props) {
const images = useMemo(() => assets.filter((a) => a.type === 'image'), [assets]);
const videos = useMemo(() => assets.filter((a) => a.type === 'video'), [assets]);
return (
<div className="mb-6">
@@ -43,7 +44,7 @@ export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete
>
{asset.type === 'image' ? (
<img
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
src={`/api/assets/thumb?path=${encodeURIComponent(asset.file_path)}&size=200`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
@@ -75,4 +76,4 @@ export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete
</div>
</div>
);
}
});

View File

@@ -15,7 +15,7 @@ export interface ImageAttachment {
preview: string;
}
export function ChatInput({ onSend, disabled }: { onSend: (content: string, images?: Array<{ data: string; mimeType: string }>) => void; disabled?: boolean }) {
export function ChatInput({ onSend, disabled, connecting }: { onSend: (content: string, images?: Array<{ data: string; mimeType: string }>) => void; disabled?: boolean; connecting?: boolean }) {
const [input, setInput] = useState('');
const [showCmds, setShowCmds] = useState(false);
const [cmdIdx, setCmdIdx] = useState(0);
@@ -186,7 +186,7 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string, imag
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder={disabled ? '等待回复中...' : '输入消息...'}
placeholder={disabled ? (connecting ? '连接中...' : '等待回复中...') : '输入消息...'}
className="flex-1 bg-transparent text-[15px] resize-none outline-none placeholder:text-zinc-300 text-zinc-800 min-h-[40px] max-h-[140px] py-2 leading-relaxed"
disabled={disabled}
style={{ height: 'auto' }}

View File

@@ -150,7 +150,7 @@ export function ChatView() {
<h2 className="text-lg font-semibold text-zinc-800"></h2>
<p className="text-sm text-zinc-400 mt-1"></p>
</div>
<ChatInput onSend={handleSend} disabled={thinking || creatingRef.current} />
<ChatInput onSend={handleSend} disabled={thinking || !connected || creatingRef.current} connecting={!connected} />
{accounts.length > 0 && (
<div className="flex items-center gap-2 mt-3 justify-center">
<span className="text-xs text-zinc-400">:</span>
@@ -275,7 +275,7 @@ export function ChatView() {
)}
{/* Input */}
<ChatInput onSend={handleSend} disabled={thinking} />
<ChatInput onSend={handleSend} disabled={thinking || !connected} connecting={!connected} />
</div>
</div>
</div>

View File

@@ -1,19 +1,28 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { api } from '@/lib/api';
import type { Asset } from '@/types';
export function useAssets(params?: { accountId?: string; type?: string }) {
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
const abortRef = useRef<AbortController | null>(null);
const accountId = params?.accountId;
const type = params?.type;
const refresh = useCallback(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
api.listAssets(params).then(setAssets).finally(() => setLoading(false));
}, [params?.accountId, params?.type]);
api.listAssets({ accountId, type }, controller.signal)
.then(setAssets)
.catch((err) => { if (err.name !== 'AbortError') throw err; })
.finally(() => { if (controller === abortRef.current) setLoading(false); });
}, [accountId, type]);
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => { refresh(); return () => { abortRef.current?.abort(); }; }, [refresh]);
const remove = (id: string) => api.deleteAsset(id).then(refresh);
const remove = useCallback((id: string) => api.deleteAsset(id).then(refresh), [refresh]);
return { assets, loading, refresh, remove };
}

View File

@@ -24,6 +24,10 @@ export function useChat(conversationId: string | null) {
useEffect(() => {
const onConnected = () => setConnected(true);
const onDisconnected = () => {
setConnected(false);
activeConvRef.current = null;
};
const onHistory = (data: Record<string, unknown>) => {
setMessages((data.messages as Message[]) || []);
};
@@ -96,6 +100,7 @@ export function useChat(conversationId: string | null) {
};
chatSocket.on('connected', onConnected);
chatSocket.on('disconnected', onDisconnected);
chatSocket.on('history', onHistory);
chatSocket.on('message', onMessage);
chatSocket.on('status', onStatus);
@@ -112,6 +117,7 @@ export function useChat(conversationId: string | null) {
return () => {
chatSocket.off('connected', onConnected);
chatSocket.off('disconnected', onDisconnected);
chatSocket.off('history', onHistory);
chatSocket.off('message', onMessage);
chatSocket.off('status', onStatus);

View File

@@ -40,11 +40,11 @@ export const api = {
request<void>(`/pipeline/messages/${id}`, { method: 'DELETE' }),
// Assets
listAssets: (params?: { accountId?: string; type?: string }) => {
listAssets: (params?: { accountId?: string; type?: string }, signal?: AbortSignal) => {
const qs = new URLSearchParams();
if (params?.accountId) qs.set('accountId', params.accountId);
if (params?.type) qs.set('type', params.type);
return request<Asset[]>(`/assets?${qs}`);
return request<Asset[]>(`/assets?${qs}`, { signal });
},
deleteAsset: (id: string) => request<void>(`/assets/${id}`, { method: 'DELETE' }),

View File

@@ -16,6 +16,7 @@ class ChatSocket {
try { const { type, data } = JSON.parse(event.data); this.emit(type, data); } catch {}
};
this.ws.onclose = () => {
this.emit('disconnected', {});
if (!this.intentionallyClosed) {
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
}

544
web/package-lock.json generated
View File

@@ -18,6 +18,7 @@
"express": "^4.21.0",
"multer": "^2.1.1",
"openai": "^6.36.0",
"sharp": "^0.34.5",
"ws": "^8.18.0"
},
"devDependencies": {
@@ -932,6 +933,16 @@
}
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -1398,6 +1409,471 @@
}
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@mariozechner/clipboard": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.5.tgz",
@@ -4544,18 +5020,6 @@
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -5041,6 +5505,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -5101,6 +5577,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shell-exec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/shell-exec/-/shell-exec-1.0.2.tgz",

View File

@@ -21,6 +21,7 @@
"express": "^4.21.0",
"multer": "^2.1.1",
"openai": "^6.36.0",
"sharp": "^0.34.5",
"ws": "^8.18.0"
},
"devDependencies": {

View File

@@ -92,6 +92,8 @@ export async function runAgentChat(ws: WebSocket, convId: string, userContent: s
type: 'message',
data: { id: '', role: 'assistant', content: `抱歉,出错了:${errMsg}` },
}));
} finally {
ws.send(JSON.stringify({ type: 'status', data: { status: 'done' } }));
}
}

View File

@@ -76,7 +76,8 @@ assetsRouter.post('/scan', async (_req, res) => {
manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
} catch { continue; }
const accountId = manifest.account?.id || '';
const accountId = typeof manifest.account === 'string' ? manifest.account
: manifest.account?.id || '';
// Scan images
const imagesDir = path.join(outputDir, dir.name, 'images');
@@ -131,5 +132,35 @@ assetsRouter.get('/file', (req, res) => {
const fullPath = path.resolve(PROJECT_ROOT, filePath);
if (!fullPath.startsWith(PROJECT_ROOT)) return res.status(403).send('Forbidden');
if (!fss.existsSync(fullPath)) return res.status(404).send('Not found');
// Cache static assets for 1 hour
res.setHeader('Cache-Control', 'public, max-age=3600');
res.sendFile(fullPath);
});
// Serve resized thumbnail for images
assetsRouter.get('/thumb', async (req, res) => {
const filePath = req.query.path as string;
const size = Math.min(parseInt(req.query.size as string) || 200, 400);
if (!filePath) return res.status(400).send('Missing path');
const fullPath = path.resolve(PROJECT_ROOT, filePath);
if (!fullPath.startsWith(PROJECT_ROOT)) return res.status(403).send('Forbidden');
if (!fss.existsSync(fullPath)) return res.status(404).send('Not found');
// For non-images, redirect to full file
if (!/\.(jpe?g|png|webp)$/i.test(fullPath)) {
return res.redirect(`/api/assets/file?path=${encodeURIComponent(filePath)}`);
}
try {
const sharp = (await import('sharp')).default;
const buffer = await sharp(fullPath)
.resize(size, size, { fit: 'cover' })
.jpeg({ quality: 70 })
.toBuffer();
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('Content-Type', 'image/jpeg');
res.send(buffer);
} catch {
res.sendFile(fullPath);
}
});