v0.1.4
parent
c67c6c3e6c
commit
82ce7d7373
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue