dsh-plugin-image-workbench 0.0.0-stage → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +256 -0
- package/README.md +256 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +641 -0
- package/install.mjs +387 -0
- package/package.json +57 -4
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
package/host.js
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Host 插件:生图工作台(图文 / 摄影双模式),一个插件跑全套。
|
|
3
|
+
*
|
|
4
|
+
* 号池不再有独立插件:专用生图号池(38651)由工作台后端自己拉起、守护、初始化
|
|
5
|
+
* (pool_service.py / /api/pool/service/* 路由)。号池服务端代码 + uv 随本插件
|
|
6
|
+
* 放在 vendor/chatgpt2api/,工作台后端代码放在 server/(开发机上优先用旁边的
|
|
7
|
+
* image-local/)。整包拷到新机器就能自己建虚拟环境、起号池,不依赖任何第三方插件。
|
|
8
|
+
* 本插件只负责拉起并守护 8092,顺手把号池状态转给侧栏顶部的指示灯。
|
|
9
|
+
* 所以工作台脱离 DSH、直接在浏览器打开 8092 也能独立运行。
|
|
10
|
+
* 只探端口、只重启自己拉起的进程,绝不重载 client 的 iframe。
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, appendFileSync, mkdirSync, openSync, readdirSync, readFileSync,
|
|
13
|
+
copyFileSync, statSync } from 'node:fs';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import net from 'node:net';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { join, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
export const inject = ['connection'];
|
|
22
|
+
|
|
23
|
+
const PLUGIN_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
24
|
+
const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
25
|
+
/** 插件运行时根目录:日志、后端代码副本、号池源码快照都放这里,与插件包本身解耦。 */
|
|
26
|
+
const RUNTIME_DIR = join(DSH_HOME, 'image-workbench');
|
|
27
|
+
const PLUGIN_NAME = 'dsh-plugin-image-workbench';
|
|
28
|
+
|
|
29
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
30
|
+
|
|
31
|
+
function firstFile(list) {
|
|
32
|
+
for (const p of list) if (p && existsSync(p)) return p;
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 一个目录是否构成可运行的工作台后端。 */
|
|
37
|
+
function isServerDir(dir) {
|
|
38
|
+
return !!dir && existsSync(join(dir, 'app.py')) && existsSync(join(dir, 'static', 'index.html'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 这些是运行时产物,不参与代码同步,升级时保留。 */
|
|
42
|
+
const SERVER_RUNTIME = new Set([
|
|
43
|
+
'.venv', 'images', 'thumbs', 'refs', 'repo_images', 'workbench', 'data',
|
|
44
|
+
'__pycache__', 'config.local.json', 'views.json',
|
|
45
|
+
]);
|
|
46
|
+
const VENDOR_SKIP = new Set(['.venv', '__pycache__', 'node_modules']);
|
|
47
|
+
|
|
48
|
+
/** 把代码目录覆盖到运行时目录:变了才复制,只碰代码,不动 venv、图片和账号数据。 */
|
|
49
|
+
function syncTree(src, dst, skip) {
|
|
50
|
+
mkdirSync(dst, { recursive: true });
|
|
51
|
+
for (const ent of readdirSync(src, { withFileTypes: true })) {
|
|
52
|
+
if (skip && skip.has(ent.name)) continue;
|
|
53
|
+
const s = join(src, ent.name);
|
|
54
|
+
const d = join(dst, ent.name);
|
|
55
|
+
if (ent.isDirectory()) { syncTree(s, d, skip); continue; }
|
|
56
|
+
const st = statSync(s);
|
|
57
|
+
if (existsSync(d)) {
|
|
58
|
+
const dt = statSync(d);
|
|
59
|
+
if (dt.size === st.size && dt.mtimeMs >= st.mtimeMs - 1000) continue;
|
|
60
|
+
}
|
|
61
|
+
copyFileSync(s, d);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 装在 node_modules 里的插件目录会被包管理器整体替换,运行时不能留在里面:
|
|
67
|
+
* 一是升级会把 .venv、生成的图片、账号数据一起冲掉;二是后端进程以该目录为
|
|
68
|
+
* 工作目录运行时 Windows 会锁住整个目录,pnpm 重装时报 ERR_PNPM_EPERM rename。
|
|
69
|
+
* 这类安装把代码复制到 ~/.dsh/image-workbench/ 再跑,node_modules 保持只读。
|
|
70
|
+
*/
|
|
71
|
+
function isolatedServer(bundled) {
|
|
72
|
+
const dst = join(RUNTIME_DIR, 'server');
|
|
73
|
+
try {
|
|
74
|
+
syncTree(bundled, dst, SERVER_RUNTIME);
|
|
75
|
+
// 号池源码快照放到 server 的同级 vendor/,pool_service 会从那里找
|
|
76
|
+
const vendSrc = join(PLUGIN_DIR, 'vendor', 'chatgpt2api');
|
|
77
|
+
if (existsSync(join(vendSrc, 'source', 'main.py'))) {
|
|
78
|
+
syncTree(vendSrc, join(RUNTIME_DIR, 'vendor', 'chatgpt2api'), VENDOR_SKIP);
|
|
79
|
+
}
|
|
80
|
+
// 1.1.3 及更早版本把运行时直接写在插件目录里,搬一次,别让用户丢图
|
|
81
|
+
for (const name of ['images', 'data', 'workbench', 'refs', 'config.local.json']) {
|
|
82
|
+
const s = join(bundled, name);
|
|
83
|
+
const d = join(dst, name);
|
|
84
|
+
if (!existsSync(s) || existsSync(d)) continue;
|
|
85
|
+
if (statSync(s).isDirectory()) syncTree(s, d);
|
|
86
|
+
else copyFileSync(s, d);
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.error('[image-workbench] 同步后端代码失败,改用插件目录:' + e.message);
|
|
90
|
+
return bundled;
|
|
91
|
+
}
|
|
92
|
+
return isServerDir(dst) ? dst : bundled;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 工作台目录:环境变量 > patch 配置 > 插件同级 image-local > 插件自带 server/。 */
|
|
96
|
+
export function locateServer(config = {}) {
|
|
97
|
+
const candidates = [
|
|
98
|
+
process.env.DSH_IMAGE_SERVER_DIR,
|
|
99
|
+
config.serverDir,
|
|
100
|
+
resolve(PLUGIN_DIR, '..', 'image-local'),
|
|
101
|
+
].filter(Boolean);
|
|
102
|
+
for (const dir of candidates) if (isServerDir(dir)) return dir;
|
|
103
|
+
|
|
104
|
+
const bundled = resolve(PLUGIN_DIR, 'server');
|
|
105
|
+
if (!isServerDir(bundled)) return '';
|
|
106
|
+
// 插件被装进 node_modules 时,目录归包管理器管,运行时要搬出去
|
|
107
|
+
if (bundled.split(/[\\/]/).includes('node_modules')) return isolatedServer(bundled);
|
|
108
|
+
return bundled;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 工作台 Python:自带 venv 优先,其次系统 Python。 */
|
|
112
|
+
export function locatePython(serverDir, config = {}) {
|
|
113
|
+
return firstFile([
|
|
114
|
+
process.env.DSH_IMAGE_PYTHON,
|
|
115
|
+
config.python,
|
|
116
|
+
serverDir && join(serverDir, '.venv', 'Scripts', 'python.exe'),
|
|
117
|
+
serverDir && join(serverDir, '.venv', 'bin', 'python'),
|
|
118
|
+
join(homedir(), 'AppData', 'Local', 'Programs', 'Python', 'Python312', 'python.exe'),
|
|
119
|
+
].filter(Boolean)) || 'python';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function probe(port, timeout = 700) {
|
|
123
|
+
return new Promise(done => {
|
|
124
|
+
const socket = net.connect({ host: '127.0.0.1', port });
|
|
125
|
+
let settled = false;
|
|
126
|
+
const finish = value => { if (!settled) { settled = true; socket.destroy(); done(value); } };
|
|
127
|
+
socket.setTimeout(timeout);
|
|
128
|
+
socket.once('connect', () => finish(true));
|
|
129
|
+
socket.once('error', () => finish(false));
|
|
130
|
+
socket.once('timeout', () => finish(false));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** node:http 直连回环,绕开宿主可能挂上的全局代理 dispatcher。 */
|
|
135
|
+
function localGetJson(port, pathname, timeout = 2500) {
|
|
136
|
+
return new Promise((done, fail) => {
|
|
137
|
+
const req = http.get({ host: '127.0.0.1', port, path: pathname,
|
|
138
|
+
headers: { Accept: 'application/json' }, timeout }, res => {
|
|
139
|
+
let body = '';
|
|
140
|
+
res.setEncoding('utf8');
|
|
141
|
+
res.on('data', c => { body += c; if (body.length > 500_000) req.destroy(new Error('响应过大')); });
|
|
142
|
+
res.on('end', () => {
|
|
143
|
+
try { done({ status: res.statusCode, data: JSON.parse(body) }); }
|
|
144
|
+
catch { done({ status: res.statusCode, data: {} }); }
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
req.on('timeout', () => req.destroy(new Error('连接超时')));
|
|
148
|
+
req.on('error', fail);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 用系统默认浏览器打开 URL。
|
|
154
|
+
*
|
|
155
|
+
* DSH 宿主不保证是 Electron(也可能跑在纯 node 里),所以不能只靠 shell.openExternal。
|
|
156
|
+
* 顺序:electron.shell → 平台原生命令(win: cmd /c start、mac: open、linux: xdg-open)。
|
|
157
|
+
* 一律返回 { ok, via, error },让 client 能把成功/失败如实告诉用户 —— 以前只返回 false
|
|
158
|
+
* 且 client 不看返回值,点了像没反应。
|
|
159
|
+
*/
|
|
160
|
+
async function openExternal(url) {
|
|
161
|
+
let electronError = '';
|
|
162
|
+
if (process.versions.electron) {
|
|
163
|
+
try {
|
|
164
|
+
const { shell } = await import('electron');
|
|
165
|
+
if (typeof shell?.openExternal === 'function') {
|
|
166
|
+
await shell.openExternal(url);
|
|
167
|
+
return { ok: true, via: 'electron.shell' };
|
|
168
|
+
}
|
|
169
|
+
electronError = 'electron.shell 不可用';
|
|
170
|
+
} catch (error) {
|
|
171
|
+
electronError = 'electron 模块加载失败:' + error.message;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const [command, args] = process.platform === 'win32'
|
|
176
|
+
// 第二个空参数是 start 的窗口标题占位,少了它 URL 会被当成标题
|
|
177
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
178
|
+
: process.platform === 'darwin'
|
|
179
|
+
? ['open', [url]]
|
|
180
|
+
: ['xdg-open', [url]];
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
184
|
+
child.unref();
|
|
185
|
+
// detached 后进程已脱离,这里只收 ENOENT 之类的启动错误,避免 unhandled
|
|
186
|
+
child.on('error', error => {
|
|
187
|
+
console.warn('[image-workbench] 打开浏览器失败:' + error.message);
|
|
188
|
+
});
|
|
189
|
+
return { ok: true, via: command, note: electronError };
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return { ok: false, error: [electronError, error.message].filter(Boolean).join(' / ') };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function apply(ctx, config = {}) {
|
|
196
|
+
const port = Number(config.port || process.env.DSH_IMAGE_PORT || 8092);
|
|
197
|
+
const serverDir = locateServer(config);
|
|
198
|
+
const state = {
|
|
199
|
+
port,
|
|
200
|
+
url: 'http://127.0.0.1:' + port + '/',
|
|
201
|
+
serverDir,
|
|
202
|
+
python: locatePython(serverDir, config),
|
|
203
|
+
autoStart: config.autoStart !== false,
|
|
204
|
+
serverChild: null,
|
|
205
|
+
lastServerStart: '',
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const poolPort = Number(config.poolPort || process.env.DSH_IMAGE_POOL_PORT || 38651);
|
|
209
|
+
console.log('[image-workbench] 工作台=' + (serverDir || '未找到') + ':' + port
|
|
210
|
+
+ ',内置号池=' + poolPort + '(由工作台后端自己拉起和守护)');
|
|
211
|
+
|
|
212
|
+
const watchdog = {
|
|
213
|
+
enabled: config.watchdog !== false && state.autoStart,
|
|
214
|
+
intervalMs: Math.max(3000, Number(config.watchdogIntervalMs || 15000)),
|
|
215
|
+
timer: null, busy: false, failures: 0, nextAllowedAt: 0,
|
|
216
|
+
restarts: 0, lastRestart: '',
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// 只回收自己 spawn 的子进程;守护定时器属于插件生命周期,不被「手动启动」清掉。
|
|
220
|
+
const stopChild = () => {
|
|
221
|
+
const child = state.serverChild;
|
|
222
|
+
if (child && !child.killed) { try { child.kill(); } catch { /* 已退出 */ } }
|
|
223
|
+
state.serverChild = null;
|
|
224
|
+
};
|
|
225
|
+
const cleanup = () => {
|
|
226
|
+
if (watchdog.timer) { clearInterval(watchdog.timer); watchdog.timer = null; }
|
|
227
|
+
stopChild();
|
|
228
|
+
};
|
|
229
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => cleanup, PLUGIN_NAME + ': backend lifecycle');
|
|
230
|
+
|
|
231
|
+
function logFd(name) {
|
|
232
|
+
mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
233
|
+
return openSync(join(RUNTIME_DIR, name), 'a');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 读日志末尾若干字符,排障时直接看,不用去翻文件。读不到就返回空串。 */
|
|
237
|
+
function tailOf(file, limit = 800) {
|
|
238
|
+
try {
|
|
239
|
+
const text = readFileSync(file, 'utf8');
|
|
240
|
+
const rows = text.trimEnd().split('\n');
|
|
241
|
+
// 同一行反复出现(后端每次退出都写一条)时只留一条,否则日志等于噪音
|
|
242
|
+
const out = [];
|
|
243
|
+
for (const r of rows) if (out[out.length - 1] !== r) out.push(r);
|
|
244
|
+
return out.slice(-12).join('\n').slice(-limit);
|
|
245
|
+
} catch { return ''; }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 整包拷到新机器时,server/ 里没有 .venv,用插件自带的 uv 现建一个。 */
|
|
249
|
+
function bundledUv() {
|
|
250
|
+
return firstFile([
|
|
251
|
+
process.env.DSH_IMAGE_UV,
|
|
252
|
+
join(PLUGIN_DIR, 'vendor', 'chatgpt2api', 'runtime', 'uv.exe'),
|
|
253
|
+
join(PLUGIN_DIR, 'vendor', 'chatgpt2api', 'runtime', 'uv'),
|
|
254
|
+
]);
|
|
255
|
+
}
|
|
256
|
+
function venvPython(dir) {
|
|
257
|
+
return firstFile([
|
|
258
|
+
join(dir, '.venv', 'Scripts', 'python.exe'),
|
|
259
|
+
join(dir, '.venv', 'bin', 'python'),
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 这个解释器能不能真跑起后端。
|
|
265
|
+
*
|
|
266
|
+
* 只看 python.exe 存在是不够的:上一轮装依赖失败时 venv 目录已经建好,
|
|
267
|
+
* 里面却没有 uvicorn,于是每次启动都报 "No module named uvicorn",
|
|
268
|
+
* 而代码以为环境已就绪,永远不会去补装。
|
|
269
|
+
*/
|
|
270
|
+
async function venvUsable(py, cwd) {
|
|
271
|
+
if (!py) return false;
|
|
272
|
+
const r = await runCmd(py, ['-c', 'import fastapi, uvicorn'], cwd, 30000);
|
|
273
|
+
return r.ok;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 依赖源候选,按「国内镜像直连 → 官方 PyPI 走代理 → 官方直连」排列。
|
|
278
|
+
*
|
|
279
|
+
* 关键点:镜像站不能走代理。走代理访问清华镜像会拿到 403,
|
|
280
|
+
* uv 看到的就是 "fastapi was not found in the package registry",
|
|
281
|
+
* 报错完全指不到真正原因(实测第三次装机就是这么卡住的)。
|
|
282
|
+
*/
|
|
283
|
+
function indexCandidates(proxy) {
|
|
284
|
+
const custom = (process.env.DSH_IMAGE_PYPI_INDEX || '').trim();
|
|
285
|
+
const list = [];
|
|
286
|
+
if (custom) list.push({ url: custom, label: '自定义源', proxy: false });
|
|
287
|
+
list.push({ url: 'https://pypi.tuna.tsinghua.edu.cn/simple', label: '清华镜像', proxy: false });
|
|
288
|
+
list.push({ url: 'https://mirrors.aliyun.com/pypi/simple', label: '阿里云镜像', proxy: false });
|
|
289
|
+
if (proxy) list.push({ url: 'https://pypi.org/simple', label: '官方 PyPI', proxy: true });
|
|
290
|
+
list.push({ url: 'https://pypi.org/simple', label: '官方 PyPI', proxy: false });
|
|
291
|
+
return list;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 按候选源准备 env:镜像直连(清掉代理变量),官方源才挂代理。 */
|
|
295
|
+
function envForIndex(base, cand, proxy) {
|
|
296
|
+
const env = { ...base };
|
|
297
|
+
env.UV_DEFAULT_INDEX_URL = cand.url; // 覆盖宿主可能带进来同名变量
|
|
298
|
+
const keys = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
|
|
299
|
+
if (cand.proxy && proxy) {
|
|
300
|
+
env.HTTPS_PROXY = proxy; env.HTTP_PROXY = proxy;
|
|
301
|
+
} else {
|
|
302
|
+
for (const k of keys) delete env[k];
|
|
303
|
+
}
|
|
304
|
+
env.NO_PROXY = env.no_proxy =
|
|
305
|
+
'pypi.tuna.tsinghua.edu.cn,mirrors.aliyun.com,pypi.org,files.pythonhosted.org,127.0.0.1,localhost';
|
|
306
|
+
return env;
|
|
307
|
+
}
|
|
308
|
+
function runCmd(command, args, cwd, timeoutMs = 240000, env = null) {
|
|
309
|
+
return new Promise(done => {
|
|
310
|
+
const child = spawn(command, args, { cwd, windowsHide: true,
|
|
311
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
312
|
+
...(env ? { env } : {}) });
|
|
313
|
+
let tail = '';
|
|
314
|
+
const collect = c => { tail = (tail + c.toString()).slice(-4000); };
|
|
315
|
+
child.stdout?.on('data', collect);
|
|
316
|
+
child.stderr?.on('data', collect);
|
|
317
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
318
|
+
child.once('error', e => { clearTimeout(timer); done({ ok: false, tail: e.message }); });
|
|
319
|
+
child.once('exit', code => { clearTimeout(timer);
|
|
320
|
+
done({ ok: code === 0, tail: code === 0 ? '' : tail.slice(-600) }); });
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 本机常见代理端口:uv 要出网下 Python 运行时,国内不开代理基本下不动。 */
|
|
325
|
+
const PROXY_SPECS = [['http', 7890], ['socks5', 1080], ['http', 7891],
|
|
326
|
+
['http', 8118], ['socks5', 1087], ['http', 10809]];
|
|
327
|
+
async function egressProxy() {
|
|
328
|
+
for (const k of ['HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy',
|
|
329
|
+
'HTTP_PROXY', 'http_proxy']) {
|
|
330
|
+
const v = (process.env[k] || '').trim();
|
|
331
|
+
if (v) return v;
|
|
332
|
+
}
|
|
333
|
+
for (const [scheme, port] of PROXY_SPECS) {
|
|
334
|
+
if (await probe(port, 250)) return scheme + '://127.0.0.1:' + port;
|
|
335
|
+
}
|
|
336
|
+
return '';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 判断某个解释器能不能用(版本 >= 3.10)。py 启动器要带 -3,返回的版本号同此。 */
|
|
340
|
+
async function pythonVersion(exe, prefix = []) {
|
|
341
|
+
const r = await runCmd(exe, [...prefix, '-c',
|
|
342
|
+
'import sys;print(sys.version_info[0]*100+sys.version_info[1])'], homedir(), 12000);
|
|
343
|
+
if (!r.ok) return 0;
|
|
344
|
+
const n = Number(String(r.tail || '').trim().split('\n').pop());
|
|
345
|
+
return Number.isFinite(n) ? n : 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 找一台机器上现成的 Python 3.10+。
|
|
350
|
+
*
|
|
351
|
+
* 全新电脑如果没有任何 Python,uv 会去 GitHub 下载 python-build-standalone,
|
|
352
|
+
* 国内网络经常下不动,于是整个后端起不来。所以先找现成的,找不到才交给 uv。
|
|
353
|
+
*/
|
|
354
|
+
async function findSystemPython() {
|
|
355
|
+
const seeds = [];
|
|
356
|
+
const push = (exe, prefix = []) => { if (exe) seeds.push([exe, prefix]); };
|
|
357
|
+
push(process.env.DSH_IMAGE_PYTHON);
|
|
358
|
+
push(config.python);
|
|
359
|
+
if (process.platform === 'win32') {
|
|
360
|
+
push(join(process.env.ProgramFiles || 'C:\\Program Files', 'Python313', 'python.exe'));
|
|
361
|
+
push(join(process.env.ProgramFiles || 'C:\\Program Files', 'Python312', 'python.exe'));
|
|
362
|
+
push(join(process.env.ProgramFiles || 'C:\\Program Files', 'Python311', 'python.exe'));
|
|
363
|
+
const roots = [
|
|
364
|
+
join(process.env.LOCALAPPDATA || '', 'Programs', 'Python'),
|
|
365
|
+
join(homedir(), '.workbuddy', 'binaries', 'python', 'versions'), // 宿主自带运行时
|
|
366
|
+
'C:\\Python313', 'C:\\Python312', 'C:\\Python311', 'C:\\Python310',
|
|
367
|
+
];
|
|
368
|
+
for (const root of roots) {
|
|
369
|
+
let names = [];
|
|
370
|
+
try { names = readdirSync(root).sort().reverse(); } catch { /* 目录不存在 */ }
|
|
371
|
+
for (const n of names) push(join(root, n, 'python.exe'));
|
|
372
|
+
push(join(root, 'python.exe'));
|
|
373
|
+
}
|
|
374
|
+
// py 启动器放到最后:它可能指向 3.12,而上面已排过 3.13 的绝对路径
|
|
375
|
+
push('py', ['-3']);
|
|
376
|
+
}
|
|
377
|
+
push('python3');
|
|
378
|
+
push('python');
|
|
379
|
+
push(join(homedir(), 'AppData', 'Local', 'Programs', 'Python', 'Python312', 'python.exe'));
|
|
380
|
+
|
|
381
|
+
const seen = new Set();
|
|
382
|
+
for (const [exe, prefix] of seeds) {
|
|
383
|
+
const key = exe + ' ' + prefix.join(' ');
|
|
384
|
+
if (seen.has(key)) continue;
|
|
385
|
+
seen.add(key);
|
|
386
|
+
const v = await pythonVersion(exe, prefix);
|
|
387
|
+
if (v >= 310) return { exe, prefix, version: v };
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const venvState = { tried: false, ok: false, message: '', error: '', attempts: 0, nextRetryAt: 0 };
|
|
393
|
+
let venvTask = null; // 同一时刻只允许一次初始化,宿主自启 / 守护 / 用户点按钮会撞车
|
|
394
|
+
|
|
395
|
+
/** 初始化后端虚拟环境。失败后 60 秒可再试,用户点「立即启动并重试」则立刻重试。 */
|
|
396
|
+
async function ensureServerVenv(force = false) {
|
|
397
|
+
if (!state.serverDir || venvState.ok) return;
|
|
398
|
+
if (!force && venvState.tried && Date.now() < venvState.nextRetryAt) return;
|
|
399
|
+
if (venvTask) return venvTask;
|
|
400
|
+
venvTask = runVenvInit().finally(() => { venvTask = null; });
|
|
401
|
+
return venvTask;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function runVenvInit() {
|
|
405
|
+
venvState.tried = true;
|
|
406
|
+
venvState.attempts += 1;
|
|
407
|
+
|
|
408
|
+
const existing = venvPython(state.serverDir);
|
|
409
|
+
if (existing && await venvUsable(existing, state.serverDir)) {
|
|
410
|
+
venvState.ok = true; venvState.message = ''; venvState.error = ''; state.python = existing;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (process.env.DSH_IMAGE_SKIP_VENV === '1') {
|
|
414
|
+
venvState.message = '已按 DSH_IMAGE_SKIP_VENV 跳过环境初始化';
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const uv = bundledUv();
|
|
418
|
+
const req = join(state.serverDir, 'requirements.txt');
|
|
419
|
+
const fail = msg => {
|
|
420
|
+
venvState.message = msg;
|
|
421
|
+
venvState.nextRetryAt = Date.now() + 60000;
|
|
422
|
+
};
|
|
423
|
+
if (!uv || !existsSync(req)) { fail('插件里缺 uv 或 server/requirements.txt,无法自动初始化环境'); return; }
|
|
424
|
+
|
|
425
|
+
const venvLog = join(RUNTIME_DIR, 'venv.log');
|
|
426
|
+
mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
427
|
+
const logLine = s => { try { appendFileSync(venvLog, s + '\n'); } catch { /* 日志而已 */ } };
|
|
428
|
+
|
|
429
|
+
const sys = await findSystemPython();
|
|
430
|
+
const proxy = await egressProxy();
|
|
431
|
+
const uvEnv = { ...process.env };
|
|
432
|
+
uvEnv.UV_HTTP_TIMEOUT = uvEnv.UV_HTTP_TIMEOUT || '120';
|
|
433
|
+
if (proxy) { uvEnv.HTTPS_PROXY = proxy; uvEnv.HTTP_PROXY = proxy; }
|
|
434
|
+
if (process.env.DSH_IMAGE_PYTHON_MIRROR) {
|
|
435
|
+
uvEnv.UV_PYTHON_INSTALL_MIRROR = process.env.DSH_IMAGE_PYTHON_MIRROR;
|
|
436
|
+
}
|
|
437
|
+
logLine(`[${new Date().toISOString()}] 初始化后端环境(第 ${venvState.attempts} 次)`
|
|
438
|
+
+ `,系统 Python=${sys ? sys.exe + ' (' + sys.version + ')' : '未找到'}`
|
|
439
|
+
+ `,代理=${proxy || '无'}`);
|
|
440
|
+
|
|
441
|
+
// 已有 python 就直接补装依赖:最常见的失败是 venv 建好了、包没装上,
|
|
442
|
+
// 这时重建多余,还可能被 uv 的「目录已存在」拦下来。
|
|
443
|
+
let py = existing;
|
|
444
|
+
if (!py) {
|
|
445
|
+
const venvDir = join(state.serverDir, '.venv');
|
|
446
|
+
const vArgs = ['venv', venvDir];
|
|
447
|
+
if (existsSync(venvDir)) vArgs.push('--clear'); // 目录在却没有 python:清掉重建
|
|
448
|
+
if (sys) vArgs.push('--python', sys.exe);
|
|
449
|
+
const v = await runCmd(uv, vArgs, state.serverDir, 180000, uvEnv);
|
|
450
|
+
if (!v.ok) {
|
|
451
|
+
logLine('uv venv 失败:' + v.tail);
|
|
452
|
+
venvState.error = (v.tail || '').slice(-400);
|
|
453
|
+
fail('创建 Python 环境失败' + (sys ? '' : ':本机没找到 Python 3.10+,自动下载也没成功')
|
|
454
|
+
+ '(详见 venv.log)');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
py = venvPython(state.serverDir);
|
|
458
|
+
}
|
|
459
|
+
if (!py) {
|
|
460
|
+
venvState.error = 'venv 目录里没有 python 可执行文件';
|
|
461
|
+
fail('环境目录建好了但里面没有 python(详见 venv.log)');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
state.python = py;
|
|
465
|
+
|
|
466
|
+
// 依赖安装:国内镜像直连 → 官方源(有代理就经代理)→ 官方直连。
|
|
467
|
+
// 逐个试;装完必须真能 import 才算成功,uv 返回 0 却没装上的情况见过。
|
|
468
|
+
let lastTail = '', used = '';
|
|
469
|
+
for (const cand of indexCandidates(proxy)) {
|
|
470
|
+
logLine('安装后端依赖:' + cand.label + '(' + (cand.proxy ? '经代理 ' + proxy : '直连') + ')');
|
|
471
|
+
const r = await runCmd(uv, ['pip', 'install', '--python', py, '-r', req,
|
|
472
|
+
'--index-url', cand.url],
|
|
473
|
+
state.serverDir, 300000, envForIndex(uvEnv, cand, proxy));
|
|
474
|
+
if (r.ok && await venvUsable(py, state.serverDir)) { used = cand.label; break; }
|
|
475
|
+
lastTail = r.ok ? '依赖装完仍无法 import fastapi/uvicorn' : (r.tail || '').slice(-400);
|
|
476
|
+
logLine(' ' + cand.label + ' 失败:'
|
|
477
|
+
+ (r.tail || '装完仍无法 import uvicorn').replace(/\s+/g, ' ').slice(-200));
|
|
478
|
+
if (/403|forbidden/i.test(r.tail || '')) {
|
|
479
|
+
logLine(' ↑ 镜像站被代理拦了(403),下一个源改用直连');
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (used) {
|
|
483
|
+
logLine('工作台后端依赖已装好(' + used + ')');
|
|
484
|
+
venvState.ok = true; venvState.message = ''; venvState.error = ''; state.python = py;
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
venvState.error = lastTail;
|
|
488
|
+
fail(/403|forbidden/i.test(lastTail)
|
|
489
|
+
? '镜像站被代理拦住(403)导致依赖装不上。可先关掉代理重试,'
|
|
490
|
+
+ '或用 DSH_IMAGE_PYPI_INDEX 指定一个能直连的源(详见 venv.log)'
|
|
491
|
+
: '安装后端依赖失败(网络不通或依赖源不可用,详见 venv.log)');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** 拉起生图工作台后端(8092)。 */
|
|
495
|
+
async function ensureServer(force = false) {
|
|
496
|
+
if (await probe(state.port)) return { running: true, started: false };
|
|
497
|
+
if (!state.autoStart) return { running: false, started: false, reason: 'autoStart 已关闭' };
|
|
498
|
+
if (!state.serverDir) return { running: false, started: false, reason: '未找到 image-local 工作台目录' };
|
|
499
|
+
if (state.serverChild && !state.serverChild.killed) return { running: false, started: true, reason: '工作台正在启动中' };
|
|
500
|
+
await ensureServerVenv(force);
|
|
501
|
+
if (!venvState.ok) {
|
|
502
|
+
// 环境没建好时只能用现成解释器兜底,但它必须真有 fastapi/uvicorn,
|
|
503
|
+
// 否则 spawn 出来只会得到 "No module named uvicorn",用户看不出根因。
|
|
504
|
+
const ver = await pythonVersion(state.python);
|
|
505
|
+
const pkg = ver ? await venvUsable(state.python, state.serverDir) : false;
|
|
506
|
+
if (!pkg) {
|
|
507
|
+
return { running: false, started: false,
|
|
508
|
+
reason: (venvState.message || '本机没有可用的 Python 3.10+') + ',后端无法启动' };
|
|
509
|
+
}
|
|
510
|
+
venvState.ok = true;
|
|
511
|
+
}
|
|
512
|
+
if (force) stopChild();
|
|
513
|
+
try {
|
|
514
|
+
const fd = logFd('backend.log');
|
|
515
|
+
const child = spawn(state.python, [
|
|
516
|
+
'-m', 'uvicorn', 'app:app', '--host', '127.0.0.1',
|
|
517
|
+
'--port', String(state.port), '--log-level', 'warning',
|
|
518
|
+
], {
|
|
519
|
+
cwd: state.serverDir, windowsHide: true, stdio: ['ignore', fd, fd],
|
|
520
|
+
env: { ...process.env, DSH_IMAGE_POOL_PORT: String(poolPort) },
|
|
521
|
+
});
|
|
522
|
+
state.serverChild = child;
|
|
523
|
+
const forget = message => {
|
|
524
|
+
if (state.serverChild !== child) return;
|
|
525
|
+
state.lastServerStart = message; state.serverChild = null;
|
|
526
|
+
};
|
|
527
|
+
child.once('exit', code => forget('工作台进程退出 code=' + code));
|
|
528
|
+
child.once('error', error => forget('工作台启动失败:' + error.message));
|
|
529
|
+
} catch (error) {
|
|
530
|
+
return { running: false, started: false, reason: '工作台启动失败:' + error.message };
|
|
531
|
+
}
|
|
532
|
+
for (let i = 0; i < 24; i++) {
|
|
533
|
+
await sleep(500);
|
|
534
|
+
if (await probe(state.port)) return { running: true, started: true };
|
|
535
|
+
if (!state.serverChild) break;
|
|
536
|
+
}
|
|
537
|
+
return { running: false, started: true, reason: state.lastServerStart || '工作台启动超时,请看 ' + join(RUNTIME_DIR, 'backend.log') };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (state.autoStart) void ensureServer().then(r => {
|
|
541
|
+
if (!r.running) console.warn('[image-workbench] 工作台未就绪:' + (r.reason || ''));
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const watchdogTick = async () => {
|
|
545
|
+
if (!watchdog.enabled || watchdog.busy || Date.now() < watchdog.nextAllowedAt) return;
|
|
546
|
+
watchdog.busy = true;
|
|
547
|
+
try {
|
|
548
|
+
const r = await ensureServer();
|
|
549
|
+
if (r.running) {
|
|
550
|
+
watchdog.failures = 0; watchdog.nextAllowedAt = 0;
|
|
551
|
+
if (r.started) { watchdog.restarts += 1; watchdog.lastRestart = new Date().toISOString();
|
|
552
|
+
console.warn('[image-workbench] 守护已自动拉起掉线的工作台'); }
|
|
553
|
+
} else {
|
|
554
|
+
watchdog.failures += 1;
|
|
555
|
+
const backoff = Math.min(watchdog.intervalMs * 2 ** Math.min(watchdog.failures - 1, 5), 300000);
|
|
556
|
+
watchdog.nextAllowedAt = Date.now() + backoff;
|
|
557
|
+
console.warn('[image-workbench] 守护拉起失败(第 ' + watchdog.failures + ' 次),'
|
|
558
|
+
+ Math.round(backoff / 1000) + ' 秒后重试:' + (r.reason || '未知原因'));
|
|
559
|
+
}
|
|
560
|
+
} finally { watchdog.busy = false; }
|
|
561
|
+
};
|
|
562
|
+
if (watchdog.enabled) {
|
|
563
|
+
watchdog.timer = setInterval(() => { void watchdogTick(); }, watchdog.intervalMs);
|
|
564
|
+
if (typeof watchdog.timer.unref === 'function') watchdog.timer.unref();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 最近一次主动动作的结果:client 据此给出「打开了 / 失败了」的明确反馈
|
|
568
|
+
const lastAction = { action: '', ok: null, via: '', error: '', at: '' };
|
|
569
|
+
const note = (action, result) => {
|
|
570
|
+
lastAction.action = action;
|
|
571
|
+
lastAction.ok = result.ok;
|
|
572
|
+
lastAction.via = result.via || '';
|
|
573
|
+
lastAction.error = result.error || '';
|
|
574
|
+
lastAction.at = new Date().toISOString();
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const route = {
|
|
578
|
+
path: '/api/image-workbench.status',
|
|
579
|
+
methods: ['GET', 'POST'],
|
|
580
|
+
requestBody: 'buffered',
|
|
581
|
+
async fetch(request) {
|
|
582
|
+
let action = 'status';
|
|
583
|
+
if (request.method === 'POST') {
|
|
584
|
+
try { action = String((await request.json()).action || 'status'); } catch { /* 空 body */ }
|
|
585
|
+
}
|
|
586
|
+
if (action === 'start') await ensureServer(true);
|
|
587
|
+
else if (action === 'open') {
|
|
588
|
+
const result = await openExternal(state.url);
|
|
589
|
+
note('open', result);
|
|
590
|
+
if (result.ok) console.log('[image-workbench] 已通过 ' + result.via + ' 打开 ' + state.url);
|
|
591
|
+
else console.warn('[image-workbench] 打开浏览器失败:' + result.error);
|
|
592
|
+
}
|
|
593
|
+
const running = await probe(state.port);
|
|
594
|
+
// 号池状态由工作台后端上报(它才是号池的 owner),宿主只读不算数
|
|
595
|
+
let pool = null;
|
|
596
|
+
if (running) {
|
|
597
|
+
try {
|
|
598
|
+
const r = await localGetJson(state.port, '/api/pool/service');
|
|
599
|
+
if (r.status === 200) pool = r.data;
|
|
600
|
+
} catch { /* 后端正在启动,下次刷新就有了 */ }
|
|
601
|
+
}
|
|
602
|
+
return Response.json({
|
|
603
|
+
ok: true,
|
|
604
|
+
name: '生图工作台(图文 / 摄影)',
|
|
605
|
+
url: state.url,
|
|
606
|
+
port: state.port,
|
|
607
|
+
running,
|
|
608
|
+
pool,
|
|
609
|
+
poolPort,
|
|
610
|
+
serverDir: state.serverDir,
|
|
611
|
+
python: state.python,
|
|
612
|
+
ownedProcess: Boolean(state.serverChild),
|
|
613
|
+
venv: {
|
|
614
|
+
ready: venvState.ok,
|
|
615
|
+
tried: venvState.tried,
|
|
616
|
+
attempts: venvState.attempts,
|
|
617
|
+
message: venvState.message,
|
|
618
|
+
error: venvState.error,
|
|
619
|
+
log: join(RUNTIME_DIR, 'venv.log'),
|
|
620
|
+
retryInMs: Math.max(0, venvState.nextRetryAt - Date.now()),
|
|
621
|
+
},
|
|
622
|
+
lastError: state.lastServerStart,
|
|
623
|
+
// 进程起来又退出时,真正的原因在 backend.log 末尾(缺依赖、端口被占…)
|
|
624
|
+
backendTail: tailOf(join(RUNTIME_DIR, 'backend.log'), 900),
|
|
625
|
+
lastAction,
|
|
626
|
+
engine: '内置专用生图号池 /v1/images/generations(运行时随插件自带,全新机器自动初始化;工作台自拉起、自守护)',
|
|
627
|
+
watchdog: {
|
|
628
|
+
enabled: watchdog.enabled,
|
|
629
|
+
intervalMs: watchdog.intervalMs,
|
|
630
|
+
restarts: watchdog.restarts,
|
|
631
|
+
consecutiveFailures: watchdog.failures,
|
|
632
|
+
retryInMs: Math.max(0, watchdog.nextAllowedAt - Date.now()),
|
|
633
|
+
lastRestart: watchdog.lastRestart,
|
|
634
|
+
},
|
|
635
|
+
log: join(RUNTIME_DIR, 'backend.log'),
|
|
636
|
+
}, { headers: { 'Cache-Control': 'no-store' } });
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => ctx.connection.fetch.register(route));
|
|
640
|
+
else ctx.connection.fetch.register(route);
|
|
641
|
+
}
|