xiaolongmr commited on
Commit
06e320b
·
1 Parent(s): 46e362d
api/system.py CHANGED
@@ -321,7 +321,11 @@ def create_router(app_version: str) -> APIRouter:
321
  content_type = response.headers.get("content-type") or "image/png"
322
  if not content_type.lower().startswith("image/"):
323
  content_type = "image/png"
324
- return Response(content=response.content, media_type=content_type)
 
 
 
 
325
 
326
  @router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
327
  async def get_image_thumbnail(image_path: str):
 
321
  content_type = response.headers.get("content-type") or "image/png"
322
  if not content_type.lower().startswith("image/"):
323
  content_type = "image/png"
324
+ return Response(
325
+ content=response.content,
326
+ media_type=content_type,
327
+ headers={"Cache-Control": "private, max-age=3600"},
328
+ )
329
 
330
  @router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
331
  async def get_image_thumbnail(image_path: str):
web/src/app/image/components/image-results.tsx CHANGED
@@ -4,7 +4,7 @@ import { memo, useEffect, useRef, useState } from "react";
4
  import { Clock3, Download, LoaderCircle, RotateCcw, ScanSearch, Sparkles, Trash2 } from "lucide-react";
5
 
6
  import { Button } from "@/components/ui/button";
7
- import { downloadImageSrc, normalizeImageUrl } from "@/lib/image-url";
8
  import { cn } from "@/lib/utils";
9
  import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
10
 
@@ -46,7 +46,7 @@ export type ImageLightboxItem = {
46
  type ImageResultsProps = {
47
  selectedConversation: ImageConversation | null;
48
  onOpenLightbox: (images: ImageLightboxItem[], index: number) => void;
49
- onContinueEdit: (conversationId: string, image: StoredImage | StoredReferenceImage) => void;
50
  onReversePrompt: (conversationId: string, turnId: string, image: StoredImage) => void | Promise<void>;
51
  reversePromptLoadingImageIds: string[];
52
  onDeletePrompt: (conversationId: string, turnId: string) => void;
@@ -154,6 +154,7 @@ export function ImageResults({
154
  }, []);
155
 
156
  const [imageDimensions, setImageDimensions] = useState<Record<string, string>>({});
 
157
  const [now, setNow] = useState(() => Date.now());
158
  const hasPendingTurns = selectedConversation?.turns.some(
159
  (turn) => !turn.resultsDeleted && (turn.status === "queued" || turn.status === "generating"),
@@ -172,6 +173,20 @@ export function ImageResults({
172
  setImageDimensions((current) => current[id] === dimensions ? current : { ...current, [id]: dimensions });
173
  };
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  if (!selectedConversation) {
176
  return (
177
  <div className="flex h-full min-h-[260px] items-center justify-center text-center sm:min-h-[420px]">
@@ -284,9 +299,14 @@ export function ImageResults({
284
  variant="outline"
285
  size="sm"
286
  className="rounded-full border-stone-200 bg-white text-stone-700 hover:bg-stone-50"
287
- onClick={() => onContinueEdit(selectedConversation.id, image)}
 
288
  >
289
- <Sparkles className="size-4" />
 
 
 
 
290
  加入编辑
291
  </Button>
292
  </div>
@@ -330,6 +350,9 @@ export function ImageResults({
330
  event.currentTarget.naturalWidth,
331
  event.currentTarget.naturalHeight,
332
  );
 
 
 
333
  }}
334
  />
335
  <div className="flex flex-col gap-1 px-0 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-0 sm:py-3 sm:text-xs">
@@ -358,10 +381,15 @@ export function ImageResults({
358
  variant="outline"
359
  size="sm"
360
  className="h-7 w-7 rounded-full border-stone-200 bg-white px-0 text-[10px] text-stone-700 hover:bg-stone-50 sm:h-8 sm:w-fit sm:px-3 sm:text-xs"
361
- onClick={() => onContinueEdit(selectedConversation.id, image)}
 
362
  aria-label="加入编辑"
363
  >
364
- <Sparkles className="size-3 sm:size-4" />
 
 
 
 
365
  <span className="hidden sm:inline">加入编辑</span>
366
  </Button>
367
  <Button
@@ -545,6 +573,7 @@ function getProgressLabel(progress?: string) {
545
  generation_success: "生成成功",
546
  generating: "正在生成中",
547
  };
 
548
  return labels[progress || ""] || "正在生成中";
549
  }
550
 
 
4
  import { Clock3, Download, LoaderCircle, RotateCcw, ScanSearch, Sparkles, Trash2 } from "lucide-react";
5
 
6
  import { Button } from "@/components/ui/button";
7
+ import { downloadImageSrc, normalizeImageUrl, warmImageBlob } from "@/lib/image-url";
8
  import { cn } from "@/lib/utils";
9
  import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
10
 
 
46
  type ImageResultsProps = {
47
  selectedConversation: ImageConversation | null;
48
  onOpenLightbox: (images: ImageLightboxItem[], index: number) => void;
49
+ onContinueEdit: (conversationId: string, image: StoredImage | StoredReferenceImage) => void | Promise<void>;
50
  onReversePrompt: (conversationId: string, turnId: string, image: StoredImage) => void | Promise<void>;
51
  reversePromptLoadingImageIds: string[];
52
  onDeletePrompt: (conversationId: string, turnId: string) => void;
 
154
  }, []);
155
 
156
  const [imageDimensions, setImageDimensions] = useState<Record<string, string>>({});
157
+ const [continueEditLoadingImageIds, setContinueEditLoadingImageIds] = useState<string[]>([]);
158
  const [now, setNow] = useState(() => Date.now());
159
  const hasPendingTurns = selectedConversation?.turns.some(
160
  (turn) => !turn.resultsDeleted && (turn.status === "queued" || turn.status === "generating"),
 
173
  setImageDimensions((current) => current[id] === dimensions ? current : { ...current, [id]: dimensions });
174
  };
175
 
176
+
177
+ const continueEdit = async (
178
+ conversationId: string,
179
+ image: StoredImage | StoredReferenceImage,
180
+ loadingId: string,
181
+ ) => {
182
+ setContinueEditLoadingImageIds((current) => current.includes(loadingId) ? current : [...current, loadingId]);
183
+ try {
184
+ await onContinueEdit(conversationId, image);
185
+ } finally {
186
+ setContinueEditLoadingImageIds((current) => current.filter((item) => item !== loadingId));
187
+ }
188
+ };
189
+
190
  if (!selectedConversation) {
191
  return (
192
  <div className="flex h-full min-h-[260px] items-center justify-center text-center sm:min-h-[420px]">
 
299
  variant="outline"
300
  size="sm"
301
  className="rounded-full border-stone-200 bg-white text-stone-700 hover:bg-stone-50"
302
+ onClick={() => void continueEdit(selectedConversation.id, image, `${turn.id}-reference-${index}`)}
303
+ disabled={continueEditLoadingImageIds.includes(`${turn.id}-reference-${index}`)}
304
  >
305
+ {continueEditLoadingImageIds.includes(`${turn.id}-reference-${index}`) ? (
306
+ <LoaderCircle className="size-4 animate-spin" />
307
+ ) : (
308
+ <Sparkles className="size-4" />
309
+ )}
310
  加入编辑
311
  </Button>
312
  </div>
 
350
  event.currentTarget.naturalWidth,
351
  event.currentTarget.naturalHeight,
352
  );
353
+ if (image.url && !image.b64_json) {
354
+ warmImageBlob(image.url);
355
+ }
356
  }}
357
  />
358
  <div className="flex flex-col gap-1 px-0 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-0 sm:py-3 sm:text-xs">
 
381
  variant="outline"
382
  size="sm"
383
  className="h-7 w-7 rounded-full border-stone-200 bg-white px-0 text-[10px] text-stone-700 hover:bg-stone-50 sm:h-8 sm:w-fit sm:px-3 sm:text-xs"
384
+ onClick={() => void continueEdit(selectedConversation.id, image, image.id)}
385
+ disabled={continueEditLoadingImageIds.includes(image.id)}
386
  aria-label="加入编辑"
387
  >
388
+ {continueEditLoadingImageIds.includes(image.id) ? (
389
+ <LoaderCircle className="size-3 animate-spin sm:size-4" />
390
+ ) : (
391
+ <Sparkles className="size-3 sm:size-4" />
392
+ )}
393
  <span className="hidden sm:inline">加入编辑</span>
394
  </Button>
395
  <Button
 
573
  generation_success: "生成成功",
574
  generating: "正在生成中",
575
  };
576
+
577
  return labels[progress || ""] || "正在生成中";
578
  }
579
 
web/src/app/image/page.tsx CHANGED
@@ -40,7 +40,7 @@ import {
40
  type RedeemCode,
41
  type ThirdPartyImageModelOption,
42
  } from "@/lib/api";
43
- import { fetchImageBlob } from "@/lib/image-url";
44
  import { formatChinaDateTime, formatChinaShortDateTime } from "@/lib/time";
45
  import { useAuthGuard } from "@/lib/use-auth-guard";
46
  import { Input } from "@/components/ui/input";
@@ -433,15 +433,19 @@ async function buildReferenceImageFromStoredImage(image: StoredImage, fileName:
433
  return null;
434
  }
435
  const file = await fetchImageAsFile(image.url, fileName);
436
- return {
437
- referenceImage: {
438
- name: file.name,
439
- type: file.type || "image/png",
440
- dataUrl: await readFileAsDataUrl(file),
441
- source: "result" as const,
442
- },
443
- file,
444
- };
 
 
 
 
445
  }
446
 
447
  function hasImageTaskResult(item: NonNullable<ImageTask["data"]>[number] | null | undefined) {
@@ -466,6 +470,11 @@ function taskDataToStoredImages(image: StoredImage, task: ImageTask): StoredImag
466
  durationMs: task.duration_ms,
467
  }];
468
  }
 
 
 
 
 
469
  return resultItems.map((item, index) => ({
470
  ...image,
471
  id: index === 0 ? image.id : `${image.id}-extra-${index}`,
@@ -1753,7 +1762,6 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1753
 
1754
  setReferenceImages((prev) => [...prev, nextReference.referenceImage]);
1755
  setReferenceImageFiles((prev) => [...prev, nextReference.file]);
1756
- setImagePrompt("");
1757
  textareaRef.current?.focus();
1758
  toast.success("已加入当前参考图,继续输入描述即可编辑");
1759
  } catch (error) {
@@ -1773,11 +1781,6 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1773
 
1774
  setSelectedConversationId(conversationId);
1775
  setImagePrompt(turn.prompt);
1776
- setReferenceImages([]);
1777
- setReferenceImageFiles([]);
1778
- if (fileInputRef.current) {
1779
- fileInputRef.current.value = "";
1780
- }
1781
  textareaRef.current?.focus();
1782
  toast.success("已复用这条提示词");
1783
  }, []);
 
40
  type RedeemCode,
41
  type ThirdPartyImageModelOption,
42
  } from "@/lib/api";
43
+ import { fetchImageBlob, releaseImageBlob, warmImageBlob } from "@/lib/image-url";
44
  import { formatChinaDateTime, formatChinaShortDateTime } from "@/lib/time";
45
  import { useAuthGuard } from "@/lib/use-auth-guard";
46
  import { Input } from "@/components/ui/input";
 
433
  return null;
434
  }
435
  const file = await fetchImageAsFile(image.url, fileName);
436
+ try {
437
+ return {
438
+ referenceImage: {
439
+ name: file.name,
440
+ type: file.type || "image/png",
441
+ dataUrl: await readFileAsDataUrl(file),
442
+ source: "result" as const,
443
+ },
444
+ file,
445
+ };
446
+ } finally {
447
+ releaseImageBlob(image.url);
448
+ }
449
  }
450
 
451
  function hasImageTaskResult(item: NonNullable<ImageTask["data"]>[number] | null | undefined) {
 
470
  durationMs: task.duration_ms,
471
  }];
472
  }
473
+ resultItems.forEach((item) => {
474
+ if (item.url && !item.b64_json) {
475
+ warmImageBlob(item.url);
476
+ }
477
+ });
478
  return resultItems.map((item, index) => ({
479
  ...image,
480
  id: index === 0 ? image.id : `${image.id}-extra-${index}`,
 
1762
 
1763
  setReferenceImages((prev) => [...prev, nextReference.referenceImage]);
1764
  setReferenceImageFiles((prev) => [...prev, nextReference.file]);
 
1765
  textareaRef.current?.focus();
1766
  toast.success("已加入当前参考图,继续输入描述即可编辑");
1767
  } catch (error) {
 
1781
 
1782
  setSelectedConversationId(conversationId);
1783
  setImagePrompt(turn.prompt);
 
 
 
 
 
1784
  textareaRef.current?.focus();
1785
  toast.success("已复用这条提示词");
1786
  }, []);
web/src/lib/image-url.ts CHANGED
@@ -1,6 +1,17 @@
1
  import webConfig from "@/constants/common-env";
2
  import { getStoredAuthKey } from "@/store/auth";
3
 
 
 
 
 
 
 
 
 
 
 
 
4
  function isBrowserHttps() {
5
  return typeof window !== "undefined" && window.location.protocol === "https:";
6
  }
@@ -74,11 +85,11 @@ function imageFetchCandidates(src: string) {
74
  return Array.from(new Set(candidates.filter(Boolean)));
75
  }
76
 
77
- export async function fetchImageBlob(src: string) {
78
  let lastError: unknown = null;
79
  for (const candidate of imageFetchCandidates(src)) {
80
  try {
81
- const response = await fetch(candidate, { cache: "no-store" });
82
  if (response.ok) {
83
  return response.blob();
84
  }
@@ -94,7 +105,7 @@ export async function fetchImageBlob(src: string) {
94
  const apiBase = webConfig.apiUrl.replace(/\/$/, "");
95
  const authKey = await getStoredAuthKey();
96
  const response = await fetch(`${apiBase}/api/images/proxy?url=${encodeURIComponent(parsed.toString())}`, {
97
- cache: "no-store",
98
  headers: authKey ? { Authorization: `Bearer ${authKey}` } : undefined,
99
  });
100
  if (response.ok) {
@@ -110,6 +121,67 @@ export async function fetchImageBlob(src: string) {
110
  throw new Error(`读取图片失败${detail}`);
111
  }
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  export async function downloadImageSrc(src: string, filename: string) {
114
  const blob = await fetchImageBlob(src);
115
  const objectUrl = URL.createObjectURL(blob);
 
1
  import webConfig from "@/constants/common-env";
2
  import { getStoredAuthKey } from "@/store/auth";
3
 
4
+ type ImageBlobCacheEntry = {
5
+ promise: Promise<Blob>;
6
+ size: number;
7
+ lastUsedAt: number;
8
+ };
9
+
10
+ const imageBlobCache = new Map<string, ImageBlobCacheEntry>();
11
+ const MAX_IMAGE_BLOB_CACHE_ITEMS = 8;
12
+ const MAX_IMAGE_BLOB_CACHE_BYTES = 64 * 1024 * 1024;
13
+ let imageBlobCacheBytes = 0;
14
+
15
  function isBrowserHttps() {
16
  return typeof window !== "undefined" && window.location.protocol === "https:";
17
  }
 
85
  return Array.from(new Set(candidates.filter(Boolean)));
86
  }
87
 
88
+ async function fetchImageBlobUncached(src: string) {
89
  let lastError: unknown = null;
90
  for (const candidate of imageFetchCandidates(src)) {
91
  try {
92
+ const response = await fetch(candidate, { cache: "force-cache" });
93
  if (response.ok) {
94
  return response.blob();
95
  }
 
105
  const apiBase = webConfig.apiUrl.replace(/\/$/, "");
106
  const authKey = await getStoredAuthKey();
107
  const response = await fetch(`${apiBase}/api/images/proxy?url=${encodeURIComponent(parsed.toString())}`, {
108
+ cache: "force-cache",
109
  headers: authKey ? { Authorization: `Bearer ${authKey}` } : undefined,
110
  });
111
  if (response.ok) {
 
121
  throw new Error(`读取图片失败${detail}`);
122
  }
123
 
124
+ function evictImageBlobCache(protectedKey = "") {
125
+ while (
126
+ imageBlobCache.size > MAX_IMAGE_BLOB_CACHE_ITEMS ||
127
+ imageBlobCacheBytes > MAX_IMAGE_BLOB_CACHE_BYTES
128
+ ) {
129
+ const candidate = [...imageBlobCache.entries()]
130
+ .filter(([key]) => key !== protectedKey)
131
+ .sort(([, left], [, right]) => left.lastUsedAt - right.lastUsedAt)[0];
132
+ if (!candidate) return;
133
+ imageBlobCache.delete(candidate[0]);
134
+ imageBlobCacheBytes = Math.max(0, imageBlobCacheBytes - candidate[1].size);
135
+ }
136
+ }
137
+
138
+ export async function fetchImageBlob(src: string) {
139
+ const key = normalizeImageUrl(src);
140
+ const cached = imageBlobCache.get(key);
141
+ if (cached) {
142
+ cached.lastUsedAt = Date.now();
143
+ return cached.promise;
144
+ }
145
+
146
+ const entry: ImageBlobCacheEntry = {
147
+ promise: Promise.resolve(new Blob()),
148
+ size: 0,
149
+ lastUsedAt: Date.now(),
150
+ };
151
+ entry.promise = fetchImageBlobUncached(key)
152
+ .then((blob) => {
153
+ if (imageBlobCache.get(key) === entry) {
154
+ entry.size = blob.size;
155
+ entry.lastUsedAt = Date.now();
156
+ imageBlobCacheBytes += blob.size;
157
+ evictImageBlobCache(key);
158
+ }
159
+ return blob;
160
+ })
161
+ .catch((error) => {
162
+ if (imageBlobCache.get(key) === entry) {
163
+ imageBlobCache.delete(key);
164
+ }
165
+ throw error;
166
+ });
167
+ imageBlobCache.set(key, entry);
168
+ evictImageBlobCache(key);
169
+ return entry.promise;
170
+ }
171
+
172
+ export function warmImageBlob(src: string) {
173
+ if (!String(src || "").trim()) return;
174
+ void fetchImageBlob(src).catch(() => undefined);
175
+ }
176
+
177
+ export function releaseImageBlob(src: string) {
178
+ const key = normalizeImageUrl(src);
179
+ const cached = imageBlobCache.get(key);
180
+ if (!cached) return;
181
+ imageBlobCache.delete(key);
182
+ imageBlobCacheBytes = Math.max(0, imageBlobCacheBytes - cached.size);
183
+ }
184
+
185
  export async function downloadImageSrc(src: string, filename: string) {
186
  const blob = await fetchImageBlob(src);
187
  const objectUrl = URL.createObjectURL(blob);