auto-save 2026-05-26 00:13 (~8)

This commit is contained in:
2026-05-26 00:13:17 +08:00
parent 089a30d970
commit 544087cf9d
8 changed files with 407 additions and 67 deletions

View File

@@ -1,11 +1,5 @@
{ {
"entries": [ "entries": [
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record subject pack deployment",
"ts": "2026-05-20T02:53:56Z",
"type": "session-heartbeat"
},
{ {
"files_changed": 1, "files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record subject pack deployment", "message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record subject pack deployment",
@@ -3225,6 +3219,13 @@
"type": "session-heartbeat", "type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record Feishu OAuth enablement", "message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record Feishu OAuth enablement",
"files_changed": 1 "files_changed": 1
},
{
"ts": "2026-05-26T00:07:48+08:00",
"type": "commit",
"message": "auto-save 2026-05-26 00:07 (+1, ~3)",
"hash": "089a30d",
"files_changed": 4
} }
] ]
} }

View File

@@ -417,14 +417,20 @@ def upsert_canvas_project(user: dict, project: dict) -> dict | None:
) )
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,1,%s,%s) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,1,%s,%s)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name, name = CASE
thumbnail = EXCLUDED.thumbnail, WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.name
ELSE canvas_projects.name
END,
thumbnail = CASE
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.thumbnail
ELSE canvas_projects.thumbnail
END,
visibility = CASE visibility = CASE
WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN EXCLUDED.visibility WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.visibility
ELSE canvas_projects.visibility ELSE canvas_projects.visibility
END, END,
canvas_data = CASE canvas_data = CASE
WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN EXCLUDED.canvas_data WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.canvas_data
ELSE canvas_projects.canvas_data ELSE canvas_projects.canvas_data
END, END,
updated_at = CASE updated_at = CASE
@@ -432,11 +438,11 @@ def upsert_canvas_project(user: dict, project: dict) -> dict | None:
ELSE canvas_projects.updated_at ELSE canvas_projects.updated_at
END, END,
version = CASE version = CASE
WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN canvas_projects.version + 1 WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN canvas_projects.version + 1
ELSE canvas_projects.version ELSE canvas_projects.version
END, END,
deleted_at = CASE deleted_at = CASE
WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN NULL WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN NULL
ELSE canvas_projects.deleted_at ELSE canvas_projects.deleted_at
END END
RETURNING id, name, thumbnail, visibility, canvas_data, created_at, updated_at, version, owner_id RETURNING id, name, thumbnail, visibility, canvas_data, created_at, updated_at, version, owner_id

View File

@@ -1544,6 +1544,7 @@ def _write_prompt_item(item: PromptLibraryItem) -> None:
PROMPT_LIBRARY_ITEMS_DIR.mkdir(parents=True, exist_ok=True) PROMPT_LIBRARY_ITEMS_DIR.mkdir(parents=True, exist_ok=True)
_prompt_item_file(item).write_text(item.model_dump_json(indent=2), encoding="utf-8") _prompt_item_file(item).write_text(item.model_dump_json(indent=2), encoding="utf-8")
_write_prompt_library_index() _write_prompt_library_index()
db.index_prompt_item(item.model_dump())
def _write_asset_item(item: AssetLibraryItem) -> None: def _write_asset_item(item: AssetLibraryItem) -> None:
@@ -1551,6 +1552,7 @@ def _write_asset_item(item: AssetLibraryItem) -> None:
p.parent.mkdir(parents=True, exist_ok=True) p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(item.model_dump_json(indent=2), encoding="utf-8") p.write_text(item.model_dump_json(indent=2), encoding="utf-8")
_write_asset_library_index() _write_asset_library_index()
db.index_asset_item(item.model_dump())
def _read_prompt_item(path: Path) -> PromptLibraryItem | None: def _read_prompt_item(path: Path) -> PromptLibraryItem | None:
@@ -1921,6 +1923,7 @@ def cancel_queued_video_task(job_id: str, video_id: str) -> bool:
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
db_ready = db.init_schema()
try: try:
_rebuild_library_index() _rebuild_library_index()
except Exception as e: except Exception as e:
@@ -2000,6 +2003,16 @@ async def lifespan(_: FastAPI):
JOBS[p.name] = job JOBS[p.name] = job
except Exception: except Exception:
pass pass
if db_ready:
for job in JOBS.values():
db.index_job(job.model_dump(), str(job_dir(job.id) / "state.json"))
try:
for item in load_prompt_library_items():
db.index_prompt_item(item.model_dump())
for item in load_asset_library_items():
db.index_asset_item(item.model_dump())
except Exception as e:
print(f"[db] initial library sync failed: {e}", flush=True)
yield yield
@@ -2057,11 +2070,12 @@ def auth_me(request: Request) -> dict:
session = auth_session_from_request(request) session = auth_session_from_request(request)
if not session: if not session:
raise HTTPException(401, "unauthorized") raise HTTPException(401, "unauthorized")
db.upsert_user(session, request)
return {"ok": True, "user": session} return {"ok": True, "user": session}
@app.post("/auth/login") @app.post("/auth/login")
def auth_login(payload: AuthLoginPayload, response: Response) -> dict: def auth_login(payload: AuthLoginPayload, request: Request, response: Response) -> dict:
ensure_password_auth_configured() ensure_password_auth_configured()
username = payload.username.strip() username = payload.username.strip()
password = payload.password password = payload.password
@@ -2080,6 +2094,9 @@ def auth_login(payload: AuthLoginPayload, response: Response) -> dict:
samesite="lax", samesite="lax",
path="/", path="/",
) )
session = _public_session({"u": WEB_AUTH_USERNAME, "name": WEB_AUTH_USERNAME, "provider": "password", "uid": f"password:{WEB_AUTH_USERNAME}"})
db.upsert_user(session, request)
db.audit(session, "login.password", "user", session["uid"], request=request)
return {"ok": True, "username": WEB_AUTH_USERNAME} return {"ok": True, "username": WEB_AUTH_USERNAME}
@@ -2122,6 +2139,8 @@ def auth_feishu_callback(request: Request) -> RedirectResponse:
access_token = _exchange_feishu_code(code, _feishu_redirect_uri(request)) access_token = _exchange_feishu_code(code, _feishu_redirect_uri(request))
session = _build_feishu_session(_fetch_feishu_user(access_token)) session = _build_feishu_session(_fetch_feishu_user(access_token))
_validate_feishu_session(session) _validate_feishu_session(session)
db.upsert_user(session, request)
db.audit(session, "login.feishu", "user", session["uid"], request=request)
ttl_seconds = 60 * 60 * 24 * 30 ttl_seconds = 60 * 60 * 24 * 30
response = RedirectResponse(_normalize_next_url(str(state_payload.get("next") or "/")), status_code=302) response = RedirectResponse(_normalize_next_url(str(state_payload.get("next") or "/")), status_code=302)
@@ -2154,6 +2173,128 @@ def auth_logout(response: Response) -> dict:
return {"ok": True} return {"ok": True}
class CanvasProjectWriteReq(BaseModel):
id: str = ""
name: str = "未命名项目"
thumbnail: str = ""
visibility: Literal["private", "team", "company"] = "private"
canvas_data: dict = Field(default_factory=dict)
created_at: float = 0.0
updated_at: float = 0.0
source: str = "canvas"
class CanvasProjectImportReq(BaseModel):
projects: list[CanvasProjectWriteReq] = Field(default_factory=list)
def _ts(value) -> float:
if hasattr(value, "timestamp"):
return float(value.timestamp())
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def _require_db() -> None:
if not db.enabled():
raise HTTPException(503, "database not configured")
def _canvas_project_public(row: dict) -> dict:
return {
"id": str(row.get("id") or ""),
"name": str(row.get("name") or ""),
"thumbnail": str(row.get("thumbnail") or ""),
"visibility": str(row.get("visibility") or "private"),
"canvas_data": row.get("canvas_data") or {},
"created_at": _ts(row.get("created_at")),
"updated_at": _ts(row.get("updated_at")),
"version": int(row.get("version") or 1),
"owner_id": str(row.get("owner_id") or ""),
"owner_name": str(row.get("owner_name") or ""),
"owner_email": str(row.get("owner_email") or ""),
"owner_provider": str(row.get("owner_provider") or ""),
}
@app.get("/canvas-projects")
def list_canvas_projects(request: Request) -> dict:
_require_db()
user = data_user_from_request(request)
db.upsert_user(user, request)
return {
"ok": True,
"items": [_canvas_project_public(row) for row in db.list_canvas_projects(user)],
}
@app.post("/canvas-projects")
def create_canvas_project(req: CanvasProjectWriteReq, request: Request) -> dict:
_require_db()
user = data_user_from_request(request)
db.upsert_user(user, request)
row = db.upsert_canvas_project(user, req.model_dump())
if not row:
raise HTTPException(500, "canvas project save failed")
db.audit(user, "canvas_project.create", "canvas_project", str(row.get("id") or ""), req.model_dump(exclude={"canvas_data"}), request, str(row.get("visibility") or "private"))
return {"ok": True, "item": _canvas_project_public(row)}
@app.put("/canvas-projects/{project_id}")
def put_canvas_project(project_id: str, req: CanvasProjectWriteReq, request: Request) -> dict:
_require_db()
user = data_user_from_request(request)
db.upsert_user(user, request)
payload = req.model_dump()
payload["id"] = project_id
row = db.upsert_canvas_project(user, payload)
if not row:
raise HTTPException(500, "canvas project save failed")
if str(row.get("owner_id") or "") != _session_user_id(user):
raise HTTPException(403, "canvas project belongs to another user")
db.audit(user, "canvas_project.save", "canvas_project", project_id, {"name": req.name, "visibility": req.visibility}, request, str(row.get("visibility") or "private"))
return {"ok": True, "item": _canvas_project_public(row)}
@app.get("/canvas-projects/{project_id}")
def get_canvas_project(project_id: str, request: Request) -> dict:
_require_db()
user = data_user_from_request(request)
row = db.get_canvas_project(project_id, user)
if not row:
raise HTTPException(404, "canvas project not found")
return {"ok": True, "item": _canvas_project_public(row)}
@app.delete("/canvas-projects/{project_id}")
def delete_canvas_project(project_id: str, request: Request) -> dict:
_require_db()
user = data_user_from_request(request)
ok = db.soft_delete_canvas_project(user, project_id)
if not ok:
raise HTTPException(404, "canvas project not found")
db.audit(user, "canvas_project.delete", "canvas_project", project_id, request=request)
return {"ok": True, "id": project_id}
@app.post("/canvas-projects/import")
def import_canvas_projects(req: CanvasProjectImportReq, request: Request) -> dict:
_require_db()
user = data_user_from_request(request)
db.upsert_user(user, request)
imported = []
for item in req.projects[:200]:
payload = item.model_dump()
payload["source"] = "localStorage"
row = db.upsert_canvas_project(user, payload)
if row:
imported.append(_canvas_project_public(row))
db.audit(user, "canvas_project.import", "canvas_project", "", {"count": len(imported)}, request)
return {"ok": True, "items": imported}
def _parse_library_metadata(raw: str) -> dict: def _parse_library_metadata(raw: str) -> dict:
if not raw.strip(): if not raw.strip():
return {} return {}
@@ -2209,7 +2350,8 @@ def get_prompt_library_item(item_id: str) -> PromptLibraryItem:
@app.post("/prompt-library", response_model=PromptLibraryItem) @app.post("/prompt-library", response_model=PromptLibraryItem)
def create_prompt_library_item(req: PromptLibraryWriteReq) -> PromptLibraryItem: def create_prompt_library_item(req: PromptLibraryWriteReq, request: Request) -> PromptLibraryItem:
user = data_user_from_request(request)
now = _now_ts() now = _now_ts()
name = req.name.strip() name = req.name.strip()
prompt_en = _ensure_english(req.prompt_en.strip()) if req.prompt_en.strip() else "" prompt_en = _ensure_english(req.prompt_en.strip()) if req.prompt_en.strip() else ""
@@ -2229,11 +2371,13 @@ def create_prompt_library_item(req: PromptLibraryWriteReq) -> PromptLibraryItem:
updated_at=now, updated_at=now,
) )
_write_prompt_item(item) _write_prompt_item(item)
db.audit(user, "prompt_library.create", "prompt", item.id, {"category": item.category, "name": item.name}, request, "company")
return item return item
@app.patch("/prompt-library/{item_id}", response_model=PromptLibraryItem) @app.patch("/prompt-library/{item_id}", response_model=PromptLibraryItem)
def patch_prompt_library_item(item_id: str, req: PromptLibraryPatchReq) -> PromptLibraryItem: def patch_prompt_library_item(item_id: str, req: PromptLibraryPatchReq, request: Request) -> PromptLibraryItem:
user = data_user_from_request(request)
item = find_prompt_library_item(item_id) item = find_prompt_library_item(item_id)
data = item.model_dump() data = item.model_dump()
patch = req.model_dump(exclude_unset=True) patch = req.model_dump(exclude_unset=True)
@@ -2249,26 +2393,31 @@ def patch_prompt_library_item(item_id: str, req: PromptLibraryPatchReq) -> Promp
if not updated.name.strip(): if not updated.name.strip():
raise HTTPException(400, "prompt name required") raise HTTPException(400, "prompt name required")
_write_prompt_item(updated) _write_prompt_item(updated)
db.audit(user, "prompt_library.update", "prompt", item_id, {"fields": sorted(patch.keys())}, request, "company")
return updated return updated
@app.delete("/prompt-library/{item_id}") @app.delete("/prompt-library/{item_id}")
def delete_prompt_library_item(item_id: str) -> dict: def delete_prompt_library_item(item_id: str, request: Request) -> dict:
user = data_user_from_request(request)
item = find_prompt_library_item(item_id) item = find_prompt_library_item(item_id)
src = _prompt_item_file(item) src = _prompt_item_file(item)
trash = LIBRARY_TRASH_DIR / "prompt_library" / f"{item.id}_{int(_now_ts())}.json" trash = LIBRARY_TRASH_DIR / "prompt_library" / f"{item.id}_{int(_now_ts())}.json"
trash.parent.mkdir(parents=True, exist_ok=True) trash.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(trash)) shutil.move(str(src), str(trash))
_write_prompt_library_index() _write_prompt_library_index()
db.audit(user, "prompt_library.delete", "prompt", item.id, request=request, visibility="company")
return {"ok": True, "id": item.id, "trashed": str(trash)} return {"ok": True, "id": item.id, "trashed": str(trash)}
@app.post("/prompt-library/{item_id}/use", response_model=PromptLibraryItem) @app.post("/prompt-library/{item_id}/use", response_model=PromptLibraryItem)
def use_prompt_library_item(item_id: str) -> PromptLibraryItem: def use_prompt_library_item(item_id: str, request: Request) -> PromptLibraryItem:
user = data_user_from_request(request)
item = find_prompt_library_item(item_id) item = find_prompt_library_item(item_id)
item.use_count += 1 item.use_count += 1
item.updated_at = _now_ts() item.updated_at = _now_ts()
_write_prompt_item(item) _write_prompt_item(item)
db.audit(user, "prompt_library.use", "prompt", item.id, request=request, visibility="company")
return item return item
@@ -2288,9 +2437,11 @@ def get_asset_library_item(kind: AssetLibraryKind, item_id: str) -> AssetLibrary
@app.post("/asset-library/{kind}", response_model=AssetLibraryItem) @app.post("/asset-library/{kind}", response_model=AssetLibraryItem)
async def create_asset_library_item( async def create_asset_library_item(
kind: AssetLibraryKind, kind: AssetLibraryKind,
request: Request,
metadata: str = Form("{}"), metadata: str = Form("{}"),
files: list[UploadFile] = File(default=[]), files: list[UploadFile] = File(default=[]),
) -> AssetLibraryItem: ) -> AssetLibraryItem:
user = data_user_from_request(request)
meta = _parse_library_metadata(metadata) meta = _parse_library_metadata(metadata)
if not files: if not files:
raise HTTPException(400, "at least one file required") raise HTTPException(400, "at least one file required")
@@ -2363,11 +2514,13 @@ async def create_asset_library_item(
raise HTTPException(400, "video file required") raise HTTPException(400, "video file required")
_hydrate_asset_library_urls(item) _hydrate_asset_library_urls(item)
_write_asset_item(item) _write_asset_item(item)
db.audit(user, "asset_library.create", "asset_library", item.id, {"kind": kind, "name": item.name}, request, "company")
return item return item
@app.patch("/asset-library/{kind}/{item_id}", response_model=AssetLibraryItem) @app.patch("/asset-library/{kind}/{item_id}", response_model=AssetLibraryItem)
def patch_asset_library_item(kind: AssetLibraryKind, item_id: str, req: AssetLibraryPatchReq) -> AssetLibraryItem: def patch_asset_library_item(kind: AssetLibraryKind, item_id: str, req: AssetLibraryPatchReq, request: Request) -> AssetLibraryItem:
user = data_user_from_request(request)
item = find_asset_library_item(kind, item_id) item = find_asset_library_item(kind, item_id)
data = item.model_dump() data = item.model_dump()
patch = req.model_dump(exclude_unset=True) patch = req.model_dump(exclude_unset=True)
@@ -2378,6 +2531,7 @@ def patch_asset_library_item(kind: AssetLibraryKind, item_id: str, req: AssetLib
updated = AssetLibraryItem(**data) updated = AssetLibraryItem(**data)
_hydrate_asset_library_urls(updated) _hydrate_asset_library_urls(updated)
_write_asset_item(updated) _write_asset_item(updated)
db.audit(user, "asset_library.update", "asset_library", item_id, {"kind": kind, "fields": sorted(patch.keys())}, request, "company")
return updated return updated
@@ -2388,7 +2542,8 @@ def asset_library_refs(kind: AssetLibraryKind, item_id: str) -> dict:
@app.delete("/asset-library/{kind}/{item_id}") @app.delete("/asset-library/{kind}/{item_id}")
def delete_asset_library_item(kind: AssetLibraryKind, item_id: str, force: bool = False) -> dict: def delete_asset_library_item(kind: AssetLibraryKind, item_id: str, request: Request, force: bool = False) -> dict:
user = data_user_from_request(request)
item = find_asset_library_item(kind, item_id) item = find_asset_library_item(kind, item_id)
refs = _library_ref_usage(kind, item_id) refs = _library_ref_usage(kind, item_id)
if refs["count"] and not force: if refs["count"] and not force:
@@ -2398,11 +2553,14 @@ def delete_asset_library_item(kind: AssetLibraryKind, item_id: str, force: bool
trash.parent.mkdir(parents=True, exist_ok=True) trash.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(trash)) shutil.move(str(src), str(trash))
_write_asset_library_index() _write_asset_library_index()
db.audit(user, "asset_library.delete", "asset_library", item.id, {"kind": kind, "refs": refs}, request, "company")
return {"ok": True, "id": item.id, "refs": refs, "trashed": str(trash)} return {"ok": True, "id": item.id, "refs": refs, "trashed": str(trash)}
@app.post("/asset-library/{kind}/{item_id}/copy-to-job/{job_id}") @app.post("/asset-library/{kind}/{item_id}/copy-to-job/{job_id}")
def copy_asset_library_to_job(kind: AssetLibraryKind, item_id: str, job_id: str) -> dict: def copy_asset_library_to_job(kind: AssetLibraryKind, item_id: str, job_id: str, request: Request) -> dict:
user = data_user_from_request(request)
db.audit(user, "asset_library.copy_to_job", "asset_library", item_id, {"kind": kind, "job_id": job_id}, request, "company")
return _copy_library_to_job(kind, item_id, job_id) return _copy_library_to_job(kind, item_id, job_id)
@@ -5455,6 +5613,7 @@ def health() -> dict:
"feishu": FEISHU_AUTH_CONFIGURED, "feishu": FEISHU_AUTH_CONFIGURED,
"data_isolation": AUTH_DATA_ISOLATION_ENABLED, "data_isolation": AUTH_DATA_ISOLATION_ENABLED,
}, },
"database": db.health(),
"base_url": LLM_BASE_URL or "openai-default", "base_url": LLM_BASE_URL or "openai-default",
"asr_base_url": ASR_BASE_URL or LLM_BASE_URL or "openai-default", "asr_base_url": ASR_BASE_URL or LLM_BASE_URL or "openai-default",
"image_base_url": IMAGE_BASE_URL or LLM_BASE_URL or "openai-default", "image_base_url": IMAGE_BASE_URL or LLM_BASE_URL or "openai-default",
@@ -5571,6 +5730,7 @@ async def create_job(req: CreateJobReq, bg: BackgroundTasks, request: Request) -
assign_owner(job, user) assign_owner(job, user)
JOBS[job_id] = job JOBS[job_id] = job
save_state(job) save_state(job)
db.audit(user, "job.create", "job", job_id, {"url": job.url}, request)
bg.add_task(pipeline_download, job_id) bg.add_task(pipeline_download, job_id)
return job return job
@@ -5623,6 +5783,7 @@ async def create_job_from_upload(bg: BackgroundTasks, request: Request, file: Up
assign_owner(job, user) assign_owner(job, user)
JOBS[job_id] = job JOBS[job_id] = job
save_state(job) save_state(job)
db.audit(user, "job.upload", "job", job_id, {"filename": file.filename}, request)
bg.add_task(pipeline_download, job_id) bg.add_task(pipeline_download, job_id)
return job return job
@@ -5688,6 +5849,7 @@ async def create_creative_image_job(request: Request) -> Job:
assign_owner(job, user) assign_owner(job, user)
JOBS[job_id] = job JOBS[job_id] = job
save_state(job) save_state(job)
db.audit(user, "creative_job.create", "job", job_id, {"source": source_label}, request)
return job return job
@@ -9139,6 +9301,7 @@ def save_agent_run(run: AgentRun) -> None:
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
agent_run_path(run.id).write_text(run.model_dump_json(indent=2), encoding="utf-8") agent_run_path(run.id).write_text(run.model_dump_json(indent=2), encoding="utf-8")
AGENT_RUNS[run.id] = run AGENT_RUNS[run.id] = run
db.index_agent_run(run.model_dump())
def agent_log( def agent_log(
@@ -9462,6 +9625,7 @@ async def create_agent_run(
assign_owner(run, user) assign_owner(run, user)
save_agent_run(run) save_agent_run(run)
agent_log(run, f"任务已入队 · job={job_id} · 产品图 {len(refs)}", status="queued", stage="queued", progress=1) agent_log(run, f"任务已入队 · job={job_id} · 产品图 {len(refs)}", status="queued", stage="queued", progress=1)
db.audit(user, "agent_run.create", "agent_run", run_id, {"job_id": job_id, "product_refs": len(refs)}, request)
threading.Thread(target=agent_run_worker, args=(run_id, refs), daemon=True).start() threading.Thread(target=agent_run_worker, args=(run_id, refs), daemon=True).start()
return run return run

View File

@@ -9,6 +9,12 @@ KEYFRAME_COUNT=12
CORS_ORIGINS=https://marketing.skg.com CORS_ORIGINS=https://marketing.skg.com
API_PORT=4291 API_PORT=4291
# Company persistence database. Real password and DATABASE_URL live only on server.
POSTGRES_DB=skg_marketing
POSTGRES_USER=skg_marketing
POSTGRES_PASSWORD=
DATABASE_URL=postgresql://skg_marketing:CHANGE_ME@postgres:5432/skg_marketing
# Web login. Keep real password and session secret only on the server. # Web login. Keep real password and session secret only on the server.
WEB_AUTH_USERNAME=skg WEB_AUTH_USERNAME=skg
WEB_AUTH_PASSWORD= WEB_AUTH_PASSWORD=

View File

@@ -1,6 +1,24 @@
name: skg-marketing-studio name: skg-marketing-studio
services: services:
postgres:
image: postgres:16-alpine
container_name: skg-marketing-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-skg_marketing}
POSTGRES_USER: ${POSTGRES_USER:-skg_marketing}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- ./data/postgres:/var/lib/postgresql/data
restart: unless-stopped
networks:
- skg-marketing-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skg_marketing} -d ${POSTGRES_DB:-skg_marketing}"]
interval: 10s
timeout: 5s
retries: 10
api: api:
build: build:
context: . context: .
@@ -13,6 +31,7 @@ services:
AGENT_RUNS_DIR: /data/agent_runs AGENT_RUNS_DIR: /data/agent_runs
ASSET_LIBRARY_DIR: /data/asset_library ASSET_LIBRARY_DIR: /data/asset_library
PROMPT_LIBRARY_DIR: /data/prompt_library PROMPT_LIBRARY_DIR: /data/prompt_library
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
CORS_ORIGINS: https://marketing.skg.com CORS_ORIGINS: https://marketing.skg.com
volumes: volumes:
- ./data/jobs:/data/jobs - ./data/jobs:/data/jobs
@@ -22,6 +41,9 @@ services:
- ./data/_trash:/data/_trash - ./data/_trash:/data/_trash
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt - ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
restart: unless-stopped restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
networks: networks:
- skg-marketing-internal - skg-marketing-internal

View File

@@ -1,6 +1,24 @@
name: skg-agent-cut name: skg-agent-cut
services: services:
postgres:
image: postgres:16-alpine
container_name: skg-agent-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-skg_marketing}
POSTGRES_USER: ${POSTGRES_USER:-skg_marketing}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- ./data/postgres:/var/lib/postgresql/data
restart: unless-stopped
networks:
- skg-agent-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skg_marketing} -d ${POSTGRES_DB:-skg_marketing}"]
interval: 10s
timeout: 5s
retries: 10
api: api:
build: build:
context: . context: .
@@ -13,6 +31,7 @@ services:
AGENT_RUNS_DIR: /data/agent_runs AGENT_RUNS_DIR: /data/agent_runs
ASSET_LIBRARY_DIR: /data/asset_library ASSET_LIBRARY_DIR: /data/asset_library
PROMPT_LIBRARY_DIR: /data/prompt_library PROMPT_LIBRARY_DIR: /data/prompt_library
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
CORS_ORIGINS: http://2.24.28.41:4290,http://localhost:4290 CORS_ORIGINS: http://2.24.28.41:4290,http://localhost:4290
volumes: volumes:
- ./data/jobs:/data/jobs - ./data/jobs:/data/jobs
@@ -22,6 +41,9 @@ services:
- ./data/_trash:/data/_trash - ./data/_trash:/data/_trash
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt - ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
restart: unless-stopped restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
networks: networks:
skg-agent-internal: skg-agent-internal:
aliases: aliases:

View File

@@ -16,11 +16,93 @@ export const projects = ref([])
// Current project ID | 当前项目ID // Current project ID | 当前项目ID
export const currentProjectId = ref(null) export const currentProjectId = ref(null)
export const projectSyncStatus = ref('idle')
export const projectSyncError = ref('')
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
const remoteSaveTimers = new Map()
let initPromise = null
let remoteAvailable = false
// Current project | 当前项目 // Current project | 当前项目
export const currentProject = computed(() => { export const currentProject = computed(() => {
return projects.value.find(p => p.id === currentProjectId.value) || null return projects.value.find(p => p.id === currentProjectId.value) || null
}) })
const dateToSeconds = (value) => {
if (value instanceof Date) return value.getTime() / 1000
const parsed = new Date(value)
return Number.isFinite(parsed.getTime()) ? parsed.getTime() / 1000 : Date.now() / 1000
}
const secondsToDate = (value) => {
if (value instanceof Date) return value
const num = Number(value || 0)
return new Date(num > 100000000000 ? num : num * 1000)
}
const projectFromApi = (item) => ({
id: item.id,
name: item.name || '未命名项目',
thumbnail: item.thumbnail || '',
visibility: item.visibility || 'private',
ownerId: item.owner_id || '',
ownerName: item.owner_name || '',
ownerEmail: item.owner_email || '',
ownerProvider: item.owner_provider || '',
version: item.version || 1,
createdAt: secondsToDate(item.created_at),
updatedAt: secondsToDate(item.updated_at),
canvasData: item.canvas_data || {
nodes: [],
edges: [],
viewport: { x: 100, y: 50, zoom: 0.8 }
}
})
const projectToApi = (project) => ({
id: project.id,
name: project.name || '未命名项目',
thumbnail: project.thumbnail || '',
visibility: project.visibility || 'private',
canvas_data: cleanProjectForStorage(project).canvasData || {
nodes: [],
edges: [],
viewport: { x: 100, y: 50, zoom: 0.8 }
},
created_at: dateToSeconds(project.createdAt),
updated_at: dateToSeconds(project.updatedAt),
source: 'canvas'
})
const requestJson = async (path, init = {}) => {
const response = await fetch(apiUrl(path), {
...init,
headers: {
...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(init.headers || {})
}
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(text || `${path} ${response.status}`)
}
return response.json()
}
const mergeProjectLists = (localItems, remoteItems) => {
const byId = new Map()
for (const item of remoteItems) byId.set(item.id, item)
for (const item of localItems) {
const existing = byId.get(item.id)
if (!existing || dateToSeconds(item.updatedAt) > dateToSeconds(existing.updatedAt)) {
byId.set(item.id, item)
}
}
return [...byId.values()].sort((a, b) => dateToSeconds(b.updatedAt) - dateToSeconds(a.updatedAt))
}
/** /**
* Load projects from localStorage | 从 localStorage 加载项目 * Load projects from localStorage | 从 localStorage 加载项目
*/ */
@@ -42,6 +124,69 @@ export const loadProjects = () => {
} }
} }
const saveRemoteProjectNow = async (project) => {
if (!project?.id) return null
const response = await requestJson(`/canvas-projects/${encodeURIComponent(project.id)}`, {
method: 'PUT',
body: JSON.stringify(projectToApi(project))
})
return response.item ? projectFromApi(response.item) : null
}
const scheduleRemoteSave = (project, delay = 800) => {
if (!remoteAvailable || !project?.id) return
if (remoteSaveTimers.has(project.id)) {
clearTimeout(remoteSaveTimers.get(project.id))
}
remoteSaveTimers.set(project.id, setTimeout(async () => {
remoteSaveTimers.delete(project.id)
try {
projectSyncStatus.value = 'syncing'
await saveRemoteProjectNow(project)
projectSyncStatus.value = 'synced'
projectSyncError.value = ''
} catch (err) {
projectSyncStatus.value = 'error'
projectSyncError.value = err.message || '项目同步失败'
console.warn('Failed to sync project:', err)
}
}, delay))
}
const importLocalProjectsToServer = async (localItems) => {
if (!localItems.length) return []
const payload = { projects: localItems.map(projectToApi) }
const response = await requestJson('/canvas-projects/import', {
method: 'POST',
body: JSON.stringify(payload)
})
return (response.items || []).map(projectFromApi)
}
export const loadRemoteProjects = async () => {
try {
projectSyncStatus.value = 'syncing'
const localItems = [...projects.value]
const response = await requestJson('/canvas-projects')
remoteAvailable = true
const remoteItems = (response.items || []).map(projectFromApi)
const missingLocal = localItems.filter(local => !remoteItems.some(remote => remote.id === local.id))
const importedItems = await importLocalProjectsToServer(missingLocal)
const merged = mergeProjectLists(localItems, [...remoteItems, ...importedItems])
projects.value = merged
saveProjects({ remote: false })
projectSyncStatus.value = 'synced'
projectSyncError.value = ''
return merged
} catch (err) {
remoteAvailable = false
projectSyncStatus.value = 'error'
projectSyncError.value = err.message || '项目同步失败'
console.warn('Remote project sync unavailable:', err)
return projects.value
}
}
/** /**
* Clean node data for storage | 清理节点数据用于存储 * Clean node data for storage | 清理节点数据用于存储
* Removes base64 data URLs to reduce storage size | 移除 base64 数据减小存储大小 * Removes base64 data URLs to reduce storage size | 移除 base64 数据减小存储大小
@@ -89,7 +234,7 @@ const cleanProjectForStorage = (project) => {
* Save projects to localStorage | 保存项目到 localStorage * Save projects to localStorage | 保存项目到 localStorage
* Handles QuotaExceededError by compressing data | 通过压缩数据处理配额超限错误 * Handles QuotaExceededError by compressing data | 通过压缩数据处理配额超限错误
*/ */
export const saveProjects = () => { export const saveProjects = ({ remote = false } = {}) => {
// Always clean data before saving | 保存前始终清理数据 // Always clean data before saving | 保存前始终清理数据
const cleanedProjects = projects.value.map(cleanProjectForStorage) const cleanedProjects = projects.value.map(cleanProjectForStorage)
@@ -128,6 +273,10 @@ export const saveProjects = () => {
console.error('Failed to save projects:', err) console.error('Failed to save projects:', err)
} }
} }
if (remote) {
for (const project of projects.value) scheduleRemoteSave(project)
}
} }
/** /**
@@ -155,6 +304,7 @@ export const createProject = (name = '未命名项目') => {
projects.value = [newProject, ...projects.value] projects.value = [newProject, ...projects.value]
saveProjects() saveProjects()
scheduleRemoteSave(newProject, 0)
return id return id
} }
@@ -179,6 +329,7 @@ export const updateProject = (id, data) => {
projects.value = [updated, ...projects.value] projects.value = [updated, ...projects.value]
saveProjects() saveProjects()
scheduleRemoteSave(updated)
return true return true
} }
@@ -239,6 +390,10 @@ export const getProjectCanvas = (id) => {
export const deleteProject = (id) => { export const deleteProject = (id) => {
projects.value = projects.value.filter(p => p.id !== id) projects.value = projects.value.filter(p => p.id !== id)
saveProjects() saveProjects()
if (remoteAvailable) {
requestJson(`/canvas-projects/${encodeURIComponent(id)}`, { method: 'DELETE' })
.catch(err => console.warn('Failed to delete remote project:', err))
}
} }
/** /**
@@ -263,6 +418,7 @@ export const duplicateProject = (id) => {
projects.value = [newProject, ...projects.value] projects.value = [newProject, ...projects.value]
saveProjects() saveProjects()
scheduleRemoteSave(newProject, 0)
return newId return newId
} }
@@ -320,51 +476,14 @@ export const getSortedProjects = (sortBy = 'updatedAt', order = 'desc') => {
/** /**
* Initialize projects store | 初始化项目存储 * Initialize projects store | 初始化项目存储
*/ */
export const initProjectsStore = () => { export const initProjectsStore = async () => {
if (initPromise) return initPromise
initPromise = (async () => {
loadProjects() loadProjects()
await loadRemoteProjects()
// Create sample project if empty | 如果为空则创建示例项目 return projects.value
if (projects.value.length === 0) { })()
const id = createProject('示例项目') return initPromise
const project = projects.value.find(p => p.id === id)
if (project) {
project.canvasData = {
nodes: [
{
id: 'node_0',
type: 'text',
position: { x: 150, y: 150 },
data: {
content: '一只金毛寻回犬在草地上奔跑,摇着尾巴,脸上带着快乐的表情。它的毛发在阳光下闪耀,眼神充满了对自由的渴望,全身散发着阳光、友善的气息。',
label: '文本输入'
}
},
{
id: 'node_1',
type: 'imageConfig',
position: { x: 500, y: 150 },
data: {
prompt: '',
model: 'auto',
size: '1024x1024',
label: '文生图'
}
}
],
edges: [
{
id: 'edge_node_0_node_1',
source: 'node_0',
target: 'node_1',
sourceHandle: 'right',
targetHandle: 'left'
}
],
viewport: { x: 100, y: 50, zoom: 0.8 }
}
saveProjects()
}
}
} }
// Export for debugging | 导出用于调试 // Export for debugging | 导出用于调试

View File

@@ -415,7 +415,7 @@ const scrollToProjects = () => {
} }
// Initialize projects store on mount | 挂载时初始化项目存储 // Initialize projects store on mount | 挂载时初始化项目存储
onMounted(() => { onMounted(async () => {
initProjectsStore() await initProjectsStore()
}) })
</script> </script>