@honlnk/image-studio-companion 0.1.0
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.md +232 -0
- package/dist/credentials.d.ts +11 -0
- package/dist/credentials.js +42 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +129 -0
- package/dist/middleware/auth.d.ts +2 -0
- package/dist/middleware/auth.js +16 -0
- package/dist/pairingState.d.ts +16 -0
- package/dist/pairingState.js +106 -0
- package/dist/routes/auth.d.ts +2 -0
- package/dist/routes/auth.js +20 -0
- package/dist/routes/images.d.ts +8 -0
- package/dist/routes/images.js +107 -0
- package/dist/routes/pair.d.ts +6 -0
- package/dist/routes/pair.js +15 -0
- package/dist/securityConfig.d.ts +19 -0
- package/dist/securityConfig.js +67 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +50 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.js +1 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 鸿影
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# GPT Image Studio Companion CLI
|
|
2
|
+
|
|
3
|
+
本地 CLI 助手,为 GPT Image Studio 网页端提供安全的 API 凭据代理服务。
|
|
4
|
+
|
|
5
|
+
## 安装与运行
|
|
6
|
+
|
|
7
|
+
### npm 安装
|
|
8
|
+
|
|
9
|
+
推荐用户通过 npm 全局安装:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @honlnk/image-studio-companion
|
|
13
|
+
gpt-image-studio login
|
|
14
|
+
gpt-image-studio serve --channel stable
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
已安装 pnpm 的用户也可以使用:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm add -g @honlnk/image-studio-companion
|
|
21
|
+
gpt-image-studio login
|
|
22
|
+
gpt-image-studio serve --channel stable
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 从源码开发运行
|
|
26
|
+
|
|
27
|
+
项目使用 pnpm workspace。在仓库根目录执行:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pnpm install
|
|
31
|
+
pnpm dev:companion
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`pnpm dev:companion` 会以开发渠道启动服务,默认允许本地 Web App origin。
|
|
35
|
+
|
|
36
|
+
### 从源码构建后运行
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pnpm --filter @honlnk/image-studio-companion build
|
|
40
|
+
pnpm --filter @honlnk/image-studio-companion start
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
也可以直接调用入口文件:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx tsx companion/src/main.ts serve --port 19750
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 生产渠道运行
|
|
50
|
+
|
|
51
|
+
通过 npm 安装后,生产渠道默认只允许正式站点访问本地 companion:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
gpt-image-studio serve --channel stable
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
如需临时允许额外调试页面,必须显式提供完整 origin:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
gpt-image-studio serve --channel stable --allow-origin http://localhost:5173
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
启动后监听 `127.0.0.1:19750`,等待网页端发起配对连接。
|
|
64
|
+
|
|
65
|
+
## 命令
|
|
66
|
+
|
|
67
|
+
### `serve` — 启动本地服务
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
gpt-image-studio serve
|
|
71
|
+
# 源码开发时也可以直接调用入口文件
|
|
72
|
+
npx tsx companion/src/main.ts serve
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
常用参数:
|
|
76
|
+
|
|
77
|
+
| 参数 | 说明 |
|
|
78
|
+
|------|------|
|
|
79
|
+
| `--port <port>` | 指定监听端口,默认 `19750` |
|
|
80
|
+
| `--channel stable|dev` | 指定安全渠道;stable 只允许正式站点,dev 额外允许本地开发 origin |
|
|
81
|
+
| `--allow-origin <origin...>` | 追加允许的完整 origin,不支持通配符 |
|
|
82
|
+
| `--session-ttl-days <days>` | 指定配对 session 有效天数,默认 30 天 |
|
|
83
|
+
|
|
84
|
+
### `login` — 配置 API 凭据
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
gpt-image-studio login
|
|
88
|
+
# 源码开发时
|
|
89
|
+
npx tsx companion/src/main.ts login
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
交互式输入:
|
|
93
|
+
|
|
94
|
+
1. **API Base URL** — 回车使用默认值 `https://api.openai.com/v1/images`
|
|
95
|
+
2. **API Key** — 输入时不回显
|
|
96
|
+
|
|
97
|
+
凭据保存到 `~/.gpt-image-studio/credentials.json`。
|
|
98
|
+
|
|
99
|
+
### `status` — 查看状态
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
gpt-image-studio status
|
|
103
|
+
# 源码开发时
|
|
104
|
+
npx tsx companion/src/main.ts status
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
显示:
|
|
108
|
+
- 凭据配置情况(Base URL + 脱敏后的 API Key)
|
|
109
|
+
- 配对状态
|
|
110
|
+
- 服务是否运行
|
|
111
|
+
|
|
112
|
+
### `logout` — 清除凭据
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
gpt-image-studio logout
|
|
116
|
+
# 源码开发时
|
|
117
|
+
npx tsx companion/src/main.ts logout
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
删除本地保存的 API 凭据文件。
|
|
121
|
+
|
|
122
|
+
### `unpair` — 清除网页端配对
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
gpt-image-studio unpair
|
|
126
|
+
# 源码开发时
|
|
127
|
+
npx tsx companion/src/main.ts unpair
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
删除本地保存的配对 session,不会清除 API 凭据。
|
|
131
|
+
|
|
132
|
+
## 配对流程
|
|
133
|
+
|
|
134
|
+
1. 启动 companion 服务(`pnpm dev:companion`)
|
|
135
|
+
2. 在网页端设置中切换到「本地 Companion」模式
|
|
136
|
+
3. 点击配对,终端会显示 6 位配对码
|
|
137
|
+
4. 在网页端输入配对码完成连接
|
|
138
|
+
|
|
139
|
+
配对码有效期 5 分钟。配对成功后,session token 保存在 `~/.gpt-image-studio/session.json`,默认有效期 30 天,下次启动服务时自动恢复。可以通过 `--session-ttl-days` 调整有效天数。
|
|
140
|
+
|
|
141
|
+
## 数据目录
|
|
142
|
+
|
|
143
|
+
所有本地状态保存在 `~/.gpt-image-studio/`:
|
|
144
|
+
|
|
145
|
+
| 文件 | 内容 |
|
|
146
|
+
|------|------|
|
|
147
|
+
| `credentials.json` | API Base URL + API Key |
|
|
148
|
+
| `session.json` | 配对 session token |
|
|
149
|
+
|
|
150
|
+
## 升级
|
|
151
|
+
|
|
152
|
+
### npm 安装升级
|
|
153
|
+
|
|
154
|
+
使用全局安装的用户可以通过同一包管理器升级:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm update -g @honlnk/image-studio-companion
|
|
158
|
+
# 或
|
|
159
|
+
pnpm update -g @honlnk/image-studio-companion
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
升级后建议运行:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
gpt-image-studio status
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 源码升级
|
|
169
|
+
|
|
170
|
+
从源码升级时,在仓库根目录拉取最新代码并重新安装依赖:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
git pull
|
|
174
|
+
pnpm install
|
|
175
|
+
pnpm --filter @honlnk/image-studio-companion build
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
升级不会自动删除 `~/.gpt-image-studio/` 中的凭据和配对 session。升级后建议运行:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
npx tsx companion/src/main.ts status
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
如果服务端口、正式站点 origin 或 session 策略发生变化,重新启动 `serve` 后按网页端提示重新配对。
|
|
185
|
+
|
|
186
|
+
## 卸载与清理
|
|
187
|
+
|
|
188
|
+
### npm 卸载
|
|
189
|
+
|
|
190
|
+
使用全局安装的用户可以执行:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npm uninstall -g @honlnk/image-studio-companion
|
|
194
|
+
# 或
|
|
195
|
+
pnpm remove -g @honlnk/image-studio-companion
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
卸载 npm 包不会自动删除 `~/.gpt-image-studio/` 中的凭据和配对 session。
|
|
199
|
+
|
|
200
|
+
### 本地状态清理
|
|
201
|
+
|
|
202
|
+
停止 companion 进程后,可以按需要清理本地状态:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
# 只清除 API 凭据
|
|
206
|
+
gpt-image-studio logout
|
|
207
|
+
# 源码开发时
|
|
208
|
+
npx tsx companion/src/main.ts logout
|
|
209
|
+
|
|
210
|
+
# 只清除网页端配对 session
|
|
211
|
+
gpt-image-studio unpair
|
|
212
|
+
# 源码开发时
|
|
213
|
+
npx tsx companion/src/main.ts unpair
|
|
214
|
+
|
|
215
|
+
# 完整删除 companion 本地状态
|
|
216
|
+
rm -rf ~/.gpt-image-studio
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
如果只是切换 API key,运行 `login` 覆盖当前凭据即可,不需要删除整个目录。
|
|
220
|
+
|
|
221
|
+
## 安全说明
|
|
222
|
+
|
|
223
|
+
- 服务仅监听 `127.0.0.1`,不对外暴露
|
|
224
|
+
- CORS 白名单默认只允许 `https://gpt-image.honlnk.com`
|
|
225
|
+
- `--channel dev` 会额外允许 `http://127.0.0.1:8888` 和 `http://localhost:8888`
|
|
226
|
+
- `--allow-origin` 只接受完整 origin,不支持通配符
|
|
227
|
+
- 非公开端点需要配对后的 Bearer token 鉴权
|
|
228
|
+
- 网页端无法读取真实 API Key,只能通过代理发起请求
|
|
229
|
+
- 代理请求会限制 body 大小、引用图片数量和图片 MIME 类型
|
|
230
|
+
- 日志会脱敏 Authorization、API key 和图片 base64 字段
|
|
231
|
+
- 凭据和 session 文件会以 `0600` 权限写入
|
|
232
|
+
- 凭据当前以明文 JSON 保存,请确保在个人设备上使用
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Credentials = {
|
|
2
|
+
apiBaseUrl: string;
|
|
3
|
+
apiKey: string;
|
|
4
|
+
savedAt: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function loadCredentials(): Credentials | null;
|
|
7
|
+
export declare function saveCredentials(apiBaseUrl: string, apiKey: string): void;
|
|
8
|
+
export declare function clearCredentials(): void;
|
|
9
|
+
export declare function hasCredentials(): boolean;
|
|
10
|
+
export declare function maskApiKey(apiKey: string): string;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const CONFIG_DIR = join(homedir(), ".gpt-image-studio");
|
|
5
|
+
const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
|
|
6
|
+
export function loadCredentials() {
|
|
7
|
+
try {
|
|
8
|
+
if (!existsSync(CREDENTIALS_FILE))
|
|
9
|
+
return null;
|
|
10
|
+
const data = JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8"));
|
|
11
|
+
if (!data.apiBaseUrl || !data.apiKey)
|
|
12
|
+
return null;
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function saveCredentials(apiBaseUrl, apiKey) {
|
|
20
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
21
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
22
|
+
}
|
|
23
|
+
const data = { apiBaseUrl, apiKey, savedAt: new Date().toISOString() };
|
|
24
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
25
|
+
chmodSync(CREDENTIALS_FILE, 0o600);
|
|
26
|
+
}
|
|
27
|
+
export function clearCredentials() {
|
|
28
|
+
try {
|
|
29
|
+
if (existsSync(CREDENTIALS_FILE)) {
|
|
30
|
+
unlinkSync(CREDENTIALS_FILE);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
}
|
|
35
|
+
export function hasCredentials() {
|
|
36
|
+
return loadCredentials() !== null;
|
|
37
|
+
}
|
|
38
|
+
export function maskApiKey(apiKey) {
|
|
39
|
+
if (apiKey.length <= 8)
|
|
40
|
+
return "***";
|
|
41
|
+
return apiKey.slice(0, 8) + "***";
|
|
42
|
+
}
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { program } from "commander";
|
|
4
|
+
import { loadCredentials, saveCredentials, clearCredentials, maskApiKey } from "./credentials.js";
|
|
5
|
+
import { clearSession, getSessionInfo, loadSession } from "./pairingState.js";
|
|
6
|
+
import { createSecurityConfig } from "./securityConfig.js";
|
|
7
|
+
const VERSION = "0.1.0";
|
|
8
|
+
program
|
|
9
|
+
.name("gpt-image-studio")
|
|
10
|
+
.description("GPT Image Studio 本地 CLI Companion")
|
|
11
|
+
.version(VERSION);
|
|
12
|
+
program
|
|
13
|
+
.command("serve")
|
|
14
|
+
.description("启动本地 companion HTTP 服务")
|
|
15
|
+
.option("-p, --port <port>", "监听端口", "19750")
|
|
16
|
+
.option("--channel <channel>", "安全渠道:stable 或 dev", process.env.GPT_IMAGE_STUDIO_COMPANION_CHANNEL)
|
|
17
|
+
.option("--allow-origin <origin...>", "额外允许的完整 origin,例如 http://localhost:5173")
|
|
18
|
+
.option("--session-ttl-days <days>", "配对 session 有效天数", "30")
|
|
19
|
+
.action(async (opts) => {
|
|
20
|
+
const { startServer } = await import("./server.js");
|
|
21
|
+
await startServer({
|
|
22
|
+
port: Number(opts.port),
|
|
23
|
+
security: createSecurityConfig({
|
|
24
|
+
channel: opts.channel,
|
|
25
|
+
allowOrigins: opts.allowOrigin ?? [],
|
|
26
|
+
sessionTtlDays: Number(opts.sessionTtlDays),
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
program
|
|
31
|
+
.command("login")
|
|
32
|
+
.description("配置 API 凭据")
|
|
33
|
+
.action(async () => {
|
|
34
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
35
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
36
|
+
const apiBaseUrl = (await ask("API Base URL (默认 https://api.openai.com/v1/images): ")).trim()
|
|
37
|
+
|| "https://api.openai.com/v1/images";
|
|
38
|
+
const apiKey = await new Promise((resolve) => {
|
|
39
|
+
process.stdout.write("API Key: ");
|
|
40
|
+
const stdin = process.stdin;
|
|
41
|
+
const wasRaw = stdin.isRaw;
|
|
42
|
+
if (stdin.isTTY)
|
|
43
|
+
stdin.setRawMode(true);
|
|
44
|
+
let input = "";
|
|
45
|
+
const onData = (ch) => {
|
|
46
|
+
const c = ch.toString();
|
|
47
|
+
if (c === "\n" || c === "\r") {
|
|
48
|
+
stdin.removeListener("data", onData);
|
|
49
|
+
if (stdin.isTTY)
|
|
50
|
+
stdin.setRawMode(wasRaw ?? false);
|
|
51
|
+
process.stdout.write("\n");
|
|
52
|
+
resolve(input);
|
|
53
|
+
}
|
|
54
|
+
else if (c === "" || c === "\b") {
|
|
55
|
+
input = input.slice(0, -1);
|
|
56
|
+
}
|
|
57
|
+
else if (c === "") {
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
input += c;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
stdin.resume();
|
|
65
|
+
stdin.on("data", onData);
|
|
66
|
+
});
|
|
67
|
+
rl.close();
|
|
68
|
+
if (!apiKey.trim()) {
|
|
69
|
+
console.log("未输入 API Key,取消操作。");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
saveCredentials(apiBaseUrl, apiKey.trim());
|
|
73
|
+
console.log("");
|
|
74
|
+
console.log("凭据已保存。");
|
|
75
|
+
console.log(` API Base URL: ${apiBaseUrl}`);
|
|
76
|
+
console.log(` API Key: ${maskApiKey(apiKey.trim())}`);
|
|
77
|
+
});
|
|
78
|
+
program
|
|
79
|
+
.command("status")
|
|
80
|
+
.description("查看 companion 状态")
|
|
81
|
+
.action(async () => {
|
|
82
|
+
loadSession();
|
|
83
|
+
const creds = loadCredentials();
|
|
84
|
+
const session = getSessionInfo();
|
|
85
|
+
console.log("┌─────────────────────────────────┐");
|
|
86
|
+
console.log("│ GPT Image Studio Companion │");
|
|
87
|
+
console.log("└─────────────────────────────────┘");
|
|
88
|
+
console.log("");
|
|
89
|
+
if (creds) {
|
|
90
|
+
console.log(`凭据: 已配置`);
|
|
91
|
+
console.log(` Base URL: ${creds.apiBaseUrl}`);
|
|
92
|
+
console.log(` API Key: ${maskApiKey(creds.apiKey)}`);
|
|
93
|
+
console.log(` 保存时间: ${creds.savedAt}`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
console.log("凭据: 未配置(运行 gpt-image-studio login 进行配置)");
|
|
97
|
+
}
|
|
98
|
+
console.log(`配对: ${session.paired ? "已配对" : "未配对"}`);
|
|
99
|
+
if (session.expiresAt) {
|
|
100
|
+
console.log(` 过期时间: ${session.expiresAt}`);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetch("http://127.0.0.1:19750/health", { signal: AbortSignal.timeout(2000) });
|
|
104
|
+
if (res.ok) {
|
|
105
|
+
console.log("服务: 运行中 (127.0.0.1:19750)");
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
console.log("服务: 未运行");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
console.log("服务: 未运行");
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
program
|
|
116
|
+
.command("logout")
|
|
117
|
+
.description("清除已保存的凭据")
|
|
118
|
+
.action(async () => {
|
|
119
|
+
clearCredentials();
|
|
120
|
+
console.log("凭据已清除。");
|
|
121
|
+
});
|
|
122
|
+
program
|
|
123
|
+
.command("unpair")
|
|
124
|
+
.description("清除网页端配对 session")
|
|
125
|
+
.action(async () => {
|
|
126
|
+
clearSession();
|
|
127
|
+
console.log("配对 session 已清除。");
|
|
128
|
+
});
|
|
129
|
+
program.parse();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { validateToken } from "../pairingState.js";
|
|
2
|
+
const PUBLIC_PATHS = ["/health", "/pair/start", "/pair/confirm"];
|
|
3
|
+
export async function authMiddleware(app) {
|
|
4
|
+
app.addHook("onRequest", async (req, reply) => {
|
|
5
|
+
if (PUBLIC_PATHS.includes(req.url))
|
|
6
|
+
return;
|
|
7
|
+
const authHeader = req.headers.authorization;
|
|
8
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
9
|
+
return reply.status(401).send({ error: "未授权:缺少 session token" });
|
|
10
|
+
}
|
|
11
|
+
const token = authHeader.slice(7);
|
|
12
|
+
if (!validateToken(token)) {
|
|
13
|
+
return reply.status(401).send({ error: "未授权:token 无效" });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function loadSession(): void;
|
|
2
|
+
export declare function isPaired(): boolean;
|
|
3
|
+
export declare function getSessionInfo(): {
|
|
4
|
+
paired: boolean;
|
|
5
|
+
expiresAt: string | null;
|
|
6
|
+
};
|
|
7
|
+
export declare function startPairing(): {
|
|
8
|
+
pairingCode: string;
|
|
9
|
+
expiresInSeconds: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function confirmPairing(code: string, ttlMs?: number): {
|
|
12
|
+
sessionToken: string;
|
|
13
|
+
expiresAt: string;
|
|
14
|
+
} | null;
|
|
15
|
+
export declare function validateToken(token: string): boolean;
|
|
16
|
+
export declare function clearSession(): void;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomUUID, randomInt } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".gpt-image-studio");
|
|
6
|
+
const SESSION_FILE = join(CONFIG_DIR, "session.json");
|
|
7
|
+
const PAIRING_CODE_EXPIRY_MS = 5 * 60 * 1000;
|
|
8
|
+
const DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
9
|
+
let activePairingCode = null;
|
|
10
|
+
let pairingCodeExpiresAt = 0;
|
|
11
|
+
let sessionToken = null;
|
|
12
|
+
let sessionExpiresAt = null;
|
|
13
|
+
export function loadSession() {
|
|
14
|
+
try {
|
|
15
|
+
if (existsSync(SESSION_FILE)) {
|
|
16
|
+
const data = JSON.parse(readFileSync(SESSION_FILE, "utf-8"));
|
|
17
|
+
if (!data.token || !data.expiresAt) {
|
|
18
|
+
sessionToken = null;
|
|
19
|
+
sessionExpiresAt = null;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const expiresAt = Date.parse(data.expiresAt);
|
|
23
|
+
if (!Number.isFinite(expiresAt) || Date.now() >= expiresAt) {
|
|
24
|
+
clearSession();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
sessionToken = data.token;
|
|
28
|
+
sessionExpiresAt = expiresAt;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
sessionToken = null;
|
|
33
|
+
sessionExpiresAt = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function saveSession(token, ttlMs) {
|
|
37
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
38
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
39
|
+
}
|
|
40
|
+
const createdAt = new Date();
|
|
41
|
+
const expiresAt = new Date(createdAt.getTime() + ttlMs);
|
|
42
|
+
const data = {
|
|
43
|
+
token,
|
|
44
|
+
createdAt: createdAt.toISOString(),
|
|
45
|
+
expiresAt: expiresAt.toISOString(),
|
|
46
|
+
};
|
|
47
|
+
writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
48
|
+
chmodSync(SESSION_FILE, 0o600);
|
|
49
|
+
sessionExpiresAt = expiresAt.getTime();
|
|
50
|
+
}
|
|
51
|
+
export function isPaired() {
|
|
52
|
+
return getSessionInfo().paired;
|
|
53
|
+
}
|
|
54
|
+
export function getSessionInfo() {
|
|
55
|
+
if (!sessionToken || !sessionExpiresAt) {
|
|
56
|
+
return { paired: false, expiresAt: null };
|
|
57
|
+
}
|
|
58
|
+
if (Date.now() >= sessionExpiresAt) {
|
|
59
|
+
clearSession();
|
|
60
|
+
return { paired: false, expiresAt: null };
|
|
61
|
+
}
|
|
62
|
+
return { paired: true, expiresAt: new Date(sessionExpiresAt).toISOString() };
|
|
63
|
+
}
|
|
64
|
+
export function startPairing() {
|
|
65
|
+
activePairingCode = String(randomInt(100000, 999999));
|
|
66
|
+
pairingCodeExpiresAt = Date.now() + PAIRING_CODE_EXPIRY_MS;
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log("┌─────────────────────────────────┐");
|
|
69
|
+
console.log("│ 配对码: " + activePairingCode + " │");
|
|
70
|
+
console.log("│ 请在网页端输入此配对码 │");
|
|
71
|
+
console.log("│ 有效期 5 分钟 │");
|
|
72
|
+
console.log("└─────────────────────────────────┘");
|
|
73
|
+
console.log("");
|
|
74
|
+
return {
|
|
75
|
+
pairingCode: activePairingCode,
|
|
76
|
+
expiresInSeconds: Math.floor(PAIRING_CODE_EXPIRY_MS / 1000),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function confirmPairing(code, ttlMs = DEFAULT_SESSION_TTL_MS) {
|
|
80
|
+
if (!activePairingCode)
|
|
81
|
+
return null;
|
|
82
|
+
if (Date.now() > pairingCodeExpiresAt) {
|
|
83
|
+
activePairingCode = null;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (code !== activePairingCode)
|
|
87
|
+
return null;
|
|
88
|
+
activePairingCode = null;
|
|
89
|
+
sessionToken = randomUUID();
|
|
90
|
+
saveSession(sessionToken, ttlMs);
|
|
91
|
+
console.log("配对成功!");
|
|
92
|
+
return { sessionToken, expiresAt: getSessionInfo().expiresAt };
|
|
93
|
+
}
|
|
94
|
+
export function validateToken(token) {
|
|
95
|
+
return getSessionInfo().paired && token === sessionToken;
|
|
96
|
+
}
|
|
97
|
+
export function clearSession() {
|
|
98
|
+
sessionToken = null;
|
|
99
|
+
sessionExpiresAt = null;
|
|
100
|
+
try {
|
|
101
|
+
if (existsSync(SESSION_FILE)) {
|
|
102
|
+
unlinkSync(SESSION_FILE);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch { }
|
|
106
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { loadCredentials, maskApiKey } from "../credentials.js";
|
|
2
|
+
export async function authRoutes(app) {
|
|
3
|
+
app.get("/auth/status", async () => {
|
|
4
|
+
const creds = loadCredentials();
|
|
5
|
+
if (!creds) {
|
|
6
|
+
return {
|
|
7
|
+
provider: "openai",
|
|
8
|
+
mode: "api_key",
|
|
9
|
+
ready: false,
|
|
10
|
+
accountLabel: "",
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
provider: "openai",
|
|
15
|
+
mode: "api_key",
|
|
16
|
+
ready: true,
|
|
17
|
+
accountLabel: maskApiKey(creds.apiKey),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import type { CompanionSecurityConfig } from "../securityConfig.js";
|
|
3
|
+
type ImagesRoutesOptions = {
|
|
4
|
+
security: CompanionSecurityConfig;
|
|
5
|
+
};
|
|
6
|
+
export declare function imagesRoutes(app: FastifyInstance, opts: ImagesRoutesOptions): Promise<void>;
|
|
7
|
+
export declare function validateEditMultipart(body: Buffer, security: CompanionSecurityConfig): string | null;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { loadCredentials } from "../credentials.js";
|
|
2
|
+
export async function imagesRoutes(app, opts) {
|
|
3
|
+
app.post("/images/generations", async (req, reply) => {
|
|
4
|
+
const creds = loadCredentials();
|
|
5
|
+
if (!creds) {
|
|
6
|
+
return reply.status(503).send({ error: "Companion 未配置凭据,请先运行 login" });
|
|
7
|
+
}
|
|
8
|
+
if (!isJsonRequest(req.headers["content-type"])) {
|
|
9
|
+
return reply.status(415).send({ error: "请求 Content-Type 必须是 application/json" });
|
|
10
|
+
}
|
|
11
|
+
const body = req.body;
|
|
12
|
+
const validationError = validateGenerationBody(body);
|
|
13
|
+
if (validationError) {
|
|
14
|
+
return reply.status(400).send({ error: validationError });
|
|
15
|
+
}
|
|
16
|
+
const apiUrl = `${creds.apiBaseUrl.replace(/\/+$/, "")}/generations`;
|
|
17
|
+
const response = await fetch(apiUrl, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: {
|
|
20
|
+
"Authorization": `Bearer ${creds.apiKey}`,
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
});
|
|
25
|
+
const payload = await response.text();
|
|
26
|
+
return reply.status(response.status).header("content-type", "application/json").send(payload);
|
|
27
|
+
});
|
|
28
|
+
app.addContentTypeParser("multipart/form-data", function (_req, payload, done) {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
payload.on("data", (chunk) => chunks.push(chunk));
|
|
31
|
+
payload.on("end", () => done(null, Buffer.concat(chunks)));
|
|
32
|
+
payload.on("error", done);
|
|
33
|
+
});
|
|
34
|
+
app.post("/images/edits", { bodyLimit: opts.security.maxEditBodyBytes }, async (req, reply) => {
|
|
35
|
+
const creds = loadCredentials();
|
|
36
|
+
if (!creds) {
|
|
37
|
+
return reply.status(503).send({ error: "Companion 未配置凭据,请先运行 login" });
|
|
38
|
+
}
|
|
39
|
+
if (!isMultipartRequest(req.headers["content-type"])) {
|
|
40
|
+
return reply.status(415).send({ error: "请求 Content-Type 必须是 multipart/form-data" });
|
|
41
|
+
}
|
|
42
|
+
const apiUrl = `${creds.apiBaseUrl.replace(/\/+$/, "")}/edits`;
|
|
43
|
+
const contentType = req.headers["content-type"];
|
|
44
|
+
const rawBody = req.body;
|
|
45
|
+
const validationError = validateEditMultipart(rawBody, opts.security);
|
|
46
|
+
if (validationError) {
|
|
47
|
+
return reply.status(400).send({ error: validationError });
|
|
48
|
+
}
|
|
49
|
+
const response = await fetch(apiUrl, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"Authorization": `Bearer ${creds.apiKey}`,
|
|
53
|
+
"Content-Type": contentType,
|
|
54
|
+
},
|
|
55
|
+
body: new Uint8Array(rawBody),
|
|
56
|
+
});
|
|
57
|
+
const payload = await response.text();
|
|
58
|
+
return reply.status(response.status).header("content-type", "application/json").send(payload);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function isJsonRequest(contentType) {
|
|
62
|
+
return contentType?.toLowerCase().split(";")[0]?.trim() === "application/json";
|
|
63
|
+
}
|
|
64
|
+
function isMultipartRequest(contentType) {
|
|
65
|
+
return contentType?.toLowerCase().startsWith("multipart/form-data") ?? false;
|
|
66
|
+
}
|
|
67
|
+
function validateGenerationBody(body) {
|
|
68
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
69
|
+
return "请求体必须是 JSON object";
|
|
70
|
+
}
|
|
71
|
+
if (typeof body.model !== "string" || !body.model.trim()) {
|
|
72
|
+
return "缺少 model";
|
|
73
|
+
}
|
|
74
|
+
if (typeof body.prompt !== "string" || !body.prompt.trim()) {
|
|
75
|
+
return "缺少 prompt";
|
|
76
|
+
}
|
|
77
|
+
if ("b64_json" in body || "image" in body || "image[]" in body) {
|
|
78
|
+
return "文生图请求不能包含图片内容";
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
export function validateEditMultipart(body, security) {
|
|
83
|
+
const text = body.toString("latin1");
|
|
84
|
+
const imagePartNames = [...text.matchAll(/name="image(?:\[\])?"/g)];
|
|
85
|
+
if (imagePartNames.length === 0) {
|
|
86
|
+
return "编辑请求至少需要一张引用图片";
|
|
87
|
+
}
|
|
88
|
+
if (imagePartNames.length > security.maxEditImages) {
|
|
89
|
+
return `编辑请求最多支持 ${security.maxEditImages} 张引用图片`;
|
|
90
|
+
}
|
|
91
|
+
const partHeaders = text.match(/Content-Disposition:[\s\S]*?(?=\r\n\r\n)/g) ?? [];
|
|
92
|
+
for (const header of partHeaders) {
|
|
93
|
+
if (!/name="(?:image(?:\[\])?|mask)"/.test(header))
|
|
94
|
+
continue;
|
|
95
|
+
const mime = /Content-Type:\s*([^\r\n]+)/i.exec(header)?.[1]?.trim().toLowerCase();
|
|
96
|
+
if (!mime) {
|
|
97
|
+
return "图片 part 缺少 Content-Type";
|
|
98
|
+
}
|
|
99
|
+
if (/name="mask"/.test(header) && mime !== "image/png") {
|
|
100
|
+
return "mask 必须是 image/png";
|
|
101
|
+
}
|
|
102
|
+
if (/name="image(?:\[\])?"/.test(header) && !security.allowedEditImageMimeTypes.includes(mime)) {
|
|
103
|
+
return `不支持的图片类型:${mime}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { startPairing, confirmPairing } from "../pairingState.js";
|
|
2
|
+
export async function pairRoutes(app, opts) {
|
|
3
|
+
app.post("/pair/start", async (_req, reply) => {
|
|
4
|
+
const result = startPairing();
|
|
5
|
+
return reply.send(result);
|
|
6
|
+
});
|
|
7
|
+
app.post("/pair/confirm", async (req, reply) => {
|
|
8
|
+
const { pairingCode } = req.body;
|
|
9
|
+
const result = confirmPairing(pairingCode, opts.sessionTtlMs);
|
|
10
|
+
if (!result) {
|
|
11
|
+
return reply.status(401).send({ error: "配对码无效或已过期" });
|
|
12
|
+
}
|
|
13
|
+
return reply.send(result);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type CompanionChannel = "stable" | "dev";
|
|
2
|
+
export type CompanionSecurityConfig = {
|
|
3
|
+
channel: CompanionChannel;
|
|
4
|
+
allowedOrigins: string[];
|
|
5
|
+
sessionTtlMs: number;
|
|
6
|
+
maxJsonBodyBytes: number;
|
|
7
|
+
maxEditBodyBytes: number;
|
|
8
|
+
maxEditImages: number;
|
|
9
|
+
allowedEditImageMimeTypes: string[];
|
|
10
|
+
};
|
|
11
|
+
export declare function resolveChannel(value: string | undefined): CompanionChannel;
|
|
12
|
+
export declare function normalizeOrigin(origin: string): string;
|
|
13
|
+
export declare function parseAllowOrigins(values?: string[]): string[];
|
|
14
|
+
export declare function createSecurityConfig(opts?: {
|
|
15
|
+
channel?: string;
|
|
16
|
+
allowOrigins?: string[];
|
|
17
|
+
sessionTtlDays?: number;
|
|
18
|
+
}): CompanionSecurityConfig;
|
|
19
|
+
export declare function isOriginAllowed(origin: string | undefined, allowedOrigins: string[]): boolean;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const STABLE_ORIGINS = ["https://gpt-image.honlnk.com"];
|
|
2
|
+
const DEV_ORIGINS = [
|
|
3
|
+
"https://gpt-image.honlnk.com",
|
|
4
|
+
"http://127.0.0.1:8888",
|
|
5
|
+
"http://localhost:8888",
|
|
6
|
+
];
|
|
7
|
+
const DEFAULT_SESSION_TTL_DAYS = 30;
|
|
8
|
+
const DEFAULT_JSON_BODY_BYTES = 1024 * 1024;
|
|
9
|
+
const DEFAULT_EDIT_BODY_BYTES = 50 * 1024 * 1024;
|
|
10
|
+
const DEFAULT_MAX_EDIT_IMAGES = 16;
|
|
11
|
+
const DEFAULT_EDIT_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"];
|
|
12
|
+
export function resolveChannel(value) {
|
|
13
|
+
return value === "dev" ? "dev" : "stable";
|
|
14
|
+
}
|
|
15
|
+
export function normalizeOrigin(origin) {
|
|
16
|
+
const trimmed = origin.trim();
|
|
17
|
+
if (!trimmed || trimmed === "*") {
|
|
18
|
+
throw new Error("Origin 必须是完整 origin,不能为空或使用通配符。");
|
|
19
|
+
}
|
|
20
|
+
let url;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(trimmed);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error(`Origin 格式无效:${origin}`);
|
|
26
|
+
}
|
|
27
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
28
|
+
throw new Error(`Origin 只支持 http 或 https:${origin}`);
|
|
29
|
+
}
|
|
30
|
+
if (url.pathname !== "/" || url.search || url.hash) {
|
|
31
|
+
throw new Error(`Origin 不能包含路径、查询或 hash:${origin}`);
|
|
32
|
+
}
|
|
33
|
+
if (url.username || url.password) {
|
|
34
|
+
throw new Error(`Origin 不能包含用户名或密码:${origin}`);
|
|
35
|
+
}
|
|
36
|
+
return url.origin;
|
|
37
|
+
}
|
|
38
|
+
export function parseAllowOrigins(values = []) {
|
|
39
|
+
return values.map(normalizeOrigin);
|
|
40
|
+
}
|
|
41
|
+
export function createSecurityConfig(opts = {}) {
|
|
42
|
+
const channel = resolveChannel(opts.channel);
|
|
43
|
+
const baseOrigins = channel === "dev" ? DEV_ORIGINS : STABLE_ORIGINS;
|
|
44
|
+
const extraOrigins = parseAllowOrigins(opts.allowOrigins);
|
|
45
|
+
const sessionTtlDays = Number.isFinite(opts.sessionTtlDays)
|
|
46
|
+
? opts.sessionTtlDays
|
|
47
|
+
: DEFAULT_SESSION_TTL_DAYS;
|
|
48
|
+
return {
|
|
49
|
+
channel,
|
|
50
|
+
allowedOrigins: Array.from(new Set([...baseOrigins, ...extraOrigins])),
|
|
51
|
+
sessionTtlMs: Math.max(1, sessionTtlDays) * 24 * 60 * 60 * 1000,
|
|
52
|
+
maxJsonBodyBytes: DEFAULT_JSON_BODY_BYTES,
|
|
53
|
+
maxEditBodyBytes: DEFAULT_EDIT_BODY_BYTES,
|
|
54
|
+
maxEditImages: DEFAULT_MAX_EDIT_IMAGES,
|
|
55
|
+
allowedEditImageMimeTypes: DEFAULT_EDIT_IMAGE_MIME_TYPES,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function isOriginAllowed(origin, allowedOrigins) {
|
|
59
|
+
if (!origin)
|
|
60
|
+
return true;
|
|
61
|
+
try {
|
|
62
|
+
return allowedOrigins.includes(normalizeOrigin(origin));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import cors from "@fastify/cors";
|
|
3
|
+
import { loadSession, isPaired } from "./pairingState.js";
|
|
4
|
+
import { pairRoutes } from "./routes/pair.js";
|
|
5
|
+
import { authRoutes } from "./routes/auth.js";
|
|
6
|
+
import { imagesRoutes } from "./routes/images.js";
|
|
7
|
+
import { authMiddleware } from "./middleware/auth.js";
|
|
8
|
+
import { isOriginAllowed } from "./securityConfig.js";
|
|
9
|
+
export async function startServer(opts) {
|
|
10
|
+
loadSession();
|
|
11
|
+
const app = Fastify({
|
|
12
|
+
bodyLimit: opts.security.maxJsonBodyBytes,
|
|
13
|
+
logger: {
|
|
14
|
+
redact: [
|
|
15
|
+
"req.headers.authorization",
|
|
16
|
+
"req.headers.cookie",
|
|
17
|
+
"res.headers.authorization",
|
|
18
|
+
"headers.authorization",
|
|
19
|
+
"apiKey",
|
|
20
|
+
"api_key",
|
|
21
|
+
"b64_json",
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
await app.register(cors, {
|
|
26
|
+
origin: (origin, cb) => {
|
|
27
|
+
cb(null, isOriginAllowed(origin, opts.security.allowedOrigins));
|
|
28
|
+
},
|
|
29
|
+
credentials: true,
|
|
30
|
+
});
|
|
31
|
+
await authMiddleware(app);
|
|
32
|
+
await app.register(pairRoutes, { sessionTtlMs: opts.security.sessionTtlMs });
|
|
33
|
+
await app.register(authRoutes);
|
|
34
|
+
await app.register(imagesRoutes, { security: opts.security });
|
|
35
|
+
app.get("/health", async () => {
|
|
36
|
+
return {
|
|
37
|
+
app: "gpt-image-studio-companion",
|
|
38
|
+
version: "0.1.0",
|
|
39
|
+
paired: isPaired(),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
await app.listen({ host: "127.0.0.1", port: opts.port });
|
|
43
|
+
console.log(`Companion 服务已启动: http://127.0.0.1:${opts.port}`);
|
|
44
|
+
console.log(`安全渠道: ${opts.security.channel}`);
|
|
45
|
+
console.log("允许的 Origin:");
|
|
46
|
+
opts.security.allowedOrigins.forEach((origin) => console.log(` - ${origin}`));
|
|
47
|
+
if (!isPaired()) {
|
|
48
|
+
console.log("等待网页端发起配对...");
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type CompanionHealthResponse = {
|
|
2
|
+
app: "gpt-image-studio-companion";
|
|
3
|
+
version: string;
|
|
4
|
+
paired: boolean;
|
|
5
|
+
};
|
|
6
|
+
export type CompanionAuthStatus = {
|
|
7
|
+
provider: string;
|
|
8
|
+
mode: "api_key";
|
|
9
|
+
ready: boolean;
|
|
10
|
+
accountLabel: string;
|
|
11
|
+
};
|
|
12
|
+
export type PairStartResponse = {
|
|
13
|
+
pairingCode: string;
|
|
14
|
+
expiresInSeconds: number;
|
|
15
|
+
};
|
|
16
|
+
export type PairConfirmRequest = {
|
|
17
|
+
pairingCode: string;
|
|
18
|
+
};
|
|
19
|
+
export type PairConfirmResponse = {
|
|
20
|
+
sessionToken: string;
|
|
21
|
+
expiresAt?: string;
|
|
22
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@honlnk/image-studio-companion",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local CLI companion for GPT Image Studio image API credential proxying.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gpt-image-studio": "dist/main.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/honlnk/gpt-image-studio.git",
|
|
18
|
+
"directory": "companion"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"gpt-image-studio",
|
|
22
|
+
"openai",
|
|
23
|
+
"images",
|
|
24
|
+
"cli",
|
|
25
|
+
"companion"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"dev": "tsx src/main.ts serve --channel dev",
|
|
32
|
+
"prepack": "pnpm build",
|
|
33
|
+
"build": "tsc",
|
|
34
|
+
"start": "node dist/main.js serve",
|
|
35
|
+
"typecheck": "tsc --noEmit"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@fastify/cors": "^11.0.0",
|
|
39
|
+
"commander": "^13.1.0",
|
|
40
|
+
"fastify": "^5.3.3"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.15.0",
|
|
44
|
+
"tsx": "^4.19.0",
|
|
45
|
+
"typescript": "^6.0.3"
|
|
46
|
+
}
|
|
47
|
+
}
|