dsh-plugin-image-workbench 0.0.0-stage → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +256 -0
- package/README.md +256 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +641 -0
- package/install.mjs +387 -0
- package/package.json +57 -4
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from services.account_service import account_service
|
|
12
|
+
from services.config import DATA_DIR
|
|
13
|
+
from services.content_filter import request_text
|
|
14
|
+
from services.log_service import LOG_TYPE_CALL, log_service
|
|
15
|
+
from services.openai_backend_api import EDITABLE_FILE_MODEL, OpenAIBackendAPI
|
|
16
|
+
from utils.helper import new_uuid
|
|
17
|
+
|
|
18
|
+
TASK_STATUS_QUEUED = "queued"
|
|
19
|
+
TASK_STATUS_RUNNING = "running"
|
|
20
|
+
TASK_STATUS_SUCCESS = "success"
|
|
21
|
+
TASK_STATUS_ERROR = "error"
|
|
22
|
+
UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
|
|
23
|
+
EDITABLE_FILE_PLAN_TYPES = ("Plus", "Team", "Pro", "Enterprise")
|
|
24
|
+
EDITABLE_FILE_ROOT = DATA_DIR / "files"
|
|
25
|
+
EDITABLE_FILE_TASKS_PATH = DATA_DIR / "editable_file_tasks.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _now_iso() -> str:
|
|
29
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _clean(value: object, default: str = "") -> str:
|
|
33
|
+
return str(value or default).strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _owner_id(identity: dict[str, object]) -> str:
|
|
37
|
+
return _clean(identity.get("id")) or "anonymous"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _task_key(owner_id: str, task_id: str) -> str:
|
|
41
|
+
return f"{owner_id}:{task_id}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _elapsed_seconds(task: dict[str, Any]) -> int:
|
|
45
|
+
start = float(task.get("started_ts") or task.get("created_ts") or 0)
|
|
46
|
+
end = float(task.get("ended_ts") or time.time())
|
|
47
|
+
return max(0, int(end - start)) if start else 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _file_url(path: Path, base_url: str) -> str:
|
|
51
|
+
rel = path.resolve().relative_to(EDITABLE_FILE_ROOT.resolve()).as_posix()
|
|
52
|
+
prefix = str(base_url or "").strip().rstrip("/")
|
|
53
|
+
return f"{prefix}/files/{quote(rel, safe='/')}" if prefix else f"/files/{quote(rel, safe='/')}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _editable_access_token() -> str:
|
|
57
|
+
accounts = [
|
|
58
|
+
item for item in account_service.list_accounts()
|
|
59
|
+
if _clean(item.get("access_token"))
|
|
60
|
+
and item.get("status") not in {"禁用", "异常"}
|
|
61
|
+
and account_service._account_matches_any_plan_type(item, EDITABLE_FILE_PLAN_TYPES)
|
|
62
|
+
]
|
|
63
|
+
if not accounts:
|
|
64
|
+
raise RuntimeError("no available plus/team/pro account")
|
|
65
|
+
accounts.sort(key=lambda item: _clean(item.get("last_used_at")))
|
|
66
|
+
token = _clean(accounts[0].get("access_token"))
|
|
67
|
+
return account_service.refresh_access_token(token, event="editable_file_task") or token
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _public_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
71
|
+
item = {
|
|
72
|
+
"id": task.get("id"),
|
|
73
|
+
"taskId": task.get("id"),
|
|
74
|
+
"status": task.get("status"),
|
|
75
|
+
"kind": task.get("kind"),
|
|
76
|
+
"created_at": task.get("created_at"),
|
|
77
|
+
"updated_at": task.get("updated_at"),
|
|
78
|
+
"elapsed_seconds": _elapsed_seconds(task),
|
|
79
|
+
}
|
|
80
|
+
for key in ("result", "error"):
|
|
81
|
+
if task.get(key):
|
|
82
|
+
item[key] = task[key]
|
|
83
|
+
return item
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class EditableFileTaskService:
|
|
87
|
+
def __init__(self, path: Path = EDITABLE_FILE_TASKS_PATH) -> None:
|
|
88
|
+
self.path = path
|
|
89
|
+
self._lock = threading.RLock()
|
|
90
|
+
self._tasks: dict[str, dict[str, Any]] = {}
|
|
91
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
with self._lock:
|
|
93
|
+
self._tasks = self._load_locked()
|
|
94
|
+
if self._recover_unfinished_locked():
|
|
95
|
+
self._save_locked()
|
|
96
|
+
|
|
97
|
+
def submit_ppt(self, identity: dict[str, object], *, client_task_id: str = "", prompt: str = "", base64_images: list[str] | None = None, base_url: str = "") -> dict[str, Any]:
|
|
98
|
+
return self._submit(identity, client_task_id=client_task_id, kind="ppt", prompt=prompt, base64_images=base64_images or [], base_url=base_url)
|
|
99
|
+
|
|
100
|
+
def submit_psd(self, identity: dict[str, object], *, client_task_id: str = "", prompt: str = "", base64_images: list[str] | None = None, base_url: str = "") -> dict[str, Any]:
|
|
101
|
+
return self._submit(identity, client_task_id=client_task_id, kind="psd", prompt=prompt, base64_images=base64_images or [], base_url=base_url)
|
|
102
|
+
|
|
103
|
+
def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
|
|
104
|
+
owner = _owner_id(identity)
|
|
105
|
+
requested = [_clean(item) for item in task_ids if _clean(item)]
|
|
106
|
+
with self._lock:
|
|
107
|
+
if requested:
|
|
108
|
+
items = [task for task_id in requested if (task := self._tasks.get(_task_key(owner, task_id)))]
|
|
109
|
+
return {"items": [_public_task(item) for item in items], "missing_ids": [task_id for task_id in requested if _task_key(owner, task_id) not in self._tasks]}
|
|
110
|
+
items = [task for task in self._tasks.values() if task.get("owner_id") == owner]
|
|
111
|
+
items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
|
112
|
+
return {"items": [_public_task(item) for item in items], "missing_ids": []}
|
|
113
|
+
|
|
114
|
+
def _submit(self, identity: dict[str, object], *, client_task_id: str, kind: str, prompt: str, base64_images: list[str], base_url: str) -> dict[str, Any]:
|
|
115
|
+
task_id = _clean(client_task_id) or new_uuid()
|
|
116
|
+
owner = _owner_id(identity)
|
|
117
|
+
key = _task_key(owner, task_id)
|
|
118
|
+
now = _now_iso()
|
|
119
|
+
with self._lock:
|
|
120
|
+
if key in self._tasks:
|
|
121
|
+
return _public_task(self._tasks[key])
|
|
122
|
+
ts = time.time()
|
|
123
|
+
self._tasks[key] = {"id": task_id, "owner_id": owner, "status": TASK_STATUS_QUEUED, "kind": kind, "model": EDITABLE_FILE_MODEL, "created_at": now, "updated_at": now, "created_ts": ts, "updated_ts": ts}
|
|
124
|
+
task = dict(self._tasks[key])
|
|
125
|
+
self._save_locked()
|
|
126
|
+
threading.Thread(target=self._run_task, args=(key, kind, prompt, base64_images, dict(identity), base_url), name=f"{kind}-file-task-{task_id[:16]}", daemon=True).start()
|
|
127
|
+
return _public_task(task)
|
|
128
|
+
|
|
129
|
+
def _run_task(self, key: str, kind: str, prompt: str, base64_images: list[str], identity: dict[str, object], base_url: str) -> None:
|
|
130
|
+
started = time.time()
|
|
131
|
+
token = ""
|
|
132
|
+
account_email = ""
|
|
133
|
+
self._update_task(key, status=TASK_STATUS_RUNNING, error="", started_ts=started)
|
|
134
|
+
backend = None
|
|
135
|
+
try:
|
|
136
|
+
if kind == "psd" and not base64_images:
|
|
137
|
+
raise ValueError("base64_images is empty")
|
|
138
|
+
token = _editable_access_token()
|
|
139
|
+
account = account_service.get_account(token) or {}
|
|
140
|
+
account_email = _clean(account.get("email"))
|
|
141
|
+
backend = OpenAIBackendAPI(token)
|
|
142
|
+
output_dir = EDITABLE_FILE_ROOT / kind / key.rsplit(":", 1)[-1]
|
|
143
|
+
result = backend.export_psd_zip(base64_images, prompt, output_dir) if kind == "psd" else backend.export_ppt_zip(base64_images, prompt, output_dir)
|
|
144
|
+
account_service.mark_text_used(token)
|
|
145
|
+
data = {"conversation_id": result.conversation_id, "primary_url": _file_url(result.primary_path, base_url), "zip_url": _file_url(result.zip_path, base_url)}
|
|
146
|
+
self._update_task(key, status=TASK_STATUS_SUCCESS, result=data, account_email=account_email, error="", ended_ts=time.time())
|
|
147
|
+
self._log_call(identity, kind, started, request_text(prompt), account_email=account_email, result=data)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
error = str(exc) or "editable file task failed"
|
|
150
|
+
self._update_task(key, status=TASK_STATUS_ERROR, error=error, account_email=account_email, ended_ts=time.time())
|
|
151
|
+
self._log_call(identity, kind, started, request_text(prompt), status="failed", error=error, account_email=account_email)
|
|
152
|
+
finally:
|
|
153
|
+
if backend is not None:
|
|
154
|
+
backend.close()
|
|
155
|
+
|
|
156
|
+
def public_file_path(self, relative_path: str) -> Path:
|
|
157
|
+
raw = str(relative_path or "").replace("\\", "/").lstrip("/")
|
|
158
|
+
path = (EDITABLE_FILE_ROOT / raw).resolve()
|
|
159
|
+
path.relative_to(EDITABLE_FILE_ROOT.resolve())
|
|
160
|
+
if not path.is_file():
|
|
161
|
+
raise FileNotFoundError(raw)
|
|
162
|
+
return path
|
|
163
|
+
|
|
164
|
+
def _update_task(self, key: str, **updates: Any) -> None:
|
|
165
|
+
with self._lock:
|
|
166
|
+
task = self._tasks.get(key)
|
|
167
|
+
if task is None:
|
|
168
|
+
return
|
|
169
|
+
task.update(updates)
|
|
170
|
+
task["updated_at"] = _now_iso()
|
|
171
|
+
task["updated_ts"] = time.time()
|
|
172
|
+
self._save_locked()
|
|
173
|
+
|
|
174
|
+
def _load_locked(self) -> dict[str, dict[str, Any]]:
|
|
175
|
+
if not self.path.exists():
|
|
176
|
+
return {}
|
|
177
|
+
try:
|
|
178
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
179
|
+
except Exception:
|
|
180
|
+
return {}
|
|
181
|
+
tasks: dict[str, dict[str, Any]] = {}
|
|
182
|
+
for item in (raw.get("tasks") if isinstance(raw, dict) else raw) or []:
|
|
183
|
+
if not isinstance(item, dict):
|
|
184
|
+
continue
|
|
185
|
+
task_id = _clean(item.get("id"))
|
|
186
|
+
owner = _clean(item.get("owner_id"))
|
|
187
|
+
if not task_id or not owner:
|
|
188
|
+
continue
|
|
189
|
+
task = {
|
|
190
|
+
"id": task_id,
|
|
191
|
+
"owner_id": owner,
|
|
192
|
+
"status": _clean(item.get("status"), TASK_STATUS_ERROR),
|
|
193
|
+
"kind": "psd" if item.get("kind") == "psd" else "ppt",
|
|
194
|
+
"created_at": _clean(item.get("created_at"), _now_iso()),
|
|
195
|
+
"updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
|
|
196
|
+
"created_ts": float(item.get("created_ts") or 0),
|
|
197
|
+
"updated_ts": float(item.get("updated_ts") or 0),
|
|
198
|
+
}
|
|
199
|
+
for field in ("result", "error", "started_ts", "ended_ts"):
|
|
200
|
+
if item.get(field):
|
|
201
|
+
task[field] = item[field]
|
|
202
|
+
tasks[_task_key(owner, task_id)] = task
|
|
203
|
+
return tasks
|
|
204
|
+
|
|
205
|
+
def _save_locked(self) -> None:
|
|
206
|
+
items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
|
207
|
+
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
208
|
+
tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
209
|
+
tmp_path.replace(self.path)
|
|
210
|
+
|
|
211
|
+
def _recover_unfinished_locked(self) -> bool:
|
|
212
|
+
changed = False
|
|
213
|
+
for task in self._tasks.values():
|
|
214
|
+
if task.get("status") in UNFINISHED_STATUSES:
|
|
215
|
+
task["status"] = TASK_STATUS_ERROR
|
|
216
|
+
task["error"] = "服务已重启,未完成的任务已中断"
|
|
217
|
+
task["ended_ts"] = time.time()
|
|
218
|
+
task["updated_at"] = _now_iso()
|
|
219
|
+
task["updated_ts"] = time.time()
|
|
220
|
+
changed = True
|
|
221
|
+
return changed
|
|
222
|
+
|
|
223
|
+
def _log_call(
|
|
224
|
+
self,
|
|
225
|
+
identity: dict[str, object],
|
|
226
|
+
kind: str,
|
|
227
|
+
started: float,
|
|
228
|
+
request_preview: str,
|
|
229
|
+
*,
|
|
230
|
+
status: str = "success",
|
|
231
|
+
error: str = "",
|
|
232
|
+
account_email: str = "",
|
|
233
|
+
result: dict[str, str] | None = None,
|
|
234
|
+
) -> None:
|
|
235
|
+
detail = {
|
|
236
|
+
"key_id": identity.get("id"),
|
|
237
|
+
"key_name": identity.get("name"),
|
|
238
|
+
"role": identity.get("role"),
|
|
239
|
+
"endpoint": f"/v1/{kind}/generations",
|
|
240
|
+
"model": EDITABLE_FILE_MODEL,
|
|
241
|
+
"started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
|
|
242
|
+
"ended_at": _now_iso(),
|
|
243
|
+
"duration_ms": int((time.time() - started) * 1000),
|
|
244
|
+
"status": status,
|
|
245
|
+
}
|
|
246
|
+
if request_preview:
|
|
247
|
+
detail["request_text"] = request_preview
|
|
248
|
+
if account_email:
|
|
249
|
+
detail["account_email"] = account_email
|
|
250
|
+
if error:
|
|
251
|
+
detail["error"] = error
|
|
252
|
+
if result:
|
|
253
|
+
detail["result"] = result
|
|
254
|
+
try:
|
|
255
|
+
log_service.add(LOG_TYPE_CALL, f"{kind.upper()}生成任务{'失败' if status == 'failed' else '完成'}", detail)
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
editable_file_task_service = EditableFileTaskService()
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import shutil
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException
|
|
11
|
+
from fastapi.responses import FileResponse, Response
|
|
12
|
+
from PIL import Image, ImageOps
|
|
13
|
+
|
|
14
|
+
from services.config import config
|
|
15
|
+
from services.image_storage_service import image_storage_service
|
|
16
|
+
from services.image_tags_service import load_tags, remove_tags
|
|
17
|
+
from utils.log import logger
|
|
18
|
+
|
|
19
|
+
THUMBNAIL_SIZE = (320, 320)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cleanup_empty_dirs(root: Path) -> None:
|
|
23
|
+
for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
|
|
24
|
+
try:
|
|
25
|
+
path.rmdir()
|
|
26
|
+
except OSError:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_relative_path(path: str) -> str:
|
|
31
|
+
value = str(path or "").strip().replace("\\", "/").lstrip("/")
|
|
32
|
+
if not value:
|
|
33
|
+
raise HTTPException(status_code=404, detail="image not found")
|
|
34
|
+
parts = Path(value).parts
|
|
35
|
+
if any(part in {"", ".", ".."} for part in parts):
|
|
36
|
+
raise HTTPException(status_code=404, detail="image not found")
|
|
37
|
+
return Path(*parts).as_posix()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _safe_image_path(relative_path: str) -> Path:
|
|
41
|
+
rel = _safe_relative_path(relative_path)
|
|
42
|
+
root = config.images_dir.resolve()
|
|
43
|
+
path = (root / rel).resolve()
|
|
44
|
+
try:
|
|
45
|
+
path.relative_to(root)
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
raise HTTPException(status_code=404, detail="image not found") from exc
|
|
48
|
+
if not path.is_file():
|
|
49
|
+
raise HTTPException(status_code=404, detail="image not found")
|
|
50
|
+
return path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_image_response(relative_path: str) -> FileResponse | Response:
|
|
54
|
+
headers = {
|
|
55
|
+
"Access-Control-Allow-Origin": "*",
|
|
56
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
57
|
+
"Access-Control-Allow-Headers": "*",
|
|
58
|
+
}
|
|
59
|
+
if image_storage_service.has_local(relative_path):
|
|
60
|
+
return FileResponse(_safe_image_path(relative_path), headers=headers)
|
|
61
|
+
return Response(content=image_storage_service.get_bytes(relative_path), media_type="image/png", headers=headers)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _thumbnail_path(relative_path: str) -> Path:
|
|
65
|
+
rel = _safe_relative_path(relative_path)
|
|
66
|
+
return config.image_thumbnails_dir / f"{rel}.png"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def thumbnail_url(base_url: str, relative_path: str) -> str:
|
|
70
|
+
return f"{base_url.rstrip('/')}/image-thumbnails/{_safe_relative_path(relative_path)}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _image_dimensions(path: Path) -> tuple[int, int] | None:
|
|
74
|
+
try:
|
|
75
|
+
with Image.open(path) as image:
|
|
76
|
+
return image.size
|
|
77
|
+
except Exception:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def ensure_thumbnail(relative_path: str) -> Path:
|
|
82
|
+
target = _thumbnail_path(relative_path)
|
|
83
|
+
source_mtime = 0.0
|
|
84
|
+
source: Path | None = None
|
|
85
|
+
if image_storage_service.has_local(relative_path):
|
|
86
|
+
source = _safe_image_path(relative_path)
|
|
87
|
+
source_mtime = source.stat().st_mtime
|
|
88
|
+
if target.exists() and (not source_mtime or target.stat().st_mtime >= source_mtime):
|
|
89
|
+
return target
|
|
90
|
+
|
|
91
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
try:
|
|
93
|
+
image_source = source if source is not None else io.BytesIO(image_storage_service.get_bytes(relative_path))
|
|
94
|
+
with Image.open(image_source) as image:
|
|
95
|
+
image = ImageOps.exif_transpose(image)
|
|
96
|
+
if image.mode not in {"RGB", "RGBA"}:
|
|
97
|
+
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
|
98
|
+
image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
|
|
99
|
+
image.save(target, format="PNG", optimize=True)
|
|
100
|
+
except HTTPException:
|
|
101
|
+
raise
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
raise HTTPException(status_code=422, detail="failed to create thumbnail") from exc
|
|
104
|
+
return target
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_thumbnail_response(relative_path: str) -> FileResponse:
|
|
108
|
+
headers = {
|
|
109
|
+
"Access-Control-Allow-Origin": "*",
|
|
110
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
111
|
+
"Access-Control-Allow-Headers": "*",
|
|
112
|
+
}
|
|
113
|
+
return FileResponse(ensure_thumbnail(relative_path), headers=headers)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_image_download_response(relative_path: str) -> FileResponse:
|
|
117
|
+
cors_headers = {
|
|
118
|
+
"Access-Control-Allow-Origin": "*",
|
|
119
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
120
|
+
"Access-Control-Allow-Headers": "*",
|
|
121
|
+
}
|
|
122
|
+
if image_storage_service.has_local(relative_path):
|
|
123
|
+
path = _safe_image_path(relative_path)
|
|
124
|
+
headers = {**cors_headers, "Content-Disposition": f'attachment; filename="{path.name}"'}
|
|
125
|
+
return FileResponse(path, filename=path.name, headers=headers)
|
|
126
|
+
rel = _safe_relative_path(relative_path)
|
|
127
|
+
headers = {
|
|
128
|
+
**cors_headers,
|
|
129
|
+
"Content-Disposition": f'attachment; filename="{Path(rel).name}"',
|
|
130
|
+
}
|
|
131
|
+
return Response(
|
|
132
|
+
content=image_storage_service.get_bytes(rel),
|
|
133
|
+
media_type="image/png",
|
|
134
|
+
headers=headers,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cleanup_image_thumbnails() -> int:
|
|
139
|
+
thumbnails_root = config.image_thumbnails_dir
|
|
140
|
+
removed = 0
|
|
141
|
+
for path in thumbnails_root.rglob("*"):
|
|
142
|
+
if not path.is_file():
|
|
143
|
+
continue
|
|
144
|
+
rel = path.relative_to(thumbnails_root).as_posix()
|
|
145
|
+
if not rel.endswith(".png") or not image_storage_service.exists(rel[:-4]):
|
|
146
|
+
path.unlink()
|
|
147
|
+
removed += 1
|
|
148
|
+
_cleanup_empty_dirs(thumbnails_root)
|
|
149
|
+
return removed
|
|
150
|
+
|
|
151
|
+
def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
|
|
152
|
+
config.cleanup_old_images()
|
|
153
|
+
cleanup_image_thumbnails()
|
|
154
|
+
all_tags = load_tags()
|
|
155
|
+
items = [
|
|
156
|
+
{
|
|
157
|
+
**item,
|
|
158
|
+
"url": str(item.get("url") or f"{base_url.rstrip('/')}/images/{item['path']}"),
|
|
159
|
+
"thumbnail_url": thumbnail_url(base_url, str(item["path"])),
|
|
160
|
+
"tags": all_tags.get(str(item["path"]), []),
|
|
161
|
+
}
|
|
162
|
+
for item in image_storage_service.list_items(base_url, start_date, end_date)
|
|
163
|
+
]
|
|
164
|
+
groups: dict[str, list[dict[str, object]]] = {}
|
|
165
|
+
for item in items:
|
|
166
|
+
groups.setdefault(str(item["date"]), []).append(item)
|
|
167
|
+
return {"items": items, "groups": [{"date": key, "items": value} for key, value in groups.items()]}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def delete_images(paths: list[str] | None = None, start_date: str = "", end_date: str = "", all_matching: bool = False) -> dict[str, int]:
|
|
171
|
+
root = config.images_dir.resolve()
|
|
172
|
+
targets = [
|
|
173
|
+
str(item["path"])
|
|
174
|
+
for item in image_storage_service.list_items("", start_date=start_date, end_date=end_date)
|
|
175
|
+
] if all_matching else (paths or [])
|
|
176
|
+
removed = 0
|
|
177
|
+
for item in targets:
|
|
178
|
+
path = (root / item).resolve()
|
|
179
|
+
try:
|
|
180
|
+
path.relative_to(root)
|
|
181
|
+
except ValueError:
|
|
182
|
+
continue
|
|
183
|
+
if image_storage_service.delete(item):
|
|
184
|
+
removed += 1
|
|
185
|
+
for thumbnail in (_thumbnail_path(item), config.image_thumbnails_dir / _safe_relative_path(item)):
|
|
186
|
+
if thumbnail.is_file():
|
|
187
|
+
thumbnail.unlink()
|
|
188
|
+
remove_tags(item)
|
|
189
|
+
_cleanup_empty_dirs(root)
|
|
190
|
+
_cleanup_empty_dirs(config.image_thumbnails_dir)
|
|
191
|
+
return {"removed": removed}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def download_images_zip(paths: list[str]) -> io.BytesIO:
|
|
195
|
+
root = config.images_dir.resolve()
|
|
196
|
+
buf = io.BytesIO()
|
|
197
|
+
added = 0
|
|
198
|
+
used_names: set[str] = set()
|
|
199
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
200
|
+
for item in paths:
|
|
201
|
+
rel = _safe_relative_path(item)
|
|
202
|
+
path = (root / rel).resolve()
|
|
203
|
+
payload: bytes | None = None
|
|
204
|
+
try:
|
|
205
|
+
path.relative_to(root)
|
|
206
|
+
except ValueError:
|
|
207
|
+
continue
|
|
208
|
+
if path.is_file():
|
|
209
|
+
payload = path.read_bytes()
|
|
210
|
+
else:
|
|
211
|
+
try:
|
|
212
|
+
payload = image_storage_service.get_bytes(rel)
|
|
213
|
+
except Exception:
|
|
214
|
+
continue
|
|
215
|
+
name = path.name
|
|
216
|
+
if name in used_names:
|
|
217
|
+
stem = path.stem
|
|
218
|
+
suffix = path.suffix
|
|
219
|
+
counter = 2
|
|
220
|
+
while f"{stem}_{counter}{suffix}" in used_names:
|
|
221
|
+
counter += 1
|
|
222
|
+
name = f"{stem}_{counter}{suffix}"
|
|
223
|
+
used_names.add(name)
|
|
224
|
+
zf.writestr(name, payload)
|
|
225
|
+
added += 1
|
|
226
|
+
if added == 0:
|
|
227
|
+
raise HTTPException(status_code=404, detail="no images found")
|
|
228
|
+
buf.seek(0)
|
|
229
|
+
return buf
|
|
230
|
+
def storage_stats() -> dict:
|
|
231
|
+
import shutil
|
|
232
|
+
usage = shutil.disk_usage(config.images_dir)
|
|
233
|
+
total_mb = usage.total // (1024 * 1024)
|
|
234
|
+
used_mb = usage.used // (1024 * 1024)
|
|
235
|
+
free_mb = usage.free // (1024 * 1024)
|
|
236
|
+
|
|
237
|
+
image_count = 0
|
|
238
|
+
image_size = 0
|
|
239
|
+
for p in config.images_dir.rglob("*"):
|
|
240
|
+
if p.is_file():
|
|
241
|
+
image_count += 1
|
|
242
|
+
image_size += p.stat().st_size
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
"disk_total_mb": total_mb,
|
|
246
|
+
"disk_used_mb": used_mb,
|
|
247
|
+
"disk_free_mb": free_mb,
|
|
248
|
+
"image_count": image_count,
|
|
249
|
+
"image_size_mb": image_size // (1024 * 1024),
|
|
250
|
+
"image_size_bytes": image_size,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def compress_images(quality: int = 60) -> dict:
|
|
255
|
+
"""重新压缩所有图片,返回节省的空间"""
|
|
256
|
+
saved = 0
|
|
257
|
+
count = 0
|
|
258
|
+
for p in sorted(config.images_dir.rglob("*.png")):
|
|
259
|
+
if not p.is_file():
|
|
260
|
+
continue
|
|
261
|
+
try:
|
|
262
|
+
orig = p.stat().st_size
|
|
263
|
+
with Image.open(p) as img:
|
|
264
|
+
img = ImageOps.exif_transpose(img)
|
|
265
|
+
img.save(str(p) + ".tmp", format="PNG", optimize=True)
|
|
266
|
+
new_size = Path(str(p) + ".tmp").stat().st_size
|
|
267
|
+
if new_size < orig:
|
|
268
|
+
Path(str(p) + ".tmp").replace(p)
|
|
269
|
+
saved += orig - new_size
|
|
270
|
+
count += 1
|
|
271
|
+
else:
|
|
272
|
+
Path(str(p) + ".tmp").unlink()
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
return {"compressed": count, "saved_bytes": saved, "saved_mb": saved // (1024 * 1024)}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def delete_to_target(target_free_mb: int, dry_run: bool = False) -> dict:
|
|
279
|
+
"""删除最旧的图片直到剩余空间达到 target_free_mb"""
|
|
280
|
+
import shutil
|
|
281
|
+
usage = shutil.disk_usage(config.images_dir)
|
|
282
|
+
current_free = usage.free // (1024 * 1024)
|
|
283
|
+
if current_free >= target_free_mb and not dry_run:
|
|
284
|
+
return {"removed": 0, "current_free_mb": current_free, "target_free_mb": target_free_mb, "done": True}
|
|
285
|
+
|
|
286
|
+
files = sorted(
|
|
287
|
+
(p for p in config.images_dir.rglob("*.png") if p.is_file()),
|
|
288
|
+
key=lambda p: p.stat().st_mtime,
|
|
289
|
+
)
|
|
290
|
+
removed = 0
|
|
291
|
+
freed = 0
|
|
292
|
+
for p in files:
|
|
293
|
+
if current_free + freed // (1024 * 1024) >= target_free_mb:
|
|
294
|
+
break
|
|
295
|
+
size = p.stat().st_size
|
|
296
|
+
if not dry_run:
|
|
297
|
+
rel = p.relative_to(config.images_dir).as_posix()
|
|
298
|
+
for tp in (_thumbnail_path(rel), config.image_thumbnails_dir / _safe_relative_path(rel)):
|
|
299
|
+
if tp.is_file():
|
|
300
|
+
tp.unlink()
|
|
301
|
+
remove_tags(rel)
|
|
302
|
+
p.unlink()
|
|
303
|
+
freed += size
|
|
304
|
+
removed += 1
|
|
305
|
+
|
|
306
|
+
if not dry_run:
|
|
307
|
+
_cleanup_empty_dirs(config.images_dir)
|
|
308
|
+
_cleanup_empty_dirs(config.image_thumbnails_dir)
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
"removed": removed,
|
|
312
|
+
"freed_mb": freed // (1024 * 1024),
|
|
313
|
+
"target_free_mb": target_free_mb,
|
|
314
|
+
"current_free_mb": current_free + (freed // (1024 * 1024)),
|
|
315
|
+
"done": (current_free + freed // (1024 * 1024)) >= target_free_mb,
|
|
316
|
+
"dry_run": dry_run,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def download_images_zip(paths: list[str]) -> io.BytesIO:
|
|
321
|
+
root = config.images_dir.resolve()
|
|
322
|
+
buf = io.BytesIO()
|
|
323
|
+
added = 0
|
|
324
|
+
used_names: set[str] = set()
|
|
325
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
326
|
+
for item in paths:
|
|
327
|
+
rel = _safe_relative_path(item)
|
|
328
|
+
path = (root / rel).resolve()
|
|
329
|
+
try:
|
|
330
|
+
path.relative_to(root)
|
|
331
|
+
except ValueError:
|
|
332
|
+
continue
|
|
333
|
+
if not path.is_file():
|
|
334
|
+
continue
|
|
335
|
+
name = path.name
|
|
336
|
+
if name in used_names:
|
|
337
|
+
stem = path.stem
|
|
338
|
+
suffix = path.suffix
|
|
339
|
+
counter = 2
|
|
340
|
+
while f"{stem}_{counter}{suffix}" in used_names:
|
|
341
|
+
counter += 1
|
|
342
|
+
name = f"{stem}_{counter}{suffix}"
|
|
343
|
+
used_names.add(name)
|
|
344
|
+
zf.write(path, name)
|
|
345
|
+
added += 1
|
|
346
|
+
if added == 0:
|
|
347
|
+
raise HTTPException(status_code=404, detail="no images found")
|
|
348
|
+
buf.seek(0)
|
|
349
|
+
return buf
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _auto_cleanup_worker(stop_event: threading.Event) -> None:
|
|
353
|
+
"""后台线程:每30分钟检查存储,空间低于阈值自动清理最旧图片"""
|
|
354
|
+
import shutil
|
|
355
|
+
min_free_mb = getattr(config, "image_min_free_mb", None)
|
|
356
|
+
if min_free_mb is None:
|
|
357
|
+
min_free_mb = 500
|
|
358
|
+
|
|
359
|
+
while not stop_event.wait(1800): # 每30分钟
|
|
360
|
+
try:
|
|
361
|
+
config.cleanup_old_images()
|
|
362
|
+
cleanup_image_thumbnails()
|
|
363
|
+
usage = shutil.disk_usage(config.images_dir)
|
|
364
|
+
free_mb = usage.free // (1024 * 1024)
|
|
365
|
+
if free_mb < min_free_mb:
|
|
366
|
+
logger.info({"event": "image_auto_cleanup", "free_mb": free_mb, "min_free_mb": min_free_mb})
|
|
367
|
+
result = delete_to_target(min_free_mb)
|
|
368
|
+
logger.info({"event": "image_auto_cleanup_done", **result})
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def start_image_cleanup_scheduler(stop_event: threading.Event) -> threading.Thread:
|
|
374
|
+
t = threading.Thread(target=_auto_cleanup_worker, args=(stop_event,), daemon=True, name="image-cleanup")
|
|
375
|
+
t.start()
|
|
376
|
+
return t
|