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:
168
bot/api.py
168
bot/api.py
@@ -1121,6 +1121,174 @@ async def reset_role_color_to_fallback():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(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")
|
@app.post("/manual/send")
|
||||||
async def manual_send(
|
async def manual_send(
|
||||||
message: str = Form(...),
|
message: str = Form(...),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Miku Control Panel</title>
|
<title>Miku Control Panel</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -666,6 +668,76 @@
|
|||||||
.tab-button { font-size: 0.8rem; padding: 0.35rem 0.5rem; }
|
.tab-button { font-size: 0.8rem; padding: 0.35rem 0.5rem; }
|
||||||
h1 { font-size: 1.2rem; }
|
h1 { font-size: 1.2rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Profile Picture Tab Styles */
|
||||||
|
.pfp-preview-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pfp-preview-box {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pfp-preview-box img {
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 400px;
|
||||||
|
border: 2px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
.pfp-preview-box .label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.pfp-crop-container {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 550px;
|
||||||
|
background: #111;
|
||||||
|
border: 2px solid #555;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.pfp-crop-container img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.crop-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.crop-mode-toggle label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.crop-mode-toggle input[type="radio"] {
|
||||||
|
accent-color: #4CAF50;
|
||||||
|
}
|
||||||
|
.pfp-description-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #ddd;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.pfp-description-editor:focus {
|
||||||
|
border-color: #61dafb;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -704,6 +776,7 @@
|
|||||||
<button class="tab-button" data-tab="tab7" onclick="switchTab('tab7')">💬 Chat with LLM</button>
|
<button class="tab-button" data-tab="tab7" onclick="switchTab('tab7')">💬 Chat with LLM</button>
|
||||||
<button class="tab-button" data-tab="tab8" onclick="switchTab('tab8')">📞 Voice Call</button>
|
<button class="tab-button" data-tab="tab8" onclick="switchTab('tab8')">📞 Voice Call</button>
|
||||||
<button class="tab-button" data-tab="tab9" onclick="switchTab('tab9')">🧠 Memories</button>
|
<button class="tab-button" data-tab="tab9" onclick="switchTab('tab9')">🧠 Memories</button>
|
||||||
|
<button class="tab-button" data-tab="tab11" onclick="switchTab('tab11')">🖼️ Profile Picture</button>
|
||||||
<button class="tab-button" onclick="window.location.href='/static/system.html'">🎛️ System Settings</button>
|
<button class="tab-button" onclick="window.location.href='/static/system.html'">🎛️ System Settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -921,49 +994,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>🎨 Profile Picture</h3>
|
|
||||||
<p style="font-size: 0.9rem; color: #aaa;">Change Miku's profile picture using Danbooru search or upload a custom image.</p>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<button onclick="changeProfilePicture()">🎨 Change Profile Picture (Danbooru)</button>
|
|
||||||
<button onclick="restoreFallbackPfp()">🔄 Restore Original Avatar</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<label for="pfp-upload">Upload Custom Image:</label>
|
|
||||||
<input type="file" id="pfp-upload" accept="image/*" style="margin-left: 0.5rem;">
|
|
||||||
<button onclick="uploadCustomPfp()">📤 Upload & Apply</button>
|
|
||||||
<div style="font-size: 0.8rem; color: #888; margin-top: 0.3rem; margin-left: 0.5rem;">
|
|
||||||
💡 Supports static images (PNG, JPG) and animated GIFs<br>
|
|
||||||
⚠️ Animated GIFs require Discord Nitro on the bot account
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="pfp-status" style="margin-top: 0.5rem; font-size: 0.9rem; color: #61dafb;"></div>
|
|
||||||
|
|
||||||
<div id="pfp-metadata" style="margin-top: 1rem; background: #1e1e1e; padding: 0.5rem; border: 1px solid #333; display: none;">
|
|
||||||
<h4 style="margin-top: 0;">Current Profile Picture Info:</h4>
|
|
||||||
<pre id="pfp-metadata-content" style="margin: 0;"></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Role Color Management -->
|
|
||||||
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #333;">
|
|
||||||
<h4>🎨 Role Color Management</h4>
|
|
||||||
<p style="font-size: 0.9rem; color: #aaa;">Manually set Miku's role color or reset to fallback (#86cecb)</p>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 1rem; display: flex; gap: 10px; align-items: end;">
|
|
||||||
<div>
|
|
||||||
<label for="role-color-hex">Hex Color:</label>
|
|
||||||
<input type="text" id="role-color-hex" placeholder="#86cecb" maxlength="7" style="width: 100px; font-family: monospace;">
|
|
||||||
</div>
|
|
||||||
<button onclick="setCustomRoleColor()">🎨 Apply Color</button>
|
|
||||||
<button onclick="resetRoleColor()">🔄 Reset to Fallback</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="role-color-status" style="margin-top: 0.5rem; font-size: 0.9rem; color: #61dafb;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Figurine DM Subscribers</h3>
|
<h3>Figurine DM Subscribers</h3>
|
||||||
@@ -1740,6 +1771,113 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 11: Profile Picture Management -->
|
||||||
|
<div id="tab11" class="tab-content">
|
||||||
|
<div class="section">
|
||||||
|
<h3>🖼️ Profile Picture Management</h3>
|
||||||
|
<p style="font-size: 0.9rem; color: #aaa;">Change, crop, and manage Miku's profile picture. Edit descriptions and role colors.</p>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div style="margin-bottom: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||||
|
<button onclick="pfpChangeDanbooru()">🎨 Change (Danbooru)</button>
|
||||||
|
<button onclick="pfpRestoreFallback()">🔄 Restore Original Avatar</button>
|
||||||
|
<button onclick="pfpRecrop()">✂️ Re-crop Current</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Section -->
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label for="pfp-tab-upload">Upload Custom Image:</label>
|
||||||
|
<input type="file" id="pfp-tab-upload" accept="image/*" style="margin-left: 0.5rem;">
|
||||||
|
<button onclick="pfpUploadCustom()">📤 Upload & Apply</button>
|
||||||
|
<div style="font-size: 0.8rem; color: #888; margin-top: 0.3rem; margin-left: 0.5rem;">
|
||||||
|
💡 Supports static images (PNG, JPG) and animated GIFs |
|
||||||
|
⚠️ Animated GIFs require Discord Nitro on the bot account
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Crop Mode Toggle -->
|
||||||
|
<div class="crop-mode-toggle">
|
||||||
|
<span style="color: #61dafb; font-weight: bold;">Crop Mode:</span>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="pfp-crop-mode" value="auto" checked>
|
||||||
|
🤖 Auto (face detection)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="pfp-crop-mode" value="manual">
|
||||||
|
✂️ Manual
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div id="pfp-tab-status" style="margin-top: 0.5rem; font-size: 0.9rem; color: #61dafb;"></div>
|
||||||
|
|
||||||
|
<!-- Manual Crop Interface (hidden by default) -->
|
||||||
|
<div id="pfp-crop-section" style="display: none; margin: 1rem 0; padding: 1rem; background: #1a1a2e; border: 1px solid #444; border-radius: 8px;">
|
||||||
|
<h4 style="margin-top: 0;">✂️ Manual Crop</h4>
|
||||||
|
<p style="font-size: 0.85rem; color: #aaa; margin-bottom: 0.5rem;">
|
||||||
|
Drag to select a square crop region. Discord avatars are displayed as circles, so keep the subject centered.
|
||||||
|
</p>
|
||||||
|
<div class="pfp-crop-container">
|
||||||
|
<img id="pfp-crop-image" src="" alt="Crop source">
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<button onclick="pfpApplyManualCrop()" style="background: #4CAF50; color: #fff; font-weight: bold;">✂️ Apply Crop</button>
|
||||||
|
<button onclick="pfpApplyAutoCrop()">🤖 Use Auto Crop Instead</button>
|
||||||
|
<button onclick="pfpHideCropInterface()" style="background: #666;">✖ Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Previews -->
|
||||||
|
<div class="pfp-preview-container">
|
||||||
|
<div class="pfp-preview-box">
|
||||||
|
<span class="label">📷 Original (full resolution)</span>
|
||||||
|
<img id="pfp-preview-original" src="" alt="Original" style="cursor: pointer;" onclick="pfpRecrop()" title="Click to re-crop">
|
||||||
|
<div id="pfp-original-dims" style="font-size: 0.8rem; color: #666; margin-top: 0.3rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pfp-preview-box">
|
||||||
|
<span class="label">🎯 Current Avatar (cropped)</span>
|
||||||
|
<img id="pfp-preview-current" src="" alt="Current avatar" style="border-radius: 50%; max-width: 256px; max-height: 256px;">
|
||||||
|
<div style="font-size: 0.8rem; color: #666; margin-top: 0.3rem;">512×512 · displayed as circle</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Role Color Management -->
|
||||||
|
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #333;">
|
||||||
|
<h4>🎨 Role Color Management</h4>
|
||||||
|
<p style="font-size: 0.9rem; color: #aaa;">Manually set Miku's role color or reset to fallback (#86cecb)</p>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1rem; display: flex; gap: 10px; align-items: end;">
|
||||||
|
<div>
|
||||||
|
<label for="pfp-tab-role-color-hex">Hex Color:</label>
|
||||||
|
<input type="text" id="pfp-tab-role-color-hex" placeholder="#86cecb" maxlength="7" style="width: 100px; font-family: monospace;">
|
||||||
|
</div>
|
||||||
|
<button onclick="setCustomRoleColor()">🎨 Apply Color</button>
|
||||||
|
<button onclick="resetRoleColor()">🔄 Reset to Fallback</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pfp-tab-role-color-status" style="margin-top: 0.5rem; font-size: 0.9rem; color: #61dafb;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description Editor -->
|
||||||
|
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #333;">
|
||||||
|
<h4>📝 Profile Picture Description</h4>
|
||||||
|
<p style="font-size: 0.9rem; color: #aaa;">Edit the description used for context when users ask about Miku's avatar. Saved to Cheshire Cat memory.</p>
|
||||||
|
<textarea id="pfp-description-editor" class="pfp-description-editor" placeholder="Loading description..."></textarea>
|
||||||
|
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<button onclick="pfpSaveDescription()" style="background: #4CAF50; color: #fff;">💾 Save Description</button>
|
||||||
|
<button onclick="pfpRegenerateDescription()">🔄 Re-generate (Vision Model)</button>
|
||||||
|
</div>
|
||||||
|
<div id="pfp-desc-status" style="margin-top: 0.5rem; font-size: 0.9rem; color: #61dafb;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata (bottom) -->
|
||||||
|
<div id="pfp-tab-metadata" style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #333; background: #1e1e1e; padding: 0.5rem; border: 1px solid #333; display: none;">
|
||||||
|
<h4 style="margin-top: 0;">Current Profile Picture Info:</h4>
|
||||||
|
<pre id="pfp-tab-metadata-content" style="margin: 0;"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1888,6 +2026,10 @@ function switchTab(tabId) {
|
|||||||
showTabLoading('tab10');
|
showTabLoading('tab10');
|
||||||
loadDMUsers().finally(() => hideTabLoading('tab10'));
|
loadDMUsers().finally(() => hideTabLoading('tab10'));
|
||||||
}
|
}
|
||||||
|
if (tabId === 'tab11') {
|
||||||
|
console.log('🖼️ Loading Profile Picture tab');
|
||||||
|
loadPfpTab();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTabLoading(tabId) {
|
function showTabLoading(tabId) {
|
||||||
@@ -2187,22 +2329,9 @@ function displayServers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadProfilePictureMetadata() {
|
async function loadProfilePictureMetadata() {
|
||||||
try {
|
// Delegated to PFP tab loader — only runs if tab11 is active
|
||||||
const result = await apiCall('/profile-picture/metadata');
|
if (document.getElementById('tab11') && document.getElementById('tab11').classList.contains('active')) {
|
||||||
|
await loadPfpTab();
|
||||||
if (result.status === 'ok' && result.metadata) {
|
|
||||||
const metadataDiv = document.getElementById('pfp-metadata');
|
|
||||||
const metadataContent = document.getElementById('pfp-metadata-content');
|
|
||||||
|
|
||||||
metadataContent.textContent = JSON.stringify(result.metadata, null, 2);
|
|
||||||
metadataDiv.style.display = 'block';
|
|
||||||
|
|
||||||
console.log('🎨 Loaded profile picture metadata:', result.metadata);
|
|
||||||
} else {
|
|
||||||
console.log('🎨 No profile picture metadata available');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('🎨 Failed to load profile picture metadata:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3333,52 +3462,113 @@ async function triggerShareTweet() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile Picture Management
|
// ============================================================================
|
||||||
async function changeProfilePicture() {
|
// Profile Picture Tab (tab11) — Full Management
|
||||||
const selectedServer = document.getElementById('server-select').value;
|
// ============================================================================
|
||||||
const statusDiv = document.getElementById('pfp-status');
|
|
||||||
const metadataDiv = document.getElementById('pfp-metadata');
|
|
||||||
const metadataContent = document.getElementById('pfp-metadata-content');
|
|
||||||
|
|
||||||
statusDiv.textContent = '⏳ Searching Danbooru and changing profile picture...';
|
let pfpCropper = null; // Cropper.js instance
|
||||||
statusDiv.style.color = '#61dafb';
|
|
||||||
|
|
||||||
|
function getPfpCropMode() {
|
||||||
|
const radio = document.querySelector('input[name="pfp-crop-mode"]:checked');
|
||||||
|
return radio ? radio.value : 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pfpSetStatus(text, color = '#61dafb') {
|
||||||
|
const el = document.getElementById('pfp-tab-status');
|
||||||
|
if (el) { el.textContent = text; el.style.color = color; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pfpRefreshPreviews() {
|
||||||
|
const t = Date.now();
|
||||||
|
const origImg = document.getElementById('pfp-preview-original');
|
||||||
|
const curImg = document.getElementById('pfp-preview-current');
|
||||||
|
if (origImg) origImg.src = `/profile-picture/image/original?t=${t}`;
|
||||||
|
if (curImg) curImg.src = `/profile-picture/image/current?t=${t}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPfpTab() {
|
||||||
|
// Load metadata
|
||||||
try {
|
try {
|
||||||
let endpoint = '/profile-picture/change';
|
const result = await apiCall('/profile-picture/metadata');
|
||||||
const params = new URLSearchParams();
|
if (result.status === 'ok' && result.metadata) {
|
||||||
|
const metaDiv = document.getElementById('pfp-tab-metadata');
|
||||||
|
const metaContent = document.getElementById('pfp-tab-metadata-content');
|
||||||
|
metaContent.textContent = JSON.stringify(result.metadata, null, 2);
|
||||||
|
metaDiv.style.display = 'block';
|
||||||
|
|
||||||
// Add guild_id parameter if a specific server is selected
|
// Show original dimensions if available
|
||||||
if (selectedServer !== 'all') {
|
const dimsEl = document.getElementById('pfp-original-dims');
|
||||||
params.append('guild_id', selectedServer);
|
if (dimsEl && result.metadata.original_width) {
|
||||||
|
dimsEl.textContent = `${result.metadata.original_width}×${result.metadata.original_height}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load PFP metadata:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load description
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/profile-picture/description');
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
document.getElementById('pfp-description-editor').value = result.description || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load PFP description:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh preview images
|
||||||
|
pfpRefreshPreviews();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Danbooru Change ---
|
||||||
|
async function pfpChangeDanbooru() {
|
||||||
|
const mode = getPfpCropMode();
|
||||||
|
const selectedServer = document.getElementById('server-select').value;
|
||||||
|
pfpSetStatus('⏳ Searching Danbooru...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = mode === 'manual' ? '/profile-picture/change-no-crop' : '/profile-picture/change';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedServer !== 'all') params.append('guild_id', selectedServer);
|
||||||
const url = params.toString() ? `${endpoint}?${params.toString()}` : endpoint;
|
const url = params.toString() ? `${endpoint}?${params.toString()}` : endpoint;
|
||||||
|
|
||||||
const result = await apiCall(url, 'POST');
|
const result = await apiCall(url, 'POST');
|
||||||
|
|
||||||
statusDiv.textContent = `✅ ${result.message}`;
|
if (result.status === 'ok') {
|
||||||
statusDiv.style.color = 'green';
|
pfpSetStatus(`✅ ${result.message}`, 'green');
|
||||||
|
showNotification('Profile picture changed!');
|
||||||
|
|
||||||
// Display metadata if available
|
// Show metadata
|
||||||
|
const metaDiv = document.getElementById('pfp-tab-metadata');
|
||||||
|
const metaContent = document.getElementById('pfp-tab-metadata-content');
|
||||||
if (result.metadata) {
|
if (result.metadata) {
|
||||||
metadataContent.textContent = JSON.stringify(result.metadata, null, 2);
|
metaContent.textContent = JSON.stringify(result.metadata, null, 2);
|
||||||
metadataDiv.style.display = 'block';
|
metaDiv.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
showNotification('Profile picture changed successfully!');
|
pfpRefreshPreviews();
|
||||||
|
|
||||||
|
// If manual mode, show crop interface
|
||||||
|
if (mode === 'manual') {
|
||||||
|
pfpShowCropInterface();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Unknown error');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to change profile picture:', error);
|
console.error('PFP Danbooru error:', error);
|
||||||
statusDiv.textContent = `❌ Error: ${error.message}`;
|
pfpSetStatus(`❌ Error: ${error.message}`, 'red');
|
||||||
statusDiv.style.color = 'red';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadCustomPfp() {
|
// Keep old function names working (backwards compatibility for autonomous/API callers)
|
||||||
const fileInput = document.getElementById('pfp-upload');
|
async function changeProfilePicture() { await pfpChangeDanbooru(); }
|
||||||
|
|
||||||
|
// --- Custom Upload ---
|
||||||
|
async function pfpUploadCustom() {
|
||||||
|
const fileInput = document.getElementById('pfp-tab-upload');
|
||||||
|
const mode = getPfpCropMode();
|
||||||
const selectedServer = document.getElementById('server-select').value;
|
const selectedServer = document.getElementById('server-select').value;
|
||||||
const statusDiv = document.getElementById('pfp-status');
|
|
||||||
const metadataDiv = document.getElementById('pfp-metadata');
|
|
||||||
const metadataContent = document.getElementById('pfp-metadata-content');
|
|
||||||
|
|
||||||
if (!fileInput.files || fileInput.files.length === 0) {
|
if (!fileInput.files || fileInput.files.length === 0) {
|
||||||
showNotification('Please select an image file first', 'error');
|
showNotification('Please select an image file first', 'error');
|
||||||
@@ -3386,88 +3576,249 @@ async function uploadCustomPfp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const file = fileInput.files[0];
|
const file = fileInput.files[0];
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
showNotification('Please select a valid image file', 'error');
|
showNotification('Please select a valid image file', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
statusDiv.textContent = '⏳ Uploading and processing custom image...';
|
pfpSetStatus('⏳ Uploading and processing...');
|
||||||
statusDiv.style.color = '#61dafb';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
// Add guild_id parameter if a specific server is selected
|
const endpoint = mode === 'manual' ? '/profile-picture/change-no-crop' : '/profile-picture/change';
|
||||||
let endpoint = '/profile-picture/change';
|
let url = endpoint;
|
||||||
if (selectedServer !== 'all') {
|
if (selectedServer !== 'all') url += `?guild_id=${selectedServer}`;
|
||||||
endpoint += `?guild_id=${selectedServer}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const response = await fetch(url, { method: 'POST', body: formData });
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.status === 'ok') {
|
if (response.ok && result.status === 'ok') {
|
||||||
statusDiv.textContent = `✅ ${result.message}`;
|
pfpSetStatus(`✅ ${result.message}`, 'green');
|
||||||
statusDiv.style.color = 'green';
|
showNotification('Image uploaded successfully!');
|
||||||
|
|
||||||
// Display metadata if available
|
|
||||||
if (result.metadata) {
|
|
||||||
metadataContent.textContent = JSON.stringify(result.metadata, null, 2);
|
|
||||||
metadataDiv.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear file input
|
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
|
|
||||||
showNotification('Custom profile picture applied successfully!');
|
if (result.metadata) {
|
||||||
|
const metaDiv = document.getElementById('pfp-tab-metadata');
|
||||||
|
const metaContent = document.getElementById('pfp-tab-metadata-content');
|
||||||
|
metaContent.textContent = JSON.stringify(result.metadata, null, 2);
|
||||||
|
metaDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
pfpRefreshPreviews();
|
||||||
|
|
||||||
|
if (mode === 'manual') {
|
||||||
|
pfpShowCropInterface();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.message || 'Failed to apply custom profile picture');
|
throw new Error(result.message || 'Upload failed');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload custom profile picture:', error);
|
console.error('PFP upload error:', error);
|
||||||
statusDiv.textContent = `❌ Error: ${error.message}`;
|
pfpSetStatus(`❌ Error: ${error.message}`, 'red');
|
||||||
statusDiv.style.color = 'red';
|
showNotification(error.message, 'error');
|
||||||
showNotification(error.message || 'Failed to upload custom profile picture', 'error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreFallbackPfp() {
|
// Keep old function name working
|
||||||
const statusDiv = document.getElementById('pfp-status');
|
async function uploadCustomPfp() { await pfpUploadCustom(); }
|
||||||
const metadataDiv = document.getElementById('pfp-metadata');
|
|
||||||
|
|
||||||
if (!confirm('Are you sure you want to restore the original fallback avatar?')) {
|
// --- Restore Fallback ---
|
||||||
return;
|
async function pfpRestoreFallback() {
|
||||||
}
|
if (!confirm('Are you sure you want to restore the original fallback avatar?')) return;
|
||||||
|
|
||||||
statusDiv.textContent = '⏳ Restoring original avatar...';
|
pfpSetStatus('⏳ Restoring original avatar...');
|
||||||
statusDiv.style.color = '#61dafb';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiCall('/profile-picture/restore-fallback', 'POST');
|
const result = await apiCall('/profile-picture/restore-fallback', 'POST');
|
||||||
|
pfpSetStatus(`✅ ${result.message}`, 'green');
|
||||||
statusDiv.textContent = `✅ ${result.message}`;
|
document.getElementById('pfp-tab-metadata').style.display = 'none';
|
||||||
statusDiv.style.color = 'green';
|
pfpRefreshPreviews();
|
||||||
metadataDiv.style.display = 'none';
|
showNotification('Original avatar restored!');
|
||||||
|
|
||||||
showNotification('Original avatar restored successfully!');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to restore fallback avatar:', error);
|
console.error('Restore fallback error:', error);
|
||||||
statusDiv.textContent = `❌ Error: ${error.message}`;
|
pfpSetStatus(`❌ Error: ${error.message}`, 'red');
|
||||||
statusDiv.style.color = 'red';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role Color Management
|
async function restoreFallbackPfp() { await pfpRestoreFallback(); }
|
||||||
|
|
||||||
|
// --- Crop Interface ---
|
||||||
|
function pfpShowCropInterface() {
|
||||||
|
const section = document.getElementById('pfp-crop-section');
|
||||||
|
const img = document.getElementById('pfp-crop-image');
|
||||||
|
|
||||||
|
// Destroy previous cropper if any
|
||||||
|
if (pfpCropper) {
|
||||||
|
pfpCropper.destroy();
|
||||||
|
pfpCropper = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load original image
|
||||||
|
img.src = `/profile-picture/image/original?t=${Date.now()}`;
|
||||||
|
section.style.display = 'block';
|
||||||
|
|
||||||
|
img.onload = function() {
|
||||||
|
pfpCropper = new Cropper(img, {
|
||||||
|
aspectRatio: 1,
|
||||||
|
viewMode: 2,
|
||||||
|
minCropBoxWidth: 64,
|
||||||
|
minCropBoxHeight: 64,
|
||||||
|
responsive: true,
|
||||||
|
autoCropArea: 0.8,
|
||||||
|
background: true,
|
||||||
|
guides: true,
|
||||||
|
center: true,
|
||||||
|
highlight: true,
|
||||||
|
cropBoxMovable: true,
|
||||||
|
cropBoxResizable: true,
|
||||||
|
toggleDragModeOnDblclick: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pfpHideCropInterface() {
|
||||||
|
if (pfpCropper) {
|
||||||
|
pfpCropper.destroy();
|
||||||
|
pfpCropper = null;
|
||||||
|
}
|
||||||
|
document.getElementById('pfp-crop-section').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-crop: open crop interface on stored original
|
||||||
|
function pfpRecrop() {
|
||||||
|
pfpShowCropInterface();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pfpApplyManualCrop() {
|
||||||
|
if (!pfpCropper) {
|
||||||
|
showNotification('No crop region selected', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = pfpCropper.getData(true); // rounded integers
|
||||||
|
pfpSetStatus('⏳ Applying manual crop...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/profile-picture/manual-crop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
x: Math.round(data.x),
|
||||||
|
y: Math.round(data.y),
|
||||||
|
width: Math.round(data.width),
|
||||||
|
height: Math.round(data.height)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.status === 'ok') {
|
||||||
|
pfpSetStatus(`✅ ${result.message}`, 'green');
|
||||||
|
showNotification('Manual crop applied!');
|
||||||
|
pfpHideCropInterface();
|
||||||
|
pfpRefreshPreviews();
|
||||||
|
|
||||||
|
// Refresh metadata
|
||||||
|
if (result.metadata) {
|
||||||
|
const metaContent = document.getElementById('pfp-tab-metadata-content');
|
||||||
|
const existing = metaContent.textContent ? JSON.parse(metaContent.textContent) : {};
|
||||||
|
Object.assign(existing, result.metadata);
|
||||||
|
metaContent.textContent = JSON.stringify(existing, null, 2);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Crop failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Manual crop error:', error);
|
||||||
|
pfpSetStatus(`❌ Error: ${error.message}`, 'red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pfpApplyAutoCrop() {
|
||||||
|
pfpSetStatus('⏳ Running auto-crop (face detection)...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/profile-picture/auto-crop', 'POST');
|
||||||
|
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
pfpSetStatus(`✅ ${result.message}`, 'green');
|
||||||
|
showNotification('Auto-crop applied!');
|
||||||
|
pfpHideCropInterface();
|
||||||
|
pfpRefreshPreviews();
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Auto-crop failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-crop error:', error);
|
||||||
|
pfpSetStatus(`❌ Error: ${error.message}`, 'red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Description ---
|
||||||
|
async function pfpSaveDescription() {
|
||||||
|
const descEl = document.getElementById('pfp-description-editor');
|
||||||
|
const statusEl = document.getElementById('pfp-desc-status');
|
||||||
|
const description = descEl.value.trim();
|
||||||
|
|
||||||
|
if (!description) {
|
||||||
|
statusEl.textContent = '⚠️ Description cannot be empty';
|
||||||
|
statusEl.style.color = 'orange';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = '⏳ Saving description...';
|
||||||
|
statusEl.style.color = '#61dafb';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/profile-picture/description', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ description })
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.status === 'ok') {
|
||||||
|
statusEl.textContent = '✅ Description saved & injected into Cat memory';
|
||||||
|
statusEl.style.color = 'green';
|
||||||
|
showNotification('Description saved!');
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Save failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save description error:', error);
|
||||||
|
statusEl.textContent = `❌ Error: ${error.message}`;
|
||||||
|
statusEl.style.color = 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pfpRegenerateDescription() {
|
||||||
|
const statusEl = document.getElementById('pfp-desc-status');
|
||||||
|
statusEl.textContent = '⏳ Regenerating description via vision model...';
|
||||||
|
statusEl.style.color = '#61dafb';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/profile-picture/regenerate-description', 'POST');
|
||||||
|
|
||||||
|
if (result.status === 'ok' && result.description) {
|
||||||
|
document.getElementById('pfp-description-editor').value = result.description;
|
||||||
|
statusEl.textContent = '✅ Description regenerated & saved';
|
||||||
|
statusEl.style.color = 'green';
|
||||||
|
showNotification('Description regenerated!');
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Regeneration failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Regenerate description error:', error);
|
||||||
|
statusEl.textContent = `❌ Error: ${error.message}`;
|
||||||
|
statusEl.style.color = 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Role Color (updated element IDs for tab11) ---
|
||||||
async function setCustomRoleColor() {
|
async function setCustomRoleColor() {
|
||||||
const statusDiv = document.getElementById('role-color-status');
|
const statusDiv = document.getElementById('pfp-tab-role-color-status');
|
||||||
const hexInput = document.getElementById('role-color-hex');
|
const hexInput = document.getElementById('pfp-tab-role-color-hex');
|
||||||
const hexColor = hexInput.value.trim();
|
const hexColor = hexInput.value.trim();
|
||||||
|
|
||||||
if (!hexColor) {
|
if (!hexColor) {
|
||||||
@@ -3506,7 +3857,7 @@ async function setCustomRoleColor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resetRoleColor() {
|
async function resetRoleColor() {
|
||||||
const statusDiv = document.getElementById('role-color-status');
|
const statusDiv = document.getElementById('pfp-tab-role-color-status');
|
||||||
|
|
||||||
statusDiv.textContent = '⏳ Resetting to fallback color...';
|
statusDiv.textContent = '⏳ Resetting to fallback color...';
|
||||||
statusDiv.style.color = '#61dafb';
|
statusDiv.style.color = '#61dafb';
|
||||||
@@ -3517,8 +3868,7 @@ async function resetRoleColor() {
|
|||||||
statusDiv.textContent = `✅ ${result.message}`;
|
statusDiv.textContent = `✅ ${result.message}`;
|
||||||
statusDiv.style.color = 'green';
|
statusDiv.style.color = 'green';
|
||||||
|
|
||||||
// Update the input to show fallback color
|
document.getElementById('pfp-tab-role-color-hex').value = '#86cecb';
|
||||||
document.getElementById('role-color-hex').value = '#86cecb';
|
|
||||||
|
|
||||||
showNotification('Role color reset to fallback #86cecb');
|
showNotification('Role color reset to fallback #86cecb');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ProfilePictureManager:
|
|||||||
PROFILE_PIC_DIR = "memory/profile_pictures"
|
PROFILE_PIC_DIR = "memory/profile_pictures"
|
||||||
FALLBACK_PATH = "memory/profile_pictures/fallback.png"
|
FALLBACK_PATH = "memory/profile_pictures/fallback.png"
|
||||||
CURRENT_PATH = "memory/profile_pictures/current.png"
|
CURRENT_PATH = "memory/profile_pictures/current.png"
|
||||||
|
ORIGINAL_PATH = "memory/profile_pictures/original.png"
|
||||||
METADATA_PATH = "memory/profile_pictures/metadata.json"
|
METADATA_PATH = "memory/profile_pictures/metadata.json"
|
||||||
|
|
||||||
# Face detection API endpoint
|
# Face detection API endpoint
|
||||||
@@ -244,7 +245,8 @@ class ProfilePictureManager:
|
|||||||
mood: Optional[str] = None,
|
mood: Optional[str] = None,
|
||||||
custom_image_bytes: Optional[bytes] = None,
|
custom_image_bytes: Optional[bytes] = None,
|
||||||
debug: bool = False,
|
debug: bool = False,
|
||||||
max_retries: int = 5
|
max_retries: int = 5,
|
||||||
|
skip_crop: bool = False
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Main function to change Miku's profile picture.
|
Main function to change Miku's profile picture.
|
||||||
@@ -254,6 +256,7 @@ class ProfilePictureManager:
|
|||||||
custom_image_bytes: If provided, use this image instead of Danbooru
|
custom_image_bytes: If provided, use this image instead of Danbooru
|
||||||
debug: Enable debug output
|
debug: Enable debug output
|
||||||
max_retries: Maximum number of attempts to find a valid Miku image (for Danbooru)
|
max_retries: Maximum number of attempts to find a valid Miku image (for Danbooru)
|
||||||
|
skip_crop: If True, save original but skip cropping/description (for manual crop workflow)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with status and metadata
|
Dict with status and metadata
|
||||||
@@ -467,6 +470,24 @@ class ProfilePictureManager:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
# === NORMAL STATIC IMAGE PATH ===
|
# === NORMAL STATIC IMAGE PATH ===
|
||||||
|
# Save full-resolution original for manual cropping later
|
||||||
|
with open(self.ORIGINAL_PATH, 'wb') as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
if debug:
|
||||||
|
logger.info(f"Saved full-resolution original ({len(image_bytes)} bytes, {image.size[0]}x{image.size[1]})")
|
||||||
|
result["metadata"]["original_width"] = image.size[0]
|
||||||
|
result["metadata"]["original_height"] = image.size[1]
|
||||||
|
|
||||||
|
# If skip_crop requested (manual crop workflow), return early with original saved
|
||||||
|
if skip_crop:
|
||||||
|
result["success"] = True
|
||||||
|
result["metadata"]["changed_at"] = datetime.now().isoformat()
|
||||||
|
result["metadata"]["skip_crop"] = True
|
||||||
|
self._save_metadata(result["metadata"])
|
||||||
|
if debug:
|
||||||
|
logger.info("Skipping auto-crop (manual crop workflow) - original saved")
|
||||||
|
return result
|
||||||
|
|
||||||
# Step 2: Generate description of the validated image
|
# Step 2: Generate description of the validated image
|
||||||
if debug:
|
if debug:
|
||||||
logger.info("Generating image description...")
|
logger.info("Generating image description...")
|
||||||
@@ -1426,6 +1447,312 @@ Respond in JSON format:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def manual_crop(self, x: int, y: int, width: int, height: int, target_size: int = 512, debug: bool = False) -> Dict:
|
||||||
|
"""
|
||||||
|
Manually crop the stored original image and apply it as the Discord avatar.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Left edge of crop region (pixels)
|
||||||
|
y: Top edge of crop region (pixels)
|
||||||
|
width: Width of crop region (pixels)
|
||||||
|
height: Height of crop region (pixels)
|
||||||
|
target_size: Final resize target (default 512)
|
||||||
|
debug: Enable debug output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status and metadata
|
||||||
|
"""
|
||||||
|
result = {"success": False, "error": None, "metadata": {}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not os.path.exists(self.ORIGINAL_PATH):
|
||||||
|
result["error"] = "No original image found. Upload or fetch an image first."
|
||||||
|
return result
|
||||||
|
|
||||||
|
image = Image.open(self.ORIGINAL_PATH)
|
||||||
|
img_width, img_height = image.size
|
||||||
|
|
||||||
|
# Validate crop region
|
||||||
|
if x < 0 or y < 0:
|
||||||
|
result["error"] = f"Crop coordinates must be non-negative (got x={x}, y={y})"
|
||||||
|
return result
|
||||||
|
if x + width > img_width or y + height > img_height:
|
||||||
|
result["error"] = f"Crop region ({x},{y},{width},{height}) exceeds image bounds ({img_width}x{img_height})"
|
||||||
|
return result
|
||||||
|
if width < 64 or height < 64:
|
||||||
|
result["error"] = f"Crop region too small (minimum 64x64, got {width}x{height})"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Perform crop
|
||||||
|
cropped = image.crop((x, y, x + width, y + height))
|
||||||
|
|
||||||
|
# Resize to target
|
||||||
|
cropped = cropped.resize((target_size, target_size), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
logger.info(f"Manual crop: ({x},{y},{width},{height}) -> {target_size}x{target_size}")
|
||||||
|
|
||||||
|
# Save cropped image
|
||||||
|
output_buffer = io.BytesIO()
|
||||||
|
cropped.save(output_buffer, format='PNG')
|
||||||
|
cropped_bytes = output_buffer.getvalue()
|
||||||
|
|
||||||
|
with open(self.CURRENT_PATH, 'wb') as f:
|
||||||
|
f.write(cropped_bytes)
|
||||||
|
|
||||||
|
# Extract dominant color
|
||||||
|
dominant_color = self._extract_dominant_color(cropped, debug=debug)
|
||||||
|
if dominant_color:
|
||||||
|
result["metadata"]["dominant_color"] = {
|
||||||
|
"rgb": dominant_color,
|
||||||
|
"hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update Discord avatar
|
||||||
|
if globals.client and globals.client.user:
|
||||||
|
try:
|
||||||
|
if globals.client.loop and globals.client.loop.is_running():
|
||||||
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
globals.client.user.edit(avatar=cropped_bytes),
|
||||||
|
globals.client.loop
|
||||||
|
)
|
||||||
|
future.result(timeout=10)
|
||||||
|
else:
|
||||||
|
await globals.client.user.edit(avatar=cropped_bytes)
|
||||||
|
|
||||||
|
result["success"] = True
|
||||||
|
result["metadata"]["changed_at"] = datetime.now().isoformat()
|
||||||
|
result["metadata"]["crop_region"] = {"x": x, "y": y, "width": width, "height": height}
|
||||||
|
|
||||||
|
# Update existing metadata
|
||||||
|
existing_meta = self.load_metadata() or {}
|
||||||
|
existing_meta.update(result["metadata"])
|
||||||
|
existing_meta.pop("skip_crop", None)
|
||||||
|
self._save_metadata(existing_meta)
|
||||||
|
|
||||||
|
logger.info("Manual crop applied and Discord avatar updated")
|
||||||
|
|
||||||
|
# Update role colors
|
||||||
|
if dominant_color:
|
||||||
|
await self._update_role_colors(dominant_color, debug=debug)
|
||||||
|
|
||||||
|
# Update bipolar webhook avatars
|
||||||
|
if globals.BIPOLAR_MODE:
|
||||||
|
try:
|
||||||
|
from utils.bipolar_mode import update_webhook_avatars
|
||||||
|
await update_webhook_avatars(globals.client)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to update bipolar webhook avatars: {e}")
|
||||||
|
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
result["error"] = f"Discord API error: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Unexpected error: {e}"
|
||||||
|
else:
|
||||||
|
result["error"] = "Bot client not ready"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Error in manual crop: {e}"
|
||||||
|
logger.error(f"Error in manual_crop: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def auto_crop_only(self, debug: bool = False) -> Dict:
|
||||||
|
"""
|
||||||
|
Run intelligent auto-crop on the stored original image and apply as Discord avatar.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status and metadata
|
||||||
|
"""
|
||||||
|
result = {"success": False, "error": None, "metadata": {}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not os.path.exists(self.ORIGINAL_PATH):
|
||||||
|
result["error"] = "No original image found. Upload or fetch an image first."
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Load original
|
||||||
|
with open(self.ORIGINAL_PATH, 'rb') as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
image = Image.open(io.BytesIO(image_bytes))
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
logger.info(f"Auto-cropping original image ({image.size[0]}x{image.size[1]})")
|
||||||
|
|
||||||
|
# Run intelligent crop
|
||||||
|
target_size = 512
|
||||||
|
width, height = image.size
|
||||||
|
if width <= target_size and height <= target_size:
|
||||||
|
if debug:
|
||||||
|
logger.info(f"Image already at/below target size, skipping crop")
|
||||||
|
cropped_image = image
|
||||||
|
else:
|
||||||
|
cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug)
|
||||||
|
|
||||||
|
if not cropped_image:
|
||||||
|
result["error"] = "Intelligent crop failed"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Save cropped
|
||||||
|
output_buffer = io.BytesIO()
|
||||||
|
cropped_image.save(output_buffer, format='PNG')
|
||||||
|
cropped_bytes = output_buffer.getvalue()
|
||||||
|
|
||||||
|
with open(self.CURRENT_PATH, 'wb') as f:
|
||||||
|
f.write(cropped_bytes)
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
logger.info(f"Saved auto-cropped image ({len(cropped_bytes)} bytes)")
|
||||||
|
|
||||||
|
# Extract dominant color
|
||||||
|
dominant_color = self._extract_dominant_color(cropped_image, debug=debug)
|
||||||
|
if dominant_color:
|
||||||
|
result["metadata"]["dominant_color"] = {
|
||||||
|
"rgb": dominant_color,
|
||||||
|
"hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update Discord avatar
|
||||||
|
if globals.client and globals.client.user:
|
||||||
|
try:
|
||||||
|
if globals.client.loop and globals.client.loop.is_running():
|
||||||
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
globals.client.user.edit(avatar=cropped_bytes),
|
||||||
|
globals.client.loop
|
||||||
|
)
|
||||||
|
future.result(timeout=10)
|
||||||
|
else:
|
||||||
|
await globals.client.user.edit(avatar=cropped_bytes)
|
||||||
|
|
||||||
|
result["success"] = True
|
||||||
|
result["metadata"]["changed_at"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# Update existing metadata
|
||||||
|
existing_meta = self.load_metadata() or {}
|
||||||
|
existing_meta.update(result["metadata"])
|
||||||
|
existing_meta.pop("skip_crop", None)
|
||||||
|
existing_meta.pop("crop_region", None)
|
||||||
|
self._save_metadata(existing_meta)
|
||||||
|
|
||||||
|
logger.info("Auto-crop applied and Discord avatar updated")
|
||||||
|
|
||||||
|
if dominant_color:
|
||||||
|
await self._update_role_colors(dominant_color, debug=debug)
|
||||||
|
|
||||||
|
if globals.BIPOLAR_MODE:
|
||||||
|
try:
|
||||||
|
from utils.bipolar_mode import update_webhook_avatars
|
||||||
|
await update_webhook_avatars(globals.client)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to update bipolar webhook avatars: {e}")
|
||||||
|
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
result["error"] = f"Discord API error: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Unexpected error: {e}"
|
||||||
|
else:
|
||||||
|
result["error"] = "Bot client not ready"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Error in auto_crop_only: {e}"
|
||||||
|
logger.error(f"Error in auto_crop_only: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def update_description(self, description: str, reinject_cat: bool = True, debug: bool = False) -> Dict:
|
||||||
|
"""
|
||||||
|
Update the profile picture description and optionally re-inject into Cheshire Cat memory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: New description text
|
||||||
|
reinject_cat: Whether to store as a declarative fact in Cheshire Cat
|
||||||
|
debug: Enable debug output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status
|
||||||
|
"""
|
||||||
|
result = {"success": False, "error": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save to description file
|
||||||
|
description_path = os.path.join(self.PROFILE_PIC_DIR, "current_description.txt")
|
||||||
|
with open(description_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(description)
|
||||||
|
|
||||||
|
# Update metadata
|
||||||
|
metadata = self.load_metadata() or {}
|
||||||
|
metadata["description"] = description
|
||||||
|
self._save_metadata(metadata)
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
logger.info(f"Updated description ({len(description)} chars)")
|
||||||
|
|
||||||
|
# Re-inject into Cheshire Cat as declarative memory
|
||||||
|
if reinject_cat and globals.USE_CHESHIRE_CAT:
|
||||||
|
try:
|
||||||
|
from utils.cat_client import cat_adapter
|
||||||
|
fact_content = f"Miku's current profile picture shows: {description}"
|
||||||
|
await cat_adapter.create_memory_point(
|
||||||
|
collection="declarative",
|
||||||
|
content=fact_content,
|
||||||
|
user_id="profile_picture_manager",
|
||||||
|
source="profile_picture_description",
|
||||||
|
metadata={"type": "profile_picture", "updated_at": datetime.now().isoformat()}
|
||||||
|
)
|
||||||
|
if debug:
|
||||||
|
logger.info("Re-injected description into Cheshire Cat declarative memory")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to re-inject description into Cat: {e}")
|
||||||
|
# Don't fail the whole operation
|
||||||
|
|
||||||
|
result["success"] = True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Error updating description: {e}"
|
||||||
|
logger.error(f"Error in update_description: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def regenerate_description(self, debug: bool = False) -> Dict:
|
||||||
|
"""
|
||||||
|
Re-generate the description from the current original image using the vision model.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status and new description
|
||||||
|
"""
|
||||||
|
result = {"success": False, "error": None, "description": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try original first, fall back to current
|
||||||
|
image_path = self.ORIGINAL_PATH if os.path.exists(self.ORIGINAL_PATH) else self.CURRENT_PATH
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
result["error"] = "No image found to describe"
|
||||||
|
return result
|
||||||
|
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
logger.info("Regenerating image description via vision model...")
|
||||||
|
|
||||||
|
description = await self._generate_image_description(image_bytes, debug=debug)
|
||||||
|
|
||||||
|
if description:
|
||||||
|
# Save it
|
||||||
|
update_result = await self.update_description(description, reinject_cat=True, debug=debug)
|
||||||
|
result["success"] = update_result["success"]
|
||||||
|
result["description"] = description
|
||||||
|
if update_result.get("error"):
|
||||||
|
result["error"] = update_result["error"]
|
||||||
|
else:
|
||||||
|
result["error"] = "Vision model returned no description"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Error regenerating description: {e}"
|
||||||
|
logger.error(f"Error in regenerate_description: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
profile_picture_manager = ProfilePictureManager()
|
profile_picture_manager = ProfilePictureManager()
|
||||||
|
|||||||
Reference in New Issue
Block a user