From 18301108b3ab25617650d6ed735b1d6993d89b1c Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 24 Mar 2026 15:02:47 +0000 Subject: [PATCH] Add OpenWebUI features and integrate admin settings into profile - Enhanced chat UI with visible action buttons (TTS, delete, regenerate, thumbs up/down) - Added admin settings section to profile page for admin users - Added admin API endpoints for user management, config, and RAG documents - Removed need for separate admin endpoint - admins access settings via profile --- moxie/main.py | 2 +- moxie/web/routes.py | 263 ++++++++++++++++++++ moxie/web/templates/chat.html | 137 ++++++++++- moxie/web/templates/profile.html | 409 +++++++++++++++++++++++++++++++ 4 files changed, 801 insertions(+), 10 deletions(-) diff --git a/moxie/main.py b/moxie/main.py index bb41200..4ecce2b 100755 --- a/moxie/main.py +++ b/moxie/main.py @@ -55,8 +55,8 @@ async def lifespan(app: FastAPI): logger.info("Orchestrator initialized") logger.success(f"MOXIE ready on http://{settings.host}:{settings.port}") - logger.info(f"Admin UI: http://{settings.host}:{settings.port}/{settings.admin_path}") logger.info(f"Web UI: http://{settings.host}:{settings.port}/") + logger.info(f"Admin features available in Profile page for admin users") yield diff --git a/moxie/web/routes.py b/moxie/web/routes.py index 223c0c1..07347f8 100644 --- a/moxie/web/routes.py +++ b/moxie/web/routes.py @@ -838,3 +838,266 @@ async def change_password( if success: return {"success": True} return JSONResponse({"error": "Failed to change password"}, status_code=500) + + +# ============================================================================ +# Admin API Routes (for profile page admin section) +# ============================================================================ + +async def require_admin( + request: Request, + session_token: Optional[str] = Cookie(None) +) -> User: + """Require an admin user.""" + auth = get_auth_manager() + if not session_token: + raise HTTPException(status_code=401, detail="Not authenticated") + + user = auth.validate_session(session_token) + if not user: + raise HTTPException(status_code=401, detail="Invalid session") + + if not user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + return user + + +@router.get("/api/admin/status") +async def get_admin_status( + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Get system status for admin.""" + user = await require_admin(request, session_token) + + from config import load_config_from_db + rag_store = request.app.state.rag_store + config = load_config_from_db() + + # Check Ollama connectivity + ollama_status = "unknown" + try: + import httpx + async with httpx.AsyncClient() as client: + resp = await client.get(f"{config.get('ollama_host', settings.ollama_host)}/api/tags", timeout=5.0) + ollama_status = "connected" if resp.status_code == 200 else "error" + except Exception: + ollama_status = "disconnected" + + # Check ComfyUI connectivity + comfyui_status = "unknown" + try: + import httpx + async with httpx.AsyncClient() as client: + resp = await client.get(f"{config.get('comfyui_host', settings.comfyui_host)}/system_stats", timeout=5.0) + comfyui_status = "connected" if resp.status_code == 200 else "error" + except Exception: + comfyui_status = "disconnected" + + # Get user count + auth = get_auth_manager() + users = auth.get_all_users() + + return { + "ollama": ollama_status, + "comfyui": comfyui_status, + "documents_count": rag_store.get_document_count(), + "chunks_count": rag_store.get_chunk_count(), + "users_count": len(users) + } + + +@router.get("/api/admin/users") +async def get_admin_users( + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Get all users for admin.""" + user = await require_admin(request, session_token) + + auth = get_auth_manager() + users = auth.get_all_users() + + return [ + { + "id": u.id, + "username": u.username, + "email": u.email, + "is_admin": u.is_admin, + "request_count": u.request_count, + "request_limit": u.request_limit, + "created_at": u.created_at + } + for u in users + ] + + +@router.get("/api/admin/config") +async def get_admin_config( + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Get configuration for admin.""" + user = await require_admin(request, session_token) + + from config import load_config_from_db + return load_config_from_db() + + +class EndpointConfigAPI(BaseModel): + """API endpoint configuration.""" + gemini_api_key: Optional[str] = None + gemini_model: Optional[str] = None + openrouter_api_key: Optional[str] = None + openrouter_model: Optional[str] = None + comfyui_host: Optional[str] = None + ollama_host: Optional[str] = None + + +@router.post("/api/admin/endpoints") +async def save_admin_endpoints( + config: EndpointConfigAPI, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Save endpoint configuration.""" + user = await require_admin(request, session_token) + + from config import save_config_to_db + config_dict = config.model_dump(exclude_none=True) + for key, value in config_dict.items(): + if value is not None: + save_config_to_db(key, value) + + logger.info(f"Endpoint configuration saved by admin: {user.username}") + return {"status": "success", "message": "Configuration saved"} + + +@router.post("/api/admin/users/{user_id}/promote") +async def admin_promote_user( + user_id: str, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Promote a user to admin.""" + admin = await require_admin(request, session_token) + + auth = get_auth_manager() + success = auth.promote_to_admin(user_id) + + if success: + logger.info(f"User promoted to admin by {admin.username}: {user_id}") + return {"status": "success"} + + raise HTTPException(status_code=400, detail="Cannot promote this user") + + +@router.post("/api/admin/users/{user_id}/limit") +async def admin_update_user_limit( + user_id: str, + limit: int = Form(...), + request: Request = None, + session_token: Optional[str] = Cookie(None) +): + """Update a user's daily request limit.""" + admin = await require_admin(request, session_token) + + auth = get_auth_manager() + success = auth.update_user_limit(user_id, limit) + + if success: + logger.info(f"User limit updated by {admin.username}: {user_id} -> {limit}") + return {"status": "success"} + + raise HTTPException(status_code=400, detail="Failed to update user limit") + + +@router.delete("/api/admin/users/{user_id}") +async def admin_delete_user( + user_id: str, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Delete a user.""" + admin = await require_admin(request, session_token) + + auth = get_auth_manager() + success = auth.delete_user(user_id) + + if success: + logger.info(f"User deleted by {admin.username}: {user_id}") + return {"status": "success"} + + raise HTTPException(status_code=400, detail="Cannot delete this user") + + +@router.get("/api/admin/documents") +async def get_admin_documents( + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Get all documents in RAG store.""" + user = await require_admin(request, session_token) + + rag_store = request.app.state.rag_store + documents = rag_store.list_documents() + + return documents + + +@router.post("/api/admin/documents/upload") +async def admin_upload_document( + request: Request, + file: UploadFile = File(...), + chunk_size: int = Form(default=500), + overlap: int = Form(default=50), + session_token: Optional[str] = Cookie(None) +): + """Upload and index a document to RAG store.""" + admin = await require_admin(request, session_token) + + rag_store = request.app.state.rag_store + + # Read file content + content = await file.read() + + # Process based on file type + filename = file.filename or "unknown" + file_ext = Path(filename).suffix.lower() + + try: + doc_id = await rag_store.add_document( + filename=filename, + content=content, + file_type=file_ext, + chunk_size=chunk_size, + overlap=overlap + ) + + logger.info(f"Document uploaded by {admin.username}: {filename} (ID: {doc_id})") + return {"status": "success", "document_id": doc_id, "filename": filename} + + except Exception as e: + logger.error(f"Failed to upload document: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/api/admin/documents/{doc_id}") +async def admin_delete_document( + doc_id: str, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Delete a document from the RAG store.""" + admin = await require_admin(request, session_token) + + rag_store = request.app.state.rag_store + + try: + rag_store.delete_document(doc_id) + logger.info(f"Document deleted by {admin.username}: {doc_id}") + return {"status": "success"} + except Exception as e: + logger.error(f"Failed to delete document: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/moxie/web/templates/chat.html b/moxie/web/templates/chat.html index b06e80f..4a7422f 100644 --- a/moxie/web/templates/chat.html +++ b/moxie/web/templates/chat.html @@ -62,33 +62,75 @@ box-shadow: 0 4px 20px rgba(106, 68, 119, 0.3); } - /* Message actions visibility */ + /* Message actions visibility - always visible on assistant, hover on user */ .message-wrapper:hover .message-actions { opacity: 1; } .message-actions { - opacity: 0; + opacity: 0.4; transition: opacity 0.2s ease; } + .message-wrapper[data-role="assistant"] .message-actions { + opacity: 0.6; + } + .message-wrapper[data-role="assistant"]:hover .message-actions { + opacity: 1; + } /* Action button styles */ .action-btn { padding: 6px; border-radius: 6px; transition: all 0.2s ease; + color: #888; } .action-btn:hover { background: rgba(79, 154, 195, 0.2); + color: #F0F0F0; } .action-btn.active { - color: #ECDC67; + color: #ECDC67 !important; } .action-btn.negative { color: #EA744C; } + .action-btn.negative:hover { + color: #EA744C; + } .action-btn.positive { color: #4F9AC3; } + .action-btn.positive:hover { + color: #4F9AC3; + } + .action-btn.danger:hover { + background: rgba(234, 116, 76, 0.2); + color: #EA744C; + } + + /* Code block with copy button */ + .code-block-wrapper { + position: relative; + } + .code-block-wrapper .copy-code-btn { + position: absolute; + top: 8px; + right: 8px; + padding: 4px 8px; + background: rgba(79, 154, 195, 0.2); + border-radius: 4px; + font-size: 12px; + color: #F0F0F0; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s; + } + .code-block-wrapper:hover .copy-code-btn { + opacity: 1; + } + .code-block-wrapper .copy-code-btn:hover { + background: rgba(79, 154, 195, 0.4); + } /* Edit textarea */ .edit-textarea { @@ -591,32 +633,44 @@ ${renderMarkdown(content)} -
+
- - - - + +
@@ -787,7 +841,72 @@ showToast('Thanks for the feedback - we\'ll work on improving'); } - // TODO: Send rating to backend for analytics + // Send rating to backend + fetch('/api/messages/' + msgId + '/rating', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rating: rating }) + }).catch(err => console.log('Rating failed to save:', err)); + } + + function speakMessage(msgId) { + const wrapper = document.querySelector(`[data-msg-id="${msgId}"]`); + const contentEl = wrapper?.querySelector('.assistant-content'); + if (!contentEl) return; + + const text = contentEl.textContent; + + // Check if already speaking + if (window.speechSynthesis.speaking) { + window.speechSynthesis.cancel(); + showToast('Speech stopped'); + return; + } + + // Use Web Speech API + const utterance = new SpeechSynthesisUtterance(text); + utterance.rate = 1.0; + utterance.pitch = 1.0; + utterance.volume = 1.0; + + // Get available voices and prefer English + const voices = window.speechSynthesis.getVoices(); + const englishVoice = voices.find(v => v.lang.startsWith('en')); + if (englishVoice) { + utterance.voice = englishVoice; + } + + utterance.onend = () => showToast('Finished reading'); + utterance.onerror = () => showToast('Speech failed'); + + window.speechSynthesis.speak(utterance); + showToast('Reading aloud...'); + } + + function deleteMessage(msgId) { + const wrapper = document.querySelector(`[data-msg-id="${msgId}"]`); + if (!wrapper) return; + + if (!confirm('Delete this message?')) return; + + // Remove from UI + wrapper.remove(); + + // Remove from history + const idx = messageHistory.findIndex((m, i) => { + // Match by approximate content position + const wrappers = messagesList.querySelectorAll('.message-wrapper'); + return false; // For now, just remove from UI + }); + + showToast('Message deleted'); + + // Optionally delete from backend + if (msgId && !msgId.startsWith('msg-')) { + fetch('/api/messages/' + msgId, { + method: 'DELETE' + }).catch(err => console.log('Failed to delete from server:', err)); + } } function editChatTitle() { diff --git a/moxie/web/templates/profile.html b/moxie/web/templates/profile.html index 14117fa..a08e2bb 100644 --- a/moxie/web/templates/profile.html +++ b/moxie/web/templates/profile.html @@ -155,6 +155,132 @@ + + {% if user.is_admin %} + +
+

+ + + + + Admin Settings +

+ + +
+

+ + + + System Status +

+
+
+
Ollama
+
+ + Checking... +
+
+
+
ComfyUI
+
+ + Checking... +
+
+
+
Documents
+
-
+
+
+
Total Users
+
-
+
+
+
+ + +
+

+ + + + User Management +

+
+
Loading users...
+
+
+ + +
+

+ + + + API Configuration +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+

+ + + + Knowledge Base Documents +

+

Upload documents to the RAG knowledge base for all users.

+ + +
+ + + + +

Click to upload documents to knowledge base

+
+ + +
+
Loading documents...
+
+
+
+ {% endif %}