dsh-plugin-image-workbench 1.1.5 → 1.1.7

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 (173) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +5 -3
  3. package/README.md +7 -3
  4. package/client.js +241 -241
  5. package/cordis.patch.yml +14 -14
  6. package/host.js +51 -0
  7. package/install.mjs +333 -387
  8. package/package.json +3 -2
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -154
  11. package/server/access.py +26 -26
  12. package/server/app.py +1371 -1329
  13. package/server/catalog.py +147 -147
  14. package/server/composer.py +391 -391
  15. package/server/drafts.py +158 -158
  16. package/server/history.py +575 -575
  17. package/server/imaging.py +172 -172
  18. package/server/pool.py +1335 -1335
  19. package/server/pool_admin.py +921 -921
  20. package/server/pool_service.py +1073 -1073
  21. package/server/requirements.txt +6 -6
  22. package/server/smoke_check.py +402 -402
  23. package/server/static/access.html +202 -202
  24. package/server/static/index.html +15 -0
  25. package/vendor/chatgpt2api/source/.env.example +82 -82
  26. package/vendor/chatgpt2api/source/.python-version +1 -1
  27. package/vendor/chatgpt2api/source/LICENSE +21 -21
  28. package/vendor/chatgpt2api/source/README.md +391 -391
  29. package/vendor/chatgpt2api/source/VERSION +1 -1
  30. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  31. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  32. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  33. package/vendor/chatgpt2api/source/api/app.py +62 -62
  34. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  35. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  36. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  37. package/vendor/chatgpt2api/source/api/support.py +134 -134
  38. package/vendor/chatgpt2api/source/api/system.py +365 -365
  39. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  40. package/vendor/chatgpt2api/source/config.default.json +108 -108
  41. package/vendor/chatgpt2api/source/main.py +9 -9
  42. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  43. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  44. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  45. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  46. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  47. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  48. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  49. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  50. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  51. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  52. package/vendor/chatgpt2api/source/services/config.py +667 -667
  53. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  54. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  55. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  56. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  57. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  58. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  59. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  60. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  61. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  62. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  63. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  64. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  65. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  66. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  67. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  68. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  69. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  70. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  75. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  76. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  77. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  78. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  79. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  80. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  81. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  82. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  83. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  84. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  85. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  86. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  87. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  88. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  89. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  90. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  91. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  92. package/vendor/chatgpt2api/source/uv.lock +904 -904
  93. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  94. package/vendor/chatgpt2api/source/web/components.json +24 -24
  95. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  96. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  97. package/vendor/chatgpt2api/source/web/package.json +58 -58
  98. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  100. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  107. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  108. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  109. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  112. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  113. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  114. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  115. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  116. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  117. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  118. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  133. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  134. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  135. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  136. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  137. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  138. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  139. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  140. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  141. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  142. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  143. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  144. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  145. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  146. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  147. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  149. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  150. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  151. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  152. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  153. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  154. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  155. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  156. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  157. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  158. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  159. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  160. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  161. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  162. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  163. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  164. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  165. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  166. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  167. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  168. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  169. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  170. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  171. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  172. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  173. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
@@ -1,337 +1,337 @@
1
- from __future__ import annotations
2
-
3
- import hashlib
4
- import json
5
- import itertools
6
- import time
7
- from dataclasses import dataclass, field
8
- from datetime import datetime
9
- from pathlib import Path
10
- from typing import Any
11
- from uuid import uuid4
12
-
13
- from fastapi import HTTPException
14
- from fastapi.concurrency import run_in_threadpool
15
- from fastapi.responses import JSONResponse, StreamingResponse
16
-
17
- from services.config import DATA_DIR
18
- from services.protocol.error_response import anthropic_error_response, openai_error_response
19
- from utils.helper import anthropic_sse_stream, sse_json_stream
20
-
21
- LOG_TYPE_CALL = "call"
22
- LOG_TYPE_ACCOUNT = "account"
23
- INTERNAL_RESPONSE_KEYS = {"_account_email", "_conversation_id"}
24
-
25
-
26
- class LogService:
27
- def __init__(self, path: Path):
28
- self.path = path
29
- self.path.parent.mkdir(parents=True, exist_ok=True)
30
-
31
- @staticmethod
32
- def _legacy_id(raw_line: str, line_number: int) -> str:
33
- payload = f"{line_number}:{raw_line}".encode("utf-8", errors="ignore")
34
- return hashlib.sha1(payload).hexdigest()[:24]
35
-
36
- def _parse_line(self, raw_line: str, line_number: int) -> dict[str, Any] | None:
37
- try:
38
- item = json.loads(raw_line)
39
- except Exception:
40
- return None
41
- if not isinstance(item, dict):
42
- return None
43
- parsed = dict(item)
44
- parsed["id"] = str(parsed.get("id") or self._legacy_id(raw_line, line_number))
45
- return parsed
46
-
47
- @staticmethod
48
- def _serialize_item(item: dict[str, Any]) -> str:
49
- return json.dumps(item, ensure_ascii=False, separators=(",", ":"))
50
-
51
- @staticmethod
52
- def _matches_filters(item: dict[str, Any], *, type: str = "", start_date: str = "", end_date: str = "") -> bool:
53
- t = str(item.get("time") or "")
54
- day = t[:10]
55
- if type and item.get("type") != type:
56
- return False
57
- if start_date and day < start_date:
58
- return False
59
- if end_date and day > end_date:
60
- return False
61
- return True
62
-
63
- def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None:
64
- item = {
65
- "id": uuid4().hex,
66
- "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
67
- "type": type,
68
- "summary": summary,
69
- "detail": detail or data,
70
- }
71
- with self.path.open("a", encoding="utf-8") as file:
72
- file.write(self._serialize_item(item) + "\n")
73
-
74
- def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]:
75
- if not self.path.exists():
76
- return []
77
- items: list[dict[str, Any]] = []
78
- lines = self.path.read_text(encoding="utf-8").splitlines()
79
- for line_number in range(len(lines) - 1, -1, -1):
80
- item = self._parse_line(lines[line_number], line_number)
81
- if item is None:
82
- continue
83
- if not self._matches_filters(item, type=type, start_date=start_date, end_date=end_date):
84
- continue
85
- items.append(item)
86
- if len(items) >= limit:
87
- break
88
- return items
89
-
90
- def delete(self, ids: list[str]) -> dict[str, int]:
91
- target_ids = {str(item or "").strip() for item in ids if str(item or "").strip()}
92
- if not self.path.exists() or not target_ids:
93
- return {"removed": 0}
94
- lines = self.path.read_text(encoding="utf-8").splitlines()
95
- kept_lines: list[str] = []
96
- removed = 0
97
- for line_number, raw_line in enumerate(lines):
98
- item = self._parse_line(raw_line, line_number)
99
- if item is None:
100
- kept_lines.append(raw_line)
101
- continue
102
- if str(item.get("id") or "") in target_ids:
103
- removed += 1
104
- continue
105
- kept_lines.append(self._serialize_item(item))
106
- content = "\n".join(kept_lines)
107
- if content:
108
- content += "\n"
109
- self.path.write_text(content, encoding="utf-8")
110
- return {"removed": removed}
111
-
112
-
113
- log_service = LogService(DATA_DIR / "logs.jsonl")
114
-
115
-
116
- def _collect_urls(value: object) -> list[str]:
117
- urls: list[str] = []
118
- if isinstance(value, dict):
119
- for key, item in value.items():
120
- if key == "url" and isinstance(item, str):
121
- urls.append(item)
122
- elif key == "urls" and isinstance(item, list):
123
- urls.extend(str(url) for url in item if isinstance(url, str))
124
- else:
125
- urls.extend(_collect_urls(item))
126
- elif isinstance(value, list):
127
- for item in value:
128
- urls.extend(_collect_urls(item))
129
- return urls
130
-
131
-
132
- def _collect_account_emails(value: object) -> list[str]:
133
- emails: list[str] = []
134
- if isinstance(value, dict):
135
- for key, item in value.items():
136
- if key in {"_account_email", "account_email"} and isinstance(item, str) and item.strip():
137
- emails.append(item.strip())
138
- else:
139
- emails.extend(_collect_account_emails(item))
140
- elif isinstance(value, list):
141
- for item in value:
142
- emails.extend(_collect_account_emails(item))
143
- return emails
144
-
145
-
146
- def _collect_conversation_ids(value: object) -> list[str]:
147
- ids: list[str] = []
148
- if isinstance(value, dict):
149
- for key, item in value.items():
150
- if key == "_conversation_id" and isinstance(item, str) and item.strip():
151
- ids.append(item.strip())
152
- else:
153
- ids.extend(_collect_conversation_ids(item))
154
- elif isinstance(value, list):
155
- for item in value:
156
- ids.extend(_collect_conversation_ids(item))
157
- return ids
158
-
159
-
160
- def _strip_internal_response_fields(value: object) -> object:
161
- if isinstance(value, dict):
162
- return {
163
- key: _strip_internal_response_fields(item)
164
- for key, item in value.items()
165
- if key not in INTERNAL_RESPONSE_KEYS
166
- }
167
- if isinstance(value, list):
168
- return [_strip_internal_response_fields(item) for item in value]
169
- return value
170
-
171
-
172
- def _request_excerpt(text: object, limit: int = 1000) -> str:
173
- value = str(text or "").strip()
174
- if not value:
175
- return ""
176
- normalized = " ".join(value.split())
177
- if len(normalized) <= limit:
178
- return normalized
179
- return normalized[: limit - 1].rstrip() + "…"
180
-
181
-
182
- def _image_error_response(exc: Exception) -> JSONResponse:
183
- from services.protocol.conversation import public_image_error_message
184
-
185
- message = public_image_error_message(str(exc))
186
- if "no available image quota" in message.lower():
187
- return openai_error_response(
188
- {
189
- "error": {
190
- "message": "no available image quota",
191
- "type": "insufficient_quota",
192
- "param": None,
193
- "code": "insufficient_quota",
194
- }
195
- },
196
- 429,
197
- )
198
- if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
199
- return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
200
- return openai_error_response(message, 502)
201
-
202
-
203
- def _protocol_error_response(exc: Exception, status_code: int, sse: str) -> JSONResponse:
204
- message = str(exc)
205
- if sse == "anthropic":
206
- return anthropic_error_response(message, status_code)
207
- return openai_error_response(message, status_code)
208
-
209
-
210
- def _next_item(items):
211
- try:
212
- return True, next(items)
213
- except StopIteration:
214
- return False, None
215
-
216
-
217
- @dataclass
218
- class LoggedCall:
219
- identity: dict[str, object]
220
- endpoint: str
221
- model: str
222
- summary: str
223
- started: float = field(default_factory=time.time)
224
- request_text: str = ""
225
- request_shape: dict[str, int] | None = None
226
-
227
- async def run(self, handler, *args, sse: str = "openai"):
228
- from services.protocol.conversation import ImageGenerationError
229
-
230
- try:
231
- result = await run_in_threadpool(handler, *args)
232
- except ImageGenerationError as exc:
233
- self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
234
- conversation_id=getattr(exc, "conversation_id", ""))
235
- return _image_error_response(exc)
236
- except HTTPException as exc:
237
- self.log("调用失败", status="failed", error=str(exc.detail))
238
- raise
239
- except Exception as exc:
240
- self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
241
- if self.endpoint.startswith("/v1/images"):
242
- return _image_error_response(exc)
243
- return _protocol_error_response(exc, 502, sse)
244
-
245
- if isinstance(result, dict):
246
- self.log("调用完成", result)
247
- response = dict(result)
248
- response.pop("_account_email", None)
249
- return response
250
-
251
- sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
252
- try:
253
- has_first, first = await run_in_threadpool(_next_item, result)
254
- except ImageGenerationError as exc:
255
- self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
256
- conversation_id=getattr(exc, "conversation_id", ""))
257
- return _image_error_response(exc)
258
- except HTTPException as exc:
259
- self.log("调用失败", status="failed", error=str(exc.detail))
260
- raise
261
- except Exception as exc:
262
- self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
263
- if self.endpoint.startswith("/v1/images"):
264
- return _image_error_response(exc)
265
- return _protocol_error_response(exc, 502, sse)
266
- if not has_first:
267
- self.log("流式调用结束")
268
- return StreamingResponse(sender(()), media_type="text/event-stream")
269
- return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
270
-
271
- def stream(self, items):
272
- urls: list[str] = []
273
- account_emails: list[str] = []
274
- conversation_ids: list[str] = []
275
- failed = False
276
- try:
277
- for item in items:
278
- urls.extend(_collect_urls(item))
279
- account_emails.extend(_collect_account_emails(item))
280
- conversation_ids.extend(_collect_conversation_ids(item))
281
- yield _strip_internal_response_fields(item)
282
- except Exception as exc:
283
- failed = True
284
- self.log(
285
- "流式调用失败",
286
- status="failed",
287
- error=str(exc),
288
- urls=urls,
289
- account_email=(account_emails[0] if account_emails else getattr(exc, "account_email", "")),
290
- conversation_id=(conversation_ids[0] if conversation_ids else getattr(exc, "conversation_id", "")),
291
- )
292
- if self.endpoint.startswith("/v1/images") and not hasattr(exc, "to_openai_error"):
293
- from services.protocol.conversation import ImageGenerationError, public_image_error_message
294
-
295
- raise ImageGenerationError(public_image_error_message(str(exc))) from exc
296
- raise
297
- finally:
298
- if not failed:
299
- self.log("流式调用结束", urls=urls, account_email=account_emails[0] if account_emails else "",
300
- conversation_id=conversation_ids[0] if conversation_ids else "")
301
-
302
- def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
303
- urls: list[str] | None = None, account_email: str = "", conversation_id: str = "") -> None:
304
- detail = {
305
- "key_id": self.identity.get("id"),
306
- "key_name": self.identity.get("name"),
307
- "role": self.identity.get("role"),
308
- "endpoint": self.endpoint,
309
- "model": self.model,
310
- "started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
311
- "ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
312
- "duration_ms": int((time.time() - self.started) * 1000),
313
- "status": status,
314
- }
315
- request_excerpt = _request_excerpt(self.request_text)
316
- if request_excerpt:
317
- detail["request_text"] = request_excerpt
318
- if self.request_shape:
319
- detail["request_shape"] = self.request_shape
320
- if error:
321
- detail["error"] = error
322
- email = str(account_email or "").strip()
323
- if not email:
324
- emails = _collect_account_emails(result)
325
- email = emails[0] if emails else ""
326
- if email:
327
- detail["account_email"] = email
328
- conv_id = str(conversation_id or "").strip()
329
- if not conv_id:
330
- conv_ids = _collect_conversation_ids(result)
331
- conv_id = conv_ids[0] if conv_ids else ""
332
- if conv_id:
333
- detail["conversation_id"] = conv_id
334
- collected_urls = [*(urls or []), *_collect_urls(result)]
335
- if collected_urls and not self.endpoint.startswith("/v1/search"):
336
- detail["urls"] = list(dict.fromkeys(collected_urls))
337
- log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import itertools
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ from fastapi import HTTPException
14
+ from fastapi.concurrency import run_in_threadpool
15
+ from fastapi.responses import JSONResponse, StreamingResponse
16
+
17
+ from services.config import DATA_DIR
18
+ from services.protocol.error_response import anthropic_error_response, openai_error_response
19
+ from utils.helper import anthropic_sse_stream, sse_json_stream
20
+
21
+ LOG_TYPE_CALL = "call"
22
+ LOG_TYPE_ACCOUNT = "account"
23
+ INTERNAL_RESPONSE_KEYS = {"_account_email", "_conversation_id"}
24
+
25
+
26
+ class LogService:
27
+ def __init__(self, path: Path):
28
+ self.path = path
29
+ self.path.parent.mkdir(parents=True, exist_ok=True)
30
+
31
+ @staticmethod
32
+ def _legacy_id(raw_line: str, line_number: int) -> str:
33
+ payload = f"{line_number}:{raw_line}".encode("utf-8", errors="ignore")
34
+ return hashlib.sha1(payload).hexdigest()[:24]
35
+
36
+ def _parse_line(self, raw_line: str, line_number: int) -> dict[str, Any] | None:
37
+ try:
38
+ item = json.loads(raw_line)
39
+ except Exception:
40
+ return None
41
+ if not isinstance(item, dict):
42
+ return None
43
+ parsed = dict(item)
44
+ parsed["id"] = str(parsed.get("id") or self._legacy_id(raw_line, line_number))
45
+ return parsed
46
+
47
+ @staticmethod
48
+ def _serialize_item(item: dict[str, Any]) -> str:
49
+ return json.dumps(item, ensure_ascii=False, separators=(",", ":"))
50
+
51
+ @staticmethod
52
+ def _matches_filters(item: dict[str, Any], *, type: str = "", start_date: str = "", end_date: str = "") -> bool:
53
+ t = str(item.get("time") or "")
54
+ day = t[:10]
55
+ if type and item.get("type") != type:
56
+ return False
57
+ if start_date and day < start_date:
58
+ return False
59
+ if end_date and day > end_date:
60
+ return False
61
+ return True
62
+
63
+ def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None:
64
+ item = {
65
+ "id": uuid4().hex,
66
+ "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
67
+ "type": type,
68
+ "summary": summary,
69
+ "detail": detail or data,
70
+ }
71
+ with self.path.open("a", encoding="utf-8") as file:
72
+ file.write(self._serialize_item(item) + "\n")
73
+
74
+ def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]:
75
+ if not self.path.exists():
76
+ return []
77
+ items: list[dict[str, Any]] = []
78
+ lines = self.path.read_text(encoding="utf-8").splitlines()
79
+ for line_number in range(len(lines) - 1, -1, -1):
80
+ item = self._parse_line(lines[line_number], line_number)
81
+ if item is None:
82
+ continue
83
+ if not self._matches_filters(item, type=type, start_date=start_date, end_date=end_date):
84
+ continue
85
+ items.append(item)
86
+ if len(items) >= limit:
87
+ break
88
+ return items
89
+
90
+ def delete(self, ids: list[str]) -> dict[str, int]:
91
+ target_ids = {str(item or "").strip() for item in ids if str(item or "").strip()}
92
+ if not self.path.exists() or not target_ids:
93
+ return {"removed": 0}
94
+ lines = self.path.read_text(encoding="utf-8").splitlines()
95
+ kept_lines: list[str] = []
96
+ removed = 0
97
+ for line_number, raw_line in enumerate(lines):
98
+ item = self._parse_line(raw_line, line_number)
99
+ if item is None:
100
+ kept_lines.append(raw_line)
101
+ continue
102
+ if str(item.get("id") or "") in target_ids:
103
+ removed += 1
104
+ continue
105
+ kept_lines.append(self._serialize_item(item))
106
+ content = "\n".join(kept_lines)
107
+ if content:
108
+ content += "\n"
109
+ self.path.write_text(content, encoding="utf-8")
110
+ return {"removed": removed}
111
+
112
+
113
+ log_service = LogService(DATA_DIR / "logs.jsonl")
114
+
115
+
116
+ def _collect_urls(value: object) -> list[str]:
117
+ urls: list[str] = []
118
+ if isinstance(value, dict):
119
+ for key, item in value.items():
120
+ if key == "url" and isinstance(item, str):
121
+ urls.append(item)
122
+ elif key == "urls" and isinstance(item, list):
123
+ urls.extend(str(url) for url in item if isinstance(url, str))
124
+ else:
125
+ urls.extend(_collect_urls(item))
126
+ elif isinstance(value, list):
127
+ for item in value:
128
+ urls.extend(_collect_urls(item))
129
+ return urls
130
+
131
+
132
+ def _collect_account_emails(value: object) -> list[str]:
133
+ emails: list[str] = []
134
+ if isinstance(value, dict):
135
+ for key, item in value.items():
136
+ if key in {"_account_email", "account_email"} and isinstance(item, str) and item.strip():
137
+ emails.append(item.strip())
138
+ else:
139
+ emails.extend(_collect_account_emails(item))
140
+ elif isinstance(value, list):
141
+ for item in value:
142
+ emails.extend(_collect_account_emails(item))
143
+ return emails
144
+
145
+
146
+ def _collect_conversation_ids(value: object) -> list[str]:
147
+ ids: list[str] = []
148
+ if isinstance(value, dict):
149
+ for key, item in value.items():
150
+ if key == "_conversation_id" and isinstance(item, str) and item.strip():
151
+ ids.append(item.strip())
152
+ else:
153
+ ids.extend(_collect_conversation_ids(item))
154
+ elif isinstance(value, list):
155
+ for item in value:
156
+ ids.extend(_collect_conversation_ids(item))
157
+ return ids
158
+
159
+
160
+ def _strip_internal_response_fields(value: object) -> object:
161
+ if isinstance(value, dict):
162
+ return {
163
+ key: _strip_internal_response_fields(item)
164
+ for key, item in value.items()
165
+ if key not in INTERNAL_RESPONSE_KEYS
166
+ }
167
+ if isinstance(value, list):
168
+ return [_strip_internal_response_fields(item) for item in value]
169
+ return value
170
+
171
+
172
+ def _request_excerpt(text: object, limit: int = 1000) -> str:
173
+ value = str(text or "").strip()
174
+ if not value:
175
+ return ""
176
+ normalized = " ".join(value.split())
177
+ if len(normalized) <= limit:
178
+ return normalized
179
+ return normalized[: limit - 1].rstrip() + "…"
180
+
181
+
182
+ def _image_error_response(exc: Exception) -> JSONResponse:
183
+ from services.protocol.conversation import public_image_error_message
184
+
185
+ message = public_image_error_message(str(exc))
186
+ if "no available image quota" in message.lower():
187
+ return openai_error_response(
188
+ {
189
+ "error": {
190
+ "message": "no available image quota",
191
+ "type": "insufficient_quota",
192
+ "param": None,
193
+ "code": "insufficient_quota",
194
+ }
195
+ },
196
+ 429,
197
+ )
198
+ if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
199
+ return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
200
+ return openai_error_response(message, 502)
201
+
202
+
203
+ def _protocol_error_response(exc: Exception, status_code: int, sse: str) -> JSONResponse:
204
+ message = str(exc)
205
+ if sse == "anthropic":
206
+ return anthropic_error_response(message, status_code)
207
+ return openai_error_response(message, status_code)
208
+
209
+
210
+ def _next_item(items):
211
+ try:
212
+ return True, next(items)
213
+ except StopIteration:
214
+ return False, None
215
+
216
+
217
+ @dataclass
218
+ class LoggedCall:
219
+ identity: dict[str, object]
220
+ endpoint: str
221
+ model: str
222
+ summary: str
223
+ started: float = field(default_factory=time.time)
224
+ request_text: str = ""
225
+ request_shape: dict[str, int] | None = None
226
+
227
+ async def run(self, handler, *args, sse: str = "openai"):
228
+ from services.protocol.conversation import ImageGenerationError
229
+
230
+ try:
231
+ result = await run_in_threadpool(handler, *args)
232
+ except ImageGenerationError as exc:
233
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
234
+ conversation_id=getattr(exc, "conversation_id", ""))
235
+ return _image_error_response(exc)
236
+ except HTTPException as exc:
237
+ self.log("调用失败", status="failed", error=str(exc.detail))
238
+ raise
239
+ except Exception as exc:
240
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
241
+ if self.endpoint.startswith("/v1/images"):
242
+ return _image_error_response(exc)
243
+ return _protocol_error_response(exc, 502, sse)
244
+
245
+ if isinstance(result, dict):
246
+ self.log("调用完成", result)
247
+ response = dict(result)
248
+ response.pop("_account_email", None)
249
+ return response
250
+
251
+ sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
252
+ try:
253
+ has_first, first = await run_in_threadpool(_next_item, result)
254
+ except ImageGenerationError as exc:
255
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
256
+ conversation_id=getattr(exc, "conversation_id", ""))
257
+ return _image_error_response(exc)
258
+ except HTTPException as exc:
259
+ self.log("调用失败", status="failed", error=str(exc.detail))
260
+ raise
261
+ except Exception as exc:
262
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
263
+ if self.endpoint.startswith("/v1/images"):
264
+ return _image_error_response(exc)
265
+ return _protocol_error_response(exc, 502, sse)
266
+ if not has_first:
267
+ self.log("流式调用结束")
268
+ return StreamingResponse(sender(()), media_type="text/event-stream")
269
+ return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
270
+
271
+ def stream(self, items):
272
+ urls: list[str] = []
273
+ account_emails: list[str] = []
274
+ conversation_ids: list[str] = []
275
+ failed = False
276
+ try:
277
+ for item in items:
278
+ urls.extend(_collect_urls(item))
279
+ account_emails.extend(_collect_account_emails(item))
280
+ conversation_ids.extend(_collect_conversation_ids(item))
281
+ yield _strip_internal_response_fields(item)
282
+ except Exception as exc:
283
+ failed = True
284
+ self.log(
285
+ "流式调用失败",
286
+ status="failed",
287
+ error=str(exc),
288
+ urls=urls,
289
+ account_email=(account_emails[0] if account_emails else getattr(exc, "account_email", "")),
290
+ conversation_id=(conversation_ids[0] if conversation_ids else getattr(exc, "conversation_id", "")),
291
+ )
292
+ if self.endpoint.startswith("/v1/images") and not hasattr(exc, "to_openai_error"):
293
+ from services.protocol.conversation import ImageGenerationError, public_image_error_message
294
+
295
+ raise ImageGenerationError(public_image_error_message(str(exc))) from exc
296
+ raise
297
+ finally:
298
+ if not failed:
299
+ self.log("流式调用结束", urls=urls, account_email=account_emails[0] if account_emails else "",
300
+ conversation_id=conversation_ids[0] if conversation_ids else "")
301
+
302
+ def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
303
+ urls: list[str] | None = None, account_email: str = "", conversation_id: str = "") -> None:
304
+ detail = {
305
+ "key_id": self.identity.get("id"),
306
+ "key_name": self.identity.get("name"),
307
+ "role": self.identity.get("role"),
308
+ "endpoint": self.endpoint,
309
+ "model": self.model,
310
+ "started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
311
+ "ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
312
+ "duration_ms": int((time.time() - self.started) * 1000),
313
+ "status": status,
314
+ }
315
+ request_excerpt = _request_excerpt(self.request_text)
316
+ if request_excerpt:
317
+ detail["request_text"] = request_excerpt
318
+ if self.request_shape:
319
+ detail["request_shape"] = self.request_shape
320
+ if error:
321
+ detail["error"] = error
322
+ email = str(account_email or "").strip()
323
+ if not email:
324
+ emails = _collect_account_emails(result)
325
+ email = emails[0] if emails else ""
326
+ if email:
327
+ detail["account_email"] = email
328
+ conv_id = str(conversation_id or "").strip()
329
+ if not conv_id:
330
+ conv_ids = _collect_conversation_ids(result)
331
+ conv_id = conv_ids[0] if conv_ids else ""
332
+ if conv_id:
333
+ detail["conversation_id"] = conv_id
334
+ collected_urls = [*(urls or []), *_collect_urls(result)]
335
+ if collected_urls and not self.endpoint.startswith("/v1/search"):
336
+ detail["urls"] = list(dict.fromkeys(collected_urls))
337
+ log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)