v 0.1.3
parent
92db4da304
commit
6e9eba0e55
|
|
@ -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()
|
||||
|
|
|
|||
125
backend/main.py
125
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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<Record<string, WebSocket>>({});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '保存文件',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export const wizardZhCn = {
|
|||
loadImages: '加载镜像列表',
|
||||
noReady: '暂无 READY 镜像。',
|
||||
baseInfo: '基础信息',
|
||||
accessPassword: '访问密码',
|
||||
accessPasswordPlaceholder: '访问密码(可选)',
|
||||
botIdPlaceholder: 'Bot ID(如 analyst_bot_01)',
|
||||
botName: 'Bot 名称',
|
||||
modelAccess: '模型接入',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<StrictMode>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<WorkspacePreviewState | null>(null);
|
||||
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
|
||||
const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(true);
|
||||
const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
|
||||
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
|
||||
const filePickerRef = useRef<HTMLInputElement | null>(null);
|
||||
|
|
@ -545,9 +574,15 @@ export function BotDashboardModule({
|
|||
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
|
||||
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
|
||||
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
|
||||
const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | null>(null);
|
||||
const runtimeMenuRef = useRef<HTMLDivElement | null>(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<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 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<string, string | number> = {};
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
</button>,
|
||||
);
|
||||
|
|
@ -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}
|
||||
>
|
||||
<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>
|
||||
</button>,
|
||||
);
|
||||
|
|
@ -2593,26 +2774,28 @@ export function BotDashboardModule({
|
|||
<div className="section-mini-title">{t.workspaceOutputs}</div>
|
||||
{workspaceError ? <div className="ops-empty-inline">{workspaceError}</div> : null}
|
||||
<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>
|
||||
<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 className="workspace-panel">
|
||||
<div className="workspace-list">
|
||||
|
|
@ -2758,6 +2941,15 @@ export function BotDashboardModule({
|
|||
<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} />
|
||||
|
||||
<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>
|
||||
<select
|
||||
className="select"
|
||||
|
|
@ -2776,83 +2968,6 @@ export function BotDashboardModule({
|
|||
</div>
|
||||
) : 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>
|
||||
<input
|
||||
className="input"
|
||||
|
|
@ -2884,6 +2999,84 @@ export function BotDashboardModule({
|
|||
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="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">
|
||||
<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>
|
||||
|
|
@ -3025,7 +3218,7 @@ export function BotDashboardModule({
|
|||
</LucentIconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wizard-channel-list">
|
||||
<div className="wizard-channel-list ops-skills-list-scroll">
|
||||
{botSkills.length === 0 ? (
|
||||
<div className="ops-empty-inline">{t.skillsEmpty}</div>
|
||||
) : (
|
||||
|
|
@ -3204,7 +3397,7 @@ export function BotDashboardModule({
|
|||
) : cronJobs.length === 0 ? (
|
||||
<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) => {
|
||||
const stopping = cronActionJobId === job.id;
|
||||
const channel = String(job.payload?.channel || '').trim();
|
||||
|
|
@ -3335,13 +3528,13 @@ export function BotDashboardModule({
|
|||
{workspacePreview.isImage ? (
|
||||
<img
|
||||
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'}
|
||||
/>
|
||||
) : workspacePreview.isHtml ? (
|
||||
<iframe
|
||||
className="workspace-preview-embed"
|
||||
src={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}`}
|
||||
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
|
||||
title={workspacePreview.path}
|
||||
/>
|
||||
) : workspacePreview.isMarkdown ? (
|
||||
|
|
@ -3374,7 +3567,7 @@ export function BotDashboardModule({
|
|||
) : (
|
||||
<a
|
||||
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"
|
||||
rel="noopener noreferrer"
|
||||
download={workspacePreview.path.split('/').pop() || 'workspace-file'}
|
||||
|
|
@ -3387,6 +3580,30 @@ export function BotDashboardModule({
|
|||
</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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export function normalizeAssistantMessageText(input: string) {
|
|||
|
||||
// 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(/<\/?tool_call>/gi, '').trim();
|
||||
|
||||
// Keep HTML-enabled markdown readable: cap excessive HTML line breaks/empty paragraphs.
|
||||
text = text.replace(/(?:<br\s*\/?>\s*){4,}/gi, '<br><br>');
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { wizardZhCn } from '../../i18n/wizard.zh-cn';
|
|||
import { wizardEn } from '../../i18n/wizard.en';
|
||||
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
||||
import { setBotAccessPassword } from '../../utils/botAccess';
|
||||
|
||||
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
|
||||
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
|
||||
|
|
@ -96,6 +97,7 @@ const providerPresets: Record<string, { model: string; note: { 'zh-cn': string;
|
|||
const initialForm = {
|
||||
id: '',
|
||||
name: '',
|
||||
access_password: '',
|
||||
llm_provider: 'dashscope',
|
||||
llm_model: providerPresets.dashscope.model,
|
||||
api_key: '',
|
||||
|
|
@ -274,6 +276,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots`, {
|
||||
id: form.id,
|
||||
name: form.name,
|
||||
access_password: form.access_password,
|
||||
llm_provider: form.llm_provider,
|
||||
llm_model: form.llm_model,
|
||||
api_key: form.api_key,
|
||||
|
|
@ -304,6 +307,10 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
env_params: form.env_params,
|
||||
});
|
||||
|
||||
if (String(form.access_password || '').trim()) {
|
||||
setBotAccessPassword(form.id, form.access_password);
|
||||
}
|
||||
|
||||
if (autoStart) {
|
||||
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>
|
||||
<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" 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>
|
||||
<select className="select" value={form.llm_provider} onChange={(e) => onProviderChange(e.target.value)}>
|
||||
<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" 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 }))} />
|
||||
|
||||
<div className="card wizard-note-card" style={{ fontSize: 12, color: 'var(--muted)' }}>
|
||||
{providerPresets[form.llm_provider]?.note[noteLocale]}
|
||||
</div>
|
||||
|
|
@ -610,9 +657,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
{isTestingProvider ? ui.testing : ui.test}
|
||||
</button>
|
||||
{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="slider-row">
|
||||
<label className="field-label">Temperature: {form.temperature.toFixed(2)}</label>
|
||||
|
|
@ -652,44 +697,6 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
</button>
|
||||
))}
|
||||
</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 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>Bot ID: <span className="mono">{form.id}</span></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>{ui.model}: {form.llm_model}</div>
|
||||
<div>Temperature: {form.temperature.toFixed(2)}</div>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export interface BotEvent {
|
|||
export interface BotState {
|
||||
id: string;
|
||||
name: string;
|
||||
access_password?: string;
|
||||
has_access_password?: boolean;
|
||||
avatar_model: string;
|
||||
avatar_skin?: string;
|
||||
docker_status: string;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue