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.
Files changed (186) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +258 -0
  3. package/README.md +260 -2
  4. package/client.js +241 -0
  5. package/cordis.patch.yml +14 -0
  6. package/host.js +692 -0
  7. package/install.mjs +333 -0
  8. package/package.json +58 -4
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -0
  11. package/server/access.py +26 -0
  12. package/server/app.py +1329 -0
  13. package/server/catalog.json +1 -0
  14. package/server/catalog.py +147 -0
  15. package/server/composer.py +391 -0
  16. package/server/drafts.py +158 -0
  17. package/server/history.py +575 -0
  18. package/server/imaging.py +172 -0
  19. package/server/pool.py +1335 -0
  20. package/server/pool_admin.py +921 -0
  21. package/server/pool_service.py +1073 -0
  22. package/server/requirements.txt +6 -0
  23. package/server/smoke_check.py +402 -0
  24. package/server/static/access.html +202 -0
  25. package/server/static/index.html +4725 -0
  26. package/vendor/chatgpt2api/README.md +13 -0
  27. package/vendor/chatgpt2api/SOURCE.json +11 -0
  28. package/vendor/chatgpt2api/runtime/uv.exe +0 -0
  29. package/vendor/chatgpt2api/source/.env.example +82 -0
  30. package/vendor/chatgpt2api/source/.python-version +1 -0
  31. package/vendor/chatgpt2api/source/LICENSE +21 -0
  32. package/vendor/chatgpt2api/source/README.md +391 -0
  33. package/vendor/chatgpt2api/source/VERSION +1 -0
  34. package/vendor/chatgpt2api/source/api/__init__.py +2 -0
  35. package/vendor/chatgpt2api/source/api/accounts.py +537 -0
  36. package/vendor/chatgpt2api/source/api/ai.py +217 -0
  37. package/vendor/chatgpt2api/source/api/app.py +62 -0
  38. package/vendor/chatgpt2api/source/api/errors.py +46 -0
  39. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
  40. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
  41. package/vendor/chatgpt2api/source/api/support.py +134 -0
  42. package/vendor/chatgpt2api/source/api/system.py +365 -0
  43. package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
  44. package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
  45. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
  46. package/vendor/chatgpt2api/source/config.default.json +108 -0
  47. package/vendor/chatgpt2api/source/main.py +9 -0
  48. package/vendor/chatgpt2api/source/pyproject.toml +27 -0
  49. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
  50. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
  51. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
  52. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
  53. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
  54. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
  55. package/vendor/chatgpt2api/source/services/__init__.py +0 -0
  56. package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
  57. package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
  58. package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
  59. package/vendor/chatgpt2api/source/services/config.py +667 -0
  60. package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
  61. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
  62. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
  63. package/vendor/chatgpt2api/source/services/image_service.py +376 -0
  64. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
  65. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
  66. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
  67. package/vendor/chatgpt2api/source/services/log_service.py +337 -0
  68. package/vendor/chatgpt2api/source/services/model_service.py +182 -0
  69. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
  70. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
  71. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
  72. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
  73. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
  74. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
  75. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
  76. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
  77. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
  78. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
  79. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
  80. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
  81. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
  82. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
  83. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
  84. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
  85. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
  86. package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
  87. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
  88. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
  89. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
  90. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
  91. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
  92. package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
  93. package/vendor/chatgpt2api/source/utils/helper.py +469 -0
  94. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
  95. package/vendor/chatgpt2api/source/utils/log.py +110 -0
  96. package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
  97. package/vendor/chatgpt2api/source/utils/pow.py +207 -0
  98. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
  99. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
  100. package/vendor/chatgpt2api/source/uv.lock +904 -0
  101. package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
  102. package/vendor/chatgpt2api/source/web/components.json +24 -0
  103. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
  104. package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
  105. package/vendor/chatgpt2api/source/web/package.json +58 -0
  106. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
  107. package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
  108. package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
  109. package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
  110. package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
  111. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
  112. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
  113. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
  114. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
  115. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
  116. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
  117. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
  118. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
  119. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
  120. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
  121. package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
  122. package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
  123. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
  124. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
  125. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
  126. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
  127. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
  128. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
  129. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
  130. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
  131. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
  133. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
  134. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
  135. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
  136. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
  137. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
  138. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
  139. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
  140. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
  141. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
  142. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
  143. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
  144. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
  145. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
  146. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
  147. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
  148. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
  149. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
  150. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
  151. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
  152. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
  153. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
  154. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
  155. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
  156. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
  157. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
  158. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
  159. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
  160. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
  161. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
  162. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
  163. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
  164. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
  165. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
  166. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
  167. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
  168. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
  169. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
  170. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
  171. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
  172. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
  173. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
  174. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
  175. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
  176. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
  177. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
  178. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
  179. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
  180. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
  181. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
  182. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
  183. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
  184. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
  185. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
  186. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
@@ -0,0 +1,667 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from dataclasses import dataclass
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ import time
10
+
11
+ from services.storage.base import StorageBackend
12
+
13
+ BASE_DIR = Path(__file__).resolve().parents[1]
14
+ DATA_DIR = BASE_DIR / "data"
15
+ CONFIG_FILE = BASE_DIR / "config.json"
16
+ VERSION_FILE = BASE_DIR / "VERSION"
17
+ BACKUP_STATE_FILE = DATA_DIR / "backup_state.json"
18
+
19
+ DEFAULT_BACKUP_INCLUDE = {
20
+ "config": True,
21
+ "cpa": True,
22
+ "sub2api": True,
23
+ "logs": True,
24
+ "image_tasks": True,
25
+ "accounts_snapshot": True,
26
+ "auth_keys_snapshot": True,
27
+ "images": False,
28
+ }
29
+
30
+ DEFAULT_IMAGE_STORAGE = {
31
+ "enabled": False,
32
+ "mode": "local",
33
+ "webdav_url": "",
34
+ "webdav_username": "",
35
+ "webdav_password": "",
36
+ "webdav_root_path": "chatgpt2api/images",
37
+ "public_base_url": "",
38
+ }
39
+
40
+ DEFAULT_CHAT_COMPLETION_CACHE = {
41
+ "enabled": True,
42
+ "ttl_seconds": 60,
43
+ "max_entries": 256,
44
+ "dedupe_inflight": True,
45
+ "stream_cache": True,
46
+ "normalize_messages": True,
47
+ "drop_adjacent_duplicates": True,
48
+ "drop_assistant_history": False,
49
+ }
50
+
51
+ DEFAULT_PROXY_RUNTIME_USER_AGENT = (
52
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
53
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
54
+ "Chrome/145.0.0.0 Safari/537.36"
55
+ )
56
+
57
+ DEFAULT_PROXY_RUNTIME = {
58
+ "enabled": False,
59
+ "egress_mode": "direct",
60
+ "proxy_url": "",
61
+ "resource_proxy_url": "",
62
+ "skip_ssl_verify": False,
63
+ "reset_session_status_codes": [403],
64
+ "clearance": {
65
+ "enabled": False,
66
+ "mode": "none",
67
+ "cf_cookies": "",
68
+ "cf_clearance": "",
69
+ "user_agent": DEFAULT_PROXY_RUNTIME_USER_AGENT,
70
+ "browser": "chrome",
71
+ "flaresolverr_url": "",
72
+ "timeout_sec": 60,
73
+ "refresh_interval": 3600,
74
+ "warm_up_on_start": False,
75
+ },
76
+ }
77
+
78
+ DEFAULT_THIRD_PARTY_APPS = {
79
+ "infinite_canvas": {
80
+ "enabled": False,
81
+ "url": "https://canvas.best",
82
+ },
83
+ }
84
+
85
+
86
+ def _normalize_bool(value: object, default: bool = False) -> bool:
87
+ if isinstance(value, str):
88
+ lowered = value.strip().lower()
89
+ if lowered in {"1", "true", "yes", "on"}:
90
+ return True
91
+ if lowered in {"0", "false", "no", "off"}:
92
+ return False
93
+ return default
94
+ if value is None:
95
+ return default
96
+ return bool(value)
97
+
98
+
99
+ def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int:
100
+ try:
101
+ normalized = int(value)
102
+ except (OverflowError, TypeError, ValueError):
103
+ normalized = default
104
+ return max(minimum, normalized)
105
+
106
+
107
+ def _normalize_backup_include(value: object) -> dict[str, bool]:
108
+ source = value if isinstance(value, dict) else {}
109
+ normalized = dict(DEFAULT_BACKUP_INCLUDE)
110
+ for key in normalized:
111
+ normalized[key] = _normalize_bool(source.get(key), normalized[key])
112
+ return normalized
113
+
114
+
115
+ def _normalize_backup_settings(value: object) -> dict[str, object]:
116
+ source = value if isinstance(value, dict) else {}
117
+ return {
118
+ "enabled": _normalize_bool(source.get("enabled"), False),
119
+ "provider": "cloudflare_r2",
120
+ "account_id": str(source.get("account_id") or "").strip(),
121
+ "access_key_id": str(source.get("access_key_id") or "").strip(),
122
+ "secret_access_key": str(source.get("secret_access_key") or "").strip(),
123
+ "bucket": str(source.get("bucket") or "").strip(),
124
+ "prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups",
125
+ "interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1),
126
+ "rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0),
127
+ "encrypt": _normalize_bool(source.get("encrypt"), False),
128
+ "passphrase": str(source.get("passphrase") or "").strip(),
129
+ "include": _normalize_backup_include(source.get("include")),
130
+ }
131
+
132
+
133
+ def _normalize_backup_state(value: object) -> dict[str, object]:
134
+ source = value if isinstance(value, dict) else {}
135
+ return {
136
+ "last_started_at": str(source.get("last_started_at") or "").strip() or None,
137
+ "last_finished_at": str(source.get("last_finished_at") or "").strip() or None,
138
+ "last_status": str(source.get("last_status") or "idle").strip() or "idle",
139
+ "last_error": str(source.get("last_error") or "").strip() or None,
140
+ "last_object_key": str(source.get("last_object_key") or "").strip() or None,
141
+ }
142
+
143
+
144
+ def _normalize_image_storage_settings(value: object) -> dict[str, object]:
145
+ source = value if isinstance(value, dict) else {}
146
+ mode = str(source.get("mode") or "local").strip().lower()
147
+ if mode not in {"local", "webdav", "both"}:
148
+ mode = "local"
149
+ enabled = _normalize_bool(source.get("enabled"), False)
150
+ if not enabled:
151
+ mode = "local"
152
+ root_path = str(source.get("webdav_root_path") or DEFAULT_IMAGE_STORAGE["webdav_root_path"]).strip().strip("/")
153
+ return {
154
+ "enabled": enabled,
155
+ "mode": mode,
156
+ "webdav_url": str(source.get("webdav_url") or "").strip().rstrip("/"),
157
+ "webdav_username": str(source.get("webdav_username") or "").strip(),
158
+ "webdav_password": str(source.get("webdav_password") or "").strip(),
159
+ "webdav_root_path": root_path or str(DEFAULT_IMAGE_STORAGE["webdav_root_path"]),
160
+ "public_base_url": str(source.get("public_base_url") or "").strip().rstrip("/"),
161
+ }
162
+
163
+
164
+ def _normalize_chat_completion_cache_settings(value: object) -> dict[str, object]:
165
+ source = value if isinstance(value, dict) else {}
166
+ return {
167
+ "enabled": _normalize_bool(source.get("enabled"), DEFAULT_CHAT_COMPLETION_CACHE["enabled"]),
168
+ "ttl_seconds": _normalize_positive_int(
169
+ source.get("ttl_seconds"),
170
+ int(DEFAULT_CHAT_COMPLETION_CACHE["ttl_seconds"]),
171
+ 0,
172
+ ),
173
+ "max_entries": _normalize_positive_int(
174
+ source.get("max_entries"),
175
+ int(DEFAULT_CHAT_COMPLETION_CACHE["max_entries"]),
176
+ 1,
177
+ ),
178
+ "dedupe_inflight": _normalize_bool(
179
+ source.get("dedupe_inflight"),
180
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["dedupe_inflight"]),
181
+ ),
182
+ "stream_cache": _normalize_bool(
183
+ source.get("stream_cache"),
184
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["stream_cache"]),
185
+ ),
186
+ "normalize_messages": _normalize_bool(
187
+ source.get("normalize_messages"),
188
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["normalize_messages"]),
189
+ ),
190
+ "drop_adjacent_duplicates": _normalize_bool(
191
+ source.get("drop_adjacent_duplicates"),
192
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_adjacent_duplicates"]),
193
+ ),
194
+ "drop_assistant_history": _normalize_bool(
195
+ source.get("drop_assistant_history"),
196
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_assistant_history"]),
197
+ ),
198
+ }
199
+
200
+
201
+ def _normalize_status_codes(value: object) -> list[int]:
202
+ items = value if isinstance(value, list) else DEFAULT_PROXY_RUNTIME["reset_session_status_codes"]
203
+ normalized: list[int] = []
204
+ for item in items:
205
+ if isinstance(item, bool):
206
+ continue
207
+ try:
208
+ status = int(item)
209
+ except (OverflowError, TypeError, ValueError):
210
+ continue
211
+ if 100 <= status <= 599 and status not in normalized:
212
+ normalized.append(status)
213
+ if not normalized:
214
+ return list(DEFAULT_PROXY_RUNTIME["reset_session_status_codes"])
215
+ return normalized
216
+
217
+
218
+ def _normalize_proxy_runtime_settings(value: object) -> dict[str, object]:
219
+ source = value if isinstance(value, dict) else {}
220
+ default_clearance = DEFAULT_PROXY_RUNTIME["clearance"]
221
+ clearance_source = source.get("clearance") if isinstance(source.get("clearance"), dict) else {}
222
+
223
+ egress_mode = str(source.get("egress_mode") or DEFAULT_PROXY_RUNTIME["egress_mode"]).strip().lower()
224
+ if egress_mode not in {"direct", "single_proxy"}:
225
+ egress_mode = str(DEFAULT_PROXY_RUNTIME["egress_mode"])
226
+
227
+ clearance_mode = str(clearance_source.get("mode") or default_clearance["mode"]).strip().lower()
228
+ if clearance_mode not in {"none", "manual", "flaresolverr"}:
229
+ clearance_mode = str(default_clearance["mode"])
230
+
231
+ user_agent = str(clearance_source.get("user_agent") or default_clearance["user_agent"]).strip()
232
+ browser = str(clearance_source.get("browser") or default_clearance["browser"]).strip()
233
+
234
+ existing_clearance_cookies = str(source.get("_existing_cf_cookies") or "").strip()
235
+ existing_cf_clearance = str(source.get("_existing_cf_clearance") or "").strip()
236
+ cf_cookies = str(clearance_source.get("cf_cookies") or "").strip()
237
+ cf_clearance = str(clearance_source.get("cf_clearance") or "").strip()
238
+ if not cf_cookies and _normalize_bool(clearance_source.get("has_cf_cookies"), False):
239
+ cf_cookies = existing_clearance_cookies
240
+ if not cf_clearance and _normalize_bool(clearance_source.get("has_cf_clearance"), False):
241
+ cf_clearance = existing_cf_clearance
242
+
243
+ return {
244
+ "enabled": _normalize_bool(source.get("enabled"), bool(DEFAULT_PROXY_RUNTIME["enabled"])),
245
+ "egress_mode": egress_mode,
246
+ "proxy_url": str(source.get("proxy_url") or "").strip(),
247
+ "resource_proxy_url": str(source.get("resource_proxy_url") or "").strip(),
248
+ "skip_ssl_verify": _normalize_bool(
249
+ source.get("skip_ssl_verify"),
250
+ bool(DEFAULT_PROXY_RUNTIME["skip_ssl_verify"]),
251
+ ),
252
+ "reset_session_status_codes": _normalize_status_codes(source.get("reset_session_status_codes")),
253
+ "clearance": {
254
+ "enabled": _normalize_bool(clearance_source.get("enabled"), bool(default_clearance["enabled"])),
255
+ "mode": clearance_mode,
256
+ "cf_cookies": cf_cookies,
257
+ "cf_clearance": cf_clearance,
258
+ "user_agent": user_agent or str(default_clearance["user_agent"]),
259
+ "browser": browser or str(default_clearance["browser"]),
260
+ "flaresolverr_url": str(clearance_source.get("flaresolverr_url") or "").strip(),
261
+ "timeout_sec": _normalize_positive_int(
262
+ clearance_source.get("timeout_sec"),
263
+ int(default_clearance["timeout_sec"]),
264
+ 1,
265
+ ),
266
+ "refresh_interval": _normalize_positive_int(
267
+ clearance_source.get("refresh_interval"),
268
+ int(default_clearance["refresh_interval"]),
269
+ 60,
270
+ ),
271
+ "warm_up_on_start": _normalize_bool(
272
+ clearance_source.get("warm_up_on_start"),
273
+ bool(default_clearance["warm_up_on_start"]),
274
+ ),
275
+ },
276
+ }
277
+
278
+
279
+ def _normalize_third_party_apps_settings(value: object) -> dict[str, object]:
280
+ source = value if isinstance(value, dict) else {}
281
+ canvas_source = source.get("infinite_canvas") if isinstance(source.get("infinite_canvas"), dict) else {}
282
+ return {
283
+ "infinite_canvas": {
284
+ "enabled": _normalize_bool(canvas_source.get("enabled"), False),
285
+ "url": str(canvas_source.get("url") or DEFAULT_THIRD_PARTY_APPS["infinite_canvas"]["url"]).strip(),
286
+ },
287
+ }
288
+
289
+
290
+ def _validate_image_storage_settings(settings: dict[str, object]) -> None:
291
+ if not _normalize_bool(settings.get("enabled"), False):
292
+ return
293
+ if not str(settings.get("webdav_url") or "").strip():
294
+ raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV URL")
295
+ if not str(settings.get("webdav_password") or "").strip():
296
+ raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV 密码")
297
+
298
+
299
+ @dataclass(frozen=True)
300
+ class LoadedSettings:
301
+ auth_key: str
302
+ refresh_account_interval_minute: int
303
+
304
+
305
+ def _normalize_auth_key(value: object) -> str:
306
+ return str(value or "").strip()
307
+
308
+
309
+ def _is_invalid_auth_key(value: object) -> bool:
310
+ return _normalize_auth_key(value) == ""
311
+
312
+
313
+ def _read_json_object(path: Path, *, name: str) -> dict[str, object]:
314
+ if not path.exists():
315
+ return {}
316
+ if path.is_dir():
317
+ print(
318
+ f"Warning: {name} at '{path}' is a directory, ignoring it and falling back to other configuration sources.",
319
+ file=sys.stderr,
320
+ )
321
+ return {}
322
+ try:
323
+ data = json.loads(path.read_text(encoding="utf-8"))
324
+ except Exception:
325
+ return {}
326
+ return data if isinstance(data, dict) else {}
327
+
328
+
329
+ def _load_settings() -> LoadedSettings:
330
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
331
+ raw_config = _read_json_object(CONFIG_FILE, name="config.json")
332
+ auth_key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key"))
333
+ if _is_invalid_auth_key(auth_key):
334
+ raise ValueError(
335
+ "❌ auth-key 未设置!\n"
336
+ "请在环境变量 CHATGPT2API_AUTH_KEY 中设置,或者在 config.json 中填写 auth-key。"
337
+ )
338
+
339
+ try:
340
+ refresh_interval = int(raw_config.get("refresh_account_interval_minute", 5))
341
+ except (TypeError, ValueError):
342
+ refresh_interval = 5
343
+
344
+ return LoadedSettings(
345
+ auth_key=auth_key,
346
+ refresh_account_interval_minute=refresh_interval,
347
+ )
348
+
349
+
350
+ class ConfigStore:
351
+ def __init__(self, path: Path):
352
+ self.path = path
353
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
354
+ self.data = self._load()
355
+ self._storage_backend: StorageBackend | None = None
356
+ if _is_invalid_auth_key(self.auth_key):
357
+ raise ValueError(
358
+ "❌ auth-key 未设置!\n"
359
+ "请按以下任意一种方式解决:\n"
360
+ "1. 在 Render 的 Environment 变量中添加:\n"
361
+ " CHATGPT2API_AUTH_KEY = your_real_auth_key\n"
362
+ "2. 或者在 config.json 中填写:\n"
363
+ ' "auth-key": "your_real_auth_key"'
364
+ )
365
+
366
+ def _load(self) -> dict[str, object]:
367
+ return _read_json_object(self.path, name="config.json")
368
+
369
+ def _save(self) -> None:
370
+ self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
371
+
372
+ @property
373
+ def auth_key(self) -> str:
374
+ return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key"))
375
+
376
+ @property
377
+ def accounts_file(self) -> Path:
378
+ return DATA_DIR / "accounts.json"
379
+
380
+ @property
381
+ def refresh_account_interval_minute(self) -> int:
382
+ try:
383
+ return int(self.data.get("refresh_account_interval_minute", 5))
384
+ except (TypeError, ValueError):
385
+ return 5
386
+
387
+ @property
388
+ def image_retention_days(self) -> int:
389
+ try:
390
+ return max(1, int(self.data.get("image_retention_days", 30)))
391
+ except (TypeError, ValueError):
392
+ return 30
393
+
394
+ @property
395
+ def image_poll_timeout_secs(self) -> int:
396
+ try:
397
+ return max(1, int(self.data.get("image_poll_timeout_secs", 120)))
398
+ except (TypeError, ValueError):
399
+ return 120
400
+
401
+ @property
402
+ def image_poll_interval_secs(self) -> float:
403
+ try:
404
+ return max(0.5, float(self.data.get("image_poll_interval_secs", 10.0)))
405
+ except (TypeError, ValueError):
406
+ return 10.0
407
+
408
+ @property
409
+ def image_poll_initial_wait_secs(self) -> float:
410
+ """Image generation upstream takes ~30s; polling immediately wastes requests
411
+ and trips a transient 429. Default 10s gives the conversation document time
412
+ to commit before the first poll."""
413
+ try:
414
+ return max(0.0, float(self.data.get("image_poll_initial_wait_secs", 10.0)))
415
+ except (TypeError, ValueError):
416
+ return 10.0
417
+
418
+ @property
419
+ def image_account_concurrency(self) -> int:
420
+ try:
421
+ return max(1, int(self.data.get("image_account_concurrency", 3)))
422
+ except (TypeError, ValueError):
423
+ return 3
424
+
425
+ @property
426
+ def image_parallel_generation(self) -> bool:
427
+ value = self.data.get("image_parallel_generation", True)
428
+ if isinstance(value, str):
429
+ return value.strip().lower() in {"1", "true", "yes", "on"}
430
+ return bool(value)
431
+
432
+ @property
433
+ def image_settle_enabled(self) -> bool:
434
+ """图片二次确认机制:找到 file_ids 后等待一段时间再次确认。"""
435
+ value = self.data.get("image_settle_enabled", True)
436
+ if isinstance(value, str):
437
+ return value.strip().lower() in {"1", "true", "yes", "on"}
438
+ return bool(value)
439
+
440
+ @property
441
+ def image_check_before_hit_enabled(self) -> bool:
442
+ """先check再hit:通过轮询确认 file_ids 存在后再返回,而非仅依赖 SSE 事件。"""
443
+ value = self.data.get("image_check_before_hit_enabled", True)
444
+ if isinstance(value, str):
445
+ return value.strip().lower() in {"1", "true", "yes", "on"}
446
+ return bool(value)
447
+
448
+ @property
449
+ def image_remove_conversation_after_result(self) -> bool:
450
+ """出图成功后异步隐藏 ChatGPT 本地对话记录。"""
451
+ value = self.data.get("image_remove_conversation_after_result", False)
452
+ if isinstance(value, str):
453
+ return value.strip().lower() in {"1", "true", "yes", "on"}
454
+ return bool(value)
455
+
456
+ @property
457
+ def image_remove_conversation_always(self) -> bool:
458
+ """无论是否出图,画图请求结束后都异步隐藏 ChatGPT 本地对话记录。"""
459
+ return _normalize_bool(self.data.get("image_remove_conversation_always"), False)
460
+
461
+ @property
462
+ def image_settle_secs(self) -> float:
463
+ """二次确认等待时间(秒)。"""
464
+ try:
465
+ return max(0.5, float(self.data.get("image_settle_secs", 2.0)))
466
+ except (TypeError, ValueError):
467
+ return 2.0
468
+
469
+ @property
470
+ def auto_remove_invalid_accounts(self) -> bool:
471
+ value = self.data.get("auto_remove_invalid_accounts", False)
472
+ if isinstance(value, str):
473
+ return value.strip().lower() in {"1", "true", "yes", "on"}
474
+ return bool(value)
475
+
476
+ @property
477
+ def auto_remove_rate_limited_accounts(self) -> bool:
478
+ value = self.data.get("auto_remove_rate_limited_accounts", False)
479
+ if isinstance(value, str):
480
+ return value.strip().lower() in {"1", "true", "yes", "on"}
481
+ return bool(value)
482
+
483
+ @property
484
+ def auto_relogin_after_refresh(self) -> bool:
485
+ value = self.data.get("auto_relogin_after_refresh", False)
486
+ if isinstance(value, str):
487
+ return value.strip().lower() in {"1", "true", "yes", "on"}
488
+ return bool(value)
489
+
490
+ @property
491
+ def log_levels(self) -> list[str]:
492
+ levels = self.data.get("log_levels")
493
+ if not isinstance(levels, list):
494
+ return []
495
+ allowed = {"debug", "info", "warning", "error"}
496
+ return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
497
+
498
+ @property
499
+ def sensitive_words(self) -> list[str]:
500
+ words = self.data.get("sensitive_words")
501
+ return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else []
502
+
503
+ @property
504
+ def ai_review(self) -> dict[str, object]:
505
+ value = self.data.get("ai_review")
506
+ return value if isinstance(value, dict) else {}
507
+
508
+ @property
509
+ def global_system_prompt(self) -> str:
510
+ return str(self.data.get("global_system_prompt") or "").strip()
511
+
512
+ @property
513
+ def default_upstream_model_name(self) -> str:
514
+ return str(self.data.get("default_upstream_model_name") or "gpt-5-5").strip()
515
+
516
+ @property
517
+ def default_thinking_effort(self) -> str:
518
+ value = str(self.data.get("default_thinking_effort") or "auto").strip().lower()
519
+ return value if value in {"auto", "standard", "extended", "max"} else "auto"
520
+
521
+ @property
522
+ def images_dir(self) -> Path:
523
+ path = DATA_DIR / "images"
524
+ path.mkdir(parents=True, exist_ok=True)
525
+ return path
526
+
527
+ @property
528
+ def image_thumbnails_dir(self) -> Path:
529
+ path = DATA_DIR / "image_thumbnails"
530
+ path.mkdir(parents=True, exist_ok=True)
531
+ return path
532
+
533
+ def cleanup_old_images(self) -> int:
534
+ cutoff = time.time() - self.image_retention_days * 86400
535
+ removed = 0
536
+ for path in self.images_dir.rglob("*"):
537
+ if path.is_file() and path.stat().st_mtime < cutoff:
538
+ path.unlink()
539
+ removed += 1
540
+ for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
541
+ try:
542
+ path.rmdir()
543
+ except OSError:
544
+ pass
545
+ return removed
546
+
547
+ @property
548
+ def base_url(self) -> str:
549
+ return str(
550
+ os.getenv("CHATGPT2API_BASE_URL")
551
+ or self.data.get("base_url")
552
+ or ""
553
+ ).strip().rstrip("/")
554
+
555
+ @property
556
+ def app_version(self) -> str:
557
+ try:
558
+ value = VERSION_FILE.read_text(encoding="utf-8").strip()
559
+ except FileNotFoundError:
560
+ return "0.0.0"
561
+ return value or "0.0.0"
562
+
563
+ def get(self) -> dict[str, object]:
564
+ data = dict(self.data)
565
+ data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
566
+ data["image_retention_days"] = self.image_retention_days
567
+ data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
568
+ data["image_poll_interval_secs"] = self.image_poll_interval_secs
569
+ data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs
570
+ data["image_account_concurrency"] = self.image_account_concurrency
571
+ data["image_parallel_generation"] = self.image_parallel_generation
572
+ data["image_remove_conversation_after_result"] = self.image_remove_conversation_after_result
573
+ data["image_remove_conversation_always"] = self.image_remove_conversation_always
574
+ data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
575
+ data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
576
+ data["auto_relogin_after_refresh"] = self.auto_relogin_after_refresh
577
+ data["log_levels"] = self.log_levels
578
+ data["sensitive_words"] = self.sensitive_words
579
+ data["ai_review"] = self.ai_review
580
+ data["global_system_prompt"] = self.global_system_prompt
581
+ data["default_upstream_model_name"] = self.default_upstream_model_name
582
+ data["default_thinking_effort"] = self.default_thinking_effort
583
+ data["backup"] = self.get_backup_settings()
584
+ data["image_storage"] = self.get_image_storage_settings()
585
+ data["chat_completion_cache"] = self.get_chat_completion_cache_settings()
586
+ data["proxy_runtime"] = self.get_public_proxy_runtime_settings()
587
+ data["third_party_apps"] = self.get_third_party_apps_settings()
588
+ data.pop("auth-key", None)
589
+ return data
590
+
591
+ def get_proxy_settings(self) -> str:
592
+ return str(self.data.get("proxy") or "").strip()
593
+
594
+ def get_proxy_runtime_settings(self) -> dict[str, object]:
595
+ return _normalize_proxy_runtime_settings(self.data.get("proxy_runtime"))
596
+
597
+ def get_public_proxy_runtime_settings(self) -> dict[str, object]:
598
+ runtime = copy.deepcopy(self.get_proxy_runtime_settings())
599
+ clearance = runtime.get("clearance") if isinstance(runtime.get("clearance"), dict) else {}
600
+ if isinstance(clearance, dict):
601
+ cf_cookies = str(clearance.get("cf_cookies") or "").strip()
602
+ cf_clearance = str(clearance.get("cf_clearance") or "").strip()
603
+ clearance["cf_cookies"] = ""
604
+ clearance["cf_clearance"] = ""
605
+ clearance["has_cf_cookies"] = bool(cf_cookies)
606
+ clearance["has_cf_clearance"] = bool(cf_clearance)
607
+ return runtime
608
+
609
+ def get_third_party_apps_settings(self) -> dict[str, object]:
610
+ return _normalize_third_party_apps_settings(self.data.get("third_party_apps"))
611
+
612
+ def update(self, data: dict[str, object]) -> dict[str, object]:
613
+ next_data = dict(self.data)
614
+ next_data.update(dict(data or {}))
615
+ if "backup" in next_data:
616
+ next_data["backup"] = _normalize_backup_settings(next_data.get("backup"))
617
+ if "image_storage" in next_data:
618
+ next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
619
+ _validate_image_storage_settings(next_data["image_storage"])
620
+ if "chat_completion_cache" in next_data:
621
+ next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings(
622
+ next_data.get("chat_completion_cache")
623
+ )
624
+ if "third_party_apps" in next_data:
625
+ next_data["third_party_apps"] = _normalize_third_party_apps_settings(next_data.get("third_party_apps"))
626
+ if "proxy_runtime" in next_data:
627
+ incoming_runtime = next_data.get("proxy_runtime")
628
+ if isinstance(incoming_runtime, dict):
629
+ previous_clearance = self.get_proxy_runtime_settings().get("clearance")
630
+ if isinstance(previous_clearance, dict):
631
+ incoming_runtime = dict(incoming_runtime)
632
+ incoming_runtime["_existing_cf_cookies"] = previous_clearance.get("cf_cookies")
633
+ incoming_runtime["_existing_cf_clearance"] = previous_clearance.get("cf_clearance")
634
+ next_data["proxy_runtime"] = _normalize_proxy_runtime_settings(incoming_runtime)
635
+ next_data.pop("backup_state", None)
636
+ self.data = next_data
637
+ self._save()
638
+ return self.get()
639
+
640
+ def get_backup_settings(self) -> dict[str, object]:
641
+ return _normalize_backup_settings(self.data.get("backup"))
642
+
643
+ def get_image_storage_settings(self) -> dict[str, object]:
644
+ return _normalize_image_storage_settings(self.data.get("image_storage"))
645
+
646
+ def get_chat_completion_cache_settings(self) -> dict[str, object]:
647
+ return _normalize_chat_completion_cache_settings(self.data.get("chat_completion_cache"))
648
+
649
+ def get_storage_backend(self) -> StorageBackend:
650
+ """获取存储后端实例(单例)"""
651
+ if self._storage_backend is None:
652
+ from services.storage.factory import create_storage_backend
653
+ self._storage_backend = create_storage_backend(DATA_DIR)
654
+ return self._storage_backend
655
+
656
+
657
+ def load_backup_state() -> dict[str, object]:
658
+ return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json"))
659
+
660
+
661
+ def save_backup_state(state: dict[str, object]) -> dict[str, object]:
662
+ normalized = _normalize_backup_state(state)
663
+ BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
664
+ return normalized
665
+
666
+
667
+ config = ConfigStore(CONFIG_FILE)