dsh-plugin-image-workbench 1.1.5 → 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 (172) 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 +1329 -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/vendor/chatgpt2api/source/.env.example +82 -82
  25. package/vendor/chatgpt2api/source/.python-version +1 -1
  26. package/vendor/chatgpt2api/source/LICENSE +21 -21
  27. package/vendor/chatgpt2api/source/README.md +391 -391
  28. package/vendor/chatgpt2api/source/VERSION +1 -1
  29. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  30. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  31. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  32. package/vendor/chatgpt2api/source/api/app.py +62 -62
  33. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  34. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  35. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  36. package/vendor/chatgpt2api/source/api/support.py +134 -134
  37. package/vendor/chatgpt2api/source/api/system.py +365 -365
  38. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  39. package/vendor/chatgpt2api/source/config.default.json +108 -108
  40. package/vendor/chatgpt2api/source/main.py +9 -9
  41. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  42. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  43. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  44. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  45. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  46. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  47. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  48. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  49. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  50. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  51. package/vendor/chatgpt2api/source/services/config.py +667 -667
  52. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  53. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  54. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  55. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  56. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  57. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  58. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  59. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  60. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  61. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  62. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  63. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  64. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  65. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  66. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  67. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  68. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  69. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  70. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  75. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  76. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  77. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  78. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  79. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  80. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  81. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  82. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  83. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  84. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  85. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  86. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  87. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  88. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  89. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  90. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  91. package/vendor/chatgpt2api/source/uv.lock +904 -904
  92. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  93. package/vendor/chatgpt2api/source/web/components.json +24 -24
  94. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  95. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  96. package/vendor/chatgpt2api/source/web/package.json +58 -58
  97. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  98. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  100. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  107. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  108. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  109. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  112. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  113. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  114. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  115. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  116. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  117. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  118. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  132. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  133. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  134. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  135. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  136. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  137. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  138. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  139. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  140. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  141. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  142. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  143. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  144. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  145. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  146. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  147. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  149. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  150. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  151. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  152. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  153. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  154. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  155. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  156. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  157. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  158. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  159. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  160. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  161. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  162. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  163. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  164. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  165. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  166. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  167. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  168. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  169. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  170. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  171. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  172. 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)