feat: add Profile Picture Management tab with manual crop, description editor

- profile_picture_manager.py:
  - Add ORIGINAL_PATH constant; save full-res original before every crop
  - Add skip_crop param to change_profile_picture() for manual crop workflow
  - Add manual_crop(x,y,w,h) method with Discord avatar update + role color sync
  - Add auto_crop_only() to re-run face-detection crop on stored original
  - Add update_description() with Cheshire Cat declarative memory re-injection
  - Add regenerate_description() via vision model
  - Skip crop step if image is already at/below 512x512

- api.py:
  - GET /profile-picture/image/original — serve full-res original (no-cache)
  - GET /profile-picture/image/current  — serve current cropped avatar (no-cache)
  - POST /profile-picture/change-no-crop — acquire image, skip auto-crop
  - POST /profile-picture/manual-crop   — apply crop coords {x,y,width,height}
  - POST /profile-picture/auto-crop     — re-run intelligent crop on original
  - POST /profile-picture/description   — save freeform description + Cat inject
  - POST /profile-picture/regenerate-description — re-generate via vision model
  - GET  /profile-picture/description   — fetch current description text

- index.html:
  - Add new tab11 '🖼️ Profile Picture Management'
  - Remove PFP + role color sections from Actions tab (tab2)
  - Add Cropper.js 1.6.2 via CDN for manual square crop
  - Tab layout: action buttons, file upload, auto/manual crop toggle,
    Cropper.js interface, side-by-side original/cropped previews,
    role color management, freeform description editor, metadata box (bottom)
  - Wire switchTab hook for tab11 → loadPfpTab()
  - All new JS functions: pfpChangeDanbooru, pfpUploadCustom, pfpRestoreFallback,
    pfpShowCropInterface, pfpApplyManualCrop, pfpApplyAutoCrop, pfpSaveDescription,
    pfpRegenerateDescription, pfpRefreshPreviews, setCustomRoleColor, resetRoleColor
This commit is contained in:
2026-03-30 15:10:19 +03:00
parent 08fb465c67
commit f092cadb9d
3 changed files with 991 additions and 146 deletions

View File

@@ -1121,6 +1121,174 @@ async def reset_role_color_to_fallback():
except Exception as e:
return {"status": "error", "message": str(e)}
# === Profile Picture Image Serving ===
@app.get("/profile-picture/image/original")
async def serve_original_profile_picture():
"""Serve the full-resolution original profile picture"""
from utils.profile_picture_manager import profile_picture_manager
path = profile_picture_manager.ORIGINAL_PATH
if not os.path.exists(path):
return {"status": "error", "message": "No original image found"}
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
@app.get("/profile-picture/image/current")
async def serve_current_profile_picture():
"""Serve the current cropped profile picture"""
from utils.profile_picture_manager import profile_picture_manager
path = profile_picture_manager.CURRENT_PATH
if not os.path.exists(path):
return {"status": "error", "message": "No current image found"}
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
# === Profile Picture Manual Crop Workflow ===
@app.post("/profile-picture/change-no-crop")
async def trigger_profile_picture_change_no_crop(
guild_id: int = None,
file: UploadFile = File(None)
):
"""
Change Miku's profile picture but skip auto-cropping.
Saves the full-resolution original for manual cropping later.
"""
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return {"status": "error", "message": "Bot not ready"}
try:
from utils.profile_picture_manager import profile_picture_manager
from server_manager import server_manager
mood = None
if guild_id is not None:
mood, _ = server_manager.get_server_mood(guild_id)
else:
mood = globals.DM_MOOD
custom_image_bytes = None
if file:
custom_image_bytes = await file.read()
logger.info(f"Received custom image for manual crop ({len(custom_image_bytes)} bytes)")
result = await profile_picture_manager.change_profile_picture(
mood=mood,
custom_image_bytes=custom_image_bytes,
debug=True,
skip_crop=True
)
if result["success"]:
return {
"status": "ok",
"message": "Image saved for manual cropping",
"source": result["source"],
"metadata": result.get("metadata", {})
}
else:
return {
"status": "error",
"message": result.get("error", "Unknown error"),
"source": result.get("source")
}
except Exception as e:
logger.error(f"Error in change-no-crop API: {e}")
import traceback
traceback.print_exc()
return {"status": "error", "message": f"Unexpected error: {str(e)}"}
class ManualCropRequest(BaseModel):
x: int
y: int
width: int
height: int
@app.post("/profile-picture/manual-crop")
async def apply_manual_crop(req: ManualCropRequest):
"""Apply a manual crop to the stored original image"""
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return {"status": "error", "message": "Bot not ready"}
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.manual_crop(
x=req.x, y=req.y, width=req.width, height=req.height, debug=True
)
if result["success"]:
return {
"status": "ok",
"message": "Manual crop applied successfully",
"metadata": result.get("metadata", {})
}
else:
return {"status": "error", "message": result.get("error", "Unknown error")}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.post("/profile-picture/auto-crop")
async def apply_auto_crop():
"""Run intelligent auto-crop on the stored original image"""
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return {"status": "error", "message": "Bot not ready"}
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.auto_crop_only(debug=True)
if result["success"]:
return {
"status": "ok",
"message": "Auto-crop applied successfully",
"metadata": result.get("metadata", {})
}
else:
return {"status": "error", "message": result.get("error", "Unknown error")}
except Exception as e:
return {"status": "error", "message": str(e)}
class DescriptionUpdateRequest(BaseModel):
description: str
@app.post("/profile-picture/description")
async def update_profile_picture_description(req: DescriptionUpdateRequest):
"""Update the profile picture description (and optionally re-inject into Cat)"""
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.update_description(
description=req.description, reinject_cat=True, debug=True
)
if result["success"]:
return {"status": "ok", "message": "Description updated successfully"}
else:
return {"status": "error", "message": result.get("error", "Unknown error")}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.post("/profile-picture/regenerate-description")
async def regenerate_profile_picture_description():
"""Re-generate the profile picture description using the vision model"""
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.regenerate_description(debug=True)
if result["success"]:
return {
"status": "ok",
"message": "Description regenerated successfully",
"description": result["description"]
}
else:
return {"status": "error", "message": result.get("error", "Unknown error")}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.get("/profile-picture/description")
async def get_profile_picture_description():
"""Get the current profile picture description text"""
try:
from utils.profile_picture_manager import profile_picture_manager
description = profile_picture_manager.get_current_description()
return {"status": "ok", "description": description or ""}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.post("/manual/send")
async def manual_send(
message: str = Form(...),