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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ EXPOSE 8000
# Probe the configured port, not a hardcoded 8000 — overriding PICGEN_PORT
# used to leave a working container permanently "unhealthy".
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/api/health' % os.environ.get('PICGEN_PORT', '8000'), timeout=3).read()" || exit 1
CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/api/ready' % os.environ.get('PICGEN_PORT', '8000'), timeout=3).read()" || exit 1

ENTRYPOINT ["python", "-m", "picgen.cli"]
CMD ["--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--log-format", "json"]
25 changes: 14 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PicGen Console

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

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

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

## 0.1.66 主要特性
## 0.1.67 主要特性

- **数据库故障不再假健康**:`/api/ready` 会用短超时 `quick_check` 验证 SQLite,并对数据库与输出目录所在文件系统执行相邻临时文件的 write、fsync 和清理;任一依赖异常时返回 HTTP 503。完整性结果最长缓存 5 分钟,文件身份、大小或修改时间变化会立即重检。
- **容器健康检查覆盖 SQLite**:Dockerfile 与 Compose 改探测 `/api/ready`;`/api/health` 继续只表示进程存活。就绪结果缓存 5 秒,限制高频请求造成的探针写放大。

- **简洁模式壳层统一**:顶栏、状态、预览和页脚使用既有 Arco token 重做;专业模式除模式按钮和“我的收藏”需求文案外保持原像素。
- **编辑 Logo 缩放检测闭环**:检测坐标按源画布到结果画布的宽度比例缩放,缺失尺寸会先补齐;保留判定记录匹配率、像素数、阈值和候选关联,便于追查误判。
Expand Down Expand Up @@ -90,7 +93,7 @@
- `CORSMiddleware` — 配置化跨域
- **原子化落盘**:图片与 sidecar JSON 用临时文件 + rename 写入,崩溃不留半截文件;核心生成、
反馈、分享和取图送达数据会进入 SQLite,便于管理员后续审计。
- **健康分级**:`/api/health` 仅看进程存活;`/api/ready` 联动客户端、磁盘可写性、版本号。
- **健康分级**:`/api/health` 仅看进程存活;`/api/ready` 联动客户端、输出存储、SQLite 与数据库文件系统、版本号。
- **Telegram 统一通知**:可配置 Telegram Bot 接收后台/上游异常、成功生图、Bug 反馈和找回密码申请;
消息以 `【PicGen|分类】` 开头,Telegram 列表里可直接区分事件类型和关键标题。
- **旅行提示词标签**:生成提示词下方的快捷标签改为“高级旅行 / 精致海报 / 酒店质感 /
Expand Down Expand Up @@ -136,10 +139,10 @@ PICGEN_LOG_FORMAT=json \
### Docker

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

或:
Expand All @@ -154,13 +157,13 @@ docker compose up -d
./scripts/docker-build-push.sh
```

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

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

镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root
镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/ready`,以非 root
用户 `picgen` 运行。`docker-compose.yml` 使用 `picgen-data` volume 保存 `/app/data`,因此注册用户、
用量统计和落盘结果会在容器重启后继续保留。

Expand Down Expand Up @@ -270,7 +273,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到

## 图像通道

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

| 用户操作 | 默认接口 | 默认模型 |
| --- | --- | --- |
Expand All @@ -290,7 +293,7 @@ Responses,并覆盖模型、接口和 reasoning effort。非 Images 原生精
| --- | --- | --- |
| `/api/config` | GET | 返回前端用的配置(不含 key 明文) |
| `/api/health` | GET | 进程健康 |
| `/api/ready` | GET | 联动健康(磁盘、HTTP 客户端、版本) |
| `/api/ready` | GET | 联动健康(输出存储、SQLite、数据库存储、HTTP 客户端、版本) |
| `/api/image-jobs` | POST | 统一生成、参考图、延展和编辑入口,由服务端选择实际通道 |
| `/api/generate` | POST | 调上游 Images 生成接口(默认通道) |
| `/api/edit` | POST | 调上游 Images 编辑接口(默认通道,含参考图 / 延展 / 编辑) |
Expand Down Expand Up @@ -403,7 +406,7 @@ uv run picgen --prune-now
- 按需配置 `PICGEN_CORS_ALLOW_ORIGINS`。
- 收紧 `PICGEN_MAX_REQUEST_BODY_BYTES` / `PICGEN_MAX_IMAGE_BYTES` 以降低 DoS 面积。

`/api/health` 与 `/api/ready` 默认绕过鉴权与限流,便于探针
`/api/health` 与 `/api/ready` 默认绕过鉴权与限流,便于容器探针;就绪结果会短时缓存

## 可观测性

Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
picgen:
image: minorli/picgen:0.1.66
image: minorli/picgen:0.1.67
build:
context: .
ports:
Expand Down Expand Up @@ -29,7 +29,7 @@ services:
- "CMD"
- "python"
- "-c"
- "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/api/health' % os.environ.get('PICGEN_PORT', '8000'), timeout=3).read()"
- "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/api/ready' % os.environ.get('PICGEN_PORT', '8000'), timeout=3).read()"
interval: 30s
timeout: 5s
retries: 3
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.66"
version = "0.1.67"
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.66}"
VERSION="${VERSION:-0.1.67}"
PLATFORM="${PLATFORM:-linux/amd64}"

docker buildx build \
Expand Down
63 changes: 63 additions & 0 deletions src/picgen/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
import hashlib
import hmac
import json
import os
import secrets
import sqlite3
import threading
import time
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any

from .config import DEFAULT_RESPONSES_MODEL, LEGACY_DEFAULT_RESPONSES_MODEL
from .storage import storage_is_writable

_HASH_NAME = "pbkdf2_sha256"
_HASH_ITERATIONS = 600_000
Expand All @@ -23,6 +26,7 @@
_SHARED_RESULT_OWNERSHIP_SCHEMA_VERSION = 15
_MAX_FAILED_LOGIN_ATTEMPTS = 5
_ACCOUNT_LOCK_MINUTES = 15
_DATABASE_INTEGRITY_CACHE_SECONDS = 5 * 60.0
_PASSWORD_RESET_REQUEST_THROTTLE_MINUTES = 10
TEAM_CHAT_BOT_ID = "gpt-bot"
TEAM_CHAT_BOT_NAME = "GPT-BOT"
Expand Down Expand Up @@ -104,6 +108,65 @@ def __init__(self, db_path: Path) -> None:
self.db_path = db_path
self._lock = threading.RLock()
self._initialized = False
self._integrity_lock = threading.Lock()
self._integrity_checked_at = 0.0
self._integrity_ready = False
self._integrity_signature: tuple[int, int, int, int] | None = None

def is_ready(self) -> bool:
"""Check SQLite integrity and write headroom without mutating business data."""

try:
if not self.db_path.is_file():
return False
if not os.access(self.db_path, os.R_OK | os.W_OK):
return False
if not storage_is_writable(self.db_path.parent):
return False
file_stat = self.db_path.stat()
with self.db_path.open("rb") as database:
if database.read(16) != b"SQLite format 3\0":
return False
signature = (file_stat.st_dev, file_stat.st_ino, file_stat.st_size, file_stat.st_mtime_ns)
return self._integrity_check_is_ready(signature)
except (OSError, sqlite3.Error):
return False

def _integrity_check_is_ready(self, signature: tuple[int, int, int, int]) -> bool:
now = time.monotonic()
with self._integrity_lock:
if (
self._integrity_ready
and self._integrity_signature == signature
and now - self._integrity_checked_at < _DATABASE_INTEGRITY_CACHE_SECONDS
):
return True
connection: sqlite3.Connection | None = None
try:
database_uri = f"{self.db_path.resolve().as_uri()}?mode=ro"
connection = sqlite3.connect(database_uri, uri=True, timeout=0.25)
connection.execute("PRAGMA query_only=ON")
result = connection.execute("PRAGMA quick_check(1)").fetchone()
current_schema = connection.execute(
"SELECT 1 FROM schema_migrations WHERE version = ? LIMIT 1",
(_SCHEMA_VERSION,),
).fetchone()
connection.execute("SELECT 1 FROM users LIMIT 1").fetchone()
ready = (
result is not None
and str(result[0]).lower() == "ok"
and current_schema is not None
)
except (OSError, sqlite3.Error):
ready = False
finally:
if connection is not None:
with suppress(sqlite3.Error):
connection.close()
self._integrity_ready = ready
self._integrity_signature = signature
self._integrity_checked_at = time.monotonic()
return ready

def initialize(self) -> None:
with self._lock:
Expand Down
3 changes: 3 additions & 0 deletions src/picgen/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
error_alert_notifications_enabled,
send_error_alert_notification,
)
from .readiness import ReadinessProbe
from .redaction import redact_sensitive_text
from .routes import create_router, drain_notification_tasks
from .storage import prune_old_outputs
Expand Down Expand Up @@ -166,6 +167,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
)
app.state.settings = resolved_settings
app.state.auth_store = auth_store
app.state.readiness_probe = ReadinessProbe()
app.state.readiness_limiter = anyio.CapacityLimiter(1)

# add_middleware() prepends, so the LAST addition runs OUTERMOST. Runtime
# order (outer → inner): CORS → RequestId → SecurityHeaders → RateLimit →
Expand Down
51 changes: 51 additions & 0 deletions src/picgen/readiness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from __future__ import annotations

import threading
import time
from dataclasses import dataclass
from pathlib import Path

from .auth import AuthStore
from .storage import storage_is_writable

_READINESS_CACHE_SECONDS = 5.0


@dataclass(frozen=True)
class StorageReadiness:
storage_writable: bool
database_writable: bool


class ReadinessProbe:
"""Cache durable storage probes to bound health-check write amplification."""

def __init__(self, cache_seconds: float = _READINESS_CACHE_SECONDS) -> None:
self._cache_seconds = cache_seconds
self._lock = threading.Lock()
self._checked_at = 0.0
self._cached: StorageReadiness | None = None

def check(
self,
*,
outputs_dir: Path,
auth_enabled: bool,
auth_store: AuthStore,
) -> StorageReadiness:
now = time.monotonic()
with self._lock:
if self._cached is not None and now - self._checked_at < self._cache_seconds:
return self._cached
storage_writable = storage_is_writable(outputs_dir)
try:
database_writable = not auth_enabled or auth_store.is_ready()
except Exception:
database_writable = False
result = StorageReadiness(
storage_writable=storage_writable,
database_writable=database_writable,
)
self._cached = result
self._checked_at = time.monotonic()
return result
32 changes: 21 additions & 11 deletions src/picgen/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from urllib.parse import urlsplit

import anyio
from fastapi import APIRouter, Body, Depends, Request
from fastapi import APIRouter, Body, Depends, Request, Response
from fastapi.responses import FileResponse, JSONResponse

from . import __version__
Expand Down Expand Up @@ -63,6 +63,7 @@
send_password_reset_request_notification,
smtp_notifications_enabled,
)
from .readiness import ReadinessProbe
from .recipes import list_prompt_recipes, recipe_public_dict
from .redaction import redact_sensitive_text
from .restricted_destinations import (
Expand Down Expand Up @@ -1954,20 +1955,29 @@ async def health() -> HealthResponse:

@router.get("/api/ready", response_model=ReadinessResponse)
async def ready(
request: Request,
response: Response,
settings: Settings = Depends(get_settings),
client: UpstreamClient = Depends(get_client),
auth_store: AuthStore = Depends(get_auth_store),
) -> ReadinessResponse:
storage_ok = True
try:
settings.outputs_dir.mkdir(parents=True, exist_ok=True)
probe = settings.outputs_dir / ".ready"
probe.write_text("ok")
probe.unlink(missing_ok=True)
except Exception:
storage_ok = False
readiness_probe: ReadinessProbe = request.app.state.readiness_probe
readiness_limiter: anyio.CapacityLimiter = request.app.state.readiness_limiter
storage = await anyio.to_thread.run_sync(
lambda: readiness_probe.check(
outputs_dir=settings.outputs_dir,
auth_enabled=settings.auth_enabled,
auth_store=auth_store,
),
limiter=readiness_limiter,
)
ready_ok = storage.storage_writable and storage.database_writable and client is not None
if not ready_ok:
response.status_code = HTTPStatus.SERVICE_UNAVAILABLE
return ReadinessResponse(
ok=storage_ok and client is not None,
storage_writable=storage_ok,
ok=ready_ok,
storage_writable=storage.storage_writable,
database_writable=storage.database_writable,
upstream_client_ready=client is not None,
version=__version__,
)
Expand Down
1 change: 1 addition & 0 deletions src/picgen/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,7 @@ class HealthResponse(BaseModel):
class ReadinessResponse(BaseModel):
ok: bool
storage_writable: bool
database_writable: bool
upstream_client_ready: bool
version: str

Expand Down
32 changes: 32 additions & 0 deletions src/picgen/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,38 @@
"image/webp": "WEBP",
}
MAX_EXACT_IMAGE_PIXELS = 8_294_400
_READINESS_MIN_FREE_BYTES = 16 * 1024 * 1024
_READINESS_PROBE_BYTES = 4 * 1024


def storage_is_writable(path: Path) -> bool:
"""Verify free-space headroom and a durable adjacent temporary write."""

probe_path: Path | None = None
write_succeeded = False
try:
if not path.is_dir() or not os.access(path, os.W_OK | os.X_OK):
return False
filesystem = os.statvfs(path)
block_size = filesystem.f_frsize or filesystem.f_bsize
if filesystem.f_blocks > 0 and filesystem.f_bavail * block_size < _READINESS_MIN_FREE_BYTES:
return False
if filesystem.f_files > 0 and filesystem.f_favail < 1:
return False
with tempfile.NamedTemporaryFile(prefix=".picgen-ready-", dir=path, delete=False) as probe:
probe_path = Path(probe.name)
probe.write(b"\0" * _READINESS_PROBE_BYTES)
probe.flush()
os.fsync(probe.fileno())
write_succeeded = True
except OSError:
write_succeeded = False
if probe_path is not None:
try:
probe_path.unlink()
except OSError:
return False
return write_succeeded


def extension_for_mime(image_mime: str) -> str:
Expand Down
Loading
Loading