diff --git a/index.html b/index.html
index 1f738fe..4af8334 100644
--- a/index.html
+++ b/index.html
@@ -1,10 +1,26 @@
-
+
-
- vite-app
+
+
+
+
+
+
+
+
+
+ Simple LLM Chat UI
diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..d933667
Binary files /dev/null and b/public/icons/apple-touch-icon.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..80af286
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..e3c4207
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png
new file mode 100644
index 0000000..e3c4207
Binary files /dev/null and b/public/icons/maskable-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..f6157ff
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,33 @@
+{
+ "name": "Simple LLM Chat UI",
+ "short_name": "LLM Chat",
+ "description": "A local-first chat panel for OpenAI-compatible LLM services.",
+ "id": "/",
+ "lang": "zh-CN",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#ffffff",
+ "theme_color": "#111111",
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/public/service-worker.js b/public/service-worker.js
new file mode 100644
index 0000000..14ed945
--- /dev/null
+++ b/public/service-worker.js
@@ -0,0 +1,85 @@
+const CACHE_VERSION = "simple-llm-chat-ui-v2"
+const APP_SHELL_URLS = [
+ "/",
+ "/manifest.webmanifest",
+ "/icons/icon-192.png",
+ "/icons/icon-512.png",
+ "/icons/maskable-512.png",
+ "/icons/apple-touch-icon.png",
+]
+const CACHEABLE_DESTINATIONS = new Set(["font", "image", "script", "style"])
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches
+ .open(CACHE_VERSION)
+ .then((cache) => cache.addAll(APP_SHELL_URLS))
+ .then(() => self.skipWaiting())
+ )
+})
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(
+ keys
+ .filter((key) => key !== CACHE_VERSION)
+ .map((key) => caches.delete(key))
+ )
+ )
+ .then(() => self.clients.claim())
+ )
+})
+
+self.addEventListener("fetch", (event) => {
+ const { request } = event
+ const url = new URL(request.url)
+
+ if (request.method !== "GET" || url.origin !== self.location.origin) {
+ return
+ }
+
+ if (request.mode === "navigate") {
+ event.respondWith(networkFirst(request))
+ return
+ }
+
+ if (CACHEABLE_DESTINATIONS.has(request.destination)) {
+ event.respondWith(cacheFirst(request))
+ }
+})
+
+async function networkFirst(request) {
+ const cache = await caches.open(CACHE_VERSION)
+
+ try {
+ const response = await fetch(request)
+
+ if (response.ok) {
+ await cache.put(request, response.clone())
+ }
+
+ return response
+ } catch {
+ return (await cache.match(request)) ?? (await cache.match("/"))
+ }
+}
+
+async function cacheFirst(request) {
+ const cache = await caches.open(CACHE_VERSION)
+ const cached = await cache.match(request)
+
+ if (cached) {
+ return cached
+ }
+
+ const response = await fetch(request)
+
+ if (response.ok) {
+ await cache.put(request, response.clone())
+ }
+
+ return response
+}
diff --git a/public/vite.svg b/public/vite.svg
deleted file mode 100644
index e7b8dfb..0000000
--- a/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/main.tsx b/src/main.tsx
index 2c36773..970bc72 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -12,3 +12,9 @@ createRoot(document.getElementById("root")!).render(
)
+
+if ("serviceWorker" in navigator && import.meta.env.PROD) {
+ window.addEventListener("load", () => {
+ void navigator.serviceWorker.register("/service-worker.js")
+ })
+}