feat: complete image console and add verified REST and MCP workflows

This commit is contained in:
2026-11-04 00:41:54 +08:00
parent 3140cd51d2
commit bef8af53ed
48 changed files with 5167 additions and 1176 deletions
+1
View File
@@ -0,0 +1 @@
"""服务层。"""
+143
View File
@@ -0,0 +1,143 @@
"""画廊服务:扫描 output/ 目录结构。
output/ 下的每个一级子目录 = 一个画廊,目录名 = 谁生成的图片(profile_name)。
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from .. import config, db
from ..providers import ProviderError
def _folder(profile_name: str) -> Path:
if not config.PROFILE_RE.fullmatch(profile_name):
raise ProviderError("无效的画廊名称", status=400)
root = config.OUTPUT_DIR.resolve()
folder = (root / profile_name).resolve()
if not folder.is_relative_to(root):
raise ProviderError("画廊路径超出输出目录", status=400)
return folder
def _meta_by_file() -> dict[tuple[str, str], dict[str, Any]]:
"""从 generations 表建立 (profile, filename) → 元信息的索引。"""
index: dict[tuple[str, str], dict[str, Any]] = {}
with db.session() as conn:
rows = conn.execute("SELECT * FROM generations ORDER BY created_at DESC").fetchall()
for row in rows:
try:
files = json.loads(row["files"] or "[]")
except ValueError:
files = []
for name in files:
key = (row["profile_name"], name)
index.setdefault(
key,
{
"model": row["model"],
"provider_name": row["provider_name"],
"created_at": row["created_at"],
},
)
return index
def _images_in(folder: Path) -> list[Path]:
return sorted(
(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in config.IMAGE_SUFFIXES),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
def list_galleries() -> list[dict[str, Any]]:
config.ensure_dirs()
meta = _meta_by_file()
galleries: list[dict[str, Any]] = []
for folder in sorted(config.OUTPUT_DIR.iterdir()):
if not folder.is_dir():
continue
images = _images_in(folder)
if not images:
continue
total_bytes = 0
models: list[str] = []
latest = 0.0
for img in images:
stat = img.stat()
total_bytes += stat.st_size
latest = max(latest, stat.st_mtime)
info = meta.get((folder.name, img.name))
if info and info["model"] and info["model"] not in models:
models.append(info["model"])
cover = images[0]
galleries.append(
{
"name": folder.name,
"count": len(images),
"bytes": total_bytes,
"updated_at": datetime.fromtimestamp(latest).strftime("%Y-%m-%d %H:%M"),
"cover": f"/output/{folder.name}/{cover.name}",
"models": models,
}
)
galleries.sort(key=lambda g: g["updated_at"], reverse=True)
return galleries
def list_gallery(profile_name: str) -> list[dict[str, Any]]:
config.ensure_dirs()
folder = _folder(profile_name)
if not folder.is_dir():
return []
meta = _meta_by_file()
items: list[dict[str, Any]] = []
for img in _images_in(folder):
stat = img.stat()
info = meta.get((profile_name, img.name), {})
items.append(
{
"profile_name": profile_name,
"filename": img.name,
"url": f"/output/{profile_name}/{img.name}",
"bytes": stat.st_size,
"model": info.get("model", "—"),
"provider_name": info.get("provider_name", "—"),
"created_at": info.get("created_at")
or datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
}
)
return items
def delete_gallery_item(profile_name: str, filename: str) -> None:
folder = _folder(profile_name)
if Path(filename).name != filename or "\\" in filename:
raise ProviderError("无效的文件名", status=400)
target = folder / filename
if not target.resolve().is_relative_to(folder):
raise ProviderError("图片路径超出画廊目录", status=400)
if not target.is_file():
raise FileNotFoundError(f"文件不存在: {profile_name}/{filename}")
target.unlink()
# 同步 generations 记录
with db.session() as conn:
rows = conn.execute(
"SELECT id, files FROM generations WHERE profile_name=?", (profile_name,)
).fetchall()
for row in rows:
try:
files = json.loads(row["files"] or "[]")
except ValueError:
files = []
if filename in files:
files = [f for f in files if f != filename]
conn.execute(
"UPDATE generations SET files=? WHERE id=?",
(json.dumps(files, ensure_ascii=False), row["id"]),
)
+207
View File
@@ -0,0 +1,207 @@
"""出图服务:调用适配器、落盘、记录 generations。"""
from __future__ import annotations
import json
import math
import re
import time
import uuid
from datetime import datetime
from typing import Any
from pydantic import ValidationError
from .. import config, db
from ..providers import ProviderError, get_adapter
from ..providers.agnes import size_to_tier_and_ratio
from ..schemas import GenerateIn
from . import providers as provider_service
WEB_PROFILE = "user"
_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
def _safe_model(model: str) -> str:
return _SAFE.sub("-", model).strip("-") or "image"
def record_generation(
*,
provider_id: str,
provider_name: str,
model: str,
profile_name: str,
prompt: str,
size: str,
ratio: str,
n: int,
status: str,
error: str,
files: list[str],
duration_ms: int,
) -> str:
gid = "gen_" + uuid.uuid4().hex[:12]
with db.session() as conn:
conn.execute(
"INSERT INTO generations (id,provider_id,provider_name,model,profile_name,prompt,size,"
"ratio,n,status,error,files,duration_ms,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
gid,
provider_id,
provider_name,
model,
profile_name,
prompt,
size,
ratio,
n,
status,
error,
json.dumps(files, ensure_ascii=False),
duration_ms,
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
),
)
return gid
def list_generations() -> list[dict[str, Any]]:
with db.session() as conn:
rows = conn.execute("SELECT * FROM generations ORDER BY created_at DESC, rowid DESC").fetchall()
out: list[dict[str, Any]] = []
for row in rows:
try:
files = json.loads(row["files"] or "[]")
except ValueError:
files = []
out.append(
{
"id": row["id"],
"provider_id": row["provider_id"],
"provider_name": row["provider_name"],
"model": row["model"],
"profile_name": row["profile_name"],
"prompt": row["prompt"],
"size": row["size"],
"ratio": row["ratio"],
"n": row["n"],
"status": row["status"],
"error": row["error"],
"files": files,
"duration_ms": row["duration_ms"],
"created_at": row["created_at"],
}
)
return out
def generate(
*,
provider_id: str,
model: str,
prompt: str,
size: str = "1024x1024",
ratio: str = "",
n: int = 1,
images: list[str] | None = None,
profile_name: str | None = None,
) -> dict[str, Any]:
try:
payload = GenerateIn(provider_id=provider_id, model=model, prompt=prompt, size=size,
ratio=ratio, n=n, images=images or [], profile_name=profile_name)
except ValidationError as exc:
raise ProviderError(str(exc), status=400) from exc
prompt = payload.prompt
size = payload.size
profile = payload.profile_name or WEB_PROFILE
cfg = provider_service.provider_config(provider_id, require_enabled=True)
selected = provider_service.get_model(f"{provider_id}:{model}")
if not selected["enabled"]:
raise ProviderError("模型已停用", status=400)
provider_name = cfg.name
adapter = get_adapter(cfg)
if images and not adapter.capabilities.supports_image_input:
raise ProviderError("该供应商不支持参考图输入", status=400)
if ratio and cfg.adapter != "agnes":
raise ProviderError("该供应商不支持 ratio,请使用 WxH 尺寸", status=400)
if cfg.adapter == "agnes":
_, ratio = size_to_tier_and_ratio(size, ratio)
elif "x" in size:
width, height = map(int, size.split("x"))
divisor = math.gcd(width, height)
ratio = f"{width // divisor}:{height // divisor}"
started = time.monotonic()
files: list[str] = []
folder = config.OUTPUT_DIR / profile
if not folder.resolve().is_relative_to(config.OUTPUT_DIR.resolve()):
raise ProviderError("画廊路径超出输出目录", status=400)
images_out: list[dict[str, Any]] = []
try:
results = adapter.generate(model, prompt, size, ratio, n, images or [])
if not results:
raise ProviderError("上游未返回图片")
folder.mkdir(parents=True, exist_ok=True)
stamp = uuid.uuid4().hex[:8]
for idx, item in enumerate(results):
if item.url and not item.data:
item = adapter.download(item.url)
if not item.data:
raise ProviderError("上游返回空图片")
suffix = item.filename if item.filename in config.IMAGE_SUFFIXES else ".png"
filename = f"{_safe_model(model)}-{stamp}{'' if len(results) == 1 else f'-{idx + 1}'}{suffix}"
path = folder / filename
files.append(filename)
path.write_bytes(item.data)
images_out.append({"filename": filename, "url": f"/output/{profile}/{filename}",
"bytes": path.stat().st_size})
except (ProviderError, OSError) as exc:
for filename in files:
(folder / filename).unlink(missing_ok=True)
message = exc.message if isinstance(exc, ProviderError) else "保存图片失败"
duration = int((time.monotonic() - started) * 1000)
record_generation(
provider_id=provider_id,
provider_name=provider_name,
model=model,
profile_name=profile,
prompt=prompt,
size=size,
ratio=ratio,
n=n,
status="failed",
error=message,
files=[],
duration_ms=duration,
)
raise ProviderError(message, status=exc.status if isinstance(exc, ProviderError) else 500) from exc
duration = int((time.monotonic() - started) * 1000)
gid = record_generation(
provider_id=provider_id,
provider_name=provider_name,
model=model,
profile_name=profile,
prompt=prompt,
size=size,
ratio=ratio or "1:1",
n=n,
status="success",
error="",
files=files,
duration_ms=duration,
)
return {
"id": gid,
"provider": provider_id,
"provider_name": provider_name,
"model": model,
"profile_name": profile,
"size": size,
"ratio": ratio or "1:1",
"images": images_out,
"output_dir": f"output/{profile}/",
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
+272
View File
@@ -0,0 +1,272 @@
"""供应商与模型的服务层:CRUD、密钥加解密、模型发现与落库。"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from .. import db, security
from ..providers import ADAPTER_LABELS, ProviderConfig, ProviderError, get_adapter
def now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _row_to_dict(row) -> dict[str, Any]:
models = _models_of(row["id"])
return {
"id": row["id"],
"name": row["name"],
"adapter": row["adapter"],
"adapter_label": ADAPTER_LABELS.get(row["adapter"], row["adapter"]),
"base_url": row["base_url"],
"api_key_masked": security.mask(security.decrypt(row["api_key"] or "")),
"default_model": row["default_model"],
"enabled": bool(row["enabled"]),
"note": row["note"],
"model_count": len(models),
"enabled_model_count": sum(1 for m in models if m["enabled"]),
"models_discovered": _latest_discovered(row["id"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _models_of(provider_id: str) -> list[dict[str, Any]]:
with db.session() as conn:
rows = conn.execute(
"SELECT * FROM models WHERE provider_id = ? ORDER BY model_id", (provider_id,)
).fetchall()
return [_model_to_dict(r) for r in rows]
def _model_to_dict(row) -> dict[str, Any]:
return {
"id": row["id"],
"provider_id": row["provider_id"],
"model_id": row["model_id"],
"display_name": row["display_name"],
"enabled": bool(row["enabled"]),
"kind": row["kind"],
"size_hint": row["size_hint"],
"supports_image_input": bool(row["supports_image_input"]),
"note": row["note"],
"discovered_at": row["discovered_at"],
}
def _latest_discovered(provider_id: str) -> str:
with db.session() as conn:
row = conn.execute(
"SELECT MAX(discovered_at) AS d FROM models WHERE provider_id = ?", (provider_id,)
).fetchone()
return (row["d"] if row and row["d"] else "—") or "—"
# --------------------------------------------------------------- 供应商 CRUD
def list_providers() -> list[dict[str, Any]]:
with db.session() as conn:
rows = conn.execute("SELECT * FROM providers ORDER BY created_at, id").fetchall()
return [_row_to_dict(r) for r in rows]
def get_provider_row(provider_id: str):
with db.session() as conn:
return conn.execute("SELECT * FROM providers WHERE id = ?", (provider_id,)).fetchone()
def get_provider(provider_id: str) -> dict[str, Any] | None:
row = get_provider_row(provider_id)
return _row_to_dict(row) if row else None
def save_provider(data: dict[str, Any]) -> dict[str, Any]:
"""新增或更新供应商。api_key 留空表示不修改。"""
pid = data["id"].strip()
ts = now()
existing = get_provider_row(pid)
if existing:
api_key_enc = existing["api_key"]
if data.get("api_key"):
api_key_enc = security.encrypt(data["api_key"])
with db.session() as conn:
conn.execute(
"UPDATE providers SET name=?, adapter=?, base_url=?, api_key=?, default_model=?,"
" enabled=?, note=?, updated_at=? WHERE id=?",
(
data["name"],
data["adapter"],
data["base_url"],
api_key_enc,
data.get("default_model", ""),
1 if data.get("enabled", True) else 0,
data.get("note", ""),
ts,
pid,
),
)
else:
with db.session() as conn:
conn.execute(
"INSERT INTO providers (id,name,adapter,base_url,api_key,default_model,enabled,"
"note,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
(
pid,
data["name"],
data["adapter"],
data["base_url"],
security.encrypt(data.get("api_key", "")),
data.get("default_model", ""),
1 if data.get("enabled", True) else 0,
data.get("note", ""),
ts,
ts,
),
)
return get_provider(pid)
def delete_provider(provider_id: str) -> None:
with db.session() as conn:
conn.execute("DELETE FROM providers WHERE id = ?", (provider_id,))
def provider_config(provider_id: str, *, require_enabled: bool = False) -> ProviderConfig:
row = get_provider_row(provider_id)
if row is None:
raise ProviderError(f"供应商不存在: {provider_id}", status=404)
if require_enabled and not row["enabled"]:
raise ProviderError("供应商已停用", status=400)
return ProviderConfig(
id=row["id"],
name=row["name"],
adapter=row["adapter"],
base_url=row["base_url"],
api_key=security.decrypt(row["api_key"] or ""),
default_model=row["default_model"],
)
# ----------------------------------------------------------------- 模型
def list_models(provider_id: str | None = None) -> list[dict[str, Any]]:
with db.session() as conn:
if provider_id:
rows = conn.execute(
"SELECT * FROM models WHERE provider_id = ? ORDER BY model_id", (provider_id,)
).fetchall()
else:
rows = conn.execute("SELECT * FROM models ORDER BY provider_id, model_id").fetchall()
return [_model_to_dict(r) for r in rows]
def set_model_enabled(model_id: str, enabled: bool) -> dict[str, Any]:
with db.session() as conn:
conn.execute(
"UPDATE models SET enabled=?, updated_at=? WHERE id=?", (1 if enabled else 0, now(), model_id)
)
return get_model(model_id)
def set_model_note(model_id: str, note: str) -> dict[str, Any]:
with db.session() as conn:
conn.execute(
"UPDATE models SET note=?, updated_at=? WHERE id=?", (note, now(), model_id)
)
return get_model(model_id)
def set_models_enabled(model_ids: list[str], enabled: bool) -> list[dict[str, Any]]:
ts = now()
with db.session() as conn:
for mid in model_ids:
conn.execute(
"UPDATE models SET enabled=?, updated_at=? WHERE id=?", (1 if enabled else 0, ts, mid)
)
return list_models()
def get_model(model_id: str) -> dict[str, Any]:
with db.session() as conn:
row = conn.execute("SELECT * FROM models WHERE id = ?", (model_id,)).fetchone()
if row is None:
raise ProviderError(f"模型不存在: {model_id}", status=404)
return _model_to_dict(row)
def upsert_models(
provider_id: str,
model_ids: list[str],
enabled: bool = True,
note: str = "自动发现",
exclusive: bool = False,
preserve_enabled: bool = False,
) -> list[dict[str, Any]]:
"""批量写入模型;已存在的仅更新启用态与发现时间。
exclusive=True 时,把该供应商下未被选中的模型置为停用。
"""
cfg = provider_config(provider_id)
capabilities = get_adapter(cfg).capabilities
ts = now()
today = ts[:10]
with db.session() as conn:
if exclusive:
keep = {f"{provider_id}:{m}" for m in model_ids}
for row in conn.execute(
"SELECT id FROM models WHERE provider_id = ?", (provider_id,)
).fetchall():
if row["id"] not in keep:
conn.execute(
"UPDATE models SET enabled=0, updated_at=? WHERE id=?", (ts, row["id"])
)
for mid in model_ids:
model_id = f"{provider_id}:{mid}"
exists = conn.execute("SELECT id FROM models WHERE id = ?", (model_id,)).fetchone()
if exists:
conn.execute(
"UPDATE models SET enabled=CASE WHEN ? THEN enabled ELSE ? END,"
" discovered_at=?, updated_at=?, kind=?, size_hint=?, supports_image_input=? WHERE id=?",
(preserve_enabled, int(enabled), today, ts, capabilities.kind,
capabilities.size_hint, int(capabilities.supports_image_input), model_id),
)
else:
conn.execute(
"INSERT INTO models (id,provider_id,model_id,display_name,enabled,kind,size_hint,"
"supports_image_input,note,discovered_at,created_at,updated_at)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(model_id, provider_id, mid, mid, int(enabled), capabilities.kind,
capabilities.size_hint, int(capabilities.supports_image_input), note, today, ts, ts),
)
return list_models(provider_id)
# ------------------------------------------------------------- 模型发现
def discover(provider_id: str, *, require_enabled: bool = False) -> dict[str, Any]:
"""按已保存的供应商拉取上游模型,返回 {provider_id,total,matched}。"""
cfg = provider_config(provider_id, require_enabled=require_enabled)
adapter = get_adapter(cfg)
all_ids = adapter.list_models()
matched = adapter.filter_image_models(all_ids)
upsert_models(provider_id, matched, enabled=True, preserve_enabled=True)
if require_enabled:
enabled = {m["model_id"] for m in list_models(provider_id) if m["enabled"]}
matched = [mid for mid in matched if mid in enabled]
return {"provider_id": provider_id, "total": len(all_ids), "matched": matched}
def discover_with_credentials(
adapter_name: str, base_url: str, api_key: str
) -> dict[str, Any]:
"""尚未保存供应商时的临时发现。"""
cfg = ProviderConfig(
id="__tmp__",
name="__tmp__",
adapter=adapter_name,
base_url=base_url,
api_key=api_key,
)
adapter = get_adapter(cfg)
all_ids = adapter.list_models()
matched = adapter.filter_image_models(all_ids)
return {"total": len(all_ids), "matched": matched}
+28
View File
@@ -0,0 +1,28 @@
"""SKILL.md 读取服务。
文档本身不含固定模型名,模型清单一律来自 discover_models 的实时返回。
"""
from __future__ import annotations
from datetime import datetime
from .. import config
def _read() -> str:
if config.SKILL_PATH.is_file():
return config.SKILL_PATH.read_text(encoding="utf-8")
return ""
def get_skill() -> dict[str, str]:
body = _read()
stat = config.SKILL_PATH.stat() if config.SKILL_PATH.is_file() else None
if stat:
mtime = datetime.fromtimestamp(stat.st_mtime)
updated_at = mtime.strftime("%Y-%m-%d %H:%M")
version = mtime.strftime("%Y-%m-%d")
else:
updated_at = "—"
version = "—"
return {"version": version, "updated_at": updated_at, "body": body}