chatgpt2api / services /editable_file_task_service.py
huanx's picture
Deploy chatgpt2api to Hugging Face Space
bf53440 verified
Raw
History Blame Contribute Delete
11.6 kB
from __future__ import annotations
import json
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import quote
from services.account_service import account_service
from services.config import DATA_DIR
from services.content_filter import request_text
from services.log_service import LOG_TYPE_CALL, log_service
from services.openai_backend_api import EDITABLE_FILE_MODEL, OpenAIBackendAPI
from utils.helper import new_uuid
TASK_STATUS_QUEUED = "queued"
TASK_STATUS_RUNNING = "running"
TASK_STATUS_SUCCESS = "success"
TASK_STATUS_ERROR = "error"
UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
EDITABLE_FILE_PLAN_TYPES = ("Plus", "Team", "Pro", "Enterprise")
EDITABLE_FILE_ROOT = DATA_DIR / "files"
EDITABLE_FILE_TASKS_PATH = DATA_DIR / "editable_file_tasks.json"
def _now_iso() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _clean(value: object, default: str = "") -> str:
return str(value or default).strip()
def _owner_id(identity: dict[str, object]) -> str:
return _clean(identity.get("id")) or "anonymous"
def _task_key(owner_id: str, task_id: str) -> str:
return f"{owner_id}:{task_id}"
def _elapsed_seconds(task: dict[str, Any]) -> int:
start = float(task.get("started_ts") or task.get("created_ts") or 0)
end = float(task.get("ended_ts") or time.time())
return max(0, int(end - start)) if start else 0
def _file_url(path: Path, base_url: str) -> str:
rel = path.resolve().relative_to(EDITABLE_FILE_ROOT.resolve()).as_posix()
prefix = str(base_url or "").strip().rstrip("/")
return f"{prefix}/files/{quote(rel, safe='/')}" if prefix else f"/files/{quote(rel, safe='/')}"
def _editable_access_token() -> str:
accounts = [
item for item in account_service.list_accounts()
if _clean(item.get("access_token"))
and item.get("status") not in {"禁用", "异常"}
and account_service._account_matches_any_plan_type(item, EDITABLE_FILE_PLAN_TYPES)
]
if not accounts:
raise RuntimeError("no available plus/team/pro account")
accounts.sort(key=lambda item: _clean(item.get("last_used_at")))
token = _clean(accounts[0].get("access_token"))
return account_service.refresh_access_token(token, event="editable_file_task") or token
def _public_task(task: dict[str, Any]) -> dict[str, Any]:
item = {
"id": task.get("id"),
"taskId": task.get("id"),
"status": task.get("status"),
"kind": task.get("kind"),
"created_at": task.get("created_at"),
"updated_at": task.get("updated_at"),
"elapsed_seconds": _elapsed_seconds(task),
}
for key in ("result", "error"):
if task.get(key):
item[key] = task[key]
return item
class EditableFileTaskService:
def __init__(self, path: Path = EDITABLE_FILE_TASKS_PATH) -> None:
self.path = path
self._lock = threading.RLock()
self._tasks: dict[str, dict[str, Any]] = {}
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._lock:
self._tasks = self._load_locked()
if self._recover_unfinished_locked():
self._save_locked()
def submit_ppt(self, identity: dict[str, object], *, client_task_id: str = "", prompt: str = "", base64_images: list[str] | None = None, base_url: str = "") -> dict[str, Any]:
return self._submit(identity, client_task_id=client_task_id, kind="ppt", prompt=prompt, base64_images=base64_images or [], base_url=base_url)
def submit_psd(self, identity: dict[str, object], *, client_task_id: str = "", prompt: str = "", base64_images: list[str] | None = None, base_url: str = "") -> dict[str, Any]:
return self._submit(identity, client_task_id=client_task_id, kind="psd", prompt=prompt, base64_images=base64_images or [], base_url=base_url)
def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
owner = _owner_id(identity)
requested = [_clean(item) for item in task_ids if _clean(item)]
with self._lock:
if requested:
items = [task for task_id in requested if (task := self._tasks.get(_task_key(owner, task_id)))]
return {"items": [_public_task(item) for item in items], "missing_ids": [task_id for task_id in requested if _task_key(owner, task_id) not in self._tasks]}
items = [task for task in self._tasks.values() if task.get("owner_id") == owner]
items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
return {"items": [_public_task(item) for item in items], "missing_ids": []}
def _submit(self, identity: dict[str, object], *, client_task_id: str, kind: str, prompt: str, base64_images: list[str], base_url: str) -> dict[str, Any]:
task_id = _clean(client_task_id) or new_uuid()
owner = _owner_id(identity)
key = _task_key(owner, task_id)
now = _now_iso()
with self._lock:
if key in self._tasks:
return _public_task(self._tasks[key])
ts = time.time()
self._tasks[key] = {"id": task_id, "owner_id": owner, "status": TASK_STATUS_QUEUED, "kind": kind, "model": EDITABLE_FILE_MODEL, "created_at": now, "updated_at": now, "created_ts": ts, "updated_ts": ts}
task = dict(self._tasks[key])
self._save_locked()
threading.Thread(target=self._run_task, args=(key, kind, prompt, base64_images, dict(identity), base_url), name=f"{kind}-file-task-{task_id[:16]}", daemon=True).start()
return _public_task(task)
def _run_task(self, key: str, kind: str, prompt: str, base64_images: list[str], identity: dict[str, object], base_url: str) -> None:
started = time.time()
token = ""
account_email = ""
self._update_task(key, status=TASK_STATUS_RUNNING, error="", started_ts=started)
try:
if kind == "psd" and not base64_images:
raise ValueError("base64_images is empty")
token = _editable_access_token()
account = account_service.get_account(token) or {}
account_email = _clean(account.get("email"))
backend = OpenAIBackendAPI(token)
output_dir = EDITABLE_FILE_ROOT / kind / key.rsplit(":", 1)[-1]
result = backend.export_psd_zip(base64_images, prompt, output_dir) if kind == "psd" else backend.export_ppt_zip(base64_images, prompt, output_dir)
account_service.mark_text_used(token)
data = {"conversation_id": result.conversation_id, "primary_url": _file_url(result.primary_path, base_url), "zip_url": _file_url(result.zip_path, base_url)}
self._update_task(key, status=TASK_STATUS_SUCCESS, result=data, account_email=account_email, error="", ended_ts=time.time())
self._log_call(identity, kind, started, request_text(prompt), account_email=account_email, result=data)
except Exception as exc:
error = str(exc) or "editable file task failed"
self._update_task(key, status=TASK_STATUS_ERROR, error=error, account_email=account_email, ended_ts=time.time())
self._log_call(identity, kind, started, request_text(prompt), status="failed", error=error, account_email=account_email)
def public_file_path(self, relative_path: str) -> Path:
raw = str(relative_path or "").replace("\\", "/").lstrip("/")
path = (EDITABLE_FILE_ROOT / raw).resolve()
path.relative_to(EDITABLE_FILE_ROOT.resolve())
if not path.is_file():
raise FileNotFoundError(raw)
return path
def _update_task(self, key: str, **updates: Any) -> None:
with self._lock:
task = self._tasks.get(key)
if task is None:
return
task.update(updates)
task["updated_at"] = _now_iso()
task["updated_ts"] = time.time()
self._save_locked()
def _load_locked(self) -> dict[str, dict[str, Any]]:
if not self.path.exists():
return {}
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except Exception:
return {}
tasks: dict[str, dict[str, Any]] = {}
for item in (raw.get("tasks") if isinstance(raw, dict) else raw) or []:
if not isinstance(item, dict):
continue
task_id = _clean(item.get("id"))
owner = _clean(item.get("owner_id"))
if not task_id or not owner:
continue
task = {
"id": task_id,
"owner_id": owner,
"status": _clean(item.get("status"), TASK_STATUS_ERROR),
"kind": "psd" if item.get("kind") == "psd" else "ppt",
"created_at": _clean(item.get("created_at"), _now_iso()),
"updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
"created_ts": float(item.get("created_ts") or 0),
"updated_ts": float(item.get("updated_ts") or 0),
}
for field in ("result", "error", "started_ts", "ended_ts"):
if item.get(field):
task[field] = item[field]
tasks[_task_key(owner, task_id)] = task
return tasks
def _save_locked(self) -> None:
items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
def _recover_unfinished_locked(self) -> bool:
changed = False
for task in self._tasks.values():
if task.get("status") in UNFINISHED_STATUSES:
task["status"] = TASK_STATUS_ERROR
task["error"] = "服务已重启,未完成的任务已中断"
task["ended_ts"] = time.time()
task["updated_at"] = _now_iso()
task["updated_ts"] = time.time()
changed = True
return changed
def _log_call(
self,
identity: dict[str, object],
kind: str,
started: float,
request_preview: str,
*,
status: str = "success",
error: str = "",
account_email: str = "",
result: dict[str, str] | None = None,
) -> None:
detail = {
"key_id": identity.get("id"),
"key_name": identity.get("name"),
"role": identity.get("role"),
"endpoint": f"/v1/{kind}/generations",
"model": EDITABLE_FILE_MODEL,
"started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
"ended_at": _now_iso(),
"duration_ms": int((time.time() - started) * 1000),
"status": status,
}
if request_preview:
detail["request_text"] = request_preview
if account_email:
detail["account_email"] = account_email
if error:
detail["error"] = error
if result:
detail["result"] = result
try:
log_service.add(LOG_TYPE_CALL, f"{kind.upper()}生成任务{'失败' if status == 'failed' else '完成'}", detail)
except Exception:
pass
editable_file_task_service = EditableFileTaskService()