xiaolongmr commited on
Commit
dbfb96e
·
1 Parent(s): d99f5c3

Improve frontend loading performance

Browse files
Dockerfile CHANGED
@@ -48,6 +48,21 @@ COPY services ./services
48
  COPY utils ./utils
49
  COPY scripts ./scripts
50
  COPY --from=web-build /app/web/out ./web_dist
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
  EXPOSE 7860
53
 
 
48
  COPY utils ./utils
49
  COPY scripts ./scripts
50
  COPY --from=web-build /app/web/out ./web_dist
51
+ RUN python - <<'PY'
52
+ from __future__ import annotations
53
+
54
+ import gzip
55
+ from pathlib import Path
56
+
57
+ root = Path("web_dist")
58
+ suffixes = {".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"}
59
+ for path in root.rglob("*"):
60
+ if not path.is_file() or path.suffix.lower() not in suffixes or path.stat().st_size < 1024:
61
+ continue
62
+ gz_path = path.with_name(f"{path.name}.gz")
63
+ with path.open("rb") as source, gzip.open(gz_path, "wb", compresslevel=9) as target:
64
+ target.writelines(source)
65
+ PY
66
 
67
  EXPOSE 7860
68
 
api/app.py CHANGED
@@ -1,9 +1,11 @@
1
  from __future__ import annotations
2
 
3
  from contextlib import asynccontextmanager
 
 
4
  from threading import Event
5
 
6
- from fastapi import FastAPI, HTTPException
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from fastapi.responses import FileResponse
9
 
@@ -13,6 +15,45 @@ from api.support import resolve_web_asset, start_limited_account_watcher
13
  from services.backup_service import backup_service
14
  from services.config import config
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  def create_app() -> FastAPI:
18
  app_version = config.app_version
@@ -47,15 +88,33 @@ def create_app() -> FastAPI:
47
  app.include_router(system.create_router(app_version))
48
 
49
  @app.get("/{full_path:path}", include_in_schema=False)
50
- async def serve_web(full_path: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  asset = resolve_web_asset(full_path)
52
  if asset is not None:
53
- return FileResponse(asset)
 
54
  if full_path.strip("/").startswith("_next/"):
55
  raise HTTPException(status_code=404, detail="Not Found")
56
  fallback = resolve_web_asset("")
57
  if fallback is None:
58
  raise HTTPException(status_code=404, detail="Not Found")
59
- return FileResponse(fallback)
 
60
 
61
  return app
 
1
  from __future__ import annotations
2
 
3
  from contextlib import asynccontextmanager
4
+ from mimetypes import guess_type
5
+ from pathlib import Path
6
  from threading import Event
7
 
8
+ from fastapi import FastAPI, HTTPException, Request
9
  from fastapi.middleware.cors import CORSMiddleware
10
  from fastapi.responses import FileResponse
11
 
 
15
  from services.backup_service import backup_service
16
  from services.config import config
17
 
18
+ STATIC_CACHE_CONTROL = "public, max-age=31536000, immutable"
19
+ ASSET_CACHE_CONTROL = "public, max-age=86400"
20
+ HTML_CACHE_CONTROL = "no-cache"
21
+ COMPRESSIBLE_SUFFIXES = {".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"}
22
+
23
+
24
+ def _web_cache_headers(full_path: str, asset: Path) -> dict[str, str]:
25
+ clean_path = full_path.strip("/")
26
+ suffix = asset.suffix.lower()
27
+ headers: dict[str, str] = {}
28
+ if clean_path.startswith("_next/static/"):
29
+ headers["Cache-Control"] = STATIC_CACHE_CONTROL
30
+ elif suffix in {".html", ".txt"}:
31
+ headers["Cache-Control"] = HTML_CACHE_CONTROL
32
+ else:
33
+ headers["Cache-Control"] = ASSET_CACHE_CONTROL
34
+ return headers
35
+
36
+
37
+ def _select_web_asset(request: Request, full_path: str, asset: Path) -> tuple[Path, dict[str, str], str | None]:
38
+ headers = _web_cache_headers(full_path, asset)
39
+ media_type = guess_type(str(asset))[0]
40
+ accept_encoding = str(request.headers.get("accept-encoding") or "").lower()
41
+ gzip_asset = asset.with_name(f"{asset.name}.gz")
42
+ is_compressible = asset.suffix.lower() in COMPRESSIBLE_SUFFIXES
43
+ if is_compressible:
44
+ headers = dict(headers)
45
+ headers["Vary"] = "Accept-Encoding"
46
+ if "gzip" in accept_encoding and is_compressible and gzip_asset.is_file():
47
+ headers = dict(headers)
48
+ headers["Content-Encoding"] = "gzip"
49
+ return gzip_asset, headers, media_type
50
+ return asset, headers, media_type
51
+
52
+
53
+ def _is_api_like_path(full_path: str) -> bool:
54
+ clean_path = full_path.strip("/")
55
+ return clean_path.startswith(("api/", "auth/", "v1/"))
56
+
57
 
58
  def create_app() -> FastAPI:
59
  app_version = config.app_version
 
88
  app.include_router(system.create_router(app_version))
89
 
90
  @app.get("/{full_path:path}", include_in_schema=False)
91
+ async def serve_web(full_path: str, request: Request):
92
+ asset = resolve_web_asset(full_path)
93
+ if asset is not None:
94
+ selected_asset, headers, media_type = _select_web_asset(request, full_path, asset)
95
+ return FileResponse(selected_asset, headers=headers, media_type=media_type)
96
+ if full_path.strip("/").startswith("_next/"):
97
+ raise HTTPException(status_code=404, detail="Not Found")
98
+ fallback = resolve_web_asset("")
99
+ if fallback is None:
100
+ raise HTTPException(status_code=404, detail="Not Found")
101
+ selected_asset, headers, media_type = _select_web_asset(request, "", fallback)
102
+ return FileResponse(selected_asset, headers=headers, media_type=media_type)
103
+
104
+ @app.head("/{full_path:path}", include_in_schema=False)
105
+ async def serve_web_head(full_path: str, request: Request):
106
+ if _is_api_like_path(full_path):
107
+ raise HTTPException(status_code=405, detail="Method Not Allowed")
108
  asset = resolve_web_asset(full_path)
109
  if asset is not None:
110
+ selected_asset, headers, media_type = _select_web_asset(request, full_path, asset)
111
+ return FileResponse(selected_asset, headers=headers, media_type=media_type)
112
  if full_path.strip("/").startswith("_next/"):
113
  raise HTTPException(status_code=404, detail="Not Found")
114
  fallback = resolve_web_asset("")
115
  if fallback is None:
116
  raise HTTPException(status_code=404, detail="Not Found")
117
+ selected_asset, headers, media_type = _select_web_asset(request, "", fallback)
118
+ return FileResponse(selected_asset, headers=headers, media_type=media_type)
119
 
120
  return app
web/src/components/announcement-markdown.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import ReactMarkdown from "react-markdown";
4
+ import remarkBreaks from "remark-breaks";
5
+ import remarkGfm from "remark-gfm";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ export function AnnouncementMarkdown({ content, compact = false }: { content: string; compact?: boolean }) {
10
+ return (
11
+ <div
12
+ className={cn(
13
+ "space-y-2 text-sm leading-6 text-stone-700",
14
+ compact && "space-y-1",
15
+ "[&_input[type=checkbox]]:mr-2 [&_input[type=checkbox]]:align-middle",
16
+ )}
17
+ >
18
+ <ReactMarkdown
19
+ remarkPlugins={[remarkGfm, remarkBreaks]}
20
+ skipHtml
21
+ components={{
22
+ h1: ({ children }) => <h2 className={cn("font-semibold text-stone-950", compact ? "text-base" : "text-lg")}>{children}</h2>,
23
+ h2: ({ children }) => <h3 className={cn("font-semibold text-stone-950", compact ? "text-sm" : "text-base")}>{children}</h3>,
24
+ h3: ({ children }) => <h4 className="text-sm font-semibold text-stone-900">{children}</h4>,
25
+ h4: ({ children }) => <h5 className="text-sm font-semibold text-stone-900">{children}</h5>,
26
+ p: ({ children }) => <p>{children}</p>,
27
+ a: ({ children, href }) => (
28
+ <a href={href || "#"} target="_blank" rel="noreferrer" className="font-medium text-sky-700 underline underline-offset-2">
29
+ {children}
30
+ </a>
31
+ ),
32
+ ul: ({ children, className }) => (
33
+ <ul className={cn("ml-5 space-y-1", className?.includes("contains-task-list") ? "list-none" : "list-disc")}>{children}</ul>
34
+ ),
35
+ ol: ({ children }) => <ol className="ml-5 list-decimal space-y-1">{children}</ol>,
36
+ li: ({ children, className }) => <li className={cn("pl-1", className?.includes("task-list-item") && "-ml-5 list-none pl-0")}>{children}</li>,
37
+ blockquote: ({ children }) => <blockquote className="border-l-2 border-stone-300 pl-3 text-stone-500">{children}</blockquote>,
38
+ code: ({ children, className }) => {
39
+ const isBlock = String(className || "").includes("language-");
40
+ return isBlock ? (
41
+ <code className="block overflow-x-auto rounded-xl bg-stone-100 p-3 font-mono text-xs leading-5 text-stone-800">{children}</code>
42
+ ) : (
43
+ <code className="rounded bg-stone-100 px-1 py-0.5 font-mono text-xs text-stone-800">{children}</code>
44
+ );
45
+ },
46
+ pre: ({ children }) => <pre className="overflow-x-auto">{children}</pre>,
47
+ table: ({ children }) => (
48
+ <div className="overflow-x-auto rounded-xl border border-stone-200">
49
+ <table className="w-full text-left text-xs">{children}</table>
50
+ </div>
51
+ ),
52
+ th: ({ children }) => <th className="border-b border-stone-200 bg-stone-50 px-3 py-2 font-semibold text-stone-700">{children}</th>,
53
+ td: ({ children }) => <td className="border-b border-stone-100 px-3 py-2 text-stone-600">{children}</td>,
54
+ hr: () => <hr className="border-stone-200" />,
55
+ img: ({ src, alt }) => (
56
+ <img src={src || ""} alt={alt || ""} className="w-full rounded-xl border border-stone-100 object-cover" loading="lazy" />
57
+ ),
58
+ }}
59
+ >
60
+ {content || ""}
61
+ </ReactMarkdown>
62
+ </div>
63
+ );
64
+ }
web/src/components/announcements-popover.tsx CHANGED
@@ -2,9 +2,7 @@
2
 
3
  import { useEffect, useMemo, useState } from "react";
4
  import { Bell, LoaderCircle, Megaphone } from "lucide-react";
5
- import ReactMarkdown from "react-markdown";
6
- import remarkBreaks from "remark-breaks";
7
- import remarkGfm from "remark-gfm";
8
 
9
  import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
10
  import { fetchAnnouncements, type Announcement, type AnnouncementType } from "@/lib/api";
@@ -13,6 +11,13 @@ import { cn } from "@/lib/utils";
13
  import type { StoredAuthSession } from "@/store/auth";
14
 
15
  const READ_STORAGE_PREFIX = "chatgpt2api:announcements:read:";
 
 
 
 
 
 
 
16
 
17
  function getAnnouncementStamp(item: Announcement) {
18
  return `${item.id}:${item.updated_at || item.created_at || ""}`;
@@ -208,7 +213,7 @@ export function AnnouncementsPopover({ session }: { session: StoredAuthSession }
208
  ) : tab === "notice" ? (
209
  <div className="space-y-5">
210
  {visibleItems.map((item) => (
211
- <MarkdownBlock key={item.id} content={item.content} />
212
  ))}
213
  </div>
214
  ) : (
@@ -224,7 +229,7 @@ export function AnnouncementsPopover({ session }: { session: StoredAuthSession }
224
  )}
225
  aria-hidden="true"
226
  />
227
- <MarkdownBlock content={item.content} compact />
228
  <div className="mt-2 text-xs leading-5 text-stone-400">{formatTimelineTime(item.updated_at || item.created_at)}</div>
229
  </article>
230
  );
@@ -246,59 +251,3 @@ export function AnnouncementsPopover({ session }: { session: StoredAuthSession }
246
  );
247
  }
248
 
249
- function MarkdownBlock({ content, compact = false }: { content: string; compact?: boolean }) {
250
- return (
251
- <div
252
- className={cn(
253
- "space-y-2 text-sm leading-6 text-stone-700",
254
- compact && "space-y-1",
255
- "[&_input[type=checkbox]]:mr-2 [&_input[type=checkbox]]:align-middle",
256
- )}
257
- >
258
- <ReactMarkdown
259
- remarkPlugins={[remarkGfm, remarkBreaks]}
260
- skipHtml
261
- components={{
262
- h1: ({ children }) => <h2 className={cn("font-semibold text-stone-950", compact ? "text-base" : "text-lg")}>{children}</h2>,
263
- h2: ({ children }) => <h3 className={cn("font-semibold text-stone-950", compact ? "text-sm" : "text-base")}>{children}</h3>,
264
- h3: ({ children }) => <h4 className="text-sm font-semibold text-stone-900">{children}</h4>,
265
- h4: ({ children }) => <h5 className="text-sm font-semibold text-stone-900">{children}</h5>,
266
- p: ({ children }) => <p>{children}</p>,
267
- a: ({ children, href }) => (
268
- <a href={href || "#"} target="_blank" rel="noreferrer" className="font-medium text-sky-700 underline underline-offset-2">
269
- {children}
270
- </a>
271
- ),
272
- ul: ({ children, className }) => (
273
- <ul className={cn("ml-5 space-y-1", className?.includes("contains-task-list") ? "list-none" : "list-disc")}>{children}</ul>
274
- ),
275
- ol: ({ children }) => <ol className="ml-5 list-decimal space-y-1">{children}</ol>,
276
- li: ({ children, className }) => <li className={cn("pl-1", className?.includes("task-list-item") && "-ml-5 list-none pl-0")}>{children}</li>,
277
- blockquote: ({ children }) => <blockquote className="border-l-2 border-stone-300 pl-3 text-stone-500">{children}</blockquote>,
278
- code: ({ children, className }) => {
279
- const isBlock = String(className || "").includes("language-");
280
- return isBlock ? (
281
- <code className="block overflow-x-auto rounded-xl bg-stone-100 p-3 font-mono text-xs leading-5 text-stone-800">{children}</code>
282
- ) : (
283
- <code className="rounded bg-stone-100 px-1 py-0.5 font-mono text-xs text-stone-800">{children}</code>
284
- );
285
- },
286
- pre: ({ children }) => <pre className="overflow-x-auto">{children}</pre>,
287
- table: ({ children }) => (
288
- <div className="overflow-x-auto rounded-xl border border-stone-200">
289
- <table className="w-full text-left text-xs">{children}</table>
290
- </div>
291
- ),
292
- th: ({ children }) => <th className="border-b border-stone-200 bg-stone-50 px-3 py-2 font-semibold text-stone-700">{children}</th>,
293
- td: ({ children }) => <td className="border-b border-stone-100 px-3 py-2 text-stone-600">{children}</td>,
294
- hr: () => <hr className="border-stone-200" />,
295
- img: ({ src, alt }) => (
296
- <img src={src || ""} alt={alt || ""} className="w-full rounded-xl border border-stone-100 object-cover" loading="lazy" />
297
- ),
298
- }}
299
- >
300
- {content || ""}
301
- </ReactMarkdown>
302
- </div>
303
- );
304
- }
 
2
 
3
  import { useEffect, useMemo, useState } from "react";
4
  import { Bell, LoaderCircle, Megaphone } from "lucide-react";
5
+ import dynamic from "next/dynamic";
 
 
6
 
7
  import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
8
  import { fetchAnnouncements, type Announcement, type AnnouncementType } from "@/lib/api";
 
11
  import type { StoredAuthSession } from "@/store/auth";
12
 
13
  const READ_STORAGE_PREFIX = "chatgpt2api:announcements:read:";
14
+ const AnnouncementMarkdown = dynamic(
15
+ () => import("@/components/announcement-markdown").then((module) => module.AnnouncementMarkdown),
16
+ {
17
+ ssr: false,
18
+ loading: () => <div className="h-16 animate-pulse rounded-xl bg-stone-100" />,
19
+ },
20
+ );
21
 
22
  function getAnnouncementStamp(item: Announcement) {
23
  return `${item.id}:${item.updated_at || item.created_at || ""}`;
 
213
  ) : tab === "notice" ? (
214
  <div className="space-y-5">
215
  {visibleItems.map((item) => (
216
+ <AnnouncementMarkdown key={item.id} content={item.content} />
217
  ))}
218
  </div>
219
  ) : (
 
229
  )}
230
  aria-hidden="true"
231
  />
232
+ <AnnouncementMarkdown content={item.content} compact />
233
  <div className="mt-2 text-xs leading-5 text-stone-400">{formatTimelineTime(item.updated_at || item.created_at)}</div>
234
  </article>
235
  );
 
251
  );
252
  }
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/lib/auth-session.ts CHANGED
@@ -3,6 +3,12 @@
3
  import { login } from "@/lib/api";
4
  import { clearStoredAuthSession, getStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
5
 
 
 
 
 
 
 
6
  function isUnauthorizedError(error: unknown) {
7
  if (!error || typeof error !== "object") {
8
  return false;
@@ -14,9 +20,29 @@ function isUnauthorizedError(error: unknown) {
14
  export async function getValidatedAuthSession(): Promise<StoredAuthSession | null> {
15
  const storedSession = await getStoredAuthSession();
16
  if (!storedSession) {
 
 
17
  return null;
18
  }
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  try {
21
  const data = await login(storedSession.key);
22
  const nextSession: StoredAuthSession = {
@@ -27,12 +53,18 @@ export async function getValidatedAuthSession(): Promise<StoredAuthSession | nul
27
  wechatOpenid: storedSession.wechatOpenid,
28
  };
29
  await setStoredAuthSession(nextSession);
 
 
30
  return nextSession;
31
  } catch (error) {
32
  if (isUnauthorizedError(error)) {
33
  await clearStoredAuthSession();
 
 
34
  return null;
35
  }
 
 
36
  return storedSession;
37
  }
38
  }
 
3
  import { login } from "@/lib/api";
4
  import { clearStoredAuthSession, getStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
5
 
6
+ const VALIDATION_CACHE_MS = 10_000;
7
+ let cachedSession: StoredAuthSession | null = null;
8
+ let cachedAt = 0;
9
+ let validationPromise: Promise<StoredAuthSession | null> | null = null;
10
+ let validationKey = "";
11
+
12
  function isUnauthorizedError(error: unknown) {
13
  if (!error || typeof error !== "object") {
14
  return false;
 
20
  export async function getValidatedAuthSession(): Promise<StoredAuthSession | null> {
21
  const storedSession = await getStoredAuthSession();
22
  if (!storedSession) {
23
+ cachedSession = null;
24
+ cachedAt = 0;
25
  return null;
26
  }
27
 
28
+ const now = Date.now();
29
+ if (cachedSession?.key === storedSession.key && now - cachedAt < VALIDATION_CACHE_MS) {
30
+ return cachedSession;
31
+ }
32
+
33
+ if (validationPromise && validationKey === storedSession.key) {
34
+ return validationPromise;
35
+ }
36
+
37
+ validationKey = storedSession.key;
38
+ validationPromise = validateStoredSession(storedSession).finally(() => {
39
+ validationPromise = null;
40
+ validationKey = "";
41
+ });
42
+ return validationPromise;
43
+ }
44
+
45
+ async function validateStoredSession(storedSession: StoredAuthSession): Promise<StoredAuthSession | null> {
46
  try {
47
  const data = await login(storedSession.key);
48
  const nextSession: StoredAuthSession = {
 
53
  wechatOpenid: storedSession.wechatOpenid,
54
  };
55
  await setStoredAuthSession(nextSession);
56
+ cachedSession = nextSession;
57
+ cachedAt = Date.now();
58
  return nextSession;
59
  } catch (error) {
60
  if (isUnauthorizedError(error)) {
61
  await clearStoredAuthSession();
62
+ cachedSession = null;
63
+ cachedAt = 0;
64
  return null;
65
  }
66
+ cachedSession = storedSession;
67
+ cachedAt = Date.now();
68
  return storedSession;
69
  }
70
  }