dsh-plugin-image-workbench 0.0.0-stage → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +258 -0
- package/README.md +260 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +692 -0
- package/install.mjs +333 -0
- package/package.json +58 -4
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
|
@@ -0,0 +1,1799 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { ArrowDown, History, LoaderCircle, Plus, Trash2 } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
|
|
7
|
+
import { ImageComposer } from "@/app/image/components/image-composer";
|
|
8
|
+
import { ImageResults, type ImageLightboxItem } from "@/app/image/components/image-results";
|
|
9
|
+
import { ImageSidebar } from "@/app/image/components/image-sidebar";
|
|
10
|
+
import { ImageLightbox } from "@/components/image-lightbox";
|
|
11
|
+
import {
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogContent,
|
|
14
|
+
DialogDescription,
|
|
15
|
+
DialogFooter,
|
|
16
|
+
DialogHeader,
|
|
17
|
+
DialogTitle,
|
|
18
|
+
} from "@/components/ui/dialog";
|
|
19
|
+
import { Button } from "@/components/ui/button";
|
|
20
|
+
import {
|
|
21
|
+
createImageEditTask,
|
|
22
|
+
createImageGenerationTask,
|
|
23
|
+
fetchAccounts,
|
|
24
|
+
fetchModels,
|
|
25
|
+
fetchImageTasks,
|
|
26
|
+
resumeImagePoll,
|
|
27
|
+
type Account,
|
|
28
|
+
type ImageModel,
|
|
29
|
+
type Model,
|
|
30
|
+
type ImageTask,
|
|
31
|
+
} from "@/lib/api";
|
|
32
|
+
import { useAuthGuard } from "@/lib/use-auth-guard";
|
|
33
|
+
import { useSettingsStore } from "@/app/settings/store";
|
|
34
|
+
import {
|
|
35
|
+
clearImageConversations,
|
|
36
|
+
deleteImageConversation,
|
|
37
|
+
getImageConversationStats,
|
|
38
|
+
listImageConversations,
|
|
39
|
+
renameImageConversation,
|
|
40
|
+
saveImageConversation,
|
|
41
|
+
saveImageConversations,
|
|
42
|
+
type ImageConversation,
|
|
43
|
+
type ImageConversationMode,
|
|
44
|
+
type ImageTurn,
|
|
45
|
+
type ImageTurnStatus,
|
|
46
|
+
type StoredImage,
|
|
47
|
+
type StoredReferenceImage,
|
|
48
|
+
} from "@/store/image-conversations";
|
|
49
|
+
|
|
50
|
+
const ACTIVE_CONVERSATION_STORAGE_KEY = "chatgpt2api:image_active_conversation_id";
|
|
51
|
+
const IMAGE_RATIO_STORAGE_KEY = "chatgpt2api:image_last_ratio";
|
|
52
|
+
const IMAGE_TIER_STORAGE_KEY = "chatgpt2api:image_last_tier";
|
|
53
|
+
const IMAGE_QUALITY_STORAGE_KEY = "chatgpt2api:image_last_quality";
|
|
54
|
+
const IMAGE_MODEL_STORAGE_KEY = "chatgpt2api:image_last_model";
|
|
55
|
+
const IMAGE_COUNT_STORAGE_KEY = "chatgpt2api:image_last_count";
|
|
56
|
+
const SCROLL_POSITIONS_STORAGE_KEY = "chatgpt2api:image_scroll_positions";
|
|
57
|
+
const SCROLL_TO_LATEST_THRESHOLD = 160;
|
|
58
|
+
|
|
59
|
+
function loadScrollPositions(): Map<string, number> {
|
|
60
|
+
if (typeof window === "undefined") return new Map();
|
|
61
|
+
try {
|
|
62
|
+
const raw = window.sessionStorage.getItem(SCROLL_POSITIONS_STORAGE_KEY);
|
|
63
|
+
if (!raw) return new Map();
|
|
64
|
+
const parsed = JSON.parse(raw) as Record<string, number>;
|
|
65
|
+
return new Map(Object.entries(parsed));
|
|
66
|
+
} catch {
|
|
67
|
+
return new Map();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function saveScrollPositions(positions: Map<string, number>) {
|
|
72
|
+
if (typeof window === "undefined") return;
|
|
73
|
+
try {
|
|
74
|
+
const obj: Record<string, number> = {};
|
|
75
|
+
positions.forEach((value, key) => { obj[key] = value; });
|
|
76
|
+
window.sessionStorage.setItem(SCROLL_POSITIONS_STORAGE_KEY, JSON.stringify(obj));
|
|
77
|
+
} catch {
|
|
78
|
+
// sessionStorage may be full or unavailable
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function clampImageCount(value: string) {
|
|
83
|
+
return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
|
|
84
|
+
}
|
|
85
|
+
function parseImageSize(size: string) {
|
|
86
|
+
const match = size.match(/^(\d+)x(\d+)$/);
|
|
87
|
+
return match ? { width: match[1], height: match[2] } : { width: "1024", height: "1024" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const activeConversationQueueIds = new Set<string>();
|
|
91
|
+
let pollAbortController: AbortController | null = null;
|
|
92
|
+
|
|
93
|
+
function getResultsDistanceFromBottom(element: HTMLElement) {
|
|
94
|
+
return element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildConversationTitle(prompt: string) {
|
|
98
|
+
const trimmed = prompt.trim();
|
|
99
|
+
if (trimmed.length <= 12) {
|
|
100
|
+
return trimmed;
|
|
101
|
+
}
|
|
102
|
+
return `${trimmed.slice(0, 12)}...`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatConversationTime(value: string) {
|
|
106
|
+
const date = new Date(value);
|
|
107
|
+
if (Number.isNaN(date.getTime())) {
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
return new Intl.DateTimeFormat("zh-CN", {
|
|
111
|
+
month: "2-digit",
|
|
112
|
+
day: "2-digit",
|
|
113
|
+
hour: "2-digit",
|
|
114
|
+
minute: "2-digit",
|
|
115
|
+
}).format(date);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatAvailableQuota(accounts: Account[]) {
|
|
119
|
+
const availableAccounts = accounts.filter((account) => account.status !== "禁用");
|
|
120
|
+
return String(availableAccounts.reduce((sum, account) => sum + Math.max(0, account.quota), 0));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createId() {
|
|
124
|
+
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readFileAsDataUrl(file: File) {
|
|
128
|
+
return new Promise<string>((resolve, reject) => {
|
|
129
|
+
const reader = new FileReader();
|
|
130
|
+
reader.onload = () => resolve(String(reader.result || ""));
|
|
131
|
+
reader.onerror = () => reject(new Error("读取参考图失败"));
|
|
132
|
+
reader.readAsDataURL(file);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function dataUrlToFile(dataUrl: string, fileName: string, mimeType?: string) {
|
|
137
|
+
const [header, content] = dataUrl.split(",", 2);
|
|
138
|
+
const matchedMimeType = header.match(/data:(.*?);base64/)?.[1];
|
|
139
|
+
const binary = atob(content || "");
|
|
140
|
+
const bytes = new Uint8Array(binary.length);
|
|
141
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
142
|
+
bytes[index] = binary.charCodeAt(index);
|
|
143
|
+
}
|
|
144
|
+
return new File([bytes], fileName, { type: mimeType || matchedMimeType || "image/png" });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function filterImageModels(items: Model[]): ImageModel[] {
|
|
148
|
+
return items
|
|
149
|
+
.map((item) => String(item.id || "").trim())
|
|
150
|
+
.filter((id, index, list) => id.toLowerCase().includes("image") && list.indexOf(id) === index);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeStoredImageModel(value: string | null, availableModels: ImageModel[]): ImageModel {
|
|
154
|
+
const normalized = String(value || "").trim();
|
|
155
|
+
if (normalized && availableModels.includes(normalized)) {
|
|
156
|
+
return normalized;
|
|
157
|
+
}
|
|
158
|
+
return availableModels[0] || "gpt-image-2";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildReferenceImageFromResult(image: StoredImage, fileName: string): StoredReferenceImage | null {
|
|
162
|
+
if (!image.b64_json) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
name: fileName,
|
|
168
|
+
type: "image/png",
|
|
169
|
+
dataUrl: `data:image/png;base64,${image.b64_json}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function fetchImageAsFile(url: string, fileName: string) {
|
|
174
|
+
const response = await fetch(url);
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
throw new Error("读取结果图失败");
|
|
177
|
+
}
|
|
178
|
+
const blob = await response.blob();
|
|
179
|
+
return new File([blob], fileName, { type: blob.type || "image/png" });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function buildReferenceImageFromStoredImage(image: StoredImage, fileName: string) {
|
|
183
|
+
const direct = buildReferenceImageFromResult(image, fileName);
|
|
184
|
+
if (direct) {
|
|
185
|
+
return {
|
|
186
|
+
referenceImage: direct,
|
|
187
|
+
file: dataUrlToFile(direct.dataUrl, direct.name, direct.type),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!image.url) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const file = await fetchImageAsFile(image.url, fileName);
|
|
195
|
+
return {
|
|
196
|
+
referenceImage: {
|
|
197
|
+
name: file.name,
|
|
198
|
+
type: file.type || "image/png",
|
|
199
|
+
dataUrl: await readFileAsDataUrl(file),
|
|
200
|
+
},
|
|
201
|
+
file,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function taskDataToStoredImage(image: StoredImage, task: ImageTask): StoredImage {
|
|
206
|
+
if (task.status === "success") {
|
|
207
|
+
const first = task.data?.[0];
|
|
208
|
+
if (!first?.b64_json && !first?.url) {
|
|
209
|
+
return {
|
|
210
|
+
...image,
|
|
211
|
+
taskId: task.id,
|
|
212
|
+
status: "error",
|
|
213
|
+
taskStatus: undefined,
|
|
214
|
+
progress: undefined,
|
|
215
|
+
error: "未返回图片数据",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
...image,
|
|
220
|
+
taskId: task.id,
|
|
221
|
+
status: "success",
|
|
222
|
+
taskStatus: undefined,
|
|
223
|
+
progress: undefined,
|
|
224
|
+
b64_json: first.b64_json,
|
|
225
|
+
url: first.url,
|
|
226
|
+
revised_prompt: first.revised_prompt,
|
|
227
|
+
error: undefined,
|
|
228
|
+
durationMs: task.duration_ms,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (task.status === "error") {
|
|
233
|
+
return {
|
|
234
|
+
...image,
|
|
235
|
+
taskId: task.id,
|
|
236
|
+
status: "error",
|
|
237
|
+
taskStatus: undefined,
|
|
238
|
+
progress: undefined,
|
|
239
|
+
error: task.error || "生成失败",
|
|
240
|
+
durationMs: task.duration_ms,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const newTaskStatus = task.status === "queued" ? "queued" : task.status === "running" ? "running" : image.taskStatus;
|
|
245
|
+
const shouldSetStartTime = newTaskStatus === "running" && !image.startTime;
|
|
246
|
+
const startTime = shouldSetStartTime ? Date.now() : image.startTime;
|
|
247
|
+
// elapsedSecs 仅使用后端返回的值,确保计时从 image_stream_resolve_start 开始
|
|
248
|
+
const elapsedSecs =
|
|
249
|
+
newTaskStatus === "running" && typeof task.elapsed_secs === "number"
|
|
250
|
+
? task.elapsed_secs
|
|
251
|
+
: undefined;
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
...image,
|
|
255
|
+
taskId: task.id,
|
|
256
|
+
status: "loading",
|
|
257
|
+
taskStatus: newTaskStatus,
|
|
258
|
+
progress: task.progress || image.progress,
|
|
259
|
+
error: undefined,
|
|
260
|
+
startTime,
|
|
261
|
+
elapsedSecs,
|
|
262
|
+
elapsedUpdatedAt: elapsedSecs != null ? Date.now() : undefined,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function sleep(ms: number) {
|
|
267
|
+
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function pickFallbackConversationId(conversations: ImageConversation[]) {
|
|
271
|
+
const activeConversation = conversations.find((conversation) =>
|
|
272
|
+
conversation.turns.some((turn) => turn.status === "queued" || turn.status === "generating"),
|
|
273
|
+
);
|
|
274
|
+
return activeConversation?.id ?? conversations[0]?.id ?? null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sortImageConversations(conversations: ImageConversation[]) {
|
|
278
|
+
return [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function deriveTurnStatus(turn: ImageTurn): Pick<ImageTurn, "status" | "error"> {
|
|
282
|
+
const loadingCount = turn.images.filter((image) => image.status === "loading").length;
|
|
283
|
+
const failedCount = turn.images.filter((image) => image.status === "error").length;
|
|
284
|
+
const successCount = turn.images.filter((image) => image.status === "success").length;
|
|
285
|
+
if (loadingCount > 0) {
|
|
286
|
+
// 如果任何图片的 taskStatus 为 running,则状态为 generating
|
|
287
|
+
const hasRunning = turn.images.some((image) => image.taskStatus === "running");
|
|
288
|
+
if (hasRunning) {
|
|
289
|
+
return { status: "generating", error: undefined };
|
|
290
|
+
}
|
|
291
|
+
return { status: turn.status === "queued" ? "queued" : "generating", error: undefined };
|
|
292
|
+
}
|
|
293
|
+
if (failedCount > 0) {
|
|
294
|
+
return { status: "error", error: `其中 ${failedCount} 张未成功生成` };
|
|
295
|
+
}
|
|
296
|
+
if (successCount > 0) {
|
|
297
|
+
return { status: "success", error: undefined };
|
|
298
|
+
}
|
|
299
|
+
// 所有图片都被忽略(images 为空),视为完成
|
|
300
|
+
return { status: "success", error: undefined };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function finalizeIdleQueuedTurn(turn: ImageTurn): ImageTurn {
|
|
304
|
+
if (
|
|
305
|
+
(turn.status !== "queued" && turn.status !== "generating") ||
|
|
306
|
+
turn.images.some((image) => image.status === "loading")
|
|
307
|
+
) {
|
|
308
|
+
return turn;
|
|
309
|
+
}
|
|
310
|
+
const derived = deriveTurnStatus(turn);
|
|
311
|
+
if (derived.status === turn.status && derived.error === turn.error) {
|
|
312
|
+
return turn;
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
...turn,
|
|
316
|
+
...derived,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function syncConversationImageTasks(items: ImageConversation[]) {
|
|
321
|
+
const taskIds = Array.from(
|
|
322
|
+
new Set(
|
|
323
|
+
items.flatMap((conversation) =>
|
|
324
|
+
conversation.turns.flatMap((turn) =>
|
|
325
|
+
turn.resultsDeleted
|
|
326
|
+
? []
|
|
327
|
+
: turn.images.flatMap((image) =>
|
|
328
|
+
(image.status === "loading" || (image.status === "error" && image.taskId))
|
|
329
|
+
? [image.taskId!]
|
|
330
|
+
: [],
|
|
331
|
+
),
|
|
332
|
+
),
|
|
333
|
+
),
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
if (taskIds.length === 0) {
|
|
337
|
+
return items;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let taskList: Awaited<ReturnType<typeof fetchImageTasks>>;
|
|
341
|
+
try {
|
|
342
|
+
taskList = await fetchImageTasks(taskIds);
|
|
343
|
+
} catch {
|
|
344
|
+
return items;
|
|
345
|
+
}
|
|
346
|
+
const taskMap = new Map(taskList.items.map((task) => [task.id, task]));
|
|
347
|
+
let changed = false;
|
|
348
|
+
const normalized = items.map((conversation) => {
|
|
349
|
+
const turns = conversation.turns.map((turn) => {
|
|
350
|
+
let turnChanged = false;
|
|
351
|
+
const images = turn.images.map((image) => {
|
|
352
|
+
if (!image.taskId) {
|
|
353
|
+
return image;
|
|
354
|
+
}
|
|
355
|
+
if (image.status !== "loading" && image.status !== "error") {
|
|
356
|
+
return image;
|
|
357
|
+
}
|
|
358
|
+
const task = taskMap.get(image.taskId);
|
|
359
|
+
if (!task) {
|
|
360
|
+
return image;
|
|
361
|
+
}
|
|
362
|
+
const nextImage = taskDataToStoredImage(image, task);
|
|
363
|
+
if (nextImage !== image) {
|
|
364
|
+
turnChanged = true;
|
|
365
|
+
}
|
|
366
|
+
return nextImage;
|
|
367
|
+
});
|
|
368
|
+
if (!turnChanged) {
|
|
369
|
+
return turn;
|
|
370
|
+
}
|
|
371
|
+
changed = true;
|
|
372
|
+
const derived = deriveTurnStatus({ ...turn, images });
|
|
373
|
+
return {
|
|
374
|
+
...turn,
|
|
375
|
+
...derived,
|
|
376
|
+
images,
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
if (turns === conversation.turns || !turns.some((turn, index) => turn !== conversation.turns[index])) {
|
|
380
|
+
return conversation;
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
...conversation,
|
|
384
|
+
turns,
|
|
385
|
+
updatedAt: new Date().toISOString(),
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
if (changed) {
|
|
390
|
+
await saveImageConversations(normalized);
|
|
391
|
+
}
|
|
392
|
+
return normalized;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function recoverConversationHistory(items: ImageConversation[]) {
|
|
396
|
+
let changed = false;
|
|
397
|
+
const normalized = items.map((conversation) => {
|
|
398
|
+
const turns = conversation.turns.map((turn) => {
|
|
399
|
+
if (turn.status !== "queued" && turn.status !== "generating" && turn.status !== "error") {
|
|
400
|
+
return turn;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let turnChanged = false;
|
|
404
|
+
const images = turn.images.map((image) => {
|
|
405
|
+
if (image.status !== "loading" || image.taskId) {
|
|
406
|
+
return image;
|
|
407
|
+
}
|
|
408
|
+
turnChanged = true;
|
|
409
|
+
return {
|
|
410
|
+
...image,
|
|
411
|
+
status: "error" as const,
|
|
412
|
+
error: "页面刷新或任务中断,未找到可恢复的任务 ID",
|
|
413
|
+
};
|
|
414
|
+
});
|
|
415
|
+
const candidateTurn = turnChanged ? { ...turn, images } : turn;
|
|
416
|
+
const nextTurn = finalizeIdleQueuedTurn(candidateTurn);
|
|
417
|
+
const derived = turnChanged ? deriveTurnStatus(nextTurn) : { status: nextTurn.status, error: nextTurn.error };
|
|
418
|
+
if (!turnChanged && nextTurn === turn && derived.status === turn.status && derived.error === turn.error) {
|
|
419
|
+
return turn;
|
|
420
|
+
}
|
|
421
|
+
changed = true;
|
|
422
|
+
return {
|
|
423
|
+
...nextTurn,
|
|
424
|
+
...derived,
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
if (!turns.some((turn, index) => turn !== conversation.turns[index])) {
|
|
429
|
+
return conversation;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
...conversation,
|
|
434
|
+
turns,
|
|
435
|
+
updatedAt: new Date().toISOString(),
|
|
436
|
+
};
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
if (changed) {
|
|
440
|
+
await saveImageConversations(normalized);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return syncConversationImageTasks(normalized);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
448
|
+
const didLoadQuotaRef = useRef(false);
|
|
449
|
+
const conversationsRef = useRef<ImageConversation[]>([]);
|
|
450
|
+
const loadCancelledRef = useRef(false);
|
|
451
|
+
const resultsViewportRef = useRef<HTMLDivElement>(null);
|
|
452
|
+
const lastConversationIdRef = useRef<string | null>(null);
|
|
453
|
+
const shouldStickToBottomRef = useRef(true);
|
|
454
|
+
const scrollRafRef = useRef<number | null>(null);
|
|
455
|
+
const scrollSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
456
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
457
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
458
|
+
const scrollPositionsRef = useRef<Map<string, number>>(loadScrollPositions());
|
|
459
|
+
const isRestoringScrollRef = useRef(false);
|
|
460
|
+
const scrollRestoreGenerationRef = useRef(0);
|
|
461
|
+
|
|
462
|
+
const config = useSettingsStore((state) => state.config);
|
|
463
|
+
const imageTimeoutRetrySecs = Number(config?.image_timeout_retry_secs || 30);
|
|
464
|
+
|
|
465
|
+
const [imagePrompt, setImagePrompt] = useState("");
|
|
466
|
+
const [imageCount, setImageCount] = useState("3");
|
|
467
|
+
const [imageRatio, setImageRatio] = useState("auto");
|
|
468
|
+
const [imageTier, setImageTier] = useState("1k");
|
|
469
|
+
const [imageWidth, setImageWidth] = useState("1024");
|
|
470
|
+
const [imageHeight, setImageHeight] = useState("1024");
|
|
471
|
+
const [imageQuality, setImageQuality] = useState("auto");
|
|
472
|
+
const [imageModel, setImageModel] = useState<ImageModel>("gpt-image-2");
|
|
473
|
+
const [imageModels, setImageModels] = useState<ImageModel[]>(["gpt-image-2"]);
|
|
474
|
+
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
|
475
|
+
const [referenceImageFiles, setReferenceImageFiles] = useState<File[]>([]);
|
|
476
|
+
const [referenceImages, setReferenceImages] = useState<StoredReferenceImage[]>([]);
|
|
477
|
+
const [conversations, setConversations] = useState<ImageConversation[]>([]);
|
|
478
|
+
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
|
|
479
|
+
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
|
480
|
+
const [availableQuota, setAvailableQuota] = useState("加载中...");
|
|
481
|
+
const [lightboxImages, setLightboxImages] = useState<ImageLightboxItem[]>([]);
|
|
482
|
+
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
483
|
+
const [lightboxIndex, setLightboxIndex] = useState(0);
|
|
484
|
+
const scrollToLatestBtnRef = useRef<HTMLButtonElement>(null);
|
|
485
|
+
const [deleteConfirm, setDeleteConfirm] = useState<
|
|
486
|
+
| { type: "one"; id: string }
|
|
487
|
+
| { type: "prompt"; conversationId: string; turnId: string }
|
|
488
|
+
| { type: "results"; conversationId: string; turnId: string }
|
|
489
|
+
| { type: "all" }
|
|
490
|
+
| null
|
|
491
|
+
>(null);
|
|
492
|
+
const [timeoutRetry, setTimeoutRetry] = useState<{
|
|
493
|
+
conversationId: string;
|
|
494
|
+
taskId: string;
|
|
495
|
+
taskError: string;
|
|
496
|
+
} | null>(null);
|
|
497
|
+
|
|
498
|
+
const parsedCount = useMemo(() => Number(clampImageCount(imageCount)), [imageCount]);
|
|
499
|
+
const selectedConversation = useMemo(
|
|
500
|
+
() => conversations.find((item) => item.id === selectedConversationId) ?? null,
|
|
501
|
+
[conversations, selectedConversationId],
|
|
502
|
+
);
|
|
503
|
+
const activeTaskCount = useMemo(
|
|
504
|
+
() =>
|
|
505
|
+
conversations.reduce((sum, conversation) => {
|
|
506
|
+
const stats = getImageConversationStats(conversation);
|
|
507
|
+
return sum + stats.queued + stats.running;
|
|
508
|
+
}, 0),
|
|
509
|
+
[conversations],
|
|
510
|
+
);
|
|
511
|
+
const deleteConfirmTitle =
|
|
512
|
+
deleteConfirm?.type === "all"
|
|
513
|
+
? "清空历史记录"
|
|
514
|
+
: deleteConfirm?.type === "prompt"
|
|
515
|
+
? "删除提示词记录"
|
|
516
|
+
: deleteConfirm?.type === "results"
|
|
517
|
+
? "删除生成结果"
|
|
518
|
+
: deleteConfirm?.type === "one"
|
|
519
|
+
? "删除对话"
|
|
520
|
+
: "";
|
|
521
|
+
const deleteConfirmDescription =
|
|
522
|
+
deleteConfirm?.type === "all"
|
|
523
|
+
? "确认删除全部图片历史记录吗?删除后无法恢复。"
|
|
524
|
+
: deleteConfirm?.type === "prompt"
|
|
525
|
+
? "确认删除这条提示词记录吗?对应生成结果会保留。"
|
|
526
|
+
: deleteConfirm?.type === "results"
|
|
527
|
+
? "确认删除这条生成结果吗?对应提示词记录会保留。"
|
|
528
|
+
: deleteConfirm?.type === "one"
|
|
529
|
+
? "确认删除这条图片对话吗?删除后无法恢复。"
|
|
530
|
+
: "";
|
|
531
|
+
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
conversationsRef.current = conversations;
|
|
534
|
+
}, [conversations]);
|
|
535
|
+
|
|
536
|
+
const scrollResultsToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
|
|
537
|
+
const element = resultsViewportRef.current;
|
|
538
|
+
if (!element) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
shouldStickToBottomRef.current = true;
|
|
543
|
+
const btn = scrollToLatestBtnRef.current;
|
|
544
|
+
if (btn) btn.style.display = "none";
|
|
545
|
+
element.scrollTo({
|
|
546
|
+
top: element.scrollHeight,
|
|
547
|
+
behavior,
|
|
548
|
+
});
|
|
549
|
+
}, []);
|
|
550
|
+
|
|
551
|
+
const handleResultsScroll = useCallback(() => {
|
|
552
|
+
if (scrollRafRef.current !== null) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
scrollRafRef.current = window.requestAnimationFrame(() => {
|
|
557
|
+
scrollRafRef.current = null;
|
|
558
|
+
const element = resultsViewportRef.current;
|
|
559
|
+
if (!element) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// 恢复滚动位置期间不处理滚动事件
|
|
564
|
+
if (isRestoringScrollRef.current) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// 保存当前会话的滚动位置(debounce 300ms 写入 sessionStorage)
|
|
569
|
+
const convId = lastConversationIdRef.current;
|
|
570
|
+
if (convId) {
|
|
571
|
+
scrollPositionsRef.current.set(convId, element.scrollTop);
|
|
572
|
+
if (scrollSaveTimerRef.current) clearTimeout(scrollSaveTimerRef.current);
|
|
573
|
+
scrollSaveTimerRef.current = setTimeout(() => {
|
|
574
|
+
scrollSaveTimerRef.current = null;
|
|
575
|
+
saveScrollPositions(scrollPositionsRef.current);
|
|
576
|
+
}, 300);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const isAwayFromLatest = getResultsDistanceFromBottom(element) > SCROLL_TO_LATEST_THRESHOLD;
|
|
580
|
+
shouldStickToBottomRef.current = !isAwayFromLatest;
|
|
581
|
+
// 直接操作 DOM 控制按钮显隐,避免 setState 触发全组件重渲染
|
|
582
|
+
const btn = scrollToLatestBtnRef.current;
|
|
583
|
+
if (btn) {
|
|
584
|
+
if (isAwayFromLatest) {
|
|
585
|
+
btn.style.display = "";
|
|
586
|
+
} else {
|
|
587
|
+
btn.style.display = "none";
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
}, []);
|
|
592
|
+
|
|
593
|
+
useEffect(() => {
|
|
594
|
+
return () => {
|
|
595
|
+
if (scrollRafRef.current !== null) {
|
|
596
|
+
window.cancelAnimationFrame(scrollRafRef.current);
|
|
597
|
+
}
|
|
598
|
+
if (scrollSaveTimerRef.current !== null) {
|
|
599
|
+
clearTimeout(scrollSaveTimerRef.current);
|
|
600
|
+
saveScrollPositions(scrollPositionsRef.current);
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
}, []);
|
|
604
|
+
|
|
605
|
+
const loadHistory = useCallback(async () => {
|
|
606
|
+
try {
|
|
607
|
+
const storedRatio =
|
|
608
|
+
typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_RATIO_STORAGE_KEY) : null;
|
|
609
|
+
const storedTier =
|
|
610
|
+
typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_TIER_STORAGE_KEY) : null;
|
|
611
|
+
const storedQuality =
|
|
612
|
+
typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_QUALITY_STORAGE_KEY) : null;
|
|
613
|
+
const storedCount =
|
|
614
|
+
typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_COUNT_STORAGE_KEY) : null;
|
|
615
|
+
setImageRatio(storedRatio || "1:1");
|
|
616
|
+
setImageTier(storedTier || "1k");
|
|
617
|
+
setImageWidth("1024");
|
|
618
|
+
setImageHeight("1024");
|
|
619
|
+
setImageQuality(storedQuality || "auto");
|
|
620
|
+
setImageCount(storedCount ? clampImageCount(storedCount) : "1");
|
|
621
|
+
|
|
622
|
+
const items = await listImageConversations();
|
|
623
|
+
const normalizedItems = await recoverConversationHistory(items);
|
|
624
|
+
if (loadCancelledRef.current) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
conversationsRef.current = normalizedItems;
|
|
629
|
+
setConversations(normalizedItems);
|
|
630
|
+
const storedConversationId =
|
|
631
|
+
typeof window !== "undefined" ? window.localStorage.getItem(ACTIVE_CONVERSATION_STORAGE_KEY) : null;
|
|
632
|
+
const nextSelectedConversationId =
|
|
633
|
+
(storedConversationId && normalizedItems.some((conversation) => conversation.id === storedConversationId)
|
|
634
|
+
? storedConversationId
|
|
635
|
+
: null) ?? pickFallbackConversationId(normalizedItems);
|
|
636
|
+
setSelectedConversationId(nextSelectedConversationId);
|
|
637
|
+
} catch (error) {
|
|
638
|
+
const message = error instanceof Error ? error.message : "读取会话记录失败";
|
|
639
|
+
toast.error(message);
|
|
640
|
+
} finally {
|
|
641
|
+
if (!loadCancelledRef.current) {
|
|
642
|
+
setIsLoadingHistory(false);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}, [
|
|
646
|
+
setImageRatio,
|
|
647
|
+
setImageTier,
|
|
648
|
+
setImageWidth,
|
|
649
|
+
setImageHeight,
|
|
650
|
+
setImageQuality,
|
|
651
|
+
setImageCount,
|
|
652
|
+
setConversations,
|
|
653
|
+
setSelectedConversationId,
|
|
654
|
+
setIsLoadingHistory,
|
|
655
|
+
]);
|
|
656
|
+
|
|
657
|
+
// Handle bfcache (back/forward cache) — re-sync task status on page restore
|
|
658
|
+
useEffect(() => {
|
|
659
|
+
const handlePageShow = (event: PageTransitionEvent) => {
|
|
660
|
+
if (event.persisted) {
|
|
661
|
+
void loadHistory();
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
window.addEventListener("pageshow", handlePageShow);
|
|
665
|
+
return () => window.removeEventListener("pageshow", handlePageShow);
|
|
666
|
+
}, [loadHistory]);
|
|
667
|
+
|
|
668
|
+
useEffect(() => {
|
|
669
|
+
loadCancelledRef.current = false;
|
|
670
|
+
void loadHistory();
|
|
671
|
+
return () => {
|
|
672
|
+
loadCancelledRef.current = true;
|
|
673
|
+
// 组件卸载时保存当前滚动位置到 sessionStorage
|
|
674
|
+
const element = resultsViewportRef.current;
|
|
675
|
+
const convId = lastConversationIdRef.current;
|
|
676
|
+
if (element && convId) {
|
|
677
|
+
scrollPositionsRef.current.set(convId, element.scrollTop);
|
|
678
|
+
saveScrollPositions(scrollPositionsRef.current);
|
|
679
|
+
}
|
|
680
|
+
activeConversationQueueIds.clear();
|
|
681
|
+
if (pollAbortController) {
|
|
682
|
+
pollAbortController.abort();
|
|
683
|
+
pollAbortController = null;
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
}, [loadHistory]);
|
|
687
|
+
|
|
688
|
+
useEffect(() => {
|
|
689
|
+
let cancelled = false;
|
|
690
|
+
|
|
691
|
+
const loadImageModels = async () => {
|
|
692
|
+
try {
|
|
693
|
+
const data = await fetchModels();
|
|
694
|
+
const available = filterImageModels(Array.isArray(data.data) ? data.data : []);
|
|
695
|
+
if (cancelled || available.length === 0) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
setImageModels(available);
|
|
699
|
+
const storedModel = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_MODEL_STORAGE_KEY) : null;
|
|
700
|
+
setImageModel((current) => {
|
|
701
|
+
if (available.includes(current)) {
|
|
702
|
+
return current;
|
|
703
|
+
}
|
|
704
|
+
return normalizeStoredImageModel(storedModel, available);
|
|
705
|
+
});
|
|
706
|
+
} catch {
|
|
707
|
+
if (!cancelled) {
|
|
708
|
+
setImageModels(["gpt-image-2"]);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
void loadImageModels();
|
|
714
|
+
return () => {
|
|
715
|
+
cancelled = true;
|
|
716
|
+
};
|
|
717
|
+
}, []);
|
|
718
|
+
|
|
719
|
+
const loadQuota = useCallback(async () => {
|
|
720
|
+
if (!isAdmin) {
|
|
721
|
+
setAvailableQuota("--");
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const data = await fetchAccounts();
|
|
726
|
+
setAvailableQuota(formatAvailableQuota(data.items));
|
|
727
|
+
} catch {
|
|
728
|
+
setAvailableQuota((prev) => (prev === "加载中..." ? "--" : prev));
|
|
729
|
+
}
|
|
730
|
+
}, [isAdmin]);
|
|
731
|
+
|
|
732
|
+
useEffect(() => {
|
|
733
|
+
if (didLoadQuotaRef.current) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
didLoadQuotaRef.current = true;
|
|
737
|
+
|
|
738
|
+
const handleFocus = () => {
|
|
739
|
+
void loadQuota();
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
void loadQuota();
|
|
743
|
+
window.addEventListener("focus", handleFocus);
|
|
744
|
+
return () => {
|
|
745
|
+
window.removeEventListener("focus", handleFocus);
|
|
746
|
+
};
|
|
747
|
+
}, [isAdmin, loadQuota]);
|
|
748
|
+
|
|
749
|
+
// 切换会话时保存旧会话滚动位置,并隐藏容器防止闪烁
|
|
750
|
+
useLayoutEffect(() => {
|
|
751
|
+
if (!selectedConversation) {
|
|
752
|
+
lastConversationIdRef.current = null;
|
|
753
|
+
shouldStickToBottomRef.current = true;
|
|
754
|
+
const btn = scrollToLatestBtnRef.current;
|
|
755
|
+
if (btn) btn.style.display = "none";
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const element = resultsViewportRef.current;
|
|
760
|
+
if (!element) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const didSwitchConversation = lastConversationIdRef.current !== selectedConversation.id;
|
|
765
|
+
|
|
766
|
+
if (didSwitchConversation) {
|
|
767
|
+
// 递增 generation,使之前未完成的 rAF 回调失效
|
|
768
|
+
scrollRestoreGenerationRef.current += 1;
|
|
769
|
+
|
|
770
|
+
// 先保存旧会话的滚动位置(lastConversationIdRef 还是旧值)
|
|
771
|
+
const oldConvId = lastConversationIdRef.current;
|
|
772
|
+
if (oldConvId) {
|
|
773
|
+
scrollPositionsRef.current.set(oldConvId, element.scrollTop);
|
|
774
|
+
saveScrollPositions(scrollPositionsRef.current);
|
|
775
|
+
}
|
|
776
|
+
// 更新为新会话 ID
|
|
777
|
+
lastConversationIdRef.current = selectedConversation.id;
|
|
778
|
+
|
|
779
|
+
// 如果有保存的滚动位置,隐藏容器防止用户看到 scrollTop=0 的内容
|
|
780
|
+
const savedScrollTop = scrollPositionsRef.current.get(selectedConversation.id);
|
|
781
|
+
if (savedScrollTop != null && savedScrollTop > 0) {
|
|
782
|
+
element.style.visibility = "hidden";
|
|
783
|
+
isRestoringScrollRef.current = true;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}, [selectedConversation?.id]);
|
|
787
|
+
|
|
788
|
+
// 恢复滚动位置或跟随最新内容
|
|
789
|
+
useEffect(() => {
|
|
790
|
+
if (!selectedConversation) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const element = resultsViewportRef.current;
|
|
795
|
+
if (!element) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const savedScrollTop = scrollPositionsRef.current.get(selectedConversation.id);
|
|
800
|
+
|
|
801
|
+
if (savedScrollTop != null && savedScrollTop > 0) {
|
|
802
|
+
// 捕获当前 generation,用于检测是否已被新的切换取代
|
|
803
|
+
const generation = scrollRestoreGenerationRef.current;
|
|
804
|
+
// 容器已在 useLayoutEffect 中设为 visibility:hidden,用户看不到滚动过程
|
|
805
|
+
requestAnimationFrame(() => {
|
|
806
|
+
// 如果 generation 已变,说明用户又切换了,放弃本次恢复
|
|
807
|
+
if (scrollRestoreGenerationRef.current !== generation) return;
|
|
808
|
+
element.scrollTop = savedScrollTop;
|
|
809
|
+
// 再等一帧确保 scrollTop 生效后再显示容器
|
|
810
|
+
requestAnimationFrame(() => {
|
|
811
|
+
// 再次检查 generation
|
|
812
|
+
if (scrollRestoreGenerationRef.current !== generation) return;
|
|
813
|
+
const isAwayFromLatest = getResultsDistanceFromBottom(element) > SCROLL_TO_LATEST_THRESHOLD;
|
|
814
|
+
shouldStickToBottomRef.current = !isAwayFromLatest;
|
|
815
|
+
const btn = scrollToLatestBtnRef.current;
|
|
816
|
+
if (btn) btn.style.display = isAwayFromLatest ? "" : "none";
|
|
817
|
+
// 显示容器 — 用户直接看到正确位置的内容
|
|
818
|
+
element.style.visibility = "";
|
|
819
|
+
isRestoringScrollRef.current = false;
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
// 恢复后清除保存的位置,下次内容更新时走正常的 shouldFollowLatest 逻辑
|
|
823
|
+
scrollPositionsRef.current.delete(selectedConversation.id);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// 无保存位置,按正常逻辑处理
|
|
828
|
+
const shouldFollowLatest =
|
|
829
|
+
shouldStickToBottomRef.current ||
|
|
830
|
+
getResultsDistanceFromBottom(element) <= SCROLL_TO_LATEST_THRESHOLD;
|
|
831
|
+
|
|
832
|
+
if (shouldFollowLatest) {
|
|
833
|
+
requestAnimationFrame(() => scrollResultsToLatest("smooth"));
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const btn = scrollToLatestBtnRef.current;
|
|
838
|
+
if (btn) btn.style.display = "";
|
|
839
|
+
}, [selectedConversation?.id, selectedConversation?.updatedAt, selectedConversation?.turns.length, scrollResultsToLatest]);
|
|
840
|
+
|
|
841
|
+
useEffect(() => {
|
|
842
|
+
if (typeof window === "undefined") {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (selectedConversationId) {
|
|
847
|
+
window.localStorage.setItem(ACTIVE_CONVERSATION_STORAGE_KEY, selectedConversationId);
|
|
848
|
+
} else {
|
|
849
|
+
window.localStorage.removeItem(ACTIVE_CONVERSATION_STORAGE_KEY);
|
|
850
|
+
}
|
|
851
|
+
}, [selectedConversationId]);
|
|
852
|
+
|
|
853
|
+
useEffect(() => {
|
|
854
|
+
if (typeof window === "undefined") {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
window.localStorage.setItem(IMAGE_RATIO_STORAGE_KEY, imageRatio);
|
|
859
|
+
window.localStorage.setItem(IMAGE_TIER_STORAGE_KEY, imageTier);
|
|
860
|
+
window.localStorage.setItem(IMAGE_QUALITY_STORAGE_KEY, imageQuality);
|
|
861
|
+
window.localStorage.setItem(IMAGE_MODEL_STORAGE_KEY, imageModel);
|
|
862
|
+
}, [imageRatio, imageTier, imageQuality, imageModel]);
|
|
863
|
+
|
|
864
|
+
useEffect(() => {
|
|
865
|
+
if (typeof window !== "undefined" && parsedCount > 0) {
|
|
866
|
+
window.localStorage.setItem(IMAGE_COUNT_STORAGE_KEY, String(parsedCount));
|
|
867
|
+
}
|
|
868
|
+
}, [parsedCount]);
|
|
869
|
+
|
|
870
|
+
useEffect(() => {
|
|
871
|
+
if (selectedConversationId && !conversations.some((conversation) => conversation.id === selectedConversationId)) {
|
|
872
|
+
setSelectedConversationId(pickFallbackConversationId(conversations));
|
|
873
|
+
}
|
|
874
|
+
}, [conversations, selectedConversationId]);
|
|
875
|
+
|
|
876
|
+
const persistConversation = async (conversation: ImageConversation) => {
|
|
877
|
+
const nextConversations = sortImageConversations([
|
|
878
|
+
conversation,
|
|
879
|
+
...conversationsRef.current.filter((item) => item.id !== conversation.id),
|
|
880
|
+
]);
|
|
881
|
+
conversationsRef.current = nextConversations;
|
|
882
|
+
setConversations(nextConversations);
|
|
883
|
+
await saveImageConversation(conversation);
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const updateConversation = useCallback(
|
|
887
|
+
async (
|
|
888
|
+
conversationId: string,
|
|
889
|
+
updater: (current: ImageConversation | null) => ImageConversation,
|
|
890
|
+
options: { persist?: boolean } = {},
|
|
891
|
+
) => {
|
|
892
|
+
const current = conversationsRef.current.find((item) => item.id === conversationId) ?? null;
|
|
893
|
+
const nextConversation = updater(current);
|
|
894
|
+
const nextConversations = sortImageConversations([
|
|
895
|
+
nextConversation,
|
|
896
|
+
...conversationsRef.current.filter((item) => item.id !== conversationId),
|
|
897
|
+
]);
|
|
898
|
+
conversationsRef.current = nextConversations;
|
|
899
|
+
setConversations(nextConversations);
|
|
900
|
+
if (options.persist !== false) {
|
|
901
|
+
await saveImageConversation(nextConversation);
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
[],
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
const clearComposerInputs = useCallback(() => {
|
|
908
|
+
setImagePrompt("");
|
|
909
|
+
setReferenceImageFiles([]);
|
|
910
|
+
setReferenceImages([]);
|
|
911
|
+
if (fileInputRef.current) {
|
|
912
|
+
fileInputRef.current.value = "";
|
|
913
|
+
}
|
|
914
|
+
}, []);
|
|
915
|
+
|
|
916
|
+
const resetComposer = useCallback(() => {
|
|
917
|
+
clearComposerInputs();
|
|
918
|
+
}, [clearComposerInputs]);
|
|
919
|
+
|
|
920
|
+
const handleCreateDraft = () => {
|
|
921
|
+
shouldStickToBottomRef.current = true;
|
|
922
|
+
const btn = scrollToLatestBtnRef.current;
|
|
923
|
+
if (btn) btn.style.display = "none";
|
|
924
|
+
setSelectedConversationId(null);
|
|
925
|
+
resetComposer();
|
|
926
|
+
textareaRef.current?.focus();
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const handleDeleteConversation = async (id: string) => {
|
|
930
|
+
const nextConversations = conversations.filter((item) => item.id !== id);
|
|
931
|
+
conversationsRef.current = nextConversations;
|
|
932
|
+
setConversations(nextConversations);
|
|
933
|
+
if (selectedConversationId === id) {
|
|
934
|
+
setSelectedConversationId(pickFallbackConversationId(nextConversations));
|
|
935
|
+
resetComposer();
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
try {
|
|
939
|
+
await deleteImageConversation(id);
|
|
940
|
+
} catch (error) {
|
|
941
|
+
const message = error instanceof Error ? error.message : "删除会话失败";
|
|
942
|
+
toast.error(message);
|
|
943
|
+
const items = await listImageConversations();
|
|
944
|
+
conversationsRef.current = items;
|
|
945
|
+
setConversations(items);
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
const handleDeleteTurnPart = async (conversationId: string, turnId: string, part: "prompt" | "results") => {
|
|
950
|
+
const conversation = conversationsRef.current.find((item) => item.id === conversationId);
|
|
951
|
+
if (!conversation) {
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const turns = conversation.turns
|
|
956
|
+
.map((turn) => {
|
|
957
|
+
if (turn.id !== turnId) {
|
|
958
|
+
return turn;
|
|
959
|
+
}
|
|
960
|
+
const images =
|
|
961
|
+
part === "results"
|
|
962
|
+
? turn.images.map((image) => ({ id: image.id, status: "error" as const, error: "生成结果已删除" }))
|
|
963
|
+
: turn.images;
|
|
964
|
+
const derived =
|
|
965
|
+
part === "results"
|
|
966
|
+
? deriveTurnStatus({
|
|
967
|
+
...turn,
|
|
968
|
+
images,
|
|
969
|
+
})
|
|
970
|
+
: { status: turn.status, error: turn.error };
|
|
971
|
+
const nextTurn = {
|
|
972
|
+
...turn,
|
|
973
|
+
prompt: part === "prompt" ? "" : turn.prompt,
|
|
974
|
+
promptDeleted: part === "prompt" ? true : turn.promptDeleted,
|
|
975
|
+
resultsDeleted: part === "results" ? true : turn.resultsDeleted,
|
|
976
|
+
...derived,
|
|
977
|
+
images,
|
|
978
|
+
};
|
|
979
|
+
return nextTurn.promptDeleted && nextTurn.resultsDeleted ? null : nextTurn;
|
|
980
|
+
})
|
|
981
|
+
.filter((turn): turn is ImageTurn => Boolean(turn));
|
|
982
|
+
|
|
983
|
+
if (turns.length === 0) {
|
|
984
|
+
await handleDeleteConversation(conversationId);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const nextConversation = {
|
|
989
|
+
...conversation,
|
|
990
|
+
updatedAt: new Date().toISOString(),
|
|
991
|
+
turns,
|
|
992
|
+
};
|
|
993
|
+
await persistConversation(nextConversation);
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
const handleClearHistory = async () => {
|
|
997
|
+
try {
|
|
998
|
+
await clearImageConversations();
|
|
999
|
+
conversationsRef.current = [];
|
|
1000
|
+
setConversations([]);
|
|
1001
|
+
setSelectedConversationId(null);
|
|
1002
|
+
resetComposer();
|
|
1003
|
+
toast.success("已清空历史记录");
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
const message = error instanceof Error ? error.message : "清空历史记录失败";
|
|
1006
|
+
toast.error(message);
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
const handleRenameConversation = async (id: string, title: string) => {
|
|
1011
|
+
const nextConversations = conversations.map((item) =>
|
|
1012
|
+
item.id === id ? { ...item, title, updatedAt: new Date().toISOString() } : item,
|
|
1013
|
+
);
|
|
1014
|
+
conversationsRef.current = sortImageConversations(nextConversations);
|
|
1015
|
+
setConversations(conversationsRef.current);
|
|
1016
|
+
try {
|
|
1017
|
+
await renameImageConversation(id, title);
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
const message = error instanceof Error ? error.message : "重命名失败";
|
|
1020
|
+
toast.error(message);
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const openDeleteConversationConfirm = (id: string) => {
|
|
1025
|
+
setIsHistoryOpen(false);
|
|
1026
|
+
setDeleteConfirm({ type: "one", id });
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
const openDeletePromptConfirm = (conversationId: string, turnId: string) => {
|
|
1030
|
+
setDeleteConfirm({ type: "prompt", conversationId, turnId });
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
const openDeleteResultsConfirm = (conversationId: string, turnId: string) => {
|
|
1034
|
+
setDeleteConfirm({ type: "results", conversationId, turnId });
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
const openClearHistoryConfirm = () => {
|
|
1038
|
+
setIsHistoryOpen(false);
|
|
1039
|
+
setDeleteConfirm({ type: "all" });
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const handleConfirmDelete = async () => {
|
|
1043
|
+
const target = deleteConfirm;
|
|
1044
|
+
setDeleteConfirm(null);
|
|
1045
|
+
if (!target) {
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (target.type === "all") {
|
|
1049
|
+
await handleClearHistory();
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (target.type === "prompt" || target.type === "results") {
|
|
1053
|
+
await handleDeleteTurnPart(target.conversationId, target.turnId, target.type);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
await handleDeleteConversation(target.id);
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
const appendReferenceImages = useCallback(async (files: File[]) => {
|
|
1060
|
+
if (files.length === 0) {
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
try {
|
|
1065
|
+
const previews = await Promise.all(
|
|
1066
|
+
files.map(async (file) => ({
|
|
1067
|
+
name: file.name,
|
|
1068
|
+
type: file.type || "image/png",
|
|
1069
|
+
dataUrl: await readFileAsDataUrl(file),
|
|
1070
|
+
})),
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
setReferenceImageFiles((prev) => [...prev, ...files]);
|
|
1074
|
+
setReferenceImages((prev) => [...prev, ...previews]);
|
|
1075
|
+
if (fileInputRef.current) {
|
|
1076
|
+
fileInputRef.current.value = "";
|
|
1077
|
+
}
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
const message = error instanceof Error ? error.message : "读取参考图失败";
|
|
1080
|
+
toast.error(message);
|
|
1081
|
+
}
|
|
1082
|
+
}, []);
|
|
1083
|
+
|
|
1084
|
+
const handleReferenceImageChange = useCallback(
|
|
1085
|
+
async (files: File[]) => {
|
|
1086
|
+
if (files.length === 0) {
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
await appendReferenceImages(files);
|
|
1091
|
+
},
|
|
1092
|
+
[appendReferenceImages],
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
const handleRemoveReferenceImage = useCallback((index: number) => {
|
|
1096
|
+
setReferenceImageFiles((prev) => {
|
|
1097
|
+
const next = prev.filter((_, currentIndex) => currentIndex !== index);
|
|
1098
|
+
if (next.length === 0 && fileInputRef.current) {
|
|
1099
|
+
fileInputRef.current.value = "";
|
|
1100
|
+
}
|
|
1101
|
+
return next;
|
|
1102
|
+
});
|
|
1103
|
+
setReferenceImages((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
|
|
1104
|
+
}, []);
|
|
1105
|
+
|
|
1106
|
+
const handleContinueEdit = useCallback(
|
|
1107
|
+
async (conversationId: string, image: StoredImage | StoredReferenceImage) => {
|
|
1108
|
+
try {
|
|
1109
|
+
const nextReference =
|
|
1110
|
+
"dataUrl" in image
|
|
1111
|
+
? {
|
|
1112
|
+
referenceImage: image,
|
|
1113
|
+
file: dataUrlToFile(image.dataUrl, image.name, image.type),
|
|
1114
|
+
}
|
|
1115
|
+
: await buildReferenceImageFromStoredImage(image, `conversation-${conversationId}-${Date.now()}.png`);
|
|
1116
|
+
if (!nextReference) {
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
setSelectedConversationId(conversationId);
|
|
1121
|
+
|
|
1122
|
+
setReferenceImages((prev) => [...prev, nextReference.referenceImage]);
|
|
1123
|
+
setReferenceImageFiles((prev) => [...prev, nextReference.file]);
|
|
1124
|
+
setImagePrompt("");
|
|
1125
|
+
textareaRef.current?.focus();
|
|
1126
|
+
toast.success("已加入当前参考图,继续输入描述即可编辑");
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
const message = error instanceof Error ? error.message : "读取结果图失败";
|
|
1129
|
+
toast.error(message);
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1132
|
+
[],
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
const handleReuseTurnConfig = useCallback(async (conversationId: string, turnId: string) => {
|
|
1136
|
+
const conversation = conversationsRef.current.find((item) => item.id === conversationId);
|
|
1137
|
+
const turn = conversation?.turns.find((item) => item.id === turnId);
|
|
1138
|
+
if (!conversation || !turn || !turn.prompt.trim()) {
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
setSelectedConversationId(conversationId);
|
|
1143
|
+
setImagePrompt(turn.prompt);
|
|
1144
|
+
setImageCount(String(Math.max(1, turn.count || turn.images.length || 1)));
|
|
1145
|
+
setImageRatio(turn.ratio);
|
|
1146
|
+
setImageTier(turn.tier);
|
|
1147
|
+
const parsedSize = parseImageSize(turn.size);
|
|
1148
|
+
setImageWidth(parsedSize.width);
|
|
1149
|
+
setImageHeight(parsedSize.height);
|
|
1150
|
+
setImageQuality(turn.quality);
|
|
1151
|
+
setImageModel(turn.model);
|
|
1152
|
+
setReferenceImages(turn.referenceImages);
|
|
1153
|
+
setReferenceImageFiles(
|
|
1154
|
+
turn.referenceImages.map((image) => dataUrlToFile(image.dataUrl, image.name, image.type)),
|
|
1155
|
+
);
|
|
1156
|
+
if (fileInputRef.current) {
|
|
1157
|
+
fileInputRef.current.value = "";
|
|
1158
|
+
}
|
|
1159
|
+
textareaRef.current?.focus();
|
|
1160
|
+
toast.success("已复用这条提示词配置");
|
|
1161
|
+
}, []);
|
|
1162
|
+
|
|
1163
|
+
const openLightbox = useCallback((images: ImageLightboxItem[], index: number) => {
|
|
1164
|
+
if (images.length === 0) {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
setLightboxImages(images);
|
|
1169
|
+
setLightboxIndex(Math.max(0, Math.min(index, images.length - 1)));
|
|
1170
|
+
setLightboxOpen(true);
|
|
1171
|
+
}, []);
|
|
1172
|
+
|
|
1173
|
+
const createLoadingImages = (turnId: string, count: number) =>
|
|
1174
|
+
Array.from({ length: count }, (_, index) => {
|
|
1175
|
+
const imageId = `${turnId}-${index}`;
|
|
1176
|
+
return {
|
|
1177
|
+
id: imageId,
|
|
1178
|
+
taskId: imageId,
|
|
1179
|
+
status: "loading" as const,
|
|
1180
|
+
};
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
/* eslint-disable react-hooks/preserve-manual-memoization */
|
|
1184
|
+
const runConversationQueue = useCallback(
|
|
1185
|
+
async (conversationId: string) => {
|
|
1186
|
+
if (activeConversationQueueIds.has(conversationId)) {
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const snapshot = conversationsRef.current.find((conversation) => conversation.id === conversationId);
|
|
1191
|
+
const activeTurn = snapshot?.turns.find(
|
|
1192
|
+
(turn) =>
|
|
1193
|
+
(turn.status === "queued" || turn.status === "generating") &&
|
|
1194
|
+
turn.images.some((image) => image.status === "loading"),
|
|
1195
|
+
);
|
|
1196
|
+
if (!snapshot || !activeTurn) {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
activeConversationQueueIds.add(conversationId);
|
|
1201
|
+
const applyTasks = async (tasks: ImageTask[]) => {
|
|
1202
|
+
const taskMap = new Map(tasks.map((task) => [task.id, task]));
|
|
1203
|
+
await updateConversation(conversationId, (current) => {
|
|
1204
|
+
const conversation = current ?? snapshot;
|
|
1205
|
+
const turns = conversation.turns.map((turn) => {
|
|
1206
|
+
if (turn.id !== activeTurn.id) {
|
|
1207
|
+
return turn;
|
|
1208
|
+
}
|
|
1209
|
+
const images = turn.images.map((image) => {
|
|
1210
|
+
const taskId = image.taskId || image.id;
|
|
1211
|
+
const task = taskMap.get(taskId);
|
|
1212
|
+
return task ? taskDataToStoredImage({ ...image, taskId }, task) : image;
|
|
1213
|
+
});
|
|
1214
|
+
const derived = deriveTurnStatus({ ...turn, images });
|
|
1215
|
+
return {
|
|
1216
|
+
...turn,
|
|
1217
|
+
...derived,
|
|
1218
|
+
images,
|
|
1219
|
+
};
|
|
1220
|
+
});
|
|
1221
|
+
return {
|
|
1222
|
+
...conversation,
|
|
1223
|
+
updatedAt: new Date().toISOString(),
|
|
1224
|
+
turns,
|
|
1225
|
+
};
|
|
1226
|
+
});
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
try {
|
|
1230
|
+
|
|
1231
|
+
const referenceFiles = activeTurn.referenceImages.map((image, index) =>
|
|
1232
|
+
dataUrlToFile(image.dataUrl, image.name || `${activeTurn.id}-${index + 1}.png`, image.type),
|
|
1233
|
+
);
|
|
1234
|
+
if (activeTurn.mode === "edit" && referenceFiles.length === 0) {
|
|
1235
|
+
throw new Error("未找到可用于继续编辑的参考图");
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const pendingImages = activeTurn.images.filter((image) => image.status === "loading");
|
|
1239
|
+
const submitted = await Promise.all(
|
|
1240
|
+
pendingImages.map((image) => {
|
|
1241
|
+
const taskId = image.taskId || image.id;
|
|
1242
|
+
return activeTurn.mode === "edit"
|
|
1243
|
+
? createImageEditTask(taskId, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality)
|
|
1244
|
+
: createImageGenerationTask(taskId, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality);
|
|
1245
|
+
}),
|
|
1246
|
+
);
|
|
1247
|
+
await applyTasks(submitted);
|
|
1248
|
+
|
|
1249
|
+
let consecutiveErrors = 0;
|
|
1250
|
+
const retryingTaskIdsRef = new Set<string>();
|
|
1251
|
+
while (true) {
|
|
1252
|
+
const latestConversation = conversationsRef.current.find((conversation) => conversation.id === conversationId);
|
|
1253
|
+
const latestTurn = latestConversation?.turns.find((turn) => turn.id === activeTurn.id);
|
|
1254
|
+
const loadingTaskIds =
|
|
1255
|
+
latestTurn?.images.flatMap((image) =>
|
|
1256
|
+
image.status === "loading" && image.taskId ? [image.taskId] : [],
|
|
1257
|
+
) || [];
|
|
1258
|
+
if (loadingTaskIds.length === 0) {
|
|
1259
|
+
break;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
await sleep(2000);
|
|
1263
|
+
try {
|
|
1264
|
+
const taskList = await fetchImageTasks(loadingTaskIds);
|
|
1265
|
+
consecutiveErrors = 0;
|
|
1266
|
+
if (taskList.items.length > 0) {
|
|
1267
|
+
// 检测是否有超时错误且需要显示重试按钮
|
|
1268
|
+
const timeoutTask = taskList.items.find(
|
|
1269
|
+
(task) =>
|
|
1270
|
+
task.status === "error" &&
|
|
1271
|
+
task.error?.includes("超时") &&
|
|
1272
|
+
task.conversation_id &&
|
|
1273
|
+
!retryingTaskIdsRef.has(task.id),
|
|
1274
|
+
);
|
|
1275
|
+
if (timeoutTask && timeoutTask.conversation_id) {
|
|
1276
|
+
retryingTaskIdsRef.add(timeoutTask.id);
|
|
1277
|
+
setTimeoutRetry({
|
|
1278
|
+
conversationId: timeoutTask.conversation_id,
|
|
1279
|
+
taskId: timeoutTask.id,
|
|
1280
|
+
taskError: timeoutTask.error || "生图超时",
|
|
1281
|
+
});
|
|
1282
|
+
// 应用超时错误到对应图片,显示继续等待按钮
|
|
1283
|
+
await applyTasks([timeoutTask]);
|
|
1284
|
+
} else {
|
|
1285
|
+
await applyTasks(taskList.items);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
if (taskList.missing_ids.length > 0 && latestTurn) {
|
|
1289
|
+
const missingImages = latestTurn.images.filter(
|
|
1290
|
+
(image) => image.status === "loading" && image.taskId && taskList.missing_ids.includes(image.taskId),
|
|
1291
|
+
);
|
|
1292
|
+
const resubmitted = await Promise.all(
|
|
1293
|
+
missingImages.map((image) =>
|
|
1294
|
+
activeTurn.mode === "edit"
|
|
1295
|
+
? createImageEditTask(image.taskId || image.id, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality)
|
|
1296
|
+
: createImageGenerationTask(image.taskId || image.id, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality),
|
|
1297
|
+
),
|
|
1298
|
+
);
|
|
1299
|
+
if (resubmitted.length > 0) {
|
|
1300
|
+
await applyTasks(resubmitted);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
} catch (pollError) {
|
|
1304
|
+
consecutiveErrors += 1;
|
|
1305
|
+
if (consecutiveErrors >= 10) {
|
|
1306
|
+
throw pollError;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
await loadQuota();
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
const message = error instanceof Error ? error.message : "生成图片失败";
|
|
1314
|
+
await updateConversation(conversationId, (current) => {
|
|
1315
|
+
const conversation = current ?? snapshot;
|
|
1316
|
+
return {
|
|
1317
|
+
...conversation,
|
|
1318
|
+
updatedAt: new Date().toISOString(),
|
|
1319
|
+
turns: conversation.turns.map((turn) =>
|
|
1320
|
+
turn.id === activeTurn.id
|
|
1321
|
+
? {
|
|
1322
|
+
...turn,
|
|
1323
|
+
status: "error",
|
|
1324
|
+
error: message,
|
|
1325
|
+
images: turn.images.map((image) =>
|
|
1326
|
+
image.status === "loading" ? { ...image, status: "error", error: message } : image,
|
|
1327
|
+
),
|
|
1328
|
+
}
|
|
1329
|
+
: turn,
|
|
1330
|
+
),
|
|
1331
|
+
};
|
|
1332
|
+
});
|
|
1333
|
+
toast.error(message);
|
|
1334
|
+
} finally {
|
|
1335
|
+
activeConversationQueueIds.delete(conversationId);
|
|
1336
|
+
for (const conversation of conversationsRef.current) {
|
|
1337
|
+
if (
|
|
1338
|
+
!activeConversationQueueIds.has(conversation.id) &&
|
|
1339
|
+
conversation.turns.some(
|
|
1340
|
+
(turn) =>
|
|
1341
|
+
(turn.status === "queued" || turn.status === "generating") &&
|
|
1342
|
+
turn.images.some((image) => image.status === "loading"),
|
|
1343
|
+
)
|
|
1344
|
+
) {
|
|
1345
|
+
void runConversationQueue(conversation.id);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
},
|
|
1350
|
+
[loadQuota, updateConversation],
|
|
1351
|
+
);
|
|
1352
|
+
/* eslint-enable react-hooks/preserve-manual-memoization */
|
|
1353
|
+
|
|
1354
|
+
const handleRegenerateTurn = useCallback(
|
|
1355
|
+
async (conversationId: string, turnId: string) => {
|
|
1356
|
+
const conversation = conversationsRef.current.find((item) => item.id === conversationId);
|
|
1357
|
+
const sourceTurn = conversation?.turns.find((turn) => turn.id === turnId);
|
|
1358
|
+
if (!conversation || !sourceTurn || !sourceTurn.prompt.trim()) {
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const now = new Date().toISOString();
|
|
1363
|
+
const nextTurnId = createId();
|
|
1364
|
+
const count = Math.max(1, sourceTurn.count || sourceTurn.images.length || 1);
|
|
1365
|
+
const nextTurn: ImageTurn = {
|
|
1366
|
+
id: nextTurnId,
|
|
1367
|
+
prompt: sourceTurn.prompt,
|
|
1368
|
+
model: sourceTurn.model,
|
|
1369
|
+
mode: sourceTurn.mode,
|
|
1370
|
+
referenceImages: sourceTurn.referenceImages,
|
|
1371
|
+
count,
|
|
1372
|
+
size: sourceTurn.size,
|
|
1373
|
+
ratio: sourceTurn.ratio,
|
|
1374
|
+
tier: sourceTurn.tier,
|
|
1375
|
+
quality: sourceTurn.quality,
|
|
1376
|
+
images: createLoadingImages(nextTurnId, count),
|
|
1377
|
+
createdAt: now,
|
|
1378
|
+
status: "queued",
|
|
1379
|
+
};
|
|
1380
|
+
const nextConversation = {
|
|
1381
|
+
...conversation,
|
|
1382
|
+
updatedAt: now,
|
|
1383
|
+
turns: [...conversation.turns, nextTurn],
|
|
1384
|
+
};
|
|
1385
|
+
|
|
1386
|
+
setSelectedConversationId(conversationId);
|
|
1387
|
+
await persistConversation(nextConversation);
|
|
1388
|
+
void runConversationQueue(conversationId);
|
|
1389
|
+
toast.success("已加入重新生成队列");
|
|
1390
|
+
},
|
|
1391
|
+
[runConversationQueue],
|
|
1392
|
+
);
|
|
1393
|
+
|
|
1394
|
+
const handleRetryImage = useCallback(
|
|
1395
|
+
async (conversationId: string, turnId: string, imageId: string) => {
|
|
1396
|
+
const conversation = conversationsRef.current.find((item) => item.id === conversationId);
|
|
1397
|
+
if (!conversation) {
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const now = new Date().toISOString();
|
|
1402
|
+
const retryImageId = `${turnId}-${createId()}`;
|
|
1403
|
+
const nextConversation = {
|
|
1404
|
+
...conversation,
|
|
1405
|
+
updatedAt: now,
|
|
1406
|
+
turns: conversation.turns.map((turn) => {
|
|
1407
|
+
if (turn.id !== turnId) {
|
|
1408
|
+
return turn;
|
|
1409
|
+
}
|
|
1410
|
+
if (!turn.prompt.trim()) {
|
|
1411
|
+
return turn;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const images = turn.images.map((image) =>
|
|
1415
|
+
image.id === imageId
|
|
1416
|
+
? {
|
|
1417
|
+
id: retryImageId,
|
|
1418
|
+
taskId: retryImageId,
|
|
1419
|
+
status: "loading" as const,
|
|
1420
|
+
}
|
|
1421
|
+
: image,
|
|
1422
|
+
);
|
|
1423
|
+
const derived = deriveTurnStatus({ ...turn, status: "queued", images });
|
|
1424
|
+
return {
|
|
1425
|
+
...turn,
|
|
1426
|
+
...derived,
|
|
1427
|
+
images,
|
|
1428
|
+
};
|
|
1429
|
+
}),
|
|
1430
|
+
};
|
|
1431
|
+
|
|
1432
|
+
setSelectedConversationId(conversationId);
|
|
1433
|
+
await persistConversation(nextConversation);
|
|
1434
|
+
void runConversationQueue(conversationId);
|
|
1435
|
+
},
|
|
1436
|
+
[runConversationQueue],
|
|
1437
|
+
);
|
|
1438
|
+
|
|
1439
|
+
const handleTimeoutRetryContinue = useCallback(async () => {
|
|
1440
|
+
if (!timeoutRetry) return;
|
|
1441
|
+
const { conversationId, taskId } = timeoutRetry;
|
|
1442
|
+
try {
|
|
1443
|
+
await resumeImagePoll(taskId, imageTimeoutRetrySecs);
|
|
1444
|
+
// 将对应图片的状态重置为 loading,并清除错误
|
|
1445
|
+
void updateConversation(conversationId, (current) => {
|
|
1446
|
+
const conversation = current ?? conversationsRef.current.find((c) => c.id === conversationId);
|
|
1447
|
+
if (!conversation) return current!;
|
|
1448
|
+
return {
|
|
1449
|
+
...conversation,
|
|
1450
|
+
updatedAt: new Date().toISOString(),
|
|
1451
|
+
turns: conversation.turns.map((turn) => {
|
|
1452
|
+
const hasLoading = turn.images.some((image) => image.taskId === taskId);
|
|
1453
|
+
if (!hasLoading) return turn;
|
|
1454
|
+
return {
|
|
1455
|
+
...turn,
|
|
1456
|
+
status: "generating" as const,
|
|
1457
|
+
error: undefined,
|
|
1458
|
+
images: turn.images.map((image) =>
|
|
1459
|
+
image.taskId === taskId
|
|
1460
|
+
? { ...image, status: "loading" as const, error: undefined, taskStatus: "running" as const, startTime: image.startTime || Date.now() }
|
|
1461
|
+
: image
|
|
1462
|
+
),
|
|
1463
|
+
};
|
|
1464
|
+
}),
|
|
1465
|
+
};
|
|
1466
|
+
});
|
|
1467
|
+
// 清除重试状态
|
|
1468
|
+
setTimeoutRetry(null);
|
|
1469
|
+
toast.info(`已继续等待 ${imageTimeoutRetrySecs} 秒`);
|
|
1470
|
+
} catch (err) {
|
|
1471
|
+
const msg = err instanceof Error ? err.message : "续轮询失败";
|
|
1472
|
+
toast.error(msg);
|
|
1473
|
+
setTimeoutRetry(null);
|
|
1474
|
+
}
|
|
1475
|
+
}, [timeoutRetry, updateConversation, imageTimeoutRetrySecs]);
|
|
1476
|
+
|
|
1477
|
+
const handleTimeoutRetryCancel = useCallback(() => {
|
|
1478
|
+
if (!timeoutRetry) return;
|
|
1479
|
+
const { conversationId: convId, taskId, taskError } = timeoutRetry;
|
|
1480
|
+
// 将超时错误应用到对应图片
|
|
1481
|
+
void updateConversation(convId, (current) => {
|
|
1482
|
+
const conversation = current ?? conversationsRef.current.find((c) => c.id === convId);
|
|
1483
|
+
if (!conversation) return current!;
|
|
1484
|
+
return {
|
|
1485
|
+
...conversation,
|
|
1486
|
+
updatedAt: new Date().toISOString(),
|
|
1487
|
+
turns: conversation.turns.map((turn) => {
|
|
1488
|
+
const hasLoading = turn.images.some((image) => image.status === "loading" && image.taskId === taskId);
|
|
1489
|
+
if (!hasLoading) return turn;
|
|
1490
|
+
const images = turn.images.map((image) =>
|
|
1491
|
+
image.taskId === taskId ? { ...image, status: "error" as const, error: taskError } : image,
|
|
1492
|
+
);
|
|
1493
|
+
const derived = deriveTurnStatus({ ...turn, images });
|
|
1494
|
+
return {
|
|
1495
|
+
...turn,
|
|
1496
|
+
...derived,
|
|
1497
|
+
images,
|
|
1498
|
+
};
|
|
1499
|
+
}),
|
|
1500
|
+
};
|
|
1501
|
+
});
|
|
1502
|
+
setTimeoutRetry(null);
|
|
1503
|
+
toast.error(taskError);
|
|
1504
|
+
}, [timeoutRetry, updateConversation]);
|
|
1505
|
+
|
|
1506
|
+
const handleDismissErrors = useCallback(
|
|
1507
|
+
async (conversationId: string, turnId: string) => {
|
|
1508
|
+
await updateConversation(conversationId, (current) => {
|
|
1509
|
+
const conversation = current ?? conversationsRef.current.find((c) => c.id === conversationId);
|
|
1510
|
+
if (!conversation) return current!;
|
|
1511
|
+
return {
|
|
1512
|
+
...conversation,
|
|
1513
|
+
updatedAt: new Date().toISOString(),
|
|
1514
|
+
turns: conversation.turns.map((turn) => {
|
|
1515
|
+
if (turn.id !== turnId) return turn;
|
|
1516
|
+
const successImages = turn.images.filter((image) => image.status !== "error");
|
|
1517
|
+
const derived = deriveTurnStatus({ ...turn, images: successImages });
|
|
1518
|
+
return {
|
|
1519
|
+
...turn,
|
|
1520
|
+
...derived,
|
|
1521
|
+
count: successImages.length,
|
|
1522
|
+
images: successImages,
|
|
1523
|
+
};
|
|
1524
|
+
}),
|
|
1525
|
+
};
|
|
1526
|
+
});
|
|
1527
|
+
},
|
|
1528
|
+
[updateConversation],
|
|
1529
|
+
);
|
|
1530
|
+
|
|
1531
|
+
useEffect(() => {
|
|
1532
|
+
for (const conversation of conversations) {
|
|
1533
|
+
if (
|
|
1534
|
+
!activeConversationQueueIds.has(conversation.id) &&
|
|
1535
|
+
conversation.turns.some(
|
|
1536
|
+
(turn) =>
|
|
1537
|
+
!turn.resultsDeleted &&
|
|
1538
|
+
(turn.status === "queued" || turn.status === "generating") &&
|
|
1539
|
+
turn.images.some((image) => image.status === "loading"),
|
|
1540
|
+
)
|
|
1541
|
+
) {
|
|
1542
|
+
void runConversationQueue(conversation.id);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}, [conversations, runConversationQueue]);
|
|
1546
|
+
|
|
1547
|
+
const handleSubmit = async () => {
|
|
1548
|
+
const prompt = imagePrompt.trim();
|
|
1549
|
+
if (!prompt) {
|
|
1550
|
+
toast.error("请输入提示词");
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const effectiveImageMode: ImageConversationMode = referenceImageFiles.length > 0 ? "edit" : "generate";
|
|
1555
|
+
|
|
1556
|
+
const targetConversation = selectedConversationId
|
|
1557
|
+
? conversationsRef.current.find((conversation) => conversation.id === selectedConversationId) ?? null
|
|
1558
|
+
: null;
|
|
1559
|
+
const now = new Date().toISOString();
|
|
1560
|
+
const conversationId = targetConversation?.id ?? createId();
|
|
1561
|
+
const turnId = createId();
|
|
1562
|
+
const imageSize = `${imageWidth || 1024}x${imageHeight || 1024}`;
|
|
1563
|
+
const draftTurn: ImageTurn = {
|
|
1564
|
+
id: turnId,
|
|
1565
|
+
prompt,
|
|
1566
|
+
model: imageModel,
|
|
1567
|
+
mode: effectiveImageMode,
|
|
1568
|
+
referenceImages: effectiveImageMode === "edit" ? referenceImages : [],
|
|
1569
|
+
count: parsedCount,
|
|
1570
|
+
size: imageSize,
|
|
1571
|
+
ratio: imageRatio,
|
|
1572
|
+
tier: imageTier,
|
|
1573
|
+
quality: imageQuality,
|
|
1574
|
+
images: createLoadingImages(turnId, parsedCount),
|
|
1575
|
+
createdAt: now,
|
|
1576
|
+
status: "queued",
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
const baseConversation: ImageConversation = targetConversation
|
|
1580
|
+
? {
|
|
1581
|
+
...targetConversation,
|
|
1582
|
+
updatedAt: now,
|
|
1583
|
+
turns: [...targetConversation.turns, draftTurn],
|
|
1584
|
+
}
|
|
1585
|
+
: {
|
|
1586
|
+
id: conversationId,
|
|
1587
|
+
title: buildConversationTitle(prompt),
|
|
1588
|
+
createdAt: now,
|
|
1589
|
+
updatedAt: now,
|
|
1590
|
+
turns: [draftTurn],
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
shouldStickToBottomRef.current = true;
|
|
1594
|
+
const btn = scrollToLatestBtnRef.current;
|
|
1595
|
+
if (btn) btn.style.display = "none";
|
|
1596
|
+
setSelectedConversationId(conversationId);
|
|
1597
|
+
clearComposerInputs();
|
|
1598
|
+
|
|
1599
|
+
await persistConversation(baseConversation);
|
|
1600
|
+
void runConversationQueue(conversationId);
|
|
1601
|
+
|
|
1602
|
+
const targetStats = getImageConversationStats(baseConversation);
|
|
1603
|
+
if (targetStats.running > 0 || targetStats.queued > 1) {
|
|
1604
|
+
toast.success("已加入当前对话队列");
|
|
1605
|
+
} else if (!targetConversation) {
|
|
1606
|
+
toast.success("已创建新对话并开始处理");
|
|
1607
|
+
} else {
|
|
1608
|
+
toast.success("已发送到当前对话");
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
return (
|
|
1613
|
+
<>
|
|
1614
|
+
<section className="mx-auto grid h-[calc(100dvh-6.5rem)] min-h-0 w-full max-w-[1380px] grid-cols-1 gap-2 overflow-hidden px-0 pb-[calc(env(safe-area-inset-bottom)+0.5rem)] sm:h-[calc(100dvh-5.25rem)] sm:gap-3 sm:px-3 sm:pb-6 lg:grid-cols-[240px_minmax(0,1fr)]">
|
|
1615
|
+
<div className="hidden h-full min-h-0 border-r border-stone-200/70 pr-3 lg:block">
|
|
1616
|
+
<ImageSidebar
|
|
1617
|
+
conversations={conversations}
|
|
1618
|
+
isLoadingHistory={isLoadingHistory}
|
|
1619
|
+
selectedConversationId={selectedConversationId}
|
|
1620
|
+
onCreateDraft={handleCreateDraft}
|
|
1621
|
+
onClearHistory={openClearHistoryConfirm}
|
|
1622
|
+
onSelectConversation={setSelectedConversationId}
|
|
1623
|
+
onDeleteConversation={openDeleteConversationConfirm}
|
|
1624
|
+
onRenameConversation={handleRenameConversation}
|
|
1625
|
+
formatConversationTime={formatConversationTime}
|
|
1626
|
+
/>
|
|
1627
|
+
</div>
|
|
1628
|
+
|
|
1629
|
+
<Dialog open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
|
|
1630
|
+
<DialogContent className="flex h-[min(82dvh,760px)] w-[92vw] max-w-[460px] flex-col overflow-hidden rounded-[32px] border-white/80 bg-white p-0 shadow-[0_32px_110px_-38px_rgba(15,23,42,0.45)] sm:rounded-[36px]">
|
|
1631
|
+
<DialogHeader className="px-6 pt-7 pb-4 sm:px-8">
|
|
1632
|
+
<DialogTitle className="flex items-center gap-2 text-xl font-bold tracking-tight">
|
|
1633
|
+
<History className="size-5" />
|
|
1634
|
+
历史记录
|
|
1635
|
+
</DialogTitle>
|
|
1636
|
+
</DialogHeader>
|
|
1637
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-8 sm:px-8">
|
|
1638
|
+
<ImageSidebar
|
|
1639
|
+
conversations={conversations}
|
|
1640
|
+
isLoadingHistory={isLoadingHistory}
|
|
1641
|
+
selectedConversationId={selectedConversationId}
|
|
1642
|
+
onCreateDraft={() => {
|
|
1643
|
+
handleCreateDraft();
|
|
1644
|
+
setIsHistoryOpen(false);
|
|
1645
|
+
}}
|
|
1646
|
+
onClearHistory={openClearHistoryConfirm}
|
|
1647
|
+
onSelectConversation={(id) => {
|
|
1648
|
+
setSelectedConversationId(id);
|
|
1649
|
+
setIsHistoryOpen(false);
|
|
1650
|
+
}}
|
|
1651
|
+
onDeleteConversation={openDeleteConversationConfirm}
|
|
1652
|
+
onRenameConversation={handleRenameConversation}
|
|
1653
|
+
formatConversationTime={formatConversationTime}
|
|
1654
|
+
hideActionButtons
|
|
1655
|
+
/>
|
|
1656
|
+
</div>
|
|
1657
|
+
</DialogContent>
|
|
1658
|
+
</Dialog>
|
|
1659
|
+
|
|
1660
|
+
<div className="flex min-h-0 flex-col gap-2 sm:gap-4">
|
|
1661
|
+
<div className="flex items-center justify-between gap-2 px-1 lg:hidden">
|
|
1662
|
+
<Button
|
|
1663
|
+
variant="outline"
|
|
1664
|
+
className="h-10 flex-1 rounded-2xl border-stone-200 bg-white/90 text-stone-700 shadow-sm"
|
|
1665
|
+
onClick={() => setIsHistoryOpen(true)}
|
|
1666
|
+
>
|
|
1667
|
+
<History className="mr-2 size-4" />
|
|
1668
|
+
历史记录 ({conversations.length})
|
|
1669
|
+
</Button>
|
|
1670
|
+
<Button
|
|
1671
|
+
className="h-10 rounded-2xl bg-stone-950 text-white shadow-sm"
|
|
1672
|
+
onClick={handleCreateDraft}
|
|
1673
|
+
>
|
|
1674
|
+
<Plus className="size-4" />
|
|
1675
|
+
新建
|
|
1676
|
+
</Button>
|
|
1677
|
+
<Button
|
|
1678
|
+
variant="outline"
|
|
1679
|
+
className="h-10 rounded-2xl border-stone-200 bg-white/85 px-3 text-stone-600 shadow-sm"
|
|
1680
|
+
onClick={openClearHistoryConfirm}
|
|
1681
|
+
disabled={conversations.length === 0}
|
|
1682
|
+
>
|
|
1683
|
+
<Trash2 className="size-4" />
|
|
1684
|
+
</Button>
|
|
1685
|
+
</div>
|
|
1686
|
+
|
|
1687
|
+
<div className="relative min-h-0 flex-1">
|
|
1688
|
+
<div
|
|
1689
|
+
ref={resultsViewportRef}
|
|
1690
|
+
onScroll={handleResultsScroll}
|
|
1691
|
+
className="hide-scrollbar h-full overscroll-contain overflow-y-auto px-1 py-2 sm:px-4 sm:py-4"
|
|
1692
|
+
style={{ contain: "layout style paint" }}
|
|
1693
|
+
>
|
|
1694
|
+
<ImageResults
|
|
1695
|
+
selectedConversation={selectedConversation}
|
|
1696
|
+
onOpenLightbox={openLightbox}
|
|
1697
|
+
onContinueEdit={handleContinueEdit}
|
|
1698
|
+
onDeletePrompt={openDeletePromptConfirm}
|
|
1699
|
+
onDeleteResults={openDeleteResultsConfirm}
|
|
1700
|
+
onReuseTurnConfig={handleReuseTurnConfig}
|
|
1701
|
+
onRegenerateTurn={handleRegenerateTurn}
|
|
1702
|
+
onRetryImage={handleRetryImage}
|
|
1703
|
+
onTimeoutRetryContinue={handleTimeoutRetryContinue}
|
|
1704
|
+
onDismissErrors={handleDismissErrors}
|
|
1705
|
+
formatConversationTime={formatConversationTime}
|
|
1706
|
+
/>
|
|
1707
|
+
</div>
|
|
1708
|
+
|
|
1709
|
+
<button
|
|
1710
|
+
ref={scrollToLatestBtnRef}
|
|
1711
|
+
type="button"
|
|
1712
|
+
aria-label="滚动到最新消息"
|
|
1713
|
+
title="滚动到最新消息"
|
|
1714
|
+
onClick={() => scrollResultsToLatest("smooth")}
|
|
1715
|
+
className="absolute bottom-4 left-1/2 z-20 inline-flex size-11 -translate-x-1/2 items-center justify-center rounded-full border border-stone-200 bg-white/95 text-stone-700 shadow-lg shadow-stone-200/60 backdrop-blur transition hover:-translate-y-0.5 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400 dark:border-white/10 dark:bg-stone-800/95 dark:text-stone-100 dark:shadow-black/40 dark:hover:bg-stone-700"
|
|
1716
|
+
style={{ display: "none" }}
|
|
1717
|
+
>
|
|
1718
|
+
<ArrowDown className="size-5" />
|
|
1719
|
+
</button>
|
|
1720
|
+
</div>
|
|
1721
|
+
|
|
1722
|
+
<ImageComposer
|
|
1723
|
+
prompt={imagePrompt}
|
|
1724
|
+
imageCount={imageCount}
|
|
1725
|
+
imageRatio={imageRatio}
|
|
1726
|
+
imageTier={imageTier}
|
|
1727
|
+
imageWidth={imageWidth}
|
|
1728
|
+
imageHeight={imageHeight}
|
|
1729
|
+
imageQuality={imageQuality}
|
|
1730
|
+
imageModel={imageModel}
|
|
1731
|
+
imageModels={imageModels}
|
|
1732
|
+
availableQuota={availableQuota}
|
|
1733
|
+
activeTaskCount={activeTaskCount}
|
|
1734
|
+
referenceImages={referenceImages}
|
|
1735
|
+
textareaRef={textareaRef}
|
|
1736
|
+
fileInputRef={fileInputRef}
|
|
1737
|
+
onPromptChange={setImagePrompt}
|
|
1738
|
+
onImageCountChange={(value) => setImageCount(value ? clampImageCount(value) : "")}
|
|
1739
|
+
onImageRatioChange={setImageRatio}
|
|
1740
|
+
onImageTierChange={setImageTier}
|
|
1741
|
+
onImageWidthChange={setImageWidth}
|
|
1742
|
+
onImageHeightChange={setImageHeight}
|
|
1743
|
+
onImageQualityChange={setImageQuality}
|
|
1744
|
+
onImageModelChange={setImageModel}
|
|
1745
|
+
onSubmit={handleSubmit}
|
|
1746
|
+
onPickReferenceImage={() => fileInputRef.current?.click()}
|
|
1747
|
+
onReferenceImageChange={handleReferenceImageChange}
|
|
1748
|
+
onRemoveReferenceImage={handleRemoveReferenceImage}
|
|
1749
|
+
/>
|
|
1750
|
+
</div>
|
|
1751
|
+
</section>
|
|
1752
|
+
|
|
1753
|
+
<ImageLightbox
|
|
1754
|
+
images={lightboxImages}
|
|
1755
|
+
currentIndex={lightboxIndex}
|
|
1756
|
+
open={lightboxOpen}
|
|
1757
|
+
onOpenChange={setLightboxOpen}
|
|
1758
|
+
onIndexChange={setLightboxIndex}
|
|
1759
|
+
/>
|
|
1760
|
+
|
|
1761
|
+
{deleteConfirm ? (
|
|
1762
|
+
<Dialog open onOpenChange={(open) => (!open ? setDeleteConfirm(null) : null)}>
|
|
1763
|
+
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
|
1764
|
+
<DialogHeader className="gap-2">
|
|
1765
|
+
<DialogTitle>{deleteConfirmTitle}</DialogTitle>
|
|
1766
|
+
<DialogDescription className="text-sm leading-6">
|
|
1767
|
+
{deleteConfirmDescription}
|
|
1768
|
+
</DialogDescription>
|
|
1769
|
+
</DialogHeader>
|
|
1770
|
+
<DialogFooter>
|
|
1771
|
+
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
|
1772
|
+
取消
|
|
1773
|
+
</Button>
|
|
1774
|
+
<Button className="bg-rose-600 text-white hover:bg-rose-700" onClick={() => void handleConfirmDelete()}>
|
|
1775
|
+
确认删除
|
|
1776
|
+
</Button>
|
|
1777
|
+
</DialogFooter>
|
|
1778
|
+
</DialogContent>
|
|
1779
|
+
</Dialog>
|
|
1780
|
+
) : null}
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
</>
|
|
1784
|
+
);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
export default function ImagePage() {
|
|
1788
|
+
const { isCheckingAuth, session } = useAuthGuard();
|
|
1789
|
+
|
|
1790
|
+
if (isCheckingAuth || !session) {
|
|
1791
|
+
return (
|
|
1792
|
+
<div className="flex min-h-[40vh] items-center justify-center">
|
|
1793
|
+
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
|
1794
|
+
</div>
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
return <ImagePageContent isAdmin={session.role === "admin"} />;
|
|
1799
|
+
}
|