From 91b0ff604d71be69af5a7da89f0e9b25c3f45a7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:06:58 +0000 Subject: [PATCH] Add Immich photo storage backend, demo page, and connection settings --- client/src/App.tsx | 2 + client/src/components/app-sidebar.tsx | 20 +- client/src/lib/page-title.ts | 1 + client/src/pages/immich-demo.tsx | 290 ++++++++++++++++++++++++++ client/src/pages/immich-settings.tsx | 193 +++++++++++++++++ client/src/pages/settings-layout.tsx | 8 +- server/routes.ts | 221 ++++++++++++++++++++ 7 files changed, 732 insertions(+), 3 deletions(-) create mode 100644 client/src/pages/immich-demo.tsx create mode 100644 client/src/pages/immich-settings.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index f3314c2..ffa1abf 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -35,6 +35,7 @@ import AiChatDemo from "@/pages/ai-chat-demo"; import ImageDetailPage from "@/pages/image-detail"; import DailyNotesList from "@/pages/daily-notes"; import DailyNoteDetail from "@/pages/daily-note-detail"; +import ImmichDemo from "@/pages/immich-demo"; import NotFound from "@/pages/not-found"; const SEEN_EXPORTS_KEY = "seen_completed_export_task_ids"; @@ -116,6 +117,7 @@ function Router() { + diff --git a/client/src/components/app-sidebar.tsx b/client/src/components/app-sidebar.tsx index b350626..694cd94 100644 --- a/client/src/components/app-sidebar.tsx +++ b/client/src/components/app-sidebar.tsx @@ -14,9 +14,11 @@ import { Sparkles, MessagesSquare, BookOpen, + ImagePlus, } from "lucide-react"; import { Link, useLocation } from "wouter"; import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Sidebar, SidebarContent, @@ -106,6 +108,22 @@ export function AppSidebar() { const { user, logoutMutation } = useAuth(); const [theme, setTheme] = useState<"light" | "dark">("light"); + const { data: immichSettings } = useQuery<{ enabled: boolean }>({ + queryKey: ["/api/immich/settings"], + enabled: !!user, + }); + + const visibleMenuItems = immichSettings?.enabled + ? [ + ...menuItems, + { + title: "Immich Demo", + url: "/immich-demo", + icon: ImagePlus, + }, + ] + : menuItems; + useEffect(() => { const savedTheme = localStorage.getItem("theme") as "light" | "dark" | null; const initialTheme = savedTheme || "light"; @@ -135,7 +153,7 @@ export function AppSidebar() { PRM 2.0 - {menuItems.map((item) => ( + {visibleMenuItems.map((item) => ( for direct-from-Immich image fetching. The browser does +// not let us set custom headers on a plain tag, so we fetch the image +// with the API key, turn it into a blob URL, and feed that to the . +function ImmichImg({ + url, + apiKey, + assetId, + size = "preview", + alt, + className, +}: { + url: string; + apiKey: string; + assetId: string; + size?: "thumbnail" | "preview"; + alt: string; + className?: string; +}) { + const [src, setSrc] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + let objectUrl: string | null = null; + setError(false); + setSrc(null); + (async () => { + try { + const resp = await fetch(`${url}/api/assets/${assetId}/thumbnail?size=${size}`, { + headers: { "x-api-key": apiKey, Accept: "image/*" }, + }); + if (!resp.ok) { + if (!cancelled) setError(true); + return; + } + const blob = await resp.blob(); + objectUrl = URL.createObjectURL(blob); + if (!cancelled) { + setSrc(objectUrl); + } else { + URL.revokeObjectURL(objectUrl); + } + } catch { + if (!cancelled) setError(true); + } + })(); + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [url, apiKey, assetId, size]); + + if (error) { + return ( +
+ +
+ ); + } + if (!src) { + return ( +
+ +
+ ); + } + return {alt}; +} + +export default function ImmichDemoPage() { + const { toast } = useToast(); + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + + const { data: clientConfig, isLoading: configLoading } = useQuery({ + queryKey: ["/api/immich/client-config"], + }); + + const { + data: assetsData, + isLoading: assetsLoading, + refetch: refetchAssets, + error: assetsError, + } = useQuery<{ assets: ImmichAsset[] }>({ + queryKey: ["/api/immich/assets"], + enabled: !!clientConfig?.enabled, + }); + + const handleUpload = useCallback( + async (file: File) => { + setIsUploading(true); + try { + const formData = new FormData(); + formData.append("image", file); + const res = await fetch("/api/immich/upload", { + method: "POST", + body: formData, + credentials: "include", + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to upload"); + toast({ + title: data.duplicate ? "Already in Immich" : "Uploaded to Immich", + description: data.duplicate + ? "Immich detected this as a duplicate." + : `Asset ${data.assetId} stored.`, + }); + await refetchAssets(); + } catch (err: any) { + toast({ title: "Upload failed", description: err.message, variant: "destructive" }); + } finally { + setIsUploading(false); + } + }, + [refetchAssets, toast] + ); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + void handleUpload(file); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files?.[0]; + if (!file || !file.type.startsWith("image/")) return; + void handleUpload(file); + }; + + if (configLoading) { + return ( +
+ +
+ ); + } + + if (!clientConfig?.enabled) { + return ( +
+

+ + Immich Demo +

+ + +

+ The Immich demo is not enabled. Configure and enable it on the{" "} + + Immich Connection + {" "} + settings page. +

+
+
+
+ ); + } + + const assets = assetsData?.assets ?? []; + + return ( +
+
+
+

+ + Immich Demo +

+

+ Upload photos through the PRM middleware to your Immich server. Photos are then fetched + directly from Immich by your browser, bypassing the PRM server. +

+
+ +
+ +
fileInputRef.current?.click()} + onDrop={handleDrop} + onDragOver={(e) => e.preventDefault()} + data-testid="dropzone-immich-upload" + > + + {isUploading ? ( + <> + +

Uploading to Immich…

+ + ) : ( + <> +
+ +
+
+

Drop an image here to upload to Immich

+

or click to browse

+
+ + )} +
+ +
+
+

Photos in Immich

+ + {assets.length} loaded + +
+ {assetsError ? ( + + +

+ Could not load assets from Immich: {(assetsError as Error).message} +

+
+
+ ) : assetsLoading ? ( + + ) : assets.length === 0 ? ( + + + No photos found in Immich yet. Upload one above to get started. + + + ) : ( +
+ {assets.map((a) => ( +
+ +
+ ))} +
+ )} +
+
+ ); +} diff --git a/client/src/pages/immich-settings.tsx b/client/src/pages/immich-settings.tsx new file mode 100644 index 0000000..7bf5146 --- /dev/null +++ b/client/src/pages/immich-settings.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Loader2, Save, ImagePlus, CheckCircle2, AlertCircle } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { queryClient } from "@/lib/queryClient"; + +type ImmichSettings = { + enabled: boolean; + url: string; + hasApiKey: boolean; +}; + +export default function ImmichSettingsPage() { + const { toast } = useToast(); + const { data, isLoading } = useQuery({ + queryKey: ["/api/immich/settings"], + }); + + const [enabled, setEnabled] = useState(false); + const [url, setUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [hasStoredKey, setHasStoredKey] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); + + useEffect(() => { + if (!data) return; + setEnabled(data.enabled); + setUrl(data.url); + setHasStoredKey(data.hasApiKey); + }, [data]); + + const handleSaveAndTest = async () => { + setIsSaving(true); + setTestResult(null); + try { + // Save first (only send apiKey if user typed one). + const savePayload: Record = { enabled, url }; + if (apiKey.length > 0) savePayload.apiKey = apiKey; + const saveRes = await fetch("/api/immich/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(savePayload), + credentials: "include", + }); + if (!saveRes.ok) { + const err = await saveRes.json().catch(() => ({})); + throw new Error(err.error || "Failed to save settings"); + } + + // Then test against the now-saved values. + const testRes = await fetch("/api/immich/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + credentials: "include", + }); + const testData = (await testRes.json()) as { ok: boolean; message: string }; + setTestResult(testData); + + if (apiKey.length > 0) { + setApiKey(""); + setHasStoredKey(true); + } + // Refresh queries that depend on these settings. + queryClient.invalidateQueries({ queryKey: ["/api/immich/settings"] }); + queryClient.invalidateQueries({ queryKey: ["/api/immich/client-config"] }); + queryClient.invalidateQueries({ queryKey: ["/api/immich/assets"] }); + + toast({ + title: testData.ok ? "Settings saved" : "Saved, but test failed", + description: testData.message, + variant: testData.ok ? "default" : "destructive", + }); + } catch (err: any) { + toast({ title: "Save failed", description: err.message, variant: "destructive" }); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+

+ + Immich Connection +

+

+ Configure the connection to your Immich server. When enabled, the Immich Demo page is + available from the main menu. +

+
+ + + + Connection + + Photo uploads go through PRM, but image downloads are made directly from your browser to + Immich. + + + + {isLoading ? ( + + ) : ( + <> +
+
+ +

+ Adds an "Immich Demo" entry to the main menu. +

+
+ +
+ +
+ + setUrl(e.target.value)} + data-testid="input-immich-url" + /> +

+ Base URL of your Immich server (no trailing slash). +

+
+ +
+ + setApiKey(e.target.value)} + autoComplete="off" + data-testid="input-immich-api-key" + /> +

+ Create an API key in Immich under Account Settings → API Keys. +

+
+ +
+ + {testResult && ( + + {testResult.ok ? ( + + ) : ( + + )} + {testResult.message} + + )} +
+ + )} +
+
+
+ ); +} diff --git a/client/src/pages/settings-layout.tsx b/client/src/pages/settings-layout.tsx index bea85a4..7d1da3c 100644 --- a/client/src/pages/settings-layout.tsx +++ b/client/src/pages/settings-layout.tsx @@ -1,5 +1,5 @@ import { Route, Switch, Link, useLocation, Redirect } from "wouter"; -import { ArrowLeft, User, Settings, Heart, Book, MessageSquare, Key, AtSign, Trash2, FolderSync, Users, Share2, Database, ChevronRight, Camera, ImageIcon, ListTodo, Layers, HardDrive, Chrome, Scan, ScanFace, Network, Table2, BrainCircuit, Wrench } from "lucide-react"; +import { ArrowLeft, User, Settings, Heart, Book, MessageSquare, Key, AtSign, Trash2, FolderSync, Users, Share2, Database, ChevronRight, Camera, ImageIcon, ListTodo, Layers, HardDrive, Chrome, Scan, ScanFace, Network, Table2, BrainCircuit, Wrench, ImagePlus } from "lucide-react"; import { Sidebar, SidebarContent, @@ -47,6 +47,7 @@ import IntelligenceSettingsPage from "@/pages/intelligence-settings"; import IntelligenceToolsSettingsPage from "@/pages/intelligence-tools-settings"; import IntelligenceImagesSettingsPage from "@/pages/intelligence-images-settings"; import VectorSettingsPage from "@/pages/vector-settings"; +import ImmichSettingsPage from "@/pages/immich-settings"; import TaskDetailPage from "@/pages/task-detail"; import NotFound from "@/pages/not-found"; @@ -76,6 +77,7 @@ const settingsMenuItems: MenuItem[] = [ { title: "Social Graph", url: "/social-graph", icon: Network }, { title: "Chrome Extension", url: "/chrome-extension", icon: Chrome }, { title: "API Settings", url: "/api-settings", icon: Key }, + { title: "Immich Connection", url: "/immich", icon: ImagePlus }, ], }, { @@ -158,7 +160,8 @@ function SettingsSidebar() { location.startsWith("/app") || location.startsWith("/social-graph") || location === "/chrome-extension" || - location === "/api-settings"; + location === "/api-settings" || + location === "/immich"; const isImageStorageActive = location.startsWith("/image-storage") && location !== "/image-storage/tasks"; const isIntelligenceActive = location.startsWith("/intelligence") || location === "/vector"; const isTasksActive = location.startsWith("/tasks") || location === "/image-tasks" || location === "/image-storage/tasks"; @@ -289,6 +292,7 @@ export default function SettingsLayout() { + diff --git a/server/routes.ts b/server/routes.ts index cfe2506..04ef5c0 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -5790,6 +5790,227 @@ export async function registerRoutes(app: Express): Promise { } }); + // ── Immich photo storage backend ────────────────────────────────────────── + // Settings are stored in the app_settings key-value table, reusing the + // getOllamaSetting/setOllamaSetting helpers above (the helpers are generic). + + function normalizeImmichBaseUrl(url: string): string { + return url.trim().replace(/\/+$/, ""); + } + + async function getImmichConfig(): Promise<{ enabled: boolean; url: string; apiKey: string }> { + const enabled = (await getOllamaSetting("immich_enabled")) === "true"; + const url = normalizeImmichBaseUrl((await getOllamaSetting("immich_url")) ?? ""); + const apiKey = (await getOllamaSetting("immich_api_key")) ?? ""; + return { enabled, url, apiKey }; + } + + async function pingImmich(url: string, apiKey: string): Promise<{ ok: boolean; message: string }> { + if (!url) return { ok: false, message: "No Immich URL configured." }; + if (!apiKey) return { ok: false, message: "No Immich API key configured." }; + const base = normalizeImmichBaseUrl(url); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + try { + // /api/users/me requires a valid x-api-key, so this is a good + // combined auth + connectivity check. + const resp = await fetch(`${base}/api/users/me`, { + headers: { "x-api-key": apiKey, Accept: "application/json" }, + signal: controller.signal, + }); + clearTimeout(timeout); + if (resp.ok) { + const data = (await resp.json().catch(() => ({}))) as { email?: string; name?: string }; + const who = data.email || data.name || "user"; + return { ok: true, message: `Connected to Immich as ${who}.` }; + } + const detail = (await resp.text().catch(() => "")).trim().slice(0, 200); + return { ok: false, message: `Immich returned ${resp.status}${detail ? ` — ${detail}` : ""}` }; + } catch (err: any) { + clearTimeout(timeout); + if (err?.name === "AbortError") return { ok: false, message: "Request timed out after 8 seconds." }; + return { ok: false, message: `Failed to reach Immich: ${err?.message ?? err}` }; + } + } + + app.get("/api/immich/settings", async (req, res) => { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + try { + const enabled = (await getOllamaSetting("immich_enabled")) ?? "false"; + const url = (await getOllamaSetting("immich_url")) ?? ""; + const hasApiKey = !!(await getOllamaSetting("immich_api_key")); + res.json({ enabled: enabled === "true", url, hasApiKey }); + } catch (error) { + res.status(500).json({ error: "Failed to fetch Immich settings" }); + } + }); + + app.post("/api/immich/settings", async (req, res) => { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + const { enabled, url, apiKey } = req.body ?? {}; + try { + if (typeof enabled === "boolean") await setOllamaSetting("immich_enabled", String(enabled)); + if (typeof url === "string") await setOllamaSetting("immich_url", normalizeImmichBaseUrl(url)); + if (typeof apiKey === "string" && apiKey.length > 0) await setOllamaSetting("immich_api_key", apiKey); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: "Failed to save Immich settings" }); + } + }); + + // Save (if values provided) and run a connectivity test against Immich. + app.post("/api/immich/test", async (req, res) => { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + try { + const reqUrl = typeof req.body?.url === "string" ? req.body.url : null; + const reqKey = typeof req.body?.apiKey === "string" && req.body.apiKey.length > 0 ? req.body.apiKey : null; + const url = reqUrl !== null ? normalizeImmichBaseUrl(reqUrl) : normalizeImmichBaseUrl((await getOllamaSetting("immich_url")) ?? ""); + const apiKey = reqKey ?? ((await getOllamaSetting("immich_api_key")) ?? ""); + const result = await pingImmich(url, apiKey); + res.json(result); + } catch (error: any) { + res.status(500).json({ ok: false, message: error?.message ?? "Test failed" }); + } + }); + + // Returns the URL + API key the client needs to call Immich directly for + // image retrieval. Only returned to authenticated users when Immich is + // enabled and configured. + app.get("/api/immich/client-config", async (req, res) => { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + try { + const { enabled, url, apiKey } = await getImmichConfig(); + if (!enabled || !url || !apiKey) { + return res.json({ enabled: false, url: "", apiKey: "" }); + } + res.json({ enabled: true, url, apiKey }); + } catch (error) { + res.status(500).json({ error: "Failed to fetch Immich client config" }); + } + }); + + // List recent IMAGE assets via Immich search. Proxies through middleware so + // the API key is not required to list, but the actual image bytes are + // fetched directly from Immich by the client (see /api/immich/client-config). + app.get("/api/immich/assets", async (req, res) => { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + try { + const { enabled, url, apiKey } = await getImmichConfig(); + if (!enabled) return res.status(400).json({ error: "Immich demo is not enabled" }); + if (!url || !apiKey) return res.status(400).json({ error: "Immich is not configured" }); + const size = Math.min(Number(req.query.size) || 100, 500); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + try { + const resp = await fetch(`${url}/api/search/metadata`, { + method: "POST", + headers: { "x-api-key": apiKey, "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ type: "IMAGE", size, page: 1, order: "desc" }), + signal: controller.signal, + }); + clearTimeout(timeout); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + return res.status(502).json({ error: `Immich returned ${resp.status}: ${text.slice(0, 200)}` }); + } + const data = (await resp.json()) as any; + const items: any[] = data?.assets?.items ?? data?.items ?? []; + const assets = items.map((a) => ({ + id: a.id, + originalFileName: a.originalFileName ?? null, + fileCreatedAt: a.fileCreatedAt ?? null, + type: a.type ?? null, + })); + res.json({ assets }); + } catch (err: any) { + clearTimeout(timeout); + if (err?.name === "AbortError") return res.status(504).json({ error: "Request timed out" }); + res.status(502).json({ error: `Failed to reach Immich: ${err?.message ?? err}` }); + } + } catch (error: any) { + res.status(500).json({ error: error?.message ?? "Failed to list Immich assets" }); + } + }); + + // Photo upload goes through the middleware: the file is forwarded to Immich + // and a row is registered in the photos table so the asset is tracked. + app.post("/api/immich/upload", upload.single("image"), async (req, res) => { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + try { + if (!req.file) return res.status(400).json({ error: "No image file provided" }); + const { enabled, url, apiKey } = await getImmichConfig(); + if (!enabled) return res.status(400).json({ error: "Immich demo is not enabled" }); + if (!url || !apiKey) return res.status(400).json({ error: "Immich is not configured" }); + + const now = new Date(); + const fileCreatedAt = now.toISOString(); + const fileModifiedAt = now.toISOString(); + const deviceAssetId = `prm-${crypto.randomUUID()}`; + const deviceId = "PRM"; + + const form = new FormData(); + // Node 18+ provides global Blob/FormData; multer gives us a Buffer. + const blob = new Blob([req.file.buffer], { type: req.file.mimetype || "application/octet-stream" }); + form.append("assetData", blob, req.file.originalname || "upload.jpg"); + form.append("deviceAssetId", deviceAssetId); + form.append("deviceId", deviceId); + form.append("fileCreatedAt", fileCreatedAt); + form.append("fileModifiedAt", fileModifiedAt); + form.append("isFavorite", "false"); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60000); + let immichResp: Response; + try { + immichResp = await fetch(`${url}/api/assets`, { + method: "POST", + headers: { "x-api-key": apiKey, Accept: "application/json" }, + body: form as any, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + if (!immichResp.ok) { + const text = await immichResp.text().catch(() => ""); + return res.status(502).json({ error: `Immich upload failed (${immichResp.status}): ${text.slice(0, 300)}` }); + } + const result = (await immichResp.json()) as { id?: string; status?: string; duplicate?: boolean }; + const assetId = result.id; + if (!assetId) { + return res.status(502).json({ error: "Immich did not return an asset id" }); + } + + // Register in photos table so this image is tracked alongside others. + // We store the Immich asset URL as the location so it can be resolved + // back later, and use a prmLocation namespace of "immich_demo:". + const location = `${url}/api/assets/${assetId}/original`; + const prmLocation = `immich_demo:${assetId}`; + let photoId: string | undefined; + try { + const existing = await storage.getPhotoByLocation(location); + if (existing) { + photoId = existing.id; + } else { + const photo = await storage.insertPhoto({ location, prmLocation, isSubImage: false }); + photoId = photo.id; + } + } catch (photoErr) { + console.error("Warning: failed to register Immich photo in photos table:", photoErr); + } + + res.json({ + assetId, + status: result.status ?? "ok", + duplicate: !!result.duplicate, + photoId, + }); + } catch (error: any) { + console.error("Immich upload error:", error); + res.status(500).json({ error: error?.message ?? "Failed to upload to Immich" }); + } + }); + // ── AI Chat (Ollama text chat) ──────────────────────────────────────────── const DEFAULT_CHAT_MODEL = "llama3";