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
This commit is contained in:
parent
347f14067a
commit
18301108b3
@ -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
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)}
|
||||
</div>
|
||||
<!-- Assistant message actions -->
|
||||
<div class="message-actions flex items-center gap-1 mt-2">
|
||||
<div class="message-actions flex items-center gap-1 mt-2 flex-wrap">
|
||||
<button class="action-btn copy-btn" title="Copy" onclick="copyMessage('${msgId}')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" title="Regenerate" onclick="regenerateResponse()">
|
||||
<button class="action-btn" title="Regenerate response" onclick="regenerateResponse()">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn positive" title="Good response" onclick="rateMessage('${msgId}', 'positive')">
|
||||
<div class="w-px h-4 bg-text-main/20 mx-1"></div>
|
||||
<button class="action-btn positive" title="Good response (thumbs up)" onclick="rateMessage('${msgId}', 'positive')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn negative" title="Bad response" onclick="rateMessage('${msgId}', 'negative')">
|
||||
<button class="action-btn negative" title="Bad response (thumbs down)" onclick="rateMessage('${msgId}', 'negative')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" title="Continue generation" onclick="continueGeneration()">
|
||||
<div class="w-px h-4 bg-text-main/20 mx-1"></div>
|
||||
<button class="action-btn" title="Read aloud (TTS)" onclick="speakMessage('${msgId}')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" title="Continue generating" onclick="continueGeneration()">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn danger" title="Delete message" onclick="deleteMessage('${msgId}')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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() {
|
||||
|
||||
@ -155,6 +155,132 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user.is_admin %}
|
||||
<!-- Admin Settings Section -->
|
||||
<div class="mt-8 border-t border-balloon-purple/20 pt-8">
|
||||
<h2 class="text-xl font-bold mb-6 flex items-center gap-2 text-balloon-yellow">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
Admin Settings
|
||||
</h2>
|
||||
|
||||
<!-- System Status -->
|
||||
<div class="bg-surface/50 rounded-xl p-6 border border-balloon-purple/20 mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-balloon-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
System Status
|
||||
</h3>
|
||||
<div id="systemStatus" class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="bg-background/30 rounded-lg p-4 text-center">
|
||||
<div class="text-sm text-text-main/50 mb-1">Ollama</div>
|
||||
<div id="ollamaStatus" class="flex items-center justify-center gap-2">
|
||||
<span class="w-2 h-2 bg-gray-500 rounded-full animate-pulse"></span>
|
||||
<span class="text-sm">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-background/30 rounded-lg p-4 text-center">
|
||||
<div class="text-sm text-text-main/50 mb-1">ComfyUI</div>
|
||||
<div id="comfyuiStatus" class="flex items-center justify-center gap-2">
|
||||
<span class="w-2 h-2 bg-gray-500 rounded-full animate-pulse"></span>
|
||||
<span class="text-sm">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-background/30 rounded-lg p-4 text-center">
|
||||
<div class="text-sm text-text-main/50 mb-1">Documents</div>
|
||||
<div id="docsCount" class="text-xl font-bold text-balloon-purple">-</div>
|
||||
</div>
|
||||
<div class="bg-background/30 rounded-lg p-4 text-center">
|
||||
<div class="text-sm text-text-main/50 mb-1">Total Users</div>
|
||||
<div id="usersCount" class="text-xl font-bold text-balloon-yellow">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management -->
|
||||
<div class="bg-surface/50 rounded-xl p-6 border border-balloon-purple/20 mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-balloon-red" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||
</svg>
|
||||
User Management
|
||||
</h3>
|
||||
<div id="usersList" class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div class="text-center text-text-main/30 py-4">Loading users...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Configuration -->
|
||||
<div class="bg-surface/50 rounded-xl p-6 border border-balloon-purple/20 mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-balloon-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
|
||||
</svg>
|
||||
API Configuration
|
||||
</h3>
|
||||
<form id="configForm" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-text-main/70 mb-1">Gemini API Key</label>
|
||||
<input type="password" name="gemini_api_key" placeholder="AIza..." class="w-full px-4 py-2 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-text-main/70 mb-1">Gemini Model</label>
|
||||
<input type="text" name="gemini_model" value="gemini-1.5-flash" class="w-full px-4 py-2 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-text-main/70 mb-1">OpenRouter API Key</label>
|
||||
<input type="password" name="openrouter_api_key" placeholder="sk-or-..." class="w-full px-4 py-2 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-text-main/70 mb-1">OpenRouter Model</label>
|
||||
<input type="text" name="openrouter_model" value="meta-llama/llama-3-8b-instruct:free" class="w-full px-4 py-2 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-text-main/70 mb-1">ComfyUI Host</label>
|
||||
<input type="text" name="comfyui_host" value="http://127.0.0.1:8188" class="w-full px-4 py-2 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-text-main/70 mb-1">Ollama Host</label>
|
||||
<input type="text" name="ollama_host" value="http://127.0.0.1:11434" class="w-full px-4 py-2 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="px-6 py-2 bg-balloon-purple hover:bg-balloon-purple/80 text-white font-medium rounded-lg transition-colors">
|
||||
Save Configuration
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- RAG Documents -->
|
||||
<div class="bg-surface/50 rounded-xl p-6 border border-balloon-purple/20">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-balloon-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Knowledge Base Documents
|
||||
</h3>
|
||||
<p class="text-sm text-text-main/50 mb-4">Upload documents to the RAG knowledge base for all users.</p>
|
||||
|
||||
<!-- Upload area -->
|
||||
<div id="adminUploadArea" class="border-2 border-dashed border-balloon-purple/30 rounded-lg p-6 text-center cursor-pointer hover:border-balloon-blue transition-colors mb-4">
|
||||
<input type="file" id="adminDocInput" class="hidden" accept=".pdf,.doc,.docx,.txt,.md">
|
||||
<svg class="w-10 h-10 mx-auto text-text-main/30 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
|
||||
</svg>
|
||||
<p class="text-sm text-text-main/50">Click to upload documents to knowledge base</p>
|
||||
</div>
|
||||
|
||||
<!-- Documents list -->
|
||||
<div id="ragDocumentsList" class="space-y-2">
|
||||
<div class="text-center text-text-main/30 text-sm py-4">Loading documents...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
@ -252,6 +378,289 @@
|
||||
alert('Failed to delete document');
|
||||
}
|
||||
}
|
||||
|
||||
// Admin functionality
|
||||
{% if user.is_admin %}
|
||||
// Load admin data
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSystemStatus();
|
||||
loadUsers();
|
||||
loadConfig();
|
||||
loadRAGDocuments();
|
||||
setupAdminUpload();
|
||||
});
|
||||
|
||||
async function loadSystemStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/status');
|
||||
const data = await response.json();
|
||||
|
||||
// Update status indicators
|
||||
updateStatusIndicator('ollamaStatus', data.ollama);
|
||||
updateStatusIndicator('comfyuiStatus', data.comfyui);
|
||||
document.getElementById('docsCount').textContent = data.documents_count || 0;
|
||||
document.getElementById('usersCount').textContent = data.users_count || '-';
|
||||
} catch (error) {
|
||||
console.error('Failed to load status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusIndicator(elementId, status) {
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
|
||||
const statusColors = {
|
||||
'connected': 'bg-green-500',
|
||||
'disconnected': 'bg-red-500',
|
||||
'error': 'bg-yellow-500',
|
||||
'unknown': 'bg-gray-500'
|
||||
};
|
||||
|
||||
el.innerHTML = `
|
||||
<span class="w-2 h-2 ${statusColors[status] || statusColors.unknown} rounded-full"></span>
|
||||
<span class="text-sm capitalize">${status || 'Unknown'}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/users');
|
||||
const users = await response.json();
|
||||
|
||||
const list = document.getElementById('usersList');
|
||||
list.innerHTML = '';
|
||||
|
||||
users.forEach(user => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'flex items-center justify-between p-3 bg-background/30 rounded-lg';
|
||||
el.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-balloon-purple/30 rounded-full flex items-center justify-center text-sm">
|
||||
${user.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">${user.username}</div>
|
||||
<div class="text-xs text-text-main/40">${user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
${user.is_admin
|
||||
? '<span class="px-2 py-0.5 bg-balloon-yellow/20 text-balloon-yellow text-xs rounded-full">Admin</span>'
|
||||
: `<button onclick="promoteUser('${user.id}')" class="px-2 py-1 text-xs bg-balloon-purple/20 hover:bg-balloon-purple/40 rounded transition-colors">Promote</button>`
|
||||
}
|
||||
<button onclick="updateUserLimit('${user.id}')" class="px-2 py-1 text-xs bg-surface hover:bg-surface/80 rounded transition-colors" title="Set daily limit">
|
||||
${user.request_limit}/day
|
||||
</button>
|
||||
<button onclick="deleteUser('${user.id}')" class="p-1 text-balloon-red hover:bg-balloon-red/20 rounded transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(el);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
document.getElementById('usersList').innerHTML = '<div class="text-center text-text-main/30 py-4">Failed to load users</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/config');
|
||||
const config = await response.json();
|
||||
|
||||
const form = document.getElementById('configForm');
|
||||
if (config.gemini_model) form.querySelector('[name="gemini_model"]').value = config.gemini_model;
|
||||
if (config.openrouter_model) form.querySelector('[name="openrouter_model"]').value = config.openrouter_model;
|
||||
if (config.comfyui_host) form.querySelector('[name="comfyui_host"]').value = config.comfyui_host;
|
||||
if (config.ollama_host) form.querySelector('[name="ollama_host"]').value = config.ollama_host;
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRAGDocuments() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/documents');
|
||||
const documents = await response.json();
|
||||
|
||||
const list = document.getElementById('ragDocumentsList');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (documents.length === 0) {
|
||||
list.innerHTML = '<div class="text-center text-text-main/30 text-sm py-4">No documents in knowledge base</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
documents.forEach(doc => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'flex items-center justify-between p-3 bg-background/30 rounded-lg';
|
||||
el.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl">📄</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium">${doc.filename || doc.id}</div>
|
||||
<div class="text-xs text-text-main/40">${doc.chunks || 0} chunks</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="deleteRAGDocument('${doc.id}')" class="p-2 text-balloon-red hover:bg-balloon-red/20 rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
list.appendChild(el);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load RAG documents:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function setupAdminUpload() {
|
||||
const uploadArea = document.getElementById('adminUploadArea');
|
||||
const docInput = document.getElementById('adminDocInput');
|
||||
|
||||
if (!uploadArea || !docInput) return;
|
||||
|
||||
uploadArea.addEventListener('click', () => docInput.click());
|
||||
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('border-balloon-blue');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('border-balloon-blue');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('border-balloon-blue');
|
||||
for (const file of e.dataTransfer.files) {
|
||||
await uploadRAGDocument(file);
|
||||
}
|
||||
});
|
||||
|
||||
docInput.addEventListener('change', async (e) => {
|
||||
for (const file of e.target.files) {
|
||||
await uploadRAGDocument(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadRAGDocument(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadRAGDocuments();
|
||||
loadSystemStatus();
|
||||
alert('Document uploaded successfully!');
|
||||
} else {
|
||||
alert('Failed to upload document');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('An error occurred during upload');
|
||||
}
|
||||
}
|
||||
|
||||
// Config form
|
||||
document.getElementById('configForm')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
gemini_api_key: form.gemini_api_key.value,
|
||||
gemini_model: form.gemini_model.value,
|
||||
openrouter_api_key: form.openrouter_api_key.value,
|
||||
openrouter_model: form.openrouter_model.value,
|
||||
comfyui_host: form.comfyui_host.value,
|
||||
ollama_host: form.ollama_host.value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/endpoints', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Configuration saved!');
|
||||
} else {
|
||||
alert('Failed to save configuration');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('An error occurred');
|
||||
}
|
||||
});
|
||||
|
||||
// User management functions
|
||||
async function promoteUser(userId) {
|
||||
if (!confirm('Promote this user to admin?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}/promote`, { method: 'POST' });
|
||||
if (response.ok) {
|
||||
loadUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to promote user');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserLimit(userId) {
|
||||
const limit = prompt('Enter new daily request limit:', '5');
|
||||
if (!limit) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}/limit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `limit=${limit}`
|
||||
});
|
||||
if (response.ok) {
|
||||
loadUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to update limit');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId) {
|
||||
if (!confirm('Delete this user? This cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
loadUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to delete user');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRAGDocument(docId) {
|
||||
if (!confirm('Delete this document from knowledge base?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/documents/${docId}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
loadRAGDocuments();
|
||||
loadSystemStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to delete document');
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user