diff --git a/backend/core/database.py b/backend/core/database.py index 0248867..9a02a42 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -16,6 +16,7 @@ def _ensure_botinstance_columns() -> None: "current_state": "TEXT DEFAULT 'IDLE'", "last_action": "TEXT", "image_tag": "TEXT DEFAULT 'nanobot-base:v0.1.4'", + "access_password": "TEXT DEFAULT ''", } with engine.connect() as conn: existing_rows = conn.execute(text("PRAGMA table_info(botinstance)")).fetchall() diff --git a/backend/main.py b/backend/main.py index ce3a20e..e8356c6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,11 +8,12 @@ import tempfile import zipfile from datetime import datetime from typing import Any, Dict, List, Optional +from urllib.parse import unquote import httpx from pydantic import BaseModel -from fastapi import Depends, FastAPI, File, HTTPException, UploadFile, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse +from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware from sqlmodel import Session, select @@ -72,6 +73,7 @@ class ChannelConfigUpdateRequest(BaseModel): class BotCreateRequest(BaseModel): id: str name: str + access_password: Optional[str] = None llm_provider: str llm_model: str api_key: str @@ -98,6 +100,7 @@ class BotCreateRequest(BaseModel): class BotUpdateRequest(BaseModel): name: Optional[str] = None + access_password: Optional[str] = None llm_provider: Optional[str] = None llm_model: Optional[str] = None api_key: Optional[str] = None @@ -276,6 +279,57 @@ class WSConnectionManager: manager = WSConnectionManager() +BOT_ACCESS_PASSWORD_HEADER = "x-bot-password" + + +def _extract_bot_id_from_api_path(path: str) -> Optional[str]: + raw = str(path or "").strip() + if not raw.startswith("/api/bots/"): + return None + rest = raw[len("/api/bots/") :] + if not rest: + return None + bot_id_segment = rest.split("/", 1)[0].strip() + if not bot_id_segment: + return None + try: + decoded = unquote(bot_id_segment) + except Exception: + decoded = bot_id_segment + return str(decoded).strip() or None + + +def _get_supplied_bot_password_http(request: Request) -> str: + header_value = str(request.headers.get(BOT_ACCESS_PASSWORD_HEADER) or "").strip() + if header_value: + return header_value + query_value = str(request.query_params.get("access_password") or "").strip() + return query_value + + +@app.middleware("http") +async def bot_access_password_guard(request: Request, call_next): + if request.method.upper() == "OPTIONS": + return await call_next(request) + + bot_id = _extract_bot_id_from_api_path(request.url.path) + if not bot_id: + return await call_next(request) + + with Session(engine) as session: + bot = session.get(BotInstance, bot_id) + if not bot: + return JSONResponse(status_code=404, content={"detail": "Bot not found"}) + configured_password = str(bot.access_password or "").strip() + if configured_password: + supplied = _get_supplied_bot_password_http(request) + if not supplied: + return JSONResponse(status_code=401, content={"detail": "Bot access password required"}) + if supplied != configured_password: + return JSONResponse(status_code=401, content={"detail": "Invalid bot access password"}) + + return await call_next(request) + def docker_callback(bot_id: str, packet: Dict[str, Any]): _persist_runtime_packet(bot_id, packet) @@ -821,6 +875,8 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]: return { "id": bot.id, "name": bot.name, + "access_password": bot.access_password or "", + "has_access_password": bool(str(bot.access_password or "").strip()), "avatar_model": "base", "avatar_skin": "blue_suit", "image_tag": bot.image_tag, @@ -1039,6 +1095,30 @@ def _env_store_path(bot_id: str) -> str: return os.path.join(_bot_data_root(bot_id), "env.json") +def _sessions_root(bot_id: str) -> str: + return os.path.join(_workspace_root(bot_id), "sessions") + + +def _clear_bot_sessions(bot_id: str) -> int: + """Remove persisted session files for the bot workspace.""" + root = _sessions_root(bot_id) + if not os.path.isdir(root): + return 0 + deleted = 0 + for name in os.listdir(root): + path = os.path.join(root, name) + if not os.path.isfile(path): + continue + if not name.lower().endswith(".jsonl"): + continue + try: + os.remove(path) + deleted += 1 + except Exception: + continue + return deleted + + def _read_env_store(bot_id: str) -> Dict[str, str]: path = _env_store_path(bot_id) if not os.path.isfile(path): @@ -1134,6 +1214,16 @@ def _is_visual_attachment_path(path: str) -> bool: return _is_image_attachment_path(path) or _is_video_attachment_path(path) +def _workspace_stat_ctime_iso(stat: os.stat_result) -> str: + ts = getattr(stat, "st_birthtime", None) + if ts is None: + ts = getattr(stat, "st_ctime", None) + try: + return datetime.utcfromtimestamp(float(ts)).isoformat() + "Z" + except Exception: + return datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z" + + def _build_workspace_tree(path: str, root: str, depth: int) -> List[Dict[str, Any]]: rows: List[Dict[str, Any]] = [] try: @@ -1150,6 +1240,7 @@ def _build_workspace_tree(path: str, root: str, depth: int) -> List[Dict[str, An base: Dict[str, Any] = { "name": name, "path": rel_path, + "ctime": _workspace_stat_ctime_iso(stat), "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", } if os.path.isdir(abs_path): @@ -1185,6 +1276,7 @@ def _list_workspace_dir(path: str, root: str) -> List[Dict[str, Any]]: "type": "dir" if os.path.isdir(abs_path) else "file", "size": stat.st_size if os.path.isfile(abs_path) else None, "ext": os.path.splitext(name)[1].lower() if os.path.isfile(abs_path) else "", + "ctime": _workspace_stat_ctime_iso(stat), "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", } ) @@ -1331,6 +1423,7 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session bot = BotInstance( id=payload.id, name=payload.name, + access_password=str(payload.access_password or ""), image_tag=payload.image_tag, workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, payload.id), ) @@ -1497,7 +1590,7 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend ) runtime_overrides.update(normalized_resources) - db_fields = {"name", "image_tag"} + db_fields = {"name", "image_tag", "access_password"} for key, value in update_data.items(): if key in db_fields: setattr(bot, key, value) @@ -2128,12 +2221,19 @@ def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)): for row in rows: session.delete(row) deleted += 1 + cleared_sessions = _clear_bot_sessions(bot_id) + # Also reset in-memory session cache for running bot process. + if str(bot.docker_status or "").upper() == "RUNNING": + try: + docker_manager.send_command(bot_id, "/new") + except Exception: + pass bot.last_action = "" bot.current_state = "IDLE" bot.updated_at = datetime.utcnow() session.add(bot) session.commit() - return {"bot_id": bot_id, "deleted": deleted} + return {"bot_id": bot_id, "deleted": deleted, "cleared_sessions": cleared_sessions} @app.get("/api/bots/{bot_id}/logs") @@ -2382,6 +2482,23 @@ async def upload_workspace_files( @app.websocket("/ws/monitor/{bot_id}") async def websocket_endpoint(websocket: WebSocket, bot_id: str): + with Session(engine) as session: + bot = session.get(BotInstance, bot_id) + if not bot: + await websocket.close(code=4404, reason="Bot not found") + return + configured_password = str(bot.access_password or "").strip() + if configured_password: + supplied = str( + websocket.headers.get(BOT_ACCESS_PASSWORD_HEADER) or websocket.query_params.get("access_password") or "" + ).strip() + if not supplied: + await websocket.close(code=4401, reason="Bot access password required") + return + if supplied != configured_password: + await websocket.close(code=4401, reason="Invalid bot access password") + return + await manager.connect(bot_id, websocket) docker_manager.ensure_monitor(bot_id, docker_callback) try: diff --git a/backend/models/bot.py b/backend/models/bot.py index ff27a7d..1c99b7f 100644 --- a/backend/models/bot.py +++ b/backend/models/bot.py @@ -5,6 +5,7 @@ from datetime import datetime class BotInstance(SQLModel, table=True): id: str = Field(primary_key=True) name: str + access_password: str = Field(default="") workspace_dir: str = Field(unique=True) docker_status: str = Field(default="STOPPED", index=True) current_state: Optional[str] = Field(default="IDLE") diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts index c59a00e..d3e419b 100644 --- a/frontend/src/hooks/useBotsSync.ts +++ b/frontend/src/hooks/useBotsSync.ts @@ -7,6 +7,7 @@ import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgr import { pickLocale } from '../i18n'; import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn'; import { botsSyncEn } from '../i18n/bots-sync.en'; +import { buildMonitorWsUrl, getBotAccessPassword } from '../utils/botAccess'; function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' { const s = (v || '').toUpperCase(); @@ -56,6 +57,24 @@ function isLikelyEchoOfUserInput(progressText: string, userText: string): boolea return false; } +function extractToolCallProgressHint(raw: string, isZh: boolean): string | null { + const text = String(raw || '').replace(/<\/?tool_call>/gi, '').trim(); + if (!text) return null; + const hasToolCallSignal = + /"name"\s*:/.test(text) && /"arguments"\s*:/.test(text); + if (!hasToolCallSignal) return null; + + const nameMatch = text.match(/"name"\s*:\s*"([^"]+)"/); + const toolName = String(nameMatch?.[1] || '').trim(); + if (!toolName) return null; + + const queryMatch = text.match(/"query"\s*:\s*"([^"]+)"/); + const pathMatch = text.match(/"path"\s*:\s*"([^"]+)"/); + const target = String(queryMatch?.[1] || pathMatch?.[1] || '').trim(); + const callLabel = target ? `${toolName}("${target.slice(0, 80)}${target.length > 80 ? '…' : ''}")` : toolName; + return `${isZh ? '工具调用' : 'Tool Call'}\n${callLabel}`; +} + export function useBotsSync() { const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore(); const socketsRef = useRef>({}); @@ -97,6 +116,8 @@ export function useBotsSync() { botIds.forEach((botId) => { if (hydratedMessagesRef.current[botId]) return; + const bot = activeBots[botId]; + if (bot?.has_access_password && !getBotAccessPassword(botId)) return; hydratedMessagesRef.current[botId] = true; void (async () => { try { @@ -150,11 +171,14 @@ export function useBotsSync() { if (bot.docker_status !== 'RUNNING') { return; } + if (bot.has_access_password && !getBotAccessPassword(bot.id)) { + return; + } if (socketsRef.current[bot.id]) { return; } - const ws = new WebSocket(`${APP_ENDPOINTS.wsBase}/${bot.id}`); + const ws = new WebSocket(buildMonitorWsUrl(APP_ENDPOINTS.wsBase, bot.id)); ws.onopen = () => { const beat = window.setInterval(() => { if (ws.readyState === WebSocket.OPEN) { @@ -220,11 +244,12 @@ export function useBotsSync() { if (data.type === 'BUS_EVENT') { const content = normalizeAssistantMessageText(String(data.content || payload.content || '')); const isProgress = Boolean(data.is_progress); - const isTool = Boolean(data.is_tool); + const toolHintFromText = extractToolCallProgressHint(content, isZh); + const isTool = Boolean(data.is_tool) || Boolean(toolHintFromText); if (isProgress) { const state = normalizeBusState(isTool); const progressText = summarizeProgressText(content, isZh); - const fullProgress = content || progressText || (isZh ? '处理中...' : 'Processing...'); + const fullProgress = toolHintFromText || content || progressText || (isZh ? '处理中...' : 'Processing...'); updateBotState(bot.id, state, fullProgress); addBotEvent(bot.id, { state, text: fullProgress || t.progress, ts: Date.now(), channel: sourceChannel || undefined }); if (isDashboardChannel) { diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index 171d6b7..e999ca2 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -68,7 +68,7 @@ export const dashboardEn = { selectBot: 'Select a bot to inspect', runtime: 'Runtime Status', base: 'Base', - params: 'Params', + params: 'Model', channels: 'Channels', skills: 'Skills', tools: 'Tools', @@ -114,7 +114,9 @@ export const dashboardEn = { previewTitle: 'Click to preview', fileNotPreviewable: 'File type not previewable', baseConfig: 'Base Configuration', - baseConfigSub: 'Only editable: name and LLM access settings', + baseConfigSub: 'Edit name, access password, and resource limits', + accessPassword: 'Access Password', + accessPasswordPlaceholder: 'Leave blank to disable password', botIdReadonly: 'Bot ID (Read-only)', botName: 'Bot Name', botNamePlaceholder: 'Bot name', @@ -127,7 +129,7 @@ export const dashboardEn = { testModelConnection: 'Test model connection', cancel: 'Cancel', save: 'Save', - modelParams: 'Model Parameters', + modelParams: 'Model', saveParams: 'Save Params', agentFiles: 'Agent Files', saveFiles: 'Save Files', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index 067a87e..ccfbf51 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -68,7 +68,7 @@ export const dashboardZhCn = { selectBot: '请选择 Bot 查看详情', runtime: '运行状态', base: '基础', - params: '参数', + params: '模型', channels: '渠道', skills: '技能', tools: '工具', @@ -114,7 +114,9 @@ export const dashboardZhCn = { previewTitle: '点击打开预览', fileNotPreviewable: '该文件类型不可预览', baseConfig: '基础配置', - baseConfigSub: '仅允许修改名称和大模型接入信息', + baseConfigSub: '可修改名称、访问密码与资源配额', + accessPassword: '访问密码', + accessPasswordPlaceholder: '留空表示不设置访问密码', botIdReadonly: 'Bot ID(只读)', botName: 'Bot 名称', botNamePlaceholder: '输入 Bot 名称', @@ -127,7 +129,7 @@ export const dashboardZhCn = { testModelConnection: '测试模型连接', cancel: '取消', save: '保存', - modelParams: '模型参数设置', + modelParams: '模型', saveParams: '保存参数', agentFiles: '代理文件配置', saveFiles: '保存文件', diff --git a/frontend/src/i18n/wizard.en.ts b/frontend/src/i18n/wizard.en.ts index def1ef2..0c614c8 100644 --- a/frontend/src/i18n/wizard.en.ts +++ b/frontend/src/i18n/wizard.en.ts @@ -21,6 +21,8 @@ export const wizardEn = { loadImages: 'Load images', noReady: 'No READY image.', baseInfo: 'Base Info', + accessPassword: 'Access Password', + accessPasswordPlaceholder: 'Access password (optional)', botIdPlaceholder: 'Bot ID', botName: 'Bot Name', modelAccess: 'Model Access', diff --git a/frontend/src/i18n/wizard.zh-cn.ts b/frontend/src/i18n/wizard.zh-cn.ts index d81d307..802406d 100644 --- a/frontend/src/i18n/wizard.zh-cn.ts +++ b/frontend/src/i18n/wizard.zh-cn.ts @@ -21,6 +21,8 @@ export const wizardZhCn = { loadImages: '加载镜像列表', noReady: '暂无 READY 镜像。', baseInfo: '基础信息', + accessPassword: '访问密码', + accessPasswordPlaceholder: '访问密码(可选)', botIdPlaceholder: 'Bot ID(如 analyst_bot_01)', botName: 'Bot 名称', modelAccess: '模型接入', diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 04229a4..d00507a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,9 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import { LucentPromptProvider } from './components/lucent/LucentPromptProvider.tsx' +import { setupBotAccessAuth } from './utils/botAccess.ts' + +setupBotAccessAuth(); createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 690f66d..2e24d04 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -1155,6 +1155,18 @@ gap: 8px; } +.ops-skills-list-scroll { + max-height: min(56vh, 520px); + overflow: auto; + padding-right: 4px; +} + +.ops-cron-list-scroll { + max-height: min(58vh, 560px); + overflow: auto; + padding-right: 4px; +} + .ops-cron-item { display: grid; grid-template-columns: 1fr auto; @@ -1352,6 +1364,18 @@ align-items: center; } +.workspace-path { + min-width: 0; + flex: 1 1 auto; +} + +.workspace-toolbar-actions { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + .workspace-refresh-icon-btn { width: 30px; height: 30px; @@ -1499,6 +1523,45 @@ cursor: not-allowed; } +.workspace-hover-panel { + position: fixed; + z-index: 140; + width: min(420px, calc(100vw - 16px)); + border: 1px solid color-mix(in oklab, var(--line) 70%, var(--brand) 30%); + border-radius: 10px; + background: color-mix(in oklab, var(--panel) 90%, #000 10%); + box-shadow: 0 12px 26px rgba(7, 13, 26, 0.28); + padding: 8px 10px; + display: grid; + gap: 5px; + pointer-events: none; +} + +.workspace-hover-panel.is-above { + transform: translateY(-100%); +} + +.workspace-entry-info-row { + display: grid; + grid-template-columns: 60px 1fr; + gap: 8px; + align-items: start; +} + +.workspace-entry-info-label { + color: var(--subtitle); + font-size: 11px; + font-weight: 700; +} + +.workspace-entry-info-value { + color: var(--text); + font-size: 12px; + font-weight: 600; + line-height: 1.35; + word-break: break-all; +} + .workspace-hint { background: var(--panel-soft); color: var(--muted); diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index 629b7b2..b9df5a4 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -18,6 +18,7 @@ import { dashboardZhCn } from '../../i18n/dashboard.zh-cn'; import { dashboardEn } from '../../i18n/dashboard.en'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { LucentIconButton } from '../../components/lucent/LucentIconButton'; +import { clearBotAccessPassword, getBotAccessPassword, isBotUnauthorizedError, setBotAccessPassword } from '../../utils/botAccess'; interface BotDashboardModuleProps { onOpenCreateWizard?: () => void; @@ -38,10 +39,18 @@ interface WorkspaceNode { type: WorkspaceNodeType; size?: number; ext?: string; + ctime?: string; mtime?: string; children?: WorkspaceNode[]; } +interface WorkspaceHoverCardState { + node: WorkspaceNode; + top: number; + left: number; + above: boolean; +} + interface WorkspaceTreeResponse { bot_id: string; root: string; @@ -449,6 +458,26 @@ function formatPercent(value: number): string { return `${Math.max(0, n).toFixed(2)}%`; } +function formatWorkspaceTime(raw: string | undefined, isZh: boolean): string { + const text = String(raw || '').trim(); + if (!text) return '-'; + const dt = new Date(text); + if (Number.isNaN(dt.getTime())) return '-'; + try { + return dt.toLocaleString(isZh ? 'zh-CN' : 'en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } catch { + return dt.toLocaleString(); + } +} + function formatCronSchedule(job: CronJob, isZh: boolean) { const s = job.schedule || {}; if (s.kind === 'every' && Number(s.everyMs) > 0) { @@ -512,7 +541,7 @@ export function BotDashboardModule({ const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false); const [workspacePreview, setWorkspacePreview] = useState(null); const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false); - const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(true); + const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false); const [pendingAttachments, setPendingAttachments] = useState([]); const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); const filePickerRef = useRef(null); @@ -545,9 +574,15 @@ export function BotDashboardModule({ const [expandedProgressByKey, setExpandedProgressByKey] = useState>({}); const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState>({}); const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false); + const [workspaceHoverCard, setWorkspaceHoverCard] = useState(null); const runtimeMenuRef = useRef(null); - const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => - `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(filePath)}${forceDownload ? '&download=1' : ''}`; + const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => { + const accessPassword = selectedBotId ? getBotAccessPassword(selectedBotId) : ''; + const query = [`path=${encodeURIComponent(filePath)}`]; + if (forceDownload) query.push('download=1'); + if (accessPassword) query.push(`access_password=${encodeURIComponent(accessPassword)}`); + return `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?${query.join('&')}`; + }; const closeWorkspacePreview = () => { setWorkspacePreview(null); setWorkspacePreviewFullscreen(false); @@ -744,6 +779,7 @@ export function BotDashboardModule({ const [editForm, setEditForm] = useState({ name: '', + access_password: '', llm_provider: '', llm_model: '', image_tag: '', @@ -777,6 +813,77 @@ export function BotDashboardModule({ const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const }); const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn }); const lc = isZh ? channelsZhCn : channelsEn; + const botAccessCheckRef = useRef | undefined>>({}); + + const promptForBotPassword = (botName: string, invalid: boolean): string | null => { + const title = invalid + ? (isZh ? `访问密码错误:${botName}` : `Invalid access password: ${botName}`) + : (isZh ? `请输入访问密码:${botName}` : `Enter access password for ${botName}`); + const value = window.prompt(title, ''); + if (value === null) return null; + const trimmed = String(value || '').trim(); + if (!trimmed) return null; + return trimmed; + }; + + const verifyBotPassword = async (botId: string): Promise => { + await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`); + return true; + }; + + const ensureBotAccess = async (botId: string): Promise => { + const normalizedBotId = String(botId || '').trim(); + if (!normalizedBotId) return false; + const bot = activeBots[normalizedBotId]; + if (!bot?.has_access_password) return true; + + const inFlight = botAccessCheckRef.current[normalizedBotId]; + if (inFlight) return inFlight; + + const checkPromise = (async () => { + const botName = String(bot.name || bot.id || normalizedBotId).trim(); + let askForNewPassword = false; + for (let attempt = 0; attempt < 3; attempt += 1) { + let password = getBotAccessPassword(normalizedBotId); + if (!password || askForNewPassword) { + const input = promptForBotPassword(botName, askForNewPassword); + if (input === null) { + notify(isZh ? '已取消密码输入,无法访问该机器人。' : 'Password input cancelled. Bot access blocked.', { + tone: 'warning', + }); + return false; + } + setBotAccessPassword(normalizedBotId, input); + password = input; + } + if (!password) { + askForNewPassword = true; + continue; + } + + try { + await verifyBotPassword(normalizedBotId); + return true; + } catch (error: any) { + if (isBotUnauthorizedError(error, normalizedBotId)) { + clearBotAccessPassword(normalizedBotId); + askForNewPassword = true; + notify(isZh ? '访问密码错误,请重试。' : 'Access password is invalid. Please retry.', { tone: 'warning' }); + continue; + } + throw error; + } + } + return false; + })(); + + botAccessCheckRef.current[normalizedBotId] = checkPromise; + try { + return await checkPromise; + } finally { + delete botAccessCheckRef.current[normalizedBotId]; + } + }; const baseImageOptions = useMemo(() => { const readyTags = new Set( availableImages @@ -901,6 +1008,23 @@ export function BotDashboardModule({ const runtimeActionDisplay = runtimeActionHasMore ? runtimeActionSummary : runtimeAction; const resourceBot = useMemo(() => bots.find((b) => b.id === resourceBotId), [bots, resourceBotId]); + const hideWorkspaceHoverCard = () => setWorkspaceHoverCard(null); + + const showWorkspaceHoverCard = (node: WorkspaceNode, anchor: HTMLElement) => { + const rect = anchor.getBoundingClientRect(); + const panelHeight = 160; + const panelWidth = 420; + const gap = 8; + const viewportPadding = 8; + const belowSpace = window.innerHeight - rect.bottom; + const aboveSpace = rect.top; + const above = belowSpace < panelHeight && aboveSpace > panelHeight; + const leftRaw = rect.left + 8; + const left = Math.max(viewportPadding, Math.min(leftRaw, window.innerWidth - panelWidth - viewportPadding)); + const top = above ? rect.top - gap : rect.bottom + gap; + setWorkspaceHoverCard({ node, top, left, above }); + }; + const shouldCollapseProgress = (text: string) => { const normalized = String(text || '').trim(); if (!normalized) return false; @@ -1098,8 +1222,20 @@ export function BotDashboardModule({ useEffect(() => { setExpandedProgressByKey({}); setShowRuntimeActionModal(false); + setWorkspaceHoverCard(null); }, [selectedBotId]); + useEffect(() => { + if (!workspaceHoverCard) return; + const close = () => setWorkspaceHoverCard(null); + window.addEventListener('scroll', close, true); + window.addEventListener('resize', close); + return () => { + window.removeEventListener('scroll', close, true); + window.removeEventListener('resize', close); + }; + }, [workspaceHoverCard]); + useEffect(() => { let alive = true; const loadSystemDefaults = async () => { @@ -1138,6 +1274,7 @@ export function BotDashboardModule({ setProviderTestResult(''); setEditForm({ name: bot.name || '', + access_password: bot.access_password || '', llm_provider: bot.llm_provider || 'dashscope', llm_model: bot.llm_model || '', image_tag: bot.image_tag || '', @@ -1913,10 +2050,37 @@ export function BotDashboardModule({ setEnvParams({}); return; } - void loadWorkspaceTree(selectedBotId, ''); - void loadCronJobs(selectedBotId); - void loadBotSkills(selectedBotId); - void loadBotEnvParams(selectedBotId); + let cancelled = false; + const loadAll = async () => { + try { + const granted = await ensureBotAccess(selectedBotId); + if (!granted || cancelled) return; + await Promise.all([ + loadWorkspaceTree(selectedBotId, ''), + loadCronJobs(selectedBotId), + loadBotSkills(selectedBotId), + loadBotEnvParams(selectedBotId), + ]); + } catch (error: any) { + const detail = String(error?.response?.data?.detail || '').trim(); + if (isBotUnauthorizedError(error, selectedBotId)) { + clearBotAccessPassword(selectedBotId); + if (!cancelled) { + notify(isZh ? '访问密码校验失败,请重新进入该机器人。' : 'Bot password check failed. Reopen the bot and retry.', { + tone: 'error', + }); + } + return; + } + if (!cancelled && detail) { + notify(detail, { tone: 'error' }); + } + } + }; + void loadAll(); + return () => { + cancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedBotId]); @@ -1952,9 +2116,7 @@ export function BotDashboardModule({ const payload: Record = {}; if (mode === 'base') { payload.name = editForm.name; - payload.llm_provider = editForm.llm_provider; - payload.llm_model = editForm.llm_model; - payload.api_base = editForm.api_base; + payload.access_password = editForm.access_password; payload.image_tag = editForm.image_tag; const selectedImageOption = baseImageOptions.find((opt) => opt.tag === editForm.image_tag); if (selectedImageOption?.disabled) { @@ -1966,32 +2128,39 @@ export function BotDashboardModule({ source_dir: 'manual', }); } - if (editForm.api_key.trim()) payload.api_key = editForm.api_key.trim(); - } - if (mode === 'params') { - payload.temperature = clampTemperature(Number(editForm.temperature)); - payload.top_p = Number(editForm.top_p); - const normalizedMaxTokens = clampMaxTokens(Number(paramDraft.max_tokens)); const normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores)); const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb)); const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb)); - payload.max_tokens = normalizedMaxTokens; payload.cpu_cores = normalizedCpuCores; payload.memory_mb = normalizedMemoryMb; payload.storage_gb = normalizedStorageGb; setEditForm((p) => ({ ...p, - max_tokens: normalizedMaxTokens, cpu_cores: normalizedCpuCores, memory_mb: normalizedMemoryMb, storage_gb: normalizedStorageGb, })); - setParamDraft({ - max_tokens: String(normalizedMaxTokens), + setParamDraft((p) => ({ + ...p, cpu_cores: String(normalizedCpuCores), memory_mb: String(normalizedMemoryMb), storage_gb: String(normalizedStorageGb), - }); + })); + } + if (mode === 'params') { + payload.llm_provider = editForm.llm_provider; + payload.llm_model = editForm.llm_model; + payload.api_base = editForm.api_base; + if (editForm.api_key.trim()) payload.api_key = editForm.api_key.trim(); + payload.temperature = clampTemperature(Number(editForm.temperature)); + payload.top_p = Number(editForm.top_p); + const normalizedMaxTokens = clampMaxTokens(Number(paramDraft.max_tokens)); + payload.max_tokens = normalizedMaxTokens; + setEditForm((p) => ({ + ...p, + max_tokens: normalizedMaxTokens, + })); + setParamDraft((p) => ({ ...p, max_tokens: String(normalizedMaxTokens) })); } if (mode === 'agent') { payload.agents_md = editForm.agents_md; @@ -2002,6 +2171,14 @@ export function BotDashboardModule({ } await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${targetBotId}`, payload); + if (mode === 'base') { + const nextPassword = String(editForm.access_password || '').trim(); + if (nextPassword) { + setBotAccessPassword(targetBotId, nextPassword); + } else { + clearBotAccessPassword(targetBotId); + } + } await refresh(); setShowBaseModal(false); setShowParamModal(false); @@ -2124,7 +2301,7 @@ export function BotDashboardModule({ title={t.openFolderTitle} > - {node.name} + {node.name} {t.folder} , ); @@ -2139,10 +2316,14 @@ export function BotDashboardModule({ className={`workspace-entry file ${previewable ? '' : 'disabled'}`} disabled={!previewable || workspaceFileLoading} onClick={() => void openWorkspaceFilePreview(node.path)} + onMouseEnter={(event) => showWorkspaceHoverCard(node, event.currentTarget)} + onMouseLeave={hideWorkspaceHoverCard} + onFocus={(event) => showWorkspaceHoverCard(node, event.currentTarget)} + onBlur={hideWorkspaceHoverCard} title={previewable ? (downloadOnlyFile ? t.download : t.previewTitle) : t.fileNotPreviewable} > - {node.name} + {node.name} {node.ext || '-'} , ); @@ -2593,26 +2774,28 @@ export function BotDashboardModule({
{t.workspaceOutputs}
{workspaceError ?
{workspaceError}
: null}
- void loadWorkspaceTree(selectedBot.id, workspaceCurrentPath)} - tooltip={lc.refreshHint} - aria-label={lc.refreshHint} - > - - - {workspaceCurrentPath || '/'} +
+ void loadWorkspaceTree(selectedBot.id, workspaceCurrentPath)} + tooltip={lc.refreshHint} + aria-label={lc.refreshHint} + > + + + +
@@ -2758,6 +2941,15 @@ export function BotDashboardModule({ setEditForm((p) => ({ ...p, name: e.target.value }))} placeholder={t.botNamePlaceholder} /> + + setEditForm((p) => ({ ...p, access_password: e.target.value }))} + placeholder={t.accessPasswordPlaceholder} + /> + onBaseProviderChange(e.target.value)}> - - - - - - - - - - setEditForm((p) => ({ ...p, llm_model: e.target.value }))} placeholder={t.modelNamePlaceholder} /> - - - setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} /> - - - setEditForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="API Base URL" /> - -
- {providerPresets[editForm.llm_provider]?.note[noteLocale]} -
- - {providerTestResult &&
{providerTestResult}
} -
- - -
-
-
- )} - - {showParamModal && ( -
setShowParamModal(false)}> -
e.stopPropagation()}> -
-
-

{t.modelParams}

-
-
- setShowParamModal(false)} tooltip={t.close} aria-label={t.close}> - - -
-
-
- - setEditForm((p) => ({ ...p, temperature: clampTemperature(Number(e.target.value)) }))} /> -
-
- - setEditForm((p) => ({ ...p, top_p: Number(e.target.value) }))} /> -
- - setParamDraft((p) => ({ ...p, max_tokens: e.target.value }))} - /> -
- {[4096, 8192, 16384, 32768].map((value) => ( - - ))} -
setParamDraft((p) => ({ ...p, storage_gb: e.target.value }))} />
{isZh ? '提示:填写 0 表示不限制(保存后需手动重启 Bot 生效)。' : 'Tip: value 0 means unlimited (takes effect after manual bot restart).'}
+
+ + +
+
+
+ )} + + {showParamModal && ( +
setShowParamModal(false)}> +
e.stopPropagation()}> +
+
+

{t.modelParams}

+
+
+ setShowParamModal(false)} tooltip={t.close} aria-label={t.close}> + + +
+
+ + + + + setEditForm((p) => ({ ...p, llm_model: e.target.value }))} placeholder={t.modelNamePlaceholder} /> + + + setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} /> + + + setEditForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="API Base URL" /> + +
+ {providerPresets[editForm.llm_provider]?.note[noteLocale]} +
+ + {providerTestResult &&
{providerTestResult}
} + +
+ + setEditForm((p) => ({ ...p, temperature: clampTemperature(Number(e.target.value)) }))} /> +
+
+ + setEditForm((p) => ({ ...p, top_p: Number(e.target.value) }))} /> +
+ + setParamDraft((p) => ({ ...p, max_tokens: e.target.value }))} + /> +
+ {[4096, 8192, 16384, 32768].map((value) => ( + + ))} +
@@ -3025,7 +3218,7 @@ export function BotDashboardModule({
-
+
{botSkills.length === 0 ? (
{t.skillsEmpty}
) : ( @@ -3204,7 +3397,7 @@ export function BotDashboardModule({ ) : cronJobs.length === 0 ? (
{t.cronEmpty}
) : ( -
+
{cronJobs.map((job) => { const stopping = cronActionJobId === job.id; const channel = String(job.payload?.channel || '').trim(); @@ -3335,13 +3528,13 @@ export function BotDashboardModule({ {workspacePreview.isImage ? ( {workspacePreview.path.split('/').pop() ) : workspacePreview.isHtml ? (