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 import httpx
from pydantic import BaseModel from pydantic import BaseModel
from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect 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 fastapi.middleware.cors import CORSMiddleware
from sqlmodel import Session, select from sqlmodel import Session, select
@ -138,6 +138,10 @@ class BotEnvParamsUpdateRequest(BaseModel):
env_params: Optional[Dict[str, str]] = None env_params: Optional[Dict[str, str]] = None
class BotPageAuthLoginRequest(BaseModel):
password: str
class CommandRequest(BaseModel): class CommandRequest(BaseModel):
command: Optional[str] = None command: Optional[str] = None
attachments: Optional[List[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 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") @app.get("/api/bots/{bot_id}/resources")
def get_bot_resources(bot_id: str, session: Session = Depends(get_session)): def get_bot_resources(bot_id: str, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) 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) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:
raise HTTPException(status_code=404, detail="Bot not found") 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") raise HTTPException(status_code=404, detail="File not found")
media_type, _ = mimetypes.guess_type(target) 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: if download:
return FileResponse( return FileResponse(
target, target,
media_type=media_type or "application/octet-stream", media_type=media_type or "application/octet-stream",
filename=os.path.basename(target), 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") @app.get("/api/bots/{bot_id}/cron/jobs")
@ -2618,9 +2698,10 @@ def download_workspace_file(
bot_id: str, bot_id: str,
path: str, path: str,
download: bool = False, download: bool = False,
request: Request = None,
session: Session = Depends(get_session), 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") @app.get("/public/bots/{bot_id}/workspace/download")
@ -2628,9 +2709,10 @@ def public_download_workspace_file(
bot_id: str, bot_id: str,
path: str, path: str,
download: bool = False, download: bool = False,
request: Request = None,
session: Session = Depends(get_session), 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") @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 { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess';
import './App.css'; import './App.css';
function getSingleBotPasswordKey(botId: string) {
return `nanobot-bot-page-password:${String(botId || '').trim()}`;
}
function AuthenticatedApp({ function AuthenticatedApp({
forcedBotId, forcedBotId,
compactMode, compactMode,
@ -28,6 +32,7 @@ function AuthenticatedApp({
const [singleBotPassword, setSingleBotPassword] = useState(''); const [singleBotPassword, setSingleBotPassword] = useState('');
const [singleBotPasswordError, setSingleBotPasswordError] = useState(''); const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false); const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
useBotsSync(forcedBotId); useBotsSync(forcedBotId);
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim()); const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim());
@ -55,13 +60,54 @@ function AuthenticatedApp({
setSingleBotPasswordError(''); setSingleBotPasswordError('');
}, [forced]); }, [forced]);
const unlockSingleBot = () => { useEffect(() => {
if (!String(singleBotPassword || '').trim()) { 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.'); setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.');
return; return;
} }
setSingleBotPasswordError(''); if (!forced) return;
setSingleBotUnlocked(true); 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 ( return (
@ -216,14 +262,14 @@ function AuthenticatedApp({
if (singleBotPasswordError) setSingleBotPasswordError(''); if (singleBotPasswordError) setSingleBotPasswordError('');
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter') unlockSingleBot(); if (event.key === 'Enter') void unlockSingleBot();
}} }}
placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'} placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'}
autoFocus autoFocus
/> />
{singleBotPasswordError ? <div className="app-login-error">{singleBotPasswordError}</div> : null} {singleBotPasswordError ? <div className="app-login-error">{singleBotPasswordError}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={unlockSingleBot}> <button className="btn btn-primary app-login-submit" onClick={() => void unlockSingleBot()} disabled={singleBotSubmitting}>
{locale === 'zh' ? '进入' : 'Continue'} {singleBotSubmitting ? (locale === 'zh' ? '校验中...' : 'Checking...') : (locale === 'zh' ? '进入' : 'Continue')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -283,6 +283,22 @@
gap: 6px; 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 { .ops-bot-open-inline {
width: 16px; width: 16px;
height: 16px; height: 16px;
@ -308,6 +324,19 @@
stroke-width: 2.25; 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 { .ops-bot-actions .ops-bot-action-monitor {
background: color-mix(in oklab, var(--panel-soft) 75%, var(--brand-soft) 25%); 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%); border-color: color-mix(in oklab, var(--brand) 44%, var(--line) 56%);
@ -2096,6 +2125,46 @@
gap: 4px; 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 { .workspace-preview-header-actions {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -2115,6 +2184,13 @@
overflow: auto; overflow: auto;
} }
.workspace-preview-body.media {
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
}
.workspace-preview-embed { .workspace-preview-embed {
width: 100%; width: 100%;
min-height: 68vh; 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 { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
import axios from 'axios'; 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 ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
@ -77,6 +77,8 @@ interface WorkspacePreviewState {
isMarkdown: boolean; isMarkdown: boolean;
isImage: boolean; isImage: boolean;
isHtml: boolean; isHtml: boolean;
isVideo: boolean;
isAudio: boolean;
} }
interface WorkspaceUploadResponse { interface WorkspaceUploadResponse {
@ -314,8 +316,17 @@ function parseBotTimestamp(raw?: string | number) {
function isPreviewableWorkspaceFile(node: WorkspaceNode) { function isPreviewableWorkspaceFile(node: WorkspaceNode) {
if (node.type !== 'file') return false; if (node.type !== 'file') return false;
const ext = (node.ext || '').toLowerCase(); const ext = (node.ext || '').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'].includes(ext); 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) { 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'); 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([ const MEDIA_UPLOAD_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.heic', '.heif', '.tif', '.tiff', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.heic', '.heif', '.tif', '.tiff',
'.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma', '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma',
@ -358,7 +379,7 @@ function isOfficePath(path: string) {
function isPreviewableWorkspacePath(path: string) { function isPreviewableWorkspacePath(path: string) {
const normalized = String(path || '').trim().toLowerCase(); 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), normalized.endsWith(ext),
); );
} }
@ -367,7 +388,7 @@ function workspaceFileAction(path: string): 'preview' | 'download' | 'unsupporte
const normalized = String(path || '').trim(); const normalized = String(path || '').trim();
if (!normalized) return 'unsupported'; if (!normalized) return 'unsupported';
if (isPdfPath(normalized) || isOfficePath(normalized)) return 'download'; 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(); const lower = normalized.toLowerCase();
if (['.md', '.json', '.log', '.txt', '.csv'].some((ext) => lower.endsWith(ext))) return 'preview'; if (['.md', '.json', '.log', '.txt', '.csv'].some((ext) => lower.endsWith(ext))) return 'preview';
return 'unsupported'; return 'unsupported';
@ -730,6 +751,15 @@ export function BotDashboardModule({
notify(t.urlCopyFail, { tone: 'error' }); 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 copyTextToClipboard = async (textRaw: string, successMsg: string, failMsg: string) => {
const text = String(textRaw || ''); const text = String(textRaw || '');
if (!text.trim()) return; if (!text.trim()) return;
@ -1567,6 +1597,8 @@ export function BotDashboardModule({
isMarkdown: false, isMarkdown: false,
isImage: true, isImage: true,
isHtml: false, isHtml: false,
isVideo: false,
isAudio: false,
}); });
return; return;
} }
@ -1580,6 +1612,38 @@ export function BotDashboardModule({
isMarkdown: false, isMarkdown: false,
isImage: false, isImage: false,
isHtml: true, 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; return;
} }
@ -1606,6 +1670,8 @@ export function BotDashboardModule({
isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown), isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown),
isImage: false, isImage: false,
isHtml: false, isHtml: false,
isVideo: false,
isAudio: false,
}); });
} catch (error: any) { } catch (error: any) {
const msg = error?.response?.data?.detail || t.fileReadFail; const msg = error?.response?.data?.detail || t.fileReadFail;
@ -2740,11 +2806,16 @@ export function BotDashboardModule({
<div key={bot.id} className={`ops-bot-card ${selected ? 'is-active' : ''}`} onClick={() => setSelectedBotId(bot.id)}> <div key={bot.id} className={`ops-bot-card ${selected ? 'is-active' : ''}`} onClick={() => setSelectedBotId(bot.id)}>
<span className={`ops-bot-strip ${bot.docker_status === 'RUNNING' ? 'is-running' : 'is-stopped'}`} aria-hidden="true" /> <span className={`ops-bot-strip ${bot.docker_status === 'RUNNING' ? 'is-running' : 'is-stopped'}`} aria-hidden="true" />
<div className="row-between ops-bot-top"> <div className="row-between ops-bot-top">
<div className="ops-bot-name-wrap"> <div className="ops-bot-name-wrap">
<div className="ops-bot-name-row"> <div className="ops-bot-name-row">
<div className="ops-bot-name">{bot.name}</div> {bot.has_access_password ? (
<LucentIconButton <span className="ops-bot-lock" title={isZh ? '已设置访问密码' : 'Access password enabled'} aria-label={isZh ? '已设置访问密码' : 'Access password enabled'}>
className="ops-bot-open-inline" <Lock size={12} />
</span>
) : null}
<div className="ops-bot-name">{bot.name}</div>
<LucentIconButton
className="ops-bot-open-inline"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`; const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`;
@ -4023,7 +4094,17 @@ export function BotDashboardModule({
<div className="modal-title-row workspace-preview-header"> <div className="modal-title-row workspace-preview-header">
<div className="workspace-preview-header-text"> <div className="workspace-preview-header-text">
<h3>{t.filePreview}</h3> <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>
<div className="workspace-preview-header-actions"> <div className="workspace-preview-header-actions">
<LucentIconButton <LucentIconButton
@ -4044,13 +4125,27 @@ export function BotDashboardModule({
</LucentIconButton> </LucentIconButton>
</div> </div>
</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 ? ( {workspacePreview.isImage ? (
<img <img
className="workspace-preview-image" className="workspace-preview-image"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)} src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
alt={workspacePreview.path.split('/').pop() || 'workspace-image'} 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 ? ( ) : workspacePreview.isHtml ? (
<iframe <iframe
className="workspace-preview-embed" 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' import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [react()], 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,
},
},
},
}
}) })