main
mula.liu 2026-03-11 02:28:39 +08:00
parent c67c6c3e6c
commit 82ce7d7373
5 changed files with 348 additions and 27 deletions

View File

@ -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")

View File

@ -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;
}
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 ? <div className="app-login-error">{singleBotPasswordError}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={unlockSingleBot}>
{locale === 'zh' ? '进入' : 'Continue'}
<button className="btn btn-primary app-login-submit" onClick={() => void unlockSingleBot()} disabled={singleBotSubmitting}>
{singleBotSubmitting ? (locale === 'zh' ? '校验中...' : 'Checking...') : (locale === 'zh' ? '进入' : 'Continue')}
</button>
</div>
</div>

View File

@ -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;

View File

@ -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;
@ -2742,6 +2808,11 @@ export function BotDashboardModule({
<div className="row-between ops-bot-top">
<div className="ops-bot-name-wrap">
<div className="ops-bot-name-row">
{bot.has_access_password ? (
<span className="ops-bot-lock" title={isZh ? '已设置访问密码' : 'Access password enabled'} aria-label={isZh ? '已设置访问密码' : 'Access password enabled'}>
<Lock size={12} />
</span>
) : null}
<div className="ops-bot-name">{bot.name}</div>
<LucentIconButton
className="ops-bot-open-inline"
@ -4023,7 +4094,17 @@ export function BotDashboardModule({
<div className="modal-title-row workspace-preview-header">
<div className="workspace-preview-header-text">
<h3>{t.filePreview}</h3>
<span className="modal-sub mono">{workspacePreview.path}</span>
<span className="modal-sub mono workspace-preview-path-row">
<span>{workspacePreview.path}</span>
<LucentIconButton
className="workspace-preview-copy-name"
onClick={() => void copyWorkspacePreviewPath(workspacePreview.path)}
tooltip={isZh ? '复制路径' : 'Copy path'}
aria-label={isZh ? '复制路径' : 'Copy path'}
>
<Copy size={12} />
</LucentIconButton>
</span>
</div>
<div className="workspace-preview-header-actions">
<LucentIconButton
@ -4044,13 +4125,27 @@ export function BotDashboardModule({
</LucentIconButton>
</div>
</div>
<div className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''}`}>
<div className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''} ${workspacePreview.isImage || workspacePreview.isVideo || workspacePreview.isAudio ? 'media' : ''}`}>
{workspacePreview.isImage ? (
<img
className="workspace-preview-image"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
/>
) : workspacePreview.isVideo ? (
<video
className="workspace-preview-media"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
controls
preload="metadata"
/>
) : workspacePreview.isAudio ? (
<audio
className="workspace-preview-audio"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
controls
preload="metadata"
/>
) : workspacePreview.isHtml ? (
<iframe
className="workspace-preview-embed"

View File

@ -1,7 +1,29 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const backendTarget = String(env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:8000').trim()
return {
plugins: [react()],
server: {
proxy: {
'/api': {
target: backendTarget,
changeOrigin: true,
},
'/public': {
target: backendTarget,
changeOrigin: true,
},
'/ws/monitor': {
target: backendTarget,
changeOrigin: true,
ws: true,
},
},
},
}
})