Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PicGen Console

一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.60**。它把
一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.61**。它把
`/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具)
包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。

Expand All @@ -14,7 +14,9 @@

![PicGen Console 主程序界面](demo1.png)

## 0.1.60 主要特性
## 0.1.61 主要特性

- **正常页面刷新不再误限流**:业务 API 仍受每分钟总配额保护,5 秒 burst 只约束写请求;生成完成后的统计刷新、工作区重载和作品列表读取不会互相挤占短时写入配额。

- **多候选兼容参数路径**:上游若以 `tools[0].n` 等嵌套参数路径拒绝候选数量,自动去掉 `n` 并并发补齐候选;结构化识别只针对候选数字段,不会误重试内容审核错误。

Expand Down Expand Up @@ -62,7 +64,7 @@
- `RequestIdMiddleware` — 生成或透传 `X-Request-ID`
- `SecurityHeadersMiddleware` — `X-Content-Type-Options / X-Frame-Options / Referrer-Policy / Permissions-Policy / Cache-Control`
- `BodySizeLimitMiddleware` — 拒绝超大请求(默认 64 MB)
- `RateLimitMiddleware` — 滑动窗口限流(默认 120 req/min + 20 burst/5s)
- `RateLimitMiddleware` — 滑动窗口限流(默认业务 API 120 req/min + 写请求 20 burst/5s)
- `ProxyAuthMiddleware` — 可选 Bearer / `X-Proxy-Token` 鉴权
- `CORSMiddleware` — 配置化跨域
- **原子化落盘**:图片与 sidecar JSON 用临时文件 + rename 写入,崩溃不留半截文件;核心生成、
Expand Down Expand Up @@ -113,10 +115,10 @@ PICGEN_LOG_FORMAT=json \
### Docker

```bash
docker build -t minorli/picgen:0.1.60 .
docker build -t minorli/picgen:0.1.61 .
docker run --rm -p 8000:8000 \
-v picgen-data:/app/data \
minorli/picgen:0.1.60
minorli/picgen:0.1.61
```

或:
Expand All @@ -131,10 +133,10 @@ docker compose up -d
./scripts/docker-build-push.sh
```

默认会构建并推送 `minorli/picgen:0.1.60`。也可以覆盖:
默认会构建并推送 `minorli/picgen:0.1.61`。也可以覆盖:

```bash
IMAGE=minorli/picgen VERSION=0.1.60 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh
IMAGE=minorli/picgen VERSION=0.1.61 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh
```

镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root
Expand Down Expand Up @@ -247,7 +249,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到

## 图像通道

PicGen 0.1.60 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:
PicGen 0.1.61 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:

| 用户操作 | 默认接口 | 默认模型 |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
picgen:
image: minorli/picgen:0.1.60
image: minorli/picgen:0.1.61
build:
context: .
ports:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "picgen"
version = "0.1.60"
version = "0.1.61"
description = "Enterprise-grade local web console for OpenAI-compatible image generation and editing APIs."
readme = "README.md"
requires-python = ">=3.12"
Expand Down
2 changes: 1 addition & 1 deletion scripts/docker-build-push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
set -euo pipefail

IMAGE="${IMAGE:-minorli/picgen}"
VERSION="${VERSION:-0.1.60}"
VERSION="${VERSION:-0.1.61}"
PLATFORM="${PLATFORM:-linux/amd64}"

docker buildx build \
Expand Down
8 changes: 6 additions & 2 deletions src/picgen/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ async def dispatch(self, request: Request, call_next: RequestHandler) -> Respons
if request.url.path in {"/api/health", "/api/ready", "/api/config"}:
return await call_next(request)
client_key = self._client_key(request)
uses_burst_limit = request.method.upper() not in {"GET", "HEAD", "OPTIONS"}
now = time.monotonic()
async with self._lock:
if now - self._last_sweep >= 60.0:
Expand All @@ -252,7 +253,9 @@ async def dispatch(self, request: Request, call_next: RequestHandler) -> Respons
burst_bucket = self._burst_buckets.setdefault(client_key, deque())
self._evict(minute_bucket, now - 60.0)
self._evict(burst_bucket, now - 5.0)
if len(minute_bucket) >= self.per_minute or (self.burst > 0 and len(burst_bucket) >= self.burst):
if len(minute_bucket) >= self.per_minute or (
uses_burst_limit and self.burst > 0 and len(burst_bucket) >= self.burst
):
retry_after = 5
if len(minute_bucket) >= self.per_minute and minute_bucket:
# The minute window is what's exhausted — tell the client
Expand All @@ -275,7 +278,8 @@ async def dispatch(self, request: Request, call_next: RequestHandler) -> Respons
extra_headers={"Retry-After": str(retry_after)},
)
minute_bucket.append(now)
burst_bucket.append(now)
if uses_burst_limit:
burst_bucket.append(now)
return await call_next(request)


Expand Down
4 changes: 2 additions & 2 deletions static/app.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.60"
import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.61"
import {
DEFAULT_RESPONSES_MODEL,
RESPONSES_MODEL_STORAGE_VERSION,
RESPONSES_REASONING_STORAGE_VERSION,
migrateStoredResponsesReasoningSettings,
migrateStoredResponsesSettings,
} from "./responses-settings.mjs?v=0.1.60"
} from "./responses-settings.mjs?v=0.1.61"

const RESPONSES_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max", "ultra"])
const DEFAULT_RESPONSES_REASONING_EFFORT = "xhigh"
Expand Down
6 changes: 3 additions & 3 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PicGen Console</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23108566'/%3E%3Cpath d='M32 13l16 9v20l-16 9-16-9V22l16-9z' fill='%23ecfdf5'/%3E%3Ccircle cx='32' cy='32' r='8' fill='%23108566'/%3E%3C/svg%3E">
<link rel="stylesheet" href="styles.css?v=0.1.60">
<link rel="stylesheet" href="styles.css?v=0.1.61">
</head>
<body class="auth-gate">
<div id="authOverlay" class="auth-page" aria-hidden="false">
Expand Down Expand Up @@ -1046,7 +1046,7 @@ <h2>管理员高级设置</h2>
</div>

<footer class="system-footer">
<span>PicGen Console v0.1.60</span>
<span>PicGen Console v0.1.61</span>
<span>本地代理保存结果到 <strong>data/outputs</strong></span>
</footer>
</div>
Expand Down Expand Up @@ -1356,6 +1356,6 @@ <h2 id="previewModalTitle">图片预览</h2>
<span id="toastMessageText"></span>
</div>

<script src="app.js?v=0.1.60" type="module"></script>
<script src="app.js?v=0.1.61" type="module"></script>
</body>
</html>
42 changes: 40 additions & 2 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3392,8 +3392,8 @@ async def send(message: dict[str, object]) -> None:
assert response_start["status"] == 204


def test_rate_limit_returns_429(make_client, settings_factory):
settings = settings_factory(rate_limit_per_minute=2, rate_limit_burst=2)
def test_rate_limit_write_burst_returns_429(make_client, settings_factory):
settings = settings_factory(rate_limit_per_minute=100, rate_limit_burst=2)
client, _, _ = make_client(settings=settings)
# generate endpoint -> still validation 400, but counts toward rate limit
client.post("/api/generate", json={})
Expand All @@ -3404,6 +3404,44 @@ def test_rate_limit_returns_429(make_client, settings_factory):
assert "Retry-After" in response.headers


def test_rate_limit_burst_does_not_block_read_only_ui_refresh(make_client, settings_factory):
settings = settings_factory(rate_limit_per_minute=100, rate_limit_burst=2)
client, _, _ = make_client(settings=settings)

responses = [client.get("/api/recipes") for _ in range(5)]

assert all(response.status_code != 429 for response in responses)


def test_read_requests_do_not_consume_write_burst(make_client, settings_factory):
settings = settings_factory(rate_limit_per_minute=100, rate_limit_burst=2)
client, _, _ = make_client(settings=settings)

for _ in range(5):
assert client.get("/api/recipes").status_code != 429

first_write = client.post("/api/generate", json={})
second_write = client.post("/api/generate", json={})
blocked_write = client.post("/api/generate", json={})

assert first_write.status_code != 429
assert second_write.status_code != 429
assert blocked_write.status_code == 429


def test_rate_limit_per_minute_still_applies_to_read_requests(make_client, settings_factory):
settings = settings_factory(rate_limit_per_minute=2, rate_limit_burst=1)
client, _, _ = make_client(settings=settings)

first = client.get("/api/recipes")
second = client.get("/api/recipes")
third = client.get("/api/recipes")

assert first.status_code != 429
assert second.status_code != 429
assert third.status_code == 429


def test_proxy_auth_token_enforced(make_client, settings_factory):
settings = settings_factory(proxy_auth_token="topsecret")
client, _, _ = make_client(settings=settings)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_static_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def test_legacy_responses_model_storage_is_migrated_once() -> None:
settings_js = (ROOT_DIR / "static" / "responses-settings.mjs").read_text(encoding="utf-8")

assert 'const DEPRECATED_RESPONSES_MODELS = new Set(["gpt-5.4"])' in app_js
assert 'from "./responses-settings.mjs?v=0.1.60"' in app_js
assert 'from "./responses-settings.mjs?v=0.1.61"' in app_js
assert 'const LEGACY_DEFAULT_RESPONSES_MODEL = "gpt-5.5"' in settings_js
assert "const RESPONSES_MODEL_STORAGE_VERSION = 4" in settings_js
assert "function migrateStoredResponsesSettings" in settings_js
Expand All @@ -38,7 +38,7 @@ def test_logo_overlay_uses_uploaded_asset_without_ai_guidance() -> None:
assert 'const COMPANY_LOGO_URL = "6renyou.png"' in app_js
assert "composeLogoOverlayForCandidates" in app_js
assert "createOfficialLogoCanvas" in app_js
assert 'from "./logo-placement.mjs?v=0.1.60"' in app_js
assert 'from "./logo-placement.mjs?v=0.1.61"' in app_js
assert "chooseLogoPlacement" in app_js
assert "calculateLogoPlacementScore" in app_js
assert "expandLogoSafetyRegion" in placement_js
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading