Merge pull request #65 from multica-ai/feat/pwa-support

feat(web): add PWA support
This commit is contained in:
Naiyuan Qing 2026-02-03 14:51:39 +08:00 committed by GitHub
commit d6f79d2df6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 144 additions and 0 deletions

View file

@ -10,6 +10,7 @@ import { AppSidebar } from "@multica/ui/components/app-sidebar";
import { ThemeProvider } from "@multica/ui/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { HubSidebar } from "@multica/ui/components/hub-sidebar";
import { ServiceWorkerRegister } from "./sw-register";
const gatewayUrl = process.env.NEXT_PUBLIC_GATEWAY_URL;
if (gatewayUrl) {
@ -37,6 +38,14 @@ const playfair = Playfair_Display({
export const metadata: Metadata = {
title: "Multica",
description: "Distributed AI agent framework",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "Multica",
},
icons: {
apple: "/icon-192x192.png",
},
};
export default function RootLayout({
@ -65,6 +74,7 @@ export default function RootLayout({
</SidebarProvider>
</ThemeProvider>
<Toaster />
<ServiceWorkerRegister />
</body>
</html>
);

35
apps/web/app/manifest.ts Normal file
View file

@ -0,0 +1,35 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Multica",
short_name: "Multica",
description: "Distributed AI agent framework",
id: "/",
scope: "/",
start_url: "/",
display: "standalone",
orientation: "any",
background_color: "#09090b",
theme_color: "#09090b",
icons: [
{
src: "/icon-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}

View file

@ -0,0 +1,22 @@
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegister() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js", {
scope: "/",
updateViaCache: "none",
})
.catch((err) => {
if (process.env.NODE_ENV === "development") {
console.warn("SW registration failed:", err);
}
});
}
}, []);
return null;
}

View file

@ -0,0 +1,22 @@
export default function OfflinePage() {
return (
<div
className="flex h-dvh w-full items-center justify-center"
style={{ display: "flex", height: "100dvh", width: "100%", alignItems: "center", justifyContent: "center" }}
>
<div
className="flex flex-col items-center gap-4 text-center"
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "1rem", textAlign: "center" }}
>
<div className="text-4xl" style={{ fontSize: "2.25rem" }}>*</div>
<h1 className="text-xl font-semibold" style={{ fontSize: "1.25rem", fontWeight: 600 }}>
You are offline
</h1>
<p className="text-sm text-muted-foreground" style={{ fontSize: "0.875rem", color: "#a1a1aa" }}>
Multica requires an internet connection. Please check your network and
try again.
</p>
</div>
</div>
);
}

View file

@ -2,6 +2,21 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@multica/ui", "@multica/store"],
headers: async () => [
{
source: "/sw.js",
headers: [
{
key: "Cache-Control",
value: "no-cache, no-store, must-revalidate",
},
{
key: "Content-Type",
value: "application/javascript; charset=utf-8",
},
],
},
],
};
export default nextConfig;

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

40
apps/web/public/sw.js Normal file
View file

@ -0,0 +1,40 @@
const CACHE_NAME = "multica-v1";
// App shell resources to precache
const PRECACHE_URLS = ["/~offline"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
// Clean up old caches
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
)
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
// Only handle navigation requests (HTML pages)
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(() =>
caches.match("/~offline").then(
(response) =>
response ||
new Response("Offline", { status: 503, statusText: "Offline" })
)
)
);
}
});