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.
- package/LICENSE +21 -21
- package/README.en.md +5 -3
- package/README.md +7 -3
- package/client.js +241 -241
- package/cordis.patch.yml +14 -14
- package/host.js +51 -0
- package/install.mjs +333 -387
- package/package.json +3 -2
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -154
- package/server/access.py +26 -26
- package/server/app.py +1329 -1329
- package/server/catalog.py +147 -147
- package/server/composer.py +391 -391
- package/server/drafts.py +158 -158
- package/server/history.py +575 -575
- package/server/imaging.py +172 -172
- package/server/pool.py +1335 -1335
- package/server/pool_admin.py +921 -921
- package/server/pool_service.py +1073 -1073
- package/server/requirements.txt +6 -6
- package/server/smoke_check.py +402 -402
- package/server/static/access.html +202 -202
- package/vendor/chatgpt2api/source/.env.example +82 -82
- package/vendor/chatgpt2api/source/.python-version +1 -1
- package/vendor/chatgpt2api/source/LICENSE +21 -21
- package/vendor/chatgpt2api/source/README.md +391 -391
- package/vendor/chatgpt2api/source/VERSION +1 -1
- package/vendor/chatgpt2api/source/api/__init__.py +2 -2
- package/vendor/chatgpt2api/source/api/accounts.py +537 -537
- package/vendor/chatgpt2api/source/api/ai.py +217 -217
- package/vendor/chatgpt2api/source/api/app.py +62 -62
- package/vendor/chatgpt2api/source/api/errors.py +46 -46
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
- package/vendor/chatgpt2api/source/api/support.py +134 -134
- package/vendor/chatgpt2api/source/api/system.py +365 -365
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
- package/vendor/chatgpt2api/source/config.default.json +108 -108
- package/vendor/chatgpt2api/source/main.py +9 -9
- package/vendor/chatgpt2api/source/pyproject.toml +27 -27
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
- package/vendor/chatgpt2api/source/services/config.py +667 -667
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
- package/vendor/chatgpt2api/source/services/image_service.py +376 -376
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
- package/vendor/chatgpt2api/source/services/log_service.py +337 -337
- package/vendor/chatgpt2api/source/services/model_service.py +182 -182
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
- package/vendor/chatgpt2api/source/utils/helper.py +469 -469
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
- package/vendor/chatgpt2api/source/utils/log.py +110 -110
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
- package/vendor/chatgpt2api/source/utils/pow.py +207 -207
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
- package/vendor/chatgpt2api/source/uv.lock +904 -904
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
- package/vendor/chatgpt2api/source/web/components.json +24 -24
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
- package/vendor/chatgpt2api/source/web/package.json +58 -58
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
package/server/app.py
CHANGED
|
@@ -1,1329 +1,1329 @@
|
|
|
1
|
-
"""Workbench backend: handraw-style catalogs + ChatGPT account pool as token source."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import base64
|
|
7
|
-
import io
|
|
8
|
-
import json
|
|
9
|
-
import hashlib
|
|
10
|
-
import os
|
|
11
|
-
import re
|
|
12
|
-
import secrets
|
|
13
|
-
import subprocess
|
|
14
|
-
import sys
|
|
15
|
-
import time
|
|
16
|
-
import uuid
|
|
17
|
-
import zipfile
|
|
18
|
-
from urllib.parse import quote
|
|
19
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
-
from contextlib import asynccontextmanager
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
from fastapi import FastAPI, HTTPException, Request
|
|
24
|
-
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
|
25
|
-
from fastapi.staticfiles import StaticFiles
|
|
26
|
-
from pydantic import BaseModel, Field
|
|
27
|
-
|
|
28
|
-
import access
|
|
29
|
-
import catalog
|
|
30
|
-
import composer
|
|
31
|
-
import imaging
|
|
32
|
-
import pool as pool_mod
|
|
33
|
-
import pool_admin
|
|
34
|
-
import pool_service
|
|
35
|
-
from starlette.concurrency import run_in_threadpool
|
|
36
|
-
from drafts import Drafts
|
|
37
|
-
from history import History
|
|
38
|
-
from pool import (PoolManager, load_config, provider_cfg, test_connection,
|
|
39
|
-
out_dir, image_path, norm_base_url,
|
|
40
|
-
CONFIG_FILE as POOL_CONFIG_FILE)
|
|
41
|
-
|
|
42
|
-
# 本地版:所有数据目录都在本文件所在目录,不再指向 VPS 的 /root/ai-pool-lab
|
|
43
|
-
LAB = Path(__file__).resolve().parent
|
|
44
|
-
REPO_IMAGES = LAB / "repo_images"
|
|
45
|
-
STATIC = LAB / "static"
|
|
46
|
-
OUT_DIR = LAB / "images" # 默认保存目录;实际目录由 config 的 output_dir 决定
|
|
47
|
-
DEFAULT_APP_NAME = "Bluxe手绘工坊 · 生图工作台" # 页头标题默认名,可被 config 的 app_name 覆盖
|
|
48
|
-
THUMB_DIR = LAB / "thumbs"
|
|
49
|
-
REFS_DIR = LAB / "refs"
|
|
50
|
-
# 出图完成后在后台生成压缩派生图,别堵住出图线程
|
|
51
|
-
VARIANT_EXEC = ThreadPoolExecutor(max_workers=2, thread_name_prefix="wb-variant")
|
|
52
|
-
AUTH_FILE = LAB / ".auth"
|
|
53
|
-
for _d in (REPO_IMAGES, OUT_DIR, THUMB_DIR, REFS_DIR):
|
|
54
|
-
_d.mkdir(parents=True, exist_ok=True)
|
|
55
|
-
|
|
56
|
-
CATALOG = catalog.build()
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _plugin_version() -> str:
|
|
60
|
-
"""插件版本,取自插件根目录的 package.json。
|
|
61
|
-
|
|
62
|
-
独立运行时(只把 server/ 拷出来跑)读不到那个文件,返回 unknown 即可,
|
|
63
|
-
版本仅用于界面显示与排查「装的是哪一版」,不影响任何功能。
|
|
64
|
-
"""
|
|
65
|
-
for cand in (LAB.parent / "package.json", LAB / "package.json"):
|
|
66
|
-
try:
|
|
67
|
-
v = str(json.loads(cand.read_text(encoding="utf-8")).get("version") or "").strip()
|
|
68
|
-
if v:
|
|
69
|
-
return v
|
|
70
|
-
except Exception:
|
|
71
|
-
continue
|
|
72
|
-
return "unknown"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
VERSION = _plugin_version()
|
|
76
|
-
POOL = PoolManager()
|
|
77
|
-
# 图片目录传函数:用户改「保存目录」后历史立刻按新目录找图,不用重启
|
|
78
|
-
HISTORY = History(LAB / "workbench" / "history.jsonl", out_dir, THUMB_DIR,
|
|
79
|
-
finder=image_path)
|
|
80
|
-
DRAFTS = Drafts(LAB / "workbench" / "drafts.json")
|
|
81
|
-
def _finish_with_replace(job: dict) -> None:
|
|
82
|
-
"""单张重做:新图成功后先删掉原任务里那一张旧记录,再写新记录。
|
|
83
|
-
|
|
84
|
-
失败、没有图片、任务号对不上、id 格式不对,都保留旧图,避免把用户原来的图弄丢。
|
|
85
|
-
"""
|
|
86
|
-
rid = str(job.get("replace_id") or "").strip()
|
|
87
|
-
if (rid and re.fullmatch(r"[0-9a-z]{6,32}", rid)
|
|
88
|
-
and job.get("status") == "done"
|
|
89
|
-
and (job.get("result") or {}).get("image")):
|
|
90
|
-
HISTORY.drop_record(rid, task_id=job.get("task_id"))
|
|
91
|
-
HISTORY.record(job)
|
|
92
|
-
# 图一落地就把 card/preview 两档备好,点开历史时零等待。
|
|
93
|
-
# 派生图生成失败只影响预览速度,原图和历史记录不受影响。
|
|
94
|
-
try:
|
|
95
|
-
img = str((job.get("result") or {}).get("image") or "")
|
|
96
|
-
jid = str(job.get("id") or "")
|
|
97
|
-
src = image_path(img)
|
|
98
|
-
if jid and img == f"{jid}.png" and src and src.is_file():
|
|
99
|
-
VARIANT_EXEC.submit(imaging.ensure_all, THUMB_DIR, jid, src, job.get("ratio"))
|
|
100
|
-
except Exception:
|
|
101
|
-
pass
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
POOL.on_finish = _finish_with_replace
|
|
105
|
-
AUTH: tuple[str, str] | None = None
|
|
106
|
-
if AUTH_FILE.exists():
|
|
107
|
-
u, _, p = AUTH_FILE.read_text(encoding="utf-8").strip().partition(":")
|
|
108
|
-
if u and p:
|
|
109
|
-
AUTH = (u, p)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
@asynccontextmanager
|
|
113
|
-
async def lifespan(app: FastAPI):
|
|
114
|
-
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
115
|
-
out_dir().mkdir(parents=True, exist_ok=True)
|
|
116
|
-
# 号池由工作台自己拉起与守护,不依赖任何外部插件。
|
|
117
|
-
# 它比后端晚几秒就绪,起来那一刻主动对一次账,页头/监控不会先显示「1 号」。
|
|
118
|
-
loop = asyncio.get_running_loop()
|
|
119
|
-
|
|
120
|
-
async def _sync_after_pool_start():
|
|
121
|
-
# 全新机器首次启动时号池还没有任何用户 Key,工作台会 401。
|
|
122
|
-
# 号池一起来就自动签发一把(幂等:已有可用 Key 时什么都不会新建)。
|
|
123
|
-
try:
|
|
124
|
-
await asyncio.sleep(1.0)
|
|
125
|
-
k = await pool_admin.ensure_key()
|
|
126
|
-
if k.get("ok") and k.get("created"):
|
|
127
|
-
pool_mod.log("号池首次启动已自动签发用户 Key,工作台可直接出图")
|
|
128
|
-
except Exception as e:
|
|
129
|
-
pool_mod.log("自动签发号池用户 Key 失败:" + str(e)[:110])
|
|
130
|
-
await POOL.refresh_env_ids()
|
|
131
|
-
await POOL.refresh_accounts(force=True)
|
|
132
|
-
|
|
133
|
-
def _on_pool_started():
|
|
134
|
-
try:
|
|
135
|
-
asyncio.run_coroutine_threadsafe(_sync_after_pool_start(), loop)
|
|
136
|
-
except Exception:
|
|
137
|
-
pass
|
|
138
|
-
|
|
139
|
-
pool_service.ON_STARTED = _on_pool_started
|
|
140
|
-
|
|
141
|
-
async def _patch_running_pool():
|
|
142
|
-
"""号池可能在上次会话里就活着(插件升级后很常见),此时 watchdog 不会碰它,
|
|
143
|
-
源码补丁也就永远落不了地。这里主动走一次 start():它会补打补丁,
|
|
144
|
-
并在补丁刚应用时重启号池,让新代码真正加载。"""
|
|
145
|
-
try:
|
|
146
|
-
await asyncio.sleep(3) # 先让 watchdog 的首次巡检跑完
|
|
147
|
-
v = pool_service.view()
|
|
148
|
-
if not v.get("manageable") or not pool_service.probe(v["port"], 0.5):
|
|
149
|
-
return # 没在跑就交给 watchdog,那条路径同样会打补丁
|
|
150
|
-
r = await run_in_threadpool(pool_service.start)
|
|
151
|
-
pool_mod.log("号池启动自检:" + str(r.get("message") or ""))
|
|
152
|
-
except Exception as e:
|
|
153
|
-
pool_mod.log("号池启动自检异常:" + str(e)[:110])
|
|
154
|
-
|
|
155
|
-
pool_service.start_watchdog()
|
|
156
|
-
asyncio.create_task(_patch_running_pool())
|
|
157
|
-
await POOL.start()
|
|
158
|
-
yield
|
|
159
|
-
await POOL.stop()
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
app = FastAPI(title="Handraw Workbench", lifespan=lifespan)
|
|
163
|
-
@app.get("/img/{full_path:path}")
|
|
164
|
-
async def img_with_fallback(full_path: str):
|
|
165
|
-
"""风格/版式预览图路由(带兜底占位)。
|
|
166
|
-
|
|
167
|
-
repo_images/ 里的样例图不进 git(避免仓库膨胀),新安装时目录为空会全部 404、
|
|
168
|
-
画廊破图。这里做兜底:文件存在原样返回;不存在则现场生成一张 SVG 占位图
|
|
169
|
-
(渐变 + 风格编号),保证任何安装都显示整齐、可用,且不引入任何二进制资源。
|
|
170
|
-
真要放高清样例图时,直接丢进 repo_images/ 即自动生效,无需改代码。
|
|
171
|
-
"""
|
|
172
|
-
target = (REPO_IMAGES / full_path).resolve()
|
|
173
|
-
root = str(REPO_IMAGES.resolve())
|
|
174
|
-
# 目录穿越防护:只允许访问 repo_images 之内
|
|
175
|
-
if str(target).startswith(root + os.sep) and target.is_file():
|
|
176
|
-
# .webp 在部分系统(如 Windows)的 MIME 注册表里缺失,会被猜成
|
|
177
|
-
# application/octet-stream;显式声明,保证浏览器 <img> 正确识别。
|
|
178
|
-
mt = "image/webp" if target.suffix.lower() == ".webp" else None
|
|
179
|
-
return FileResponse(str(target), media_type=mt,
|
|
180
|
-
headers={"Cache-Control": "public, max-age=604800"})
|
|
181
|
-
# 文件缺失 → 生成 SVG 占位图
|
|
182
|
-
label = Path(full_path).stem
|
|
183
|
-
h = hashlib.md5(full_path.encode("utf-8")).hexdigest()
|
|
184
|
-
c1, c2 = h[:6], h[6:12]
|
|
185
|
-
svg = (
|
|
186
|
-
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">'
|
|
187
|
-
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">'
|
|
188
|
-
f'<stop offset="0" stop-color="#{c1}"/><stop offset="1" stop-color="#{c2}"/>'
|
|
189
|
-
"</linearGradient></defs>"
|
|
190
|
-
f'<rect width="240" height="160" fill="url(#g)"/>'
|
|
191
|
-
f'<text x="120" y="88" font-family="-apple-system,Segoe UI,sans-serif" font-size="22" '
|
|
192
|
-
f'fill="#ffffff" text-anchor="middle" opacity="0.95">{label}</text>'
|
|
193
|
-
"</svg>"
|
|
194
|
-
)
|
|
195
|
-
# 占位图必须 no-store:它只是「文件还没下载」的瞬态提示,绝不能进浏览器缓存。
|
|
196
|
-
# 否则真图补上后,浏览器仍按旧 URL 命中缓存里的渐变占位,画廊就一直显示渐变。
|
|
197
|
-
return Response(content=svg.encode("utf-8"), media_type="image/svg+xml",
|
|
198
|
-
headers={"Cache-Control": "no-store"})
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
app.mount("/refs", StaticFiles(directory=str(REFS_DIR)), name="refs")
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
@app.get("/files/{name}")
|
|
205
|
-
async def files(name: str):
|
|
206
|
-
"""原图路由:按当前「保存目录」实时取文件。
|
|
207
|
-
|
|
208
|
-
以前是 StaticFiles 固定挂到 images/,用户一改保存目录,改完得重启才生效,
|
|
209
|
-
而且老图留在原目录就 404。现在每次请求现查,当前目录优先、默认目录兜底。
|
|
210
|
-
"""
|
|
211
|
-
p = image_path(name)
|
|
212
|
-
if not p:
|
|
213
|
-
raise HTTPException(404, "image not found")
|
|
214
|
-
return FileResponse(p, headers={"Cache-Control": "public, max-age=604800"})
|
|
215
|
-
|
|
216
|
-
PUBLIC_ACCESS_PATHS = {
|
|
217
|
-
"/access", "/api/access/request", "/api/access/verify", "/api/access/master",
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def _client_ip(request: Request) -> str:
|
|
222
|
-
cf = request.headers.get("cf-connecting-ip", "").strip()
|
|
223
|
-
if cf:
|
|
224
|
-
return cf[:64]
|
|
225
|
-
xff = request.headers.get("x-forwarded-for", "")
|
|
226
|
-
if xff:
|
|
227
|
-
return xff.split(",")[0].strip()[:64]
|
|
228
|
-
return request.client.host if request.client else "unknown"
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def _set_session_cookie(resp: Response, token: str) -> None:
|
|
232
|
-
resp.set_cookie("wb_sid", token, httponly=True, samesite="lax", secure=True,
|
|
233
|
-
max_age=access.SESSION_TTL, path="/")
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
class AccessRequest(BaseModel):
|
|
237
|
-
email: str = ""
|
|
238
|
-
user: str = "" # 兼容浏览器缓存的旧页面
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
class AccessVerify(BaseModel):
|
|
242
|
-
email: str = ""
|
|
243
|
-
user: str = "" # 兼容浏览器缓存的旧页面
|
|
244
|
-
code: str = ""
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
class MasterRequest(BaseModel):
|
|
248
|
-
password: str = ""
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
@app.middleware("http")
|
|
252
|
-
async def access_guard(request: Request, call_next):
|
|
253
|
-
"""旧的 ?k= 免密链接与 HTTP Basic 全部作废,只认服务端签发的随机 session token。
|
|
254
|
-
|
|
255
|
-
页面路径 302 到申请页;/api/* 返回 401 JSON,避免前端 fetch 拿到一坨 HTML。
|
|
256
|
-
/files /img /refs 三个图片挂载同样受保护,不对外裸奔。
|
|
257
|
-
"""
|
|
258
|
-
path = request.url.path.rstrip("/") or "/"
|
|
259
|
-
if not AUTH or path == "/health" or path in PUBLIC_ACCESS_PATHS:
|
|
260
|
-
return await call_next(request)
|
|
261
|
-
if access.check_session(request.cookies.get("wb_sid", "")):
|
|
262
|
-
return await call_next(request)
|
|
263
|
-
if path.startswith("/api/"):
|
|
264
|
-
return JSONResponse({"detail": "auth required", "redirect": "/access"}, status_code=401)
|
|
265
|
-
return RedirectResponse("/access", status_code=302)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
class GenRequest(BaseModel):
|
|
269
|
-
mode: str = Field(default="image")
|
|
270
|
-
topic: str = Field(min_length=1, max_length=2000)
|
|
271
|
-
title: str | None = None
|
|
272
|
-
body: str | None = None
|
|
273
|
-
# 整篇正文(左侧「正文」框),只为历史「载入」还原,不参与拼提示词
|
|
274
|
-
doc_body: str | None = Field(default=None, max_length=20000)
|
|
275
|
-
# 这一批里的提交序号,0 起;并行出图完成顺序会乱,靠它还原清单顺序
|
|
276
|
-
seq: int | None = None
|
|
277
|
-
style_id: str | None = None
|
|
278
|
-
layout_id: str | None = None
|
|
279
|
-
color_id: str | None = None
|
|
280
|
-
ratio: str = "3:4"
|
|
281
|
-
note: str | None = None
|
|
282
|
-
# 整组主题(左侧「标题」),落进历史方便回看
|
|
283
|
-
group: str | None = None
|
|
284
|
-
# 一次批量出图共用一个 task_id,历史按它分组
|
|
285
|
-
task_id: str | None = None
|
|
286
|
-
# 前端拼装器生成好的完整提示词;给了就直接用,不再走 build_image_prompt
|
|
287
|
-
prompt: str | None = Field(default=None, max_length=8000)
|
|
288
|
-
# 参考图(/api/ref 上传拿到的 id),给了就随提示词一起传给 ChatGPT
|
|
289
|
-
ref_id: str | None = Field(default=None, max_length=32)
|
|
290
|
-
# 出图模式与拍摄类型,只用于历史回看/载入还原,不参与拼提示词(提示词前端已拼好)
|
|
291
|
-
image_mode: str | None = Field(default=None, max_length=16)
|
|
292
|
-
shoot_type: str | None = Field(default=None, max_length=40)
|
|
293
|
-
# 单张重做:新图成功后删掉这条旧记录,实现同一任务内原位替换
|
|
294
|
-
replace_id: str | None = Field(default=None, max_length=32)
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
class ZipRequest(BaseModel):
|
|
298
|
-
# 当前批次的 job id(展示顺序),服务端按这个顺序命名 01 02 03…
|
|
299
|
-
ids: list[str] = Field(default_factory=list, max_length=100)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
class RefRequest(BaseModel):
|
|
303
|
-
# 前端已经压到 1400px 内,这里只做落盘;dataURL 或裸 base64 都收
|
|
304
|
-
data: str = Field(max_length=24_000_000)
|
|
305
|
-
name: str | None = Field(default=None, max_length=120)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
class ComposeItem(BaseModel):
|
|
309
|
-
title: str = ""
|
|
310
|
-
body: str = ""
|
|
311
|
-
scene: str = ""
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
class ComposeRequest(BaseModel):
|
|
315
|
-
style_id: str | None = None
|
|
316
|
-
layout_id: str | None = None
|
|
317
|
-
color_id: str | None = None
|
|
318
|
-
ratio: str = "3:4"
|
|
319
|
-
mood: str | None = None
|
|
320
|
-
fusion: bool = False
|
|
321
|
-
whitespace: str = "normal"
|
|
322
|
-
note: str | None = None
|
|
323
|
-
topic: str | None = None
|
|
324
|
-
ref: bool = False
|
|
325
|
-
# 出图模式:graphic-text(默认,图文)| photo(摄影);摄影可带拍摄类型
|
|
326
|
-
image_mode: str | None = None
|
|
327
|
-
shoot_type: str | None = None
|
|
328
|
-
# 手改过的出图指令:给了就用它,不再自动拼;allow_blank:允许空标题/正文纯指令出图
|
|
329
|
-
instruction: str | None = None
|
|
330
|
-
allow_blank: bool = False
|
|
331
|
-
items: list[ComposeItem] = Field(default_factory=list)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def _find(items: list[dict], item_id: str | None) -> dict | None:
|
|
335
|
-
if not item_id:
|
|
336
|
-
return None
|
|
337
|
-
return next((x for x in items if x["id"] == item_id), None)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
def build_image_prompt(req: GenRequest) -> str:
|
|
341
|
-
if req.prompt and req.prompt.strip():
|
|
342
|
-
return req.prompt.strip()
|
|
343
|
-
style = _find(CATALOG["styles"], req.style_id)
|
|
344
|
-
layout = _find(CATALOG["layouts"], req.layout_id)
|
|
345
|
-
color = _find(CATALOG["colors"], req.color_id)
|
|
346
|
-
parts: list[str] = []
|
|
347
|
-
if req.ref_id:
|
|
348
|
-
parts.append(composer.REF_FUSION)
|
|
349
|
-
if layout:
|
|
350
|
-
parts.append(f"排版图型:{layout['prompt']}")
|
|
351
|
-
if color:
|
|
352
|
-
parts.append(color["prompt"])
|
|
353
|
-
if style:
|
|
354
|
-
parts.append(f"画风:{style['name_en']}。核心视觉特征:{style['features']}")
|
|
355
|
-
parts.append(f"主题:{req.topic.strip()}")
|
|
356
|
-
# 2.35:1 是微信公众号首图(900×383),不是小红书图文的口径,单独说清楚
|
|
357
|
-
if (req.ratio or "").strip() == "2.35:1":
|
|
358
|
-
parts.append("画面比例:2.35:1 公众号首图横幅,主体与标题压在居中横向安全区内,"
|
|
359
|
-
"上下各留一段可裁的次要背景。")
|
|
360
|
-
else:
|
|
361
|
-
parts.append(f"画面比例:{req.ratio},适合小红书图文排版。")
|
|
362
|
-
if req.note:
|
|
363
|
-
parts.append(f"补充要求:{req.note.strip()}")
|
|
364
|
-
parts.append("请立即生成图片,不要反问,不要输出任何解释文字。")
|
|
365
|
-
return "\n".join(parts)
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
_LIST_INTENT_RE = re.compile(
|
|
369
|
-
r"(?:\d+\s*(?:条|张|个|则)\s*(?:适合)?(?:生成)?(?:贴图|分镜|卡片|清单|文案))"
|
|
370
|
-
r"|(?:贴图|分镜|卡片|清单|文案)[^\n]{0,12}\d+\s*条"
|
|
371
|
-
r"|01\s*[||]"
|
|
372
|
-
)
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def build_text_prompt(req: GenRequest) -> str:
|
|
376
|
-
"""普通选题走小红书笔记模板;选题明确要「N 条贴图/分镜文案」或给了 01| 示例时,
|
|
377
|
-
改走编号清单模板,避免 AI 输出「开场导语 + 编号条目 + 结尾标签」的混合格式
|
|
378
|
-
(那种格式前端拆分只能识别成 1 条)。"""
|
|
379
|
-
topic = req.topic.strip()
|
|
380
|
-
if _LIST_INTENT_RE.search(topic):
|
|
381
|
-
m = re.search(r"(\d+)\s*条", topic)
|
|
382
|
-
n = min(max(int(m.group(1)) if m else 8, 2), 20)
|
|
383
|
-
return (
|
|
384
|
-
f"你是贴图分镜文案写手。围绕选题「{topic}」,直接输出成品,不要任何解释。\n"
|
|
385
|
-
f"第一行:整组标题(20字以内,带情绪钩子,可用一个emoji)。\n"
|
|
386
|
-
f"标题之后空一行,然后严格输出 {n} 条贴图文案,编号从 01 连续到 {n:02d}。\n"
|
|
387
|
-
"每一条占两行:第一行是两位编号、一个竖线、再加小标题(小标题 8 字以内),"
|
|
388
|
-
"第二行是这一条的正文(60到100字,口语化,写具体场景和细节,不要空话)。\n"
|
|
389
|
-
f"条与条之间用一个空行隔开,全文一共 {n} 个编号行。\n"
|
|
390
|
-
"硬性要求:不要开场导语、不要结尾总结、不要话题标签、不要 markdown 标记(不要 ** 和 #)、"
|
|
391
|
-
"不要写「以下是」「最后想告诉你」这类客套话;正文里不要出现新的编号行,"
|
|
392
|
-
"也不要把上面这些格式说明当成内容输出。"
|
|
393
|
-
)
|
|
394
|
-
return (
|
|
395
|
-
f"你是小红书爆款笔记写手。围绕选题「{req.topic.strip()}」,直接输出成品,不要任何解释:\n"
|
|
396
|
-
"第一行:标题(20字以内,带情绪钩子,可用一个emoji)\n"
|
|
397
|
-
"空行后:正文 300-500 字,口语化、分段、适量emoji,有具体细节不要空话\n"
|
|
398
|
-
"空行后:5 个 #话题标签\n"
|
|
399
|
-
"不要写“以下是”“希望对你有帮助”这类客套话。"
|
|
400
|
-
)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
def build_polish_prompt(req: GenRequest) -> str:
|
|
404
|
-
"""Polish the user's edited title/body in place (no new facts)."""
|
|
405
|
-
title = (req.title or "").strip()
|
|
406
|
-
body = (req.body or "").strip()[:4000]
|
|
407
|
-
return (
|
|
408
|
-
"你是小红书文案编辑。请润色下面这篇笔记:保持原意和事实,不换主题,"
|
|
409
|
-
"提升口语感、细节密度和情绪钩子,删掉空话。直接输出成品,不要任何解释:\n"
|
|
410
|
-
"第一行:润色后的标题(20字以内,可用一个emoji)\n"
|
|
411
|
-
"空行后:润色后的正文(保留分段,300-500字,适量emoji)\n"
|
|
412
|
-
"空行后:5 个 #话题标签\n"
|
|
413
|
-
f"原标题:{title or '(无)'}\n"
|
|
414
|
-
f"原正文:\n{body or '(空,请围绕标题补写一篇完整笔记)'}"
|
|
415
|
-
)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
@app.get("/health")
|
|
419
|
-
async def health():
|
|
420
|
-
return {"ok": True, "version": VERSION}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
@app.get("/api/catalog")
|
|
424
|
-
async def api_catalog():
|
|
425
|
-
return JSONResponse(CATALOG)
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
@app.get("/api/pool")
|
|
429
|
-
async def api_pool():
|
|
430
|
-
# 前端每 10 秒轮询一次,顺手在这里做离线自愈:号池后起也能自己从「离线」翻成在线
|
|
431
|
-
await POOL.ensure_online()
|
|
432
|
-
return JSONResponse(POOL.snapshot())
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
@app.post("/api/pool/refresh")
|
|
436
|
-
async def api_pool_refresh():
|
|
437
|
-
await POOL.refresh_env_ids()
|
|
438
|
-
return JSONResponse(POOL.snapshot())
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
@app.get("/api/pool/fails")
|
|
442
|
-
async def api_pool_fails(days: int = 7):
|
|
443
|
-
"""账号失败账本:今天每个号失败几次、最近几天各失败几次。
|
|
444
|
-
|
|
445
|
-
失败不会让任务中断(自动换号重试),但账必须留痕,用户要能核对。
|
|
446
|
-
"""
|
|
447
|
-
days = max(1, min(30, int(days or 7)))
|
|
448
|
-
try:
|
|
449
|
-
ledger = json.loads(pool_mod.FAIL_LEDGER.read_text(encoding="utf-8"))
|
|
450
|
-
except Exception:
|
|
451
|
-
ledger = {}
|
|
452
|
-
all_days = ledger.get("days") or {}
|
|
453
|
-
last = ledger.get("last") or {}
|
|
454
|
-
order = sorted(all_days.keys(), reverse=True)[:days]
|
|
455
|
-
rows = []
|
|
456
|
-
for email, n in sorted((all_days.get(time.strftime("%Y-%m-%d")) or {}).items(),
|
|
457
|
-
key=lambda kv: -int(kv[1])):
|
|
458
|
-
info = last.get(email) or {}
|
|
459
|
-
rows.append({"account": pool_mod.mask_email(email) if "@" in email else "未知账号",
|
|
460
|
-
"account_email": email, "today": int(n),
|
|
461
|
-
"error": str(info.get("error") or ""), "at": int(info.get("at") or 0)})
|
|
462
|
-
return JSONResponse({
|
|
463
|
-
"today": time.strftime("%Y-%m-%d"),
|
|
464
|
-
"items": rows,
|
|
465
|
-
"total_today": sum(int(v) for v in (all_days.get(time.strftime("%Y-%m-%d")) or {}).values()),
|
|
466
|
-
"recent": [{"day": d, "total": sum(int(v) for v in (all_days.get(d) or {}).values())}
|
|
467
|
-
for d in order],
|
|
468
|
-
})
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
@app.post("/api/pool/reset")
|
|
472
|
-
async def api_pool_reset():
|
|
473
|
-
POOL.reset_statuses()
|
|
474
|
-
await POOL.refresh_env_ids()
|
|
475
|
-
return JSONResponse(POOL.snapshot())
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
# ---------- 号池管理(「接口与号池」面板里用) ----------
|
|
479
|
-
@app.get("/api/pool/panel")
|
|
480
|
-
async def api_pool_panel():
|
|
481
|
-
return JSONResponse(await pool_admin.panel())
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
@app.post("/api/pool/accounts/import")
|
|
485
|
-
async def api_pool_import(body: dict):
|
|
486
|
-
res = await pool_admin.import_accounts(str(body.get("text") or ""))
|
|
487
|
-
if res.get("ok"):
|
|
488
|
-
# 新号立刻进轮转:页头和监控不用等下一轮后台同步
|
|
489
|
-
await POOL.refresh_accounts(force=True)
|
|
490
|
-
pool_mod.log(f"号池导入:{res.get('added', 0)} 个新增 / {res.get('skipped', 0)} 个已存在")
|
|
491
|
-
return JSONResponse(res)
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
@app.post("/api/pool/accounts/delete")
|
|
495
|
-
async def api_pool_delete(body: dict):
|
|
496
|
-
ids = body.get("ids") or []
|
|
497
|
-
if not isinstance(ids, list) or not ids:
|
|
498
|
-
raise HTTPException(400, "没选中账号")
|
|
499
|
-
res = await pool_admin.delete_accounts([str(i) for i in ids])
|
|
500
|
-
if res.get("ok"):
|
|
501
|
-
await POOL.refresh_accounts(force=True)
|
|
502
|
-
return JSONResponse(res)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
@app.post("/api/pool/accounts/status")
|
|
506
|
-
async def api_pool_account_status(body: dict):
|
|
507
|
-
ids = body.get("ids") or []
|
|
508
|
-
if not isinstance(ids, list) or not ids:
|
|
509
|
-
raise HTTPException(400, "没选中账号")
|
|
510
|
-
res = await pool_admin.set_status([str(i) for i in ids], bool(body.get("disabled")))
|
|
511
|
-
if res.get("ok"):
|
|
512
|
-
await POOL.refresh_accounts(force=True)
|
|
513
|
-
return JSONResponse(res)
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
@app.post("/api/pool/key/ensure")
|
|
517
|
-
async def api_pool_key(body: dict | None = None):
|
|
518
|
-
"""取(或新建)一把号池用户 Key,供本页复制出去接别的工具。"""
|
|
519
|
-
body = body or {}
|
|
520
|
-
return JSONResponse(await pool_admin.ensure_key(force_new=bool(body.get("new"))))
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
# ---------- 号池出口代理(受限网络必填,默认直连) ----------
|
|
524
|
-
@app.get("/api/pool/proxy")
|
|
525
|
-
async def api_pool_proxy():
|
|
526
|
-
"""读取本机号池的出口代理配置(admin 透传,浏览器不碰 key)。"""
|
|
527
|
-
res = await pool_admin.get_pool_proxy()
|
|
528
|
-
# 顺带回传工作台侧记录的「自动探测」标记,让前端区分手动/自动
|
|
529
|
-
try:
|
|
530
|
-
home = pool_service.view()["home"]
|
|
531
|
-
res["proxy_auto_url"] = pool_service._read_state(home).get("proxy_auto_url") or ""
|
|
532
|
-
except Exception:
|
|
533
|
-
res["proxy_auto_url"] = ""
|
|
534
|
-
return JSONResponse(res)
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
@app.get("/api/pool/egress")
|
|
538
|
-
async def api_pool_egress(fresh: int = 0):
|
|
539
|
-
"""探测当前设备实际可用的出口代理(只读,不改号池配置)。
|
|
540
|
-
|
|
541
|
-
生图必须过代理,所以面板里这一栏永远展示本机真实情况:换电脑、换代理
|
|
542
|
-
端口,反显的值跟着变,不会停在某个写死的地址上。
|
|
543
|
-
"""
|
|
544
|
-
try:
|
|
545
|
-
data = await asyncio.to_thread(
|
|
546
|
-
pool_service.detect_egress, 0 if fresh else 20.0)
|
|
547
|
-
except Exception as e:
|
|
548
|
-
return JSONResponse({"ok": False, "msg": str(e)[:160]})
|
|
549
|
-
return JSONResponse(data)
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
@app.post("/api/pool/proxy")
|
|
553
|
-
async def api_pool_proxy_save(body: dict):
|
|
554
|
-
"""保存本机号池的出口代理配置。默认直连;受限网络由用户填自己的代理。"""
|
|
555
|
-
return JSONResponse(await pool_admin.set_pool_proxy(body or {}))
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
@app.post("/api/pool/proxy/test")
|
|
559
|
-
async def api_pool_proxy_test(body: dict):
|
|
560
|
-
"""用号池已保存的代理测连通性。"""
|
|
561
|
-
return JSONResponse(await pool_admin.test_pool_proxy(str((body or {}).get("url", ""))))
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
@app.post("/api/pool/accounts/refresh")
|
|
565
|
-
async def api_pool_accounts_refresh():
|
|
566
|
-
"""换出口代理后重算额度:让号池重新拉一遍每个账号的可用次数。"""
|
|
567
|
-
return JSONResponse(await pool_admin.refresh_accounts())
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
@app.get("/api/pool/accounts/refresh/{progress_id}")
|
|
571
|
-
async def api_pool_accounts_refresh_progress(progress_id: str):
|
|
572
|
-
"""刷新进度:total / processed / done / status_counts / total_quota。"""
|
|
573
|
-
return JSONResponse(await pool_admin.refresh_progress(progress_id))
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
# ---------- 工作台自带号池:进程生命周期 ----------
|
|
577
|
-
# 这些路由只在「当前接口指向本机号池」时生效;第三方接口与写作台 38650 一律不动。
|
|
578
|
-
# 启动 / 停止会等几十秒(首次还要复制运行时),全部丢到线程池,别堵住事件循环。
|
|
579
|
-
@app.get("/api/pool/service")
|
|
580
|
-
async def api_pool_service():
|
|
581
|
-
return JSONResponse(await run_in_threadpool(pool_service.status))
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
@app.post("/api/pool/service/start")
|
|
585
|
-
async def api_pool_service_start(body: dict | None = None):
|
|
586
|
-
body = body or {}
|
|
587
|
-
res = await run_in_threadpool(pool_service.start, bool(body.get("force")))
|
|
588
|
-
if res.get("ok"):
|
|
589
|
-
await POOL.refresh_accounts(force=True)
|
|
590
|
-
return JSONResponse(res)
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
@app.post("/api/pool/service/stop")
|
|
594
|
-
async def api_pool_service_stop():
|
|
595
|
-
return JSONResponse(await run_in_threadpool(pool_service.stop))
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
@app.post("/api/pool/service/restart")
|
|
599
|
-
async def api_pool_service_restart():
|
|
600
|
-
res = await run_in_threadpool(pool_service.restart)
|
|
601
|
-
if res.get("ok"):
|
|
602
|
-
await POOL.refresh_accounts(force=True)
|
|
603
|
-
return JSONResponse(res)
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
@app.post("/api/pool/service/auto")
|
|
607
|
-
async def api_pool_service_auto(body: dict):
|
|
608
|
-
home = pool_service.view()["home"]
|
|
609
|
-
return JSONResponse(pool_service.set_auto_start(home, bool(body.get("enabled"))))
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
@app.post("/api/pool/use-builtin")
|
|
613
|
-
async def api_pool_use_builtin():
|
|
614
|
-
"""一键切回本工作台自带的内置号池(38651)并启动它。
|
|
615
|
-
|
|
616
|
-
典型场景:出图接口曾指向外部号池 / 写作台号池(如 38650),想切回来。
|
|
617
|
-
只动 config.local.json 与内置池的停止标记,不碰外部号池的账号与进程。
|
|
618
|
-
注意必须同时把 api_key 对齐到内置池:只改 base_url 不换 key,会用旧外部
|
|
619
|
-
key 去打内置池的 /v1,结果 401,账号面板照样刷不出内置号。
|
|
620
|
-
"""
|
|
621
|
-
home = Path.home() / ".dsh" / "chatgpt2api-image"
|
|
622
|
-
builtin_url = "http://127.0.0.1:38651/v1"
|
|
623
|
-
# 读内置池自带的用户 Key,写进 config,确保接口密钥与内置池对齐
|
|
624
|
-
user_key = pool_mod._read_pool_user_key({"pool_dir": str(home)}) or ""
|
|
625
|
-
if not user_key:
|
|
626
|
-
try:
|
|
627
|
-
user_key = pool_service.ensure_admin_key(home) or ""
|
|
628
|
-
except Exception:
|
|
629
|
-
user_key = ""
|
|
630
|
-
cfg = {}
|
|
631
|
-
if POOL_CONFIG_FILE.exists():
|
|
632
|
-
try:
|
|
633
|
-
cfg = json.loads(POOL_CONFIG_FILE.read_text(encoding="utf-8"))
|
|
634
|
-
except Exception:
|
|
635
|
-
cfg = {}
|
|
636
|
-
cfg["api_base_url"] = builtin_url
|
|
637
|
-
cfg["pool_base_url"] = builtin_url
|
|
638
|
-
cfg["api_key"] = user_key
|
|
639
|
-
cfg["pool_dir"] = str(home)
|
|
640
|
-
cfg["monitor_mode"] = "auto"
|
|
641
|
-
cfg.setdefault("port", 8092)
|
|
642
|
-
POOL_CONFIG_FILE.write_text(
|
|
643
|
-
json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
644
|
-
# 清掉手动停止标记,让内置池能被启动 / 守护自动拉起
|
|
645
|
-
try:
|
|
646
|
-
(home / pool_service.STOP_FLAG).unlink(missing_ok=True)
|
|
647
|
-
except Exception:
|
|
648
|
-
pass
|
|
649
|
-
# 启动内置池(首次建运行时可能要几十秒,丢线程池不堵事件循环)
|
|
650
|
-
res = await run_in_threadpool(pool_service.start)
|
|
651
|
-
await POOL.refresh_env_ids()
|
|
652
|
-
await POOL.refresh_accounts(force=True)
|
|
653
|
-
return JSONResponse({
|
|
654
|
-
"ok": bool(res.get("ok", True)),
|
|
655
|
-
"started": bool(res.get("ok", True)),
|
|
656
|
-
"message": res.get("message", ""),
|
|
657
|
-
"config": _cfg_view(),
|
|
658
|
-
})
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
# ---------- 接口设置(通用 OpenAI 兼容接口) ----------
|
|
662
|
-
def _cfg_view() -> dict:
|
|
663
|
-
"""当前接口配置的脱敏视图,绝不回传完整 API Key。"""
|
|
664
|
-
p = provider_cfg()
|
|
665
|
-
pool = (POOL.snapshot().get("pool") or {})
|
|
666
|
-
key = p["api_key"]
|
|
667
|
-
masked = ""
|
|
668
|
-
if key:
|
|
669
|
-
masked = (key[:6] + "***" + key[-4:]) if len(key) > 12 else "***"
|
|
670
|
-
return {
|
|
671
|
-
"app_name": (load_config().get("app_name") or "").strip() or DEFAULT_APP_NAME,
|
|
672
|
-
"app_name_default": DEFAULT_APP_NAME,
|
|
673
|
-
"provider_name": p["name"],
|
|
674
|
-
"base_url": p["base_url"],
|
|
675
|
-
"output_dir": str(out_dir()),
|
|
676
|
-
"output_dir_default": str(OUT_DIR),
|
|
677
|
-
"output_dir_is_default": str(out_dir()) == str(OUT_DIR),
|
|
678
|
-
"model_image": p["model_image"],
|
|
679
|
-
"model_text": p["model_text"],
|
|
680
|
-
"supports_edits": p["supports_edits"],
|
|
681
|
-
"edits_style": p["edits_style"],
|
|
682
|
-
"image_response_format": p["image_format"],
|
|
683
|
-
"monitor_mode": p["monitor_mode"],
|
|
684
|
-
"api_key_set": bool(key),
|
|
685
|
-
"api_key_masked": masked,
|
|
686
|
-
"is_pool": p["is_pool"],
|
|
687
|
-
"mode": pool.get("mode") or ("pool" if p["is_pool"] else "single"),
|
|
688
|
-
"pool_message": pool.get("message") or "",
|
|
689
|
-
"port": load_config().get("port", 8092),
|
|
690
|
-
"version": VERSION,
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
@app.get("/api/config")
|
|
695
|
-
async def api_config_get():
|
|
696
|
-
return JSONResponse(_cfg_view())
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
@app.post("/api/config")
|
|
700
|
-
async def api_config_save(body: dict):
|
|
701
|
-
cfg: dict = {}
|
|
702
|
-
if POOL_CONFIG_FILE.exists():
|
|
703
|
-
try:
|
|
704
|
-
cfg = json.loads(POOL_CONFIG_FILE.read_text(encoding="utf-8"))
|
|
705
|
-
except Exception:
|
|
706
|
-
cfg = {}
|
|
707
|
-
|
|
708
|
-
def put(k: str, v) -> None:
|
|
709
|
-
cfg[k] = v
|
|
710
|
-
|
|
711
|
-
if "app_name" in body:
|
|
712
|
-
# 只影响显示名:留空 = 回到内置默认名
|
|
713
|
-
put("app_name", str(body["app_name"] or "").strip()[:40])
|
|
714
|
-
if "provider_name" in body:
|
|
715
|
-
put("provider_name", str(body["provider_name"]).strip() or "生图接口")
|
|
716
|
-
if "base_url" in body:
|
|
717
|
-
b = norm_base_url(str(body["base_url"]))
|
|
718
|
-
put("api_base_url", b)
|
|
719
|
-
put("pool_base_url", b) # 兼容旧字段
|
|
720
|
-
if "pool_dir" in body:
|
|
721
|
-
# 号池数据目录:决定 admin-key / 用户 Key 从哪读。只接受真实存在的目录
|
|
722
|
-
raw = str(body["pool_dir"] or "").strip()
|
|
723
|
-
if raw and Path(raw).is_dir():
|
|
724
|
-
put("pool_dir", str(Path(raw)))
|
|
725
|
-
if "output_dir" in body:
|
|
726
|
-
raw = str(body["output_dir"] or "").strip()
|
|
727
|
-
old = str(cfg.get("output_dir") or "")
|
|
728
|
-
if raw:
|
|
729
|
-
d = Path(os.path.expandvars(os.path.expanduser(raw)))
|
|
730
|
-
if not d.is_absolute():
|
|
731
|
-
d = LAB / d
|
|
732
|
-
try:
|
|
733
|
-
d.mkdir(parents=True, exist_ok=True)
|
|
734
|
-
probe = d / ".wb_write_test"
|
|
735
|
-
probe.write_bytes(b"ok")
|
|
736
|
-
probe.unlink()
|
|
737
|
-
except Exception as e:
|
|
738
|
-
raise HTTPException(400, f"保存目录不可用:{d}({e})")
|
|
739
|
-
put("output_dir", str(d))
|
|
740
|
-
else:
|
|
741
|
-
put("output_dir", "") # 留空 = 回到默认 images/
|
|
742
|
-
# 记下用过的旧目录:换走之后,老图在历史里还得打得开
|
|
743
|
-
seen = [str(s) for s in (cfg.get("output_dirs_seen") or []) if str(s).strip()]
|
|
744
|
-
if old and Path(old) != OUT_DIR and old not in seen:
|
|
745
|
-
seen.append(old)
|
|
746
|
-
new = str(cfg.get("output_dir") or "")
|
|
747
|
-
if new and Path(new) != OUT_DIR and new not in seen:
|
|
748
|
-
seen.append(new)
|
|
749
|
-
put("output_dirs_seen", seen[-8:])
|
|
750
|
-
if body.get("api_key"): # 留空表示不改,保留原密钥
|
|
751
|
-
put("api_key", str(body["api_key"]).strip())
|
|
752
|
-
if "model_image" in body:
|
|
753
|
-
put("model_image", str(body["model_image"]).strip() or "gpt-image-2")
|
|
754
|
-
if "model_text" in body:
|
|
755
|
-
put("model_text", str(body["model_text"]).strip() or "auto")
|
|
756
|
-
if "supports_edits" in body:
|
|
757
|
-
put("supports_edits", bool(body["supports_edits"]))
|
|
758
|
-
if "edits_style" in body:
|
|
759
|
-
put("edits_style", str(body["edits_style"]).strip() or "auto")
|
|
760
|
-
if "image_response_format" in body:
|
|
761
|
-
put("image_response_format", str(body["image_response_format"]).strip() or "auto")
|
|
762
|
-
if "monitor_mode" in body:
|
|
763
|
-
put("monitor_mode", str(body["monitor_mode"]).strip() or "auto")
|
|
764
|
-
cfg.setdefault("port", 8092)
|
|
765
|
-
POOL_CONFIG_FILE.write_text(
|
|
766
|
-
json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
767
|
-
# 立即重探,让前端保存后马上看到新状态
|
|
768
|
-
await POOL.refresh_env_ids()
|
|
769
|
-
await POOL.refresh_accounts(force=True)
|
|
770
|
-
return JSONResponse(_cfg_view())
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
@app.post("/api/config/test")
|
|
774
|
-
async def api_config_test(body: dict):
|
|
775
|
-
"""按提交的临时配置试连(也可空 body 用当前配置),不落盘。"""
|
|
776
|
-
return JSONResponse(await test_connection(body or {}))
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
@app.post("/api/config/open-dir")
|
|
780
|
-
async def api_config_open_dir(request: Request):
|
|
781
|
-
"""在运行本服务的电脑上打开保存目录。
|
|
782
|
-
|
|
783
|
-
只有本机浏览器能用(远端访问时打开的是服务器上的资源管理器,没有意义),
|
|
784
|
-
远端就直接把路径返回去,让用户自己复制。
|
|
785
|
-
"""
|
|
786
|
-
d = out_dir()
|
|
787
|
-
if _client_ip(request) not in ("127.0.0.1", "::1", "localhost"):
|
|
788
|
-
return JSONResponse({"ok": False, "path": str(d),
|
|
789
|
-
"message": "只有本机访问时能直接打开目录,请复制路径到资源管理器"})
|
|
790
|
-
try:
|
|
791
|
-
if sys.platform.startswith("win"):
|
|
792
|
-
os.startfile(str(d)) # noqa: S606 - 本机打开文件夹
|
|
793
|
-
elif sys.platform == "darwin":
|
|
794
|
-
subprocess.Popen(["open", str(d)])
|
|
795
|
-
else:
|
|
796
|
-
subprocess.Popen(["xdg-open", str(d)])
|
|
797
|
-
return JSONResponse({"ok": True, "path": str(d)})
|
|
798
|
-
except Exception as e:
|
|
799
|
-
return JSONResponse({"ok": False, "path": str(d), "message": f"打开失败:{e}"})
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
@app.post("/api/compose")
|
|
803
|
-
async def api_compose(req: ComposeRequest):
|
|
804
|
-
"""把风格 + 主题色 + 图型 + 融合 + 每条文字,拼成出图指令与完整提示词。"""
|
|
805
|
-
payload = {
|
|
806
|
-
"style_id": req.style_id,
|
|
807
|
-
"layout_id": req.layout_id,
|
|
808
|
-
"color_id": req.color_id,
|
|
809
|
-
"ratio": req.ratio,
|
|
810
|
-
"mood": req.mood,
|
|
811
|
-
"fusion": req.fusion,
|
|
812
|
-
"whitespace": req.whitespace,
|
|
813
|
-
"note": req.note,
|
|
814
|
-
"topic": req.topic,
|
|
815
|
-
"ref": req.ref,
|
|
816
|
-
"image_mode": req.image_mode,
|
|
817
|
-
"shoot_type": req.shoot_type,
|
|
818
|
-
"instruction": req.instruction,
|
|
819
|
-
"allow_blank": req.allow_blank,
|
|
820
|
-
"items": [{"title": it.title, "body": it.body, "scene": it.scene} for it in req.items],
|
|
821
|
-
}
|
|
822
|
-
return JSONResponse(composer.compose(payload, CATALOG["styles"], CATALOG["layouts"], CATALOG["colors"]))
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
@app.post("/api/preview")
|
|
826
|
-
async def api_preview(req: GenRequest):
|
|
827
|
-
if req.mode == "text":
|
|
828
|
-
prompt = build_text_prompt(req)
|
|
829
|
-
elif req.mode == "polish":
|
|
830
|
-
prompt = build_polish_prompt(req)
|
|
831
|
-
else:
|
|
832
|
-
prompt = build_image_prompt(req)
|
|
833
|
-
return {"prompt": prompt}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
@app.post("/api/generate")
|
|
837
|
-
async def api_generate(req: GenRequest):
|
|
838
|
-
rid = (req.replace_id or "").strip()
|
|
839
|
-
if rid and not re.fullmatch(r"[0-9a-z]{6,32}", rid):
|
|
840
|
-
raise HTTPException(400, "bad replace_id")
|
|
841
|
-
if req.mode not in ("text", "image", "polish"):
|
|
842
|
-
raise HTTPException(400, "mode must be text, image or polish")
|
|
843
|
-
# 号池未就绪:单接口模式本就不拉账号,放行(由真实请求暴露错误);
|
|
844
|
-
# 号池 / auto 模式账号为空时,给出明确指引而非笼统的「pool is empty」。
|
|
845
|
-
if not POOL.accounts and provider_cfg().get("monitor_mode") != "single":
|
|
846
|
-
cfg = load_config()
|
|
847
|
-
base = provider_cfg(cfg).get("base_url") or ""
|
|
848
|
-
user_set = bool(cfg.get("api_base_url"))
|
|
849
|
-
if not base:
|
|
850
|
-
raise HTTPException(
|
|
851
|
-
400,
|
|
852
|
-
"尚未配置生图号池。请在「接口与号池」面板填写号池地址与 API Key 后再出图。")
|
|
853
|
-
if not user_set:
|
|
854
|
-
raise HTTPException(
|
|
855
|
-
400,
|
|
856
|
-
"尚未配置生图号池:当前仍指向内置空号池。请在「接口与号池」面板填写你自己的号池地址"
|
|
857
|
-
"(例如 http://127.0.0.1:38650/v1)与 API Key,或先启动内置号池并导入账号。")
|
|
858
|
-
raise HTTPException(
|
|
859
|
-
400,
|
|
860
|
-
f"号池 {base} 已配置但暂无可用账号。请确认号池进程已启动并已导入账号;"
|
|
861
|
-
"可在「接口与号池」面板点「测试」验证连通性。")
|
|
862
|
-
if req.mode == "text":
|
|
863
|
-
prompt = build_text_prompt(req)
|
|
864
|
-
elif req.mode == "polish":
|
|
865
|
-
prompt = build_polish_prompt(req)
|
|
866
|
-
else:
|
|
867
|
-
prompt = build_image_prompt(req)
|
|
868
|
-
ref_path = ""
|
|
869
|
-
if req.mode == "image" and req.ref_id:
|
|
870
|
-
rid = req.ref_id.strip()
|
|
871
|
-
# 只认我们自己生成的 hex id,避免路径穿越
|
|
872
|
-
if not re.fullmatch(r"[0-9a-f]{4,32}", rid):
|
|
873
|
-
raise HTTPException(400, "bad ref_id")
|
|
874
|
-
p = REFS_DIR / f"{rid}.png"
|
|
875
|
-
if not p.is_file():
|
|
876
|
-
raise HTTPException(404, "reference image not found")
|
|
877
|
-
ref_path = str(p)
|
|
878
|
-
job = {
|
|
879
|
-
"id": uuid.uuid4().hex[:12],
|
|
880
|
-
"task_id": (req.task_id or "").strip()[:40] or uuid.uuid4().hex[:10],
|
|
881
|
-
"mode": req.mode,
|
|
882
|
-
"topic": req.topic.strip(),
|
|
883
|
-
"title": (req.title or req.topic).strip(),
|
|
884
|
-
"body": (req.body or "").strip(),
|
|
885
|
-
"doc_body": (req.doc_body or "").strip(),
|
|
886
|
-
"seq": req.seq,
|
|
887
|
-
"group": (req.group or "").strip(),
|
|
888
|
-
"style_id": req.style_id,
|
|
889
|
-
"layout_id": req.layout_id,
|
|
890
|
-
"color_id": req.color_id,
|
|
891
|
-
"ratio": req.ratio,
|
|
892
|
-
"ref_id": (req.ref_id or "") if ref_path else "",
|
|
893
|
-
"ref_path": ref_path,
|
|
894
|
-
"image_mode": (req.image_mode or "graphic-text"),
|
|
895
|
-
"shoot_type": (req.shoot_type or ""),
|
|
896
|
-
"replace_id": rid,
|
|
897
|
-
"prompt": prompt,
|
|
898
|
-
"status": "queued",
|
|
899
|
-
"created": int(time.time()),
|
|
900
|
-
"account": "",
|
|
901
|
-
}
|
|
902
|
-
await POOL.submit(job)
|
|
903
|
-
return {"job_id": job["id"], "status": job["status"]}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
@app.post("/api/generate/stop")
|
|
907
|
-
async def api_generate_stop():
|
|
908
|
-
'''停止当前生图任务:中断在途请求、清空排队、阻止换号重试。'''
|
|
909
|
-
return JSONResponse(await POOL.cancel_all())
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
@app.post("/api/zip")
|
|
913
|
-
async def api_zip(req: ZipRequest):
|
|
914
|
-
"""把一批 job 的图片按展示顺序打成 zip;重启后从历史回退,仍是原图。"""
|
|
915
|
-
import io
|
|
916
|
-
import zipfile
|
|
917
|
-
|
|
918
|
-
buf = io.BytesIO()
|
|
919
|
-
used: set[str] = set()
|
|
920
|
-
packed = 0
|
|
921
|
-
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as z:
|
|
922
|
-
for idx, jid in enumerate(req.ids, 1):
|
|
923
|
-
if not re.fullmatch(r"[0-9a-f]{4,32}", jid or ""):
|
|
924
|
-
continue
|
|
925
|
-
# 服务重启后 POOL.jobs 会清空,但历史里还记着图片文件名,退回历史照打不误
|
|
926
|
-
job = POOL.jobs.get(jid)
|
|
927
|
-
rec = HISTORY.get(jid) if not job else None
|
|
928
|
-
img = ((job.get("result") or {}).get("image") if job else None) or (rec or {}).get("image")
|
|
929
|
-
if not img:
|
|
930
|
-
continue
|
|
931
|
-
p = image_path(img)
|
|
932
|
-
if not p:
|
|
933
|
-
continue
|
|
934
|
-
title = str(((job.get("title") or job.get("topic")) if job else
|
|
935
|
-
((rec or {}).get("title") or (rec or {}).get("topic"))) or "").strip()
|
|
936
|
-
safe = re.sub(r'[\\/:*?"<>|\s]+', "_", title)[:40].strip("_") or "card"
|
|
937
|
-
name = f"{idx:02d}-{safe}{p.suffix or '.png'}"
|
|
938
|
-
k = 2
|
|
939
|
-
while name in used:
|
|
940
|
-
name = f"{idx:02d}-{safe}-{k}{p.suffix or '.png'}"
|
|
941
|
-
k += 1
|
|
942
|
-
used.add(name)
|
|
943
|
-
ratio = (job.get("ratio") if job else (rec or {}).get("ratio"))
|
|
944
|
-
z.writestr(name, imaging.export_bytes(THUMB_DIR, jid, p, ratio))
|
|
945
|
-
packed += 1
|
|
946
|
-
if not packed:
|
|
947
|
-
raise HTTPException(404, "no downloadable images in this batch")
|
|
948
|
-
buf.seek(0)
|
|
949
|
-
return Response(
|
|
950
|
-
content=buf.getvalue(),
|
|
951
|
-
media_type="application/zip",
|
|
952
|
-
headers={"Content-Disposition": 'attachment; filename="task-images.zip"'},
|
|
953
|
-
)
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
@app.get("/api/jobs/{job_id}")
|
|
957
|
-
async def api_job(job_id: str):
|
|
958
|
-
"""查不到内存 job 时退回历史记录,服务重启后轮询与预览 URL 照样可用。"""
|
|
959
|
-
job = POOL.jobs.get(job_id)
|
|
960
|
-
if not job:
|
|
961
|
-
rec = HISTORY.get(job_id)
|
|
962
|
-
if not rec:
|
|
963
|
-
raise HTTPException(404, "job not found")
|
|
964
|
-
img = str(rec.get("image") or "")
|
|
965
|
-
job = {
|
|
966
|
-
"id": job_id, "status": rec.get("status") or "done",
|
|
967
|
-
"topic": rec.get("topic") or "", "title": rec.get("title") or "",
|
|
968
|
-
"account": rec.get("account") or "", "created": rec.get("created"),
|
|
969
|
-
"finished": rec.get("finished"), "error": rec.get("error") or "",
|
|
970
|
-
"ratio": rec.get("ratio"),
|
|
971
|
-
# 失败换号的轨迹要能跨重启还在,否则刷新一下就看不出是谁失败了
|
|
972
|
-
"attempts": rec.get("attempts") or [],
|
|
973
|
-
"account_email": rec.get("account_email") or "",
|
|
974
|
-
"result": ({"image": img, "width": rec.get("width"),
|
|
975
|
-
"height": rec.get("height"), "bytes": rec.get("bytes")}
|
|
976
|
-
if img else {}),
|
|
977
|
-
}
|
|
978
|
-
payload = dict(job)
|
|
979
|
-
result = payload.get("result") or {}
|
|
980
|
-
if result.get("image"):
|
|
981
|
-
raw = f"/files/{result['image']}"
|
|
982
|
-
payload["raw_url"] = raw
|
|
983
|
-
# 2.35:1 公众号首图:轮询拿到的就是裁好的成品,前端各处不用再自己判断比例
|
|
984
|
-
payload["image_url"] = (f"/api/history/{job_id}/final"
|
|
985
|
-
if imaging.needs_crop(payload.get("ratio")) else raw)
|
|
986
|
-
payload["preview_url"] = f"/api/history/{job_id}/preview"
|
|
987
|
-
return JSONResponse(payload)
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
@app.get("/api/recent")
|
|
991
|
-
async def api_recent():
|
|
992
|
-
jobs = sorted(POOL.jobs.values(), key=lambda j: j["created"], reverse=True)[:20]
|
|
993
|
-
out = []
|
|
994
|
-
for j in jobs:
|
|
995
|
-
result = j.get("result") or {}
|
|
996
|
-
img = result.get("image")
|
|
997
|
-
out.append({
|
|
998
|
-
"id": j["id"], "mode": j["mode"], "status": j["status"], "topic": j["topic"],
|
|
999
|
-
"account": j.get("account", ""), "created": j["created"],
|
|
1000
|
-
"image_url": ((f"/api/history/{j['id']}/final" if imaging.needs_crop(j.get("ratio"))
|
|
1001
|
-
else f"/files/{img}") if img else None),
|
|
1002
|
-
"error": j.get("error", ""),
|
|
1003
|
-
})
|
|
1004
|
-
return JSONResponse(out)
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
@app.get("/api/history")
|
|
1008
|
-
async def api_history(limit: int = 24, offset: int = 0, q: str = "", status: str = ""):
|
|
1009
|
-
return JSONResponse(HISTORY.list(limit=limit, offset=offset, q=q, status=status))
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
@app.get("/api/tasks")
|
|
1013
|
-
async def api_tasks(limit: int = 20, offset: int = 0, q: str = ""):
|
|
1014
|
-
"""历史按「一次批量出图」分组,默认一页 20 个任务。"""
|
|
1015
|
-
return JSONResponse(HISTORY.tasks(limit=limit, offset=offset, q=q))
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
@app.get("/api/tasks/{task_id}")
|
|
1019
|
-
async def api_task_detail(task_id: str):
|
|
1020
|
-
"""载入历史任务:带提示词全文 + 当时选的图型/风格/主题色/比例。"""
|
|
1021
|
-
d = HISTORY.task_detail(task_id)
|
|
1022
|
-
if not d:
|
|
1023
|
-
raise HTTPException(404, "task not found")
|
|
1024
|
-
return JSONResponse(d)
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
def _plain_text(s: str) -> str:
|
|
1028
|
-
"""把可能的 markdown 痕迹洗成纯文本:去代码围栏、# 标题、加粗、列表符、引用、链接壳。"""
|
|
1029
|
-
s = str(s or "")
|
|
1030
|
-
s = re.sub(r"```+[a-zA-Z0-9_-]*", "", s)
|
|
1031
|
-
s = re.sub(r"^\s{0,3}#{1,6}\s*", "", s, flags=re.M)
|
|
1032
|
-
s = re.sub(r"\*\*(.+?)\*\*", r"\1", s, flags=re.S)
|
|
1033
|
-
s = re.sub(r"__(.+?)__", r"\1", s, flags=re.S)
|
|
1034
|
-
s = re.sub(r"^\s*[-*+·]\s+", "", s, flags=re.M)
|
|
1035
|
-
s = re.sub(r"^\s*>\s?", "", s, flags=re.M)
|
|
1036
|
-
s = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", s)
|
|
1037
|
-
s = re.sub(r"`([^`]*)`", r"\1", s)
|
|
1038
|
-
return s.strip()
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
@app.get("/api/tasks/{task_id}/bundle")
|
|
1042
|
-
async def api_task_bundle(task_id: str):
|
|
1043
|
-
"""整任务打包下载:所有原图 + 文案.txt(标题 + 正文,纯文本不带 markdown 标记)。"""
|
|
1044
|
-
data = HISTORY.bundle_data(task_id)
|
|
1045
|
-
if not data:
|
|
1046
|
-
raise HTTPException(404, "task not found")
|
|
1047
|
-
title = str(data["title"] or "未命名任务")
|
|
1048
|
-
|
|
1049
|
-
def safename(s, maxlen: int = 60) -> str:
|
|
1050
|
-
return "".join(ch for ch in str(s or "") if ch not in '\\/:*?"<>|\n\r\t').strip()[:maxlen]
|
|
1051
|
-
|
|
1052
|
-
buf = io.BytesIO()
|
|
1053
|
-
n_img = 0
|
|
1054
|
-
used: set[str] = set()
|
|
1055
|
-
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
1056
|
-
body = _plain_text(data["doc_body"])
|
|
1057
|
-
if not body:
|
|
1058
|
-
# 老记录没有 doc_body:退回每条的 标题+正文
|
|
1059
|
-
parts = []
|
|
1060
|
-
for i, r in enumerate(data["rows"], 1):
|
|
1061
|
-
t = (r.get("title") or r.get("topic") or "").strip()
|
|
1062
|
-
b = _plain_text(r.get("body") or "")
|
|
1063
|
-
if t or b:
|
|
1064
|
-
parts.append((t or f"第{i:02d}条") + (("\n" + b) if b else ""))
|
|
1065
|
-
body = "\n\n".join(parts)
|
|
1066
|
-
z.writestr("文案.txt", (title + "\n\n" + body).strip() + "\n")
|
|
1067
|
-
for r in data["rows"]:
|
|
1068
|
-
if not r.get("image"):
|
|
1069
|
-
continue
|
|
1070
|
-
p = HISTORY.image_path(str(r["id"]))
|
|
1071
|
-
if not p:
|
|
1072
|
-
continue
|
|
1073
|
-
n_img += 1
|
|
1074
|
-
stem = safename(r.get("title") or r.get("topic") or "") or f"图片{n_img:02d}"
|
|
1075
|
-
base = f"{n_img:02d}_{stem}"
|
|
1076
|
-
name, j = base, 2
|
|
1077
|
-
while name in used:
|
|
1078
|
-
name = f"{base}_{j}"
|
|
1079
|
-
j += 1
|
|
1080
|
-
used.add(name)
|
|
1081
|
-
z.write(str(p), f"images/{name}.png")
|
|
1082
|
-
fname = (safename(title, 40) or task_id) + "_图片文案.zip"
|
|
1083
|
-
return Response(
|
|
1084
|
-
content=buf.getvalue(),
|
|
1085
|
-
media_type="application/zip",
|
|
1086
|
-
headers={"Content-Disposition": "attachment; filename*=UTF-8''" + quote(fname)},
|
|
1087
|
-
)
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
@app.delete("/api/tasks/{task_id}")
|
|
1091
|
-
async def api_task_delete(task_id: str):
|
|
1092
|
-
"""删掉整个任务(索引 + 原图 + 缩略图),删完不能恢复。"""
|
|
1093
|
-
r = HISTORY.delete_task(task_id)
|
|
1094
|
-
if not r.get("deleted"):
|
|
1095
|
-
raise HTTPException(404, "task not found")
|
|
1096
|
-
return JSONResponse(r)
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
@app.get("/api/drafts")
|
|
1100
|
-
async def api_drafts():
|
|
1101
|
-
return JSONResponse(DRAFTS.list())
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
@app.post("/api/drafts")
|
|
1105
|
-
async def api_draft_save(body: dict):
|
|
1106
|
-
rec = DRAFTS.save(body)
|
|
1107
|
-
return JSONResponse({"ok": True, "id": rec["id"], "updated": rec.get("updated"),
|
|
1108
|
-
"count": rec.get("count", 0)})
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
@app.get("/api/drafts/{draft_id}")
|
|
1112
|
-
async def api_draft_get(draft_id: str):
|
|
1113
|
-
rec = DRAFTS.get(draft_id)
|
|
1114
|
-
if not rec:
|
|
1115
|
-
raise HTTPException(404, "draft not found")
|
|
1116
|
-
return JSONResponse(rec)
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
@app.delete("/api/drafts/{draft_id}")
|
|
1120
|
-
async def api_draft_del(draft_id: str):
|
|
1121
|
-
if not DRAFTS.delete(draft_id):
|
|
1122
|
-
raise HTTPException(404, "draft not found")
|
|
1123
|
-
return {"ok": True}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
@app.patch("/api/drafts/{draft_id}")
|
|
1127
|
-
async def api_draft_rename(draft_id: str, body: dict):
|
|
1128
|
-
rec = DRAFTS.rename(draft_id, body.get("name") or "")
|
|
1129
|
-
if rec is None:
|
|
1130
|
-
raise HTTPException(404, "draft not found")
|
|
1131
|
-
return {"ok": True, "id": draft_id, "name": rec.get("name") or ""}
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
@app.post("/api/ref")
|
|
1135
|
-
async def api_ref_upload(req: RefRequest):
|
|
1136
|
-
"""参考图上传:前端压到 1400px 的 dataURL,这里只做落盘(PNG)。"""
|
|
1137
|
-
raw = (req.data or "").strip()
|
|
1138
|
-
if raw.lower().startswith("data:"):
|
|
1139
|
-
_, _, raw = raw.partition(",")
|
|
1140
|
-
try:
|
|
1141
|
-
blob = base64.b64decode(raw, validate=False)
|
|
1142
|
-
except Exception:
|
|
1143
|
-
raise HTTPException(400, "bad image data")
|
|
1144
|
-
if not blob or len(blob) > 12 * 1024 * 1024:
|
|
1145
|
-
raise HTTPException(400, "reference image too large (max 12MB)")
|
|
1146
|
-
|
|
1147
|
-
import io
|
|
1148
|
-
|
|
1149
|
-
from PIL import Image, ImageOps
|
|
1150
|
-
try:
|
|
1151
|
-
im = Image.open(io.BytesIO(blob))
|
|
1152
|
-
im = ImageOps.exif_transpose(im)
|
|
1153
|
-
im.load()
|
|
1154
|
-
except Exception:
|
|
1155
|
-
raise HTTPException(400, "not a readable image")
|
|
1156
|
-
if max(im.size) > 1400:
|
|
1157
|
-
im.thumbnail((1400, 1400))
|
|
1158
|
-
if im.mode not in ("RGB", "RGBA"):
|
|
1159
|
-
im = im.convert("RGBA" if "A" in im.getbands() else "RGB")
|
|
1160
|
-
|
|
1161
|
-
rid = uuid.uuid4().hex[:10]
|
|
1162
|
-
dst = REFS_DIR / f"{rid}.png"
|
|
1163
|
-
im.save(dst, "PNG", optimize=True)
|
|
1164
|
-
# 只留最近 120 张,老的自生自灭
|
|
1165
|
-
try:
|
|
1166
|
-
olds = sorted(REFS_DIR.glob("*.png"), key=lambda f: f.stat().st_mtime, reverse=True)[120:]
|
|
1167
|
-
for f in olds:
|
|
1168
|
-
f.unlink()
|
|
1169
|
-
except Exception:
|
|
1170
|
-
pass
|
|
1171
|
-
return JSONResponse({"id": rid, "url": f"/refs/{rid}.png",
|
|
1172
|
-
"w": im.width, "h": im.height, "bytes": dst.stat().st_size})
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
@app.get("/api/history/{job_id}")
|
|
1176
|
-
async def api_history_one(job_id: str):
|
|
1177
|
-
"""单条完整记录(含提示词全文),历史里的「复用」用它。"""
|
|
1178
|
-
rec = HISTORY.get(job_id)
|
|
1179
|
-
if not rec:
|
|
1180
|
-
raise HTTPException(404, "record not found")
|
|
1181
|
-
return JSONResponse(HISTORY._public(rec))
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
def _variant_response(job_id: str, kind: str):
|
|
1185
|
-
"""派生图统一出口。原图永远只在下载与大图弹窗里出现。
|
|
1186
|
-
|
|
1187
|
-
公众号首图 2.35:1 是裁出来的:卡片和右侧预览都按记录里的比例裁,
|
|
1188
|
-
否则预览里看到的构图和发出去的不是一张。
|
|
1189
|
-
"""
|
|
1190
|
-
src = HISTORY.image_path(job_id)
|
|
1191
|
-
if not src:
|
|
1192
|
-
raise HTTPException(404, "image not found")
|
|
1193
|
-
rec = HISTORY.get(job_id) or {}
|
|
1194
|
-
ratio = rec.get("ratio")
|
|
1195
|
-
try:
|
|
1196
|
-
# Pillow 解码 3MB PNG 要几百毫秒,整段放线程池,别堵住 event loop
|
|
1197
|
-
dst = imaging.ensure(THUMB_DIR, job_id, kind, src, ratio)
|
|
1198
|
-
return FileResponse(str(dst), media_type="image/webp",
|
|
1199
|
-
headers={"Cache-Control": "private, max-age=604800"})
|
|
1200
|
-
except Exception:
|
|
1201
|
-
return FileResponse(str(src), media_type="image/png")
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
@app.get("/api/history/{job_id}/thumb")
|
|
1205
|
-
async def api_history_thumb(job_id: str):
|
|
1206
|
-
"""历史卡片小图:360px WebP 约 40KB;原图仍在 /files 供下载。"""
|
|
1207
|
-
return await run_in_threadpool(_variant_response, job_id, "card")
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
@app.get("/api/history/{job_id}/preview")
|
|
1211
|
-
async def api_history_preview(job_id: str):
|
|
1212
|
-
"""右侧排版预览:960px WebP 约 280KB;双击大图与所有下载都走原图。"""
|
|
1213
|
-
return await run_in_threadpool(_variant_response, job_id, "preview")
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
@app.get("/api/history/{job_id}/download")
|
|
1217
|
-
async def api_history_download(job_id: str):
|
|
1218
|
-
path = HISTORY.image_path(job_id)
|
|
1219
|
-
if not path:
|
|
1220
|
-
raise HTTPException(404, "image not found")
|
|
1221
|
-
rec = HISTORY.get(job_id) or {}
|
|
1222
|
-
title = str(rec.get("title") or rec.get("topic") or job_id)
|
|
1223
|
-
name = "".join(ch for ch in title if ch not in '\\/:*?"<>|\n\r').strip() or job_id
|
|
1224
|
-
name = f"{name[:40]}.png"
|
|
1225
|
-
# 2.35:1 这类上游没有的尺寸,导出的就是裁好的成品;原图文件保持完整不覆盖
|
|
1226
|
-
dst = await run_in_threadpool(imaging.export_path, THUMB_DIR, job_id, path, rec.get("ratio"))
|
|
1227
|
-
return FileResponse(str(dst), media_type="image/png",
|
|
1228
|
-
headers={"Content-Disposition":
|
|
1229
|
-
f"attachment; filename*=UTF-8''{quote(name)}"})
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
@app.get("/api/history/{job_id}/final")
|
|
1233
|
-
async def api_history_final(job_id: str):
|
|
1234
|
-
"""成品图(inline):公众号首图这类需要裁切的比例,大图弹窗和下载看到的是同一张。"""
|
|
1235
|
-
path = HISTORY.image_path(job_id)
|
|
1236
|
-
if not path:
|
|
1237
|
-
raise HTTPException(404, "image not found")
|
|
1238
|
-
rec = HISTORY.get(job_id) or {}
|
|
1239
|
-
dst = await run_in_threadpool(imaging.export_path, THUMB_DIR, job_id, path, rec.get("ratio"))
|
|
1240
|
-
return FileResponse(str(dst), media_type="image/png",
|
|
1241
|
-
headers={"Cache-Control": "private, max-age=604800"})
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
@app.get("/api/monitor")
|
|
1245
|
-
async def api_monitor():
|
|
1246
|
-
# 号池占用状态只在出图时才变:有任务在跑 3 秒一刷,空闲时拉长到 20 秒,
|
|
1247
|
-
# 免得监控页开着就在后台空转打号池
|
|
1248
|
-
await POOL.refresh_accounts_throttled()
|
|
1249
|
-
snap = POOL.snapshot()
|
|
1250
|
-
by_env, by_mask = HISTORY.account_stats()
|
|
1251
|
-
for a in snap["accounts"]:
|
|
1252
|
-
st = (by_env.get(str(a["env_id"])) if a.get("env_id") else None) or by_mask.get(a["masked"]) or {}
|
|
1253
|
-
if st: # 仅当工作台自身有该账号的历史记录时才覆盖,否则保留号池提供的成功/失败/最近成功
|
|
1254
|
-
a["hist_done"] = int(st.get("done") or 0)
|
|
1255
|
-
a["hist_error"] = int(st.get("error") or 0)
|
|
1256
|
-
a["hist_last_ok"] = int(st.get("last_ok") or 0)
|
|
1257
|
-
snap["history"] = HISTORY.stats()
|
|
1258
|
-
snap["check"] = POOL.check_state
|
|
1259
|
-
snap["recent_errors"] = HISTORY.recent_errors(6)
|
|
1260
|
-
return JSONResponse(snap)
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
@app.post("/api/pool/check")
|
|
1264
|
-
async def api_pool_check():
|
|
1265
|
-
return JSONResponse(await POOL.check_all())
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
@app.get("/api/pool/check")
|
|
1269
|
-
async def api_pool_check_state():
|
|
1270
|
-
return JSONResponse(POOL.check_state)
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
@app.get("/access")
|
|
1274
|
-
async def access_page(request: Request):
|
|
1275
|
-
# expired=1 是前端 401 拦截带过来的:会话中途失效时不要弹回首页,
|
|
1276
|
-
# 否则「/access -> / -> 401 -> /access」会形成无限重载。
|
|
1277
|
-
if (request.query_params.get("expired") != "1"
|
|
1278
|
-
and access.check_session(request.cookies.get("wb_sid", ""))):
|
|
1279
|
-
return RedirectResponse("/", status_code=302)
|
|
1280
|
-
return FileResponse(str(STATIC / "access.html"))
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
@app.post("/api/access/request")
|
|
1284
|
-
async def api_access_request(req: AccessRequest, request: Request):
|
|
1285
|
-
d = await run_in_threadpool(access.request_code, req.email or req.user,
|
|
1286
|
-
_client_ip(request), request.headers.get("user-agent", ""))
|
|
1287
|
-
return JSONResponse(d, status_code=200 if d.get("ok") else (429 if d.get("limited") else 400))
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
@app.post("/api/access/verify")
|
|
1291
|
-
async def api_access_verify(req: AccessVerify, request: Request):
|
|
1292
|
-
d = await run_in_threadpool(access.verify_code, req.email or req.user,
|
|
1293
|
-
req.code, _client_ip(request))
|
|
1294
|
-
if not d.get("ok"):
|
|
1295
|
-
return JSONResponse(d, status_code=429 if d.get("limited") else 400)
|
|
1296
|
-
resp = JSONResponse({"ok": True, "msg": d["msg"]})
|
|
1297
|
-
_set_session_cookie(resp, d["token"])
|
|
1298
|
-
return resp
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
@app.post("/api/access/master")
|
|
1302
|
-
async def api_access_master(req: MasterRequest, request: Request):
|
|
1303
|
-
d = await run_in_threadpool(access.master_login, req.password, _client_ip(request))
|
|
1304
|
-
if not d.get("ok"):
|
|
1305
|
-
return JSONResponse(d, status_code=429 if d.get("limited") else 400)
|
|
1306
|
-
resp = JSONResponse({"ok": True, "msg": d["msg"]})
|
|
1307
|
-
_set_session_cookie(resp, d["token"])
|
|
1308
|
-
return resp
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
@app.get("/")
|
|
1312
|
-
async def index():
|
|
1313
|
-
# 工作台是单页应用,改版频繁;禁止缓存,避免用户拿到旧的 index.html
|
|
1314
|
-
return FileResponse(str(STATIC / "index.html"),
|
|
1315
|
-
headers={"Cache-Control": "no-store, must-revalidate"})
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
@app.get("/{path:path}")
|
|
1319
|
-
async def static_page(path: str):
|
|
1320
|
-
f = STATIC / path
|
|
1321
|
-
if f.is_file():
|
|
1322
|
-
return FileResponse(str(f))
|
|
1323
|
-
raise HTTPException(404, "not found")
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
if __name__ == "__main__":
|
|
1327
|
-
import uvicorn
|
|
1328
|
-
|
|
1329
|
-
uvicorn.run(app, host="0.0.0.0", port=8020, log_level="info")
|
|
1
|
+
"""Workbench backend: handraw-style catalogs + ChatGPT account pool as token source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
import zipfile
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
24
|
+
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
|
25
|
+
from fastapi.staticfiles import StaticFiles
|
|
26
|
+
from pydantic import BaseModel, Field
|
|
27
|
+
|
|
28
|
+
import access
|
|
29
|
+
import catalog
|
|
30
|
+
import composer
|
|
31
|
+
import imaging
|
|
32
|
+
import pool as pool_mod
|
|
33
|
+
import pool_admin
|
|
34
|
+
import pool_service
|
|
35
|
+
from starlette.concurrency import run_in_threadpool
|
|
36
|
+
from drafts import Drafts
|
|
37
|
+
from history import History
|
|
38
|
+
from pool import (PoolManager, load_config, provider_cfg, test_connection,
|
|
39
|
+
out_dir, image_path, norm_base_url,
|
|
40
|
+
CONFIG_FILE as POOL_CONFIG_FILE)
|
|
41
|
+
|
|
42
|
+
# 本地版:所有数据目录都在本文件所在目录,不再指向 VPS 的 /root/ai-pool-lab
|
|
43
|
+
LAB = Path(__file__).resolve().parent
|
|
44
|
+
REPO_IMAGES = LAB / "repo_images"
|
|
45
|
+
STATIC = LAB / "static"
|
|
46
|
+
OUT_DIR = LAB / "images" # 默认保存目录;实际目录由 config 的 output_dir 决定
|
|
47
|
+
DEFAULT_APP_NAME = "Bluxe手绘工坊 · 生图工作台" # 页头标题默认名,可被 config 的 app_name 覆盖
|
|
48
|
+
THUMB_DIR = LAB / "thumbs"
|
|
49
|
+
REFS_DIR = LAB / "refs"
|
|
50
|
+
# 出图完成后在后台生成压缩派生图,别堵住出图线程
|
|
51
|
+
VARIANT_EXEC = ThreadPoolExecutor(max_workers=2, thread_name_prefix="wb-variant")
|
|
52
|
+
AUTH_FILE = LAB / ".auth"
|
|
53
|
+
for _d in (REPO_IMAGES, OUT_DIR, THUMB_DIR, REFS_DIR):
|
|
54
|
+
_d.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
CATALOG = catalog.build()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _plugin_version() -> str:
|
|
60
|
+
"""插件版本,取自插件根目录的 package.json。
|
|
61
|
+
|
|
62
|
+
独立运行时(只把 server/ 拷出来跑)读不到那个文件,返回 unknown 即可,
|
|
63
|
+
版本仅用于界面显示与排查「装的是哪一版」,不影响任何功能。
|
|
64
|
+
"""
|
|
65
|
+
for cand in (LAB.parent / "package.json", LAB / "package.json"):
|
|
66
|
+
try:
|
|
67
|
+
v = str(json.loads(cand.read_text(encoding="utf-8")).get("version") or "").strip()
|
|
68
|
+
if v:
|
|
69
|
+
return v
|
|
70
|
+
except Exception:
|
|
71
|
+
continue
|
|
72
|
+
return "unknown"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
VERSION = _plugin_version()
|
|
76
|
+
POOL = PoolManager()
|
|
77
|
+
# 图片目录传函数:用户改「保存目录」后历史立刻按新目录找图,不用重启
|
|
78
|
+
HISTORY = History(LAB / "workbench" / "history.jsonl", out_dir, THUMB_DIR,
|
|
79
|
+
finder=image_path)
|
|
80
|
+
DRAFTS = Drafts(LAB / "workbench" / "drafts.json")
|
|
81
|
+
def _finish_with_replace(job: dict) -> None:
|
|
82
|
+
"""单张重做:新图成功后先删掉原任务里那一张旧记录,再写新记录。
|
|
83
|
+
|
|
84
|
+
失败、没有图片、任务号对不上、id 格式不对,都保留旧图,避免把用户原来的图弄丢。
|
|
85
|
+
"""
|
|
86
|
+
rid = str(job.get("replace_id") or "").strip()
|
|
87
|
+
if (rid and re.fullmatch(r"[0-9a-z]{6,32}", rid)
|
|
88
|
+
and job.get("status") == "done"
|
|
89
|
+
and (job.get("result") or {}).get("image")):
|
|
90
|
+
HISTORY.drop_record(rid, task_id=job.get("task_id"))
|
|
91
|
+
HISTORY.record(job)
|
|
92
|
+
# 图一落地就把 card/preview 两档备好,点开历史时零等待。
|
|
93
|
+
# 派生图生成失败只影响预览速度,原图和历史记录不受影响。
|
|
94
|
+
try:
|
|
95
|
+
img = str((job.get("result") or {}).get("image") or "")
|
|
96
|
+
jid = str(job.get("id") or "")
|
|
97
|
+
src = image_path(img)
|
|
98
|
+
if jid and img == f"{jid}.png" and src and src.is_file():
|
|
99
|
+
VARIANT_EXEC.submit(imaging.ensure_all, THUMB_DIR, jid, src, job.get("ratio"))
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
POOL.on_finish = _finish_with_replace
|
|
105
|
+
AUTH: tuple[str, str] | None = None
|
|
106
|
+
if AUTH_FILE.exists():
|
|
107
|
+
u, _, p = AUTH_FILE.read_text(encoding="utf-8").strip().partition(":")
|
|
108
|
+
if u and p:
|
|
109
|
+
AUTH = (u, p)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@asynccontextmanager
|
|
113
|
+
async def lifespan(app: FastAPI):
|
|
114
|
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
out_dir().mkdir(parents=True, exist_ok=True)
|
|
116
|
+
# 号池由工作台自己拉起与守护,不依赖任何外部插件。
|
|
117
|
+
# 它比后端晚几秒就绪,起来那一刻主动对一次账,页头/监控不会先显示「1 号」。
|
|
118
|
+
loop = asyncio.get_running_loop()
|
|
119
|
+
|
|
120
|
+
async def _sync_after_pool_start():
|
|
121
|
+
# 全新机器首次启动时号池还没有任何用户 Key,工作台会 401。
|
|
122
|
+
# 号池一起来就自动签发一把(幂等:已有可用 Key 时什么都不会新建)。
|
|
123
|
+
try:
|
|
124
|
+
await asyncio.sleep(1.0)
|
|
125
|
+
k = await pool_admin.ensure_key()
|
|
126
|
+
if k.get("ok") and k.get("created"):
|
|
127
|
+
pool_mod.log("号池首次启动已自动签发用户 Key,工作台可直接出图")
|
|
128
|
+
except Exception as e:
|
|
129
|
+
pool_mod.log("自动签发号池用户 Key 失败:" + str(e)[:110])
|
|
130
|
+
await POOL.refresh_env_ids()
|
|
131
|
+
await POOL.refresh_accounts(force=True)
|
|
132
|
+
|
|
133
|
+
def _on_pool_started():
|
|
134
|
+
try:
|
|
135
|
+
asyncio.run_coroutine_threadsafe(_sync_after_pool_start(), loop)
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
pool_service.ON_STARTED = _on_pool_started
|
|
140
|
+
|
|
141
|
+
async def _patch_running_pool():
|
|
142
|
+
"""号池可能在上次会话里就活着(插件升级后很常见),此时 watchdog 不会碰它,
|
|
143
|
+
源码补丁也就永远落不了地。这里主动走一次 start():它会补打补丁,
|
|
144
|
+
并在补丁刚应用时重启号池,让新代码真正加载。"""
|
|
145
|
+
try:
|
|
146
|
+
await asyncio.sleep(3) # 先让 watchdog 的首次巡检跑完
|
|
147
|
+
v = pool_service.view()
|
|
148
|
+
if not v.get("manageable") or not pool_service.probe(v["port"], 0.5):
|
|
149
|
+
return # 没在跑就交给 watchdog,那条路径同样会打补丁
|
|
150
|
+
r = await run_in_threadpool(pool_service.start)
|
|
151
|
+
pool_mod.log("号池启动自检:" + str(r.get("message") or ""))
|
|
152
|
+
except Exception as e:
|
|
153
|
+
pool_mod.log("号池启动自检异常:" + str(e)[:110])
|
|
154
|
+
|
|
155
|
+
pool_service.start_watchdog()
|
|
156
|
+
asyncio.create_task(_patch_running_pool())
|
|
157
|
+
await POOL.start()
|
|
158
|
+
yield
|
|
159
|
+
await POOL.stop()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
app = FastAPI(title="Handraw Workbench", lifespan=lifespan)
|
|
163
|
+
@app.get("/img/{full_path:path}")
|
|
164
|
+
async def img_with_fallback(full_path: str):
|
|
165
|
+
"""风格/版式预览图路由(带兜底占位)。
|
|
166
|
+
|
|
167
|
+
repo_images/ 里的样例图不进 git(避免仓库膨胀),新安装时目录为空会全部 404、
|
|
168
|
+
画廊破图。这里做兜底:文件存在原样返回;不存在则现场生成一张 SVG 占位图
|
|
169
|
+
(渐变 + 风格编号),保证任何安装都显示整齐、可用,且不引入任何二进制资源。
|
|
170
|
+
真要放高清样例图时,直接丢进 repo_images/ 即自动生效,无需改代码。
|
|
171
|
+
"""
|
|
172
|
+
target = (REPO_IMAGES / full_path).resolve()
|
|
173
|
+
root = str(REPO_IMAGES.resolve())
|
|
174
|
+
# 目录穿越防护:只允许访问 repo_images 之内
|
|
175
|
+
if str(target).startswith(root + os.sep) and target.is_file():
|
|
176
|
+
# .webp 在部分系统(如 Windows)的 MIME 注册表里缺失,会被猜成
|
|
177
|
+
# application/octet-stream;显式声明,保证浏览器 <img> 正确识别。
|
|
178
|
+
mt = "image/webp" if target.suffix.lower() == ".webp" else None
|
|
179
|
+
return FileResponse(str(target), media_type=mt,
|
|
180
|
+
headers={"Cache-Control": "public, max-age=604800"})
|
|
181
|
+
# 文件缺失 → 生成 SVG 占位图
|
|
182
|
+
label = Path(full_path).stem
|
|
183
|
+
h = hashlib.md5(full_path.encode("utf-8")).hexdigest()
|
|
184
|
+
c1, c2 = h[:6], h[6:12]
|
|
185
|
+
svg = (
|
|
186
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">'
|
|
187
|
+
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">'
|
|
188
|
+
f'<stop offset="0" stop-color="#{c1}"/><stop offset="1" stop-color="#{c2}"/>'
|
|
189
|
+
"</linearGradient></defs>"
|
|
190
|
+
f'<rect width="240" height="160" fill="url(#g)"/>'
|
|
191
|
+
f'<text x="120" y="88" font-family="-apple-system,Segoe UI,sans-serif" font-size="22" '
|
|
192
|
+
f'fill="#ffffff" text-anchor="middle" opacity="0.95">{label}</text>'
|
|
193
|
+
"</svg>"
|
|
194
|
+
)
|
|
195
|
+
# 占位图必须 no-store:它只是「文件还没下载」的瞬态提示,绝不能进浏览器缓存。
|
|
196
|
+
# 否则真图补上后,浏览器仍按旧 URL 命中缓存里的渐变占位,画廊就一直显示渐变。
|
|
197
|
+
return Response(content=svg.encode("utf-8"), media_type="image/svg+xml",
|
|
198
|
+
headers={"Cache-Control": "no-store"})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
app.mount("/refs", StaticFiles(directory=str(REFS_DIR)), name="refs")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.get("/files/{name}")
|
|
205
|
+
async def files(name: str):
|
|
206
|
+
"""原图路由:按当前「保存目录」实时取文件。
|
|
207
|
+
|
|
208
|
+
以前是 StaticFiles 固定挂到 images/,用户一改保存目录,改完得重启才生效,
|
|
209
|
+
而且老图留在原目录就 404。现在每次请求现查,当前目录优先、默认目录兜底。
|
|
210
|
+
"""
|
|
211
|
+
p = image_path(name)
|
|
212
|
+
if not p:
|
|
213
|
+
raise HTTPException(404, "image not found")
|
|
214
|
+
return FileResponse(p, headers={"Cache-Control": "public, max-age=604800"})
|
|
215
|
+
|
|
216
|
+
PUBLIC_ACCESS_PATHS = {
|
|
217
|
+
"/access", "/api/access/request", "/api/access/verify", "/api/access/master",
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _client_ip(request: Request) -> str:
|
|
222
|
+
cf = request.headers.get("cf-connecting-ip", "").strip()
|
|
223
|
+
if cf:
|
|
224
|
+
return cf[:64]
|
|
225
|
+
xff = request.headers.get("x-forwarded-for", "")
|
|
226
|
+
if xff:
|
|
227
|
+
return xff.split(",")[0].strip()[:64]
|
|
228
|
+
return request.client.host if request.client else "unknown"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _set_session_cookie(resp: Response, token: str) -> None:
|
|
232
|
+
resp.set_cookie("wb_sid", token, httponly=True, samesite="lax", secure=True,
|
|
233
|
+
max_age=access.SESSION_TTL, path="/")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class AccessRequest(BaseModel):
|
|
237
|
+
email: str = ""
|
|
238
|
+
user: str = "" # 兼容浏览器缓存的旧页面
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class AccessVerify(BaseModel):
|
|
242
|
+
email: str = ""
|
|
243
|
+
user: str = "" # 兼容浏览器缓存的旧页面
|
|
244
|
+
code: str = ""
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class MasterRequest(BaseModel):
|
|
248
|
+
password: str = ""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@app.middleware("http")
|
|
252
|
+
async def access_guard(request: Request, call_next):
|
|
253
|
+
"""旧的 ?k= 免密链接与 HTTP Basic 全部作废,只认服务端签发的随机 session token。
|
|
254
|
+
|
|
255
|
+
页面路径 302 到申请页;/api/* 返回 401 JSON,避免前端 fetch 拿到一坨 HTML。
|
|
256
|
+
/files /img /refs 三个图片挂载同样受保护,不对外裸奔。
|
|
257
|
+
"""
|
|
258
|
+
path = request.url.path.rstrip("/") or "/"
|
|
259
|
+
if not AUTH or path == "/health" or path in PUBLIC_ACCESS_PATHS:
|
|
260
|
+
return await call_next(request)
|
|
261
|
+
if access.check_session(request.cookies.get("wb_sid", "")):
|
|
262
|
+
return await call_next(request)
|
|
263
|
+
if path.startswith("/api/"):
|
|
264
|
+
return JSONResponse({"detail": "auth required", "redirect": "/access"}, status_code=401)
|
|
265
|
+
return RedirectResponse("/access", status_code=302)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class GenRequest(BaseModel):
|
|
269
|
+
mode: str = Field(default="image")
|
|
270
|
+
topic: str = Field(min_length=1, max_length=2000)
|
|
271
|
+
title: str | None = None
|
|
272
|
+
body: str | None = None
|
|
273
|
+
# 整篇正文(左侧「正文」框),只为历史「载入」还原,不参与拼提示词
|
|
274
|
+
doc_body: str | None = Field(default=None, max_length=20000)
|
|
275
|
+
# 这一批里的提交序号,0 起;并行出图完成顺序会乱,靠它还原清单顺序
|
|
276
|
+
seq: int | None = None
|
|
277
|
+
style_id: str | None = None
|
|
278
|
+
layout_id: str | None = None
|
|
279
|
+
color_id: str | None = None
|
|
280
|
+
ratio: str = "3:4"
|
|
281
|
+
note: str | None = None
|
|
282
|
+
# 整组主题(左侧「标题」),落进历史方便回看
|
|
283
|
+
group: str | None = None
|
|
284
|
+
# 一次批量出图共用一个 task_id,历史按它分组
|
|
285
|
+
task_id: str | None = None
|
|
286
|
+
# 前端拼装器生成好的完整提示词;给了就直接用,不再走 build_image_prompt
|
|
287
|
+
prompt: str | None = Field(default=None, max_length=8000)
|
|
288
|
+
# 参考图(/api/ref 上传拿到的 id),给了就随提示词一起传给 ChatGPT
|
|
289
|
+
ref_id: str | None = Field(default=None, max_length=32)
|
|
290
|
+
# 出图模式与拍摄类型,只用于历史回看/载入还原,不参与拼提示词(提示词前端已拼好)
|
|
291
|
+
image_mode: str | None = Field(default=None, max_length=16)
|
|
292
|
+
shoot_type: str | None = Field(default=None, max_length=40)
|
|
293
|
+
# 单张重做:新图成功后删掉这条旧记录,实现同一任务内原位替换
|
|
294
|
+
replace_id: str | None = Field(default=None, max_length=32)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class ZipRequest(BaseModel):
|
|
298
|
+
# 当前批次的 job id(展示顺序),服务端按这个顺序命名 01 02 03…
|
|
299
|
+
ids: list[str] = Field(default_factory=list, max_length=100)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class RefRequest(BaseModel):
|
|
303
|
+
# 前端已经压到 1400px 内,这里只做落盘;dataURL 或裸 base64 都收
|
|
304
|
+
data: str = Field(max_length=24_000_000)
|
|
305
|
+
name: str | None = Field(default=None, max_length=120)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class ComposeItem(BaseModel):
|
|
309
|
+
title: str = ""
|
|
310
|
+
body: str = ""
|
|
311
|
+
scene: str = ""
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class ComposeRequest(BaseModel):
|
|
315
|
+
style_id: str | None = None
|
|
316
|
+
layout_id: str | None = None
|
|
317
|
+
color_id: str | None = None
|
|
318
|
+
ratio: str = "3:4"
|
|
319
|
+
mood: str | None = None
|
|
320
|
+
fusion: bool = False
|
|
321
|
+
whitespace: str = "normal"
|
|
322
|
+
note: str | None = None
|
|
323
|
+
topic: str | None = None
|
|
324
|
+
ref: bool = False
|
|
325
|
+
# 出图模式:graphic-text(默认,图文)| photo(摄影);摄影可带拍摄类型
|
|
326
|
+
image_mode: str | None = None
|
|
327
|
+
shoot_type: str | None = None
|
|
328
|
+
# 手改过的出图指令:给了就用它,不再自动拼;allow_blank:允许空标题/正文纯指令出图
|
|
329
|
+
instruction: str | None = None
|
|
330
|
+
allow_blank: bool = False
|
|
331
|
+
items: list[ComposeItem] = Field(default_factory=list)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _find(items: list[dict], item_id: str | None) -> dict | None:
|
|
335
|
+
if not item_id:
|
|
336
|
+
return None
|
|
337
|
+
return next((x for x in items if x["id"] == item_id), None)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def build_image_prompt(req: GenRequest) -> str:
|
|
341
|
+
if req.prompt and req.prompt.strip():
|
|
342
|
+
return req.prompt.strip()
|
|
343
|
+
style = _find(CATALOG["styles"], req.style_id)
|
|
344
|
+
layout = _find(CATALOG["layouts"], req.layout_id)
|
|
345
|
+
color = _find(CATALOG["colors"], req.color_id)
|
|
346
|
+
parts: list[str] = []
|
|
347
|
+
if req.ref_id:
|
|
348
|
+
parts.append(composer.REF_FUSION)
|
|
349
|
+
if layout:
|
|
350
|
+
parts.append(f"排版图型:{layout['prompt']}")
|
|
351
|
+
if color:
|
|
352
|
+
parts.append(color["prompt"])
|
|
353
|
+
if style:
|
|
354
|
+
parts.append(f"画风:{style['name_en']}。核心视觉特征:{style['features']}")
|
|
355
|
+
parts.append(f"主题:{req.topic.strip()}")
|
|
356
|
+
# 2.35:1 是微信公众号首图(900×383),不是小红书图文的口径,单独说清楚
|
|
357
|
+
if (req.ratio or "").strip() == "2.35:1":
|
|
358
|
+
parts.append("画面比例:2.35:1 公众号首图横幅,主体与标题压在居中横向安全区内,"
|
|
359
|
+
"上下各留一段可裁的次要背景。")
|
|
360
|
+
else:
|
|
361
|
+
parts.append(f"画面比例:{req.ratio},适合小红书图文排版。")
|
|
362
|
+
if req.note:
|
|
363
|
+
parts.append(f"补充要求:{req.note.strip()}")
|
|
364
|
+
parts.append("请立即生成图片,不要反问,不要输出任何解释文字。")
|
|
365
|
+
return "\n".join(parts)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
_LIST_INTENT_RE = re.compile(
|
|
369
|
+
r"(?:\d+\s*(?:条|张|个|则)\s*(?:适合)?(?:生成)?(?:贴图|分镜|卡片|清单|文案))"
|
|
370
|
+
r"|(?:贴图|分镜|卡片|清单|文案)[^\n]{0,12}\d+\s*条"
|
|
371
|
+
r"|01\s*[||]"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def build_text_prompt(req: GenRequest) -> str:
|
|
376
|
+
"""普通选题走小红书笔记模板;选题明确要「N 条贴图/分镜文案」或给了 01| 示例时,
|
|
377
|
+
改走编号清单模板,避免 AI 输出「开场导语 + 编号条目 + 结尾标签」的混合格式
|
|
378
|
+
(那种格式前端拆分只能识别成 1 条)。"""
|
|
379
|
+
topic = req.topic.strip()
|
|
380
|
+
if _LIST_INTENT_RE.search(topic):
|
|
381
|
+
m = re.search(r"(\d+)\s*条", topic)
|
|
382
|
+
n = min(max(int(m.group(1)) if m else 8, 2), 20)
|
|
383
|
+
return (
|
|
384
|
+
f"你是贴图分镜文案写手。围绕选题「{topic}」,直接输出成品,不要任何解释。\n"
|
|
385
|
+
f"第一行:整组标题(20字以内,带情绪钩子,可用一个emoji)。\n"
|
|
386
|
+
f"标题之后空一行,然后严格输出 {n} 条贴图文案,编号从 01 连续到 {n:02d}。\n"
|
|
387
|
+
"每一条占两行:第一行是两位编号、一个竖线、再加小标题(小标题 8 字以内),"
|
|
388
|
+
"第二行是这一条的正文(60到100字,口语化,写具体场景和细节,不要空话)。\n"
|
|
389
|
+
f"条与条之间用一个空行隔开,全文一共 {n} 个编号行。\n"
|
|
390
|
+
"硬性要求:不要开场导语、不要结尾总结、不要话题标签、不要 markdown 标记(不要 ** 和 #)、"
|
|
391
|
+
"不要写「以下是」「最后想告诉你」这类客套话;正文里不要出现新的编号行,"
|
|
392
|
+
"也不要把上面这些格式说明当成内容输出。"
|
|
393
|
+
)
|
|
394
|
+
return (
|
|
395
|
+
f"你是小红书爆款笔记写手。围绕选题「{req.topic.strip()}」,直接输出成品,不要任何解释:\n"
|
|
396
|
+
"第一行:标题(20字以内,带情绪钩子,可用一个emoji)\n"
|
|
397
|
+
"空行后:正文 300-500 字,口语化、分段、适量emoji,有具体细节不要空话\n"
|
|
398
|
+
"空行后:5 个 #话题标签\n"
|
|
399
|
+
"不要写“以下是”“希望对你有帮助”这类客套话。"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def build_polish_prompt(req: GenRequest) -> str:
|
|
404
|
+
"""Polish the user's edited title/body in place (no new facts)."""
|
|
405
|
+
title = (req.title or "").strip()
|
|
406
|
+
body = (req.body or "").strip()[:4000]
|
|
407
|
+
return (
|
|
408
|
+
"你是小红书文案编辑。请润色下面这篇笔记:保持原意和事实,不换主题,"
|
|
409
|
+
"提升口语感、细节密度和情绪钩子,删掉空话。直接输出成品,不要任何解释:\n"
|
|
410
|
+
"第一行:润色后的标题(20字以内,可用一个emoji)\n"
|
|
411
|
+
"空行后:润色后的正文(保留分段,300-500字,适量emoji)\n"
|
|
412
|
+
"空行后:5 个 #话题标签\n"
|
|
413
|
+
f"原标题:{title or '(无)'}\n"
|
|
414
|
+
f"原正文:\n{body or '(空,请围绕标题补写一篇完整笔记)'}"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@app.get("/health")
|
|
419
|
+
async def health():
|
|
420
|
+
return {"ok": True, "version": VERSION}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@app.get("/api/catalog")
|
|
424
|
+
async def api_catalog():
|
|
425
|
+
return JSONResponse(CATALOG)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@app.get("/api/pool")
|
|
429
|
+
async def api_pool():
|
|
430
|
+
# 前端每 10 秒轮询一次,顺手在这里做离线自愈:号池后起也能自己从「离线」翻成在线
|
|
431
|
+
await POOL.ensure_online()
|
|
432
|
+
return JSONResponse(POOL.snapshot())
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@app.post("/api/pool/refresh")
|
|
436
|
+
async def api_pool_refresh():
|
|
437
|
+
await POOL.refresh_env_ids()
|
|
438
|
+
return JSONResponse(POOL.snapshot())
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@app.get("/api/pool/fails")
|
|
442
|
+
async def api_pool_fails(days: int = 7):
|
|
443
|
+
"""账号失败账本:今天每个号失败几次、最近几天各失败几次。
|
|
444
|
+
|
|
445
|
+
失败不会让任务中断(自动换号重试),但账必须留痕,用户要能核对。
|
|
446
|
+
"""
|
|
447
|
+
days = max(1, min(30, int(days or 7)))
|
|
448
|
+
try:
|
|
449
|
+
ledger = json.loads(pool_mod.FAIL_LEDGER.read_text(encoding="utf-8"))
|
|
450
|
+
except Exception:
|
|
451
|
+
ledger = {}
|
|
452
|
+
all_days = ledger.get("days") or {}
|
|
453
|
+
last = ledger.get("last") or {}
|
|
454
|
+
order = sorted(all_days.keys(), reverse=True)[:days]
|
|
455
|
+
rows = []
|
|
456
|
+
for email, n in sorted((all_days.get(time.strftime("%Y-%m-%d")) or {}).items(),
|
|
457
|
+
key=lambda kv: -int(kv[1])):
|
|
458
|
+
info = last.get(email) or {}
|
|
459
|
+
rows.append({"account": pool_mod.mask_email(email) if "@" in email else "未知账号",
|
|
460
|
+
"account_email": email, "today": int(n),
|
|
461
|
+
"error": str(info.get("error") or ""), "at": int(info.get("at") or 0)})
|
|
462
|
+
return JSONResponse({
|
|
463
|
+
"today": time.strftime("%Y-%m-%d"),
|
|
464
|
+
"items": rows,
|
|
465
|
+
"total_today": sum(int(v) for v in (all_days.get(time.strftime("%Y-%m-%d")) or {}).values()),
|
|
466
|
+
"recent": [{"day": d, "total": sum(int(v) for v in (all_days.get(d) or {}).values())}
|
|
467
|
+
for d in order],
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@app.post("/api/pool/reset")
|
|
472
|
+
async def api_pool_reset():
|
|
473
|
+
POOL.reset_statuses()
|
|
474
|
+
await POOL.refresh_env_ids()
|
|
475
|
+
return JSONResponse(POOL.snapshot())
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ---------- 号池管理(「接口与号池」面板里用) ----------
|
|
479
|
+
@app.get("/api/pool/panel")
|
|
480
|
+
async def api_pool_panel():
|
|
481
|
+
return JSONResponse(await pool_admin.panel())
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@app.post("/api/pool/accounts/import")
|
|
485
|
+
async def api_pool_import(body: dict):
|
|
486
|
+
res = await pool_admin.import_accounts(str(body.get("text") or ""))
|
|
487
|
+
if res.get("ok"):
|
|
488
|
+
# 新号立刻进轮转:页头和监控不用等下一轮后台同步
|
|
489
|
+
await POOL.refresh_accounts(force=True)
|
|
490
|
+
pool_mod.log(f"号池导入:{res.get('added', 0)} 个新增 / {res.get('skipped', 0)} 个已存在")
|
|
491
|
+
return JSONResponse(res)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@app.post("/api/pool/accounts/delete")
|
|
495
|
+
async def api_pool_delete(body: dict):
|
|
496
|
+
ids = body.get("ids") or []
|
|
497
|
+
if not isinstance(ids, list) or not ids:
|
|
498
|
+
raise HTTPException(400, "没选中账号")
|
|
499
|
+
res = await pool_admin.delete_accounts([str(i) for i in ids])
|
|
500
|
+
if res.get("ok"):
|
|
501
|
+
await POOL.refresh_accounts(force=True)
|
|
502
|
+
return JSONResponse(res)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@app.post("/api/pool/accounts/status")
|
|
506
|
+
async def api_pool_account_status(body: dict):
|
|
507
|
+
ids = body.get("ids") or []
|
|
508
|
+
if not isinstance(ids, list) or not ids:
|
|
509
|
+
raise HTTPException(400, "没选中账号")
|
|
510
|
+
res = await pool_admin.set_status([str(i) for i in ids], bool(body.get("disabled")))
|
|
511
|
+
if res.get("ok"):
|
|
512
|
+
await POOL.refresh_accounts(force=True)
|
|
513
|
+
return JSONResponse(res)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@app.post("/api/pool/key/ensure")
|
|
517
|
+
async def api_pool_key(body: dict | None = None):
|
|
518
|
+
"""取(或新建)一把号池用户 Key,供本页复制出去接别的工具。"""
|
|
519
|
+
body = body or {}
|
|
520
|
+
return JSONResponse(await pool_admin.ensure_key(force_new=bool(body.get("new"))))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ---------- 号池出口代理(受限网络必填,默认直连) ----------
|
|
524
|
+
@app.get("/api/pool/proxy")
|
|
525
|
+
async def api_pool_proxy():
|
|
526
|
+
"""读取本机号池的出口代理配置(admin 透传,浏览器不碰 key)。"""
|
|
527
|
+
res = await pool_admin.get_pool_proxy()
|
|
528
|
+
# 顺带回传工作台侧记录的「自动探测」标记,让前端区分手动/自动
|
|
529
|
+
try:
|
|
530
|
+
home = pool_service.view()["home"]
|
|
531
|
+
res["proxy_auto_url"] = pool_service._read_state(home).get("proxy_auto_url") or ""
|
|
532
|
+
except Exception:
|
|
533
|
+
res["proxy_auto_url"] = ""
|
|
534
|
+
return JSONResponse(res)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@app.get("/api/pool/egress")
|
|
538
|
+
async def api_pool_egress(fresh: int = 0):
|
|
539
|
+
"""探测当前设备实际可用的出口代理(只读,不改号池配置)。
|
|
540
|
+
|
|
541
|
+
生图必须过代理,所以面板里这一栏永远展示本机真实情况:换电脑、换代理
|
|
542
|
+
端口,反显的值跟着变,不会停在某个写死的地址上。
|
|
543
|
+
"""
|
|
544
|
+
try:
|
|
545
|
+
data = await asyncio.to_thread(
|
|
546
|
+
pool_service.detect_egress, 0 if fresh else 20.0)
|
|
547
|
+
except Exception as e:
|
|
548
|
+
return JSONResponse({"ok": False, "msg": str(e)[:160]})
|
|
549
|
+
return JSONResponse(data)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@app.post("/api/pool/proxy")
|
|
553
|
+
async def api_pool_proxy_save(body: dict):
|
|
554
|
+
"""保存本机号池的出口代理配置。默认直连;受限网络由用户填自己的代理。"""
|
|
555
|
+
return JSONResponse(await pool_admin.set_pool_proxy(body or {}))
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@app.post("/api/pool/proxy/test")
|
|
559
|
+
async def api_pool_proxy_test(body: dict):
|
|
560
|
+
"""用号池已保存的代理测连通性。"""
|
|
561
|
+
return JSONResponse(await pool_admin.test_pool_proxy(str((body or {}).get("url", ""))))
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@app.post("/api/pool/accounts/refresh")
|
|
565
|
+
async def api_pool_accounts_refresh():
|
|
566
|
+
"""换出口代理后重算额度:让号池重新拉一遍每个账号的可用次数。"""
|
|
567
|
+
return JSONResponse(await pool_admin.refresh_accounts())
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@app.get("/api/pool/accounts/refresh/{progress_id}")
|
|
571
|
+
async def api_pool_accounts_refresh_progress(progress_id: str):
|
|
572
|
+
"""刷新进度:total / processed / done / status_counts / total_quota。"""
|
|
573
|
+
return JSONResponse(await pool_admin.refresh_progress(progress_id))
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# ---------- 工作台自带号池:进程生命周期 ----------
|
|
577
|
+
# 这些路由只在「当前接口指向本机号池」时生效;第三方接口与写作台 38650 一律不动。
|
|
578
|
+
# 启动 / 停止会等几十秒(首次还要复制运行时),全部丢到线程池,别堵住事件循环。
|
|
579
|
+
@app.get("/api/pool/service")
|
|
580
|
+
async def api_pool_service():
|
|
581
|
+
return JSONResponse(await run_in_threadpool(pool_service.status))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@app.post("/api/pool/service/start")
|
|
585
|
+
async def api_pool_service_start(body: dict | None = None):
|
|
586
|
+
body = body or {}
|
|
587
|
+
res = await run_in_threadpool(pool_service.start, bool(body.get("force")))
|
|
588
|
+
if res.get("ok"):
|
|
589
|
+
await POOL.refresh_accounts(force=True)
|
|
590
|
+
return JSONResponse(res)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@app.post("/api/pool/service/stop")
|
|
594
|
+
async def api_pool_service_stop():
|
|
595
|
+
return JSONResponse(await run_in_threadpool(pool_service.stop))
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@app.post("/api/pool/service/restart")
|
|
599
|
+
async def api_pool_service_restart():
|
|
600
|
+
res = await run_in_threadpool(pool_service.restart)
|
|
601
|
+
if res.get("ok"):
|
|
602
|
+
await POOL.refresh_accounts(force=True)
|
|
603
|
+
return JSONResponse(res)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@app.post("/api/pool/service/auto")
|
|
607
|
+
async def api_pool_service_auto(body: dict):
|
|
608
|
+
home = pool_service.view()["home"]
|
|
609
|
+
return JSONResponse(pool_service.set_auto_start(home, bool(body.get("enabled"))))
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@app.post("/api/pool/use-builtin")
|
|
613
|
+
async def api_pool_use_builtin():
|
|
614
|
+
"""一键切回本工作台自带的内置号池(38651)并启动它。
|
|
615
|
+
|
|
616
|
+
典型场景:出图接口曾指向外部号池 / 写作台号池(如 38650),想切回来。
|
|
617
|
+
只动 config.local.json 与内置池的停止标记,不碰外部号池的账号与进程。
|
|
618
|
+
注意必须同时把 api_key 对齐到内置池:只改 base_url 不换 key,会用旧外部
|
|
619
|
+
key 去打内置池的 /v1,结果 401,账号面板照样刷不出内置号。
|
|
620
|
+
"""
|
|
621
|
+
home = Path.home() / ".dsh" / "chatgpt2api-image"
|
|
622
|
+
builtin_url = "http://127.0.0.1:38651/v1"
|
|
623
|
+
# 读内置池自带的用户 Key,写进 config,确保接口密钥与内置池对齐
|
|
624
|
+
user_key = pool_mod._read_pool_user_key({"pool_dir": str(home)}) or ""
|
|
625
|
+
if not user_key:
|
|
626
|
+
try:
|
|
627
|
+
user_key = pool_service.ensure_admin_key(home) or ""
|
|
628
|
+
except Exception:
|
|
629
|
+
user_key = ""
|
|
630
|
+
cfg = {}
|
|
631
|
+
if POOL_CONFIG_FILE.exists():
|
|
632
|
+
try:
|
|
633
|
+
cfg = json.loads(POOL_CONFIG_FILE.read_text(encoding="utf-8"))
|
|
634
|
+
except Exception:
|
|
635
|
+
cfg = {}
|
|
636
|
+
cfg["api_base_url"] = builtin_url
|
|
637
|
+
cfg["pool_base_url"] = builtin_url
|
|
638
|
+
cfg["api_key"] = user_key
|
|
639
|
+
cfg["pool_dir"] = str(home)
|
|
640
|
+
cfg["monitor_mode"] = "auto"
|
|
641
|
+
cfg.setdefault("port", 8092)
|
|
642
|
+
POOL_CONFIG_FILE.write_text(
|
|
643
|
+
json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
644
|
+
# 清掉手动停止标记,让内置池能被启动 / 守护自动拉起
|
|
645
|
+
try:
|
|
646
|
+
(home / pool_service.STOP_FLAG).unlink(missing_ok=True)
|
|
647
|
+
except Exception:
|
|
648
|
+
pass
|
|
649
|
+
# 启动内置池(首次建运行时可能要几十秒,丢线程池不堵事件循环)
|
|
650
|
+
res = await run_in_threadpool(pool_service.start)
|
|
651
|
+
await POOL.refresh_env_ids()
|
|
652
|
+
await POOL.refresh_accounts(force=True)
|
|
653
|
+
return JSONResponse({
|
|
654
|
+
"ok": bool(res.get("ok", True)),
|
|
655
|
+
"started": bool(res.get("ok", True)),
|
|
656
|
+
"message": res.get("message", ""),
|
|
657
|
+
"config": _cfg_view(),
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# ---------- 接口设置(通用 OpenAI 兼容接口) ----------
|
|
662
|
+
def _cfg_view() -> dict:
|
|
663
|
+
"""当前接口配置的脱敏视图,绝不回传完整 API Key。"""
|
|
664
|
+
p = provider_cfg()
|
|
665
|
+
pool = (POOL.snapshot().get("pool") or {})
|
|
666
|
+
key = p["api_key"]
|
|
667
|
+
masked = ""
|
|
668
|
+
if key:
|
|
669
|
+
masked = (key[:6] + "***" + key[-4:]) if len(key) > 12 else "***"
|
|
670
|
+
return {
|
|
671
|
+
"app_name": (load_config().get("app_name") or "").strip() or DEFAULT_APP_NAME,
|
|
672
|
+
"app_name_default": DEFAULT_APP_NAME,
|
|
673
|
+
"provider_name": p["name"],
|
|
674
|
+
"base_url": p["base_url"],
|
|
675
|
+
"output_dir": str(out_dir()),
|
|
676
|
+
"output_dir_default": str(OUT_DIR),
|
|
677
|
+
"output_dir_is_default": str(out_dir()) == str(OUT_DIR),
|
|
678
|
+
"model_image": p["model_image"],
|
|
679
|
+
"model_text": p["model_text"],
|
|
680
|
+
"supports_edits": p["supports_edits"],
|
|
681
|
+
"edits_style": p["edits_style"],
|
|
682
|
+
"image_response_format": p["image_format"],
|
|
683
|
+
"monitor_mode": p["monitor_mode"],
|
|
684
|
+
"api_key_set": bool(key),
|
|
685
|
+
"api_key_masked": masked,
|
|
686
|
+
"is_pool": p["is_pool"],
|
|
687
|
+
"mode": pool.get("mode") or ("pool" if p["is_pool"] else "single"),
|
|
688
|
+
"pool_message": pool.get("message") or "",
|
|
689
|
+
"port": load_config().get("port", 8092),
|
|
690
|
+
"version": VERSION,
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@app.get("/api/config")
|
|
695
|
+
async def api_config_get():
|
|
696
|
+
return JSONResponse(_cfg_view())
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
@app.post("/api/config")
|
|
700
|
+
async def api_config_save(body: dict):
|
|
701
|
+
cfg: dict = {}
|
|
702
|
+
if POOL_CONFIG_FILE.exists():
|
|
703
|
+
try:
|
|
704
|
+
cfg = json.loads(POOL_CONFIG_FILE.read_text(encoding="utf-8"))
|
|
705
|
+
except Exception:
|
|
706
|
+
cfg = {}
|
|
707
|
+
|
|
708
|
+
def put(k: str, v) -> None:
|
|
709
|
+
cfg[k] = v
|
|
710
|
+
|
|
711
|
+
if "app_name" in body:
|
|
712
|
+
# 只影响显示名:留空 = 回到内置默认名
|
|
713
|
+
put("app_name", str(body["app_name"] or "").strip()[:40])
|
|
714
|
+
if "provider_name" in body:
|
|
715
|
+
put("provider_name", str(body["provider_name"]).strip() or "生图接口")
|
|
716
|
+
if "base_url" in body:
|
|
717
|
+
b = norm_base_url(str(body["base_url"]))
|
|
718
|
+
put("api_base_url", b)
|
|
719
|
+
put("pool_base_url", b) # 兼容旧字段
|
|
720
|
+
if "pool_dir" in body:
|
|
721
|
+
# 号池数据目录:决定 admin-key / 用户 Key 从哪读。只接受真实存在的目录
|
|
722
|
+
raw = str(body["pool_dir"] or "").strip()
|
|
723
|
+
if raw and Path(raw).is_dir():
|
|
724
|
+
put("pool_dir", str(Path(raw)))
|
|
725
|
+
if "output_dir" in body:
|
|
726
|
+
raw = str(body["output_dir"] or "").strip()
|
|
727
|
+
old = str(cfg.get("output_dir") or "")
|
|
728
|
+
if raw:
|
|
729
|
+
d = Path(os.path.expandvars(os.path.expanduser(raw)))
|
|
730
|
+
if not d.is_absolute():
|
|
731
|
+
d = LAB / d
|
|
732
|
+
try:
|
|
733
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
734
|
+
probe = d / ".wb_write_test"
|
|
735
|
+
probe.write_bytes(b"ok")
|
|
736
|
+
probe.unlink()
|
|
737
|
+
except Exception as e:
|
|
738
|
+
raise HTTPException(400, f"保存目录不可用:{d}({e})")
|
|
739
|
+
put("output_dir", str(d))
|
|
740
|
+
else:
|
|
741
|
+
put("output_dir", "") # 留空 = 回到默认 images/
|
|
742
|
+
# 记下用过的旧目录:换走之后,老图在历史里还得打得开
|
|
743
|
+
seen = [str(s) for s in (cfg.get("output_dirs_seen") or []) if str(s).strip()]
|
|
744
|
+
if old and Path(old) != OUT_DIR and old not in seen:
|
|
745
|
+
seen.append(old)
|
|
746
|
+
new = str(cfg.get("output_dir") or "")
|
|
747
|
+
if new and Path(new) != OUT_DIR and new not in seen:
|
|
748
|
+
seen.append(new)
|
|
749
|
+
put("output_dirs_seen", seen[-8:])
|
|
750
|
+
if body.get("api_key"): # 留空表示不改,保留原密钥
|
|
751
|
+
put("api_key", str(body["api_key"]).strip())
|
|
752
|
+
if "model_image" in body:
|
|
753
|
+
put("model_image", str(body["model_image"]).strip() or "gpt-image-2")
|
|
754
|
+
if "model_text" in body:
|
|
755
|
+
put("model_text", str(body["model_text"]).strip() or "auto")
|
|
756
|
+
if "supports_edits" in body:
|
|
757
|
+
put("supports_edits", bool(body["supports_edits"]))
|
|
758
|
+
if "edits_style" in body:
|
|
759
|
+
put("edits_style", str(body["edits_style"]).strip() or "auto")
|
|
760
|
+
if "image_response_format" in body:
|
|
761
|
+
put("image_response_format", str(body["image_response_format"]).strip() or "auto")
|
|
762
|
+
if "monitor_mode" in body:
|
|
763
|
+
put("monitor_mode", str(body["monitor_mode"]).strip() or "auto")
|
|
764
|
+
cfg.setdefault("port", 8092)
|
|
765
|
+
POOL_CONFIG_FILE.write_text(
|
|
766
|
+
json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
767
|
+
# 立即重探,让前端保存后马上看到新状态
|
|
768
|
+
await POOL.refresh_env_ids()
|
|
769
|
+
await POOL.refresh_accounts(force=True)
|
|
770
|
+
return JSONResponse(_cfg_view())
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
@app.post("/api/config/test")
|
|
774
|
+
async def api_config_test(body: dict):
|
|
775
|
+
"""按提交的临时配置试连(也可空 body 用当前配置),不落盘。"""
|
|
776
|
+
return JSONResponse(await test_connection(body or {}))
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
@app.post("/api/config/open-dir")
|
|
780
|
+
async def api_config_open_dir(request: Request):
|
|
781
|
+
"""在运行本服务的电脑上打开保存目录。
|
|
782
|
+
|
|
783
|
+
只有本机浏览器能用(远端访问时打开的是服务器上的资源管理器,没有意义),
|
|
784
|
+
远端就直接把路径返回去,让用户自己复制。
|
|
785
|
+
"""
|
|
786
|
+
d = out_dir()
|
|
787
|
+
if _client_ip(request) not in ("127.0.0.1", "::1", "localhost"):
|
|
788
|
+
return JSONResponse({"ok": False, "path": str(d),
|
|
789
|
+
"message": "只有本机访问时能直接打开目录,请复制路径到资源管理器"})
|
|
790
|
+
try:
|
|
791
|
+
if sys.platform.startswith("win"):
|
|
792
|
+
os.startfile(str(d)) # noqa: S606 - 本机打开文件夹
|
|
793
|
+
elif sys.platform == "darwin":
|
|
794
|
+
subprocess.Popen(["open", str(d)])
|
|
795
|
+
else:
|
|
796
|
+
subprocess.Popen(["xdg-open", str(d)])
|
|
797
|
+
return JSONResponse({"ok": True, "path": str(d)})
|
|
798
|
+
except Exception as e:
|
|
799
|
+
return JSONResponse({"ok": False, "path": str(d), "message": f"打开失败:{e}"})
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
@app.post("/api/compose")
|
|
803
|
+
async def api_compose(req: ComposeRequest):
|
|
804
|
+
"""把风格 + 主题色 + 图型 + 融合 + 每条文字,拼成出图指令与完整提示词。"""
|
|
805
|
+
payload = {
|
|
806
|
+
"style_id": req.style_id,
|
|
807
|
+
"layout_id": req.layout_id,
|
|
808
|
+
"color_id": req.color_id,
|
|
809
|
+
"ratio": req.ratio,
|
|
810
|
+
"mood": req.mood,
|
|
811
|
+
"fusion": req.fusion,
|
|
812
|
+
"whitespace": req.whitespace,
|
|
813
|
+
"note": req.note,
|
|
814
|
+
"topic": req.topic,
|
|
815
|
+
"ref": req.ref,
|
|
816
|
+
"image_mode": req.image_mode,
|
|
817
|
+
"shoot_type": req.shoot_type,
|
|
818
|
+
"instruction": req.instruction,
|
|
819
|
+
"allow_blank": req.allow_blank,
|
|
820
|
+
"items": [{"title": it.title, "body": it.body, "scene": it.scene} for it in req.items],
|
|
821
|
+
}
|
|
822
|
+
return JSONResponse(composer.compose(payload, CATALOG["styles"], CATALOG["layouts"], CATALOG["colors"]))
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@app.post("/api/preview")
|
|
826
|
+
async def api_preview(req: GenRequest):
|
|
827
|
+
if req.mode == "text":
|
|
828
|
+
prompt = build_text_prompt(req)
|
|
829
|
+
elif req.mode == "polish":
|
|
830
|
+
prompt = build_polish_prompt(req)
|
|
831
|
+
else:
|
|
832
|
+
prompt = build_image_prompt(req)
|
|
833
|
+
return {"prompt": prompt}
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
@app.post("/api/generate")
|
|
837
|
+
async def api_generate(req: GenRequest):
|
|
838
|
+
rid = (req.replace_id or "").strip()
|
|
839
|
+
if rid and not re.fullmatch(r"[0-9a-z]{6,32}", rid):
|
|
840
|
+
raise HTTPException(400, "bad replace_id")
|
|
841
|
+
if req.mode not in ("text", "image", "polish"):
|
|
842
|
+
raise HTTPException(400, "mode must be text, image or polish")
|
|
843
|
+
# 号池未就绪:单接口模式本就不拉账号,放行(由真实请求暴露错误);
|
|
844
|
+
# 号池 / auto 模式账号为空时,给出明确指引而非笼统的「pool is empty」。
|
|
845
|
+
if not POOL.accounts and provider_cfg().get("monitor_mode") != "single":
|
|
846
|
+
cfg = load_config()
|
|
847
|
+
base = provider_cfg(cfg).get("base_url") or ""
|
|
848
|
+
user_set = bool(cfg.get("api_base_url"))
|
|
849
|
+
if not base:
|
|
850
|
+
raise HTTPException(
|
|
851
|
+
400,
|
|
852
|
+
"尚未配置生图号池。请在「接口与号池」面板填写号池地址与 API Key 后再出图。")
|
|
853
|
+
if not user_set:
|
|
854
|
+
raise HTTPException(
|
|
855
|
+
400,
|
|
856
|
+
"尚未配置生图号池:当前仍指向内置空号池。请在「接口与号池」面板填写你自己的号池地址"
|
|
857
|
+
"(例如 http://127.0.0.1:38650/v1)与 API Key,或先启动内置号池并导入账号。")
|
|
858
|
+
raise HTTPException(
|
|
859
|
+
400,
|
|
860
|
+
f"号池 {base} 已配置但暂无可用账号。请确认号池进程已启动并已导入账号;"
|
|
861
|
+
"可在「接口与号池」面板点「测试」验证连通性。")
|
|
862
|
+
if req.mode == "text":
|
|
863
|
+
prompt = build_text_prompt(req)
|
|
864
|
+
elif req.mode == "polish":
|
|
865
|
+
prompt = build_polish_prompt(req)
|
|
866
|
+
else:
|
|
867
|
+
prompt = build_image_prompt(req)
|
|
868
|
+
ref_path = ""
|
|
869
|
+
if req.mode == "image" and req.ref_id:
|
|
870
|
+
rid = req.ref_id.strip()
|
|
871
|
+
# 只认我们自己生成的 hex id,避免路径穿越
|
|
872
|
+
if not re.fullmatch(r"[0-9a-f]{4,32}", rid):
|
|
873
|
+
raise HTTPException(400, "bad ref_id")
|
|
874
|
+
p = REFS_DIR / f"{rid}.png"
|
|
875
|
+
if not p.is_file():
|
|
876
|
+
raise HTTPException(404, "reference image not found")
|
|
877
|
+
ref_path = str(p)
|
|
878
|
+
job = {
|
|
879
|
+
"id": uuid.uuid4().hex[:12],
|
|
880
|
+
"task_id": (req.task_id or "").strip()[:40] or uuid.uuid4().hex[:10],
|
|
881
|
+
"mode": req.mode,
|
|
882
|
+
"topic": req.topic.strip(),
|
|
883
|
+
"title": (req.title or req.topic).strip(),
|
|
884
|
+
"body": (req.body or "").strip(),
|
|
885
|
+
"doc_body": (req.doc_body or "").strip(),
|
|
886
|
+
"seq": req.seq,
|
|
887
|
+
"group": (req.group or "").strip(),
|
|
888
|
+
"style_id": req.style_id,
|
|
889
|
+
"layout_id": req.layout_id,
|
|
890
|
+
"color_id": req.color_id,
|
|
891
|
+
"ratio": req.ratio,
|
|
892
|
+
"ref_id": (req.ref_id or "") if ref_path else "",
|
|
893
|
+
"ref_path": ref_path,
|
|
894
|
+
"image_mode": (req.image_mode or "graphic-text"),
|
|
895
|
+
"shoot_type": (req.shoot_type or ""),
|
|
896
|
+
"replace_id": rid,
|
|
897
|
+
"prompt": prompt,
|
|
898
|
+
"status": "queued",
|
|
899
|
+
"created": int(time.time()),
|
|
900
|
+
"account": "",
|
|
901
|
+
}
|
|
902
|
+
await POOL.submit(job)
|
|
903
|
+
return {"job_id": job["id"], "status": job["status"]}
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
@app.post("/api/generate/stop")
|
|
907
|
+
async def api_generate_stop():
|
|
908
|
+
'''停止当前生图任务:中断在途请求、清空排队、阻止换号重试。'''
|
|
909
|
+
return JSONResponse(await POOL.cancel_all())
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@app.post("/api/zip")
|
|
913
|
+
async def api_zip(req: ZipRequest):
|
|
914
|
+
"""把一批 job 的图片按展示顺序打成 zip;重启后从历史回退,仍是原图。"""
|
|
915
|
+
import io
|
|
916
|
+
import zipfile
|
|
917
|
+
|
|
918
|
+
buf = io.BytesIO()
|
|
919
|
+
used: set[str] = set()
|
|
920
|
+
packed = 0
|
|
921
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as z:
|
|
922
|
+
for idx, jid in enumerate(req.ids, 1):
|
|
923
|
+
if not re.fullmatch(r"[0-9a-f]{4,32}", jid or ""):
|
|
924
|
+
continue
|
|
925
|
+
# 服务重启后 POOL.jobs 会清空,但历史里还记着图片文件名,退回历史照打不误
|
|
926
|
+
job = POOL.jobs.get(jid)
|
|
927
|
+
rec = HISTORY.get(jid) if not job else None
|
|
928
|
+
img = ((job.get("result") or {}).get("image") if job else None) or (rec or {}).get("image")
|
|
929
|
+
if not img:
|
|
930
|
+
continue
|
|
931
|
+
p = image_path(img)
|
|
932
|
+
if not p:
|
|
933
|
+
continue
|
|
934
|
+
title = str(((job.get("title") or job.get("topic")) if job else
|
|
935
|
+
((rec or {}).get("title") or (rec or {}).get("topic"))) or "").strip()
|
|
936
|
+
safe = re.sub(r'[\\/:*?"<>|\s]+', "_", title)[:40].strip("_") or "card"
|
|
937
|
+
name = f"{idx:02d}-{safe}{p.suffix or '.png'}"
|
|
938
|
+
k = 2
|
|
939
|
+
while name in used:
|
|
940
|
+
name = f"{idx:02d}-{safe}-{k}{p.suffix or '.png'}"
|
|
941
|
+
k += 1
|
|
942
|
+
used.add(name)
|
|
943
|
+
ratio = (job.get("ratio") if job else (rec or {}).get("ratio"))
|
|
944
|
+
z.writestr(name, imaging.export_bytes(THUMB_DIR, jid, p, ratio))
|
|
945
|
+
packed += 1
|
|
946
|
+
if not packed:
|
|
947
|
+
raise HTTPException(404, "no downloadable images in this batch")
|
|
948
|
+
buf.seek(0)
|
|
949
|
+
return Response(
|
|
950
|
+
content=buf.getvalue(),
|
|
951
|
+
media_type="application/zip",
|
|
952
|
+
headers={"Content-Disposition": 'attachment; filename="task-images.zip"'},
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
@app.get("/api/jobs/{job_id}")
|
|
957
|
+
async def api_job(job_id: str):
|
|
958
|
+
"""查不到内存 job 时退回历史记录,服务重启后轮询与预览 URL 照样可用。"""
|
|
959
|
+
job = POOL.jobs.get(job_id)
|
|
960
|
+
if not job:
|
|
961
|
+
rec = HISTORY.get(job_id)
|
|
962
|
+
if not rec:
|
|
963
|
+
raise HTTPException(404, "job not found")
|
|
964
|
+
img = str(rec.get("image") or "")
|
|
965
|
+
job = {
|
|
966
|
+
"id": job_id, "status": rec.get("status") or "done",
|
|
967
|
+
"topic": rec.get("topic") or "", "title": rec.get("title") or "",
|
|
968
|
+
"account": rec.get("account") or "", "created": rec.get("created"),
|
|
969
|
+
"finished": rec.get("finished"), "error": rec.get("error") or "",
|
|
970
|
+
"ratio": rec.get("ratio"),
|
|
971
|
+
# 失败换号的轨迹要能跨重启还在,否则刷新一下就看不出是谁失败了
|
|
972
|
+
"attempts": rec.get("attempts") or [],
|
|
973
|
+
"account_email": rec.get("account_email") or "",
|
|
974
|
+
"result": ({"image": img, "width": rec.get("width"),
|
|
975
|
+
"height": rec.get("height"), "bytes": rec.get("bytes")}
|
|
976
|
+
if img else {}),
|
|
977
|
+
}
|
|
978
|
+
payload = dict(job)
|
|
979
|
+
result = payload.get("result") or {}
|
|
980
|
+
if result.get("image"):
|
|
981
|
+
raw = f"/files/{result['image']}"
|
|
982
|
+
payload["raw_url"] = raw
|
|
983
|
+
# 2.35:1 公众号首图:轮询拿到的就是裁好的成品,前端各处不用再自己判断比例
|
|
984
|
+
payload["image_url"] = (f"/api/history/{job_id}/final"
|
|
985
|
+
if imaging.needs_crop(payload.get("ratio")) else raw)
|
|
986
|
+
payload["preview_url"] = f"/api/history/{job_id}/preview"
|
|
987
|
+
return JSONResponse(payload)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
@app.get("/api/recent")
|
|
991
|
+
async def api_recent():
|
|
992
|
+
jobs = sorted(POOL.jobs.values(), key=lambda j: j["created"], reverse=True)[:20]
|
|
993
|
+
out = []
|
|
994
|
+
for j in jobs:
|
|
995
|
+
result = j.get("result") or {}
|
|
996
|
+
img = result.get("image")
|
|
997
|
+
out.append({
|
|
998
|
+
"id": j["id"], "mode": j["mode"], "status": j["status"], "topic": j["topic"],
|
|
999
|
+
"account": j.get("account", ""), "created": j["created"],
|
|
1000
|
+
"image_url": ((f"/api/history/{j['id']}/final" if imaging.needs_crop(j.get("ratio"))
|
|
1001
|
+
else f"/files/{img}") if img else None),
|
|
1002
|
+
"error": j.get("error", ""),
|
|
1003
|
+
})
|
|
1004
|
+
return JSONResponse(out)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
@app.get("/api/history")
|
|
1008
|
+
async def api_history(limit: int = 24, offset: int = 0, q: str = "", status: str = ""):
|
|
1009
|
+
return JSONResponse(HISTORY.list(limit=limit, offset=offset, q=q, status=status))
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
@app.get("/api/tasks")
|
|
1013
|
+
async def api_tasks(limit: int = 20, offset: int = 0, q: str = ""):
|
|
1014
|
+
"""历史按「一次批量出图」分组,默认一页 20 个任务。"""
|
|
1015
|
+
return JSONResponse(HISTORY.tasks(limit=limit, offset=offset, q=q))
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
@app.get("/api/tasks/{task_id}")
|
|
1019
|
+
async def api_task_detail(task_id: str):
|
|
1020
|
+
"""载入历史任务:带提示词全文 + 当时选的图型/风格/主题色/比例。"""
|
|
1021
|
+
d = HISTORY.task_detail(task_id)
|
|
1022
|
+
if not d:
|
|
1023
|
+
raise HTTPException(404, "task not found")
|
|
1024
|
+
return JSONResponse(d)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _plain_text(s: str) -> str:
|
|
1028
|
+
"""把可能的 markdown 痕迹洗成纯文本:去代码围栏、# 标题、加粗、列表符、引用、链接壳。"""
|
|
1029
|
+
s = str(s or "")
|
|
1030
|
+
s = re.sub(r"```+[a-zA-Z0-9_-]*", "", s)
|
|
1031
|
+
s = re.sub(r"^\s{0,3}#{1,6}\s*", "", s, flags=re.M)
|
|
1032
|
+
s = re.sub(r"\*\*(.+?)\*\*", r"\1", s, flags=re.S)
|
|
1033
|
+
s = re.sub(r"__(.+?)__", r"\1", s, flags=re.S)
|
|
1034
|
+
s = re.sub(r"^\s*[-*+·]\s+", "", s, flags=re.M)
|
|
1035
|
+
s = re.sub(r"^\s*>\s?", "", s, flags=re.M)
|
|
1036
|
+
s = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", s)
|
|
1037
|
+
s = re.sub(r"`([^`]*)`", r"\1", s)
|
|
1038
|
+
return s.strip()
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
@app.get("/api/tasks/{task_id}/bundle")
|
|
1042
|
+
async def api_task_bundle(task_id: str):
|
|
1043
|
+
"""整任务打包下载:所有原图 + 文案.txt(标题 + 正文,纯文本不带 markdown 标记)。"""
|
|
1044
|
+
data = HISTORY.bundle_data(task_id)
|
|
1045
|
+
if not data:
|
|
1046
|
+
raise HTTPException(404, "task not found")
|
|
1047
|
+
title = str(data["title"] or "未命名任务")
|
|
1048
|
+
|
|
1049
|
+
def safename(s, maxlen: int = 60) -> str:
|
|
1050
|
+
return "".join(ch for ch in str(s or "") if ch not in '\\/:*?"<>|\n\r\t').strip()[:maxlen]
|
|
1051
|
+
|
|
1052
|
+
buf = io.BytesIO()
|
|
1053
|
+
n_img = 0
|
|
1054
|
+
used: set[str] = set()
|
|
1055
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
1056
|
+
body = _plain_text(data["doc_body"])
|
|
1057
|
+
if not body:
|
|
1058
|
+
# 老记录没有 doc_body:退回每条的 标题+正文
|
|
1059
|
+
parts = []
|
|
1060
|
+
for i, r in enumerate(data["rows"], 1):
|
|
1061
|
+
t = (r.get("title") or r.get("topic") or "").strip()
|
|
1062
|
+
b = _plain_text(r.get("body") or "")
|
|
1063
|
+
if t or b:
|
|
1064
|
+
parts.append((t or f"第{i:02d}条") + (("\n" + b) if b else ""))
|
|
1065
|
+
body = "\n\n".join(parts)
|
|
1066
|
+
z.writestr("文案.txt", (title + "\n\n" + body).strip() + "\n")
|
|
1067
|
+
for r in data["rows"]:
|
|
1068
|
+
if not r.get("image"):
|
|
1069
|
+
continue
|
|
1070
|
+
p = HISTORY.image_path(str(r["id"]))
|
|
1071
|
+
if not p:
|
|
1072
|
+
continue
|
|
1073
|
+
n_img += 1
|
|
1074
|
+
stem = safename(r.get("title") or r.get("topic") or "") or f"图片{n_img:02d}"
|
|
1075
|
+
base = f"{n_img:02d}_{stem}"
|
|
1076
|
+
name, j = base, 2
|
|
1077
|
+
while name in used:
|
|
1078
|
+
name = f"{base}_{j}"
|
|
1079
|
+
j += 1
|
|
1080
|
+
used.add(name)
|
|
1081
|
+
z.write(str(p), f"images/{name}.png")
|
|
1082
|
+
fname = (safename(title, 40) or task_id) + "_图片文案.zip"
|
|
1083
|
+
return Response(
|
|
1084
|
+
content=buf.getvalue(),
|
|
1085
|
+
media_type="application/zip",
|
|
1086
|
+
headers={"Content-Disposition": "attachment; filename*=UTF-8''" + quote(fname)},
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
@app.delete("/api/tasks/{task_id}")
|
|
1091
|
+
async def api_task_delete(task_id: str):
|
|
1092
|
+
"""删掉整个任务(索引 + 原图 + 缩略图),删完不能恢复。"""
|
|
1093
|
+
r = HISTORY.delete_task(task_id)
|
|
1094
|
+
if not r.get("deleted"):
|
|
1095
|
+
raise HTTPException(404, "task not found")
|
|
1096
|
+
return JSONResponse(r)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
@app.get("/api/drafts")
|
|
1100
|
+
async def api_drafts():
|
|
1101
|
+
return JSONResponse(DRAFTS.list())
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
@app.post("/api/drafts")
|
|
1105
|
+
async def api_draft_save(body: dict):
|
|
1106
|
+
rec = DRAFTS.save(body)
|
|
1107
|
+
return JSONResponse({"ok": True, "id": rec["id"], "updated": rec.get("updated"),
|
|
1108
|
+
"count": rec.get("count", 0)})
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
@app.get("/api/drafts/{draft_id}")
|
|
1112
|
+
async def api_draft_get(draft_id: str):
|
|
1113
|
+
rec = DRAFTS.get(draft_id)
|
|
1114
|
+
if not rec:
|
|
1115
|
+
raise HTTPException(404, "draft not found")
|
|
1116
|
+
return JSONResponse(rec)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
@app.delete("/api/drafts/{draft_id}")
|
|
1120
|
+
async def api_draft_del(draft_id: str):
|
|
1121
|
+
if not DRAFTS.delete(draft_id):
|
|
1122
|
+
raise HTTPException(404, "draft not found")
|
|
1123
|
+
return {"ok": True}
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
@app.patch("/api/drafts/{draft_id}")
|
|
1127
|
+
async def api_draft_rename(draft_id: str, body: dict):
|
|
1128
|
+
rec = DRAFTS.rename(draft_id, body.get("name") or "")
|
|
1129
|
+
if rec is None:
|
|
1130
|
+
raise HTTPException(404, "draft not found")
|
|
1131
|
+
return {"ok": True, "id": draft_id, "name": rec.get("name") or ""}
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
@app.post("/api/ref")
|
|
1135
|
+
async def api_ref_upload(req: RefRequest):
|
|
1136
|
+
"""参考图上传:前端压到 1400px 的 dataURL,这里只做落盘(PNG)。"""
|
|
1137
|
+
raw = (req.data or "").strip()
|
|
1138
|
+
if raw.lower().startswith("data:"):
|
|
1139
|
+
_, _, raw = raw.partition(",")
|
|
1140
|
+
try:
|
|
1141
|
+
blob = base64.b64decode(raw, validate=False)
|
|
1142
|
+
except Exception:
|
|
1143
|
+
raise HTTPException(400, "bad image data")
|
|
1144
|
+
if not blob or len(blob) > 12 * 1024 * 1024:
|
|
1145
|
+
raise HTTPException(400, "reference image too large (max 12MB)")
|
|
1146
|
+
|
|
1147
|
+
import io
|
|
1148
|
+
|
|
1149
|
+
from PIL import Image, ImageOps
|
|
1150
|
+
try:
|
|
1151
|
+
im = Image.open(io.BytesIO(blob))
|
|
1152
|
+
im = ImageOps.exif_transpose(im)
|
|
1153
|
+
im.load()
|
|
1154
|
+
except Exception:
|
|
1155
|
+
raise HTTPException(400, "not a readable image")
|
|
1156
|
+
if max(im.size) > 1400:
|
|
1157
|
+
im.thumbnail((1400, 1400))
|
|
1158
|
+
if im.mode not in ("RGB", "RGBA"):
|
|
1159
|
+
im = im.convert("RGBA" if "A" in im.getbands() else "RGB")
|
|
1160
|
+
|
|
1161
|
+
rid = uuid.uuid4().hex[:10]
|
|
1162
|
+
dst = REFS_DIR / f"{rid}.png"
|
|
1163
|
+
im.save(dst, "PNG", optimize=True)
|
|
1164
|
+
# 只留最近 120 张,老的自生自灭
|
|
1165
|
+
try:
|
|
1166
|
+
olds = sorted(REFS_DIR.glob("*.png"), key=lambda f: f.stat().st_mtime, reverse=True)[120:]
|
|
1167
|
+
for f in olds:
|
|
1168
|
+
f.unlink()
|
|
1169
|
+
except Exception:
|
|
1170
|
+
pass
|
|
1171
|
+
return JSONResponse({"id": rid, "url": f"/refs/{rid}.png",
|
|
1172
|
+
"w": im.width, "h": im.height, "bytes": dst.stat().st_size})
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
@app.get("/api/history/{job_id}")
|
|
1176
|
+
async def api_history_one(job_id: str):
|
|
1177
|
+
"""单条完整记录(含提示词全文),历史里的「复用」用它。"""
|
|
1178
|
+
rec = HISTORY.get(job_id)
|
|
1179
|
+
if not rec:
|
|
1180
|
+
raise HTTPException(404, "record not found")
|
|
1181
|
+
return JSONResponse(HISTORY._public(rec))
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _variant_response(job_id: str, kind: str):
|
|
1185
|
+
"""派生图统一出口。原图永远只在下载与大图弹窗里出现。
|
|
1186
|
+
|
|
1187
|
+
公众号首图 2.35:1 是裁出来的:卡片和右侧预览都按记录里的比例裁,
|
|
1188
|
+
否则预览里看到的构图和发出去的不是一张。
|
|
1189
|
+
"""
|
|
1190
|
+
src = HISTORY.image_path(job_id)
|
|
1191
|
+
if not src:
|
|
1192
|
+
raise HTTPException(404, "image not found")
|
|
1193
|
+
rec = HISTORY.get(job_id) or {}
|
|
1194
|
+
ratio = rec.get("ratio")
|
|
1195
|
+
try:
|
|
1196
|
+
# Pillow 解码 3MB PNG 要几百毫秒,整段放线程池,别堵住 event loop
|
|
1197
|
+
dst = imaging.ensure(THUMB_DIR, job_id, kind, src, ratio)
|
|
1198
|
+
return FileResponse(str(dst), media_type="image/webp",
|
|
1199
|
+
headers={"Cache-Control": "private, max-age=604800"})
|
|
1200
|
+
except Exception:
|
|
1201
|
+
return FileResponse(str(src), media_type="image/png")
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
@app.get("/api/history/{job_id}/thumb")
|
|
1205
|
+
async def api_history_thumb(job_id: str):
|
|
1206
|
+
"""历史卡片小图:360px WebP 约 40KB;原图仍在 /files 供下载。"""
|
|
1207
|
+
return await run_in_threadpool(_variant_response, job_id, "card")
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
@app.get("/api/history/{job_id}/preview")
|
|
1211
|
+
async def api_history_preview(job_id: str):
|
|
1212
|
+
"""右侧排版预览:960px WebP 约 280KB;双击大图与所有下载都走原图。"""
|
|
1213
|
+
return await run_in_threadpool(_variant_response, job_id, "preview")
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
@app.get("/api/history/{job_id}/download")
|
|
1217
|
+
async def api_history_download(job_id: str):
|
|
1218
|
+
path = HISTORY.image_path(job_id)
|
|
1219
|
+
if not path:
|
|
1220
|
+
raise HTTPException(404, "image not found")
|
|
1221
|
+
rec = HISTORY.get(job_id) or {}
|
|
1222
|
+
title = str(rec.get("title") or rec.get("topic") or job_id)
|
|
1223
|
+
name = "".join(ch for ch in title if ch not in '\\/:*?"<>|\n\r').strip() or job_id
|
|
1224
|
+
name = f"{name[:40]}.png"
|
|
1225
|
+
# 2.35:1 这类上游没有的尺寸,导出的就是裁好的成品;原图文件保持完整不覆盖
|
|
1226
|
+
dst = await run_in_threadpool(imaging.export_path, THUMB_DIR, job_id, path, rec.get("ratio"))
|
|
1227
|
+
return FileResponse(str(dst), media_type="image/png",
|
|
1228
|
+
headers={"Content-Disposition":
|
|
1229
|
+
f"attachment; filename*=UTF-8''{quote(name)}"})
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
@app.get("/api/history/{job_id}/final")
|
|
1233
|
+
async def api_history_final(job_id: str):
|
|
1234
|
+
"""成品图(inline):公众号首图这类需要裁切的比例,大图弹窗和下载看到的是同一张。"""
|
|
1235
|
+
path = HISTORY.image_path(job_id)
|
|
1236
|
+
if not path:
|
|
1237
|
+
raise HTTPException(404, "image not found")
|
|
1238
|
+
rec = HISTORY.get(job_id) or {}
|
|
1239
|
+
dst = await run_in_threadpool(imaging.export_path, THUMB_DIR, job_id, path, rec.get("ratio"))
|
|
1240
|
+
return FileResponse(str(dst), media_type="image/png",
|
|
1241
|
+
headers={"Cache-Control": "private, max-age=604800"})
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
@app.get("/api/monitor")
|
|
1245
|
+
async def api_monitor():
|
|
1246
|
+
# 号池占用状态只在出图时才变:有任务在跑 3 秒一刷,空闲时拉长到 20 秒,
|
|
1247
|
+
# 免得监控页开着就在后台空转打号池
|
|
1248
|
+
await POOL.refresh_accounts_throttled()
|
|
1249
|
+
snap = POOL.snapshot()
|
|
1250
|
+
by_env, by_mask = HISTORY.account_stats()
|
|
1251
|
+
for a in snap["accounts"]:
|
|
1252
|
+
st = (by_env.get(str(a["env_id"])) if a.get("env_id") else None) or by_mask.get(a["masked"]) or {}
|
|
1253
|
+
if st: # 仅当工作台自身有该账号的历史记录时才覆盖,否则保留号池提供的成功/失败/最近成功
|
|
1254
|
+
a["hist_done"] = int(st.get("done") or 0)
|
|
1255
|
+
a["hist_error"] = int(st.get("error") or 0)
|
|
1256
|
+
a["hist_last_ok"] = int(st.get("last_ok") or 0)
|
|
1257
|
+
snap["history"] = HISTORY.stats()
|
|
1258
|
+
snap["check"] = POOL.check_state
|
|
1259
|
+
snap["recent_errors"] = HISTORY.recent_errors(6)
|
|
1260
|
+
return JSONResponse(snap)
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
@app.post("/api/pool/check")
|
|
1264
|
+
async def api_pool_check():
|
|
1265
|
+
return JSONResponse(await POOL.check_all())
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
@app.get("/api/pool/check")
|
|
1269
|
+
async def api_pool_check_state():
|
|
1270
|
+
return JSONResponse(POOL.check_state)
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
@app.get("/access")
|
|
1274
|
+
async def access_page(request: Request):
|
|
1275
|
+
# expired=1 是前端 401 拦截带过来的:会话中途失效时不要弹回首页,
|
|
1276
|
+
# 否则「/access -> / -> 401 -> /access」会形成无限重载。
|
|
1277
|
+
if (request.query_params.get("expired") != "1"
|
|
1278
|
+
and access.check_session(request.cookies.get("wb_sid", ""))):
|
|
1279
|
+
return RedirectResponse("/", status_code=302)
|
|
1280
|
+
return FileResponse(str(STATIC / "access.html"))
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
@app.post("/api/access/request")
|
|
1284
|
+
async def api_access_request(req: AccessRequest, request: Request):
|
|
1285
|
+
d = await run_in_threadpool(access.request_code, req.email or req.user,
|
|
1286
|
+
_client_ip(request), request.headers.get("user-agent", ""))
|
|
1287
|
+
return JSONResponse(d, status_code=200 if d.get("ok") else (429 if d.get("limited") else 400))
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
@app.post("/api/access/verify")
|
|
1291
|
+
async def api_access_verify(req: AccessVerify, request: Request):
|
|
1292
|
+
d = await run_in_threadpool(access.verify_code, req.email or req.user,
|
|
1293
|
+
req.code, _client_ip(request))
|
|
1294
|
+
if not d.get("ok"):
|
|
1295
|
+
return JSONResponse(d, status_code=429 if d.get("limited") else 400)
|
|
1296
|
+
resp = JSONResponse({"ok": True, "msg": d["msg"]})
|
|
1297
|
+
_set_session_cookie(resp, d["token"])
|
|
1298
|
+
return resp
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
@app.post("/api/access/master")
|
|
1302
|
+
async def api_access_master(req: MasterRequest, request: Request):
|
|
1303
|
+
d = await run_in_threadpool(access.master_login, req.password, _client_ip(request))
|
|
1304
|
+
if not d.get("ok"):
|
|
1305
|
+
return JSONResponse(d, status_code=429 if d.get("limited") else 400)
|
|
1306
|
+
resp = JSONResponse({"ok": True, "msg": d["msg"]})
|
|
1307
|
+
_set_session_cookie(resp, d["token"])
|
|
1308
|
+
return resp
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
@app.get("/")
|
|
1312
|
+
async def index():
|
|
1313
|
+
# 工作台是单页应用,改版频繁;禁止缓存,避免用户拿到旧的 index.html
|
|
1314
|
+
return FileResponse(str(STATIC / "index.html"),
|
|
1315
|
+
headers={"Cache-Control": "no-store, must-revalidate"})
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
@app.get("/{path:path}")
|
|
1319
|
+
async def static_page(path: str):
|
|
1320
|
+
f = STATIC / path
|
|
1321
|
+
if f.is_file():
|
|
1322
|
+
return FileResponse(str(f))
|
|
1323
|
+
raise HTTPException(404, "not found")
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
if __name__ == "__main__":
|
|
1327
|
+
import uvicorn
|
|
1328
|
+
|
|
1329
|
+
uvicorn.run(app, host="0.0.0.0", port=8020, log_level="info")
|