From 7440ade66dbfbe0f9db03eeb0ed80ca2f6a1853b Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Fri, 8 May 2026 02:47:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(assets):=20=E4=BC=98=E5=8C=96=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E9=A2=84=E8=A7=88=E6=80=A7=E8=83=BD=E5=B9=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86=E5=99=A8=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E7=8A=B6=E6=80=81=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用缩略图替代原图展示,通过 sharp 库生成缓存缩略图 - 优化资产分组逻辑,避免不必要的重计算 - 添加 WebSocket 连接状态提示到输入框 - 使用 `useCallback` 和 `useRef` 优化组件渲染性能 - 添加 AbortController 支持请求取消,防止内存泄漏 - 添加 `disconnected` 事件处理,自动重置会话状态 --- .../src/components/assets/AssetGallery.tsx | 117 ++-- .../src/components/assets/AssetPreview.tsx | 10 +- .../components/assets/AssetProjectGroup.tsx | 11 +- web/client/src/components/chat/ChatInput.tsx | 4 +- web/client/src/components/chat/ChatView.tsx | 4 +- web/client/src/hooks/useAssets.ts | 19 +- web/client/src/hooks/useChat.ts | 6 + web/client/src/lib/api.ts | 4 +- web/client/src/lib/websocket.ts | 1 + web/package-lock.json | 544 +++++++++++++++++- web/package.json | 1 + web/server/agent/pi-bridge.ts | 2 + web/server/routes/assets.ts | 33 +- 13 files changed, 680 insertions(+), 76 deletions(-) diff --git a/web/client/src/components/assets/AssetGallery.tsx b/web/client/src/components/assets/AssetGallery.tsx index bfc7f06..0290287 100644 --- a/web/client/src/components/assets/AssetGallery.tsx +++ b/web/client/src/components/assets/AssetGallery.tsx @@ -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('all'); const [manifests, setManifests] = useState([]); const [previewAsset, setPreviewAsset] = useState(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(); 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 () => { + 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 (acc) parts.push(acc.name); + + 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' }); - window.location.reload(); - }; + const controller = new AbortController(); + fetch('/api/assets/manifests', { signal: controller.signal }) + .then((r) => r.json()) + .then(setManifests) + .catch(() => {}); + refresh(); + }, [refresh]); - const getGroupName = (manifestPath: string) => { - const parts = manifestPath.replace(/\\/g, '/').split('/'); - const dir = parts.length >= 2 ? parts[parts.length - 2] : manifestPath; - return dir; - }; + const handlePreview = useCallback((asset: Asset) => setPreviewAsset(asset), []); + const handleClosePreview = useCallback(() => setPreviewAsset(null), []); - const getGroupSubtitle = (manifestPath: string) => { - const m = manifests.find((m) => m.manifest_path === manifestPath); - if (!m) return ''; - 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 (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]); + 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() {
{Array.from({ length: 3 }).map((_, i) => )}
- ) : filteredGroups.length === 0 ? ( + ) : groupEntries.length === 0 ? (
@@ -160,20 +183,28 @@ export function AssetGallery() {

点击「扫描」从 output 目录导入

) : ( - filteredGroups.map(([manifestPath, groupAssets]) => ( + groupEntries.map((entry) => ( )) )}
- {previewAsset && setPreviewAsset(null)} onNavigate={setPreviewAsset} />} + {previewAsset && ( + + )} ); } diff --git a/web/client/src/components/assets/AssetPreview.tsx b/web/client/src/components/assets/AssetPreview.tsx index e83cd09..c0376a6 100644 --- a/web/client/src/components/assets/AssetPreview.tsx +++ b/web/client/src/components/assets/AssetPreview.tsx @@ -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) => { diff --git a/web/client/src/components/assets/AssetProjectGroup.tsx b/web/client/src/components/assets/AssetProjectGroup.tsx index ea1c6f0..dd70f27 100644 --- a/web/client/src/components/assets/AssetProjectGroup.tsx +++ b/web/client/src/components/assets/AssetProjectGroup.tsx @@ -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 (
@@ -43,7 +44,7 @@ export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete > {asset.type === 'image' ? (
); -} +}); diff --git a/web/client/src/components/chat/ChatInput.tsx b/web/client/src/components/chat/ChatInput.tsx index 33cab9e..ca26459 100644 --- a/web/client/src/components/chat/ChatInput.tsx +++ b/web/client/src/components/chat/ChatInput.tsx @@ -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' }} diff --git a/web/client/src/components/chat/ChatView.tsx b/web/client/src/components/chat/ChatView.tsx index d5f56c1..00df73a 100644 --- a/web/client/src/components/chat/ChatView.tsx +++ b/web/client/src/components/chat/ChatView.tsx @@ -150,7 +150,7 @@ export function ChatView() {

开始新对话

输入消息开始创作,对话将自动创建

- + {accounts.length > 0 && (
当前账号: @@ -275,7 +275,7 @@ export function ChatView() { )} {/* Input */} - +
diff --git a/web/client/src/hooks/useAssets.ts b/web/client/src/hooks/useAssets.ts index 971f5cc..9c350a1 100644 --- a/web/client/src/hooks/useAssets.ts +++ b/web/client/src/hooks/useAssets.ts @@ -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([]); const [loading, setLoading] = useState(true); + const abortRef = useRef(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 }; } diff --git a/web/client/src/hooks/useChat.ts b/web/client/src/hooks/useChat.ts index de99353..894c1c2 100644 --- a/web/client/src/hooks/useChat.ts +++ b/web/client/src/hooks/useChat.ts @@ -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) => { 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); diff --git a/web/client/src/lib/api.ts b/web/client/src/lib/api.ts index decd6c7..10de91f 100644 --- a/web/client/src/lib/api.ts +++ b/web/client/src/lib/api.ts @@ -40,11 +40,11 @@ export const api = { request(`/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(`/assets?${qs}`); + return request(`/assets?${qs}`, { signal }); }, deleteAsset: (id: string) => request(`/assets/${id}`, { method: 'DELETE' }), diff --git a/web/client/src/lib/websocket.ts b/web/client/src/lib/websocket.ts index bbc7b22..edc8dc5 100644 --- a/web/client/src/lib/websocket.ts +++ b/web/client/src/lib/websocket.ts @@ -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); } diff --git a/web/package-lock.json b/web/package-lock.json index e1f8394..8e1311b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 34d0cfb..edefae3 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/server/agent/pi-bridge.ts b/web/server/agent/pi-bridge.ts index 93d56de..00bc881 100644 --- a/web/server/agent/pi-bridge.ts +++ b/web/server/agent/pi-bridge.ts @@ -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' } })); } } diff --git a/web/server/routes/assets.ts b/web/server/routes/assets.ts index 6ff42b4..1119a00 100644 --- a/web/server/routes/assets.ts +++ b/web/server/routes/assets.ts @@ -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); + } +});