main
mula.liu 2026-03-05 10:42:39 +08:00
parent 92db4da304
commit 6e9eba0e55
15 changed files with 724 additions and 178 deletions

View File

@ -16,6 +16,7 @@ def _ensure_botinstance_columns() -> None:
"current_state": "TEXT DEFAULT 'IDLE'", "current_state": "TEXT DEFAULT 'IDLE'",
"last_action": "TEXT", "last_action": "TEXT",
"image_tag": "TEXT DEFAULT 'nanobot-base:v0.1.4'", "image_tag": "TEXT DEFAULT 'nanobot-base:v0.1.4'",
"access_password": "TEXT DEFAULT ''",
} }
with engine.connect() as conn: with engine.connect() as conn:
existing_rows = conn.execute(text("PRAGMA table_info(botinstance)")).fetchall() existing_rows = conn.execute(text("PRAGMA table_info(botinstance)")).fetchall()

View File

@ -8,11 +8,12 @@ import tempfile
import zipfile import zipfile
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from urllib.parse import unquote
import httpx import httpx
from pydantic import BaseModel from pydantic import BaseModel
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile, WebSocket, WebSocketDisconnect from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import Session, select from sqlmodel import Session, select
@ -72,6 +73,7 @@ class ChannelConfigUpdateRequest(BaseModel):
class BotCreateRequest(BaseModel): class BotCreateRequest(BaseModel):
id: str id: str
name: str name: str
access_password: Optional[str] = None
llm_provider: str llm_provider: str
llm_model: str llm_model: str
api_key: str api_key: str
@ -98,6 +100,7 @@ class BotCreateRequest(BaseModel):
class BotUpdateRequest(BaseModel): class BotUpdateRequest(BaseModel):
name: Optional[str] = None name: Optional[str] = None
access_password: Optional[str] = None
llm_provider: Optional[str] = None llm_provider: Optional[str] = None
llm_model: Optional[str] = None llm_model: Optional[str] = None
api_key: Optional[str] = None api_key: Optional[str] = None
@ -276,6 +279,57 @@ class WSConnectionManager:
manager = 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]): def docker_callback(bot_id: str, packet: Dict[str, Any]):
_persist_runtime_packet(bot_id, packet) _persist_runtime_packet(bot_id, packet)
@ -821,6 +875,8 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]:
return { return {
"id": bot.id, "id": bot.id,
"name": bot.name, "name": bot.name,
"access_password": bot.access_password or "",
"has_access_password": bool(str(bot.access_password or "").strip()),
"avatar_model": "base", "avatar_model": "base",
"avatar_skin": "blue_suit", "avatar_skin": "blue_suit",
"image_tag": bot.image_tag, "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") 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]: def _read_env_store(bot_id: str) -> Dict[str, str]:
path = _env_store_path(bot_id) path = _env_store_path(bot_id)
if not os.path.isfile(path): 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) 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]]: def _build_workspace_tree(path: str, root: str, depth: int) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = [] rows: List[Dict[str, Any]] = []
try: try:
@ -1150,6 +1240,7 @@ def _build_workspace_tree(path: str, root: str, depth: int) -> List[Dict[str, An
base: Dict[str, Any] = { base: Dict[str, Any] = {
"name": name, "name": name,
"path": rel_path, "path": rel_path,
"ctime": _workspace_stat_ctime_iso(stat),
"mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z",
} }
if os.path.isdir(abs_path): 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", "type": "dir" if os.path.isdir(abs_path) else "file",
"size": stat.st_size if os.path.isfile(abs_path) else None, "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 "", "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", "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z",
} }
) )
@ -1331,6 +1423,7 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session
bot = BotInstance( bot = BotInstance(
id=payload.id, id=payload.id,
name=payload.name, name=payload.name,
access_password=str(payload.access_password or ""),
image_tag=payload.image_tag, image_tag=payload.image_tag,
workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, payload.id), 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) runtime_overrides.update(normalized_resources)
db_fields = {"name", "image_tag"} db_fields = {"name", "image_tag", "access_password"}
for key, value in update_data.items(): for key, value in update_data.items():
if key in db_fields: if key in db_fields:
setattr(bot, key, value) setattr(bot, key, value)
@ -2128,12 +2221,19 @@ def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)):
for row in rows: for row in rows:
session.delete(row) session.delete(row)
deleted += 1 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.last_action = ""
bot.current_state = "IDLE" bot.current_state = "IDLE"
bot.updated_at = datetime.utcnow() bot.updated_at = datetime.utcnow()
session.add(bot) session.add(bot)
session.commit() 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") @app.get("/api/bots/{bot_id}/logs")
@ -2382,6 +2482,23 @@ async def upload_workspace_files(
@app.websocket("/ws/monitor/{bot_id}") @app.websocket("/ws/monitor/{bot_id}")
async def websocket_endpoint(websocket: WebSocket, bot_id: str): 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) await manager.connect(bot_id, websocket)
docker_manager.ensure_monitor(bot_id, docker_callback) docker_manager.ensure_monitor(bot_id, docker_callback)
try: try:

View File

@ -5,6 +5,7 @@ from datetime import datetime
class BotInstance(SQLModel, table=True): class BotInstance(SQLModel, table=True):
id: str = Field(primary_key=True) id: str = Field(primary_key=True)
name: str name: str
access_password: str = Field(default="")
workspace_dir: str = Field(unique=True) workspace_dir: str = Field(unique=True)
docker_status: str = Field(default="STOPPED", index=True) docker_status: str = Field(default="STOPPED", index=True)
current_state: Optional[str] = Field(default="IDLE") current_state: Optional[str] = Field(default="IDLE")

View File

@ -7,6 +7,7 @@ import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgr
import { pickLocale } from '../i18n'; import { pickLocale } from '../i18n';
import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn'; import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn';
import { botsSyncEn } from '../i18n/bots-sync.en'; import { botsSyncEn } from '../i18n/bots-sync.en';
import { buildMonitorWsUrl, getBotAccessPassword } from '../utils/botAccess';
function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' { function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' {
const s = (v || '').toUpperCase(); const s = (v || '').toUpperCase();
@ -56,6 +57,24 @@ function isLikelyEchoOfUserInput(progressText: string, userText: string): boolea
return false; 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() { export function useBotsSync() {
const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore(); const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore();
const socketsRef = useRef<Record<string, WebSocket>>({}); const socketsRef = useRef<Record<string, WebSocket>>({});
@ -97,6 +116,8 @@ export function useBotsSync() {
botIds.forEach((botId) => { botIds.forEach((botId) => {
if (hydratedMessagesRef.current[botId]) return; if (hydratedMessagesRef.current[botId]) return;
const bot = activeBots[botId];
if (bot?.has_access_password && !getBotAccessPassword(botId)) return;
hydratedMessagesRef.current[botId] = true; hydratedMessagesRef.current[botId] = true;
void (async () => { void (async () => {
try { try {
@ -150,11 +171,14 @@ export function useBotsSync() {
if (bot.docker_status !== 'RUNNING') { if (bot.docker_status !== 'RUNNING') {
return; return;
} }
if (bot.has_access_password && !getBotAccessPassword(bot.id)) {
return;
}
if (socketsRef.current[bot.id]) { if (socketsRef.current[bot.id]) {
return; return;
} }
const ws = new WebSocket(`${APP_ENDPOINTS.wsBase}/${bot.id}`); const ws = new WebSocket(buildMonitorWsUrl(APP_ENDPOINTS.wsBase, bot.id));
ws.onopen = () => { ws.onopen = () => {
const beat = window.setInterval(() => { const beat = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
@ -220,11 +244,12 @@ export function useBotsSync() {
if (data.type === 'BUS_EVENT') { if (data.type === 'BUS_EVENT') {
const content = normalizeAssistantMessageText(String(data.content || payload.content || '')); const content = normalizeAssistantMessageText(String(data.content || payload.content || ''));
const isProgress = Boolean(data.is_progress); 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) { if (isProgress) {
const state = normalizeBusState(isTool); const state = normalizeBusState(isTool);
const progressText = summarizeProgressText(content, isZh); const progressText = summarizeProgressText(content, isZh);
const fullProgress = content || progressText || (isZh ? '处理中...' : 'Processing...'); const fullProgress = toolHintFromText || content || progressText || (isZh ? '处理中...' : 'Processing...');
updateBotState(bot.id, state, fullProgress); updateBotState(bot.id, state, fullProgress);
addBotEvent(bot.id, { state, text: fullProgress || t.progress, ts: Date.now(), channel: sourceChannel || undefined }); addBotEvent(bot.id, { state, text: fullProgress || t.progress, ts: Date.now(), channel: sourceChannel || undefined });
if (isDashboardChannel) { if (isDashboardChannel) {

View File

@ -68,7 +68,7 @@ export const dashboardEn = {
selectBot: 'Select a bot to inspect', selectBot: 'Select a bot to inspect',
runtime: 'Runtime Status', runtime: 'Runtime Status',
base: 'Base', base: 'Base',
params: 'Params', params: 'Model',
channels: 'Channels', channels: 'Channels',
skills: 'Skills', skills: 'Skills',
tools: 'Tools', tools: 'Tools',
@ -114,7 +114,9 @@ export const dashboardEn = {
previewTitle: 'Click to preview', previewTitle: 'Click to preview',
fileNotPreviewable: 'File type not previewable', fileNotPreviewable: 'File type not previewable',
baseConfig: 'Base Configuration', 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)', botIdReadonly: 'Bot ID (Read-only)',
botName: 'Bot Name', botName: 'Bot Name',
botNamePlaceholder: 'Bot name', botNamePlaceholder: 'Bot name',
@ -127,7 +129,7 @@ export const dashboardEn = {
testModelConnection: 'Test model connection', testModelConnection: 'Test model connection',
cancel: 'Cancel', cancel: 'Cancel',
save: 'Save', save: 'Save',
modelParams: 'Model Parameters', modelParams: 'Model',
saveParams: 'Save Params', saveParams: 'Save Params',
agentFiles: 'Agent Files', agentFiles: 'Agent Files',
saveFiles: 'Save Files', saveFiles: 'Save Files',

View File

@ -68,7 +68,7 @@ export const dashboardZhCn = {
selectBot: '请选择 Bot 查看详情', selectBot: '请选择 Bot 查看详情',
runtime: '运行状态', runtime: '运行状态',
base: '基础', base: '基础',
params: '参数', params: '模型',
channels: '渠道', channels: '渠道',
skills: '技能', skills: '技能',
tools: '工具', tools: '工具',
@ -114,7 +114,9 @@ export const dashboardZhCn = {
previewTitle: '点击打开预览', previewTitle: '点击打开预览',
fileNotPreviewable: '该文件类型不可预览', fileNotPreviewable: '该文件类型不可预览',
baseConfig: '基础配置', baseConfig: '基础配置',
baseConfigSub: '仅允许修改名称和大模型接入信息', baseConfigSub: '可修改名称、访问密码与资源配额',
accessPassword: '访问密码',
accessPasswordPlaceholder: '留空表示不设置访问密码',
botIdReadonly: 'Bot ID只读', botIdReadonly: 'Bot ID只读',
botName: 'Bot 名称', botName: 'Bot 名称',
botNamePlaceholder: '输入 Bot 名称', botNamePlaceholder: '输入 Bot 名称',
@ -127,7 +129,7 @@ export const dashboardZhCn = {
testModelConnection: '测试模型连接', testModelConnection: '测试模型连接',
cancel: '取消', cancel: '取消',
save: '保存', save: '保存',
modelParams: '模型参数设置', modelParams: '模型',
saveParams: '保存参数', saveParams: '保存参数',
agentFiles: '代理文件配置', agentFiles: '代理文件配置',
saveFiles: '保存文件', saveFiles: '保存文件',

View File

@ -21,6 +21,8 @@ export const wizardEn = {
loadImages: 'Load images', loadImages: 'Load images',
noReady: 'No READY image.', noReady: 'No READY image.',
baseInfo: 'Base Info', baseInfo: 'Base Info',
accessPassword: 'Access Password',
accessPasswordPlaceholder: 'Access password (optional)',
botIdPlaceholder: 'Bot ID', botIdPlaceholder: 'Bot ID',
botName: 'Bot Name', botName: 'Bot Name',
modelAccess: 'Model Access', modelAccess: 'Model Access',

View File

@ -21,6 +21,8 @@ export const wizardZhCn = {
loadImages: '加载镜像列表', loadImages: '加载镜像列表',
noReady: '暂无 READY 镜像。', noReady: '暂无 READY 镜像。',
baseInfo: '基础信息', baseInfo: '基础信息',
accessPassword: '访问密码',
accessPasswordPlaceholder: '访问密码(可选)',
botIdPlaceholder: 'Bot ID如 analyst_bot_01', botIdPlaceholder: 'Bot ID如 analyst_bot_01',
botName: 'Bot 名称', botName: 'Bot 名称',
modelAccess: '模型接入', modelAccess: '模型接入',

View File

@ -3,6 +3,9 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { LucentPromptProvider } from './components/lucent/LucentPromptProvider.tsx' import { LucentPromptProvider } from './components/lucent/LucentPromptProvider.tsx'
import { setupBotAccessAuth } from './utils/botAccess.ts'
setupBotAccessAuth();
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@ -1155,6 +1155,18 @@
gap: 8px; 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 { .ops-cron-item {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
@ -1352,6 +1364,18 @@
align-items: center; 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 { .workspace-refresh-icon-btn {
width: 30px; width: 30px;
height: 30px; height: 30px;
@ -1499,6 +1523,45 @@
cursor: not-allowed; 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 { .workspace-hint {
background: var(--panel-soft); background: var(--panel-soft);
color: var(--muted); color: var(--muted);

View File

@ -18,6 +18,7 @@ import { dashboardZhCn } from '../../i18n/dashboard.zh-cn';
import { dashboardEn } from '../../i18n/dashboard.en'; import { dashboardEn } from '../../i18n/dashboard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { clearBotAccessPassword, getBotAccessPassword, isBotUnauthorizedError, setBotAccessPassword } from '../../utils/botAccess';
interface BotDashboardModuleProps { interface BotDashboardModuleProps {
onOpenCreateWizard?: () => void; onOpenCreateWizard?: () => void;
@ -38,10 +39,18 @@ interface WorkspaceNode {
type: WorkspaceNodeType; type: WorkspaceNodeType;
size?: number; size?: number;
ext?: string; ext?: string;
ctime?: string;
mtime?: string; mtime?: string;
children?: WorkspaceNode[]; children?: WorkspaceNode[];
} }
interface WorkspaceHoverCardState {
node: WorkspaceNode;
top: number;
left: number;
above: boolean;
}
interface WorkspaceTreeResponse { interface WorkspaceTreeResponse {
bot_id: string; bot_id: string;
root: string; root: string;
@ -449,6 +458,26 @@ function formatPercent(value: number): string {
return `${Math.max(0, n).toFixed(2)}%`; 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) { function formatCronSchedule(job: CronJob, isZh: boolean) {
const s = job.schedule || {}; const s = job.schedule || {};
if (s.kind === 'every' && Number(s.everyMs) > 0) { if (s.kind === 'every' && Number(s.everyMs) > 0) {
@ -512,7 +541,7 @@ export function BotDashboardModule({
const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false); const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false);
const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null); const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null);
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false); const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(true); const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false);
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]); const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
const filePickerRef = useRef<HTMLInputElement | null>(null); const filePickerRef = useRef<HTMLInputElement | null>(null);
@ -545,9 +574,15 @@ export function BotDashboardModule({
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({}); const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({}); const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false); const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | null>(null);
const runtimeMenuRef = useRef<HTMLDivElement | null>(null); const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => {
`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(filePath)}${forceDownload ? '&download=1' : ''}`; 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 = () => { const closeWorkspacePreview = () => {
setWorkspacePreview(null); setWorkspacePreview(null);
setWorkspacePreviewFullscreen(false); setWorkspacePreviewFullscreen(false);
@ -744,6 +779,7 @@ export function BotDashboardModule({
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
name: '', name: '',
access_password: '',
llm_provider: '', llm_provider: '',
llm_model: '', llm_model: '',
image_tag: '', image_tag: '',
@ -777,6 +813,77 @@ export function BotDashboardModule({
const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const }); const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn }); const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn });
const lc = isZh ? channelsZhCn : channelsEn; const lc = isZh ? channelsZhCn : channelsEn;
const botAccessCheckRef = useRef<Record<string, Promise<boolean> | 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<boolean> => {
await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`);
return true;
};
const ensureBotAccess = async (botId: string): Promise<boolean> => {
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<BaseImageOption[]>(() => { const baseImageOptions = useMemo<BaseImageOption[]>(() => {
const readyTags = new Set( const readyTags = new Set(
availableImages availableImages
@ -901,6 +1008,23 @@ export function BotDashboardModule({
const runtimeActionDisplay = runtimeActionHasMore ? runtimeActionSummary : runtimeAction; const runtimeActionDisplay = runtimeActionHasMore ? runtimeActionSummary : runtimeAction;
const resourceBot = useMemo(() => bots.find((b) => b.id === resourceBotId), [bots, resourceBotId]); 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 shouldCollapseProgress = (text: string) => {
const normalized = String(text || '').trim(); const normalized = String(text || '').trim();
if (!normalized) return false; if (!normalized) return false;
@ -1098,8 +1222,20 @@ export function BotDashboardModule({
useEffect(() => { useEffect(() => {
setExpandedProgressByKey({}); setExpandedProgressByKey({});
setShowRuntimeActionModal(false); setShowRuntimeActionModal(false);
setWorkspaceHoverCard(null);
}, [selectedBotId]); }, [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(() => { useEffect(() => {
let alive = true; let alive = true;
const loadSystemDefaults = async () => { const loadSystemDefaults = async () => {
@ -1138,6 +1274,7 @@ export function BotDashboardModule({
setProviderTestResult(''); setProviderTestResult('');
setEditForm({ setEditForm({
name: bot.name || '', name: bot.name || '',
access_password: bot.access_password || '',
llm_provider: bot.llm_provider || 'dashscope', llm_provider: bot.llm_provider || 'dashscope',
llm_model: bot.llm_model || '', llm_model: bot.llm_model || '',
image_tag: bot.image_tag || '', image_tag: bot.image_tag || '',
@ -1913,10 +2050,37 @@ export function BotDashboardModule({
setEnvParams({}); setEnvParams({});
return; return;
} }
void loadWorkspaceTree(selectedBotId, ''); let cancelled = false;
void loadCronJobs(selectedBotId); const loadAll = async () => {
void loadBotSkills(selectedBotId); try {
void loadBotEnvParams(selectedBotId); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBotId]); }, [selectedBotId]);
@ -1952,9 +2116,7 @@ export function BotDashboardModule({
const payload: Record<string, string | number> = {}; const payload: Record<string, string | number> = {};
if (mode === 'base') { if (mode === 'base') {
payload.name = editForm.name; payload.name = editForm.name;
payload.llm_provider = editForm.llm_provider; payload.access_password = editForm.access_password;
payload.llm_model = editForm.llm_model;
payload.api_base = editForm.api_base;
payload.image_tag = editForm.image_tag; payload.image_tag = editForm.image_tag;
const selectedImageOption = baseImageOptions.find((opt) => opt.tag === editForm.image_tag); const selectedImageOption = baseImageOptions.find((opt) => opt.tag === editForm.image_tag);
if (selectedImageOption?.disabled) { if (selectedImageOption?.disabled) {
@ -1966,32 +2128,39 @@ export function BotDashboardModule({
source_dir: 'manual', 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 normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores));
const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb)); const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb));
const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb)); const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb));
payload.max_tokens = normalizedMaxTokens;
payload.cpu_cores = normalizedCpuCores; payload.cpu_cores = normalizedCpuCores;
payload.memory_mb = normalizedMemoryMb; payload.memory_mb = normalizedMemoryMb;
payload.storage_gb = normalizedStorageGb; payload.storage_gb = normalizedStorageGb;
setEditForm((p) => ({ setEditForm((p) => ({
...p, ...p,
max_tokens: normalizedMaxTokens,
cpu_cores: normalizedCpuCores, cpu_cores: normalizedCpuCores,
memory_mb: normalizedMemoryMb, memory_mb: normalizedMemoryMb,
storage_gb: normalizedStorageGb, storage_gb: normalizedStorageGb,
})); }));
setParamDraft({ setParamDraft((p) => ({
max_tokens: String(normalizedMaxTokens), ...p,
cpu_cores: String(normalizedCpuCores), cpu_cores: String(normalizedCpuCores),
memory_mb: String(normalizedMemoryMb), memory_mb: String(normalizedMemoryMb),
storage_gb: String(normalizedStorageGb), 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') { if (mode === 'agent') {
payload.agents_md = editForm.agents_md; payload.agents_md = editForm.agents_md;
@ -2002,6 +2171,14 @@ export function BotDashboardModule({
} }
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${targetBotId}`, payload); 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(); await refresh();
setShowBaseModal(false); setShowBaseModal(false);
setShowParamModal(false); setShowParamModal(false);
@ -2124,7 +2301,7 @@ export function BotDashboardModule({
title={t.openFolderTitle} title={t.openFolderTitle}
> >
<FolderOpen size={14} /> <FolderOpen size={14} />
<span className="workspace-entry-name">{node.name}</span> <span className="workspace-entry-name" title={node.name}>{node.name}</span>
<span className="workspace-entry-meta">{t.folder}</span> <span className="workspace-entry-meta">{t.folder}</span>
</button>, </button>,
); );
@ -2139,10 +2316,14 @@ export function BotDashboardModule({
className={`workspace-entry file ${previewable ? '' : 'disabled'}`} className={`workspace-entry file ${previewable ? '' : 'disabled'}`}
disabled={!previewable || workspaceFileLoading} disabled={!previewable || workspaceFileLoading}
onClick={() => void openWorkspaceFilePreview(node.path)} 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} title={previewable ? (downloadOnlyFile ? t.download : t.previewTitle) : t.fileNotPreviewable}
> >
<FileText size={14} /> <FileText size={14} />
<span className="workspace-entry-name">{node.name}</span> <span className="workspace-entry-name" title={node.name}>{node.name}</span>
<span className="workspace-entry-meta mono">{node.ext || '-'}</span> <span className="workspace-entry-meta mono">{node.ext || '-'}</span>
</button>, </button>,
); );
@ -2593,26 +2774,28 @@ export function BotDashboardModule({
<div className="section-mini-title">{t.workspaceOutputs}</div> <div className="section-mini-title">{t.workspaceOutputs}</div>
{workspaceError ? <div className="ops-empty-inline">{workspaceError}</div> : null} {workspaceError ? <div className="ops-empty-inline">{workspaceError}</div> : null}
<div className="workspace-toolbar"> <div className="workspace-toolbar">
<LucentIconButton
className="workspace-refresh-icon-btn"
disabled={workspaceLoading || !selectedBotId}
onClick={() => void loadWorkspaceTree(selectedBot.id, workspaceCurrentPath)}
tooltip={lc.refreshHint}
aria-label={lc.refreshHint}
>
<RefreshCw size={14} className={workspaceLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<label className="workspace-auto-switch" title={lc.autoRefresh}>
<span className="workspace-auto-switch-label">{lc.autoRefresh}</span>
<input
type="checkbox"
checked={workspaceAutoRefresh}
onChange={() => setWorkspaceAutoRefresh((v) => !v)}
aria-label={t.autoRefresh}
/>
<span className="workspace-auto-switch-track" />
</label>
<span className="workspace-path mono">{workspaceCurrentPath || '/'}</span> <span className="workspace-path mono">{workspaceCurrentPath || '/'}</span>
<div className="workspace-toolbar-actions">
<LucentIconButton
className="workspace-refresh-icon-btn"
disabled={workspaceLoading || !selectedBotId}
onClick={() => void loadWorkspaceTree(selectedBot.id, workspaceCurrentPath)}
tooltip={lc.refreshHint}
aria-label={lc.refreshHint}
>
<RefreshCw size={14} className={workspaceLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<label className="workspace-auto-switch" title={lc.autoRefresh}>
<span className="workspace-auto-switch-label">{lc.autoRefresh}</span>
<input
type="checkbox"
checked={workspaceAutoRefresh}
onChange={() => setWorkspaceAutoRefresh((v) => !v)}
aria-label={t.autoRefresh}
/>
<span className="workspace-auto-switch-track" />
</label>
</div>
</div> </div>
<div className="workspace-panel"> <div className="workspace-panel">
<div className="workspace-list"> <div className="workspace-list">
@ -2758,6 +2941,15 @@ export function BotDashboardModule({
<label className="field-label">{t.botName}</label> <label className="field-label">{t.botName}</label>
<input className="input" value={editForm.name} onChange={(e) => setEditForm((p) => ({ ...p, name: e.target.value }))} placeholder={t.botNamePlaceholder} /> <input className="input" value={editForm.name} onChange={(e) => setEditForm((p) => ({ ...p, name: e.target.value }))} placeholder={t.botNamePlaceholder} />
<label className="field-label">{t.accessPassword}</label>
<input
className="input"
type="password"
value={editForm.access_password}
onChange={(e) => setEditForm((p) => ({ ...p, access_password: e.target.value }))}
placeholder={t.accessPasswordPlaceholder}
/>
<label className="field-label">{t.baseImageReadonly}</label> <label className="field-label">{t.baseImageReadonly}</label>
<select <select
className="select" className="select"
@ -2776,83 +2968,6 @@ export function BotDashboardModule({
</div> </div>
) : null} ) : null}
<label className="field-label">Provider</label>
<select className="select" value={editForm.llm_provider} onChange={(e) => onBaseProviderChange(e.target.value)}>
<option value="openrouter">openrouter</option>
<option value="dashscope">dashscope (aliyun qwen)</option>
<option value="openai">openai</option>
<option value="deepseek">deepseek</option>
<option value="kimi">kimi (moonshot)</option>
<option value="minimax">minimax</option>
</select>
<label className="field-label">{t.modelName}</label>
<input className="input" value={editForm.llm_model} onChange={(e) => setEditForm((p) => ({ ...p, llm_model: e.target.value }))} placeholder={t.modelNamePlaceholder} />
<label className="field-label">{t.newApiKey}</label>
<input className="input" type="password" value={editForm.api_key} onChange={(e) => setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} />
<label className="field-label">API Base</label>
<input className="input" value={editForm.api_base} onChange={(e) => setEditForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="API Base URL" />
<div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{providerPresets[editForm.llm_provider]?.note[noteLocale]}
</div>
<button className="btn btn-secondary" onClick={() => void testProviderConnection()} disabled={isTestingProvider}>
{isTestingProvider ? t.testing : t.testModelConnection}
</button>
{providerTestResult && <div className="card">{providerTestResult}</div>}
<div className="row-between">
<button className="btn btn-secondary" onClick={() => setShowBaseModal(false)}>{t.cancel}</button>
<button className="btn btn-primary" disabled={isSaving} onClick={() => void saveBot('base')}>{t.save}</button>
</div>
</div>
</div>
)}
{showParamModal && (
<div className="modal-mask" onClick={() => setShowParamModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.modelParams}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowParamModal(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="slider-row">
<label className="field-label">Temperature: {Number(editForm.temperature).toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={editForm.temperature} onChange={(e) => setEditForm((p) => ({ ...p, temperature: clampTemperature(Number(e.target.value)) }))} />
</div>
<div className="slider-row">
<label className="field-label">Top P: {Number(editForm.top_p).toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={editForm.top_p} onChange={(e) => setEditForm((p) => ({ ...p, top_p: Number(e.target.value) }))} />
</div>
<label className="field-label">Max Tokens</label>
<input
className="input"
type="number"
step="1"
min="256"
max="32768"
value={paramDraft.max_tokens}
onChange={(e) => setParamDraft((p) => ({ ...p, max_tokens: e.target.value }))}
/>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{[4096, 8192, 16384, 32768].map((value) => (
<button
key={value}
className="btn btn-secondary btn-sm"
type="button"
onClick={() => setParamDraft((p) => ({ ...p, max_tokens: String(value) }))}
>
{value}
</button>
))}
</div>
<label className="field-label">{isZh ? 'CPU 核心数' : 'CPU Cores'}</label> <label className="field-label">{isZh ? 'CPU 核心数' : 'CPU Cores'}</label>
<input <input
className="input" className="input"
@ -2884,6 +2999,84 @@ export function BotDashboardModule({
onChange={(e) => setParamDraft((p) => ({ ...p, storage_gb: e.target.value }))} onChange={(e) => setParamDraft((p) => ({ ...p, storage_gb: e.target.value }))}
/> />
<div className="field-label">{isZh ? '提示:填写 0 表示不限制(保存后需手动重启 Bot 生效)。' : 'Tip: value 0 means unlimited (takes effect after manual bot restart).'}</div> <div className="field-label">{isZh ? '提示:填写 0 表示不限制(保存后需手动重启 Bot 生效)。' : 'Tip: value 0 means unlimited (takes effect after manual bot restart).'}</div>
<div className="row-between">
<button className="btn btn-secondary" onClick={() => setShowBaseModal(false)}>{t.cancel}</button>
<button className="btn btn-primary" disabled={isSaving} onClick={() => void saveBot('base')}>{t.save}</button>
</div>
</div>
</div>
)}
{showParamModal && (
<div className="modal-mask" onClick={() => setShowParamModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.modelParams}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowParamModal(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<label className="field-label">Provider</label>
<select className="select" value={editForm.llm_provider} onChange={(e) => onBaseProviderChange(e.target.value)}>
<option value="openrouter">openrouter</option>
<option value="dashscope">dashscope (aliyun qwen)</option>
<option value="openai">openai</option>
<option value="deepseek">deepseek</option>
<option value="kimi">kimi (moonshot)</option>
<option value="minimax">minimax</option>
</select>
<label className="field-label">{t.modelName}</label>
<input className="input" value={editForm.llm_model} onChange={(e) => setEditForm((p) => ({ ...p, llm_model: e.target.value }))} placeholder={t.modelNamePlaceholder} />
<label className="field-label">{t.newApiKey}</label>
<input className="input" type="password" value={editForm.api_key} onChange={(e) => setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} />
<label className="field-label">API Base</label>
<input className="input" value={editForm.api_base} onChange={(e) => setEditForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="API Base URL" />
<div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{providerPresets[editForm.llm_provider]?.note[noteLocale]}
</div>
<button className="btn btn-secondary" onClick={() => void testProviderConnection()} disabled={isTestingProvider}>
{isTestingProvider ? t.testing : t.testModelConnection}
</button>
{providerTestResult && <div className="card">{providerTestResult}</div>}
<div className="slider-row">
<label className="field-label">Temperature: {Number(editForm.temperature).toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={editForm.temperature} onChange={(e) => setEditForm((p) => ({ ...p, temperature: clampTemperature(Number(e.target.value)) }))} />
</div>
<div className="slider-row">
<label className="field-label">Top P: {Number(editForm.top_p).toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={editForm.top_p} onChange={(e) => setEditForm((p) => ({ ...p, top_p: Number(e.target.value) }))} />
</div>
<label className="field-label">Max Tokens</label>
<input
className="input"
type="number"
step="1"
min="256"
max="32768"
value={paramDraft.max_tokens}
onChange={(e) => setParamDraft((p) => ({ ...p, max_tokens: e.target.value }))}
/>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{[4096, 8192, 16384, 32768].map((value) => (
<button
key={value}
className="btn btn-secondary btn-sm"
type="button"
onClick={() => setParamDraft((p) => ({ ...p, max_tokens: String(value) }))}
>
{value}
</button>
))}
</div>
<div className="row-between"> <div className="row-between">
<button className="btn btn-secondary" onClick={() => setShowParamModal(false)}>{t.cancel}</button> <button className="btn btn-secondary" onClick={() => setShowParamModal(false)}>{t.cancel}</button>
<button className="btn btn-primary" disabled={isSaving} onClick={() => void saveBot('params')}>{t.saveParams}</button> <button className="btn btn-primary" disabled={isSaving} onClick={() => void saveBot('params')}>{t.saveParams}</button>
@ -3025,7 +3218,7 @@ export function BotDashboardModule({
</LucentIconButton> </LucentIconButton>
</div> </div>
</div> </div>
<div className="wizard-channel-list"> <div className="wizard-channel-list ops-skills-list-scroll">
{botSkills.length === 0 ? ( {botSkills.length === 0 ? (
<div className="ops-empty-inline">{t.skillsEmpty}</div> <div className="ops-empty-inline">{t.skillsEmpty}</div>
) : ( ) : (
@ -3204,7 +3397,7 @@ export function BotDashboardModule({
) : cronJobs.length === 0 ? ( ) : cronJobs.length === 0 ? (
<div className="ops-empty-inline">{t.cronEmpty}</div> <div className="ops-empty-inline">{t.cronEmpty}</div>
) : ( ) : (
<div className="ops-cron-list"> <div className="ops-cron-list ops-cron-list-scroll">
{cronJobs.map((job) => { {cronJobs.map((job) => {
const stopping = cronActionJobId === job.id; const stopping = cronActionJobId === job.id;
const channel = String(job.payload?.channel || '').trim(); const channel = String(job.payload?.channel || '').trim();
@ -3335,13 +3528,13 @@ export function BotDashboardModule({
{workspacePreview.isImage ? ( {workspacePreview.isImage ? (
<img <img
className="workspace-preview-image" className="workspace-preview-image"
src={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}`} src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
alt={workspacePreview.path.split('/').pop() || 'workspace-image'} alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
/> />
) : workspacePreview.isHtml ? ( ) : workspacePreview.isHtml ? (
<iframe <iframe
className="workspace-preview-embed" className="workspace-preview-embed"
src={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}`} src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
title={workspacePreview.path} title={workspacePreview.path}
/> />
) : workspacePreview.isMarkdown ? ( ) : workspacePreview.isMarkdown ? (
@ -3374,7 +3567,7 @@ export function BotDashboardModule({
) : ( ) : (
<a <a
className="btn btn-secondary" className="btn btn-secondary"
href={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}&download=1`} href={buildWorkspaceDownloadHref(workspacePreview.path, true)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
download={workspacePreview.path.split('/').pop() || 'workspace-file'} download={workspacePreview.path.split('/').pop() || 'workspace-file'}
@ -3387,6 +3580,30 @@ export function BotDashboardModule({
</div> </div>
</div> </div>
)} )}
{workspaceHoverCard ? (
<div
className={`workspace-hover-panel ${workspaceHoverCard.above ? 'is-above' : ''}`}
style={{ top: workspaceHoverCard.top, left: workspaceHoverCard.left }}
role="tooltip"
>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '全称' : 'Name'}</span>
<span className="workspace-entry-info-value mono">{workspaceHoverCard.node.name || '-'}</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '创建时间' : 'Created'}</span>
<span className="workspace-entry-info-value">{formatWorkspaceTime(workspaceHoverCard.node.ctime, isZh)}</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '修改时间' : 'Modified'}</span>
<span className="workspace-entry-info-value">{formatWorkspaceTime(workspaceHoverCard.node.mtime, isZh)}</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '文件大小' : 'Size'}</span>
<span className="workspace-entry-info-value mono">{Number.isFinite(Number(workspaceHoverCard.node.size)) ? formatBytes(Number(workspaceHoverCard.node.size)) : '-'}</span>
</div>
</div>
) : null}
</> </>
); );
} }

View File

@ -50,6 +50,7 @@ export function normalizeAssistantMessageText(input: string) {
// Remove dashboard wrapper if channel accidentally outputs raw marker line. // Remove dashboard wrapper if channel accidentally outputs raw marker line.
text = text.replace(/__DASHBOARD_DATA_START__/g, '').replace(/__DASHBOARD_DATA_END__/g, '').trim(); text = text.replace(/__DASHBOARD_DATA_START__/g, '').replace(/__DASHBOARD_DATA_END__/g, '').trim();
text = text.replace(/<\/?tool_call>/gi, '').trim();
// Keep HTML-enabled markdown readable: cap excessive HTML line breaks/empty paragraphs. // Keep HTML-enabled markdown readable: cap excessive HTML line breaks/empty paragraphs.
text = text.replace(/(?:<br\s*\/?>\s*){4,}/gi, '<br><br>'); text = text.replace(/(?:<br\s*\/?>\s*){4,}/gi, '<br><br>');

View File

@ -10,6 +10,7 @@ import { wizardZhCn } from '../../i18n/wizard.zh-cn';
import { wizardEn } from '../../i18n/wizard.en'; import { wizardEn } from '../../i18n/wizard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { setBotAccessPassword } from '../../utils/botAccess';
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack'; type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
@ -96,6 +97,7 @@ const providerPresets: Record<string, { model: string; note: { 'zh-cn': string;
const initialForm = { const initialForm = {
id: '', id: '',
name: '', name: '',
access_password: '',
llm_provider: 'dashscope', llm_provider: 'dashscope',
llm_model: providerPresets.dashscope.model, llm_model: providerPresets.dashscope.model,
api_key: '', api_key: '',
@ -274,6 +276,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
await axios.post(`${APP_ENDPOINTS.apiBase}/bots`, { await axios.post(`${APP_ENDPOINTS.apiBase}/bots`, {
id: form.id, id: form.id,
name: form.name, name: form.name,
access_password: form.access_password,
llm_provider: form.llm_provider, llm_provider: form.llm_provider,
llm_model: form.llm_model, llm_model: form.llm_model,
api_key: form.api_key, api_key: form.api_key,
@ -304,6 +307,10 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
env_params: form.env_params, env_params: form.env_params,
}); });
if (String(form.access_password || '').trim()) {
setBotAccessPassword(form.id, form.access_password);
}
if (autoStart) { if (autoStart) {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${form.id}/start`); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${form.id}/start`);
} }
@ -589,7 +596,48 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
<div className="section-mini-title">{ui.baseInfo}</div> <div className="section-mini-title">{ui.baseInfo}</div>
<input className="input" placeholder={ui.botIdPlaceholder} value={form.id} onChange={(e) => setForm((p) => ({ ...p, id: e.target.value }))} /> <input className="input" placeholder={ui.botIdPlaceholder} value={form.id} onChange={(e) => setForm((p) => ({ ...p, id: e.target.value }))} />
<input className="input" placeholder={ui.botName} value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} /> <input className="input" placeholder={ui.botName} value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
<input className="input" type="password" placeholder={ui.accessPasswordPlaceholder} value={form.access_password} onChange={(e) => setForm((p) => ({ ...p, access_password: e.target.value }))} />
<div className="section-mini-title" style={{ marginTop: 10 }}>
{isZh ? '资源配额' : 'Resource Limits'}
</div>
<label className="field-label">{isZh ? 'CPU 核心数' : 'CPU Cores'}</label>
<input
className="input"
type="number"
min="0"
max="16"
step="0.1"
value={cpuCoresDraft}
onChange={(e) => setCpuCoresDraft(e.target.value)}
onBlur={(e) => commitCpuCoresDraft(e.target.value)}
/>
<label className="field-label">{isZh ? '内存 (MB)' : 'Memory (MB)'}</label>
<input
className="input"
type="number"
min="0"
max="65536"
step="128"
value={memoryMbDraft}
onChange={(e) => setMemoryMbDraft(e.target.value)}
onBlur={(e) => commitMemoryMbDraft(e.target.value)}
/>
<label className="field-label">{isZh ? '存储 (GB)' : 'Storage (GB)'}</label>
<input
className="input"
type="number"
min="0"
max="1024"
step="1"
value={storageGbDraft}
onChange={(e) => setStorageGbDraft(e.target.value)}
onBlur={(e) => commitStorageGbDraft(e.target.value)}
/>
<div className="field-label">{isZh ? '提示:填写 0 表示不限制。' : 'Tip: value 0 means unlimited.'}</div>
</div>
<div className="stack card wizard-step2-card">
<div className="section-mini-title">{ui.modelAccess}</div> <div className="section-mini-title">{ui.modelAccess}</div>
<select className="select" value={form.llm_provider} onChange={(e) => onProviderChange(e.target.value)}> <select className="select" value={form.llm_provider} onChange={(e) => onProviderChange(e.target.value)}>
<option value="openrouter">openrouter</option> <option value="openrouter">openrouter</option>
@ -602,7 +650,6 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
<input className="input" placeholder={ui.modelNamePlaceholder} value={form.llm_model} onChange={(e) => setForm((p) => ({ ...p, llm_model: e.target.value }))} /> <input className="input" placeholder={ui.modelNamePlaceholder} value={form.llm_model} onChange={(e) => setForm((p) => ({ ...p, llm_model: e.target.value }))} />
<input className="input" type="password" placeholder="API Key" value={form.api_key} onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))} /> <input className="input" type="password" placeholder="API Key" value={form.api_key} onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))} />
<input className="input" placeholder="API Base" value={form.api_base} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} /> <input className="input" placeholder="API Base" value={form.api_base} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} />
<div className="card wizard-note-card" style={{ fontSize: 12, color: 'var(--muted)' }}> <div className="card wizard-note-card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{providerPresets[form.llm_provider]?.note[noteLocale]} {providerPresets[form.llm_provider]?.note[noteLocale]}
</div> </div>
@ -610,9 +657,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
{isTestingProvider ? ui.testing : ui.test} {isTestingProvider ? ui.testing : ui.test}
</button> </button>
{testResult && <div className="card wizard-note-card">{testResult}</div>} {testResult && <div className="card wizard-note-card">{testResult}</div>}
</div>
<div className="stack card wizard-step2-card">
<div className="section-mini-title">{ui.modelParams}</div> <div className="section-mini-title">{ui.modelParams}</div>
<div className="slider-row"> <div className="slider-row">
<label className="field-label">Temperature: {form.temperature.toFixed(2)}</label> <label className="field-label">Temperature: {form.temperature.toFixed(2)}</label>
@ -652,44 +697,6 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
</button> </button>
))} ))}
</div> </div>
<div className="section-mini-title" style={{ marginTop: 10 }}>
{isZh ? '资源配额' : 'Resource Limits'}
</div>
<label className="field-label">{isZh ? 'CPU 核心数' : 'CPU Cores'}</label>
<input
className="input"
type="number"
min="0"
max="16"
step="0.1"
value={cpuCoresDraft}
onChange={(e) => setCpuCoresDraft(e.target.value)}
onBlur={(e) => commitCpuCoresDraft(e.target.value)}
/>
<label className="field-label">{isZh ? '内存 (MB)' : 'Memory (MB)'}</label>
<input
className="input"
type="number"
min="0"
max="65536"
step="128"
value={memoryMbDraft}
onChange={(e) => setMemoryMbDraft(e.target.value)}
onBlur={(e) => commitMemoryMbDraft(e.target.value)}
/>
<label className="field-label">{isZh ? '存储 (GB)' : 'Storage (GB)'}</label>
<input
className="input"
type="number"
min="0"
max="1024"
step="1"
value={storageGbDraft}
onChange={(e) => setStorageGbDraft(e.target.value)}
onBlur={(e) => commitStorageGbDraft(e.target.value)}
/>
<div className="field-label">{isZh ? '提示:填写 0 表示不限制。' : 'Tip: value 0 means unlimited.'}</div>
</div> </div>
<div className="stack card wizard-step2-card"> <div className="stack card wizard-step2-card">
@ -765,6 +772,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
<div>{ui.image}: <span className="mono">{form.image_tag}</span></div> <div>{ui.image}: <span className="mono">{form.image_tag}</span></div>
<div>Bot ID: <span className="mono">{form.id}</span></div> <div>Bot ID: <span className="mono">{form.id}</span></div>
<div>{ui.name}: {form.name}</div> <div>{ui.name}: {form.name}</div>
<div>{ui.accessPassword}: {form.access_password ? (isZh ? '已设置' : 'Configured') : (isZh ? '未设置' : 'Not set')}</div>
<div>Provider: {form.llm_provider}</div> <div>Provider: {form.llm_provider}</div>
<div>{ui.model}: {form.llm_model}</div> <div>{ui.model}: {form.llm_model}</div>
<div>Temperature: {form.temperature.toFixed(2)}</div> <div>Temperature: {form.temperature.toFixed(2)}</div>

View File

@ -18,6 +18,8 @@ export interface BotEvent {
export interface BotState { export interface BotState {
id: string; id: string;
name: string; name: string;
access_password?: string;
has_access_password?: boolean;
avatar_model: string; avatar_model: string;
avatar_skin?: string; avatar_skin?: string;
docker_status: string; docker_status: string;

View File

@ -0,0 +1,100 @@
import axios from 'axios';
const BOT_PASSWORD_HEADER = 'X-Bot-Password';
let initialized = false;
const memoryMap = new Map<string, string>();
function normalizeBotId(raw: string): string {
return String(raw || '').trim();
}
function resolveAbsoluteUrl(input: string): string {
const url = String(input || '').trim();
if (!url) return '';
try {
if (url.startsWith('http://') || url.startsWith('https://')) {
return new URL(url).pathname;
}
if (typeof window !== 'undefined') {
return new URL(url, window.location.origin).pathname;
}
return url;
} catch {
return url;
}
}
export function extractBotIdFromApiPath(rawPath: string): string | null {
const path = resolveAbsoluteUrl(rawPath);
if (!path) return null;
const match = path.match(/\/api\/bots\/([^/?#]+)/i);
if (!match?.[1]) return null;
try {
return decodeURIComponent(match[1]).trim() || null;
} catch {
return String(match[1]).trim() || null;
}
}
export function getBotAccessPassword(botId: string): string {
const key = normalizeBotId(botId);
if (!key) return '';
return memoryMap.get(key) || '';
}
export function setBotAccessPassword(botId: string, password: string): void {
const key = normalizeBotId(botId);
const value = String(password || '').trim();
if (!key || !value) return;
memoryMap.set(key, value);
}
export function clearBotAccessPassword(botId: string): void {
const key = normalizeBotId(botId);
if (!key) return;
memoryMap.delete(key);
}
export function clearAllBotAccessPasswords(): void {
if (memoryMap.size === 0) return;
memoryMap.clear();
}
export function isBotUnauthorizedError(error: any, botId?: string): boolean {
if (!axios.isAxiosError(error)) return false;
if (Number(error.response?.status) !== 401) return false;
if (!botId) return true;
const fromConfig = extractBotIdFromApiPath(String(error.config?.url || ''));
const fromRequest = extractBotIdFromApiPath(String(error.request?.responseURL || ''));
const expected = normalizeBotId(botId);
return expected === fromConfig || expected === fromRequest;
}
export function buildMonitorWsUrl(base: string, botId: string): string {
const target = `${String(base || '').replace(/\/$/, '')}/${encodeURIComponent(botId)}`;
const password = getBotAccessPassword(botId);
if (!password) return target;
const joiner = target.includes('?') ? '&' : '?';
return `${target}${joiner}access_password=${encodeURIComponent(password)}`;
}
export function setupBotAccessAuth(): void {
if (initialized) return;
initialized = true;
axios.interceptors.request.use((config) => {
const botId = extractBotIdFromApiPath(String(config.url || ''));
if (!botId) return config;
const password = getBotAccessPassword(botId);
if (!password) return config;
const headers = config.headers || {};
if (!(BOT_PASSWORD_HEADER in (headers as Record<string, unknown>))) {
(headers as Record<string, string>)[BOT_PASSWORD_HEADER] = password;
config.headers = headers;
}
return config;
});
}