From 82ce7d737317ef47c31267e4c575768493372ac0 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Wed, 11 Mar 2026 02:28:39 +0800 Subject: [PATCH] v0.1.4 --- backend/main.py | 92 +++++++++++++- frontend/src/App.tsx | 60 +++++++-- .../modules/dashboard/BotDashboardModule.css | 76 +++++++++++ .../modules/dashboard/BotDashboardModule.tsx | 119 ++++++++++++++++-- frontend/vite.config.ts | 28 ++++- 5 files changed, 348 insertions(+), 27 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8f6c527..ee006ca 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,7 +13,7 @@ from urllib.parse import unquote import httpx from pydantic import BaseModel from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware from sqlmodel import Session, select @@ -138,6 +138,10 @@ class BotEnvParamsUpdateRequest(BaseModel): env_params: Optional[Dict[str, str]] = None +class BotPageAuthLoginRequest(BaseModel): + password: str + + class CommandRequest(BaseModel): command: Optional[str] = None attachments: Optional[List[str]] = None @@ -1670,6 +1674,24 @@ def get_bot_detail(bot_id: str, session: Session = Depends(get_session)): return row +@app.post("/api/bots/{bot_id}/auth/login") +def login_bot_page(bot_id: str, payload: BotPageAuthLoginRequest, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + configured = str(bot.access_password or "").strip() + if not configured: + return {"ok": True, "enabled": False, "bot_id": bot_id} + + candidate = str(payload.password or "").strip() + if not candidate: + raise HTTPException(status_code=401, detail="Bot access password required") + if candidate != configured: + raise HTTPException(status_code=401, detail="Invalid bot access password") + return {"ok": True, "enabled": True, "bot_id": bot_id} + + @app.get("/api/bots/{bot_id}/resources") def get_bot_resources(bot_id: str, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) @@ -2538,7 +2560,59 @@ def read_workspace_file( } -def _serve_workspace_file(bot_id: str, path: str, download: bool, session: Session): +def _stream_file_range(target: str, start: int, end: int, chunk_size: int = 1024 * 1024): + with open(target, "rb") as fh: + fh.seek(start) + remaining = end - start + 1 + while remaining > 0: + chunk = fh.read(min(chunk_size, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + + +def _build_ranged_workspace_response(target: str, media_type: str, range_header: str): + file_size = os.path.getsize(target) + range_match = re.match(r"bytes=(\d*)-(\d*)", range_header.strip()) + if not range_match: + raise HTTPException(status_code=416, detail="Invalid range") + + start_raw, end_raw = range_match.groups() + if start_raw == "" and end_raw == "": + raise HTTPException(status_code=416, detail="Invalid range") + + if start_raw == "": + length = int(end_raw) + if length <= 0: + raise HTTPException(status_code=416, detail="Invalid range") + start = max(file_size - length, 0) + end = file_size - 1 + else: + start = int(start_raw) + end = int(end_raw) if end_raw else file_size - 1 + + if start >= file_size or start < 0: + raise HTTPException(status_code=416, detail="Requested range not satisfiable") + end = min(end, file_size - 1) + if end < start: + raise HTTPException(status_code=416, detail="Requested range not satisfiable") + + content_length = end - start + 1 + headers = { + "Accept-Ranges": "bytes", + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Content-Length": str(content_length), + } + return StreamingResponse( + _stream_file_range(target, start, end), + status_code=206, + media_type=media_type or "application/octet-stream", + headers=headers, + ) + + +def _serve_workspace_file(bot_id: str, path: str, download: bool, request: Request, session: Session): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") @@ -2548,13 +2622,19 @@ def _serve_workspace_file(bot_id: str, path: str, download: bool, session: Sessi raise HTTPException(status_code=404, detail="File not found") media_type, _ = mimetypes.guess_type(target) + range_header = request.headers.get("range", "") + if range_header and not download: + return _build_ranged_workspace_response(target, media_type or "application/octet-stream", range_header) + + common_headers = {"Accept-Ranges": "bytes"} if download: return FileResponse( target, media_type=media_type or "application/octet-stream", filename=os.path.basename(target), + headers=common_headers, ) - return FileResponse(target, media_type=media_type or "application/octet-stream") + return FileResponse(target, media_type=media_type or "application/octet-stream", headers=common_headers) @app.get("/api/bots/{bot_id}/cron/jobs") @@ -2618,9 +2698,10 @@ def download_workspace_file( bot_id: str, path: str, download: bool = False, + request: Request = None, session: Session = Depends(get_session), ): - return _serve_workspace_file(bot_id=bot_id, path=path, download=download, session=session) + return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session) @app.get("/public/bots/{bot_id}/workspace/download") @@ -2628,9 +2709,10 @@ def public_download_workspace_file( bot_id: str, path: str, download: bool = False, + request: Request = None, session: Session = Depends(get_session), ): - return _serve_workspace_file(bot_id=bot_id, path=path, download=download, session=session) + return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session) @app.post("/api/bots/{bot_id}/workspace/upload") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bc228b7..72ddf3e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,10 @@ import { LucentTooltip } from './components/lucent/LucentTooltip'; import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess'; import './App.css'; +function getSingleBotPasswordKey(botId: string) { + return `nanobot-bot-page-password:${String(botId || '').trim()}`; +} + function AuthenticatedApp({ forcedBotId, compactMode, @@ -28,6 +32,7 @@ function AuthenticatedApp({ const [singleBotPassword, setSingleBotPassword] = useState(''); const [singleBotPasswordError, setSingleBotPasswordError] = useState(''); const [singleBotUnlocked, setSingleBotUnlocked] = useState(false); + const [singleBotSubmitting, setSingleBotSubmitting] = useState(false); useBotsSync(forcedBotId); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim()); @@ -55,13 +60,54 @@ function AuthenticatedApp({ setSingleBotPasswordError(''); }, [forced]); - const unlockSingleBot = () => { - if (!String(singleBotPassword || '').trim()) { + useEffect(() => { + if (!forced || !forcedBot?.has_access_password || singleBotUnlocked) return; + const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(getSingleBotPasswordKey(forced)) || '' : ''; + if (!stored) return; + let alive = true; + const boot = async () => { + try { + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}/auth/login`, { password: stored }); + if (!alive) return; + setSingleBotUnlocked(true); + setSingleBotPassword(''); + setSingleBotPasswordError(''); + } catch { + if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forced)); + if (!alive) return; + setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.'); + } + }; + void boot(); + return () => { + alive = false; + }; + }, [forced, forcedBot?.has_access_password, locale, singleBotUnlocked]); + + const unlockSingleBot = async () => { + const entered = String(singleBotPassword || '').trim(); + if (!entered) { setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.'); return; } - setSingleBotPasswordError(''); - setSingleBotUnlocked(true); + if (!forced) return; + setSingleBotSubmitting(true); + try { + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}/auth/login`, { password: entered }); + if (typeof window !== 'undefined') { + window.sessionStorage.setItem(getSingleBotPasswordKey(forced), entered); + } + setSingleBotPasswordError(''); + setSingleBotUnlocked(true); + setSingleBotPassword(''); + } catch { + if (typeof window !== 'undefined') { + window.sessionStorage.removeItem(getSingleBotPasswordKey(forced)); + } + setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.'); + } finally { + setSingleBotSubmitting(false); + } }; return ( @@ -216,14 +262,14 @@ function AuthenticatedApp({ if (singleBotPasswordError) setSingleBotPasswordError(''); }} onKeyDown={(event) => { - if (event.key === 'Enter') unlockSingleBot(); + if (event.key === 'Enter') void unlockSingleBot(); }} placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'} autoFocus /> {singleBotPasswordError ?
{singleBotPasswordError}
: null} - diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 1e553bb..8ab837a 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -283,6 +283,22 @@ gap: 6px; } +.ops-bot-lock { + width: 16px; + height: 16px; + min-width: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + color: color-mix(in oklab, #f0b14a 72%, var(--text) 28%); +} + +.ops-bot-lock svg { + width: 12px; + height: 12px; + stroke-width: 2.2; +} + .ops-bot-open-inline { width: 16px; height: 16px; @@ -308,6 +324,19 @@ stroke-width: 2.25; } +.workspace-preview-media { + width: 100%; + max-height: min(72vh, 720px); + border-radius: 16px; + background: #000; +} + +.workspace-preview-audio { + width: min(100%, 760px); + align-self: center; + margin: 0 auto; +} + .ops-bot-actions .ops-bot-action-monitor { background: color-mix(in oklab, var(--panel-soft) 75%, var(--brand-soft) 25%); border-color: color-mix(in oklab, var(--brand) 44%, var(--line) 56%); @@ -2096,6 +2125,46 @@ gap: 4px; } +.workspace-preview-path-row { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.workspace-preview-path-row > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-preview-copy-name { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + box-shadow: none; +} + +.workspace-preview-copy-name:hover { + color: var(--text); + background: transparent; +} + +.workspace-preview-copy-name:focus-visible { + outline: 2px solid color-mix(in oklab, var(--brand) 40%, transparent); + outline-offset: 2px; + border-radius: 4px; +} + .workspace-preview-header-actions { display: inline-flex; align-items: center; @@ -2115,6 +2184,13 @@ overflow: auto; } +.workspace-preview-body.media { + display: flex; + align-items: center; + justify-content: center; + padding: 18px; +} + .workspace-preview-embed { width: 100%; min-height: 68vh; diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index 7adba47..8d282bc 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; import axios from 'axios'; -import { Activity, ArrowUp, Boxes, Check, ChevronLeft, ChevronRight, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Maximize2, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Repeat2, Reply, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; +import { Activity, ArrowUp, Boxes, Check, ChevronLeft, ChevronRight, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Repeat2, Reply, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; @@ -77,6 +77,8 @@ interface WorkspacePreviewState { isMarkdown: boolean; isImage: boolean; isHtml: boolean; + isVideo: boolean; + isAudio: boolean; } interface WorkspaceUploadResponse { @@ -314,8 +316,17 @@ function parseBotTimestamp(raw?: string | number) { function isPreviewableWorkspaceFile(node: WorkspaceNode) { if (node.type !== 'file') return false; - const ext = (node.ext || '').toLowerCase(); - return ['.md', '.json', '.log', '.txt', '.csv', '.html', '.htm', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].includes(ext); + const ext = (node.ext || '').trim().toLowerCase(); + if (ext) { + return [ + '.md', '.json', '.log', '.txt', '.csv', '.html', '.htm', '.pdf', + '.png', '.jpg', '.jpeg', '.webp', + '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma', + '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts', + '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps', + ].includes(ext); + } + return isPreviewableWorkspacePath(node.path); } function isPdfPath(path: string) { @@ -327,6 +338,16 @@ function isImagePath(path: string) { return normalized.endsWith('.png') || normalized.endsWith('.jpg') || normalized.endsWith('.jpeg') || normalized.endsWith('.webp'); } +function isVideoPath(path: string) { + const normalized = String(path || '').trim().toLowerCase(); + return ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts'].some((ext) => normalized.endsWith(ext)); +} + +function isAudioPath(path: string) { + const normalized = String(path || '').trim().toLowerCase(); + return ['.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma'].some((ext) => normalized.endsWith(ext)); +} + const MEDIA_UPLOAD_EXTENSIONS = new Set([ '.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.heic', '.heif', '.tif', '.tiff', '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma', @@ -358,7 +379,7 @@ function isOfficePath(path: string) { function isPreviewableWorkspacePath(path: string) { const normalized = String(path || '').trim().toLowerCase(); - return ['.md', '.json', '.log', '.txt', '.csv', '.html', '.htm', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) => + return ['.md', '.json', '.log', '.txt', '.csv', '.html', '.htm', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) => normalized.endsWith(ext), ); } @@ -367,7 +388,7 @@ function workspaceFileAction(path: string): 'preview' | 'download' | 'unsupporte const normalized = String(path || '').trim(); if (!normalized) return 'unsupported'; if (isPdfPath(normalized) || isOfficePath(normalized)) return 'download'; - if (isImagePath(normalized) || isHtmlPath(normalized)) return 'preview'; + if (isImagePath(normalized) || isHtmlPath(normalized) || isVideoPath(normalized) || isAudioPath(normalized)) return 'preview'; const lower = normalized.toLowerCase(); if (['.md', '.json', '.log', '.txt', '.csv'].some((ext) => lower.endsWith(ext))) return 'preview'; return 'unsupported'; @@ -730,6 +751,15 @@ export function BotDashboardModule({ notify(t.urlCopyFail, { tone: 'error' }); } }; + const copyWorkspacePreviewPath = async (filePath: string) => { + const normalized = String(filePath || '').trim(); + if (!normalized) return; + await copyTextToClipboard( + normalized, + isZh ? '文件路径已复制' : 'File path copied', + isZh ? '文件路径复制失败' : 'Failed to copy file path', + ); + }; const copyTextToClipboard = async (textRaw: string, successMsg: string, failMsg: string) => { const text = String(textRaw || ''); if (!text.trim()) return; @@ -1567,6 +1597,8 @@ export function BotDashboardModule({ isMarkdown: false, isImage: true, isHtml: false, + isVideo: false, + isAudio: false, }); return; } @@ -1580,6 +1612,38 @@ export function BotDashboardModule({ isMarkdown: false, isImage: false, isHtml: true, + isVideo: false, + isAudio: false, + }); + return; + } + if (isVideoPath(normalizedPath)) { + const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase(); + setWorkspacePreview({ + path: normalizedPath, + content: '', + truncated: false, + ext: fileExt ? `.${fileExt}` : '', + isMarkdown: false, + isImage: false, + isHtml: false, + isVideo: true, + isAudio: false, + }); + return; + } + if (isAudioPath(normalizedPath)) { + const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase(); + setWorkspacePreview({ + path: normalizedPath, + content: '', + truncated: false, + ext: fileExt ? `.${fileExt}` : '', + isMarkdown: false, + isImage: false, + isHtml: false, + isVideo: false, + isAudio: true, }); return; } @@ -1606,6 +1670,8 @@ export function BotDashboardModule({ isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown), isImage: false, isHtml: false, + isVideo: false, + isAudio: false, }); } catch (error: any) { const msg = error?.response?.data?.detail || t.fileReadFail; @@ -2740,11 +2806,16 @@ export function BotDashboardModule({
setSelectedBotId(bot.id)}>