v0.2.0
parent
b8ca934bd1
commit
f20dabc58e
|
|
@ -0,0 +1,124 @@
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from core.database import get_session
|
||||||
|
from models.bot import NanobotImage
|
||||||
|
|
||||||
|
from api.dashboard_router_support import DashboardRouterDeps
|
||||||
|
|
||||||
|
|
||||||
|
def build_dashboard_assets_router(*, deps: DashboardRouterDeps) -> APIRouter:
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/api/images", response_model=List[NanobotImage])
|
||||||
|
def list_images(session: Session = Depends(get_session)):
|
||||||
|
return deps.image_service.list_images(session=session)
|
||||||
|
|
||||||
|
@router.delete("/api/images/{tag:path}")
|
||||||
|
def delete_image(tag: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.image_service.delete_image(session=session, tag=tag)
|
||||||
|
|
||||||
|
@router.get("/api/docker-images")
|
||||||
|
def list_docker_images(repository: str = "nanobot-base"):
|
||||||
|
return deps.image_service.list_docker_images(repository=repository)
|
||||||
|
|
||||||
|
@router.post("/api/images/register")
|
||||||
|
def register_image(payload: dict, session: Session = Depends(get_session)):
|
||||||
|
return deps.image_service.register_image(session=session, payload=payload)
|
||||||
|
|
||||||
|
@router.post("/api/providers/test")
|
||||||
|
async def test_provider(payload: dict):
|
||||||
|
return await deps.provider_test_service.test_provider(payload=payload)
|
||||||
|
|
||||||
|
@router.get("/api/platform/skills")
|
||||||
|
def list_skill_market(session: Session = Depends(get_session)):
|
||||||
|
return deps.skill_service.list_market_items(session=session)
|
||||||
|
|
||||||
|
@router.post("/api/platform/skills")
|
||||||
|
async def create_skill_market_item(
|
||||||
|
skill_key: str = Form(""),
|
||||||
|
display_name: str = Form(""),
|
||||||
|
description: str = Form(""),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
return await deps.skill_service.create_market_item(
|
||||||
|
session=session,
|
||||||
|
skill_key=skill_key,
|
||||||
|
display_name=display_name,
|
||||||
|
description=description,
|
||||||
|
file=file,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("/api/platform/skills/{skill_id}")
|
||||||
|
async def update_skill_market_item(
|
||||||
|
skill_id: int,
|
||||||
|
skill_key: str = Form(""),
|
||||||
|
display_name: str = Form(""),
|
||||||
|
description: str = Form(""),
|
||||||
|
file: Optional[UploadFile] = File(None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
return await deps.skill_service.update_market_item(
|
||||||
|
session=session,
|
||||||
|
skill_id=skill_id,
|
||||||
|
skill_key=skill_key,
|
||||||
|
display_name=display_name,
|
||||||
|
description=description,
|
||||||
|
file=file,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/api/platform/skills/{skill_id}")
|
||||||
|
def delete_skill_market_item(skill_id: int, session: Session = Depends(get_session)):
|
||||||
|
return deps.skill_service.delete_market_item(session=session, skill_id=skill_id)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/skills")
|
||||||
|
def list_bot_skills(bot_id: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.skill_service.list_workspace_skills_for_bot(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
resolve_edge_state_context=deps.resolve_edge_state_context,
|
||||||
|
logger=deps.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/skill-market")
|
||||||
|
def list_bot_skill_market(bot_id: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.skill_service.list_bot_market_items_for_bot(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
resolve_edge_state_context=deps.resolve_edge_state_context,
|
||||||
|
logger=deps.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/skill-market/{skill_id}/install")
|
||||||
|
def install_bot_skill_from_market(bot_id: str, skill_id: int, session: Session = Depends(get_session)):
|
||||||
|
return deps.skill_service.install_market_item_for_bot_checked(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
resolve_edge_state_context=deps.resolve_edge_state_context,
|
||||||
|
logger=deps.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/skills/upload")
|
||||||
|
async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), session: Session = Depends(get_session)):
|
||||||
|
return await deps.skill_service.upload_bot_skill_zip_for_bot(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
file=file,
|
||||||
|
resolve_edge_state_context=deps.resolve_edge_state_context,
|
||||||
|
logger=deps.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/api/bots/{bot_id}/skills/{skill_name}")
|
||||||
|
def delete_bot_skill(bot_id: str, skill_name: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.skill_service.delete_workspace_skill_for_bot(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
skill_name=skill_name,
|
||||||
|
resolve_edge_state_context=deps.resolve_edge_state_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from core.database import get_session
|
||||||
|
from schemas.dashboard import (
|
||||||
|
BotCreateRequest,
|
||||||
|
BotDeployRequest,
|
||||||
|
BotEnvParamsUpdateRequest,
|
||||||
|
BotMcpConfigUpdateRequest,
|
||||||
|
BotToolsConfigUpdateRequest,
|
||||||
|
BotUpdateRequest,
|
||||||
|
ChannelConfigRequest,
|
||||||
|
ChannelConfigUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
from api.dashboard_router_support import DashboardRouterDeps
|
||||||
|
|
||||||
|
|
||||||
|
def build_dashboard_bot_admin_router(*, deps: DashboardRouterDeps) -> APIRouter:
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/api/bots")
|
||||||
|
def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_lifecycle_service.create_bot(session=session, payload=payload)
|
||||||
|
|
||||||
|
@router.get("/api/bots")
|
||||||
|
def list_bots(request: Request, session: Session = Depends(get_session)):
|
||||||
|
current_user_id = int(getattr(request.state, "sys_user_id", 0) or 0)
|
||||||
|
return deps.bot_query_service.list_bots(app_state=request.app.state, session=session, current_user_id=current_user_id)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}")
|
||||||
|
def get_bot_detail(bot_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_query_service.get_bot_detail(app_state=request.app.state, session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/resources")
|
||||||
|
def get_bot_resources(bot_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_query_service.get_bot_resources(app_state=request.app.state, session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.put("/api/bots/{bot_id}")
|
||||||
|
def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_lifecycle_service.update_bot(session=session, bot_id=bot_id, payload=payload)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/deploy")
|
||||||
|
async def deploy_bot(bot_id: str, payload: BotDeployRequest, request: Request, session: Session = Depends(get_session)):
|
||||||
|
return await deps.bot_lifecycle_service.deploy_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
node_id=payload.node_id,
|
||||||
|
runtime_kind=payload.runtime_kind,
|
||||||
|
image_tag=payload.image_tag,
|
||||||
|
auto_start=bool(payload.auto_start),
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/start")
|
||||||
|
async def start_bot(bot_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
return await deps.bot_lifecycle_service.start_bot(app_state=request.app.state, session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/stop")
|
||||||
|
def stop_bot(bot_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_lifecycle_service.stop_bot(app_state=request.app.state, session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/enable")
|
||||||
|
def enable_bot(bot_id: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_lifecycle_service.enable_bot(session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/disable")
|
||||||
|
def disable_bot(bot_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_lifecycle_service.disable_bot(app_state=request.app.state, session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/deactivate")
|
||||||
|
def deactivate_bot(bot_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_lifecycle_service.deactivate_bot(app_state=request.app.state, session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.delete("/api/bots/{bot_id}")
|
||||||
|
def delete_bot(bot_id: str, request: Request, delete_workspace: bool = True, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_lifecycle_service.delete_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
delete_workspace=delete_workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/channels")
|
||||||
|
def list_bot_channels(bot_id: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_channel_service.list_channels(session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/channels")
|
||||||
|
def create_bot_channel(bot_id: str, payload: ChannelConfigRequest, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_channel_service.create_channel(session=session, bot_id=bot_id, payload=payload)
|
||||||
|
|
||||||
|
@router.put("/api/bots/{bot_id}/channels/{channel_id}")
|
||||||
|
def update_bot_channel(bot_id: str, channel_id: str, payload: ChannelConfigUpdateRequest, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_channel_service.update_channel(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
channel_id=channel_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/api/bots/{bot_id}/channels/{channel_id}")
|
||||||
|
def delete_bot_channel(bot_id: str, channel_id: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_channel_service.delete_channel(session=session, bot_id=bot_id, channel_id=channel_id)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/tools-config")
|
||||||
|
def get_bot_tools_config(bot_id: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_query_service.get_tools_config(session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.put("/api/bots/{bot_id}/tools-config")
|
||||||
|
def update_bot_tools_config(bot_id: str, payload: BotToolsConfigUpdateRequest, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_query_service.update_tools_config(session=session, bot_id=bot_id, payload=payload)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/mcp-config")
|
||||||
|
def get_bot_mcp_config(bot_id: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_config_state_service.get_mcp_config_for_bot(session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.put("/api/bots/{bot_id}/mcp-config")
|
||||||
|
def update_bot_mcp_config(bot_id: str, payload: BotMcpConfigUpdateRequest, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_config_state_service.update_mcp_config_for_bot(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
mcp_servers=payload.mcp_servers,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/env-params")
|
||||||
|
def get_bot_env_params(bot_id: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_config_state_service.get_env_params_for_bot(session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.put("/api/bots/{bot_id}/env-params")
|
||||||
|
def update_bot_env_params(bot_id: str, payload: BotEnvParamsUpdateRequest, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_config_state_service.update_env_params_for_bot(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
env_params=payload.env_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/cron/jobs")
|
||||||
|
def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_config_state_service.list_cron_jobs_for_bot(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
include_disabled=include_disabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/stop")
|
||||||
|
def stop_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_config_state_service.stop_cron_job_for_bot(session=session, bot_id=bot_id, job_id=job_id)
|
||||||
|
|
||||||
|
@router.delete("/api/bots/{bot_id}/cron/jobs/{job_id}")
|
||||||
|
def delete_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_config_state_service.delete_cron_job_for_bot(session=session, bot_id=bot_id, job_id=job_id)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile, WebSocket
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from core.database import get_session
|
||||||
|
from schemas.dashboard import (
|
||||||
|
CommandRequest,
|
||||||
|
MessageFeedbackRequest,
|
||||||
|
WorkspaceFileUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
from api.dashboard_router_support import DashboardRouterDeps
|
||||||
|
|
||||||
|
|
||||||
|
def build_dashboard_bot_io_router(*, deps: DashboardRouterDeps) -> APIRouter:
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/command")
|
||||||
|
def send_command(bot_id: str, payload: CommandRequest, request: Request, session: Session = Depends(get_session)):
|
||||||
|
return deps.runtime_service.send_command_for_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/messages")
|
||||||
|
def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_message_service.list_messages(session=session, bot_id=bot_id, limit=limit)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/messages/page")
|
||||||
|
def list_bot_messages_page(bot_id: str, limit: Optional[int] = None, before_id: Optional[int] = None, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_message_service.list_messages_page(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
limit=limit,
|
||||||
|
before_id=before_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/messages/by-date")
|
||||||
|
def list_bot_messages_by_date(
|
||||||
|
bot_id: str,
|
||||||
|
date: str,
|
||||||
|
tz_offset_minutes: Optional[int] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
return deps.bot_message_service.list_messages_by_date(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
date=date,
|
||||||
|
tz_offset_minutes=tz_offset_minutes,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("/api/bots/{bot_id}/messages/{message_id}/feedback")
|
||||||
|
def update_bot_message_feedback(bot_id: str, message_id: int, payload: MessageFeedbackRequest, session: Session = Depends(get_session)):
|
||||||
|
return deps.bot_message_service.update_feedback(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
message_id=message_id,
|
||||||
|
feedback=payload.feedback,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/api/bots/{bot_id}/messages")
|
||||||
|
def clear_bot_messages(bot_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
return deps.runtime_service.clear_messages_for_bot(app_state=request.app.state, session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/sessions/dashboard-direct/clear")
|
||||||
|
def clear_bot_dashboard_direct_session(bot_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
return deps.runtime_service.clear_dashboard_direct_session_for_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/logs")
|
||||||
|
def get_bot_logs(bot_id: str, tail: int = 300, request: Request = None, session: Session = Depends(get_session)):
|
||||||
|
return deps.runtime_service.get_logs_for_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
tail=tail,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/workspace/tree")
|
||||||
|
def get_workspace_tree(bot_id: str, path: Optional[str] = None, recursive: bool = False, request: Request = None, session: Session = Depends(get_session)):
|
||||||
|
return deps.workspace_service.list_tree_for_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
path=path,
|
||||||
|
recursive=recursive,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/workspace/file")
|
||||||
|
def read_workspace_file(bot_id: str, path: str, max_bytes: int = 200000, request: Request = None, session: Session = Depends(get_session)):
|
||||||
|
return deps.workspace_service.read_file_for_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
path=path,
|
||||||
|
max_bytes=max_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("/api/bots/{bot_id}/workspace/file")
|
||||||
|
def update_workspace_file(bot_id: str, path: str, payload: WorkspaceFileUpdateRequest, request: Request = None, session: Session = Depends(get_session)):
|
||||||
|
return deps.workspace_service.write_markdown_for_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
path=path,
|
||||||
|
content=str(payload.content or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/workspace/download")
|
||||||
|
def download_workspace_file(bot_id: str, path: str, download: bool = False, request: Request = None, session: Session = Depends(get_session)):
|
||||||
|
return deps.workspace_service.serve_file_for_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
path=path,
|
||||||
|
download=download,
|
||||||
|
request=request,
|
||||||
|
public=False,
|
||||||
|
redirect_html_to_raw=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/public/bots/{bot_id}/workspace/download")
|
||||||
|
def public_download_workspace_file(bot_id: str, path: str, download: bool = False, request: Request = None, session: Session = Depends(get_session)):
|
||||||
|
return deps.workspace_service.serve_file_for_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
path=path,
|
||||||
|
download=download,
|
||||||
|
request=request,
|
||||||
|
public=True,
|
||||||
|
redirect_html_to_raw=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/workspace/raw/{path:path}")
|
||||||
|
def raw_workspace_file(bot_id: str, path: str, download: bool = False, request: Request = None, session: Session = Depends(get_session)):
|
||||||
|
return deps.workspace_service.serve_file_for_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
path=path,
|
||||||
|
download=download,
|
||||||
|
request=request,
|
||||||
|
public=False,
|
||||||
|
redirect_html_to_raw=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/public/bots/{bot_id}/workspace/raw/{path:path}")
|
||||||
|
def public_raw_workspace_file(bot_id: str, path: str, download: bool = False, request: Request = None, session: Session = Depends(get_session)):
|
||||||
|
return deps.workspace_service.serve_file_for_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
path=path,
|
||||||
|
download=download,
|
||||||
|
request=request,
|
||||||
|
public=True,
|
||||||
|
redirect_html_to_raw=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/workspace/upload")
|
||||||
|
async def upload_workspace_files(bot_id: str, files: List[UploadFile] = File(...), path: Optional[str] = None, request: Request = None, session: Session = Depends(get_session)):
|
||||||
|
return await deps.workspace_service.upload_files_for_bot(
|
||||||
|
app_state=request.app.state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
files=files,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/speech/transcribe")
|
||||||
|
async def transcribe_bot_speech(
|
||||||
|
bot_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
language: Optional[str] = Form(None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
return await deps.speech_transcription_service.transcribe(
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
file=file,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.websocket("/ws/monitor/{bot_id}")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket, bot_id: str):
|
||||||
|
await deps.app_lifecycle_service.handle_websocket(websocket, bot_id)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from api.dashboard_assets_router import build_dashboard_assets_router
|
||||||
|
from api.dashboard_bot_admin_router import build_dashboard_bot_admin_router
|
||||||
|
from api.dashboard_bot_io_router import build_dashboard_bot_io_router
|
||||||
|
from api.dashboard_router_support import DashboardRouterDeps
|
||||||
|
|
||||||
|
|
||||||
|
def build_dashboard_router(
|
||||||
|
*,
|
||||||
|
image_service,
|
||||||
|
provider_test_service,
|
||||||
|
bot_lifecycle_service,
|
||||||
|
bot_query_service,
|
||||||
|
bot_channel_service,
|
||||||
|
skill_service,
|
||||||
|
bot_config_state_service,
|
||||||
|
runtime_service,
|
||||||
|
bot_message_service,
|
||||||
|
workspace_service,
|
||||||
|
speech_transcription_service,
|
||||||
|
app_lifecycle_service,
|
||||||
|
resolve_edge_state_context,
|
||||||
|
logger,
|
||||||
|
) -> APIRouter:
|
||||||
|
deps = DashboardRouterDeps(
|
||||||
|
image_service=image_service,
|
||||||
|
provider_test_service=provider_test_service,
|
||||||
|
bot_lifecycle_service=bot_lifecycle_service,
|
||||||
|
bot_query_service=bot_query_service,
|
||||||
|
bot_channel_service=bot_channel_service,
|
||||||
|
skill_service=skill_service,
|
||||||
|
bot_config_state_service=bot_config_state_service,
|
||||||
|
runtime_service=runtime_service,
|
||||||
|
bot_message_service=bot_message_service,
|
||||||
|
workspace_service=workspace_service,
|
||||||
|
speech_transcription_service=speech_transcription_service,
|
||||||
|
app_lifecycle_service=app_lifecycle_service,
|
||||||
|
resolve_edge_state_context=resolve_edge_state_context,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(build_dashboard_assets_router(deps=deps))
|
||||||
|
router.include_router(build_dashboard_bot_admin_router(deps=deps))
|
||||||
|
router.include_router(build_dashboard_bot_io_router(deps=deps))
|
||||||
|
return router
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DashboardRouterDeps:
|
||||||
|
image_service: Any
|
||||||
|
provider_test_service: Any
|
||||||
|
bot_lifecycle_service: Any
|
||||||
|
bot_query_service: Any
|
||||||
|
bot_channel_service: Any
|
||||||
|
skill_service: Any
|
||||||
|
bot_config_state_service: Any
|
||||||
|
runtime_service: Any
|
||||||
|
bot_message_service: Any
|
||||||
|
workspace_service: Any
|
||||||
|
speech_transcription_service: Any
|
||||||
|
app_lifecycle_service: Any
|
||||||
|
resolve_edge_state_context: Callable[[str], Any]
|
||||||
|
logger: Any
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from api.platform_overview_router import router as platform_overview_router
|
||||||
|
from api.platform_settings_router import router as platform_settings_router
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(platform_overview_router)
|
||||||
|
router.include_router(platform_settings_router)
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from core.database import get_session
|
||||||
|
from models.bot import BotInstance
|
||||||
|
from providers.target import ProviderTarget
|
||||||
|
from services.node_registry_service import ManagedNode
|
||||||
|
|
||||||
|
from api.platform_node_support import (
|
||||||
|
edge_node_self_with_native_preflight,
|
||||||
|
managed_node_from_payload,
|
||||||
|
normalize_node_payload,
|
||||||
|
serialize_node,
|
||||||
|
)
|
||||||
|
from api.platform_shared import (
|
||||||
|
cached_platform_nodes_payload,
|
||||||
|
invalidate_platform_nodes_cache,
|
||||||
|
invalidate_platform_overview_cache,
|
||||||
|
logger,
|
||||||
|
store_platform_nodes_payload,
|
||||||
|
)
|
||||||
|
from clients.edge.errors import log_edge_failure
|
||||||
|
from schemas.platform import ManagedNodePayload
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/platform/nodes")
|
||||||
|
def list_platform_nodes(request: Request, session: Session = Depends(get_session)):
|
||||||
|
cached_payload = cached_platform_nodes_payload()
|
||||||
|
if cached_payload is not None:
|
||||||
|
return cached_payload
|
||||||
|
|
||||||
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
||||||
|
if node_registry is None or not hasattr(node_registry, "list_nodes"):
|
||||||
|
return {"items": []}
|
||||||
|
resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None)
|
||||||
|
refreshed_items = []
|
||||||
|
for node in node_registry.list_nodes():
|
||||||
|
metadata = dict(node.metadata or {})
|
||||||
|
refresh_failed = False
|
||||||
|
if callable(resolve_edge_client) and str(metadata.get("transport_kind") or "").strip().lower() == "edge" and bool(node.enabled):
|
||||||
|
try:
|
||||||
|
client = resolve_edge_client(
|
||||||
|
ProviderTarget(
|
||||||
|
node_id=node.node_id,
|
||||||
|
transport_kind="edge",
|
||||||
|
runtime_kind=str(metadata.get("runtime_kind") or "docker"),
|
||||||
|
core_adapter=str(metadata.get("core_adapter") or "nanobot"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
node_self = edge_node_self_with_native_preflight(client=client, node=node)
|
||||||
|
node = node_registry.mark_node_seen(
|
||||||
|
session,
|
||||||
|
node_id=node.node_id,
|
||||||
|
display_name=str(node.display_name or node_self.get("display_name") or node.node_id),
|
||||||
|
capabilities=dict(node_self.get("capabilities") or {}),
|
||||||
|
resources=dict(node_self.get("resources") or {}),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
refresh_failed = True
|
||||||
|
log_edge_failure(
|
||||||
|
logger,
|
||||||
|
key=f"platform-node-refresh:{node.node_id}",
|
||||||
|
exc=exc,
|
||||||
|
message=f"Failed to refresh edge node metadata for node_id={node.node_id}",
|
||||||
|
)
|
||||||
|
refreshed_items.append((node, refresh_failed))
|
||||||
|
return store_platform_nodes_payload([
|
||||||
|
serialize_node(node, refresh_failed=refresh_failed)
|
||||||
|
for node, refresh_failed in refreshed_items
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/platform/nodes/{node_id}")
|
||||||
|
def get_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
normalized_node_id = str(node_id or "").strip().lower()
|
||||||
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
||||||
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
||||||
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
||||||
|
node = node_registry.get_node(normalized_node_id)
|
||||||
|
if node is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
||||||
|
return serialize_node(node)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/platform/nodes")
|
||||||
|
def create_platform_node(payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)):
|
||||||
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
||||||
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
||||||
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
||||||
|
normalized = normalize_node_payload(payload)
|
||||||
|
if node_registry.get_node(normalized.node_id) is not None:
|
||||||
|
raise HTTPException(status_code=409, detail=f"Node already exists: {normalized.node_id}")
|
||||||
|
node = node_registry.upsert_node(session, managed_node_from_payload(normalized))
|
||||||
|
invalidate_platform_overview_cache()
|
||||||
|
invalidate_platform_nodes_cache()
|
||||||
|
return serialize_node(node)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/platform/nodes/{node_id}")
|
||||||
|
def update_platform_node(node_id: str, payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)):
|
||||||
|
normalized_node_id = str(node_id or "").strip().lower()
|
||||||
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
||||||
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
||||||
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
||||||
|
existing = node_registry.get_node(normalized_node_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
||||||
|
normalized = normalize_node_payload(payload)
|
||||||
|
if normalized.node_id != normalized_node_id:
|
||||||
|
raise HTTPException(status_code=400, detail="node_id cannot be changed")
|
||||||
|
node = node_registry.upsert_node(
|
||||||
|
session,
|
||||||
|
ManagedNode(
|
||||||
|
node_id=normalized_node_id,
|
||||||
|
display_name=normalized.display_name,
|
||||||
|
base_url=normalized.base_url,
|
||||||
|
enabled=bool(normalized.enabled),
|
||||||
|
auth_token=normalized.auth_token or existing.auth_token,
|
||||||
|
metadata={
|
||||||
|
"transport_kind": normalized.transport_kind,
|
||||||
|
"runtime_kind": normalized.runtime_kind,
|
||||||
|
"core_adapter": normalized.core_adapter,
|
||||||
|
"workspace_root": normalized.workspace_root,
|
||||||
|
"native_command": normalized.native_command,
|
||||||
|
"native_workdir": normalized.native_workdir,
|
||||||
|
"native_sandbox_mode": normalized.native_sandbox_mode,
|
||||||
|
},
|
||||||
|
capabilities=dict(existing.capabilities or {}),
|
||||||
|
resources=dict(existing.resources or {}),
|
||||||
|
last_seen_at=existing.last_seen_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
invalidate_platform_overview_cache()
|
||||||
|
invalidate_platform_nodes_cache()
|
||||||
|
return serialize_node(node)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/platform/nodes/{node_id}")
|
||||||
|
def delete_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
normalized_node_id = str(node_id or "").strip().lower()
|
||||||
|
if normalized_node_id == "local":
|
||||||
|
raise HTTPException(status_code=400, detail="Local node cannot be deleted")
|
||||||
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
||||||
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
||||||
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
||||||
|
if node_registry.get_node(normalized_node_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
||||||
|
attached_bot_ids = session.exec(select(BotInstance.id).where(BotInstance.node_id == normalized_node_id)).all()
|
||||||
|
if attached_bot_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Node {normalized_node_id} still has bots assigned: {', '.join(str(item) for item in attached_bot_ids[:5])}",
|
||||||
|
)
|
||||||
|
node_registry.delete_node(session, normalized_node_id)
|
||||||
|
invalidate_platform_overview_cache()
|
||||||
|
invalidate_platform_nodes_cache()
|
||||||
|
return {"status": "deleted", "node_id": normalized_node_id}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from clients.edge.http import HttpEdgeClient
|
||||||
|
from core.database import get_session
|
||||||
|
from schemas.platform import ManagedNodePayload
|
||||||
|
|
||||||
|
from api.platform_node_support import (
|
||||||
|
managed_node_from_payload,
|
||||||
|
normalize_node_payload,
|
||||||
|
test_edge_connectivity,
|
||||||
|
test_edge_native_preflight,
|
||||||
|
)
|
||||||
|
from api.platform_shared import invalidate_platform_nodes_cache
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/platform/nodes/test")
|
||||||
|
def test_platform_node(payload: ManagedNodePayload, request: Request):
|
||||||
|
normalized = normalize_node_payload(payload)
|
||||||
|
temp_node = managed_node_from_payload(normalized)
|
||||||
|
result = test_edge_connectivity(
|
||||||
|
lambda _target: HttpEdgeClient(
|
||||||
|
node=temp_node,
|
||||||
|
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
||||||
|
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
||||||
|
),
|
||||||
|
temp_node,
|
||||||
|
)
|
||||||
|
return result.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/platform/nodes/native/preflight")
|
||||||
|
def test_platform_node_native_preflight(payload: ManagedNodePayload, request: Request):
|
||||||
|
normalized = normalize_node_payload(payload)
|
||||||
|
temp_node = managed_node_from_payload(normalized)
|
||||||
|
result = test_edge_native_preflight(
|
||||||
|
lambda _target: HttpEdgeClient(
|
||||||
|
node=temp_node,
|
||||||
|
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
||||||
|
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
||||||
|
),
|
||||||
|
temp_node,
|
||||||
|
native_command=str(normalized.native_command or "").strip() or None,
|
||||||
|
native_workdir=str(normalized.native_workdir or "").strip() or None,
|
||||||
|
)
|
||||||
|
return result.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/platform/nodes/{node_id}/test")
|
||||||
|
def test_saved_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
normalized_node_id = str(node_id or "").strip().lower()
|
||||||
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
||||||
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
||||||
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
||||||
|
node = node_registry.get_node(normalized_node_id)
|
||||||
|
if node is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
||||||
|
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
|
||||||
|
if transport_kind != "edge":
|
||||||
|
invalidate_platform_nodes_cache()
|
||||||
|
raise HTTPException(status_code=400, detail="Only edge transport is supported")
|
||||||
|
result = test_edge_connectivity(
|
||||||
|
lambda _target: HttpEdgeClient(
|
||||||
|
node=node,
|
||||||
|
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
||||||
|
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
||||||
|
),
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
if result.ok:
|
||||||
|
node_registry.mark_node_seen(
|
||||||
|
session,
|
||||||
|
node_id=node.node_id,
|
||||||
|
display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id) if result.node_self else node.display_name,
|
||||||
|
capabilities=dict(result.node_self.get("capabilities") or {}) if result.node_self else dict(node.capabilities or {}),
|
||||||
|
resources=dict(result.node_self.get("resources") or {}) if result.node_self else dict(getattr(node, "resources", {}) or {}),
|
||||||
|
)
|
||||||
|
invalidate_platform_nodes_cache()
|
||||||
|
return result.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/platform/nodes/{node_id}/native/preflight")
|
||||||
|
def test_saved_platform_node_native_preflight(node_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
normalized_node_id = str(node_id or "").strip().lower()
|
||||||
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
||||||
|
if node_registry is None or not hasattr(node_registry, "get_node"):
|
||||||
|
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
||||||
|
node = node_registry.get_node(normalized_node_id)
|
||||||
|
if node is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
||||||
|
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
|
||||||
|
if transport_kind != "edge":
|
||||||
|
invalidate_platform_nodes_cache()
|
||||||
|
raise HTTPException(status_code=400, detail="Only edge transport is supported")
|
||||||
|
metadata = dict(node.metadata or {})
|
||||||
|
result = test_edge_native_preflight(
|
||||||
|
lambda _target: HttpEdgeClient(
|
||||||
|
node=node,
|
||||||
|
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
||||||
|
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
||||||
|
),
|
||||||
|
node,
|
||||||
|
native_command=str(metadata.get("native_command") or "").strip() or None,
|
||||||
|
native_workdir=str(metadata.get("native_workdir") or "").strip() or None,
|
||||||
|
)
|
||||||
|
if result.status == "online" and result.node_self:
|
||||||
|
node_registry.mark_node_seen(
|
||||||
|
session,
|
||||||
|
node_id=node.node_id,
|
||||||
|
display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id),
|
||||||
|
capabilities=dict(result.node_self.get("capabilities") or {}),
|
||||||
|
resources=dict(result.node_self.get("resources") or {}),
|
||||||
|
)
|
||||||
|
invalidate_platform_nodes_cache()
|
||||||
|
return result.model_dump()
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from clients.edge.errors import log_edge_failure
|
||||||
|
from core.database import get_session
|
||||||
|
from providers.selector import get_runtime_provider
|
||||||
|
from providers.target import ProviderTarget
|
||||||
|
from services.platform_overview_service import build_node_resource_overview
|
||||||
|
|
||||||
|
from api.platform_shared import logger
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/platform/nodes/{node_id}/resources")
|
||||||
|
def get_platform_node_resources(node_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
normalized_node_id = str(node_id or "").strip().lower()
|
||||||
|
node_registry = getattr(request.app.state, "node_registry_service", None)
|
||||||
|
if node_registry is not None and hasattr(node_registry, "get_node"):
|
||||||
|
node = node_registry.get_node(normalized_node_id)
|
||||||
|
if node is not None:
|
||||||
|
metadata = dict(getattr(node, "metadata", {}) or {})
|
||||||
|
if str(metadata.get("transport_kind") or "").strip().lower() == "edge":
|
||||||
|
resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None)
|
||||||
|
if callable(resolve_edge_client):
|
||||||
|
base = build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=None)
|
||||||
|
client = resolve_edge_client(
|
||||||
|
ProviderTarget(
|
||||||
|
node_id=normalized_node_id,
|
||||||
|
transport_kind="edge",
|
||||||
|
runtime_kind=str(metadata.get("runtime_kind") or "docker"),
|
||||||
|
core_adapter=str(metadata.get("core_adapter") or "nanobot"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resource_report = dict(client.get_node_resources() or {})
|
||||||
|
except Exception as exc:
|
||||||
|
log_edge_failure(
|
||||||
|
logger,
|
||||||
|
key=f"platform-node-resources:{normalized_node_id}",
|
||||||
|
exc=exc,
|
||||||
|
message=f"Failed to load edge node resources for node_id={normalized_node_id}",
|
||||||
|
)
|
||||||
|
return base
|
||||||
|
base["resources"] = dict(resource_report.get("resources") or resource_report)
|
||||||
|
if resource_report:
|
||||||
|
base["node_report"] = resource_report
|
||||||
|
return base
|
||||||
|
|
||||||
|
def _read_runtime(bot):
|
||||||
|
provider = get_runtime_provider(request.app.state, bot)
|
||||||
|
status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
|
||||||
|
runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {})
|
||||||
|
runtime.setdefault("docker_status", status)
|
||||||
|
return status, runtime
|
||||||
|
|
||||||
|
return build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=_read_runtime)
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
import shlex
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from clients.edge.errors import log_edge_failure, summarize_edge_exception
|
||||||
|
from clients.edge.http import HttpEdgeClient
|
||||||
|
from providers.target import ProviderTarget
|
||||||
|
from schemas.platform import (
|
||||||
|
ManagedNodeConnectivityResult,
|
||||||
|
ManagedNodeNativePreflightResult,
|
||||||
|
ManagedNodePayload,
|
||||||
|
)
|
||||||
|
from services.node_registry_service import ManagedNode
|
||||||
|
|
||||||
|
from api.platform_shared import logger
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_native_sandbox_mode(raw_value: Any) -> str:
|
||||||
|
text = str(raw_value or "").strip().lower()
|
||||||
|
if text in {"workspace", "sandbox", "strict"}:
|
||||||
|
return "workspace"
|
||||||
|
if text in {"full_access", "full-access", "danger-full-access", "escape"}:
|
||||||
|
return "full_access"
|
||||||
|
return "inherit"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_node_payload(payload: ManagedNodePayload) -> ManagedNodePayload:
|
||||||
|
normalized_node_id = str(payload.node_id or "").strip().lower()
|
||||||
|
if not normalized_node_id:
|
||||||
|
raise HTTPException(status_code=400, detail="node_id is required")
|
||||||
|
transport_kind = str(payload.transport_kind or "edge").strip().lower() or "edge"
|
||||||
|
if transport_kind != "edge":
|
||||||
|
raise HTTPException(status_code=400, detail="Only edge transport is supported")
|
||||||
|
runtime_kind = str(payload.runtime_kind or "docker").strip().lower() or "docker"
|
||||||
|
core_adapter = str(payload.core_adapter or "nanobot").strip().lower() or "nanobot"
|
||||||
|
native_sandbox_mode = normalize_native_sandbox_mode(payload.native_sandbox_mode)
|
||||||
|
base_url = str(payload.base_url or "").strip()
|
||||||
|
if transport_kind == "edge" and not base_url:
|
||||||
|
raise HTTPException(status_code=400, detail="base_url is required for edge nodes")
|
||||||
|
return payload.model_copy(
|
||||||
|
update={
|
||||||
|
"node_id": normalized_node_id,
|
||||||
|
"display_name": str(payload.display_name or normalized_node_id).strip() or normalized_node_id,
|
||||||
|
"base_url": base_url,
|
||||||
|
"auth_token": str(payload.auth_token or "").strip(),
|
||||||
|
"transport_kind": transport_kind,
|
||||||
|
"runtime_kind": runtime_kind,
|
||||||
|
"core_adapter": core_adapter,
|
||||||
|
"workspace_root": str(payload.workspace_root or "").strip(),
|
||||||
|
"native_command": str(payload.native_command or "").strip(),
|
||||||
|
"native_workdir": str(payload.native_workdir or "").strip(),
|
||||||
|
"native_sandbox_mode": native_sandbox_mode,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def managed_node_from_payload(payload: ManagedNodePayload) -> ManagedNode:
|
||||||
|
normalized = normalize_node_payload(payload)
|
||||||
|
return ManagedNode(
|
||||||
|
node_id=normalized.node_id,
|
||||||
|
display_name=normalized.display_name,
|
||||||
|
base_url=normalized.base_url,
|
||||||
|
enabled=bool(normalized.enabled),
|
||||||
|
auth_token=normalized.auth_token,
|
||||||
|
metadata={
|
||||||
|
"transport_kind": normalized.transport_kind,
|
||||||
|
"runtime_kind": normalized.runtime_kind,
|
||||||
|
"core_adapter": normalized.core_adapter,
|
||||||
|
"workspace_root": normalized.workspace_root,
|
||||||
|
"native_command": normalized.native_command,
|
||||||
|
"native_workdir": normalized.native_workdir,
|
||||||
|
"native_sandbox_mode": normalized.native_sandbox_mode,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def node_status(node: ManagedNode, *, refresh_failed: bool = False) -> str:
|
||||||
|
if not bool(node.enabled):
|
||||||
|
return "disabled"
|
||||||
|
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
|
||||||
|
if transport_kind != "edge":
|
||||||
|
return "unknown"
|
||||||
|
if refresh_failed:
|
||||||
|
return "offline"
|
||||||
|
return "online" if node.last_seen_at else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_node(node: ManagedNode, *, refresh_failed: bool = False) -> Dict[str, Any]:
|
||||||
|
metadata = dict(node.metadata or {})
|
||||||
|
return {
|
||||||
|
"node_id": node.node_id,
|
||||||
|
"display_name": node.display_name,
|
||||||
|
"base_url": node.base_url,
|
||||||
|
"enabled": bool(node.enabled),
|
||||||
|
"transport_kind": str(metadata.get("transport_kind") or ""),
|
||||||
|
"runtime_kind": str(metadata.get("runtime_kind") or ""),
|
||||||
|
"core_adapter": str(metadata.get("core_adapter") or ""),
|
||||||
|
"workspace_root": str(metadata.get("workspace_root") or ""),
|
||||||
|
"native_command": str(metadata.get("native_command") or ""),
|
||||||
|
"native_workdir": str(metadata.get("native_workdir") or ""),
|
||||||
|
"native_sandbox_mode": str(metadata.get("native_sandbox_mode") or "inherit"),
|
||||||
|
"metadata": metadata,
|
||||||
|
"capabilities": dict(node.capabilities or {}),
|
||||||
|
"resources": dict(getattr(node, "resources", {}) or {}),
|
||||||
|
"last_seen_at": node.last_seen_at,
|
||||||
|
"status": node_status(node, refresh_failed=refresh_failed),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def split_native_command(raw_command: Optional[str]) -> list[str]:
|
||||||
|
text = str(raw_command or "").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return [str(item or "").strip() for item in shlex.split(text) if str(item or "").strip()]
|
||||||
|
except Exception:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_native_supported(node_self: Dict[str, Any]) -> bool:
|
||||||
|
capabilities = dict(node_self.get("capabilities") or {})
|
||||||
|
runtime_caps = dict(capabilities.get("runtime") or {})
|
||||||
|
return bool(runtime_caps.get("native") is True)
|
||||||
|
|
||||||
|
|
||||||
|
def edge_node_self_with_native_preflight(*, client: HttpEdgeClient, node: ManagedNode) -> Dict[str, Any]:
|
||||||
|
node_self = dict(client.heartbeat_node() or {})
|
||||||
|
metadata = dict(node.metadata or {})
|
||||||
|
native_command = str(metadata.get("native_command") or "").strip() or None
|
||||||
|
native_workdir = str(metadata.get("native_workdir") or "").strip() or None
|
||||||
|
runtime_kind = str(metadata.get("runtime_kind") or "docker").strip().lower()
|
||||||
|
should_probe = bool(native_command or native_workdir or runtime_kind == "native")
|
||||||
|
if not should_probe:
|
||||||
|
return node_self
|
||||||
|
try:
|
||||||
|
preflight = dict(client.preflight_native(native_command=native_command, native_workdir=native_workdir) or {})
|
||||||
|
except Exception as exc:
|
||||||
|
log_edge_failure(
|
||||||
|
logger,
|
||||||
|
key=f"platform-node-native-preflight:{node.node_id}",
|
||||||
|
exc=exc,
|
||||||
|
message=f"Failed to run native preflight for node_id={node.node_id}",
|
||||||
|
)
|
||||||
|
return node_self
|
||||||
|
caps = dict(node_self.get("capabilities") or {})
|
||||||
|
process_caps = dict(caps.get("process") or {})
|
||||||
|
if preflight.get("command"):
|
||||||
|
process_caps["command"] = list(preflight.get("command") or [])
|
||||||
|
process_caps["available"] = bool(preflight.get("ok"))
|
||||||
|
process_caps["command_available"] = bool(preflight.get("command_available"))
|
||||||
|
process_caps["workdir_exists"] = bool(preflight.get("workdir_exists"))
|
||||||
|
process_caps["workdir"] = str(preflight.get("workdir") or "")
|
||||||
|
process_caps["detail"] = str(preflight.get("detail") or "")
|
||||||
|
caps["process"] = process_caps
|
||||||
|
node_self["capabilities"] = caps
|
||||||
|
node_self["native_preflight"] = preflight
|
||||||
|
return node_self
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_connectivity(resolve_edge_client, node: ManagedNode) -> ManagedNodeConnectivityResult:
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
client = resolve_edge_client(
|
||||||
|
ProviderTarget(
|
||||||
|
node_id=node.node_id,
|
||||||
|
transport_kind="edge",
|
||||||
|
runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"),
|
||||||
|
core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
node_self = edge_node_self_with_native_preflight(client=client, node=node)
|
||||||
|
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
||||||
|
return ManagedNodeConnectivityResult(
|
||||||
|
ok=True,
|
||||||
|
status="online",
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
detail="dashboard-edge reachable",
|
||||||
|
node_self=node_self,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
||||||
|
return ManagedNodeConnectivityResult(
|
||||||
|
ok=False,
|
||||||
|
status="offline",
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
detail=summarize_edge_exception(exc),
|
||||||
|
node_self=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_native_preflight(
|
||||||
|
resolve_edge_client,
|
||||||
|
node: ManagedNode,
|
||||||
|
*,
|
||||||
|
native_command: Optional[str] = None,
|
||||||
|
native_workdir: Optional[str] = None,
|
||||||
|
) -> ManagedNodeNativePreflightResult:
|
||||||
|
started = time.perf_counter()
|
||||||
|
command_hint = split_native_command(native_command)
|
||||||
|
workdir_hint = str(native_workdir or "").strip()
|
||||||
|
try:
|
||||||
|
client = resolve_edge_client(
|
||||||
|
ProviderTarget(
|
||||||
|
node_id=node.node_id,
|
||||||
|
transport_kind="edge",
|
||||||
|
runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"),
|
||||||
|
core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
node_self = dict(client.heartbeat_node() or {})
|
||||||
|
preflight = dict(
|
||||||
|
client.preflight_native(
|
||||||
|
native_command=native_command,
|
||||||
|
native_workdir=native_workdir,
|
||||||
|
) or {}
|
||||||
|
)
|
||||||
|
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
||||||
|
command = [str(item or "").strip() for item in list(preflight.get("command") or []) if str(item or "").strip()]
|
||||||
|
workdir = str(preflight.get("workdir") or "")
|
||||||
|
detail = str(preflight.get("detail") or "")
|
||||||
|
if not detail:
|
||||||
|
detail = "native launcher ready" if bool(preflight.get("ok")) else "native launcher not ready"
|
||||||
|
return ManagedNodeNativePreflightResult(
|
||||||
|
ok=bool(preflight.get("ok")),
|
||||||
|
status="online",
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
detail=detail,
|
||||||
|
command=command,
|
||||||
|
workdir=workdir,
|
||||||
|
command_available=bool(preflight.get("command_available")),
|
||||||
|
workdir_exists=bool(preflight.get("workdir_exists")),
|
||||||
|
runtime_native_supported=runtime_native_supported(node_self),
|
||||||
|
node_self=node_self,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
||||||
|
return ManagedNodeNativePreflightResult(
|
||||||
|
ok=False,
|
||||||
|
status="offline",
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
detail=summarize_edge_exception(exc),
|
||||||
|
command=command_hint,
|
||||||
|
workdir=workdir_hint,
|
||||||
|
command_available=False,
|
||||||
|
workdir_exists=False if workdir_hint else True,
|
||||||
|
runtime_native_supported=False,
|
||||||
|
node_self=None,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from api.platform_node_catalog_router import router as platform_node_catalog_router
|
||||||
|
from api.platform_node_probe_router import router as platform_node_probe_router
|
||||||
|
from api.platform_node_resource_router import router as platform_node_resource_router
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(platform_node_catalog_router)
|
||||||
|
router.include_router(platform_node_probe_router)
|
||||||
|
router.include_router(platform_node_resource_router)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from api.platform_shared import (
|
||||||
|
apply_platform_runtime_changes,
|
||||||
|
cached_platform_overview_payload,
|
||||||
|
invalidate_platform_nodes_cache,
|
||||||
|
invalidate_platform_overview_cache,
|
||||||
|
store_platform_overview_payload,
|
||||||
|
)
|
||||||
|
from core.database import get_session
|
||||||
|
from providers.selector import get_runtime_provider
|
||||||
|
from services.platform_activity_service import list_activity_events
|
||||||
|
from services.platform_analytics_service import build_dashboard_analytics
|
||||||
|
from services.platform_overview_service import build_platform_overview
|
||||||
|
from services.platform_usage_service import list_usage
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/platform/overview")
|
||||||
|
def get_platform_overview(request: Request, session: Session = Depends(get_session)):
|
||||||
|
cached_payload = cached_platform_overview_payload()
|
||||||
|
if cached_payload is not None:
|
||||||
|
return cached_payload
|
||||||
|
|
||||||
|
def _read_runtime(bot):
|
||||||
|
provider = get_runtime_provider(request.app.state, bot)
|
||||||
|
status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
|
||||||
|
runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {})
|
||||||
|
runtime.setdefault("docker_status", status)
|
||||||
|
return status, runtime
|
||||||
|
|
||||||
|
payload = build_platform_overview(session, read_runtime=_read_runtime)
|
||||||
|
return store_platform_overview_payload(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/platform/cache/clear")
|
||||||
|
def clear_platform_cache():
|
||||||
|
invalidate_platform_overview_cache()
|
||||||
|
invalidate_platform_nodes_cache()
|
||||||
|
return {"status": "cleared"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/platform/reload")
|
||||||
|
def reload_platform_runtime(request: Request):
|
||||||
|
apply_platform_runtime_changes(request)
|
||||||
|
return {"status": "reloaded"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/platform/usage")
|
||||||
|
def get_platform_usage(
|
||||||
|
bot_id: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
return list_usage(session, bot_id=bot_id, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/platform/dashboard-analytics")
|
||||||
|
def get_platform_dashboard_analytics(
|
||||||
|
since_days: int = 7,
|
||||||
|
events_limit: int = 20,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
return build_dashboard_analytics(session, since_days=since_days, events_limit=events_limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/platform/events")
|
||||||
|
def get_platform_events(
|
||||||
|
bot_id: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
return list_activity_events(session, bot_id=bot_id, limit=limit, offset=offset)
|
||||||
|
|
@ -1,696 +1,9 @@
|
||||||
import time
|
from fastapi import APIRouter
|
||||||
import shlex
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import httpx
|
from api.platform_admin_router import router as platform_admin_router
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from api.platform_nodes_router import router as platform_nodes_router
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from clients.edge.errors import log_edge_failure, summarize_edge_exception
|
|
||||||
from clients.edge.http import HttpEdgeClient
|
|
||||||
from core.cache import cache
|
|
||||||
from core.database import get_session
|
|
||||||
from models.bot import BotInstance
|
|
||||||
from providers.target import ProviderTarget
|
|
||||||
from providers.selector import get_runtime_provider
|
|
||||||
from schemas.platform import (
|
|
||||||
ManagedNodeConnectivityResult,
|
|
||||||
ManagedNodeNativePreflightResult,
|
|
||||||
ManagedNodePayload,
|
|
||||||
PlatformSettingsPayload,
|
|
||||||
SystemSettingPayload,
|
|
||||||
)
|
|
||||||
from services.node_registry_service import ManagedNode
|
|
||||||
from services.platform_service import (
|
|
||||||
build_node_resource_overview,
|
|
||||||
build_platform_overview,
|
|
||||||
create_or_update_system_setting,
|
|
||||||
delete_system_setting,
|
|
||||||
get_platform_settings,
|
|
||||||
list_system_settings,
|
|
||||||
list_activity_events,
|
|
||||||
list_usage,
|
|
||||||
save_platform_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
router.include_router(platform_admin_router)
|
||||||
PLATFORM_OVERVIEW_CACHE_KEY = "platform:overview"
|
router.include_router(platform_nodes_router)
|
||||||
PLATFORM_OVERVIEW_CACHE_TTL_SECONDS = 15
|
|
||||||
PLATFORM_NODES_CACHE_KEY = "platform:nodes:list"
|
|
||||||
PLATFORM_NODES_CACHE_TTL_SECONDS = 20
|
|
||||||
|
|
||||||
|
|
||||||
def _cached_platform_overview_payload() -> Optional[Dict[str, Any]]:
|
|
||||||
cached = cache.get_json(PLATFORM_OVERVIEW_CACHE_KEY)
|
|
||||||
return cached if isinstance(cached, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _store_platform_overview_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
cache.set_json(PLATFORM_OVERVIEW_CACHE_KEY, payload, ttl=PLATFORM_OVERVIEW_CACHE_TTL_SECONDS)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_platform_overview_cache() -> None:
|
|
||||||
cache.delete(PLATFORM_OVERVIEW_CACHE_KEY)
|
|
||||||
|
|
||||||
|
|
||||||
def _cached_platform_nodes_payload() -> Optional[Dict[str, Any]]:
|
|
||||||
cached = cache.get_json(PLATFORM_NODES_CACHE_KEY)
|
|
||||||
if not isinstance(cached, dict):
|
|
||||||
return None
|
|
||||||
items = cached.get("items")
|
|
||||||
if not isinstance(items, list):
|
|
||||||
return None
|
|
||||||
return {"items": items}
|
|
||||||
|
|
||||||
|
|
||||||
def _store_platform_nodes_payload(items: list[Dict[str, Any]]) -> Dict[str, Any]:
|
|
||||||
payload = {"items": items}
|
|
||||||
cache.set_json(PLATFORM_NODES_CACHE_KEY, payload, ttl=PLATFORM_NODES_CACHE_TTL_SECONDS)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_platform_nodes_cache() -> None:
|
|
||||||
cache.delete(PLATFORM_NODES_CACHE_KEY)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_node_payload(payload: ManagedNodePayload) -> ManagedNodePayload:
|
|
||||||
normalized_node_id = str(payload.node_id or "").strip().lower()
|
|
||||||
if not normalized_node_id:
|
|
||||||
raise HTTPException(status_code=400, detail="node_id is required")
|
|
||||||
transport_kind = str(payload.transport_kind or "edge").strip().lower() or "edge"
|
|
||||||
if transport_kind != "edge":
|
|
||||||
raise HTTPException(status_code=400, detail="Only edge transport is supported")
|
|
||||||
runtime_kind = str(payload.runtime_kind or "docker").strip().lower() or "docker"
|
|
||||||
core_adapter = str(payload.core_adapter or "nanobot").strip().lower() or "nanobot"
|
|
||||||
native_sandbox_mode = _normalize_native_sandbox_mode(payload.native_sandbox_mode)
|
|
||||||
base_url = str(payload.base_url or "").strip()
|
|
||||||
if transport_kind == "edge" and not base_url:
|
|
||||||
raise HTTPException(status_code=400, detail="base_url is required for edge nodes")
|
|
||||||
return payload.model_copy(
|
|
||||||
update={
|
|
||||||
"node_id": normalized_node_id,
|
|
||||||
"display_name": str(payload.display_name or normalized_node_id).strip() or normalized_node_id,
|
|
||||||
"base_url": base_url,
|
|
||||||
"auth_token": str(payload.auth_token or "").strip(),
|
|
||||||
"transport_kind": transport_kind,
|
|
||||||
"runtime_kind": runtime_kind,
|
|
||||||
"core_adapter": core_adapter,
|
|
||||||
"workspace_root": str(payload.workspace_root or "").strip(),
|
|
||||||
"native_command": str(payload.native_command or "").strip(),
|
|
||||||
"native_workdir": str(payload.native_workdir or "").strip(),
|
|
||||||
"native_sandbox_mode": native_sandbox_mode,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_native_sandbox_mode(raw_value: Any) -> str:
|
|
||||||
text = str(raw_value or "").strip().lower()
|
|
||||||
if text in {"workspace", "sandbox", "strict"}:
|
|
||||||
return "workspace"
|
|
||||||
if text in {"full_access", "full-access", "danger-full-access", "escape"}:
|
|
||||||
return "full_access"
|
|
||||||
return "inherit"
|
|
||||||
|
|
||||||
|
|
||||||
def _managed_node_from_payload(payload: ManagedNodePayload) -> ManagedNode:
|
|
||||||
normalized = _normalize_node_payload(payload)
|
|
||||||
return ManagedNode(
|
|
||||||
node_id=normalized.node_id,
|
|
||||||
display_name=normalized.display_name,
|
|
||||||
base_url=normalized.base_url,
|
|
||||||
enabled=bool(normalized.enabled),
|
|
||||||
auth_token=normalized.auth_token,
|
|
||||||
metadata={
|
|
||||||
"transport_kind": normalized.transport_kind,
|
|
||||||
"runtime_kind": normalized.runtime_kind,
|
|
||||||
"core_adapter": normalized.core_adapter,
|
|
||||||
"workspace_root": normalized.workspace_root,
|
|
||||||
"native_command": normalized.native_command,
|
|
||||||
"native_workdir": normalized.native_workdir,
|
|
||||||
"native_sandbox_mode": normalized.native_sandbox_mode,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _node_status(node: ManagedNode, *, refresh_failed: bool = False) -> str:
|
|
||||||
if not bool(node.enabled):
|
|
||||||
return "disabled"
|
|
||||||
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
|
|
||||||
if transport_kind != "edge":
|
|
||||||
return "unknown"
|
|
||||||
if refresh_failed:
|
|
||||||
return "offline"
|
|
||||||
return "online" if node.last_seen_at else "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_node(node: ManagedNode, *, refresh_failed: bool = False) -> Dict[str, Any]:
|
|
||||||
metadata = dict(node.metadata or {})
|
|
||||||
return {
|
|
||||||
"node_id": node.node_id,
|
|
||||||
"display_name": node.display_name,
|
|
||||||
"base_url": node.base_url,
|
|
||||||
"enabled": bool(node.enabled),
|
|
||||||
"transport_kind": str(metadata.get("transport_kind") or ""),
|
|
||||||
"runtime_kind": str(metadata.get("runtime_kind") or ""),
|
|
||||||
"core_adapter": str(metadata.get("core_adapter") or ""),
|
|
||||||
"workspace_root": str(metadata.get("workspace_root") or ""),
|
|
||||||
"native_command": str(metadata.get("native_command") or ""),
|
|
||||||
"native_workdir": str(metadata.get("native_workdir") or ""),
|
|
||||||
"native_sandbox_mode": str(metadata.get("native_sandbox_mode") or "inherit"),
|
|
||||||
"metadata": metadata,
|
|
||||||
"capabilities": dict(node.capabilities or {}),
|
|
||||||
"resources": dict(getattr(node, "resources", {}) or {}),
|
|
||||||
"last_seen_at": node.last_seen_at,
|
|
||||||
"status": _node_status(node, refresh_failed=refresh_failed),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _test_edge_connectivity(resolve_edge_client, node: ManagedNode) -> ManagedNodeConnectivityResult:
|
|
||||||
started = time.perf_counter()
|
|
||||||
try:
|
|
||||||
client = resolve_edge_client(
|
|
||||||
ProviderTarget(
|
|
||||||
node_id=node.node_id,
|
|
||||||
transport_kind="edge",
|
|
||||||
runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"),
|
|
||||||
core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
node_self = _edge_node_self_with_native_preflight(client=client, node=node)
|
|
||||||
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
|
||||||
return ManagedNodeConnectivityResult(
|
|
||||||
ok=True,
|
|
||||||
status="online",
|
|
||||||
latency_ms=latency_ms,
|
|
||||||
detail="dashboard-edge reachable",
|
|
||||||
node_self=node_self,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
|
||||||
return ManagedNodeConnectivityResult(
|
|
||||||
ok=False,
|
|
||||||
status="offline",
|
|
||||||
latency_ms=latency_ms,
|
|
||||||
detail=summarize_edge_exception(exc),
|
|
||||||
node_self=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _split_native_command(raw_command: Optional[str]) -> list[str]:
|
|
||||||
text = str(raw_command or "").strip()
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
return [str(item or "").strip() for item in shlex.split(text) if str(item or "").strip()]
|
|
||||||
except Exception:
|
|
||||||
return [text]
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_native_supported(node_self: Dict[str, Any]) -> bool:
|
|
||||||
capabilities = dict(node_self.get("capabilities") or {})
|
|
||||||
runtime_caps = dict(capabilities.get("runtime") or {})
|
|
||||||
return bool(runtime_caps.get("native") is True)
|
|
||||||
|
|
||||||
|
|
||||||
def _test_edge_native_preflight(
|
|
||||||
resolve_edge_client,
|
|
||||||
node: ManagedNode,
|
|
||||||
*,
|
|
||||||
native_command: Optional[str] = None,
|
|
||||||
native_workdir: Optional[str] = None,
|
|
||||||
) -> ManagedNodeNativePreflightResult:
|
|
||||||
started = time.perf_counter()
|
|
||||||
command_hint = _split_native_command(native_command)
|
|
||||||
workdir_hint = str(native_workdir or "").strip()
|
|
||||||
try:
|
|
||||||
client = resolve_edge_client(
|
|
||||||
ProviderTarget(
|
|
||||||
node_id=node.node_id,
|
|
||||||
transport_kind="edge",
|
|
||||||
runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"),
|
|
||||||
core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
node_self = dict(client.heartbeat_node() or {})
|
|
||||||
preflight = dict(
|
|
||||||
client.preflight_native(
|
|
||||||
native_command=native_command,
|
|
||||||
native_workdir=native_workdir,
|
|
||||||
)
|
|
||||||
or {}
|
|
||||||
)
|
|
||||||
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
|
||||||
command = [str(item or "").strip() for item in list(preflight.get("command") or []) if str(item or "").strip()]
|
|
||||||
workdir = str(preflight.get("workdir") or "")
|
|
||||||
detail = str(preflight.get("detail") or "")
|
|
||||||
if not detail:
|
|
||||||
detail = "native launcher ready" if bool(preflight.get("ok")) else "native launcher not ready"
|
|
||||||
return ManagedNodeNativePreflightResult(
|
|
||||||
ok=bool(preflight.get("ok")),
|
|
||||||
status="online",
|
|
||||||
latency_ms=latency_ms,
|
|
||||||
detail=detail,
|
|
||||||
command=command,
|
|
||||||
workdir=workdir,
|
|
||||||
command_available=bool(preflight.get("command_available")),
|
|
||||||
workdir_exists=bool(preflight.get("workdir_exists")),
|
|
||||||
runtime_native_supported=_runtime_native_supported(node_self),
|
|
||||||
node_self=node_self,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
|
|
||||||
return ManagedNodeNativePreflightResult(
|
|
||||||
ok=False,
|
|
||||||
status="offline",
|
|
||||||
latency_ms=latency_ms,
|
|
||||||
detail=summarize_edge_exception(exc),
|
|
||||||
command=command_hint,
|
|
||||||
workdir=workdir_hint,
|
|
||||||
command_available=False,
|
|
||||||
workdir_exists=False if workdir_hint else True,
|
|
||||||
runtime_native_supported=False,
|
|
||||||
node_self=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _edge_node_self_with_native_preflight(*, client: HttpEdgeClient, node: ManagedNode) -> Dict[str, Any]:
|
|
||||||
node_self = dict(client.heartbeat_node() or {})
|
|
||||||
metadata = dict(node.metadata or {})
|
|
||||||
native_command = str(metadata.get("native_command") or "").strip() or None
|
|
||||||
native_workdir = str(metadata.get("native_workdir") or "").strip() or None
|
|
||||||
runtime_kind = str(metadata.get("runtime_kind") or "docker").strip().lower()
|
|
||||||
should_probe = bool(native_command or native_workdir or runtime_kind == "native")
|
|
||||||
if not should_probe:
|
|
||||||
return node_self
|
|
||||||
try:
|
|
||||||
preflight = dict(client.preflight_native(native_command=native_command, native_workdir=native_workdir) or {})
|
|
||||||
except Exception as exc:
|
|
||||||
log_edge_failure(
|
|
||||||
logger,
|
|
||||||
key=f"platform-node-native-preflight:{node.node_id}",
|
|
||||||
exc=exc,
|
|
||||||
message=f"Failed to run native preflight for node_id={node.node_id}",
|
|
||||||
)
|
|
||||||
return node_self
|
|
||||||
caps = dict(node_self.get("capabilities") or {})
|
|
||||||
process_caps = dict(caps.get("process") or {})
|
|
||||||
if preflight.get("command"):
|
|
||||||
process_caps["command"] = list(preflight.get("command") or [])
|
|
||||||
process_caps["available"] = bool(preflight.get("ok"))
|
|
||||||
process_caps["command_available"] = bool(preflight.get("command_available"))
|
|
||||||
process_caps["workdir_exists"] = bool(preflight.get("workdir_exists"))
|
|
||||||
process_caps["workdir"] = str(preflight.get("workdir") or "")
|
|
||||||
process_caps["detail"] = str(preflight.get("detail") or "")
|
|
||||||
caps["process"] = process_caps
|
|
||||||
node_self["capabilities"] = caps
|
|
||||||
node_self["native_preflight"] = preflight
|
|
||||||
return node_self
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_platform_runtime_changes(request: Request) -> None:
|
|
||||||
_invalidate_platform_overview_cache()
|
|
||||||
_invalidate_platform_nodes_cache()
|
|
||||||
speech_service = getattr(request.app.state, "speech_service", None)
|
|
||||||
if speech_service is not None and hasattr(speech_service, "reset_runtime"):
|
|
||||||
speech_service.reset_runtime()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/platform/overview")
|
|
||||||
def get_platform_overview(request: Request, session: Session = Depends(get_session)):
|
|
||||||
cached_payload = _cached_platform_overview_payload()
|
|
||||||
if cached_payload is not None:
|
|
||||||
return cached_payload
|
|
||||||
|
|
||||||
def _read_runtime(bot):
|
|
||||||
provider = get_runtime_provider(request.app.state, bot)
|
|
||||||
status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
|
|
||||||
runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {})
|
|
||||||
runtime.setdefault("docker_status", status)
|
|
||||||
return status, runtime
|
|
||||||
|
|
||||||
payload = build_platform_overview(session, read_runtime=_read_runtime)
|
|
||||||
return _store_platform_overview_payload(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/platform/nodes")
|
|
||||||
def list_platform_nodes(request: Request, session: Session = Depends(get_session)):
|
|
||||||
cached_payload = _cached_platform_nodes_payload()
|
|
||||||
if cached_payload is not None:
|
|
||||||
return cached_payload
|
|
||||||
|
|
||||||
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
||||||
if node_registry is None or not hasattr(node_registry, "list_nodes"):
|
|
||||||
return {"items": []}
|
|
||||||
resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None)
|
|
||||||
refreshed_items = []
|
|
||||||
for node in node_registry.list_nodes():
|
|
||||||
metadata = dict(node.metadata or {})
|
|
||||||
refresh_failed = False
|
|
||||||
if (
|
|
||||||
callable(resolve_edge_client)
|
|
||||||
and str(metadata.get("transport_kind") or "").strip().lower() == "edge"
|
|
||||||
and bool(node.enabled)
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
client = resolve_edge_client(
|
|
||||||
ProviderTarget(
|
|
||||||
node_id=node.node_id,
|
|
||||||
transport_kind="edge",
|
|
||||||
runtime_kind=str(metadata.get("runtime_kind") or "docker"),
|
|
||||||
core_adapter=str(metadata.get("core_adapter") or "nanobot"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
node_self = _edge_node_self_with_native_preflight(client=client, node=node)
|
|
||||||
node = node_registry.mark_node_seen(
|
|
||||||
session,
|
|
||||||
node_id=node.node_id,
|
|
||||||
display_name=str(node.display_name or node_self.get("display_name") or node.node_id),
|
|
||||||
capabilities=dict(node_self.get("capabilities") or {}),
|
|
||||||
resources=dict(node_self.get("resources") or {}),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
refresh_failed = True
|
|
||||||
log_edge_failure(
|
|
||||||
logger,
|
|
||||||
key=f"platform-node-refresh:{node.node_id}",
|
|
||||||
exc=exc,
|
|
||||||
message=f"Failed to refresh edge node metadata for node_id={node.node_id}",
|
|
||||||
)
|
|
||||||
refreshed_items.append((node, refresh_failed))
|
|
||||||
items = []
|
|
||||||
for node, refresh_failed in refreshed_items:
|
|
||||||
items.append(_serialize_node(node, refresh_failed=refresh_failed))
|
|
||||||
return _store_platform_nodes_payload(items)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/platform/nodes/{node_id}")
|
|
||||||
def get_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
|
|
||||||
normalized_node_id = str(node_id or "").strip().lower()
|
|
||||||
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
||||||
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
||||||
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
||||||
node = node_registry.get_node(normalized_node_id)
|
|
||||||
if node is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
|
||||||
return _serialize_node(node)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/platform/nodes")
|
|
||||||
def create_platform_node(payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)):
|
|
||||||
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
||||||
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
||||||
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
||||||
normalized = _normalize_node_payload(payload)
|
|
||||||
if node_registry.get_node(normalized.node_id) is not None:
|
|
||||||
raise HTTPException(status_code=409, detail=f"Node already exists: {normalized.node_id}")
|
|
||||||
node = node_registry.upsert_node(session, _managed_node_from_payload(normalized))
|
|
||||||
_invalidate_platform_overview_cache()
|
|
||||||
_invalidate_platform_nodes_cache()
|
|
||||||
return _serialize_node(node)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/platform/nodes/{node_id}")
|
|
||||||
def update_platform_node(node_id: str, payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)):
|
|
||||||
normalized_node_id = str(node_id or "").strip().lower()
|
|
||||||
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
||||||
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
||||||
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
||||||
existing = node_registry.get_node(normalized_node_id)
|
|
||||||
if existing is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
|
||||||
normalized = _normalize_node_payload(payload)
|
|
||||||
if normalized.node_id != normalized_node_id:
|
|
||||||
raise HTTPException(status_code=400, detail="node_id cannot be changed")
|
|
||||||
node = node_registry.upsert_node(
|
|
||||||
session,
|
|
||||||
ManagedNode(
|
|
||||||
node_id=normalized_node_id,
|
|
||||||
display_name=normalized.display_name,
|
|
||||||
base_url=normalized.base_url,
|
|
||||||
enabled=bool(normalized.enabled),
|
|
||||||
auth_token=normalized.auth_token or existing.auth_token,
|
|
||||||
metadata={
|
|
||||||
"transport_kind": normalized.transport_kind,
|
|
||||||
"runtime_kind": normalized.runtime_kind,
|
|
||||||
"core_adapter": normalized.core_adapter,
|
|
||||||
"workspace_root": normalized.workspace_root,
|
|
||||||
"native_command": normalized.native_command,
|
|
||||||
"native_workdir": normalized.native_workdir,
|
|
||||||
"native_sandbox_mode": normalized.native_sandbox_mode,
|
|
||||||
},
|
|
||||||
capabilities=dict(existing.capabilities or {}),
|
|
||||||
resources=dict(existing.resources or {}),
|
|
||||||
last_seen_at=existing.last_seen_at,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
_invalidate_platform_overview_cache()
|
|
||||||
_invalidate_platform_nodes_cache()
|
|
||||||
return _serialize_node(node)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/platform/nodes/{node_id}")
|
|
||||||
def delete_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
|
|
||||||
normalized_node_id = str(node_id or "").strip().lower()
|
|
||||||
if normalized_node_id == "local":
|
|
||||||
raise HTTPException(status_code=400, detail="Local node cannot be deleted")
|
|
||||||
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
||||||
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
||||||
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
||||||
if node_registry.get_node(normalized_node_id) is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
|
||||||
attached_bot_ids = session.exec(select(BotInstance.id).where(BotInstance.node_id == normalized_node_id)).all()
|
|
||||||
if attached_bot_ids:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Node {normalized_node_id} still has bots assigned: {', '.join(str(item) for item in attached_bot_ids[:5])}",
|
|
||||||
)
|
|
||||||
node_registry.delete_node(session, normalized_node_id)
|
|
||||||
_invalidate_platform_overview_cache()
|
|
||||||
_invalidate_platform_nodes_cache()
|
|
||||||
return {"status": "deleted", "node_id": normalized_node_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/platform/nodes/test")
|
|
||||||
def test_platform_node(payload: ManagedNodePayload, request: Request):
|
|
||||||
normalized = _normalize_node_payload(payload)
|
|
||||||
temp_node = _managed_node_from_payload(normalized)
|
|
||||||
result = _test_edge_connectivity(
|
|
||||||
lambda _target: HttpEdgeClient(
|
|
||||||
node=temp_node,
|
|
||||||
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
|
||||||
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
|
||||||
),
|
|
||||||
temp_node,
|
|
||||||
)
|
|
||||||
return result.model_dump()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/platform/nodes/native/preflight")
|
|
||||||
def test_platform_node_native_preflight(payload: ManagedNodePayload, request: Request):
|
|
||||||
normalized = _normalize_node_payload(payload)
|
|
||||||
temp_node = _managed_node_from_payload(normalized)
|
|
||||||
result = _test_edge_native_preflight(
|
|
||||||
lambda _target: HttpEdgeClient(
|
|
||||||
node=temp_node,
|
|
||||||
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
|
||||||
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
|
||||||
),
|
|
||||||
temp_node,
|
|
||||||
native_command=str(normalized.native_command or "").strip() or None,
|
|
||||||
native_workdir=str(normalized.native_workdir or "").strip() or None,
|
|
||||||
)
|
|
||||||
return result.model_dump()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/platform/nodes/{node_id}/test")
|
|
||||||
def test_saved_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
|
|
||||||
normalized_node_id = str(node_id or "").strip().lower()
|
|
||||||
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
||||||
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
||||||
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
||||||
node = node_registry.get_node(normalized_node_id)
|
|
||||||
if node is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
|
||||||
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
|
|
||||||
if transport_kind != "edge":
|
|
||||||
_invalidate_platform_nodes_cache()
|
|
||||||
raise HTTPException(status_code=400, detail="Only edge transport is supported")
|
|
||||||
result = _test_edge_connectivity(
|
|
||||||
lambda target: HttpEdgeClient(
|
|
||||||
node=node,
|
|
||||||
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
|
||||||
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
|
||||||
),
|
|
||||||
node,
|
|
||||||
)
|
|
||||||
if result.ok:
|
|
||||||
node_registry.mark_node_seen(
|
|
||||||
session,
|
|
||||||
node_id=node.node_id,
|
|
||||||
display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id) if result.node_self else node.display_name,
|
|
||||||
capabilities=dict(result.node_self.get("capabilities") or {}) if result.node_self else dict(node.capabilities or {}),
|
|
||||||
resources=dict(result.node_self.get("resources") or {}) if result.node_self else dict(getattr(node, "resources", {}) or {}),
|
|
||||||
)
|
|
||||||
_invalidate_platform_nodes_cache()
|
|
||||||
return result.model_dump()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/platform/nodes/{node_id}/native/preflight")
|
|
||||||
def test_saved_platform_node_native_preflight(node_id: str, request: Request, session: Session = Depends(get_session)):
|
|
||||||
normalized_node_id = str(node_id or "").strip().lower()
|
|
||||||
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
||||||
if node_registry is None or not hasattr(node_registry, "get_node"):
|
|
||||||
raise HTTPException(status_code=500, detail="node registry is unavailable")
|
|
||||||
node = node_registry.get_node(normalized_node_id)
|
|
||||||
if node is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
|
|
||||||
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
|
|
||||||
if transport_kind != "edge":
|
|
||||||
_invalidate_platform_nodes_cache()
|
|
||||||
raise HTTPException(status_code=400, detail="Only edge transport is supported")
|
|
||||||
metadata = dict(node.metadata or {})
|
|
||||||
result = _test_edge_native_preflight(
|
|
||||||
lambda _target: HttpEdgeClient(
|
|
||||||
node=node,
|
|
||||||
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
|
|
||||||
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
|
|
||||||
),
|
|
||||||
node,
|
|
||||||
native_command=str(metadata.get("native_command") or "").strip() or None,
|
|
||||||
native_workdir=str(metadata.get("native_workdir") or "").strip() or None,
|
|
||||||
)
|
|
||||||
if result.status == "online" and result.node_self:
|
|
||||||
node_registry.mark_node_seen(
|
|
||||||
session,
|
|
||||||
node_id=node.node_id,
|
|
||||||
display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id),
|
|
||||||
capabilities=dict(result.node_self.get("capabilities") or {}),
|
|
||||||
resources=dict(result.node_self.get("resources") or {}),
|
|
||||||
)
|
|
||||||
_invalidate_platform_nodes_cache()
|
|
||||||
return result.model_dump()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/platform/nodes/{node_id}/resources")
|
|
||||||
def get_platform_node_resources(node_id: str, request: Request, session: Session = Depends(get_session)):
|
|
||||||
normalized_node_id = str(node_id or "").strip().lower()
|
|
||||||
node_registry = getattr(request.app.state, "node_registry_service", None)
|
|
||||||
if node_registry is not None and hasattr(node_registry, "get_node"):
|
|
||||||
node = node_registry.get_node(normalized_node_id)
|
|
||||||
if node is not None:
|
|
||||||
metadata = dict(getattr(node, "metadata", {}) or {})
|
|
||||||
if str(metadata.get("transport_kind") or "").strip().lower() == "edge":
|
|
||||||
resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None)
|
|
||||||
if callable(resolve_edge_client):
|
|
||||||
from providers.target import ProviderTarget
|
|
||||||
|
|
||||||
base = build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=None)
|
|
||||||
client = resolve_edge_client(
|
|
||||||
ProviderTarget(
|
|
||||||
node_id=normalized_node_id,
|
|
||||||
transport_kind="edge",
|
|
||||||
runtime_kind=str(metadata.get("runtime_kind") or "docker"),
|
|
||||||
core_adapter=str(metadata.get("core_adapter") or "nanobot"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
resource_report = dict(client.get_node_resources() or {})
|
|
||||||
except Exception as exc:
|
|
||||||
log_edge_failure(
|
|
||||||
logger,
|
|
||||||
key=f"platform-node-resources:{normalized_node_id}",
|
|
||||||
exc=exc,
|
|
||||||
message=f"Failed to load edge node resources for node_id={normalized_node_id}",
|
|
||||||
)
|
|
||||||
return base
|
|
||||||
base["resources"] = dict(resource_report.get("resources") or resource_report)
|
|
||||||
if resource_report:
|
|
||||||
base["node_report"] = resource_report
|
|
||||||
return base
|
|
||||||
|
|
||||||
def _read_runtime(bot):
|
|
||||||
provider = get_runtime_provider(request.app.state, bot)
|
|
||||||
status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
|
|
||||||
runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {})
|
|
||||||
runtime.setdefault("docker_status", status)
|
|
||||||
return status, runtime
|
|
||||||
|
|
||||||
return build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=_read_runtime)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/platform/settings")
|
|
||||||
def get_platform_settings_api(session: Session = Depends(get_session)):
|
|
||||||
return get_platform_settings(session).model_dump()
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/platform/settings")
|
|
||||||
def update_platform_settings_api(payload: PlatformSettingsPayload, request: Request, session: Session = Depends(get_session)):
|
|
||||||
result = save_platform_settings(session, payload).model_dump()
|
|
||||||
_apply_platform_runtime_changes(request)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/platform/cache/clear")
|
|
||||||
def clear_platform_cache():
|
|
||||||
_invalidate_platform_overview_cache()
|
|
||||||
_invalidate_platform_nodes_cache()
|
|
||||||
return {"status": "cleared"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/platform/reload")
|
|
||||||
def reload_platform_runtime(request: Request):
|
|
||||||
_apply_platform_runtime_changes(request)
|
|
||||||
return {"status": "reloaded"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/platform/usage")
|
|
||||||
def get_platform_usage(
|
|
||||||
bot_id: Optional[str] = None,
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
):
|
|
||||||
return list_usage(session, bot_id=bot_id, limit=limit, offset=offset)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/platform/events")
|
|
||||||
def get_platform_events(bot_id: Optional[str] = None, limit: int = 100, session: Session = Depends(get_session)):
|
|
||||||
return {"items": list_activity_events(session, bot_id=bot_id, limit=limit)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/platform/system-settings")
|
|
||||||
def get_system_settings(search: str = "", session: Session = Depends(get_session)):
|
|
||||||
return {"items": list_system_settings(session, search=search)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/platform/system-settings")
|
|
||||||
def create_system_setting(payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)):
|
|
||||||
try:
|
|
||||||
result = create_or_update_system_setting(session, payload)
|
|
||||||
_apply_platform_runtime_changes(request)
|
|
||||||
return result
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/platform/system-settings/{key}")
|
|
||||||
def update_system_setting(key: str, payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)):
|
|
||||||
try:
|
|
||||||
result = create_or_update_system_setting(session, payload.model_copy(update={"key": key}))
|
|
||||||
_apply_platform_runtime_changes(request)
|
|
||||||
return result
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/platform/system-settings/{key}")
|
|
||||||
def remove_system_setting(key: str, request: Request, session: Session = Depends(get_session)):
|
|
||||||
try:
|
|
||||||
delete_system_setting(session, key)
|
|
||||||
_apply_platform_runtime_changes(request)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
return {"status": "deleted", "key": key}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from api.platform_shared import apply_platform_runtime_changes
|
||||||
|
from core.database import get_session
|
||||||
|
from schemas.platform import PlatformSettingsPayload, SystemSettingPayload
|
||||||
|
from services.platform_settings_service import (
|
||||||
|
create_or_update_system_setting,
|
||||||
|
delete_system_setting,
|
||||||
|
get_platform_settings,
|
||||||
|
list_system_settings,
|
||||||
|
save_platform_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/platform/settings")
|
||||||
|
def get_platform_settings_api(session: Session = Depends(get_session)):
|
||||||
|
return get_platform_settings(session).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/platform/settings")
|
||||||
|
def update_platform_settings_api(
|
||||||
|
payload: PlatformSettingsPayload,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
result = save_platform_settings(session, payload).model_dump()
|
||||||
|
apply_platform_runtime_changes(request)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/platform/system-settings")
|
||||||
|
def get_system_settings(search: str = "", session: Session = Depends(get_session)):
|
||||||
|
return {"items": list_system_settings(session, search=search)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/platform/system-settings")
|
||||||
|
def create_system_setting(payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)):
|
||||||
|
try:
|
||||||
|
result = create_or_update_system_setting(session, payload)
|
||||||
|
apply_platform_runtime_changes(request)
|
||||||
|
return result
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/platform/system-settings/{key}")
|
||||||
|
def update_system_setting(
|
||||||
|
key: str,
|
||||||
|
payload: SystemSettingPayload,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
result = create_or_update_system_setting(session, payload.model_copy(update={"key": key}))
|
||||||
|
apply_platform_runtime_changes(request)
|
||||||
|
return result
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/platform/system-settings/{key}")
|
||||||
|
def remove_system_setting(key: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
try:
|
||||||
|
delete_system_setting(session, key)
|
||||||
|
apply_platform_runtime_changes(request)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"status": "deleted", "key": key}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from core.cache import cache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
PLATFORM_OVERVIEW_CACHE_KEY = "platform:overview"
|
||||||
|
PLATFORM_OVERVIEW_CACHE_TTL_SECONDS = 15
|
||||||
|
PLATFORM_NODES_CACHE_KEY = "platform:nodes:list"
|
||||||
|
PLATFORM_NODES_CACHE_TTL_SECONDS = 20
|
||||||
|
|
||||||
|
|
||||||
|
def cached_platform_overview_payload() -> Optional[Dict[str, Any]]:
|
||||||
|
cached = cache.get_json(PLATFORM_OVERVIEW_CACHE_KEY)
|
||||||
|
return cached if isinstance(cached, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def store_platform_overview_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
cache.set_json(PLATFORM_OVERVIEW_CACHE_KEY, payload, ttl=PLATFORM_OVERVIEW_CACHE_TTL_SECONDS)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_platform_overview_cache() -> None:
|
||||||
|
cache.delete(PLATFORM_OVERVIEW_CACHE_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def cached_platform_nodes_payload() -> Optional[Dict[str, Any]]:
|
||||||
|
cached = cache.get_json(PLATFORM_NODES_CACHE_KEY)
|
||||||
|
if not isinstance(cached, dict):
|
||||||
|
return None
|
||||||
|
items = cached.get("items")
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return None
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
def store_platform_nodes_payload(items: list[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
payload = {"items": items}
|
||||||
|
cache.set_json(PLATFORM_NODES_CACHE_KEY, payload, ttl=PLATFORM_NODES_CACHE_TTL_SECONDS)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_platform_nodes_cache() -> None:
|
||||||
|
cache.delete(PLATFORM_NODES_CACHE_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_platform_runtime_changes(request: Request) -> None:
|
||||||
|
invalidate_platform_overview_cache()
|
||||||
|
invalidate_platform_nodes_cache()
|
||||||
|
speech_service = getattr(request.app.state, "speech_service", None)
|
||||||
|
if speech_service is not None and hasattr(speech_service, "reset_runtime"):
|
||||||
|
speech_service.reset_runtime()
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from core.database import get_session
|
||||||
|
from models.sys_auth import SysUser
|
||||||
|
from schemas.sys_auth import (
|
||||||
|
SysAuthBootstrapResponse,
|
||||||
|
SysAuthLoginRequest,
|
||||||
|
SysProfileUpdateRequest,
|
||||||
|
SysAuthStatusResponse,
|
||||||
|
SysRoleGrantBootstrapResponse,
|
||||||
|
SysRoleListResponse,
|
||||||
|
SysRoleSummaryResponse,
|
||||||
|
SysRoleUpsertRequest,
|
||||||
|
SysUserCreateRequest,
|
||||||
|
SysUserListResponse,
|
||||||
|
SysUserSummaryResponse,
|
||||||
|
SysUserUpdateRequest,
|
||||||
|
)
|
||||||
|
from services.sys_auth_service import (
|
||||||
|
DEFAULT_ADMIN_USERNAME,
|
||||||
|
authenticate_user,
|
||||||
|
build_user_bootstrap,
|
||||||
|
create_sys_role,
|
||||||
|
create_sys_user,
|
||||||
|
delete_sys_role,
|
||||||
|
delete_sys_user,
|
||||||
|
issue_user_token,
|
||||||
|
list_role_grant_bootstrap,
|
||||||
|
list_sys_roles,
|
||||||
|
list_sys_users,
|
||||||
|
resolve_user_by_token,
|
||||||
|
revoke_user_token,
|
||||||
|
update_sys_role,
|
||||||
|
update_sys_user,
|
||||||
|
update_current_sys_user_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_auth_token(request: Request) -> str:
|
||||||
|
authorization = str(request.headers.get("authorization") or "").strip()
|
||||||
|
if authorization.lower().startswith("bearer "):
|
||||||
|
return authorization[7:].strip()
|
||||||
|
return str(request.headers.get("x-auth-token") or request.query_params.get("auth_token") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_current_user(request: Request, session: Session) -> SysUser:
|
||||||
|
state_user_id = getattr(request.state, "sys_user_id", None)
|
||||||
|
if state_user_id:
|
||||||
|
user = session.get(SysUser, state_user_id)
|
||||||
|
if user is not None and bool(user.is_active):
|
||||||
|
return user
|
||||||
|
token = _extract_auth_token(request)
|
||||||
|
user = resolve_user_by_token(session, token)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/sys/auth/status", response_model=SysAuthStatusResponse)
|
||||||
|
def get_sys_auth_status(session: Session = Depends(get_session)):
|
||||||
|
user_count = len(session.exec(select(SysUser)).all())
|
||||||
|
return SysAuthStatusResponse(
|
||||||
|
enabled=True,
|
||||||
|
user_count=user_count,
|
||||||
|
default_username=DEFAULT_ADMIN_USERNAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/sys/auth/login", response_model=SysAuthBootstrapResponse)
|
||||||
|
def login_sys_user(payload: SysAuthLoginRequest, session: Session = Depends(get_session)):
|
||||||
|
username = str(payload.username or "").strip().lower()
|
||||||
|
password = str(payload.password or "")
|
||||||
|
user = authenticate_user(session, username, password)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
try:
|
||||||
|
token, expires_at = issue_user_token(session, user)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
return SysAuthBootstrapResponse.model_validate(build_user_bootstrap(session, user, token=token, expires_at=expires_at))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/sys/auth/logout")
|
||||||
|
def logout_sys_user(request: Request, session: Session = Depends(get_session)):
|
||||||
|
token = _extract_auth_token(request)
|
||||||
|
user = resolve_user_by_token(session, token)
|
||||||
|
if user is not None:
|
||||||
|
revoke_user_token(token)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/sys/auth/me", response_model=SysAuthBootstrapResponse)
|
||||||
|
def get_current_sys_user(request: Request, session: Session = Depends(get_session)):
|
||||||
|
user = _require_current_user(request, session)
|
||||||
|
return SysAuthBootstrapResponse.model_validate(build_user_bootstrap(session, user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/sys/auth/me", response_model=SysAuthBootstrapResponse)
|
||||||
|
def update_current_sys_user(
|
||||||
|
payload: SysProfileUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
current_user = _require_current_user(request, session)
|
||||||
|
try:
|
||||||
|
user = update_current_sys_user_profile(
|
||||||
|
session,
|
||||||
|
user_id=int(current_user.id or 0),
|
||||||
|
display_name=payload.display_name,
|
||||||
|
password=payload.password,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return SysAuthBootstrapResponse.model_validate(build_user_bootstrap(session, user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/sys/users", response_model=SysUserListResponse)
|
||||||
|
def list_sys_users_api(request: Request, session: Session = Depends(get_session)):
|
||||||
|
_require_current_user(request, session)
|
||||||
|
return SysUserListResponse(items=[SysUserSummaryResponse.model_validate(item) for item in list_sys_users(session)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/sys/users", response_model=SysUserSummaryResponse)
|
||||||
|
def create_sys_user_api(payload: SysUserCreateRequest, request: Request, session: Session = Depends(get_session)):
|
||||||
|
_require_current_user(request, session)
|
||||||
|
try:
|
||||||
|
item = create_sys_user(
|
||||||
|
session,
|
||||||
|
username=payload.username,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
password=payload.password,
|
||||||
|
role_id=int(payload.role_id),
|
||||||
|
is_active=bool(payload.is_active),
|
||||||
|
bot_ids=list(payload.bot_ids or []),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return SysUserSummaryResponse.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/sys/users/{user_id}", response_model=SysUserSummaryResponse)
|
||||||
|
def update_sys_user_api(user_id: int, payload: SysUserUpdateRequest, request: Request, session: Session = Depends(get_session)):
|
||||||
|
current_user = _require_current_user(request, session)
|
||||||
|
try:
|
||||||
|
item = update_sys_user(
|
||||||
|
session,
|
||||||
|
user_id=int(user_id),
|
||||||
|
display_name=payload.display_name,
|
||||||
|
password=payload.password,
|
||||||
|
role_id=int(payload.role_id),
|
||||||
|
is_active=bool(payload.is_active),
|
||||||
|
bot_ids=list(payload.bot_ids or []),
|
||||||
|
acting_user_id=int(current_user.id or 0),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return SysUserSummaryResponse.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/sys/users/{user_id}")
|
||||||
|
def delete_sys_user_api(user_id: int, request: Request, session: Session = Depends(get_session)):
|
||||||
|
current_user = _require_current_user(request, session)
|
||||||
|
try:
|
||||||
|
delete_sys_user(session, user_id=int(user_id), acting_user_id=int(current_user.id or 0))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/sys/roles", response_model=SysRoleListResponse)
|
||||||
|
def list_sys_roles_api(request: Request, session: Session = Depends(get_session)):
|
||||||
|
_require_current_user(request, session)
|
||||||
|
return SysRoleListResponse(items=[SysRoleSummaryResponse.model_validate(item) for item in list_sys_roles(session)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/sys/roles/grants/bootstrap", response_model=SysRoleGrantBootstrapResponse)
|
||||||
|
def list_sys_role_grants_bootstrap_api(request: Request, session: Session = Depends(get_session)):
|
||||||
|
_require_current_user(request, session)
|
||||||
|
return SysRoleGrantBootstrapResponse.model_validate(list_role_grant_bootstrap(session))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/sys/roles", response_model=SysRoleSummaryResponse)
|
||||||
|
def create_sys_role_api(payload: SysRoleUpsertRequest, request: Request, session: Session = Depends(get_session)):
|
||||||
|
_require_current_user(request, session)
|
||||||
|
try:
|
||||||
|
item = create_sys_role(
|
||||||
|
session,
|
||||||
|
role_key=payload.role_key,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
is_active=bool(payload.is_active),
|
||||||
|
sort_order=int(payload.sort_order),
|
||||||
|
menu_keys=list(payload.menu_keys or []),
|
||||||
|
permission_keys=list(payload.permission_keys or []),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return SysRoleSummaryResponse.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/sys/roles/{role_id}", response_model=SysRoleSummaryResponse)
|
||||||
|
def update_sys_role_api(role_id: int, payload: SysRoleUpsertRequest, request: Request, session: Session = Depends(get_session)):
|
||||||
|
_require_current_user(request, session)
|
||||||
|
try:
|
||||||
|
item = update_sys_role(
|
||||||
|
session,
|
||||||
|
role_id=int(role_id),
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
is_active=bool(payload.is_active),
|
||||||
|
sort_order=int(payload.sort_order),
|
||||||
|
menu_keys=list(payload.menu_keys or []),
|
||||||
|
permission_keys=list(payload.permission_keys or []),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return SysRoleSummaryResponse.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/sys/roles/{role_id}")
|
||||||
|
def delete_sys_role_api(role_id: int, request: Request, session: Session = Depends(get_session)):
|
||||||
|
_require_current_user(request, session)
|
||||||
|
try:
|
||||||
|
delete_sys_role(session, role_id=int(role_id))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"success": True}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from schemas.dashboard import SystemTemplatesUpdateRequest
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_runtime_router(*, system_service) -> APIRouter:
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/api/system/defaults")
|
||||||
|
def get_system_defaults():
|
||||||
|
return system_service.get_system_defaults()
|
||||||
|
|
||||||
|
@router.get("/api/system/templates")
|
||||||
|
def get_system_templates():
|
||||||
|
return system_service.get_system_templates()
|
||||||
|
|
||||||
|
@router.put("/api/system/templates")
|
||||||
|
def update_system_templates(payload: SystemTemplatesUpdateRequest):
|
||||||
|
return system_service.update_system_templates(payload=payload)
|
||||||
|
|
||||||
|
@router.get("/api/health")
|
||||||
|
def get_health():
|
||||||
|
return system_service.get_health()
|
||||||
|
|
||||||
|
@router.get("/api/health/cache")
|
||||||
|
def get_cache_health():
|
||||||
|
return system_service.get_cache_health()
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from api.platform_router import router as platform_router
|
||||||
|
from api.sys_router import router as sys_router
|
||||||
|
from api.system_runtime_router import build_system_runtime_router
|
||||||
|
from api.topic_router import router as topic_router
|
||||||
|
from bootstrap.app_runtime import assemble_app_runtime
|
||||||
|
from core.config_manager import BotConfigManager
|
||||||
|
from core.docker_manager import BotDockerManager
|
||||||
|
from core.settings import BOTS_WORKSPACE_ROOT, DATA_ROOT
|
||||||
|
from core.speech_service import WhisperSpeechService
|
||||||
|
|
||||||
|
app = FastAPI(title="Dashboard Nanobot API")
|
||||||
|
logger = logging.getLogger("dashboard.backend")
|
||||||
|
LAST_ACTION_MAX_LENGTH = 16000
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_last_action_text(value: Any) -> str:
|
||||||
|
text = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = re.sub(r"\n{4,}", "\n\n\n", text)
|
||||||
|
return text[:LAST_ACTION_MAX_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_log_noise_guard() -> None:
|
||||||
|
for name in (
|
||||||
|
"httpx",
|
||||||
|
"httpcore",
|
||||||
|
"uvicorn.access",
|
||||||
|
"watchfiles.main",
|
||||||
|
"watchfiles.watcher",
|
||||||
|
):
|
||||||
|
logging.getLogger(name).setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
_apply_log_noise_guard()
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.include_router(topic_router)
|
||||||
|
app.include_router(platform_router)
|
||||||
|
app.include_router(sys_router)
|
||||||
|
|
||||||
|
os.makedirs(BOTS_WORKSPACE_ROOT, exist_ok=True)
|
||||||
|
os.makedirs(DATA_ROOT, exist_ok=True)
|
||||||
|
|
||||||
|
docker_manager = BotDockerManager(host_data_root=BOTS_WORKSPACE_ROOT)
|
||||||
|
config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT)
|
||||||
|
speech_service = WhisperSpeechService()
|
||||||
|
app.state.docker_manager = docker_manager
|
||||||
|
app.state.speech_service = speech_service
|
||||||
|
BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$")
|
||||||
|
|
||||||
|
runtime_assembly = assemble_app_runtime(
|
||||||
|
app=app,
|
||||||
|
logger=logger,
|
||||||
|
bots_workspace_root=BOTS_WORKSPACE_ROOT,
|
||||||
|
data_root=DATA_ROOT,
|
||||||
|
docker_manager=docker_manager,
|
||||||
|
config_manager=config_manager,
|
||||||
|
speech_service=speech_service,
|
||||||
|
bot_id_pattern=BOT_ID_PATTERN,
|
||||||
|
)
|
||||||
|
app.include_router(build_system_runtime_router(system_service=runtime_assembly.system_service))
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def bot_access_password_guard(request: Request, call_next):
|
||||||
|
return await runtime_assembly.dashboard_auth_service.guard(request, call_next)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def on_startup():
|
||||||
|
await runtime_assembly.app_lifecycle_service.on_startup()
|
||||||
|
|
@ -0,0 +1,482 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from clients.edge.errors import is_expected_edge_offline_error, log_edge_failure, summarize_edge_exception
|
||||||
|
from core.cache import cache
|
||||||
|
from core.database import engine, init_database
|
||||||
|
from core.settings import (
|
||||||
|
AGENT_MD_TEMPLATES_FILE,
|
||||||
|
DATABASE_ECHO,
|
||||||
|
DATABASE_ENGINE,
|
||||||
|
DATABASE_URL_DISPLAY,
|
||||||
|
DEFAULT_AGENTS_MD,
|
||||||
|
DEFAULT_BOT_SYSTEM_TIMEZONE,
|
||||||
|
DEFAULT_IDENTITY_MD,
|
||||||
|
DEFAULT_SOUL_MD,
|
||||||
|
DEFAULT_TOOLS_MD,
|
||||||
|
DEFAULT_USER_MD,
|
||||||
|
PROJECT_ROOT,
|
||||||
|
REDIS_ENABLED,
|
||||||
|
REDIS_PREFIX,
|
||||||
|
REDIS_URL,
|
||||||
|
TOPIC_PRESET_TEMPLATES,
|
||||||
|
TOPIC_PRESETS_TEMPLATES_FILE,
|
||||||
|
load_agent_md_templates,
|
||||||
|
load_topic_presets_template,
|
||||||
|
)
|
||||||
|
from providers.provision.edge import EdgeProvisionProvider
|
||||||
|
from providers.provision.local import LocalProvisionProvider
|
||||||
|
from providers.registry import ProviderRegistry
|
||||||
|
from providers.runtime.edge import EdgeRuntimeProvider
|
||||||
|
from providers.runtime.local import LocalRuntimeProvider
|
||||||
|
from providers.selector import get_provision_provider, get_runtime_provider
|
||||||
|
from providers.target import ProviderTarget, normalize_provider_target, provider_target_from_config, provider_target_to_dict
|
||||||
|
from providers.workspace.edge import EdgeWorkspaceProvider
|
||||||
|
from providers.workspace.local import LocalWorkspaceProvider
|
||||||
|
from services.app_lifecycle_service import AppLifecycleService
|
||||||
|
from services.bot_channel_service import BotChannelService
|
||||||
|
from services.bot_command_service import BotCommandService
|
||||||
|
from services.bot_config_state_service import BotConfigStateService
|
||||||
|
from services.bot_infra_service import BotInfraService
|
||||||
|
from services.bot_lifecycle_service import BotLifecycleService
|
||||||
|
from services.bot_message_service import BotMessageService
|
||||||
|
from services.bot_query_service import BotQueryService
|
||||||
|
from services.bot_runtime_snapshot_service import BotRuntimeSnapshotService
|
||||||
|
from services.dashboard_auth_service import DashboardAuthService
|
||||||
|
from services.image_service import ImageService
|
||||||
|
from services.node_registry_service import NodeRegistryService
|
||||||
|
from services.platform_activity_service import (
|
||||||
|
prune_expired_activity_events,
|
||||||
|
record_activity_event,
|
||||||
|
)
|
||||||
|
from services.platform_settings_service import (
|
||||||
|
get_chat_pull_page_size,
|
||||||
|
get_platform_settings_snapshot,
|
||||||
|
get_speech_runtime_settings,
|
||||||
|
)
|
||||||
|
from services.platform_usage_service import (
|
||||||
|
bind_usage_message,
|
||||||
|
create_usage_request,
|
||||||
|
fail_latest_usage,
|
||||||
|
finalize_usage_from_packet,
|
||||||
|
)
|
||||||
|
from services.provider_test_service import ProviderTestService
|
||||||
|
from services.runtime_event_service import RuntimeEventService
|
||||||
|
from services.runtime_service import RuntimeService
|
||||||
|
from services.skill_service import SkillService
|
||||||
|
from services.system_service import SystemService
|
||||||
|
from services.topic_runtime import publish_runtime_topic_packet
|
||||||
|
from services.workspace_service import WorkspaceService
|
||||||
|
from bootstrap.app_runtime_support import (
|
||||||
|
attach_runtime_services,
|
||||||
|
build_image_runtime_service,
|
||||||
|
build_speech_transcription_runtime_service,
|
||||||
|
build_system_runtime_service,
|
||||||
|
include_dashboard_api,
|
||||||
|
reconcile_image_registry,
|
||||||
|
register_provider_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppRuntimeAssembly:
|
||||||
|
dashboard_auth_service: DashboardAuthService
|
||||||
|
system_service: SystemService
|
||||||
|
app_lifecycle_service: AppLifecycleService
|
||||||
|
def assemble_app_runtime(
|
||||||
|
*,
|
||||||
|
app: Any,
|
||||||
|
logger: Any,
|
||||||
|
bots_workspace_root: str,
|
||||||
|
data_root: str,
|
||||||
|
docker_manager: Any,
|
||||||
|
config_manager: Any,
|
||||||
|
speech_service: Any,
|
||||||
|
bot_id_pattern: Any,
|
||||||
|
) -> AppRuntimeAssembly:
|
||||||
|
node_registry_service = NodeRegistryService()
|
||||||
|
skill_service = SkillService()
|
||||||
|
dashboard_auth_service = DashboardAuthService(engine=engine)
|
||||||
|
provider_registry = ProviderRegistry()
|
||||||
|
|
||||||
|
bot_infra_service = BotInfraService(
|
||||||
|
app=app,
|
||||||
|
engine=engine,
|
||||||
|
config_manager=config_manager,
|
||||||
|
node_registry_service=node_registry_service,
|
||||||
|
logger=logger,
|
||||||
|
bots_workspace_root=bots_workspace_root,
|
||||||
|
default_soul_md=DEFAULT_SOUL_MD,
|
||||||
|
default_agents_md=DEFAULT_AGENTS_MD,
|
||||||
|
default_user_md=DEFAULT_USER_MD,
|
||||||
|
default_tools_md=DEFAULT_TOOLS_MD,
|
||||||
|
default_identity_md=DEFAULT_IDENTITY_MD,
|
||||||
|
default_bot_system_timezone=DEFAULT_BOT_SYSTEM_TIMEZONE,
|
||||||
|
normalize_provider_target=normalize_provider_target,
|
||||||
|
provider_target_from_config=provider_target_from_config,
|
||||||
|
provider_target_to_dict=provider_target_to_dict,
|
||||||
|
resolve_provider_bundle_key=lambda target: provider_registry.resolve_bundle_key(target),
|
||||||
|
get_provision_provider=get_provision_provider,
|
||||||
|
read_env_store=lambda bot_id: bot_config_state_service.read_env_store(bot_id),
|
||||||
|
read_bot_runtime_snapshot=lambda bot: _read_bot_runtime_snapshot(bot),
|
||||||
|
normalize_media_list=lambda raw, bot_id: _normalize_media_list(raw, bot_id),
|
||||||
|
)
|
||||||
|
node_registry_service.register_node(bot_infra_service.local_managed_node())
|
||||||
|
app.state.node_registry_service = node_registry_service
|
||||||
|
|
||||||
|
_read_bot_config = bot_infra_service.read_bot_config
|
||||||
|
_write_bot_config = bot_infra_service.write_bot_config
|
||||||
|
_default_provider_target = bot_infra_service.default_provider_target
|
||||||
|
_read_bot_provider_target = bot_infra_service.read_bot_provider_target
|
||||||
|
_resolve_bot_provider_target_for_instance = bot_infra_service.resolve_bot_provider_target_for_instance
|
||||||
|
_clear_provider_target_override = bot_infra_service.clear_provider_target_override
|
||||||
|
_apply_provider_target_to_bot = bot_infra_service.apply_provider_target_to_bot
|
||||||
|
_local_managed_node = bot_infra_service.local_managed_node
|
||||||
|
_provider_target_from_node = bot_infra_service.provider_target_from_node
|
||||||
|
_node_display_name = bot_infra_service.node_display_name
|
||||||
|
_node_metadata = bot_infra_service.node_metadata
|
||||||
|
_serialize_provider_target_summary = bot_infra_service.serialize_provider_target_summary
|
||||||
|
_resolve_edge_client = bot_infra_service.resolve_edge_client
|
||||||
|
_resolve_edge_state_context = bot_infra_service.resolve_edge_state_context
|
||||||
|
_read_edge_state_data = bot_infra_service.read_edge_state_data
|
||||||
|
_write_edge_state_data = bot_infra_service.write_edge_state_data
|
||||||
|
_read_bot_resources = bot_infra_service.read_bot_resources
|
||||||
|
_migrate_bot_resources_store = bot_infra_service.migrate_bot_resources_store
|
||||||
|
_normalize_channel_extra = bot_infra_service.normalize_channel_extra
|
||||||
|
_read_global_delivery_flags = bot_infra_service.read_global_delivery_flags
|
||||||
|
_channel_api_to_cfg = bot_infra_service.channel_api_to_cfg
|
||||||
|
_get_bot_channels_from_config = bot_infra_service.get_bot_channels_from_config
|
||||||
|
_normalize_initial_channels = bot_infra_service.normalize_initial_channels
|
||||||
|
_parse_message_media = bot_infra_service.parse_message_media
|
||||||
|
_normalize_env_params = bot_infra_service.normalize_env_params
|
||||||
|
_get_default_system_timezone = bot_infra_service.get_default_system_timezone
|
||||||
|
_normalize_system_timezone = bot_infra_service.normalize_system_timezone
|
||||||
|
_resolve_bot_env_params = bot_infra_service.resolve_bot_env_params
|
||||||
|
_safe_float = bot_infra_service.safe_float
|
||||||
|
_safe_int = bot_infra_service.safe_int
|
||||||
|
_normalize_resource_limits = bot_infra_service.normalize_resource_limits
|
||||||
|
_sync_workspace_channels = bot_infra_service.sync_workspace_channels
|
||||||
|
_set_bot_provider_target = bot_infra_service.set_bot_provider_target
|
||||||
|
_sync_bot_workspace_via_provider = bot_infra_service.sync_bot_workspace_via_provider
|
||||||
|
_workspace_root = bot_infra_service.workspace_root
|
||||||
|
_cron_store_path = bot_infra_service.cron_store_path
|
||||||
|
_env_store_path = bot_infra_service.env_store_path
|
||||||
|
_clear_bot_sessions = bot_infra_service.clear_bot_sessions
|
||||||
|
_clear_bot_dashboard_direct_session = bot_infra_service.clear_bot_dashboard_direct_session
|
||||||
|
_ensure_provider_target_supported = bot_infra_service.ensure_provider_target_supported
|
||||||
|
_resolve_workspace_path = bot_infra_service.resolve_workspace_path
|
||||||
|
_calc_dir_size_bytes = bot_infra_service.calc_dir_size_bytes
|
||||||
|
_is_video_attachment_path = bot_infra_service.is_video_attachment_path
|
||||||
|
_is_visual_attachment_path = bot_infra_service.is_visual_attachment_path
|
||||||
|
|
||||||
|
bot_config_state_service = BotConfigStateService(
|
||||||
|
read_edge_state_data=_read_edge_state_data,
|
||||||
|
write_edge_state_data=_write_edge_state_data,
|
||||||
|
read_bot_config=_read_bot_config,
|
||||||
|
write_bot_config=_write_bot_config,
|
||||||
|
invalidate_bot_detail_cache=lambda *args, **kwargs: _invalidate_bot_detail_cache(*args, **kwargs),
|
||||||
|
env_store_path=_env_store_path,
|
||||||
|
cron_store_path=_cron_store_path,
|
||||||
|
normalize_env_params=_normalize_env_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _write_env_store(bot_id: str, env_params: Dict[str, str]) -> None:
|
||||||
|
bot_config_state_service.write_env_store(bot_id, env_params)
|
||||||
|
|
||||||
|
local_provision_provider = LocalProvisionProvider(sync_workspace_func=_sync_workspace_channels)
|
||||||
|
local_runtime_provider = LocalRuntimeProvider(
|
||||||
|
docker_manager=docker_manager,
|
||||||
|
on_state_change=lambda *args, **kwargs: docker_callback(*args, **kwargs),
|
||||||
|
provision_provider=local_provision_provider,
|
||||||
|
read_runtime_snapshot=lambda *args, **kwargs: _read_bot_runtime_snapshot(*args, **kwargs),
|
||||||
|
resolve_env_params=_resolve_bot_env_params,
|
||||||
|
write_env_store=_write_env_store,
|
||||||
|
invalidate_bot_cache=lambda *args, **kwargs: _invalidate_bot_detail_cache(*args, **kwargs),
|
||||||
|
record_agent_loop_ready_warning=lambda *args, **kwargs: _record_agent_loop_ready_warning(*args, **kwargs),
|
||||||
|
safe_float=_safe_float,
|
||||||
|
safe_int=_safe_int,
|
||||||
|
)
|
||||||
|
local_workspace_provider = LocalWorkspaceProvider()
|
||||||
|
edge_provision_provider = EdgeProvisionProvider(
|
||||||
|
read_provider_target=_read_bot_provider_target,
|
||||||
|
resolve_edge_client=_resolve_edge_client,
|
||||||
|
read_runtime_snapshot=lambda *args, **kwargs: _read_bot_runtime_snapshot(*args, **kwargs),
|
||||||
|
read_bot_channels=_get_bot_channels_from_config,
|
||||||
|
read_node_metadata=_node_metadata,
|
||||||
|
)
|
||||||
|
edge_runtime_provider = EdgeRuntimeProvider(
|
||||||
|
read_provider_target=_read_bot_provider_target,
|
||||||
|
resolve_edge_client=_resolve_edge_client,
|
||||||
|
read_runtime_snapshot=lambda *args, **kwargs: _read_bot_runtime_snapshot(*args, **kwargs),
|
||||||
|
resolve_env_params=_resolve_bot_env_params,
|
||||||
|
read_bot_channels=_get_bot_channels_from_config,
|
||||||
|
read_node_metadata=_node_metadata,
|
||||||
|
)
|
||||||
|
edge_workspace_provider = EdgeWorkspaceProvider(
|
||||||
|
read_provider_target=_read_bot_provider_target,
|
||||||
|
resolve_edge_client=_resolve_edge_client,
|
||||||
|
read_node_metadata=_node_metadata,
|
||||||
|
)
|
||||||
|
local_provider_target = ProviderTarget(
|
||||||
|
node_id="local",
|
||||||
|
transport_kind="edge",
|
||||||
|
runtime_kind="docker",
|
||||||
|
core_adapter="nanobot",
|
||||||
|
)
|
||||||
|
register_provider_runtime(
|
||||||
|
app=app,
|
||||||
|
provider_registry=provider_registry,
|
||||||
|
local_provider_target=local_provider_target,
|
||||||
|
local_provision_provider=local_provision_provider,
|
||||||
|
local_runtime_provider=local_runtime_provider,
|
||||||
|
local_workspace_provider=local_workspace_provider,
|
||||||
|
edge_provision_provider=edge_provision_provider,
|
||||||
|
edge_runtime_provider=edge_runtime_provider,
|
||||||
|
edge_workspace_provider=edge_workspace_provider,
|
||||||
|
resolve_bot_provider_target_for_instance=_resolve_bot_provider_target_for_instance,
|
||||||
|
resolve_edge_client=_resolve_edge_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
bot_runtime_snapshot_service = BotRuntimeSnapshotService(
|
||||||
|
engine=engine,
|
||||||
|
logger=logger,
|
||||||
|
docker_manager=docker_manager,
|
||||||
|
default_soul_md=DEFAULT_SOUL_MD,
|
||||||
|
default_agents_md=DEFAULT_AGENTS_MD,
|
||||||
|
default_user_md=DEFAULT_USER_MD,
|
||||||
|
default_tools_md=DEFAULT_TOOLS_MD,
|
||||||
|
default_identity_md=DEFAULT_IDENTITY_MD,
|
||||||
|
workspace_root=_workspace_root,
|
||||||
|
resolve_edge_state_context=_resolve_edge_state_context,
|
||||||
|
read_bot_config=_read_bot_config,
|
||||||
|
resolve_bot_env_params=_resolve_bot_env_params,
|
||||||
|
resolve_bot_provider_target_for_instance=_resolve_bot_provider_target_for_instance,
|
||||||
|
read_global_delivery_flags=_read_global_delivery_flags,
|
||||||
|
safe_float=_safe_float,
|
||||||
|
safe_int=_safe_int,
|
||||||
|
get_default_system_timezone=_get_default_system_timezone,
|
||||||
|
read_bot_resources=_read_bot_resources,
|
||||||
|
node_display_name=_node_display_name,
|
||||||
|
get_runtime_provider=get_runtime_provider,
|
||||||
|
invalidate_bot_detail_cache=lambda *args, **kwargs: _invalidate_bot_detail_cache(*args, **kwargs),
|
||||||
|
record_activity_event=record_activity_event,
|
||||||
|
)
|
||||||
|
_read_bot_runtime_snapshot = bot_runtime_snapshot_service.read_bot_runtime_snapshot
|
||||||
|
_serialize_bot = bot_runtime_snapshot_service.serialize_bot
|
||||||
|
_serialize_bot_list_item = bot_runtime_snapshot_service.serialize_bot_list_item
|
||||||
|
_refresh_bot_runtime_status = bot_runtime_snapshot_service.refresh_bot_runtime_status
|
||||||
|
_record_agent_loop_ready_warning = bot_runtime_snapshot_service.record_agent_loop_ready_warning
|
||||||
|
|
||||||
|
runtime_event_service = RuntimeEventService(
|
||||||
|
app=app,
|
||||||
|
engine=engine,
|
||||||
|
cache=cache,
|
||||||
|
logger=logger,
|
||||||
|
publish_runtime_topic_packet=publish_runtime_topic_packet,
|
||||||
|
bind_usage_message=bind_usage_message,
|
||||||
|
finalize_usage_from_packet=finalize_usage_from_packet,
|
||||||
|
workspace_root=_workspace_root,
|
||||||
|
parse_message_media=_parse_message_media,
|
||||||
|
)
|
||||||
|
_normalize_media_list = runtime_event_service.normalize_media_list
|
||||||
|
_persist_runtime_packet = runtime_event_service.persist_runtime_packet
|
||||||
|
_broadcast_runtime_packet = runtime_event_service.broadcast_runtime_packet
|
||||||
|
docker_callback = runtime_event_service.docker_callback
|
||||||
|
_cache_key_bots_list = runtime_event_service.cache_key_bots_list
|
||||||
|
_cache_key_bot_detail = runtime_event_service.cache_key_bot_detail
|
||||||
|
_cache_key_bot_messages = runtime_event_service.cache_key_bot_messages
|
||||||
|
_cache_key_bot_messages_page = runtime_event_service.cache_key_bot_messages_page
|
||||||
|
_serialize_bot_message_row = runtime_event_service.serialize_bot_message_row
|
||||||
|
_resolve_local_day_range = runtime_event_service.resolve_local_day_range
|
||||||
|
_cache_key_images = runtime_event_service.cache_key_images
|
||||||
|
_invalidate_bot_detail_cache = runtime_event_service.invalidate_bot_detail_cache
|
||||||
|
_invalidate_bot_messages_cache = runtime_event_service.invalidate_bot_messages_cache
|
||||||
|
_invalidate_images_cache = runtime_event_service.invalidate_images_cache
|
||||||
|
|
||||||
|
bot_command_service = BotCommandService(
|
||||||
|
read_runtime_snapshot=_read_bot_runtime_snapshot,
|
||||||
|
normalize_media_list=_normalize_media_list,
|
||||||
|
resolve_workspace_path=_resolve_workspace_path,
|
||||||
|
is_visual_attachment_path=_is_visual_attachment_path,
|
||||||
|
is_video_attachment_path=_is_video_attachment_path,
|
||||||
|
create_usage_request=create_usage_request,
|
||||||
|
record_activity_event=record_activity_event,
|
||||||
|
fail_latest_usage=fail_latest_usage,
|
||||||
|
persist_runtime_packet=_persist_runtime_packet,
|
||||||
|
get_main_loop=lambda app_state: getattr(app_state, "main_loop", None),
|
||||||
|
broadcast_packet=_broadcast_runtime_packet,
|
||||||
|
)
|
||||||
|
workspace_service = WorkspaceService()
|
||||||
|
runtime_service = RuntimeService(
|
||||||
|
command_service=bot_command_service,
|
||||||
|
resolve_runtime_provider=get_runtime_provider,
|
||||||
|
clear_bot_sessions=_clear_bot_sessions,
|
||||||
|
clear_dashboard_direct_session_file=_clear_bot_dashboard_direct_session,
|
||||||
|
invalidate_bot_detail_cache=_invalidate_bot_detail_cache,
|
||||||
|
invalidate_bot_messages_cache=_invalidate_bot_messages_cache,
|
||||||
|
record_activity_event=record_activity_event,
|
||||||
|
)
|
||||||
|
app_lifecycle_service = AppLifecycleService(
|
||||||
|
app=app,
|
||||||
|
engine=engine,
|
||||||
|
cache=cache,
|
||||||
|
logger=logger,
|
||||||
|
project_root=PROJECT_ROOT,
|
||||||
|
database_engine=DATABASE_ENGINE,
|
||||||
|
database_echo=DATABASE_ECHO,
|
||||||
|
database_url_display=DATABASE_URL_DISPLAY,
|
||||||
|
redis_enabled=REDIS_ENABLED,
|
||||||
|
init_database=init_database,
|
||||||
|
node_registry_service=node_registry_service,
|
||||||
|
local_managed_node=_local_managed_node,
|
||||||
|
prune_expired_activity_events=prune_expired_activity_events,
|
||||||
|
migrate_bot_resources_store=_migrate_bot_resources_store,
|
||||||
|
resolve_bot_provider_target_for_instance=_resolve_bot_provider_target_for_instance,
|
||||||
|
default_provider_target=_default_provider_target,
|
||||||
|
set_bot_provider_target=_set_bot_provider_target,
|
||||||
|
apply_provider_target_to_bot=_apply_provider_target_to_bot,
|
||||||
|
normalize_provider_target=normalize_provider_target,
|
||||||
|
runtime_service=runtime_service,
|
||||||
|
runtime_event_service=runtime_event_service,
|
||||||
|
clear_provider_target_overrides=bot_infra_service.clear_provider_target_overrides,
|
||||||
|
)
|
||||||
|
bot_query_service = BotQueryService(
|
||||||
|
cache=cache,
|
||||||
|
cache_key_bots_list=_cache_key_bots_list,
|
||||||
|
cache_key_bot_detail=_cache_key_bot_detail,
|
||||||
|
refresh_bot_runtime_status=_refresh_bot_runtime_status,
|
||||||
|
serialize_bot=_serialize_bot,
|
||||||
|
serialize_bot_list_item=_serialize_bot_list_item,
|
||||||
|
read_bot_resources=_read_bot_resources,
|
||||||
|
resolve_bot_provider_target=_resolve_bot_provider_target_for_instance,
|
||||||
|
get_runtime_provider=get_runtime_provider,
|
||||||
|
workspace_root=_workspace_root,
|
||||||
|
calc_dir_size_bytes=_calc_dir_size_bytes,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
bot_channel_service = BotChannelService(
|
||||||
|
read_bot_config=_read_bot_config,
|
||||||
|
write_bot_config=_write_bot_config,
|
||||||
|
sync_bot_workspace_via_provider=_sync_bot_workspace_via_provider,
|
||||||
|
invalidate_bot_detail_cache=_invalidate_bot_detail_cache,
|
||||||
|
get_bot_channels_from_config=_get_bot_channels_from_config,
|
||||||
|
normalize_channel_extra=_normalize_channel_extra,
|
||||||
|
channel_api_to_cfg=_channel_api_to_cfg,
|
||||||
|
read_global_delivery_flags=_read_global_delivery_flags,
|
||||||
|
)
|
||||||
|
bot_message_service = BotMessageService(
|
||||||
|
cache=cache,
|
||||||
|
cache_key_bot_messages=_cache_key_bot_messages,
|
||||||
|
cache_key_bot_messages_page=_cache_key_bot_messages_page,
|
||||||
|
serialize_bot_message_row=_serialize_bot_message_row,
|
||||||
|
resolve_local_day_range=_resolve_local_day_range,
|
||||||
|
invalidate_bot_messages_cache=_invalidate_bot_messages_cache,
|
||||||
|
get_chat_pull_page_size=get_chat_pull_page_size,
|
||||||
|
)
|
||||||
|
speech_transcription_service = build_speech_transcription_runtime_service(
|
||||||
|
data_root=data_root,
|
||||||
|
speech_service=speech_service,
|
||||||
|
get_speech_runtime_settings=get_speech_runtime_settings,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
image_service = build_image_runtime_service(
|
||||||
|
cache=cache,
|
||||||
|
cache_key_images=_cache_key_images,
|
||||||
|
invalidate_images_cache=_invalidate_images_cache,
|
||||||
|
docker_manager=docker_manager,
|
||||||
|
reconcile_image_registry_fn=lambda session: reconcile_image_registry(session, docker_manager=docker_manager),
|
||||||
|
)
|
||||||
|
provider_test_service = ProviderTestService()
|
||||||
|
system_service = build_system_runtime_service(
|
||||||
|
engine=engine,
|
||||||
|
cache=cache,
|
||||||
|
database_engine=DATABASE_ENGINE,
|
||||||
|
redis_enabled=REDIS_ENABLED,
|
||||||
|
redis_url=REDIS_URL,
|
||||||
|
redis_prefix=REDIS_PREFIX,
|
||||||
|
agent_md_templates_file=str(AGENT_MD_TEMPLATES_FILE),
|
||||||
|
topic_presets_templates_file=str(TOPIC_PRESETS_TEMPLATES_FILE),
|
||||||
|
default_soul_md=DEFAULT_SOUL_MD,
|
||||||
|
default_agents_md=DEFAULT_AGENTS_MD,
|
||||||
|
default_user_md=DEFAULT_USER_MD,
|
||||||
|
default_tools_md=DEFAULT_TOOLS_MD,
|
||||||
|
default_identity_md=DEFAULT_IDENTITY_MD,
|
||||||
|
topic_preset_templates=TOPIC_PRESET_TEMPLATES,
|
||||||
|
get_default_system_timezone=_get_default_system_timezone,
|
||||||
|
load_agent_md_templates=load_agent_md_templates,
|
||||||
|
load_topic_presets_template=load_topic_presets_template,
|
||||||
|
get_platform_settings_snapshot=get_platform_settings_snapshot,
|
||||||
|
get_speech_runtime_settings=get_speech_runtime_settings,
|
||||||
|
)
|
||||||
|
bot_lifecycle_service = BotLifecycleService(
|
||||||
|
bot_id_pattern=bot_id_pattern,
|
||||||
|
runtime_service=runtime_service,
|
||||||
|
refresh_bot_runtime_status=_refresh_bot_runtime_status,
|
||||||
|
resolve_bot_provider_target=_resolve_bot_provider_target_for_instance,
|
||||||
|
provider_target_from_node=_provider_target_from_node,
|
||||||
|
default_provider_target=_default_provider_target,
|
||||||
|
ensure_provider_target_supported=_ensure_provider_target_supported,
|
||||||
|
require_ready_image=image_service.require_ready_image,
|
||||||
|
sync_bot_workspace_via_provider=_sync_bot_workspace_via_provider,
|
||||||
|
apply_provider_target_to_bot=_apply_provider_target_to_bot,
|
||||||
|
serialize_provider_target_summary=_serialize_provider_target_summary,
|
||||||
|
serialize_bot=_serialize_bot,
|
||||||
|
node_display_name=_node_display_name,
|
||||||
|
invalidate_bot_detail_cache=_invalidate_bot_detail_cache,
|
||||||
|
record_activity_event=record_activity_event,
|
||||||
|
normalize_env_params=_normalize_env_params,
|
||||||
|
normalize_system_timezone=_normalize_system_timezone,
|
||||||
|
normalize_resource_limits=_normalize_resource_limits,
|
||||||
|
write_env_store=_write_env_store,
|
||||||
|
resolve_bot_env_params=_resolve_bot_env_params,
|
||||||
|
clear_provider_target_override=_clear_provider_target_override,
|
||||||
|
normalize_initial_channels=_normalize_initial_channels,
|
||||||
|
is_expected_edge_offline_error=is_expected_edge_offline_error,
|
||||||
|
summarize_edge_exception=summarize_edge_exception,
|
||||||
|
resolve_edge_client=_resolve_edge_client,
|
||||||
|
node_metadata=_node_metadata,
|
||||||
|
log_edge_failure=log_edge_failure,
|
||||||
|
invalidate_bot_messages_cache=_invalidate_bot_messages_cache,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
attach_runtime_services(
|
||||||
|
app=app,
|
||||||
|
bot_command_service=bot_command_service,
|
||||||
|
bot_lifecycle_service=bot_lifecycle_service,
|
||||||
|
app_lifecycle_service=app_lifecycle_service,
|
||||||
|
bot_query_service=bot_query_service,
|
||||||
|
bot_channel_service=bot_channel_service,
|
||||||
|
bot_message_service=bot_message_service,
|
||||||
|
bot_runtime_snapshot_service=bot_runtime_snapshot_service,
|
||||||
|
image_service=image_service,
|
||||||
|
provider_test_service=provider_test_service,
|
||||||
|
runtime_event_service=runtime_event_service,
|
||||||
|
speech_transcription_service=speech_transcription_service,
|
||||||
|
system_service=system_service,
|
||||||
|
workspace_service=workspace_service,
|
||||||
|
runtime_service=runtime_service,
|
||||||
|
)
|
||||||
|
include_dashboard_api(
|
||||||
|
app=app,
|
||||||
|
image_service=image_service,
|
||||||
|
provider_test_service=provider_test_service,
|
||||||
|
bot_lifecycle_service=bot_lifecycle_service,
|
||||||
|
bot_query_service=bot_query_service,
|
||||||
|
bot_channel_service=bot_channel_service,
|
||||||
|
skill_service=skill_service,
|
||||||
|
bot_config_state_service=bot_config_state_service,
|
||||||
|
runtime_service=runtime_service,
|
||||||
|
bot_message_service=bot_message_service,
|
||||||
|
workspace_service=workspace_service,
|
||||||
|
speech_transcription_service=speech_transcription_service,
|
||||||
|
app_lifecycle_service=app_lifecycle_service,
|
||||||
|
resolve_edge_state_context=_resolve_edge_state_context,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AppRuntimeAssembly(
|
||||||
|
dashboard_auth_service=dashboard_auth_service,
|
||||||
|
system_service=system_service,
|
||||||
|
app_lifecycle_service=app_lifecycle_service,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from api.dashboard_router import build_dashboard_router
|
||||||
|
from models.bot import NanobotImage
|
||||||
|
from services.image_service import ImageService
|
||||||
|
from services.speech_transcription_service import SpeechTranscriptionService
|
||||||
|
from services.system_service import SystemService
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_image_registry(session: Session, *, docker_manager: Any) -> None:
|
||||||
|
db_images = session.exec(select(NanobotImage)).all()
|
||||||
|
for image in db_images:
|
||||||
|
if docker_manager.has_image(image.tag):
|
||||||
|
try:
|
||||||
|
docker_image = docker_manager.client.images.get(image.tag) if docker_manager.client else None
|
||||||
|
image.image_id = docker_image.id if docker_image else image.image_id
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
image.status = "READY"
|
||||||
|
else:
|
||||||
|
image.status = "UNKNOWN"
|
||||||
|
session.add(image)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def register_provider_runtime(
|
||||||
|
*,
|
||||||
|
app: Any,
|
||||||
|
provider_registry: Any,
|
||||||
|
local_provider_target: Any,
|
||||||
|
local_provision_provider: Any,
|
||||||
|
local_runtime_provider: Any,
|
||||||
|
local_workspace_provider: Any,
|
||||||
|
edge_provision_provider: Any,
|
||||||
|
edge_runtime_provider: Any,
|
||||||
|
edge_workspace_provider: Any,
|
||||||
|
resolve_bot_provider_target_for_instance: Any,
|
||||||
|
resolve_edge_client: Any,
|
||||||
|
) -> None:
|
||||||
|
provider_registry.register_bundle(
|
||||||
|
key=local_provider_target.key,
|
||||||
|
runtime_provider=local_runtime_provider,
|
||||||
|
workspace_provider=local_workspace_provider,
|
||||||
|
provision_provider=local_provision_provider,
|
||||||
|
)
|
||||||
|
provider_registry.register_bundle(
|
||||||
|
key=type(local_provider_target)(
|
||||||
|
node_id="local",
|
||||||
|
transport_kind="edge",
|
||||||
|
runtime_kind="docker",
|
||||||
|
core_adapter="nanobot",
|
||||||
|
).key,
|
||||||
|
runtime_provider=edge_runtime_provider,
|
||||||
|
workspace_provider=edge_workspace_provider,
|
||||||
|
provision_provider=edge_provision_provider,
|
||||||
|
)
|
||||||
|
provider_registry.register_bundle(
|
||||||
|
key=type(local_provider_target)(
|
||||||
|
node_id="local",
|
||||||
|
transport_kind="edge",
|
||||||
|
runtime_kind="native",
|
||||||
|
core_adapter="nanobot",
|
||||||
|
).key,
|
||||||
|
runtime_provider=edge_runtime_provider,
|
||||||
|
workspace_provider=edge_workspace_provider,
|
||||||
|
provision_provider=edge_provision_provider,
|
||||||
|
)
|
||||||
|
app.state.provider_default_node_id = local_provider_target.node_id
|
||||||
|
app.state.provider_default_transport_kind = local_provider_target.transport_kind
|
||||||
|
app.state.provider_default_runtime_kind = local_provider_target.runtime_kind
|
||||||
|
app.state.provider_default_core_adapter = local_provider_target.core_adapter
|
||||||
|
app.state.provider_registry = provider_registry
|
||||||
|
app.state.resolve_bot_provider_target = resolve_bot_provider_target_for_instance
|
||||||
|
app.state.resolve_edge_client = resolve_edge_client
|
||||||
|
app.state.edge_provision_provider = edge_provision_provider
|
||||||
|
app.state.edge_runtime_provider = edge_runtime_provider
|
||||||
|
app.state.edge_workspace_provider = edge_workspace_provider
|
||||||
|
app.state.provision_provider = local_provision_provider
|
||||||
|
app.state.runtime_provider = local_runtime_provider
|
||||||
|
app.state.workspace_provider = local_workspace_provider
|
||||||
|
|
||||||
|
|
||||||
|
def build_speech_transcription_runtime_service(
|
||||||
|
*,
|
||||||
|
data_root: str,
|
||||||
|
speech_service: Any,
|
||||||
|
get_speech_runtime_settings: Any,
|
||||||
|
logger: Any,
|
||||||
|
) -> SpeechTranscriptionService:
|
||||||
|
return SpeechTranscriptionService(
|
||||||
|
data_root=data_root,
|
||||||
|
speech_service=speech_service,
|
||||||
|
get_speech_runtime_settings=get_speech_runtime_settings,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_image_runtime_service(
|
||||||
|
*,
|
||||||
|
cache: Any,
|
||||||
|
cache_key_images: Any,
|
||||||
|
invalidate_images_cache: Any,
|
||||||
|
docker_manager: Any,
|
||||||
|
reconcile_image_registry_fn: Any,
|
||||||
|
) -> ImageService:
|
||||||
|
return ImageService(
|
||||||
|
cache=cache,
|
||||||
|
cache_key_images=cache_key_images,
|
||||||
|
invalidate_images_cache=invalidate_images_cache,
|
||||||
|
reconcile_image_registry=reconcile_image_registry_fn,
|
||||||
|
docker_manager=docker_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_runtime_service(
|
||||||
|
*,
|
||||||
|
engine: Any,
|
||||||
|
cache: Any,
|
||||||
|
database_engine: str,
|
||||||
|
redis_enabled: bool,
|
||||||
|
redis_url: str,
|
||||||
|
redis_prefix: str,
|
||||||
|
agent_md_templates_file: str,
|
||||||
|
topic_presets_templates_file: str,
|
||||||
|
default_soul_md: str,
|
||||||
|
default_agents_md: str,
|
||||||
|
default_user_md: str,
|
||||||
|
default_tools_md: str,
|
||||||
|
default_identity_md: str,
|
||||||
|
topic_preset_templates: Any,
|
||||||
|
get_default_system_timezone: Any,
|
||||||
|
load_agent_md_templates: Any,
|
||||||
|
load_topic_presets_template: Any,
|
||||||
|
get_platform_settings_snapshot: Any,
|
||||||
|
get_speech_runtime_settings: Any,
|
||||||
|
) -> SystemService:
|
||||||
|
return SystemService(
|
||||||
|
engine=engine,
|
||||||
|
cache=cache,
|
||||||
|
database_engine=database_engine,
|
||||||
|
redis_enabled=redis_enabled,
|
||||||
|
redis_url=redis_url,
|
||||||
|
redis_prefix=redis_prefix,
|
||||||
|
agent_md_templates_file=agent_md_templates_file,
|
||||||
|
topic_presets_templates_file=topic_presets_templates_file,
|
||||||
|
default_soul_md=default_soul_md,
|
||||||
|
default_agents_md=default_agents_md,
|
||||||
|
default_user_md=default_user_md,
|
||||||
|
default_tools_md=default_tools_md,
|
||||||
|
default_identity_md=default_identity_md,
|
||||||
|
topic_preset_templates=topic_preset_templates,
|
||||||
|
get_default_system_timezone=get_default_system_timezone,
|
||||||
|
load_agent_md_templates=load_agent_md_templates,
|
||||||
|
load_topic_presets_template=load_topic_presets_template,
|
||||||
|
get_platform_settings_snapshot=get_platform_settings_snapshot,
|
||||||
|
get_speech_runtime_settings=get_speech_runtime_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def attach_runtime_services(
|
||||||
|
*,
|
||||||
|
app: Any,
|
||||||
|
bot_command_service: Any,
|
||||||
|
bot_lifecycle_service: Any,
|
||||||
|
app_lifecycle_service: Any,
|
||||||
|
bot_query_service: Any,
|
||||||
|
bot_channel_service: Any,
|
||||||
|
bot_message_service: Any,
|
||||||
|
bot_runtime_snapshot_service: Any,
|
||||||
|
image_service: Any,
|
||||||
|
provider_test_service: Any,
|
||||||
|
runtime_event_service: Any,
|
||||||
|
speech_transcription_service: Any,
|
||||||
|
system_service: Any,
|
||||||
|
workspace_service: Any,
|
||||||
|
runtime_service: Any,
|
||||||
|
) -> None:
|
||||||
|
app.state.bot_command_service = bot_command_service
|
||||||
|
app.state.bot_lifecycle_service = bot_lifecycle_service
|
||||||
|
app.state.app_lifecycle_service = app_lifecycle_service
|
||||||
|
app.state.bot_query_service = bot_query_service
|
||||||
|
app.state.bot_channel_service = bot_channel_service
|
||||||
|
app.state.bot_message_service = bot_message_service
|
||||||
|
app.state.bot_runtime_snapshot_service = bot_runtime_snapshot_service
|
||||||
|
app.state.image_service = image_service
|
||||||
|
app.state.provider_test_service = provider_test_service
|
||||||
|
app.state.runtime_event_service = runtime_event_service
|
||||||
|
app.state.speech_transcription_service = speech_transcription_service
|
||||||
|
app.state.system_service = system_service
|
||||||
|
app.state.workspace_service = workspace_service
|
||||||
|
app.state.runtime_service = runtime_service
|
||||||
|
|
||||||
|
|
||||||
|
def include_dashboard_api(
|
||||||
|
*,
|
||||||
|
app: Any,
|
||||||
|
image_service: Any,
|
||||||
|
provider_test_service: Any,
|
||||||
|
bot_lifecycle_service: Any,
|
||||||
|
bot_query_service: Any,
|
||||||
|
bot_channel_service: Any,
|
||||||
|
skill_service: Any,
|
||||||
|
bot_config_state_service: Any,
|
||||||
|
runtime_service: Any,
|
||||||
|
bot_message_service: Any,
|
||||||
|
workspace_service: Any,
|
||||||
|
speech_transcription_service: Any,
|
||||||
|
app_lifecycle_service: Any,
|
||||||
|
resolve_edge_state_context: Any,
|
||||||
|
logger: Any,
|
||||||
|
) -> None:
|
||||||
|
app.include_router(
|
||||||
|
build_dashboard_router(
|
||||||
|
image_service=image_service,
|
||||||
|
provider_test_service=provider_test_service,
|
||||||
|
bot_lifecycle_service=bot_lifecycle_service,
|
||||||
|
bot_query_service=bot_query_service,
|
||||||
|
bot_channel_service=bot_channel_service,
|
||||||
|
skill_service=skill_service,
|
||||||
|
bot_config_state_service=bot_config_state_service,
|
||||||
|
runtime_service=runtime_service,
|
||||||
|
bot_message_service=bot_message_service,
|
||||||
|
workspace_service=workspace_service,
|
||||||
|
speech_transcription_service=speech_transcription_service,
|
||||||
|
app_lifecycle_service=app_lifecycle_service,
|
||||||
|
resolve_edge_state_context=resolve_edge_state_context,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -121,6 +121,17 @@ class EdgeClient(ABC):
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def write_text_file(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
path: str,
|
||||||
|
content: str,
|
||||||
|
workspace_root: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def upload_files(
|
async def upload_files(
|
||||||
self,
|
self,
|
||||||
|
|
@ -132,6 +143,16 @@ class EdgeClient(ABC):
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete_workspace_path(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
path: str,
|
||||||
|
workspace_root: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def serve_file(
|
def serve_file(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
@ -234,6 +235,24 @@ class HttpEdgeClient(EdgeClient):
|
||||||
json=EdgeMarkdownWriteRequest(content=str(content or "")).model_dump(),
|
json=EdgeMarkdownWriteRequest(content=str(content or "")).model_dump(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def write_text_file(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
path: str,
|
||||||
|
content: str,
|
||||||
|
workspace_root: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
params: Dict[str, Any] = {"path": path}
|
||||||
|
if workspace_root:
|
||||||
|
params["workspace_root"] = str(workspace_root).strip()
|
||||||
|
return self._request_json(
|
||||||
|
"PUT",
|
||||||
|
f"/api/edge/bots/{bot_id}/workspace/file/text",
|
||||||
|
params=params,
|
||||||
|
json=EdgeMarkdownWriteRequest(content=str(content or "")).model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
async def upload_files(
|
async def upload_files(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -275,6 +294,75 @@ class HttpEdgeClient(EdgeClient):
|
||||||
raise HTTPException(status_code=502, detail="dashboard-edge upload request failed before receiving a response")
|
raise HTTPException(status_code=502, detail="dashboard-edge upload request failed before receiving a response")
|
||||||
return self._parse_json_response(response)
|
return self._parse_json_response(response)
|
||||||
|
|
||||||
|
def delete_workspace_path(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
path: str,
|
||||||
|
workspace_root: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
params: Dict[str, Any] = {"path": path}
|
||||||
|
if workspace_root:
|
||||||
|
params["workspace_root"] = str(workspace_root).strip()
|
||||||
|
return self._request_json(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/edge/bots/{bot_id}/workspace/file",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
def upload_local_files(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
local_paths: List[str],
|
||||||
|
path: Optional[str] = None,
|
||||||
|
workspace_root: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if not local_paths:
|
||||||
|
return {"bot_id": bot_id, "files": []}
|
||||||
|
base_url = self._require_base_url()
|
||||||
|
multipart_files = []
|
||||||
|
handles = []
|
||||||
|
response: httpx.Response | None = None
|
||||||
|
try:
|
||||||
|
for local_path in local_paths:
|
||||||
|
normalized = os.path.abspath(os.path.expanduser(str(local_path or "").strip()))
|
||||||
|
if not os.path.isfile(normalized):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Local upload file not found: {local_path}")
|
||||||
|
handle = open(normalized, "rb")
|
||||||
|
handles.append(handle)
|
||||||
|
multipart_files.append(
|
||||||
|
(
|
||||||
|
"files",
|
||||||
|
(
|
||||||
|
os.path.basename(normalized),
|
||||||
|
handle,
|
||||||
|
mimetypes.guess_type(normalized)[0] or "application/octet-stream",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self._http_client_factory() as client:
|
||||||
|
response = client.request(
|
||||||
|
method="POST",
|
||||||
|
url=f"{base_url}/api/edge/bots/{quote(bot_id, safe='')}/workspace/upload",
|
||||||
|
headers=self._headers(),
|
||||||
|
params=self._workspace_upload_params(path=path, workspace_root=workspace_root),
|
||||||
|
files=multipart_files,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to open local upload file: {exc.strerror or str(exc)}") from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise edge_transport_http_exception(exc, node=self._node) from exc
|
||||||
|
finally:
|
||||||
|
for handle in handles:
|
||||||
|
try:
|
||||||
|
handle.close()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if response is None:
|
||||||
|
raise HTTPException(status_code=502, detail="dashboard-edge upload request failed before receiving a response")
|
||||||
|
return self._parse_json_response(response)
|
||||||
|
|
||||||
def serve_file(
|
def serve_file(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ class BotConfigManager:
|
||||||
"qwen": "dashscope",
|
"qwen": "dashscope",
|
||||||
"aliyun-qwen": "dashscope",
|
"aliyun-qwen": "dashscope",
|
||||||
"moonshot": "kimi",
|
"moonshot": "kimi",
|
||||||
"vllm": "openai",
|
|
||||||
# Xunfei Spark provides OpenAI-compatible endpoint.
|
# Xunfei Spark provides OpenAI-compatible endpoint.
|
||||||
"xunfei": "openai",
|
"xunfei": "openai",
|
||||||
"iflytek": "openai",
|
"iflytek": "openai",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ from core.settings import (
|
||||||
from models import bot as _bot_models # noqa: F401
|
from models import bot as _bot_models # noqa: F401
|
||||||
from models import platform as _platform_models # noqa: F401
|
from models import platform as _platform_models # noqa: F401
|
||||||
from models import skill as _skill_models # noqa: F401
|
from models import skill as _skill_models # noqa: F401
|
||||||
|
from models import sys_auth as _sys_auth_models # noqa: F401
|
||||||
from models import topic as _topic_models # noqa: F401
|
from models import topic as _topic_models # noqa: F401
|
||||||
|
from services.sys_auth_service import seed_sys_auth
|
||||||
|
|
||||||
_engine_kwargs = {
|
_engine_kwargs = {
|
||||||
"echo": DATABASE_ECHO,
|
"echo": DATABASE_ECHO,
|
||||||
|
|
@ -818,6 +820,8 @@ def init_database() -> None:
|
||||||
_cleanup_legacy_default_topics()
|
_cleanup_legacy_default_topics()
|
||||||
_drop_legacy_tables()
|
_drop_legacy_tables()
|
||||||
align_postgres_sequences()
|
align_postgres_sequences()
|
||||||
|
with Session(engine) as session:
|
||||||
|
seed_sys_auth(session)
|
||||||
finally:
|
finally:
|
||||||
_release_migration_lock(lock_conn)
|
_release_migration_lock(lock_conn)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -703,6 +703,12 @@ class BotDockerManager:
|
||||||
if response_match:
|
if response_match:
|
||||||
channel = response_match.group(1).strip().lower()
|
channel = response_match.group(1).strip().lower()
|
||||||
action_msg = response_match.group(2).strip()
|
action_msg = response_match.group(2).strip()
|
||||||
|
if channel == "dashboard":
|
||||||
|
return {
|
||||||
|
"type": "ASSISTANT_MESSAGE",
|
||||||
|
"channel": "dashboard",
|
||||||
|
"text": action_msg[:4000],
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"type": "AGENT_STATE",
|
"type": "AGENT_STATE",
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,11 @@ REDIS_ENABLED: Final[bool] = _env_bool("REDIS_ENABLED", False)
|
||||||
REDIS_URL: Final[str] = str(os.getenv("REDIS_URL") or "").strip()
|
REDIS_URL: Final[str] = str(os.getenv("REDIS_URL") or "").strip()
|
||||||
REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot").strip() or "dashboard_nanobot"
|
REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot").strip() or "dashboard_nanobot"
|
||||||
REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400)
|
REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400)
|
||||||
PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip()
|
JWT_ALGORITHM: Final[str] = "HS256"
|
||||||
|
JWT_SECRET: Final[str] = str(
|
||||||
|
os.getenv("JWT_SECRET")
|
||||||
|
or f"{PROJECT_ROOT.name}:{REDIS_PREFIX}:jwt"
|
||||||
|
).strip()
|
||||||
|
|
||||||
LEGACY_TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve()
|
LEGACY_TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve()
|
||||||
TEMPLATE_ROOT: Final[Path] = (Path(DATA_ROOT) / "templates").resolve()
|
TEMPLATE_ROOT: Final[Path] = (Path(DATA_ROOT) / "templates").resolve()
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from core.settings import STT_DEVICE, STT_MODEL, STT_MODEL_DIR
|
from core.settings import STT_DEVICE, STT_MODEL, STT_MODEL_DIR
|
||||||
from services.platform_service import get_speech_runtime_settings
|
from services.platform_settings_service import get_speech_runtime_settings
|
||||||
|
|
||||||
|
|
||||||
class SpeechServiceError(RuntimeError):
|
class SpeechServiceError(RuntimeError):
|
||||||
|
|
|
||||||
4812
backend/main.py
4812
backend/main.py
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,115 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class SysRole(SQLModel, table=True):
|
||||||
|
__tablename__ = "sys_role"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("role_key", name="uq_sys_role_role_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
role_key: str = Field(index=True, max_length=64)
|
||||||
|
name: str = Field(default="", max_length=120)
|
||||||
|
description: str = Field(default="")
|
||||||
|
is_active: bool = Field(default=True, index=True)
|
||||||
|
sort_order: int = Field(default=100, index=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SysUser(SQLModel, table=True):
|
||||||
|
__tablename__ = "sys_user"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("username", name="uq_sys_user_username"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
username: str = Field(index=True, max_length=64)
|
||||||
|
display_name: str = Field(default="", max_length=120)
|
||||||
|
password_hash: str = Field(default="", max_length=255)
|
||||||
|
password_salt: str = Field(default="", max_length=64)
|
||||||
|
role_id: Optional[int] = Field(default=None, foreign_key="sys_role.id", index=True)
|
||||||
|
is_active: bool = Field(default=True, index=True)
|
||||||
|
last_login_at: Optional[datetime] = Field(default=None, index=True)
|
||||||
|
current_token_hash: Optional[str] = Field(default=None, index=True, max_length=255)
|
||||||
|
current_token_expires_at: Optional[datetime] = Field(default=None, index=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SysMenu(SQLModel, table=True):
|
||||||
|
__tablename__ = "sys_menu"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("menu_key", name="uq_sys_menu_menu_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
menu_key: str = Field(index=True, max_length=64)
|
||||||
|
parent_key: str = Field(default="", index=True, max_length=64)
|
||||||
|
title: str = Field(default="", max_length=120)
|
||||||
|
title_en: str = Field(default="", max_length=120)
|
||||||
|
menu_type: str = Field(default="item", max_length=32, index=True)
|
||||||
|
route_path: str = Field(default="", max_length=255)
|
||||||
|
icon: str = Field(default="", max_length=64)
|
||||||
|
permission_key: str = Field(default="", max_length=120)
|
||||||
|
visible: bool = Field(default=True, index=True)
|
||||||
|
sort_order: int = Field(default=100, index=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SysPermission(SQLModel, table=True):
|
||||||
|
__tablename__ = "sys_permission"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("permission_key", name="uq_sys_permission_permission_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
permission_key: str = Field(index=True, max_length=120)
|
||||||
|
name: str = Field(default="", max_length=120)
|
||||||
|
menu_key: str = Field(default="", index=True, max_length=64)
|
||||||
|
action: str = Field(default="view", max_length=32, index=True)
|
||||||
|
description: str = Field(default="")
|
||||||
|
sort_order: int = Field(default=100, index=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SysRoleMenu(SQLModel, table=True):
|
||||||
|
__tablename__ = "sys_role_menu"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("role_id", "menu_id", name="uq_sys_role_menu_role_menu"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
role_id: int = Field(foreign_key="sys_role.id", index=True)
|
||||||
|
menu_id: int = Field(foreign_key="sys_menu.id", index=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SysRolePermission(SQLModel, table=True):
|
||||||
|
__tablename__ = "sys_role_permission"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("role_id", "permission_id", name="uq_sys_role_permission_role_permission"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
role_id: int = Field(foreign_key="sys_role.id", index=True)
|
||||||
|
permission_id: int = Field(foreign_key="sys_permission.id", index=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SysUserBot(SQLModel, table=True):
|
||||||
|
__tablename__ = "sys_user_bot"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "bot_id", name="uq_sys_user_bot_user_bot"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="sys_user.id", index=True)
|
||||||
|
bot_id: str = Field(foreign_key="bot_instance.id", index=True, max_length=120)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
@ -7,7 +7,7 @@ from sqlmodel import Session
|
||||||
from models.bot import BotInstance
|
from models.bot import BotInstance
|
||||||
from providers.provision.base import ProvisionProvider
|
from providers.provision.base import ProvisionProvider
|
||||||
from providers.runtime.base import RuntimeProvider
|
from providers.runtime.base import RuntimeProvider
|
||||||
from services.platform_service import record_activity_event
|
from services.platform_activity_service import record_activity_event
|
||||||
|
|
||||||
|
|
||||||
class LocalRuntimeProvider(RuntimeProvider):
|
class LocalRuntimeProvider(RuntimeProvider):
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,7 @@ watchfiles==0.21.0
|
||||||
urllib3==1.26.18
|
urllib3==1.26.18
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
redis==5.0.8
|
redis==5.0.8
|
||||||
|
bcrypt==4.2.1
|
||||||
|
PyJWT==2.10.1
|
||||||
opencc-purepy==1.1.0
|
opencc-purepy==1.1.0
|
||||||
pywhispercpp==1.3.1
|
pywhispercpp==1.3.1
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelConfigRequest(BaseModel):
|
||||||
|
channel_type: str
|
||||||
|
external_app_id: Optional[str] = None
|
||||||
|
app_secret: Optional[str] = None
|
||||||
|
internal_port: Optional[int] = None
|
||||||
|
is_active: bool = True
|
||||||
|
extra_config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelConfigUpdateRequest(BaseModel):
|
||||||
|
channel_type: Optional[str] = None
|
||||||
|
external_app_id: Optional[str] = None
|
||||||
|
app_secret: Optional[str] = None
|
||||||
|
internal_port: Optional[int] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
extra_config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BotCreateRequest(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
enabled: Optional[bool] = True
|
||||||
|
llm_provider: str
|
||||||
|
llm_model: str
|
||||||
|
api_key: str
|
||||||
|
image_tag: Optional[str] = None
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
api_base: Optional[str] = None
|
||||||
|
temperature: float = 0.2
|
||||||
|
top_p: float = 1.0
|
||||||
|
max_tokens: int = 8192
|
||||||
|
cpu_cores: float = 1.0
|
||||||
|
memory_mb: int = 1024
|
||||||
|
storage_gb: int = 10
|
||||||
|
system_timezone: Optional[str] = None
|
||||||
|
soul_md: Optional[str] = None
|
||||||
|
agents_md: Optional[str] = None
|
||||||
|
user_md: Optional[str] = None
|
||||||
|
tools_md: Optional[str] = None
|
||||||
|
tools_config: Optional[Dict[str, Any]] = None
|
||||||
|
env_params: Optional[Dict[str, str]] = None
|
||||||
|
identity_md: Optional[str] = None
|
||||||
|
channels: Optional[List[ChannelConfigRequest]] = None
|
||||||
|
send_progress: Optional[bool] = None
|
||||||
|
send_tool_hints: Optional[bool] = None
|
||||||
|
node_id: Optional[str] = None
|
||||||
|
transport_kind: Optional[str] = None
|
||||||
|
runtime_kind: Optional[str] = None
|
||||||
|
core_adapter: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BotUpdateRequest(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
llm_provider: Optional[str] = None
|
||||||
|
llm_model: Optional[str] = None
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
api_base: Optional[str] = None
|
||||||
|
image_tag: Optional[str] = None
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
temperature: Optional[float] = None
|
||||||
|
top_p: Optional[float] = None
|
||||||
|
max_tokens: Optional[int] = None
|
||||||
|
cpu_cores: Optional[float] = None
|
||||||
|
memory_mb: Optional[int] = None
|
||||||
|
storage_gb: Optional[int] = None
|
||||||
|
system_timezone: Optional[str] = None
|
||||||
|
soul_md: Optional[str] = None
|
||||||
|
agents_md: Optional[str] = None
|
||||||
|
user_md: Optional[str] = None
|
||||||
|
tools_md: Optional[str] = None
|
||||||
|
tools_config: Optional[Dict[str, Any]] = None
|
||||||
|
env_params: Optional[Dict[str, str]] = None
|
||||||
|
identity_md: Optional[str] = None
|
||||||
|
send_progress: Optional[bool] = None
|
||||||
|
send_tool_hints: Optional[bool] = None
|
||||||
|
node_id: Optional[str] = None
|
||||||
|
transport_kind: Optional[str] = None
|
||||||
|
runtime_kind: Optional[str] = None
|
||||||
|
core_adapter: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BotDeployRequest(BaseModel):
|
||||||
|
node_id: str
|
||||||
|
runtime_kind: Optional[str] = None
|
||||||
|
image_tag: Optional[str] = None
|
||||||
|
auto_start: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class BotToolsConfigUpdateRequest(BaseModel):
|
||||||
|
tools_config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BotMcpConfigUpdateRequest(BaseModel):
|
||||||
|
mcp_servers: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BotEnvParamsUpdateRequest(BaseModel):
|
||||||
|
env_params: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRequest(BaseModel):
|
||||||
|
command: Optional[str] = None
|
||||||
|
attachments: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageFeedbackRequest(BaseModel):
|
||||||
|
feedback: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceFileUpdateRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class SystemTemplatesUpdateRequest(BaseModel):
|
||||||
|
agent_md_templates: Optional[Dict[str, str]] = None
|
||||||
|
topic_presets: Optional[Dict[str, Any]] = None
|
||||||
|
|
@ -75,6 +75,36 @@ class PlatformActivityItem(BaseModel):
|
||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformActivityListResponse(BaseModel):
|
||||||
|
items: List[PlatformActivityItem] = Field(default_factory=list)
|
||||||
|
total: int = 0
|
||||||
|
limit: int = 20
|
||||||
|
offset: int = 0
|
||||||
|
has_more: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformDashboardUsagePoint(BaseModel):
|
||||||
|
bucket_at: str
|
||||||
|
label: str
|
||||||
|
call_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformDashboardUsageSeries(BaseModel):
|
||||||
|
model: str
|
||||||
|
total_calls: int = 0
|
||||||
|
points: List[PlatformDashboardUsagePoint] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformDashboardAnalyticsResponse(BaseModel):
|
||||||
|
total_request_count: int = 0
|
||||||
|
total_model_count: int = 0
|
||||||
|
granularity: str = "day"
|
||||||
|
since_days: int = 7
|
||||||
|
events_page_size: int = 20
|
||||||
|
series: List[PlatformDashboardUsageSeries] = Field(default_factory=list)
|
||||||
|
recent_events: List[PlatformActivityItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class SystemSettingPayload(BaseModel):
|
class SystemSettingPayload(BaseModel):
|
||||||
key: str
|
key: str
|
||||||
name: str = ""
|
name: str = ""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SysAuthLoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class SysAuthMenuItem(BaseModel):
|
||||||
|
menu_key: str
|
||||||
|
parent_key: str = ""
|
||||||
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
|
menu_type: str = "item"
|
||||||
|
route_path: str = ""
|
||||||
|
icon: str = ""
|
||||||
|
permission_key: str = ""
|
||||||
|
sort_order: int = 100
|
||||||
|
children: List["SysAuthMenuItem"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SysAuthRolePayload(BaseModel):
|
||||||
|
id: int = 0
|
||||||
|
role_key: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class SysAuthUserPayload(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
role: Optional[SysAuthRolePayload] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SysAssignedBotPayload(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
enabled: bool = True
|
||||||
|
node_id: str = ""
|
||||||
|
node_display_name: str = ""
|
||||||
|
docker_status: str = "STOPPED"
|
||||||
|
image_tag: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SysRoleSummaryResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
role_key: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
is_active: bool = True
|
||||||
|
sort_order: int = 100
|
||||||
|
user_count: int = 0
|
||||||
|
menu_keys: List[str] = Field(default_factory=list)
|
||||||
|
permission_keys: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SysRoleListResponse(BaseModel):
|
||||||
|
items: List[SysRoleSummaryResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SysRoleUpsertRequest(BaseModel):
|
||||||
|
role_key: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
is_active: bool = True
|
||||||
|
sort_order: int = 100
|
||||||
|
menu_keys: List[str] = Field(default_factory=list)
|
||||||
|
permission_keys: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SysRoleGrantMenuItem(BaseModel):
|
||||||
|
menu_key: str
|
||||||
|
parent_key: str = ""
|
||||||
|
title: str
|
||||||
|
title_en: str = ""
|
||||||
|
menu_type: str = "item"
|
||||||
|
route_path: str = ""
|
||||||
|
icon: str = ""
|
||||||
|
sort_order: int = 100
|
||||||
|
children: List["SysRoleGrantMenuItem"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SysPermissionSummaryResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
permission_key: str
|
||||||
|
name: str
|
||||||
|
menu_key: str = ""
|
||||||
|
action: str = "view"
|
||||||
|
description: str = ""
|
||||||
|
sort_order: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class SysRoleGrantBootstrapResponse(BaseModel):
|
||||||
|
menus: List[SysRoleGrantMenuItem] = Field(default_factory=list)
|
||||||
|
permissions: List[SysPermissionSummaryResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SysUserSummaryResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
is_active: bool = True
|
||||||
|
last_login_at: Optional[str] = None
|
||||||
|
role: Optional[SysAuthRolePayload] = None
|
||||||
|
bot_ids: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SysUserListResponse(BaseModel):
|
||||||
|
items: List[SysUserSummaryResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SysUserCreateRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
password: str
|
||||||
|
role_id: int
|
||||||
|
is_active: bool = True
|
||||||
|
bot_ids: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SysUserUpdateRequest(BaseModel):
|
||||||
|
display_name: str
|
||||||
|
password: str = ""
|
||||||
|
role_id: int
|
||||||
|
is_active: bool = True
|
||||||
|
bot_ids: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SysProfileUpdateRequest(BaseModel):
|
||||||
|
display_name: str
|
||||||
|
password: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SysAuthBootstrapResponse(BaseModel):
|
||||||
|
token: str = ""
|
||||||
|
expires_at: Optional[str] = None
|
||||||
|
user: SysAuthUserPayload
|
||||||
|
menus: List[SysAuthMenuItem] = Field(default_factory=list)
|
||||||
|
permissions: List[str] = Field(default_factory=list)
|
||||||
|
home_path: str = "/dashboard"
|
||||||
|
assigned_bots: List[SysAssignedBotPayload] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SysAuthStatusResponse(BaseModel):
|
||||||
|
enabled: bool = True
|
||||||
|
user_count: int = 0
|
||||||
|
default_username: str = "admin"
|
||||||
|
|
||||||
|
|
||||||
|
SysAuthMenuItem.model_rebuild()
|
||||||
|
SysRoleGrantMenuItem.model_rebuild()
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from fastapi import HTTPException, WebSocket, WebSocketDisconnect
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from models.bot import BotInstance
|
||||||
|
from models.platform import BotRequestUsage
|
||||||
|
|
||||||
|
|
||||||
|
class AppLifecycleService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app: Any,
|
||||||
|
engine: Any,
|
||||||
|
cache: Any,
|
||||||
|
logger: Any,
|
||||||
|
project_root: str,
|
||||||
|
database_engine: str,
|
||||||
|
database_echo: Any,
|
||||||
|
database_url_display: str,
|
||||||
|
redis_enabled: bool,
|
||||||
|
init_database: Callable[[], None],
|
||||||
|
node_registry_service: Any,
|
||||||
|
local_managed_node: Callable[[], Any],
|
||||||
|
prune_expired_activity_events: Callable[..., int],
|
||||||
|
migrate_bot_resources_store: Callable[[str], None],
|
||||||
|
resolve_bot_provider_target_for_instance: Callable[[Any], Any],
|
||||||
|
default_provider_target: Callable[[], Any],
|
||||||
|
set_bot_provider_target: Callable[[str, Any], None],
|
||||||
|
apply_provider_target_to_bot: Callable[[Any, Any], None],
|
||||||
|
normalize_provider_target: Callable[[Any], Any],
|
||||||
|
runtime_service: Any,
|
||||||
|
runtime_event_service: Any,
|
||||||
|
clear_provider_target_overrides: Callable[[], None],
|
||||||
|
) -> None:
|
||||||
|
self._app = app
|
||||||
|
self._engine = engine
|
||||||
|
self._cache = cache
|
||||||
|
self._logger = logger
|
||||||
|
self._project_root = project_root
|
||||||
|
self._database_engine = database_engine
|
||||||
|
self._database_echo = database_echo
|
||||||
|
self._database_url_display = database_url_display
|
||||||
|
self._redis_enabled = redis_enabled
|
||||||
|
self._init_database = init_database
|
||||||
|
self._node_registry_service = node_registry_service
|
||||||
|
self._local_managed_node = local_managed_node
|
||||||
|
self._prune_expired_activity_events = prune_expired_activity_events
|
||||||
|
self._migrate_bot_resources_store = migrate_bot_resources_store
|
||||||
|
self._resolve_bot_provider_target_for_instance = resolve_bot_provider_target_for_instance
|
||||||
|
self._default_provider_target = default_provider_target
|
||||||
|
self._set_bot_provider_target = set_bot_provider_target
|
||||||
|
self._apply_provider_target_to_bot = apply_provider_target_to_bot
|
||||||
|
self._normalize_provider_target = normalize_provider_target
|
||||||
|
self._runtime_service = runtime_service
|
||||||
|
self._runtime_event_service = runtime_event_service
|
||||||
|
self._clear_provider_target_overrides = clear_provider_target_overrides
|
||||||
|
|
||||||
|
async def on_startup(self) -> None:
|
||||||
|
self._app.state.main_loop = asyncio.get_running_loop()
|
||||||
|
self._clear_provider_target_overrides()
|
||||||
|
self._logger.info(
|
||||||
|
"startup project_root=%s db_engine=%s db_echo=%s db_url=%s redis=%s",
|
||||||
|
self._project_root,
|
||||||
|
self._database_engine,
|
||||||
|
self._database_echo,
|
||||||
|
self._database_url_display,
|
||||||
|
"enabled" if self._cache.ping() else ("disabled" if self._redis_enabled else "not_configured"),
|
||||||
|
)
|
||||||
|
self._init_database()
|
||||||
|
self._cache.delete_prefix("")
|
||||||
|
with Session(self._engine) as session:
|
||||||
|
self._node_registry_service.load_from_session(session)
|
||||||
|
self._node_registry_service.upsert_node(session, self._local_managed_node())
|
||||||
|
pruned_events = self._prune_expired_activity_events(session, force=True)
|
||||||
|
if pruned_events > 0:
|
||||||
|
session.commit()
|
||||||
|
target_dirty = False
|
||||||
|
for bot in session.exec(select(BotInstance)).all():
|
||||||
|
self._migrate_bot_resources_store(bot.id)
|
||||||
|
target = self._resolve_bot_provider_target_for_instance(bot)
|
||||||
|
if str(target.transport_kind or "").strip().lower() != "edge":
|
||||||
|
target = self._normalize_provider_target(
|
||||||
|
{
|
||||||
|
"node_id": target.node_id,
|
||||||
|
"transport_kind": "edge",
|
||||||
|
"runtime_kind": target.runtime_kind,
|
||||||
|
"core_adapter": target.core_adapter,
|
||||||
|
},
|
||||||
|
fallback=self._default_provider_target(),
|
||||||
|
)
|
||||||
|
self._set_bot_provider_target(bot.id, target)
|
||||||
|
if (
|
||||||
|
str(getattr(bot, "node_id", "") or "").strip().lower() != target.node_id
|
||||||
|
or str(getattr(bot, "transport_kind", "") or "").strip().lower() != target.transport_kind
|
||||||
|
or str(getattr(bot, "runtime_kind", "") or "").strip().lower() != target.runtime_kind
|
||||||
|
or str(getattr(bot, "core_adapter", "") or "").strip().lower() != target.core_adapter
|
||||||
|
):
|
||||||
|
self._apply_provider_target_to_bot(bot, target)
|
||||||
|
session.add(bot)
|
||||||
|
target_dirty = True
|
||||||
|
if target_dirty:
|
||||||
|
session.commit()
|
||||||
|
running_bots = session.exec(select(BotInstance).where(BotInstance.docker_status == "RUNNING")).all()
|
||||||
|
for bot in running_bots:
|
||||||
|
try:
|
||||||
|
self._runtime_service.ensure_monitor(app_state=self._app.state, bot=bot)
|
||||||
|
pending_usage = session.exec(
|
||||||
|
select(BotRequestUsage)
|
||||||
|
.where(BotRequestUsage.bot_id == str(bot.id or "").strip())
|
||||||
|
.where(BotRequestUsage.status == "PENDING")
|
||||||
|
.order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
if pending_usage and str(getattr(pending_usage, "request_id", "") or "").strip():
|
||||||
|
self._runtime_service.sync_edge_monitor_packets(
|
||||||
|
app_state=self._app.state,
|
||||||
|
bot=bot,
|
||||||
|
request_id=str(pending_usage.request_id or "").strip(),
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
self._logger.warning(
|
||||||
|
"Skip runtime monitor restore on startup for bot_id=%s due to unavailable runtime backend: %s",
|
||||||
|
str(bot.id or ""),
|
||||||
|
str(getattr(exc, "detail", "") or exc),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._logger.exception("Failed to restore runtime monitor on startup for bot_id=%s", str(bot.id or ""))
|
||||||
|
|
||||||
|
async def handle_websocket(self, websocket: WebSocket, bot_id: str) -> None:
|
||||||
|
with Session(self._engine) as session:
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
await websocket.close(code=4404, reason="Bot not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
connected = False
|
||||||
|
try:
|
||||||
|
await self._runtime_event_service.manager.connect(bot_id, websocket)
|
||||||
|
connected = True
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning("websocket connect failed bot_id=%s detail=%s", bot_id, exc)
|
||||||
|
try:
|
||||||
|
await websocket.close(code=1011, reason="WebSocket accept failed")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
self._runtime_service.ensure_monitor(app_state=websocket.app.state, bot=bot)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except RuntimeError as exc:
|
||||||
|
msg = str(exc or "").lower()
|
||||||
|
if "need to call \"accept\" first" not in msg and "not connected" not in msg:
|
||||||
|
self._logger.exception("websocket runtime error bot_id=%s", bot_id)
|
||||||
|
except Exception:
|
||||||
|
self._logger.exception("websocket unexpected error bot_id=%s", bot_id)
|
||||||
|
finally:
|
||||||
|
if connected:
|
||||||
|
self._runtime_event_service.manager.disconnect(bot_id, websocket)
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
from typing import Any, Callable, Dict, List
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from models.bot import BotInstance
|
||||||
|
|
||||||
|
ReadBotConfig = Callable[[str], Dict[str, Any]]
|
||||||
|
WriteBotConfig = Callable[[str, Dict[str, Any]], None]
|
||||||
|
SyncBotWorkspace = Callable[[Session, BotInstance], None]
|
||||||
|
InvalidateBotCache = Callable[[str], None]
|
||||||
|
GetBotChannels = Callable[[BotInstance], List[Dict[str, Any]]]
|
||||||
|
NormalizeChannelExtra = Callable[[Any], Dict[str, Any]]
|
||||||
|
ChannelApiToCfg = Callable[[Dict[str, Any]], Dict[str, Any]]
|
||||||
|
ReadGlobalDeliveryFlags = Callable[[Any], tuple[bool, bool]]
|
||||||
|
|
||||||
|
|
||||||
|
class BotChannelService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
read_bot_config: ReadBotConfig,
|
||||||
|
write_bot_config: WriteBotConfig,
|
||||||
|
sync_bot_workspace_via_provider: SyncBotWorkspace,
|
||||||
|
invalidate_bot_detail_cache: InvalidateBotCache,
|
||||||
|
get_bot_channels_from_config: GetBotChannels,
|
||||||
|
normalize_channel_extra: NormalizeChannelExtra,
|
||||||
|
channel_api_to_cfg: ChannelApiToCfg,
|
||||||
|
read_global_delivery_flags: ReadGlobalDeliveryFlags,
|
||||||
|
) -> None:
|
||||||
|
self._read_bot_config = read_bot_config
|
||||||
|
self._write_bot_config = write_bot_config
|
||||||
|
self._sync_bot_workspace_via_provider = sync_bot_workspace_via_provider
|
||||||
|
self._invalidate_bot_detail_cache = invalidate_bot_detail_cache
|
||||||
|
self._get_bot_channels_from_config = get_bot_channels_from_config
|
||||||
|
self._normalize_channel_extra = normalize_channel_extra
|
||||||
|
self._channel_api_to_cfg = channel_api_to_cfg
|
||||||
|
self._read_global_delivery_flags = read_global_delivery_flags
|
||||||
|
|
||||||
|
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def list_channels(self, *, session: Session, bot_id: str) -> List[Dict[str, Any]]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self._get_bot_channels_from_config(bot)
|
||||||
|
|
||||||
|
def create_channel(self, *, session: Session, bot_id: str, payload: Any) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
ctype = str(getattr(payload, "channel_type", "") or "").strip().lower()
|
||||||
|
if not ctype:
|
||||||
|
raise HTTPException(status_code=400, detail="channel_type is required")
|
||||||
|
if ctype == "dashboard":
|
||||||
|
raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be created manually")
|
||||||
|
current_rows = self._get_bot_channels_from_config(bot)
|
||||||
|
if any(str(row.get("channel_type") or "").lower() == ctype for row in current_rows):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Channel already exists: {ctype}")
|
||||||
|
|
||||||
|
new_row = {
|
||||||
|
"id": ctype,
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"channel_type": ctype,
|
||||||
|
"external_app_id": str(getattr(payload, "external_app_id", "") or "").strip() or f"{ctype}-{bot_id}",
|
||||||
|
"app_secret": str(getattr(payload, "app_secret", "") or "").strip(),
|
||||||
|
"internal_port": max(1, min(int(getattr(payload, "internal_port", 8080) or 8080), 65535)),
|
||||||
|
"is_active": bool(getattr(payload, "is_active", True)),
|
||||||
|
"extra_config": self._normalize_channel_extra(getattr(payload, "extra_config", None)),
|
||||||
|
"locked": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
config_data = self._read_bot_config(bot_id)
|
||||||
|
channels_cfg = config_data.get("channels")
|
||||||
|
if not isinstance(channels_cfg, dict):
|
||||||
|
channels_cfg = {}
|
||||||
|
config_data["channels"] = channels_cfg
|
||||||
|
channels_cfg[ctype] = self._channel_api_to_cfg(new_row)
|
||||||
|
self._write_bot_config(bot_id, config_data)
|
||||||
|
self._sync_bot_workspace_via_provider(session, bot)
|
||||||
|
self._invalidate_bot_detail_cache(bot_id)
|
||||||
|
return new_row
|
||||||
|
|
||||||
|
def update_channel(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
channel_id: str,
|
||||||
|
payload: Any,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
channel_key = str(channel_id or "").strip().lower()
|
||||||
|
rows = self._get_bot_channels_from_config(bot)
|
||||||
|
row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Channel not found")
|
||||||
|
if str(row.get("channel_type") or "").strip().lower() == "dashboard" or bool(row.get("locked")):
|
||||||
|
raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be modified")
|
||||||
|
|
||||||
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
|
existing_type = str(row.get("channel_type") or "").strip().lower()
|
||||||
|
new_type = existing_type
|
||||||
|
if "channel_type" in update_data and update_data["channel_type"] is not None:
|
||||||
|
new_type = str(update_data["channel_type"]).strip().lower()
|
||||||
|
if not new_type:
|
||||||
|
raise HTTPException(status_code=400, detail="channel_type cannot be empty")
|
||||||
|
if existing_type == "dashboard" and new_type != "dashboard":
|
||||||
|
raise HTTPException(status_code=400, detail="dashboard channel type cannot be changed")
|
||||||
|
if new_type != existing_type and any(str(r.get("channel_type") or "").lower() == new_type for r in rows):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Channel already exists: {new_type}")
|
||||||
|
|
||||||
|
if "external_app_id" in update_data and update_data["external_app_id"] is not None:
|
||||||
|
row["external_app_id"] = str(update_data["external_app_id"]).strip()
|
||||||
|
if "app_secret" in update_data and update_data["app_secret"] is not None:
|
||||||
|
row["app_secret"] = str(update_data["app_secret"]).strip()
|
||||||
|
if "internal_port" in update_data and update_data["internal_port"] is not None:
|
||||||
|
row["internal_port"] = max(1, min(int(update_data["internal_port"]), 65535))
|
||||||
|
if "is_active" in update_data and update_data["is_active"] is not None:
|
||||||
|
next_active = bool(update_data["is_active"])
|
||||||
|
if existing_type == "dashboard" and not next_active:
|
||||||
|
raise HTTPException(status_code=400, detail="dashboard channel must remain enabled")
|
||||||
|
row["is_active"] = next_active
|
||||||
|
if "extra_config" in update_data:
|
||||||
|
row["extra_config"] = self._normalize_channel_extra(update_data.get("extra_config"))
|
||||||
|
row["channel_type"] = new_type
|
||||||
|
row["id"] = new_type
|
||||||
|
row["locked"] = new_type == "dashboard"
|
||||||
|
|
||||||
|
config_data = self._read_bot_config(bot_id)
|
||||||
|
channels_cfg = config_data.get("channels")
|
||||||
|
if not isinstance(channels_cfg, dict):
|
||||||
|
channels_cfg = {}
|
||||||
|
config_data["channels"] = channels_cfg
|
||||||
|
current_send_progress, current_send_tool_hints = self._read_global_delivery_flags(channels_cfg)
|
||||||
|
if new_type == "dashboard":
|
||||||
|
extra = self._normalize_channel_extra(row.get("extra_config"))
|
||||||
|
channels_cfg["sendProgress"] = bool(extra.get("sendProgress", current_send_progress))
|
||||||
|
channels_cfg["sendToolHints"] = bool(extra.get("sendToolHints", current_send_tool_hints))
|
||||||
|
else:
|
||||||
|
channels_cfg["sendProgress"] = current_send_progress
|
||||||
|
channels_cfg["sendToolHints"] = current_send_tool_hints
|
||||||
|
channels_cfg.pop("dashboard", None)
|
||||||
|
if existing_type != "dashboard" and existing_type in channels_cfg and existing_type != new_type:
|
||||||
|
channels_cfg.pop(existing_type, None)
|
||||||
|
if new_type != "dashboard":
|
||||||
|
channels_cfg[new_type] = self._channel_api_to_cfg(row)
|
||||||
|
self._write_bot_config(bot_id, config_data)
|
||||||
|
session.commit()
|
||||||
|
self._sync_bot_workspace_via_provider(session, bot)
|
||||||
|
self._invalidate_bot_detail_cache(bot_id)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def delete_channel(self, *, session: Session, bot_id: str, channel_id: str) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
channel_key = str(channel_id or "").strip().lower()
|
||||||
|
rows = self._get_bot_channels_from_config(bot)
|
||||||
|
row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Channel not found")
|
||||||
|
if str(row.get("channel_type") or "").lower() == "dashboard":
|
||||||
|
raise HTTPException(status_code=400, detail="dashboard channel cannot be deleted")
|
||||||
|
|
||||||
|
config_data = self._read_bot_config(bot_id)
|
||||||
|
channels_cfg = config_data.get("channels")
|
||||||
|
if not isinstance(channels_cfg, dict):
|
||||||
|
channels_cfg = {}
|
||||||
|
config_data["channels"] = channels_cfg
|
||||||
|
channels_cfg.pop(str(row.get("channel_type") or "").lower(), None)
|
||||||
|
self._write_bot_config(bot_id, config_data)
|
||||||
|
session.commit()
|
||||||
|
self._sync_bot_workspace_via_provider(session, bot)
|
||||||
|
self._invalidate_bot_detail_cache(bot_id)
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Callable, Dict, List
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from models.bot import BotInstance
|
||||||
|
|
||||||
|
|
||||||
|
ReadEdgeStateData = Callable[..., Dict[str, Any]]
|
||||||
|
WriteEdgeStateData = Callable[..., bool]
|
||||||
|
ReadBotConfig = Callable[[str], Dict[str, Any]]
|
||||||
|
WriteBotConfig = Callable[[str, Dict[str, Any]], None]
|
||||||
|
InvalidateBotCache = Callable[[str], None]
|
||||||
|
PathResolver = Callable[[str], str]
|
||||||
|
NormalizeEnvParams = Callable[[Any], Dict[str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
class BotConfigStateService:
|
||||||
|
_MCP_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
read_edge_state_data: ReadEdgeStateData,
|
||||||
|
write_edge_state_data: WriteEdgeStateData,
|
||||||
|
read_bot_config: ReadBotConfig,
|
||||||
|
write_bot_config: WriteBotConfig,
|
||||||
|
invalidate_bot_detail_cache: InvalidateBotCache,
|
||||||
|
env_store_path: PathResolver,
|
||||||
|
cron_store_path: PathResolver,
|
||||||
|
normalize_env_params: NormalizeEnvParams,
|
||||||
|
) -> None:
|
||||||
|
self._read_edge_state_data = read_edge_state_data
|
||||||
|
self._write_edge_state_data = write_edge_state_data
|
||||||
|
self._read_bot_config = read_bot_config
|
||||||
|
self._write_bot_config = write_bot_config
|
||||||
|
self._invalidate_bot_detail_cache = invalidate_bot_detail_cache
|
||||||
|
self._env_store_path = env_store_path
|
||||||
|
self._cron_store_path = cron_store_path
|
||||||
|
self._normalize_env_params = normalize_env_params
|
||||||
|
|
||||||
|
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def read_env_store(self, bot_id: str) -> Dict[str, str]:
|
||||||
|
data = self._read_edge_state_data(bot_id=bot_id, state_key="env", default_payload={})
|
||||||
|
if data:
|
||||||
|
return self._normalize_env_params(data)
|
||||||
|
|
||||||
|
path = self._env_store_path(bot_id)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as file:
|
||||||
|
payload = json.load(file)
|
||||||
|
return self._normalize_env_params(payload)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def write_env_store(self, bot_id: str, env_params: Dict[str, str]) -> None:
|
||||||
|
normalized_env = self._normalize_env_params(env_params)
|
||||||
|
if self._write_edge_state_data(bot_id=bot_id, state_key="env", data=normalized_env):
|
||||||
|
return
|
||||||
|
path = self._env_store_path(bot_id)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
tmp_path = f"{path}.tmp"
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as file:
|
||||||
|
json.dump(normalized_env, file, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
|
||||||
|
def get_env_params(self, bot_id: str) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"env_params": self.read_env_store(bot_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_env_params_for_bot(self, *, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.get_env_params(bot_id)
|
||||||
|
|
||||||
|
def update_env_params(self, bot_id: str, env_params: Any) -> Dict[str, Any]:
|
||||||
|
normalized = self._normalize_env_params(env_params)
|
||||||
|
self.write_env_store(bot_id, normalized)
|
||||||
|
self._invalidate_bot_detail_cache(bot_id)
|
||||||
|
return {
|
||||||
|
"status": "updated",
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"env_params": normalized,
|
||||||
|
"restart_required": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_env_params_for_bot(self, *, session: Session, bot_id: str, env_params: Any) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.update_env_params(bot_id, env_params)
|
||||||
|
|
||||||
|
def normalize_mcp_servers(self, raw: Any) -> Dict[str, Dict[str, Any]]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {}
|
||||||
|
rows: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for server_name, server_cfg in raw.items():
|
||||||
|
name = str(server_name or "").strip()
|
||||||
|
if not name or not self._MCP_SERVER_NAME_RE.fullmatch(name):
|
||||||
|
continue
|
||||||
|
if not isinstance(server_cfg, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = str(server_cfg.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
transport_type = str(server_cfg.get("type") or "streamableHttp").strip()
|
||||||
|
if transport_type not in {"streamableHttp", "sse"}:
|
||||||
|
transport_type = "streamableHttp"
|
||||||
|
|
||||||
|
headers_raw = server_cfg.get("headers")
|
||||||
|
headers: Dict[str, str] = {}
|
||||||
|
if isinstance(headers_raw, dict):
|
||||||
|
for key, value in headers_raw.items():
|
||||||
|
header_key = str(key or "").strip()
|
||||||
|
if not header_key:
|
||||||
|
continue
|
||||||
|
headers[header_key] = str(value or "").strip()
|
||||||
|
|
||||||
|
timeout_raw = server_cfg.get("toolTimeout", 60)
|
||||||
|
try:
|
||||||
|
timeout = int(timeout_raw)
|
||||||
|
except Exception:
|
||||||
|
timeout = 60
|
||||||
|
timeout = max(1, min(timeout, 600))
|
||||||
|
|
||||||
|
rows[name] = {
|
||||||
|
"type": transport_type,
|
||||||
|
"url": url,
|
||||||
|
"headers": headers,
|
||||||
|
"toolTimeout": timeout,
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _merge_mcp_servers_preserving_extras(
|
||||||
|
self,
|
||||||
|
current_raw: Any,
|
||||||
|
normalized: Dict[str, Dict[str, Any]],
|
||||||
|
) -> Dict[str, Dict[str, Any]]:
|
||||||
|
current_map = current_raw if isinstance(current_raw, dict) else {}
|
||||||
|
merged: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for name, normalized_cfg in normalized.items():
|
||||||
|
base = current_map.get(name)
|
||||||
|
base_cfg = dict(base) if isinstance(base, dict) else {}
|
||||||
|
next_cfg = dict(base_cfg)
|
||||||
|
next_cfg.update(normalized_cfg)
|
||||||
|
merged[name] = next_cfg
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def _sanitize_mcp_servers_in_config_data(self, config_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||||
|
if not isinstance(config_data, dict):
|
||||||
|
return {}
|
||||||
|
tools_cfg = config_data.get("tools")
|
||||||
|
if not isinstance(tools_cfg, dict):
|
||||||
|
tools_cfg = {}
|
||||||
|
current_raw = tools_cfg.get("mcpServers")
|
||||||
|
normalized = self.normalize_mcp_servers(current_raw)
|
||||||
|
merged = self._merge_mcp_servers_preserving_extras(current_raw, normalized)
|
||||||
|
tools_cfg["mcpServers"] = merged
|
||||||
|
config_data["tools"] = tools_cfg
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def get_mcp_config(self, bot_id: str) -> Dict[str, Any]:
|
||||||
|
config_data = self._read_bot_config(bot_id)
|
||||||
|
tools_cfg = config_data.get("tools") if isinstance(config_data, dict) else {}
|
||||||
|
if not isinstance(tools_cfg, dict):
|
||||||
|
tools_cfg = {}
|
||||||
|
mcp_servers = self.normalize_mcp_servers(tools_cfg.get("mcpServers"))
|
||||||
|
return {
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"mcp_servers": mcp_servers,
|
||||||
|
"locked_servers": [],
|
||||||
|
"restart_required": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_mcp_config_for_bot(self, *, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.get_mcp_config(bot_id)
|
||||||
|
|
||||||
|
def update_mcp_config(self, bot_id: str, mcp_servers: Any) -> Dict[str, Any]:
|
||||||
|
config_data = self._read_bot_config(bot_id)
|
||||||
|
if not isinstance(config_data, dict):
|
||||||
|
config_data = {}
|
||||||
|
tools_cfg = config_data.get("tools")
|
||||||
|
if not isinstance(tools_cfg, dict):
|
||||||
|
tools_cfg = {}
|
||||||
|
normalized_mcp_servers = self.normalize_mcp_servers(mcp_servers or {})
|
||||||
|
current_mcp_servers = tools_cfg.get("mcpServers")
|
||||||
|
merged_mcp_servers = self._merge_mcp_servers_preserving_extras(current_mcp_servers, normalized_mcp_servers)
|
||||||
|
tools_cfg["mcpServers"] = merged_mcp_servers
|
||||||
|
config_data["tools"] = tools_cfg
|
||||||
|
sanitized_after_save = self._sanitize_mcp_servers_in_config_data(config_data)
|
||||||
|
self._write_bot_config(bot_id, config_data)
|
||||||
|
self._invalidate_bot_detail_cache(bot_id)
|
||||||
|
return {
|
||||||
|
"status": "updated",
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"mcp_servers": self.normalize_mcp_servers(sanitized_after_save),
|
||||||
|
"locked_servers": [],
|
||||||
|
"restart_required": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_mcp_config_for_bot(self, *, session: Session, bot_id: str, mcp_servers: Any) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.update_mcp_config(bot_id, mcp_servers)
|
||||||
|
|
||||||
|
def read_cron_store(self, bot_id: str) -> Dict[str, Any]:
|
||||||
|
data = self._read_edge_state_data(
|
||||||
|
bot_id=bot_id,
|
||||||
|
state_key="cron",
|
||||||
|
default_payload={"version": 1, "jobs": []},
|
||||||
|
)
|
||||||
|
if isinstance(data, dict) and data:
|
||||||
|
jobs = data.get("jobs")
|
||||||
|
if not isinstance(jobs, list):
|
||||||
|
jobs = []
|
||||||
|
try:
|
||||||
|
version = int(data.get("version", 1) or 1)
|
||||||
|
except Exception:
|
||||||
|
version = 1
|
||||||
|
return {"version": max(1, version), "jobs": jobs}
|
||||||
|
|
||||||
|
path = self._cron_store_path(bot_id)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return {"version": 1, "jobs": []}
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as file:
|
||||||
|
payload = json.load(file)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {"version": 1, "jobs": []}
|
||||||
|
jobs = payload.get("jobs")
|
||||||
|
if not isinstance(jobs, list):
|
||||||
|
payload["jobs"] = []
|
||||||
|
if "version" not in payload:
|
||||||
|
payload["version"] = 1
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
return {"version": 1, "jobs": []}
|
||||||
|
|
||||||
|
def write_cron_store(self, bot_id: str, store: Dict[str, Any]) -> None:
|
||||||
|
normalized_store = dict(store if isinstance(store, dict) else {})
|
||||||
|
jobs = normalized_store.get("jobs")
|
||||||
|
if not isinstance(jobs, list):
|
||||||
|
normalized_store["jobs"] = []
|
||||||
|
try:
|
||||||
|
normalized_store["version"] = max(1, int(normalized_store.get("version", 1) or 1))
|
||||||
|
except Exception:
|
||||||
|
normalized_store["version"] = 1
|
||||||
|
if self._write_edge_state_data(bot_id=bot_id, state_key="cron", data=normalized_store):
|
||||||
|
return
|
||||||
|
path = self._cron_store_path(bot_id)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
tmp_path = f"{path}.tmp"
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as file:
|
||||||
|
json.dump(normalized_store, file, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
|
||||||
|
def list_cron_jobs(self, bot_id: str, include_disabled: bool = True) -> Dict[str, Any]:
|
||||||
|
store = self.read_cron_store(bot_id)
|
||||||
|
rows = []
|
||||||
|
for row in store.get("jobs", []):
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
enabled = bool(row.get("enabled", True))
|
||||||
|
if not include_disabled and not enabled:
|
||||||
|
continue
|
||||||
|
rows.append(row)
|
||||||
|
rows.sort(key=lambda value: int(((value.get("state") or {}).get("nextRunAtMs")) or 2**62))
|
||||||
|
return {"bot_id": bot_id, "version": int(store.get("version", 1) or 1), "jobs": rows}
|
||||||
|
|
||||||
|
def list_cron_jobs_for_bot(self, *, session: Session, bot_id: str, include_disabled: bool = True) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.list_cron_jobs(bot_id, include_disabled=include_disabled)
|
||||||
|
|
||||||
|
def stop_cron_job(self, bot_id: str, job_id: str) -> Dict[str, Any]:
|
||||||
|
store = self.read_cron_store(bot_id)
|
||||||
|
jobs = store.get("jobs", [])
|
||||||
|
if not isinstance(jobs, list):
|
||||||
|
jobs = []
|
||||||
|
found = None
|
||||||
|
for row in jobs:
|
||||||
|
if isinstance(row, dict) and str(row.get("id")) == job_id:
|
||||||
|
found = row
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(status_code=404, detail="Cron job not found")
|
||||||
|
found["enabled"] = False
|
||||||
|
found["updatedAtMs"] = int(datetime.utcnow().timestamp() * 1000)
|
||||||
|
self.write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
|
||||||
|
return {"status": "stopped", "job_id": job_id}
|
||||||
|
|
||||||
|
def stop_cron_job_for_bot(self, *, session: Session, bot_id: str, job_id: str) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.stop_cron_job(bot_id, job_id)
|
||||||
|
|
||||||
|
def delete_cron_job(self, bot_id: str, job_id: str) -> Dict[str, Any]:
|
||||||
|
store = self.read_cron_store(bot_id)
|
||||||
|
jobs = store.get("jobs", [])
|
||||||
|
if not isinstance(jobs, list):
|
||||||
|
jobs = []
|
||||||
|
kept = [row for row in jobs if not (isinstance(row, dict) and str(row.get("id")) == job_id)]
|
||||||
|
if len(kept) == len(jobs):
|
||||||
|
raise HTTPException(status_code=404, detail="Cron job not found")
|
||||||
|
self.write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": kept})
|
||||||
|
return {"status": "deleted", "job_id": job_id}
|
||||||
|
|
||||||
|
def delete_cron_job_for_bot(self, *, session: Session, bot_id: str, job_id: str) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.delete_cron_job(bot_id, job_id)
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,611 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from core.settings import (
|
||||||
|
BOTS_WORKSPACE_ROOT,
|
||||||
|
DEFAULT_AGENTS_MD,
|
||||||
|
DEFAULT_IDENTITY_MD,
|
||||||
|
DEFAULT_SOUL_MD,
|
||||||
|
DEFAULT_TOOLS_MD,
|
||||||
|
DEFAULT_USER_MD,
|
||||||
|
)
|
||||||
|
from models.bot import BotInstance, BotMessage
|
||||||
|
from models.platform import BotActivityEvent, BotRequestUsage
|
||||||
|
from models.skill import BotSkillInstall
|
||||||
|
from models.topic import TopicItem, TopicTopic
|
||||||
|
from providers.target import ProviderTarget, normalize_provider_target, provider_target_to_dict
|
||||||
|
from services.runtime_service import RuntimeService
|
||||||
|
|
||||||
|
RefreshBotRuntimeStatus = Callable[[Any, BotInstance], str]
|
||||||
|
ResolveBotProviderTarget = Callable[[BotInstance], ProviderTarget]
|
||||||
|
ProviderTargetFromNode = Callable[[Optional[str]], Optional[ProviderTarget]]
|
||||||
|
DefaultProviderTarget = Callable[[], ProviderTarget]
|
||||||
|
EnsureProviderTargetSupported = Callable[[ProviderTarget], None]
|
||||||
|
RequireReadyImage = Callable[..., Any]
|
||||||
|
SyncBotWorkspaceViaProvider = Callable[..., None]
|
||||||
|
ApplyProviderTargetToBot = Callable[[BotInstance, ProviderTarget], None]
|
||||||
|
SerializeProviderTargetSummary = Callable[[ProviderTarget], Dict[str, Any]]
|
||||||
|
SerializeBot = Callable[[BotInstance], Dict[str, Any]]
|
||||||
|
NodeDisplayName = Callable[[str], str]
|
||||||
|
InvalidateBotCache = Callable[[str], None]
|
||||||
|
RecordActivityEvent = Callable[..., None]
|
||||||
|
NormalizeEnvParams = Callable[[Any], Dict[str, str]]
|
||||||
|
NormalizeSystemTimezone = Callable[[Any], str]
|
||||||
|
NormalizeResourceLimits = Callable[[Any, Any, Any], Dict[str, Any]]
|
||||||
|
WriteEnvStore = Callable[[str, Dict[str, str]], None]
|
||||||
|
ResolveBotEnvParams = Callable[[str], Dict[str, str]]
|
||||||
|
ClearProviderTargetOverride = Callable[[str], None]
|
||||||
|
NormalizeInitialChannels = Callable[[str, Any], Any]
|
||||||
|
ExpectedEdgeOfflineError = Callable[[Exception], bool]
|
||||||
|
SummarizeEdgeException = Callable[[Exception], str]
|
||||||
|
ResolveEdgeClient = Callable[[ProviderTarget], Any]
|
||||||
|
NodeMetadata = Callable[[str], Dict[str, Any]]
|
||||||
|
LogEdgeFailure = Callable[..., None]
|
||||||
|
InvalidateBotMessagesCache = Callable[[str], None]
|
||||||
|
|
||||||
|
|
||||||
|
class BotLifecycleService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id_pattern: re.Pattern[str],
|
||||||
|
runtime_service: RuntimeService,
|
||||||
|
refresh_bot_runtime_status: RefreshBotRuntimeStatus,
|
||||||
|
resolve_bot_provider_target: ResolveBotProviderTarget,
|
||||||
|
provider_target_from_node: ProviderTargetFromNode,
|
||||||
|
default_provider_target: DefaultProviderTarget,
|
||||||
|
ensure_provider_target_supported: EnsureProviderTargetSupported,
|
||||||
|
require_ready_image: RequireReadyImage,
|
||||||
|
sync_bot_workspace_via_provider: SyncBotWorkspaceViaProvider,
|
||||||
|
apply_provider_target_to_bot: ApplyProviderTargetToBot,
|
||||||
|
serialize_provider_target_summary: SerializeProviderTargetSummary,
|
||||||
|
serialize_bot: SerializeBot,
|
||||||
|
node_display_name: NodeDisplayName,
|
||||||
|
invalidate_bot_detail_cache: InvalidateBotCache,
|
||||||
|
record_activity_event: RecordActivityEvent,
|
||||||
|
normalize_env_params: NormalizeEnvParams,
|
||||||
|
normalize_system_timezone: NormalizeSystemTimezone,
|
||||||
|
normalize_resource_limits: NormalizeResourceLimits,
|
||||||
|
write_env_store: WriteEnvStore,
|
||||||
|
resolve_bot_env_params: ResolveBotEnvParams,
|
||||||
|
clear_provider_target_override: ClearProviderTargetOverride,
|
||||||
|
normalize_initial_channels: NormalizeInitialChannels,
|
||||||
|
is_expected_edge_offline_error: ExpectedEdgeOfflineError,
|
||||||
|
summarize_edge_exception: SummarizeEdgeException,
|
||||||
|
resolve_edge_client: ResolveEdgeClient,
|
||||||
|
node_metadata: NodeMetadata,
|
||||||
|
log_edge_failure: LogEdgeFailure,
|
||||||
|
invalidate_bot_messages_cache: InvalidateBotMessagesCache,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
self._bot_id_pattern = bot_id_pattern
|
||||||
|
self._runtime_service = runtime_service
|
||||||
|
self._refresh_bot_runtime_status = refresh_bot_runtime_status
|
||||||
|
self._resolve_bot_provider_target = resolve_bot_provider_target
|
||||||
|
self._provider_target_from_node = provider_target_from_node
|
||||||
|
self._default_provider_target = default_provider_target
|
||||||
|
self._ensure_provider_target_supported = ensure_provider_target_supported
|
||||||
|
self._require_ready_image = require_ready_image
|
||||||
|
self._sync_bot_workspace_via_provider = sync_bot_workspace_via_provider
|
||||||
|
self._apply_provider_target_to_bot = apply_provider_target_to_bot
|
||||||
|
self._serialize_provider_target_summary = serialize_provider_target_summary
|
||||||
|
self._serialize_bot = serialize_bot
|
||||||
|
self._node_display_name = node_display_name
|
||||||
|
self._invalidate_bot_detail_cache = invalidate_bot_detail_cache
|
||||||
|
self._record_activity_event = record_activity_event
|
||||||
|
self._normalize_env_params = normalize_env_params
|
||||||
|
self._normalize_system_timezone = normalize_system_timezone
|
||||||
|
self._normalize_resource_limits = normalize_resource_limits
|
||||||
|
self._write_env_store = write_env_store
|
||||||
|
self._resolve_bot_env_params = resolve_bot_env_params
|
||||||
|
self._clear_provider_target_override = clear_provider_target_override
|
||||||
|
self._normalize_initial_channels = normalize_initial_channels
|
||||||
|
self._is_expected_edge_offline_error = is_expected_edge_offline_error
|
||||||
|
self._summarize_edge_exception = summarize_edge_exception
|
||||||
|
self._resolve_edge_client = resolve_edge_client
|
||||||
|
self._node_metadata = node_metadata
|
||||||
|
self._log_edge_failure = log_edge_failure
|
||||||
|
self._invalidate_bot_messages_cache = invalidate_bot_messages_cache
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def create_bot(self, *, session: Session, payload: Any) -> Dict[str, Any]:
|
||||||
|
normalized_bot_id = str(getattr(payload, "id", "") or "").strip()
|
||||||
|
if not normalized_bot_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Bot ID is required")
|
||||||
|
if not self._bot_id_pattern.fullmatch(normalized_bot_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Bot ID can only contain letters, numbers, and underscores")
|
||||||
|
if session.get(BotInstance, normalized_bot_id):
|
||||||
|
raise HTTPException(status_code=409, detail=f"Bot ID already exists: {normalized_bot_id}")
|
||||||
|
|
||||||
|
normalized_env_params = self._normalize_env_params(getattr(payload, "env_params", None))
|
||||||
|
try:
|
||||||
|
normalized_env_params["TZ"] = self._normalize_system_timezone(getattr(payload, "system_timezone", None))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
provider_target = normalize_provider_target(
|
||||||
|
{
|
||||||
|
"node_id": getattr(payload, "node_id", None),
|
||||||
|
"transport_kind": getattr(payload, "transport_kind", None),
|
||||||
|
"runtime_kind": getattr(payload, "runtime_kind", None),
|
||||||
|
"core_adapter": getattr(payload, "core_adapter", None),
|
||||||
|
},
|
||||||
|
fallback=self._provider_target_from_node(getattr(payload, "node_id", None)) or self._default_provider_target(),
|
||||||
|
)
|
||||||
|
self._ensure_provider_target_supported(provider_target)
|
||||||
|
|
||||||
|
normalized_image_tag = str(getattr(payload, "image_tag", "") or "").strip()
|
||||||
|
if provider_target.runtime_kind == "docker":
|
||||||
|
self._require_ready_image(session, normalized_image_tag, require_local_image=True)
|
||||||
|
|
||||||
|
bot = BotInstance(
|
||||||
|
id=normalized_bot_id,
|
||||||
|
name=getattr(payload, "name", None),
|
||||||
|
enabled=bool(getattr(payload, "enabled", True)) if getattr(payload, "enabled", None) is not None else True,
|
||||||
|
access_password="",
|
||||||
|
image_tag=normalized_image_tag,
|
||||||
|
node_id=provider_target.node_id,
|
||||||
|
transport_kind=provider_target.transport_kind,
|
||||||
|
runtime_kind=provider_target.runtime_kind,
|
||||||
|
core_adapter=provider_target.core_adapter,
|
||||||
|
workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, normalized_bot_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(bot)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(bot)
|
||||||
|
|
||||||
|
resource_limits = self._normalize_resource_limits(
|
||||||
|
getattr(payload, "cpu_cores", None),
|
||||||
|
getattr(payload, "memory_mb", None),
|
||||||
|
getattr(payload, "storage_gb", None),
|
||||||
|
)
|
||||||
|
workspace_synced = True
|
||||||
|
sync_error_detail = ""
|
||||||
|
try:
|
||||||
|
self._write_env_store(normalized_bot_id, normalized_env_params)
|
||||||
|
self._sync_bot_workspace_via_provider(
|
||||||
|
session,
|
||||||
|
bot,
|
||||||
|
target_override=provider_target,
|
||||||
|
channels_override=self._normalize_initial_channels(normalized_bot_id, getattr(payload, "channels", None)),
|
||||||
|
global_delivery_override={
|
||||||
|
"sendProgress": bool(getattr(payload, "send_progress", None))
|
||||||
|
if getattr(payload, "send_progress", None) is not None
|
||||||
|
else False,
|
||||||
|
"sendToolHints": bool(getattr(payload, "send_tool_hints", None))
|
||||||
|
if getattr(payload, "send_tool_hints", None) is not None
|
||||||
|
else False,
|
||||||
|
},
|
||||||
|
runtime_overrides={
|
||||||
|
"llm_provider": getattr(payload, "llm_provider", None),
|
||||||
|
"llm_model": getattr(payload, "llm_model", None),
|
||||||
|
"api_key": getattr(payload, "api_key", None),
|
||||||
|
"api_base": getattr(payload, "api_base", "") or "",
|
||||||
|
"temperature": getattr(payload, "temperature", None),
|
||||||
|
"top_p": getattr(payload, "top_p", None),
|
||||||
|
"max_tokens": getattr(payload, "max_tokens", None),
|
||||||
|
"cpu_cores": resource_limits["cpu_cores"],
|
||||||
|
"memory_mb": resource_limits["memory_mb"],
|
||||||
|
"storage_gb": resource_limits["storage_gb"],
|
||||||
|
"node_id": provider_target.node_id,
|
||||||
|
"transport_kind": provider_target.transport_kind,
|
||||||
|
"runtime_kind": provider_target.runtime_kind,
|
||||||
|
"core_adapter": provider_target.core_adapter,
|
||||||
|
"system_prompt": getattr(payload, "system_prompt", None) or getattr(payload, "soul_md", None) or DEFAULT_SOUL_MD,
|
||||||
|
"soul_md": getattr(payload, "soul_md", None) or getattr(payload, "system_prompt", None) or DEFAULT_SOUL_MD,
|
||||||
|
"agents_md": getattr(payload, "agents_md", None) or DEFAULT_AGENTS_MD,
|
||||||
|
"user_md": getattr(payload, "user_md", None) or DEFAULT_USER_MD,
|
||||||
|
"tools_md": getattr(payload, "tools_md", None) or DEFAULT_TOOLS_MD,
|
||||||
|
"identity_md": getattr(payload, "identity_md", None) or DEFAULT_IDENTITY_MD,
|
||||||
|
"send_progress": bool(getattr(payload, "send_progress", None))
|
||||||
|
if getattr(payload, "send_progress", None) is not None
|
||||||
|
else False,
|
||||||
|
"send_tool_hints": bool(getattr(payload, "send_tool_hints", None))
|
||||||
|
if getattr(payload, "send_tool_hints", None) is not None
|
||||||
|
else False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if self._is_expected_edge_offline_error(exc):
|
||||||
|
workspace_synced = False
|
||||||
|
sync_error_detail = self._summarize_edge_exception(exc)
|
||||||
|
self._logger.info(
|
||||||
|
"Create bot pending sync due to offline edge bot_id=%s node=%s detail=%s",
|
||||||
|
normalized_bot_id,
|
||||||
|
provider_target.node_id,
|
||||||
|
sync_error_detail,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
detail = self._summarize_edge_exception(exc)
|
||||||
|
try:
|
||||||
|
doomed = session.get(BotInstance, normalized_bot_id)
|
||||||
|
if doomed is not None:
|
||||||
|
session.delete(doomed)
|
||||||
|
session.commit()
|
||||||
|
self._clear_provider_target_override(normalized_bot_id)
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=502, detail=f"Failed to initialize bot workspace: {detail}") from exc
|
||||||
|
|
||||||
|
session.refresh(bot)
|
||||||
|
self._record_activity_event(
|
||||||
|
session,
|
||||||
|
normalized_bot_id,
|
||||||
|
"bot_created",
|
||||||
|
channel="system",
|
||||||
|
detail=f"Bot {normalized_bot_id} created",
|
||||||
|
metadata={
|
||||||
|
"image_tag": normalized_image_tag,
|
||||||
|
"workspace_synced": workspace_synced,
|
||||||
|
"sync_error": sync_error_detail if not workspace_synced else "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not workspace_synced:
|
||||||
|
self._record_activity_event(
|
||||||
|
session,
|
||||||
|
normalized_bot_id,
|
||||||
|
"bot_warning",
|
||||||
|
channel="system",
|
||||||
|
detail="Bot created, but node is offline. Workspace sync is pending.",
|
||||||
|
metadata={"sync_error": sync_error_detail, "node_id": provider_target.node_id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
self._invalidate_bot_detail_cache(normalized_bot_id)
|
||||||
|
return self._serialize_bot(bot)
|
||||||
|
|
||||||
|
def update_bot(self, *, session: Session, bot_id: str, payload: Any) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None
|
||||||
|
system_timezone = update_data.pop("system_timezone", None) if isinstance(update_data, dict) else None
|
||||||
|
normalized_system_timezone: Optional[str] = None
|
||||||
|
if system_timezone is not None:
|
||||||
|
try:
|
||||||
|
normalized_system_timezone = self._normalize_system_timezone(system_timezone)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
runtime_overrides: Dict[str, Any] = {}
|
||||||
|
update_data.pop("tools_config", None) if isinstance(update_data, dict) else None
|
||||||
|
|
||||||
|
runtime_fields = {
|
||||||
|
"llm_provider",
|
||||||
|
"llm_model",
|
||||||
|
"api_key",
|
||||||
|
"api_base",
|
||||||
|
"temperature",
|
||||||
|
"top_p",
|
||||||
|
"max_tokens",
|
||||||
|
"cpu_cores",
|
||||||
|
"memory_mb",
|
||||||
|
"storage_gb",
|
||||||
|
"soul_md",
|
||||||
|
"agents_md",
|
||||||
|
"user_md",
|
||||||
|
"tools_md",
|
||||||
|
"identity_md",
|
||||||
|
"send_progress",
|
||||||
|
"send_tool_hints",
|
||||||
|
"system_prompt",
|
||||||
|
}
|
||||||
|
execution_target_fields = {
|
||||||
|
"node_id",
|
||||||
|
"transport_kind",
|
||||||
|
"runtime_kind",
|
||||||
|
"core_adapter",
|
||||||
|
}
|
||||||
|
deploy_only_fields = {"image_tag", *execution_target_fields}
|
||||||
|
if deploy_only_fields & set(update_data.keys()):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Use /api/bots/{bot_id}/deploy for execution target or image changes",
|
||||||
|
)
|
||||||
|
for field in runtime_fields:
|
||||||
|
if field in update_data:
|
||||||
|
runtime_overrides[field] = update_data.pop(field)
|
||||||
|
|
||||||
|
next_target: Optional[ProviderTarget] = None
|
||||||
|
|
||||||
|
for text_field in ("llm_provider", "llm_model", "api_key"):
|
||||||
|
if text_field in runtime_overrides:
|
||||||
|
text = str(runtime_overrides.get(text_field) or "").strip()
|
||||||
|
if not text:
|
||||||
|
runtime_overrides.pop(text_field, None)
|
||||||
|
else:
|
||||||
|
runtime_overrides[text_field] = text
|
||||||
|
if "api_base" in runtime_overrides:
|
||||||
|
runtime_overrides["api_base"] = str(runtime_overrides.get("api_base") or "").strip()
|
||||||
|
|
||||||
|
if "system_prompt" in runtime_overrides and "soul_md" not in runtime_overrides:
|
||||||
|
runtime_overrides["soul_md"] = runtime_overrides["system_prompt"]
|
||||||
|
if "soul_md" in runtime_overrides and "system_prompt" not in runtime_overrides:
|
||||||
|
runtime_overrides["system_prompt"] = runtime_overrides["soul_md"]
|
||||||
|
if {"cpu_cores", "memory_mb", "storage_gb"} & set(runtime_overrides.keys()):
|
||||||
|
normalized_resources = self._normalize_resource_limits(
|
||||||
|
runtime_overrides.get("cpu_cores"),
|
||||||
|
runtime_overrides.get("memory_mb"),
|
||||||
|
runtime_overrides.get("storage_gb"),
|
||||||
|
)
|
||||||
|
runtime_overrides.update(normalized_resources)
|
||||||
|
|
||||||
|
db_fields = {"name", "enabled"}
|
||||||
|
for key, value in update_data.items():
|
||||||
|
if key in db_fields:
|
||||||
|
setattr(bot, key, value)
|
||||||
|
|
||||||
|
previous_env_params: Optional[Dict[str, str]] = None
|
||||||
|
next_env_params: Optional[Dict[str, str]] = None
|
||||||
|
if env_params is not None or normalized_system_timezone is not None:
|
||||||
|
previous_env_params = self._resolve_bot_env_params(bot_id)
|
||||||
|
next_env_params = dict(previous_env_params)
|
||||||
|
if env_params is not None:
|
||||||
|
next_env_params = self._normalize_env_params(env_params)
|
||||||
|
if normalized_system_timezone is not None:
|
||||||
|
next_env_params["TZ"] = normalized_system_timezone
|
||||||
|
|
||||||
|
global_delivery_override: Optional[Dict[str, Any]] = None
|
||||||
|
if "send_progress" in runtime_overrides or "send_tool_hints" in runtime_overrides:
|
||||||
|
global_delivery_override = {}
|
||||||
|
if "send_progress" in runtime_overrides:
|
||||||
|
global_delivery_override["sendProgress"] = bool(runtime_overrides.get("send_progress"))
|
||||||
|
if "send_tool_hints" in runtime_overrides:
|
||||||
|
global_delivery_override["sendToolHints"] = bool(runtime_overrides.get("send_tool_hints"))
|
||||||
|
|
||||||
|
self._sync_bot_workspace_via_provider(
|
||||||
|
session,
|
||||||
|
bot,
|
||||||
|
target_override=next_target,
|
||||||
|
runtime_overrides=runtime_overrides if runtime_overrides else None,
|
||||||
|
global_delivery_override=global_delivery_override,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if next_env_params is not None:
|
||||||
|
self._write_env_store(bot_id, next_env_params)
|
||||||
|
if next_target is not None:
|
||||||
|
self._apply_provider_target_to_bot(bot, next_target)
|
||||||
|
session.add(bot)
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
if previous_env_params is not None:
|
||||||
|
self._write_env_store(bot_id, previous_env_params)
|
||||||
|
raise
|
||||||
|
|
||||||
|
session.refresh(bot)
|
||||||
|
self._invalidate_bot_detail_cache(bot_id)
|
||||||
|
return self._serialize_bot(bot)
|
||||||
|
|
||||||
|
async def start_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return await self._runtime_service.start_bot(app_state=app_state, session=session, bot=bot)
|
||||||
|
|
||||||
|
def stop_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self._runtime_service.stop_bot(app_state=app_state, session=session, bot=bot)
|
||||||
|
|
||||||
|
def enable_bot(self, *, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
bot.enabled = True
|
||||||
|
session.add(bot)
|
||||||
|
self._record_activity_event(session, bot_id, "bot_enabled", channel="system", detail=f"Bot {bot_id} enabled")
|
||||||
|
session.commit()
|
||||||
|
self._invalidate_bot_detail_cache(bot_id)
|
||||||
|
return {"status": "enabled", "enabled": True}
|
||||||
|
|
||||||
|
def disable_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
self._set_inactive(app_state=app_state, session=session, bot=bot, activity_type="bot_disabled", detail="disabled")
|
||||||
|
return {"status": "disabled", "enabled": False}
|
||||||
|
|
||||||
|
def deactivate_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
self._set_inactive(
|
||||||
|
app_state=app_state,
|
||||||
|
session=session,
|
||||||
|
bot=bot,
|
||||||
|
activity_type="bot_deactivated",
|
||||||
|
detail="deactivated",
|
||||||
|
)
|
||||||
|
return {"status": "deactivated"}
|
||||||
|
|
||||||
|
def delete_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app_state: Any,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
delete_workspace: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
target = self._resolve_bot_provider_target(bot)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._runtime_service.stop_bot(app_state=app_state, session=session, bot=bot)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
workspace_deleted = not bool(delete_workspace)
|
||||||
|
if delete_workspace:
|
||||||
|
if target.transport_kind == "edge":
|
||||||
|
try:
|
||||||
|
workspace_root = str(self._node_metadata(target.node_id).get("workspace_root") or "").strip() or None
|
||||||
|
purge_result = self._resolve_edge_client(target).purge_workspace(
|
||||||
|
bot_id=bot_id,
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
)
|
||||||
|
workspace_deleted = str(purge_result.get("status") or "").strip().lower() in {"deleted", "not_found"}
|
||||||
|
except Exception as exc:
|
||||||
|
self._log_edge_failure(
|
||||||
|
self._logger,
|
||||||
|
key=f"bot-delete-workspace:{bot_id}",
|
||||||
|
exc=exc,
|
||||||
|
message=f"Failed to purge edge workspace for bot_id={bot_id}",
|
||||||
|
)
|
||||||
|
workspace_deleted = False
|
||||||
|
|
||||||
|
workspace_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id)
|
||||||
|
if os.path.isdir(workspace_root):
|
||||||
|
shutil.rmtree(workspace_root, ignore_errors=True)
|
||||||
|
workspace_deleted = True
|
||||||
|
|
||||||
|
messages = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all()
|
||||||
|
for row in messages:
|
||||||
|
session.delete(row)
|
||||||
|
topic_items = session.exec(select(TopicItem).where(TopicItem.bot_id == bot_id)).all()
|
||||||
|
for row in topic_items:
|
||||||
|
session.delete(row)
|
||||||
|
topics = session.exec(select(TopicTopic).where(TopicTopic.bot_id == bot_id)).all()
|
||||||
|
for row in topics:
|
||||||
|
session.delete(row)
|
||||||
|
usage_rows = session.exec(select(BotRequestUsage).where(BotRequestUsage.bot_id == bot_id)).all()
|
||||||
|
for row in usage_rows:
|
||||||
|
session.delete(row)
|
||||||
|
activity_rows = session.exec(select(BotActivityEvent).where(BotActivityEvent.bot_id == bot_id)).all()
|
||||||
|
for row in activity_rows:
|
||||||
|
session.delete(row)
|
||||||
|
skill_install_rows = session.exec(select(BotSkillInstall).where(BotSkillInstall.bot_id == bot_id)).all()
|
||||||
|
for row in skill_install_rows:
|
||||||
|
session.delete(row)
|
||||||
|
|
||||||
|
session.delete(bot)
|
||||||
|
session.commit()
|
||||||
|
self._clear_provider_target_override(bot_id)
|
||||||
|
self._invalidate_bot_detail_cache(bot_id)
|
||||||
|
self._invalidate_bot_messages_cache(bot_id)
|
||||||
|
return {"status": "deleted", "workspace_deleted": workspace_deleted}
|
||||||
|
|
||||||
|
async def deploy_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app_state: Any,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
node_id: str,
|
||||||
|
runtime_kind: Optional[str] = None,
|
||||||
|
image_tag: Optional[str] = None,
|
||||||
|
auto_start: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
|
||||||
|
actual_status = self._refresh_bot_runtime_status(app_state, bot)
|
||||||
|
session.add(bot)
|
||||||
|
session.commit()
|
||||||
|
if actual_status == "RUNNING":
|
||||||
|
raise HTTPException(status_code=409, detail="Stop the bot before deploy or migrate")
|
||||||
|
|
||||||
|
current_target = self._resolve_bot_provider_target(bot)
|
||||||
|
next_target_base = self._provider_target_from_node(node_id)
|
||||||
|
if next_target_base is None:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Managed node not found: {node_id}")
|
||||||
|
next_target = normalize_provider_target(
|
||||||
|
{
|
||||||
|
"node_id": node_id,
|
||||||
|
"runtime_kind": runtime_kind,
|
||||||
|
},
|
||||||
|
fallback=next_target_base,
|
||||||
|
)
|
||||||
|
self._ensure_provider_target_supported(next_target)
|
||||||
|
|
||||||
|
existing_image_tag = str(bot.image_tag or "").strip()
|
||||||
|
requested_image_tag = str(image_tag or "").strip()
|
||||||
|
if next_target.runtime_kind == "docker":
|
||||||
|
requested_image_tag = requested_image_tag or existing_image_tag
|
||||||
|
image_changed = requested_image_tag != str(bot.image_tag or "").strip()
|
||||||
|
target_changed = next_target.key != current_target.key
|
||||||
|
if not image_changed and not target_changed:
|
||||||
|
raise HTTPException(status_code=400, detail="No deploy changes detected")
|
||||||
|
|
||||||
|
if next_target.runtime_kind == "docker":
|
||||||
|
self._require_ready_image(
|
||||||
|
session,
|
||||||
|
requested_image_tag,
|
||||||
|
require_local_image=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._sync_bot_workspace_via_provider(
|
||||||
|
session,
|
||||||
|
bot,
|
||||||
|
target_override=next_target,
|
||||||
|
runtime_overrides=provider_target_to_dict(next_target),
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_image_tag = str(bot.image_tag or "").strip()
|
||||||
|
bot.image_tag = requested_image_tag
|
||||||
|
self._apply_provider_target_to_bot(bot, next_target)
|
||||||
|
bot.updated_at = datetime.utcnow()
|
||||||
|
session.add(bot)
|
||||||
|
self._record_activity_event(
|
||||||
|
session,
|
||||||
|
bot_id,
|
||||||
|
"bot_deployed",
|
||||||
|
channel="system",
|
||||||
|
detail=(
|
||||||
|
f"Bot {bot_id} deployed to {self._node_display_name(next_target.node_id)}"
|
||||||
|
if target_changed
|
||||||
|
else f"Bot {bot_id} redeployed with image {requested_image_tag}"
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"previous_target": self._serialize_provider_target_summary(current_target),
|
||||||
|
"next_target": self._serialize_provider_target_summary(next_target),
|
||||||
|
"previous_image_tag": previous_image_tag,
|
||||||
|
"image_tag": requested_image_tag,
|
||||||
|
"auto_start": bool(auto_start),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(bot)
|
||||||
|
|
||||||
|
started = False
|
||||||
|
if bool(auto_start):
|
||||||
|
await self._runtime_service.start_bot(app_state=app_state, session=session, bot=bot)
|
||||||
|
session.refresh(bot)
|
||||||
|
started = True
|
||||||
|
|
||||||
|
self._invalidate_bot_detail_cache(bot_id)
|
||||||
|
return {
|
||||||
|
"status": "deployed",
|
||||||
|
"bot": self._serialize_bot(bot),
|
||||||
|
"started": started,
|
||||||
|
"image_tag": requested_image_tag,
|
||||||
|
"previous_image_tag": previous_image_tag,
|
||||||
|
"previous_target": self._serialize_provider_target_summary(current_target),
|
||||||
|
"next_target": self._serialize_provider_target_summary(next_target),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _set_inactive(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app_state: Any,
|
||||||
|
session: Session,
|
||||||
|
bot: BotInstance,
|
||||||
|
activity_type: str,
|
||||||
|
detail: str,
|
||||||
|
) -> None:
|
||||||
|
bot_id = str(bot.id or "").strip()
|
||||||
|
try:
|
||||||
|
self._runtime_service.stop_bot(app_state=app_state, session=session, bot=bot)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
bot.enabled = False
|
||||||
|
bot.docker_status = "STOPPED"
|
||||||
|
if str(bot.current_state or "").upper() not in {"ERROR"}:
|
||||||
|
bot.current_state = "IDLE"
|
||||||
|
session.add(bot)
|
||||||
|
self._record_activity_event(session, bot_id, activity_type, channel="system", detail=f"Bot {bot_id} {detail}")
|
||||||
|
session.commit()
|
||||||
|
self._invalidate_bot_detail_cache(bot_id)
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from models.bot import BotInstance, BotMessage
|
||||||
|
|
||||||
|
CacheKeyMessages = Callable[[str, int], str]
|
||||||
|
CacheKeyMessagesPage = Callable[[str, int, Optional[int]], str]
|
||||||
|
SerializeMessageRow = Callable[[str, BotMessage], Dict[str, Any]]
|
||||||
|
ResolveLocalDayRange = Callable[[str, Optional[int]], tuple[datetime, datetime]]
|
||||||
|
InvalidateMessagesCache = Callable[[str], None]
|
||||||
|
GetChatPullPageSize = Callable[[], int]
|
||||||
|
|
||||||
|
|
||||||
|
class BotMessageService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cache: Any,
|
||||||
|
cache_key_bot_messages: CacheKeyMessages,
|
||||||
|
cache_key_bot_messages_page: CacheKeyMessagesPage,
|
||||||
|
serialize_bot_message_row: SerializeMessageRow,
|
||||||
|
resolve_local_day_range: ResolveLocalDayRange,
|
||||||
|
invalidate_bot_messages_cache: InvalidateMessagesCache,
|
||||||
|
get_chat_pull_page_size: GetChatPullPageSize,
|
||||||
|
) -> None:
|
||||||
|
self._cache = cache
|
||||||
|
self._cache_key_bot_messages = cache_key_bot_messages
|
||||||
|
self._cache_key_bot_messages_page = cache_key_bot_messages_page
|
||||||
|
self._serialize_bot_message_row = serialize_bot_message_row
|
||||||
|
self._resolve_local_day_range = resolve_local_day_range
|
||||||
|
self._invalidate_bot_messages_cache = invalidate_bot_messages_cache
|
||||||
|
self._get_chat_pull_page_size = get_chat_pull_page_size
|
||||||
|
|
||||||
|
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def list_messages(self, *, session: Session, bot_id: str, limit: int = 200) -> list[Dict[str, Any]]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
safe_limit = max(1, min(int(limit), 500))
|
||||||
|
cached = self._cache.get_json(self._cache_key_bot_messages(bot_id, safe_limit))
|
||||||
|
if isinstance(cached, list):
|
||||||
|
return cached
|
||||||
|
rows = session.exec(
|
||||||
|
select(BotMessage)
|
||||||
|
.where(BotMessage.bot_id == bot_id)
|
||||||
|
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
|
||||||
|
.limit(safe_limit)
|
||||||
|
).all()
|
||||||
|
ordered = list(reversed(rows))
|
||||||
|
payload = [self._serialize_bot_message_row(bot_id, row) for row in ordered]
|
||||||
|
self._cache.set_json(self._cache_key_bot_messages(bot_id, safe_limit), payload, ttl=30)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def list_messages_page(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
before_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
configured_limit = self._get_chat_pull_page_size()
|
||||||
|
safe_limit = max(1, min(int(limit if limit is not None else configured_limit), 500))
|
||||||
|
safe_before_id = int(before_id) if isinstance(before_id, int) and before_id > 0 else None
|
||||||
|
cache_key = self._cache_key_bot_messages_page(bot_id, safe_limit, safe_before_id)
|
||||||
|
cached = self._cache.get_json(cache_key)
|
||||||
|
if isinstance(cached, dict) and isinstance(cached.get("items"), list):
|
||||||
|
return cached
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(BotMessage)
|
||||||
|
.where(BotMessage.bot_id == bot_id)
|
||||||
|
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
|
||||||
|
.limit(safe_limit + 1)
|
||||||
|
)
|
||||||
|
if safe_before_id is not None:
|
||||||
|
stmt = stmt.where(BotMessage.id < safe_before_id)
|
||||||
|
|
||||||
|
rows = session.exec(stmt).all()
|
||||||
|
has_more = len(rows) > safe_limit
|
||||||
|
if has_more:
|
||||||
|
rows = rows[:safe_limit]
|
||||||
|
ordered = list(reversed(rows))
|
||||||
|
items = [self._serialize_bot_message_row(bot_id, row) for row in ordered]
|
||||||
|
next_before_id = rows[-1].id if rows else None
|
||||||
|
payload = {
|
||||||
|
"items": items,
|
||||||
|
"has_more": bool(has_more),
|
||||||
|
"next_before_id": next_before_id,
|
||||||
|
"limit": safe_limit,
|
||||||
|
}
|
||||||
|
self._cache.set_json(cache_key, payload, ttl=30)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def list_messages_by_date(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
date: str,
|
||||||
|
tz_offset_minutes: Optional[int] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
utc_start, utc_end = self._resolve_local_day_range(date, tz_offset_minutes)
|
||||||
|
configured_limit = max(60, self._get_chat_pull_page_size())
|
||||||
|
safe_limit = max(12, min(int(limit if limit is not None else configured_limit), 240))
|
||||||
|
before_limit = max(3, min(18, safe_limit // 4))
|
||||||
|
after_limit = max(0, safe_limit - before_limit - 1)
|
||||||
|
|
||||||
|
exact_anchor = session.exec(
|
||||||
|
select(BotMessage)
|
||||||
|
.where(
|
||||||
|
BotMessage.bot_id == bot_id,
|
||||||
|
BotMessage.created_at >= utc_start,
|
||||||
|
BotMessage.created_at < utc_end,
|
||||||
|
)
|
||||||
|
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
anchor = exact_anchor
|
||||||
|
matched_exact_date = exact_anchor is not None
|
||||||
|
if anchor is None:
|
||||||
|
next_row = session.exec(
|
||||||
|
select(BotMessage)
|
||||||
|
.where(BotMessage.bot_id == bot_id, BotMessage.created_at >= utc_end)
|
||||||
|
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
prev_row = session.exec(
|
||||||
|
select(BotMessage)
|
||||||
|
.where(BotMessage.bot_id == bot_id, BotMessage.created_at < utc_start)
|
||||||
|
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if next_row and prev_row:
|
||||||
|
gap_after = next_row.created_at - utc_end
|
||||||
|
gap_before = utc_start - prev_row.created_at
|
||||||
|
anchor = next_row if gap_after <= gap_before else prev_row
|
||||||
|
else:
|
||||||
|
anchor = next_row or prev_row
|
||||||
|
|
||||||
|
if anchor is None or anchor.id is None:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"anchor_id": None,
|
||||||
|
"resolved_ts": None,
|
||||||
|
"matched_exact_date": False,
|
||||||
|
"has_more_before": False,
|
||||||
|
"has_more_after": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
before_rows = session.exec(
|
||||||
|
select(BotMessage)
|
||||||
|
.where(BotMessage.bot_id == bot_id, BotMessage.id < anchor.id)
|
||||||
|
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
|
||||||
|
.limit(before_limit)
|
||||||
|
).all()
|
||||||
|
after_rows = session.exec(
|
||||||
|
select(BotMessage)
|
||||||
|
.where(BotMessage.bot_id == bot_id, BotMessage.id > anchor.id)
|
||||||
|
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
|
||||||
|
.limit(after_limit)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
ordered = list(reversed(before_rows)) + [anchor] + after_rows
|
||||||
|
first_row = ordered[0] if ordered else None
|
||||||
|
last_row = ordered[-1] if ordered else None
|
||||||
|
|
||||||
|
has_more_before = False
|
||||||
|
if first_row is not None and first_row.id is not None:
|
||||||
|
has_more_before = (
|
||||||
|
session.exec(
|
||||||
|
select(BotMessage.id)
|
||||||
|
.where(BotMessage.bot_id == bot_id, BotMessage.id < first_row.id)
|
||||||
|
.order_by(BotMessage.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
has_more_after = False
|
||||||
|
if last_row is not None and last_row.id is not None:
|
||||||
|
has_more_after = (
|
||||||
|
session.exec(
|
||||||
|
select(BotMessage.id)
|
||||||
|
.where(BotMessage.bot_id == bot_id, BotMessage.id > last_row.id)
|
||||||
|
.order_by(BotMessage.id.asc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [self._serialize_bot_message_row(bot_id, row) for row in ordered],
|
||||||
|
"anchor_id": anchor.id,
|
||||||
|
"resolved_ts": int(anchor.created_at.timestamp() * 1000),
|
||||||
|
"matched_exact_date": matched_exact_date,
|
||||||
|
"has_more_before": has_more_before,
|
||||||
|
"has_more_after": has_more_after,
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_feedback(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
message_id: int,
|
||||||
|
feedback: Optional[str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
row = session.get(BotMessage, message_id)
|
||||||
|
if not row or row.bot_id != bot_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Message not found")
|
||||||
|
if row.role != "assistant":
|
||||||
|
raise HTTPException(status_code=400, detail="Only assistant messages support feedback")
|
||||||
|
|
||||||
|
raw = str(feedback or "").strip().lower()
|
||||||
|
if raw in {"", "none", "null"}:
|
||||||
|
row.feedback = None
|
||||||
|
row.feedback_at = None
|
||||||
|
elif raw in {"up", "down"}:
|
||||||
|
row.feedback = raw
|
||||||
|
row.feedback_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="feedback must be 'up' or 'down'")
|
||||||
|
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
self._invalidate_bot_messages_cache(bot_id)
|
||||||
|
return {
|
||||||
|
"status": "updated",
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"message_id": row.id,
|
||||||
|
"feedback": row.feedback,
|
||||||
|
"feedback_at": row.feedback_at.isoformat() if row.feedback_at else None,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from clients.edge.errors import log_edge_failure
|
||||||
|
from models.bot import BotInstance
|
||||||
|
|
||||||
|
CacheKeyBotsList = Callable[[Optional[int]], str]
|
||||||
|
CacheKeyBotDetail = Callable[[str], str]
|
||||||
|
RefreshBotRuntimeStatus = Callable[[Any, BotInstance], str]
|
||||||
|
SerializeBot = Callable[[BotInstance], Dict[str, Any]]
|
||||||
|
SerializeBotListItem = Callable[[BotInstance], Dict[str, Any]]
|
||||||
|
ReadBotResources = Callable[[str], Dict[str, Any]]
|
||||||
|
ResolveBotProviderTarget = Callable[[BotInstance], Any]
|
||||||
|
WorkspaceRoot = Callable[[str], str]
|
||||||
|
CalcDirSizeBytes = Callable[[str], int]
|
||||||
|
|
||||||
|
|
||||||
|
class BotQueryService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cache: Any,
|
||||||
|
cache_key_bots_list: CacheKeyBotsList,
|
||||||
|
cache_key_bot_detail: CacheKeyBotDetail,
|
||||||
|
refresh_bot_runtime_status: RefreshBotRuntimeStatus,
|
||||||
|
serialize_bot: SerializeBot,
|
||||||
|
serialize_bot_list_item: SerializeBotListItem,
|
||||||
|
read_bot_resources: ReadBotResources,
|
||||||
|
resolve_bot_provider_target: ResolveBotProviderTarget,
|
||||||
|
get_runtime_provider: Callable[[Any, BotInstance], Any],
|
||||||
|
workspace_root: WorkspaceRoot,
|
||||||
|
calc_dir_size_bytes: CalcDirSizeBytes,
|
||||||
|
logger: Any,
|
||||||
|
) -> None:
|
||||||
|
self._cache = cache
|
||||||
|
self._cache_key_bots_list = cache_key_bots_list
|
||||||
|
self._cache_key_bot_detail = cache_key_bot_detail
|
||||||
|
self._refresh_bot_runtime_status = refresh_bot_runtime_status
|
||||||
|
self._serialize_bot = serialize_bot
|
||||||
|
self._serialize_bot_list_item = serialize_bot_list_item
|
||||||
|
self._read_bot_resources = read_bot_resources
|
||||||
|
self._resolve_bot_provider_target = resolve_bot_provider_target
|
||||||
|
self._get_runtime_provider = get_runtime_provider
|
||||||
|
self._workspace_root = workspace_root
|
||||||
|
self._calc_dir_size_bytes = calc_dir_size_bytes
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def list_bots(self, *, app_state: Any, session: Session, current_user_id: int) -> list[Dict[str, Any]]:
|
||||||
|
from models.sys_auth import SysUser
|
||||||
|
from services.sys_auth_service import list_accessible_bots_for_user
|
||||||
|
|
||||||
|
cached = self._cache.get_json(self._cache_key_bots_list(current_user_id))
|
||||||
|
if isinstance(cached, list):
|
||||||
|
return cached
|
||||||
|
current_user = session.get(SysUser, current_user_id) if current_user_id > 0 else None
|
||||||
|
if current_user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
bots = list_accessible_bots_for_user(session, current_user)
|
||||||
|
dirty = False
|
||||||
|
for bot in bots:
|
||||||
|
previous_status = str(bot.docker_status or "").upper()
|
||||||
|
previous_state = str(bot.current_state or "")
|
||||||
|
actual_status = self._refresh_bot_runtime_status(app_state, bot)
|
||||||
|
if previous_status != actual_status or previous_state != str(bot.current_state or ""):
|
||||||
|
session.add(bot)
|
||||||
|
dirty = True
|
||||||
|
if dirty:
|
||||||
|
session.commit()
|
||||||
|
for bot in bots:
|
||||||
|
session.refresh(bot)
|
||||||
|
rows = [self._serialize_bot_list_item(bot) for bot in bots]
|
||||||
|
self._cache.set_json(self._cache_key_bots_list(current_user_id), rows, ttl=30)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def get_bot_detail(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
cached = self._cache.get_json(self._cache_key_bot_detail(bot_id))
|
||||||
|
if isinstance(cached, dict):
|
||||||
|
return cached
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
previous_status = str(bot.docker_status or "").upper()
|
||||||
|
previous_state = str(bot.current_state or "")
|
||||||
|
actual_status = self._refresh_bot_runtime_status(app_state, bot)
|
||||||
|
if previous_status != actual_status or previous_state != str(bot.current_state or ""):
|
||||||
|
session.add(bot)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(bot)
|
||||||
|
row = self._serialize_bot(bot)
|
||||||
|
self._cache.set_json(self._cache_key_bot_detail(bot_id), row, ttl=30)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def get_bot_resources(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
configured = self._read_bot_resources(bot_id)
|
||||||
|
try:
|
||||||
|
runtime = self._get_runtime_provider(app_state, bot).get_resource_snapshot(bot_id=bot_id)
|
||||||
|
except Exception as exc:
|
||||||
|
log_edge_failure(
|
||||||
|
self._logger,
|
||||||
|
key=f"bot-resources:{bot_id}",
|
||||||
|
exc=exc,
|
||||||
|
message=f"Failed to refresh bot resources for bot_id={bot_id}",
|
||||||
|
)
|
||||||
|
runtime = {"usage": {}, "limits": {}, "docker_status": str(bot.docker_status or "STOPPED").upper()}
|
||||||
|
runtime_status = str(runtime.get("docker_status") or "").upper()
|
||||||
|
previous_status = str(bot.docker_status or "").upper()
|
||||||
|
previous_state = str(bot.current_state or "")
|
||||||
|
if runtime_status:
|
||||||
|
bot.docker_status = runtime_status
|
||||||
|
if runtime_status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}:
|
||||||
|
bot.current_state = "IDLE"
|
||||||
|
if previous_status != str(bot.docker_status or "").upper() or previous_state != str(bot.current_state or ""):
|
||||||
|
session.add(bot)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(bot)
|
||||||
|
target = self._resolve_bot_provider_target(bot)
|
||||||
|
usage_payload = dict(runtime.get("usage") or {})
|
||||||
|
workspace_bytes = int(usage_payload.get("container_rw_bytes") or usage_payload.get("workspace_used_bytes") or 0)
|
||||||
|
workspace_root = ""
|
||||||
|
if workspace_bytes <= 0:
|
||||||
|
workspace_root = self._workspace_root(bot_id)
|
||||||
|
workspace_bytes = self._calc_dir_size_bytes(workspace_root)
|
||||||
|
elif target.transport_kind != "edge":
|
||||||
|
workspace_root = self._workspace_root(bot_id)
|
||||||
|
configured_storage_bytes = int(configured.get("storage_gb", 0) or 0) * 1024 * 1024 * 1024
|
||||||
|
workspace_percent = 0.0
|
||||||
|
if configured_storage_bytes > 0:
|
||||||
|
workspace_percent = (workspace_bytes / configured_storage_bytes) * 100.0
|
||||||
|
|
||||||
|
limits = runtime.get("limits") or {}
|
||||||
|
cpu_limited = (limits.get("cpu_cores") or 0) > 0
|
||||||
|
memory_limited = (limits.get("memory_bytes") or 0) > 0
|
||||||
|
storage_limited = bool(limits.get("storage_bytes")) or bool(limits.get("storage_opt_raw"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"docker_status": runtime.get("docker_status") or bot.docker_status,
|
||||||
|
"configured": configured,
|
||||||
|
"runtime": runtime,
|
||||||
|
"workspace": {
|
||||||
|
"path": workspace_root or None,
|
||||||
|
"usage_bytes": workspace_bytes,
|
||||||
|
"configured_limit_bytes": configured_storage_bytes if configured_storage_bytes > 0 else None,
|
||||||
|
"usage_percent": max(0.0, workspace_percent),
|
||||||
|
},
|
||||||
|
"enforcement": {
|
||||||
|
"cpu_limited": cpu_limited,
|
||||||
|
"memory_limited": memory_limited,
|
||||||
|
"storage_limited": storage_limited,
|
||||||
|
},
|
||||||
|
"note": (
|
||||||
|
"Resource value 0 means unlimited. CPU/Memory limits come from Docker HostConfig and are enforced by cgroup. "
|
||||||
|
"Storage limit depends on Docker storage driver support."
|
||||||
|
),
|
||||||
|
"collected_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_tools_config(self, *, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return {
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"tools_config": {},
|
||||||
|
"managed_by_dashboard": False,
|
||||||
|
"hint": "Tools config is disabled in dashboard. Configure tool-related env vars manually.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_tools_config(self, *, session: Session, bot_id: str, payload: Any) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Tools config is no longer managed by dashboard. Please set required env vars manually.",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Dict
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from clients.edge.errors import log_edge_failure
|
||||||
|
from models.bot import BotInstance
|
||||||
|
from providers.target import provider_target_to_dict
|
||||||
|
|
||||||
|
|
||||||
|
class BotRuntimeSnapshotService:
|
||||||
|
_AGENT_LOOP_READY_MARKER = "Agent loop started"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
engine: Any,
|
||||||
|
logger: Any,
|
||||||
|
docker_manager: Any,
|
||||||
|
default_soul_md: str,
|
||||||
|
default_agents_md: str,
|
||||||
|
default_user_md: str,
|
||||||
|
default_tools_md: str,
|
||||||
|
default_identity_md: str,
|
||||||
|
workspace_root: Callable[[str], str],
|
||||||
|
resolve_edge_state_context: Callable[[str], Any],
|
||||||
|
read_bot_config: Callable[[str], Dict[str, Any]],
|
||||||
|
resolve_bot_env_params: Callable[[str], Dict[str, str]],
|
||||||
|
resolve_bot_provider_target_for_instance: Callable[[BotInstance], Any],
|
||||||
|
read_global_delivery_flags: Callable[[Any], tuple[bool, bool]],
|
||||||
|
safe_float: Callable[[Any, float], float],
|
||||||
|
safe_int: Callable[[Any, int], int],
|
||||||
|
get_default_system_timezone: Callable[[], str],
|
||||||
|
read_bot_resources: Callable[[str, Any], Dict[str, Any]],
|
||||||
|
node_display_name: Callable[[str], str],
|
||||||
|
get_runtime_provider: Callable[[Any, BotInstance], Any],
|
||||||
|
invalidate_bot_detail_cache: Callable[[str], None],
|
||||||
|
record_activity_event: Callable[..., None],
|
||||||
|
) -> None:
|
||||||
|
self._engine = engine
|
||||||
|
self._logger = logger
|
||||||
|
self._docker_manager = docker_manager
|
||||||
|
self._default_soul_md = default_soul_md
|
||||||
|
self._default_agents_md = default_agents_md
|
||||||
|
self._default_user_md = default_user_md
|
||||||
|
self._default_tools_md = default_tools_md
|
||||||
|
self._default_identity_md = default_identity_md
|
||||||
|
self._workspace_root = workspace_root
|
||||||
|
self._resolve_edge_state_context = resolve_edge_state_context
|
||||||
|
self._read_bot_config = read_bot_config
|
||||||
|
self._resolve_bot_env_params = resolve_bot_env_params
|
||||||
|
self._resolve_bot_provider_target_for_instance = resolve_bot_provider_target_for_instance
|
||||||
|
self._read_global_delivery_flags = read_global_delivery_flags
|
||||||
|
self._safe_float = safe_float
|
||||||
|
self._safe_int = safe_int
|
||||||
|
self._get_default_system_timezone = get_default_system_timezone
|
||||||
|
self._read_bot_resources = read_bot_resources
|
||||||
|
self._node_display_name = node_display_name
|
||||||
|
self._get_runtime_provider = get_runtime_provider
|
||||||
|
self._invalidate_bot_detail_cache = invalidate_bot_detail_cache
|
||||||
|
self._record_activity_event = record_activity_event
|
||||||
|
|
||||||
|
def read_workspace_md(self, bot_id: str, filename: str, default_value: str) -> str:
|
||||||
|
edge_context = self._resolve_edge_state_context(bot_id)
|
||||||
|
if edge_context is not None:
|
||||||
|
client, workspace_root, node_id = edge_context
|
||||||
|
try:
|
||||||
|
payload = client.read_file(
|
||||||
|
bot_id=bot_id,
|
||||||
|
path=filename,
|
||||||
|
max_bytes=1_000_000,
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
)
|
||||||
|
if bool(payload.get("is_markdown")):
|
||||||
|
content = payload.get("content")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.strip()
|
||||||
|
except Exception as exc:
|
||||||
|
log_edge_failure(
|
||||||
|
self._logger,
|
||||||
|
key=f"workspace-md-read:{node_id}:{bot_id}:{filename}",
|
||||||
|
exc=exc,
|
||||||
|
message=f"Failed to read edge workspace markdown for bot_id={bot_id}, file={filename}",
|
||||||
|
)
|
||||||
|
return default_value
|
||||||
|
path = os.path.join(self._workspace_root(bot_id), filename)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return default_value
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as file:
|
||||||
|
return file.read().strip()
|
||||||
|
except Exception:
|
||||||
|
return default_value
|
||||||
|
|
||||||
|
def read_bot_runtime_snapshot(self, bot: BotInstance) -> Dict[str, Any]:
|
||||||
|
config_data = self._read_bot_config(bot.id)
|
||||||
|
env_params = self._resolve_bot_env_params(bot.id)
|
||||||
|
target = self._resolve_bot_provider_target_for_instance(bot)
|
||||||
|
|
||||||
|
provider_name = ""
|
||||||
|
provider_cfg: Dict[str, Any] = {}
|
||||||
|
providers_cfg = config_data.get("providers")
|
||||||
|
if isinstance(providers_cfg, dict):
|
||||||
|
for p_name, p_cfg in providers_cfg.items():
|
||||||
|
provider_name = str(p_name or "").strip()
|
||||||
|
if isinstance(p_cfg, dict):
|
||||||
|
provider_cfg = p_cfg
|
||||||
|
break
|
||||||
|
|
||||||
|
agents_defaults: Dict[str, Any] = {}
|
||||||
|
agents_cfg = config_data.get("agents")
|
||||||
|
if isinstance(agents_cfg, dict):
|
||||||
|
defaults = agents_cfg.get("defaults")
|
||||||
|
if isinstance(defaults, dict):
|
||||||
|
agents_defaults = defaults
|
||||||
|
|
||||||
|
channels_cfg = config_data.get("channels")
|
||||||
|
send_progress, send_tool_hints = self._read_global_delivery_flags(channels_cfg)
|
||||||
|
|
||||||
|
llm_provider = provider_name or "dashscope"
|
||||||
|
llm_model = str(agents_defaults.get("model") or "")
|
||||||
|
api_key = str(provider_cfg.get("apiKey") or "").strip()
|
||||||
|
api_base = str(provider_cfg.get("apiBase") or "").strip()
|
||||||
|
api_base_lower = api_base.lower()
|
||||||
|
if llm_provider == "openai" and ("spark-api-open.xf-yun.com" in api_base_lower or "xf-yun.com" in api_base_lower):
|
||||||
|
llm_provider = "xunfei"
|
||||||
|
|
||||||
|
soul_md = self.read_workspace_md(bot.id, "SOUL.md", self._default_soul_md)
|
||||||
|
resources = self._read_bot_resources(bot.id, config_data=config_data)
|
||||||
|
return {
|
||||||
|
**provider_target_to_dict(target),
|
||||||
|
"llm_provider": llm_provider,
|
||||||
|
"llm_model": llm_model,
|
||||||
|
"api_key": api_key,
|
||||||
|
"api_base": api_base,
|
||||||
|
"temperature": self._safe_float(agents_defaults.get("temperature"), 0.2),
|
||||||
|
"top_p": self._safe_float(agents_defaults.get("topP"), 1.0),
|
||||||
|
"max_tokens": self._safe_int(agents_defaults.get("maxTokens"), 8192),
|
||||||
|
"cpu_cores": resources["cpu_cores"],
|
||||||
|
"memory_mb": resources["memory_mb"],
|
||||||
|
"storage_gb": resources["storage_gb"],
|
||||||
|
"system_timezone": env_params.get("TZ") or self._get_default_system_timezone(),
|
||||||
|
"send_progress": send_progress,
|
||||||
|
"send_tool_hints": send_tool_hints,
|
||||||
|
"soul_md": soul_md,
|
||||||
|
"agents_md": self.read_workspace_md(bot.id, "AGENTS.md", self._default_agents_md),
|
||||||
|
"user_md": self.read_workspace_md(bot.id, "USER.md", self._default_user_md),
|
||||||
|
"tools_md": self.read_workspace_md(bot.id, "TOOLS.md", self._default_tools_md),
|
||||||
|
"identity_md": self.read_workspace_md(bot.id, "IDENTITY.md", self._default_identity_md),
|
||||||
|
"system_prompt": soul_md,
|
||||||
|
}
|
||||||
|
|
||||||
|
def serialize_bot(self, bot: BotInstance) -> Dict[str, Any]:
|
||||||
|
runtime = self.read_bot_runtime_snapshot(bot)
|
||||||
|
target = self._resolve_bot_provider_target_for_instance(bot)
|
||||||
|
return {
|
||||||
|
"id": bot.id,
|
||||||
|
"name": bot.name,
|
||||||
|
"enabled": bool(getattr(bot, "enabled", True)),
|
||||||
|
"avatar_model": "base",
|
||||||
|
"avatar_skin": "blue_suit",
|
||||||
|
"image_tag": bot.image_tag,
|
||||||
|
"llm_provider": runtime.get("llm_provider") or "",
|
||||||
|
"llm_model": runtime.get("llm_model") or "",
|
||||||
|
"system_prompt": runtime.get("system_prompt") or "",
|
||||||
|
"api_base": runtime.get("api_base") or "",
|
||||||
|
"temperature": self._safe_float(runtime.get("temperature"), 0.2),
|
||||||
|
"top_p": self._safe_float(runtime.get("top_p"), 1.0),
|
||||||
|
"max_tokens": self._safe_int(runtime.get("max_tokens"), 8192),
|
||||||
|
"cpu_cores": self._safe_float(runtime.get("cpu_cores"), 1.0),
|
||||||
|
"memory_mb": self._safe_int(runtime.get("memory_mb"), 1024),
|
||||||
|
"storage_gb": self._safe_int(runtime.get("storage_gb"), 10),
|
||||||
|
"system_timezone": str(runtime.get("system_timezone") or self._get_default_system_timezone()),
|
||||||
|
"send_progress": bool(runtime.get("send_progress")),
|
||||||
|
"send_tool_hints": bool(runtime.get("send_tool_hints")),
|
||||||
|
"node_id": target.node_id,
|
||||||
|
"node_display_name": self._node_display_name(target.node_id),
|
||||||
|
"transport_kind": target.transport_kind,
|
||||||
|
"runtime_kind": target.runtime_kind,
|
||||||
|
"core_adapter": target.core_adapter,
|
||||||
|
"soul_md": runtime.get("soul_md") or "",
|
||||||
|
"agents_md": runtime.get("agents_md") or "",
|
||||||
|
"user_md": runtime.get("user_md") or "",
|
||||||
|
"tools_md": runtime.get("tools_md") or "",
|
||||||
|
"identity_md": runtime.get("identity_md") or "",
|
||||||
|
"workspace_dir": bot.workspace_dir,
|
||||||
|
"docker_status": bot.docker_status,
|
||||||
|
"current_state": bot.current_state,
|
||||||
|
"last_action": bot.last_action,
|
||||||
|
"created_at": bot.created_at,
|
||||||
|
"updated_at": bot.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def serialize_bot_list_item(self, bot: BotInstance) -> Dict[str, Any]:
|
||||||
|
runtime = self.read_bot_runtime_snapshot(bot)
|
||||||
|
target = self._resolve_bot_provider_target_for_instance(bot)
|
||||||
|
return {
|
||||||
|
"id": bot.id,
|
||||||
|
"name": bot.name,
|
||||||
|
"enabled": bool(getattr(bot, "enabled", True)),
|
||||||
|
"image_tag": bot.image_tag,
|
||||||
|
"llm_provider": runtime.get("llm_provider") or "",
|
||||||
|
"llm_model": runtime.get("llm_model") or "",
|
||||||
|
"node_id": target.node_id,
|
||||||
|
"node_display_name": self._node_display_name(target.node_id),
|
||||||
|
"transport_kind": target.transport_kind,
|
||||||
|
"runtime_kind": target.runtime_kind,
|
||||||
|
"core_adapter": target.core_adapter,
|
||||||
|
"docker_status": bot.docker_status,
|
||||||
|
"current_state": bot.current_state,
|
||||||
|
"last_action": bot.last_action,
|
||||||
|
"updated_at": bot.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def refresh_bot_runtime_status(self, app_state: Any, bot: BotInstance) -> str:
|
||||||
|
current_status = str(bot.docker_status or "STOPPED").upper()
|
||||||
|
try:
|
||||||
|
status = str(self._get_runtime_provider(app_state, bot).get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
|
||||||
|
except Exception as exc:
|
||||||
|
log_edge_failure(
|
||||||
|
self._logger,
|
||||||
|
key=f"bot-runtime-status:{bot.id}",
|
||||||
|
exc=exc,
|
||||||
|
message=f"Failed to refresh runtime status for bot_id={bot.id}",
|
||||||
|
)
|
||||||
|
return current_status
|
||||||
|
bot.docker_status = status
|
||||||
|
if status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}:
|
||||||
|
bot.current_state = "IDLE"
|
||||||
|
return status
|
||||||
|
|
||||||
|
async def wait_for_agent_loop_ready(
|
||||||
|
self,
|
||||||
|
bot_id: str,
|
||||||
|
timeout_seconds: float = 12.0,
|
||||||
|
poll_interval_seconds: float = 0.5,
|
||||||
|
) -> bool:
|
||||||
|
deadline = time.monotonic() + max(1.0, timeout_seconds)
|
||||||
|
marker = self._AGENT_LOOP_READY_MARKER.lower()
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
logs = self._docker_manager.get_recent_logs(bot_id, tail=200)
|
||||||
|
if any(marker in str(line or "").lower() for line in logs):
|
||||||
|
return True
|
||||||
|
await asyncio.sleep(max(0.1, poll_interval_seconds))
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def record_agent_loop_ready_warning(
|
||||||
|
self,
|
||||||
|
bot_id: str,
|
||||||
|
timeout_seconds: float = 12.0,
|
||||||
|
poll_interval_seconds: float = 0.5,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
agent_loop_ready = await self.wait_for_agent_loop_ready(
|
||||||
|
bot_id,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
poll_interval_seconds=poll_interval_seconds,
|
||||||
|
)
|
||||||
|
if agent_loop_ready:
|
||||||
|
return
|
||||||
|
if self._docker_manager.get_bot_status(bot_id) != "RUNNING":
|
||||||
|
return
|
||||||
|
detail = (
|
||||||
|
"Bot container started, but ready marker was not found in logs within "
|
||||||
|
f"{int(timeout_seconds)}s. Check bot logs or MCP config if the bot stays unavailable."
|
||||||
|
)
|
||||||
|
self._logger.warning("bot_id=%s agent loop ready marker not found within %ss", bot_id, timeout_seconds)
|
||||||
|
with Session(self._engine) as background_session:
|
||||||
|
if not background_session.get(BotInstance, bot_id):
|
||||||
|
return
|
||||||
|
self._record_activity_event(
|
||||||
|
background_session,
|
||||||
|
bot_id,
|
||||||
|
"bot_warning",
|
||||||
|
channel="system",
|
||||||
|
detail=detail,
|
||||||
|
metadata={
|
||||||
|
"kind": "agent_loop_ready_timeout",
|
||||||
|
"marker": self._AGENT_LOOP_READY_MARKER,
|
||||||
|
"timeout_seconds": timeout_seconds,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
background_session.commit()
|
||||||
|
self._invalidate_bot_detail_cache(bot_id)
|
||||||
|
except Exception:
|
||||||
|
self._logger.exception("Failed to record agent loop readiness warning for bot_id=%s", bot_id)
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from models.bot import BotInstance
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardAuthService:
|
||||||
|
AUTH_TOKEN_HEADER = "authorization"
|
||||||
|
AUTH_TOKEN_FALLBACK_HEADER = "x-auth-token"
|
||||||
|
|
||||||
|
def __init__(self, *, engine: Any) -> None:
|
||||||
|
self._engine = engine
|
||||||
|
|
||||||
|
def extract_bot_id_from_api_path(self, 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_auth_token_http(self, request: Request) -> str:
|
||||||
|
auth_header = str(request.headers.get(self.AUTH_TOKEN_HEADER) or "").strip()
|
||||||
|
if auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header[7:].strip()
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
header_value = str(request.headers.get(self.AUTH_TOKEN_FALLBACK_HEADER) or "").strip()
|
||||||
|
if header_value:
|
||||||
|
return header_value
|
||||||
|
return str(request.query_params.get("auth_token") or "").strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_public_api_path(path: str, method: str = "GET") -> bool:
|
||||||
|
raw = str(path or "").strip()
|
||||||
|
if not raw.startswith("/api/"):
|
||||||
|
return False
|
||||||
|
return raw in {
|
||||||
|
"/api/sys/auth/status",
|
||||||
|
"/api/sys/auth/login",
|
||||||
|
"/api/sys/auth/logout",
|
||||||
|
"/api/health",
|
||||||
|
"/api/health/cache",
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_bot_enable_api_path(self, path: str, method: str = "GET") -> bool:
|
||||||
|
raw = str(path or "").strip()
|
||||||
|
verb = str(method or "GET").strip().upper()
|
||||||
|
if verb != "POST":
|
||||||
|
return False
|
||||||
|
bot_id = self.extract_bot_id_from_api_path(raw)
|
||||||
|
if not bot_id:
|
||||||
|
return False
|
||||||
|
return raw == f"/api/bots/{bot_id}/enable"
|
||||||
|
|
||||||
|
def validate_dashboard_auth(self, request: Request, session: Session) -> Optional[str]:
|
||||||
|
token = self.get_supplied_auth_token_http(request)
|
||||||
|
if not token:
|
||||||
|
return "Authentication required"
|
||||||
|
|
||||||
|
from services.sys_auth_service import resolve_user_by_token
|
||||||
|
|
||||||
|
user = resolve_user_by_token(session, token)
|
||||||
|
if user is None:
|
||||||
|
return "Session expired or invalid"
|
||||||
|
|
||||||
|
request.state.sys_auth_mode = "session_token"
|
||||||
|
request.state.sys_user_id = int(user.id or 0)
|
||||||
|
request.state.sys_username = str(user.username or "")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json_error(request: Request, *, status_code: int, detail: str) -> JSONResponse:
|
||||||
|
headers = {"Access-Control-Allow-Origin": "*"}
|
||||||
|
origin = str(request.headers.get("origin") or "").strip()
|
||||||
|
if origin:
|
||||||
|
headers["Vary"] = "Origin"
|
||||||
|
return JSONResponse(status_code=status_code, content={"detail": detail}, headers=headers)
|
||||||
|
|
||||||
|
async def guard(self, request: Request, call_next: Callable[..., Any]):
|
||||||
|
if request.method.upper() == "OPTIONS":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
if self.is_public_api_path(request.url.path, request.method):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
current_user_id = 0
|
||||||
|
with Session(self._engine) as session:
|
||||||
|
auth_error = self.validate_dashboard_auth(request, session)
|
||||||
|
if auth_error:
|
||||||
|
return self._json_error(request, status_code=401, detail=auth_error)
|
||||||
|
current_user_id = int(getattr(request.state, "sys_user_id", 0) or 0)
|
||||||
|
|
||||||
|
bot_id = self.extract_bot_id_from_api_path(request.url.path)
|
||||||
|
if not bot_id:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
with Session(self._engine) as session:
|
||||||
|
from models.sys_auth import SysUser
|
||||||
|
from services.sys_auth_service import user_can_access_bot
|
||||||
|
|
||||||
|
current_user = session.get(SysUser, current_user_id) if current_user_id > 0 else None
|
||||||
|
if current_user is None:
|
||||||
|
return self._json_error(request, status_code=401, detail="Authentication required")
|
||||||
|
if not user_can_access_bot(session, current_user, bot_id):
|
||||||
|
return self._json_error(request, status_code=403, detail="You do not have access to this bot")
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
return self._json_error(request, status_code=404, detail="Bot not found")
|
||||||
|
|
||||||
|
enabled = bool(getattr(bot, "enabled", True))
|
||||||
|
if not enabled:
|
||||||
|
is_enable_api = self.is_bot_enable_api_path(request.url.path, request.method)
|
||||||
|
is_read_api = request.method.upper() == "GET"
|
||||||
|
if not (is_enable_api or is_read_api):
|
||||||
|
return self._json_error(request, status_code=403, detail="Bot is disabled. Enable it first.")
|
||||||
|
return await call_next(request)
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
from typing import Any, Callable, Dict, List
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from models.bot import BotInstance, NanobotImage
|
||||||
|
|
||||||
|
|
||||||
|
class ImageService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cache: Any,
|
||||||
|
cache_key_images: Callable[[], str],
|
||||||
|
invalidate_images_cache: Callable[[], None],
|
||||||
|
reconcile_image_registry: Callable[[Session], None],
|
||||||
|
docker_manager: Any,
|
||||||
|
) -> None:
|
||||||
|
self._cache = cache
|
||||||
|
self._cache_key_images = cache_key_images
|
||||||
|
self._invalidate_images_cache = invalidate_images_cache
|
||||||
|
self._reconcile_image_registry = reconcile_image_registry
|
||||||
|
self._docker_manager = docker_manager
|
||||||
|
|
||||||
|
def list_images(self, *, session: Session) -> List[Dict[str, Any]]:
|
||||||
|
cached = self._cache.get_json(self._cache_key_images())
|
||||||
|
if isinstance(cached, list) and all(isinstance(row, dict) for row in cached):
|
||||||
|
return cached
|
||||||
|
if isinstance(cached, list):
|
||||||
|
self._invalidate_images_cache()
|
||||||
|
self._reconcile_image_registry(session)
|
||||||
|
rows = session.exec(select(NanobotImage)).all()
|
||||||
|
payload = [row.model_dump() for row in rows]
|
||||||
|
self._cache.set_json(self._cache_key_images(), payload, ttl=60)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def delete_image(self, *, session: Session, tag: str) -> Dict[str, Any]:
|
||||||
|
image = session.get(NanobotImage, tag)
|
||||||
|
if not image:
|
||||||
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
|
|
||||||
|
bots_using = session.exec(select(BotInstance).where(BotInstance.image_tag == tag)).all()
|
||||||
|
if bots_using:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Cannot delete image: {len(bots_using)} bots are using it.")
|
||||||
|
|
||||||
|
session.delete(image)
|
||||||
|
session.commit()
|
||||||
|
self._invalidate_images_cache()
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
def list_docker_images(self, *, repository: str = "nanobot-base") -> List[Dict[str, Any]]:
|
||||||
|
return self._docker_manager.list_images_by_repo(repository)
|
||||||
|
|
||||||
|
def register_image(self, *, session: Session, payload: Dict[str, Any]) -> NanobotImage:
|
||||||
|
tag = str(payload.get("tag") or "").strip()
|
||||||
|
source_dir = str(payload.get("source_dir") or "manual").strip() or "manual"
|
||||||
|
if not tag:
|
||||||
|
raise HTTPException(status_code=400, detail="tag is required")
|
||||||
|
|
||||||
|
if not self._docker_manager.has_image(tag):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Docker image not found: {tag}")
|
||||||
|
|
||||||
|
version = tag.split(":")[-1].removeprefix("v") if ":" in tag else tag
|
||||||
|
try:
|
||||||
|
docker_img = self._docker_manager.client.images.get(tag) if self._docker_manager.client else None
|
||||||
|
image_id = docker_img.id if docker_img else None
|
||||||
|
except Exception:
|
||||||
|
image_id = None
|
||||||
|
|
||||||
|
row = session.get(NanobotImage, tag)
|
||||||
|
if not row:
|
||||||
|
row = NanobotImage(
|
||||||
|
tag=tag,
|
||||||
|
version=version,
|
||||||
|
status="READY",
|
||||||
|
source_dir=source_dir,
|
||||||
|
image_id=image_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.version = version
|
||||||
|
row.status = "READY"
|
||||||
|
row.source_dir = source_dir
|
||||||
|
row.image_id = image_id
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
self._invalidate_images_cache()
|
||||||
|
return row
|
||||||
|
|
||||||
|
def require_ready_image(self, session: Session, image_tag: str, *, require_local_image: bool) -> NanobotImage:
|
||||||
|
normalized_tag = str(image_tag or "").strip()
|
||||||
|
if not normalized_tag:
|
||||||
|
raise HTTPException(status_code=400, detail="image_tag is required")
|
||||||
|
image_row = session.get(NanobotImage, normalized_tag)
|
||||||
|
if not image_row:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Image not registered in DB: {normalized_tag}")
|
||||||
|
if image_row.status != "READY":
|
||||||
|
raise HTTPException(status_code=400, detail=f"Image status is not READY: {normalized_tag} ({image_row.status})")
|
||||||
|
if require_local_image and not self._docker_manager.has_image(normalized_tag):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {normalized_tag}")
|
||||||
|
return image_row
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import delete as sql_delete, func
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from models.platform import BotActivityEvent
|
||||||
|
from schemas.platform import PlatformActivityItem, PlatformActivityListResponse
|
||||||
|
|
||||||
|
from services.platform_common import utcnow
|
||||||
|
from services.platform_settings_service import get_activity_event_retention_days
|
||||||
|
|
||||||
|
ACTIVITY_EVENT_PRUNE_INTERVAL = timedelta(minutes=10)
|
||||||
|
OPERATIONAL_ACTIVITY_EVENT_TYPES = {
|
||||||
|
"bot_created",
|
||||||
|
"bot_deployed",
|
||||||
|
"bot_started",
|
||||||
|
"bot_stopped",
|
||||||
|
"bot_warning",
|
||||||
|
"bot_enabled",
|
||||||
|
"bot_disabled",
|
||||||
|
"bot_deactivated",
|
||||||
|
"command_submitted",
|
||||||
|
"command_failed",
|
||||||
|
"history_cleared",
|
||||||
|
}
|
||||||
|
|
||||||
|
_last_activity_event_prune_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
def prune_expired_activity_events(session: Session, force: bool = False) -> int:
|
||||||
|
global _last_activity_event_prune_at
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
if not force and _last_activity_event_prune_at and now - _last_activity_event_prune_at < ACTIVITY_EVENT_PRUNE_INTERVAL:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
retention_days = get_activity_event_retention_days(session)
|
||||||
|
cutoff = now - timedelta(days=retention_days)
|
||||||
|
result = session.exec(sql_delete(BotActivityEvent).where(BotActivityEvent.created_at < cutoff))
|
||||||
|
_last_activity_event_prune_at = now
|
||||||
|
return int(getattr(result, "rowcount", 0) or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def record_activity_event(
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
event_type: str,
|
||||||
|
request_id: Optional[str] = None,
|
||||||
|
channel: str = "dashboard",
|
||||||
|
detail: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> None:
|
||||||
|
normalized_event_type = str(event_type or "unknown").strip().lower() or "unknown"
|
||||||
|
if normalized_event_type not in OPERATIONAL_ACTIVITY_EVENT_TYPES:
|
||||||
|
return
|
||||||
|
prune_expired_activity_events(session, force=False)
|
||||||
|
row = BotActivityEvent(
|
||||||
|
bot_id=bot_id,
|
||||||
|
request_id=request_id,
|
||||||
|
event_type=normalized_event_type,
|
||||||
|
channel=str(channel or "dashboard").strip().lower() or "dashboard",
|
||||||
|
detail=(str(detail or "").strip() or None),
|
||||||
|
metadata_json=json.dumps(metadata or {}, ensure_ascii=False) if metadata else None,
|
||||||
|
created_at=utcnow(),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
|
||||||
|
|
||||||
|
def list_activity_events(
|
||||||
|
session: Session,
|
||||||
|
bot_id: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
deleted = prune_expired_activity_events(session, force=False)
|
||||||
|
if deleted > 0:
|
||||||
|
session.commit()
|
||||||
|
safe_limit = max(1, min(int(limit), 500))
|
||||||
|
safe_offset = max(0, int(offset or 0))
|
||||||
|
stmt = (
|
||||||
|
select(BotActivityEvent)
|
||||||
|
.order_by(BotActivityEvent.created_at.desc(), BotActivityEvent.id.desc())
|
||||||
|
.offset(safe_offset)
|
||||||
|
.limit(safe_limit)
|
||||||
|
)
|
||||||
|
total_stmt = select(func.count(BotActivityEvent.id))
|
||||||
|
if bot_id:
|
||||||
|
stmt = stmt.where(BotActivityEvent.bot_id == bot_id)
|
||||||
|
total_stmt = total_stmt.where(BotActivityEvent.bot_id == bot_id)
|
||||||
|
rows = session.exec(stmt).all()
|
||||||
|
total = int(session.exec(total_stmt).one() or 0)
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
metadata = json.loads(row.metadata_json or "{}")
|
||||||
|
except Exception:
|
||||||
|
metadata = {}
|
||||||
|
items.append(
|
||||||
|
PlatformActivityItem(
|
||||||
|
id=int(row.id or 0),
|
||||||
|
bot_id=row.bot_id,
|
||||||
|
request_id=row.request_id,
|
||||||
|
event_type=row.event_type,
|
||||||
|
channel=row.channel,
|
||||||
|
detail=row.detail,
|
||||||
|
metadata=metadata if isinstance(metadata, dict) else {},
|
||||||
|
created_at=row.created_at.isoformat() + "Z",
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
return PlatformActivityListResponse(
|
||||||
|
items=[PlatformActivityItem.model_validate(item) for item in items],
|
||||||
|
total=total,
|
||||||
|
limit=safe_limit,
|
||||||
|
offset=safe_offset,
|
||||||
|
has_more=safe_offset + len(items) < total,
|
||||||
|
).model_dump()
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from models.platform import BotRequestUsage
|
||||||
|
from schemas.platform import (
|
||||||
|
PlatformActivityItem,
|
||||||
|
PlatformDashboardAnalyticsResponse,
|
||||||
|
PlatformDashboardUsagePoint,
|
||||||
|
PlatformDashboardUsageSeries,
|
||||||
|
)
|
||||||
|
|
||||||
|
from services.platform_activity_service import list_activity_events
|
||||||
|
from services.platform_common import utcnow
|
||||||
|
from services.platform_settings_service import get_platform_settings
|
||||||
|
|
||||||
|
|
||||||
|
def build_dashboard_analytics(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
since_days: int = 7,
|
||||||
|
events_limit: int = 20,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
safe_since_days = max(1, min(int(since_days or 7), 30))
|
||||||
|
safe_events_limit = max(1, min(int(events_limit or get_platform_settings(session).page_size), 100))
|
||||||
|
granularity = "hour" if safe_since_days <= 2 else "day"
|
||||||
|
now = utcnow()
|
||||||
|
|
||||||
|
if granularity == "hour":
|
||||||
|
current_bucket = now.replace(minute=0, second=0, microsecond=0)
|
||||||
|
bucket_starts = [current_bucket - timedelta(hours=index) for index in range(max(1, safe_since_days * 24))]
|
||||||
|
bucket_starts.reverse()
|
||||||
|
label_format = "%m-%d %H:00"
|
||||||
|
else:
|
||||||
|
current_bucket = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
bucket_starts = [current_bucket - timedelta(days=index) for index in range(safe_since_days)]
|
||||||
|
bucket_starts.reverse()
|
||||||
|
label_format = "%m-%d"
|
||||||
|
|
||||||
|
bucket_index: Dict[str, int] = {}
|
||||||
|
points_template: list[PlatformDashboardUsagePoint] = []
|
||||||
|
for index, bucket_start in enumerate(bucket_starts):
|
||||||
|
bucket_key = bucket_start.isoformat()
|
||||||
|
bucket_index[bucket_key] = index
|
||||||
|
points_template.append(
|
||||||
|
PlatformDashboardUsagePoint(
|
||||||
|
bucket_at=bucket_start.isoformat() + "Z",
|
||||||
|
label=bucket_start.strftime(label_format),
|
||||||
|
call_count=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
since = bucket_starts[0] if bucket_starts else now - timedelta(days=safe_since_days)
|
||||||
|
rows = session.exec(
|
||||||
|
select(BotRequestUsage)
|
||||||
|
.where(BotRequestUsage.started_at >= since)
|
||||||
|
.order_by(BotRequestUsage.started_at.asc(), BotRequestUsage.id.asc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
series_map: Dict[str, PlatformDashboardUsageSeries] = {}
|
||||||
|
total_request_count = 0
|
||||||
|
for row in rows:
|
||||||
|
total_request_count += 1
|
||||||
|
model_name = str(row.model or row.provider or "Unknown").strip() or "Unknown"
|
||||||
|
point_time = row.started_at or row.created_at or now
|
||||||
|
if granularity == "hour":
|
||||||
|
bucket_start = point_time.replace(minute=0, second=0, microsecond=0)
|
||||||
|
else:
|
||||||
|
bucket_start = point_time.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
bucket_key = bucket_start.isoformat()
|
||||||
|
bucket_position = bucket_index.get(bucket_key)
|
||||||
|
if bucket_position is None:
|
||||||
|
continue
|
||||||
|
if model_name not in series_map:
|
||||||
|
series_map[model_name] = PlatformDashboardUsageSeries(
|
||||||
|
model=model_name,
|
||||||
|
total_calls=0,
|
||||||
|
points=[
|
||||||
|
PlatformDashboardUsagePoint.model_validate(point.model_dump())
|
||||||
|
for point in points_template
|
||||||
|
],
|
||||||
|
)
|
||||||
|
series = series_map[model_name]
|
||||||
|
series.total_calls += 1
|
||||||
|
series.points[bucket_position].call_count += 1
|
||||||
|
|
||||||
|
ordered_series = sorted(
|
||||||
|
series_map.values(),
|
||||||
|
key=lambda item: (-int(item.total_calls or 0), str(item.model or "").lower()),
|
||||||
|
)[:8]
|
||||||
|
|
||||||
|
return PlatformDashboardAnalyticsResponse(
|
||||||
|
total_request_count=total_request_count,
|
||||||
|
total_model_count=len(series_map),
|
||||||
|
granularity=granularity,
|
||||||
|
since_days=safe_since_days,
|
||||||
|
events_page_size=safe_events_limit,
|
||||||
|
series=ordered_series,
|
||||||
|
recent_events=[
|
||||||
|
PlatformActivityItem.model_validate(item)
|
||||||
|
for item in (list_activity_events(session, limit=safe_events_limit, offset=0).get("items") or [])
|
||||||
|
],
|
||||||
|
).model_dump()
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from core.settings import BOTS_WORKSPACE_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def bot_workspace_root(bot_id: str) -> str:
|
||||||
|
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace"))
|
||||||
|
|
||||||
|
|
||||||
|
def bot_data_root(bot_id: str) -> str:
|
||||||
|
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot"))
|
||||||
|
|
||||||
|
|
||||||
|
def calc_dir_size_bytes(path: str) -> int:
|
||||||
|
total = 0
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
return 0
|
||||||
|
for root, _, files in os.walk(path):
|
||||||
|
for name in files:
|
||||||
|
target = os.path.join(root, name)
|
||||||
|
try:
|
||||||
|
if os.path.islink(target):
|
||||||
|
continue
|
||||||
|
total += int(os.path.getsize(target))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_usage_bytes(runtime: Dict[str, Any], bot_id: str) -> int:
|
||||||
|
usage = dict(runtime.get("usage") or {})
|
||||||
|
value = usage.get("workspace_used_bytes")
|
||||||
|
if value in {None, 0, "0", ""}:
|
||||||
|
value = usage.get("container_rw_bytes")
|
||||||
|
try:
|
||||||
|
normalized = int(value or 0)
|
||||||
|
except Exception:
|
||||||
|
normalized = 0
|
||||||
|
if normalized > 0:
|
||||||
|
return normalized
|
||||||
|
return calc_dir_size_bytes(bot_workspace_root(bot_id))
|
||||||
|
|
||||||
|
|
||||||
|
def read_bot_resources(bot_id: str) -> Dict[str, Any]:
|
||||||
|
path = os.path.join(bot_data_root(bot_id), "resources.json")
|
||||||
|
raw: Dict[str, Any] = {}
|
||||||
|
if os.path.isfile(path):
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as file:
|
||||||
|
loaded = json.load(file)
|
||||||
|
if isinstance(loaded, dict):
|
||||||
|
raw = loaded
|
||||||
|
except Exception:
|
||||||
|
raw = {}
|
||||||
|
|
||||||
|
def _safe_float(value: Any, default: float) -> float:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _safe_int(value: Any, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
cpu = _safe_float(raw.get("cpuCores", raw.get("cpu_cores", 1.0)), 1.0)
|
||||||
|
memory = _safe_int(raw.get("memoryMB", raw.get("memory_mb", 1024)), 1024)
|
||||||
|
storage = _safe_int(raw.get("storageGB", raw.get("storage_gb", 10)), 10)
|
||||||
|
cpu = 0.0 if cpu == 0 else min(16.0, max(0.1, cpu))
|
||||||
|
memory = 0 if memory == 0 else min(65536, max(256, memory))
|
||||||
|
storage = 0 if storage == 0 else min(1024, max(1, storage))
|
||||||
|
return {
|
||||||
|
"cpu_cores": cpu,
|
||||||
|
"memory_mb": memory,
|
||||||
|
"storage_gb": storage,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_tokens(text: str) -> int:
|
||||||
|
content = str(text or "").strip()
|
||||||
|
if not content:
|
||||||
|
return 0
|
||||||
|
pieces = re.findall(r"[\u4e00-\u9fff]|[A-Za-z0-9_]+|[^\s]", content)
|
||||||
|
total = 0
|
||||||
|
for piece in pieces:
|
||||||
|
if re.fullmatch(r"[\u4e00-\u9fff]", piece):
|
||||||
|
total += 1
|
||||||
|
elif re.fullmatch(r"[A-Za-z0-9_]+", piece):
|
||||||
|
total += max(1, math.ceil(len(piece) / 4))
|
||||||
|
else:
|
||||||
|
total += 1
|
||||||
|
return max(1, total)
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from clients.edge.errors import log_edge_failure
|
||||||
|
from models.bot import BotInstance, NanobotImage
|
||||||
|
|
||||||
|
from services.platform_activity_service import list_activity_events, prune_expired_activity_events
|
||||||
|
from services.platform_common import read_bot_resources, workspace_usage_bytes
|
||||||
|
from services.platform_settings_service import get_platform_settings
|
||||||
|
from services.platform_usage_service import list_usage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_platform_overview(
|
||||||
|
session: Session,
|
||||||
|
read_runtime: Optional[Callable[[BotInstance], Tuple[str, Dict[str, Any]]]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
deleted = prune_expired_activity_events(session, force=False)
|
||||||
|
if deleted > 0:
|
||||||
|
session.commit()
|
||||||
|
bots = session.exec(select(BotInstance)).all()
|
||||||
|
images = session.exec(select(NanobotImage).order_by(NanobotImage.created_at.desc())).all()
|
||||||
|
settings = get_platform_settings(session)
|
||||||
|
|
||||||
|
running = 0
|
||||||
|
stopped = 0
|
||||||
|
disabled = 0
|
||||||
|
configured_cpu_total = 0.0
|
||||||
|
configured_memory_total = 0
|
||||||
|
configured_storage_total = 0
|
||||||
|
workspace_used_total = 0
|
||||||
|
workspace_limit_total = 0
|
||||||
|
live_cpu_percent_total = 0.0
|
||||||
|
live_memory_used_total = 0
|
||||||
|
live_memory_limit_total = 0
|
||||||
|
dirty = False
|
||||||
|
|
||||||
|
bot_rows: List[Dict[str, Any]] = []
|
||||||
|
for bot in bots:
|
||||||
|
enabled = bool(getattr(bot, "enabled", True))
|
||||||
|
resources = read_bot_resources(bot.id)
|
||||||
|
runtime_status = str(bot.docker_status or "STOPPED").upper()
|
||||||
|
runtime: Dict[str, Any] = {"usage": {}, "limits": {}, "docker_status": runtime_status}
|
||||||
|
if callable(read_runtime):
|
||||||
|
try:
|
||||||
|
runtime_status, runtime = read_runtime(bot)
|
||||||
|
except Exception as exc:
|
||||||
|
log_edge_failure(
|
||||||
|
logger,
|
||||||
|
key=f"platform-overview-runtime:{bot.id}",
|
||||||
|
exc=exc,
|
||||||
|
message=f"Failed to read platform runtime snapshot for bot_id={bot.id}",
|
||||||
|
)
|
||||||
|
runtime_status = str(runtime_status or runtime.get("docker_status") or "STOPPED").upper()
|
||||||
|
runtime["docker_status"] = runtime_status
|
||||||
|
if str(bot.docker_status or "").upper() != runtime_status:
|
||||||
|
bot.docker_status = runtime_status
|
||||||
|
session.add(bot)
|
||||||
|
dirty = True
|
||||||
|
if runtime_status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}:
|
||||||
|
next_state = "IDLE"
|
||||||
|
if str(bot.current_state or "") != next_state:
|
||||||
|
bot.current_state = next_state
|
||||||
|
session.add(bot)
|
||||||
|
dirty = True
|
||||||
|
workspace_used = workspace_usage_bytes(runtime, bot.id)
|
||||||
|
workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
configured_cpu_total += float(resources["cpu_cores"] or 0)
|
||||||
|
configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024
|
||||||
|
configured_storage_total += workspace_limit
|
||||||
|
workspace_used_total += workspace_used
|
||||||
|
workspace_limit_total += workspace_limit
|
||||||
|
live_cpu_percent_total += float((runtime.get("usage") or {}).get("cpu_percent") or 0.0)
|
||||||
|
live_memory_used_total += int((runtime.get("usage") or {}).get("memory_bytes") or 0)
|
||||||
|
live_memory_limit_total += int((runtime.get("usage") or {}).get("memory_limit_bytes") or 0)
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
disabled += 1
|
||||||
|
elif runtime_status == "RUNNING":
|
||||||
|
running += 1
|
||||||
|
else:
|
||||||
|
stopped += 1
|
||||||
|
|
||||||
|
bot_rows.append(
|
||||||
|
{
|
||||||
|
"id": bot.id,
|
||||||
|
"name": bot.name,
|
||||||
|
"enabled": enabled,
|
||||||
|
"docker_status": runtime_status,
|
||||||
|
"image_tag": bot.image_tag,
|
||||||
|
"llm_provider": getattr(bot, "llm_provider", None),
|
||||||
|
"llm_model": getattr(bot, "llm_model", None),
|
||||||
|
"current_state": bot.current_state,
|
||||||
|
"last_action": bot.last_action,
|
||||||
|
"resources": resources,
|
||||||
|
"workspace_usage_bytes": workspace_used,
|
||||||
|
"workspace_limit_bytes": workspace_limit if workspace_limit > 0 else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if dirty:
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
usage = list_usage(session, limit=20)
|
||||||
|
events = list_activity_events(session, limit=get_platform_settings(session).page_size, offset=0).get("items") or []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"bots": {
|
||||||
|
"total": len(bots),
|
||||||
|
"running": running,
|
||||||
|
"stopped": stopped,
|
||||||
|
"disabled": disabled,
|
||||||
|
},
|
||||||
|
"images": {
|
||||||
|
"total": len(images),
|
||||||
|
"ready": len([row for row in images if row.status == "READY"]),
|
||||||
|
"abnormal": len([row for row in images if row.status != "READY"]),
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"configured_cpu_cores": round(configured_cpu_total, 2),
|
||||||
|
"configured_memory_bytes": configured_memory_total,
|
||||||
|
"configured_storage_bytes": configured_storage_total,
|
||||||
|
"live_cpu_percent": round(live_cpu_percent_total, 2),
|
||||||
|
"live_memory_used_bytes": live_memory_used_total,
|
||||||
|
"live_memory_limit_bytes": live_memory_limit_total,
|
||||||
|
"workspace_used_bytes": workspace_used_total,
|
||||||
|
"workspace_limit_bytes": workspace_limit_total,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"tag": row.tag,
|
||||||
|
"version": row.version,
|
||||||
|
"status": row.status,
|
||||||
|
"source_dir": row.source_dir,
|
||||||
|
"created_at": row.created_at.isoformat() + "Z",
|
||||||
|
}
|
||||||
|
for row in images
|
||||||
|
],
|
||||||
|
"bots": bot_rows,
|
||||||
|
"settings": settings.model_dump(),
|
||||||
|
"usage": usage,
|
||||||
|
"events": events,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_node_resource_overview(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
node_id: str,
|
||||||
|
read_runtime: Optional[Callable[[BotInstance], Tuple[str, Dict[str, Any]]]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
normalized_node_id = str(node_id or "").strip().lower()
|
||||||
|
bots = session.exec(select(BotInstance).where(BotInstance.node_id == normalized_node_id)).all()
|
||||||
|
|
||||||
|
running = 0
|
||||||
|
stopped = 0
|
||||||
|
disabled = 0
|
||||||
|
configured_cpu_total = 0.0
|
||||||
|
configured_memory_total = 0
|
||||||
|
configured_storage_total = 0
|
||||||
|
workspace_used_total = 0
|
||||||
|
workspace_limit_total = 0
|
||||||
|
live_cpu_percent_total = 0.0
|
||||||
|
live_memory_used_total = 0
|
||||||
|
live_memory_limit_total = 0
|
||||||
|
dirty = False
|
||||||
|
|
||||||
|
for bot in bots:
|
||||||
|
enabled = bool(getattr(bot, "enabled", True))
|
||||||
|
resources = read_bot_resources(bot.id)
|
||||||
|
runtime_status = str(bot.docker_status or "STOPPED").upper()
|
||||||
|
runtime: Dict[str, Any] = {"usage": {}, "limits": {}, "docker_status": runtime_status}
|
||||||
|
if callable(read_runtime):
|
||||||
|
try:
|
||||||
|
runtime_status, runtime = read_runtime(bot)
|
||||||
|
except Exception as exc:
|
||||||
|
log_edge_failure(
|
||||||
|
logger,
|
||||||
|
key=f"platform-node-runtime:{normalized_node_id}:{bot.id}",
|
||||||
|
exc=exc,
|
||||||
|
message=f"Failed to read node runtime snapshot for bot_id={bot.id}",
|
||||||
|
)
|
||||||
|
runtime_status = str(runtime_status or runtime.get("docker_status") or "STOPPED").upper()
|
||||||
|
runtime["docker_status"] = runtime_status
|
||||||
|
if str(bot.docker_status or "").upper() != runtime_status:
|
||||||
|
bot.docker_status = runtime_status
|
||||||
|
session.add(bot)
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
workspace_used = workspace_usage_bytes(runtime, bot.id)
|
||||||
|
workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
configured_cpu_total += float(resources["cpu_cores"] or 0)
|
||||||
|
configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024
|
||||||
|
configured_storage_total += workspace_limit
|
||||||
|
workspace_used_total += workspace_used
|
||||||
|
workspace_limit_total += workspace_limit
|
||||||
|
live_cpu_percent_total += float((runtime.get("usage") or {}).get("cpu_percent") or 0.0)
|
||||||
|
live_memory_used_total += int((runtime.get("usage") or {}).get("memory_bytes") or 0)
|
||||||
|
live_memory_limit_total += int((runtime.get("usage") or {}).get("memory_limit_bytes") or 0)
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
disabled += 1
|
||||||
|
elif runtime_status == "RUNNING":
|
||||||
|
running += 1
|
||||||
|
else:
|
||||||
|
stopped += 1
|
||||||
|
|
||||||
|
if dirty:
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"node_id": normalized_node_id,
|
||||||
|
"bots": {
|
||||||
|
"total": len(bots),
|
||||||
|
"running": running,
|
||||||
|
"stopped": stopped,
|
||||||
|
"disabled": disabled,
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"configured_cpu_cores": round(configured_cpu_total, 2),
|
||||||
|
"configured_memory_bytes": configured_memory_total,
|
||||||
|
"configured_storage_bytes": configured_storage_total,
|
||||||
|
"live_cpu_percent": round(live_cpu_percent_total, 2),
|
||||||
|
"live_memory_used_bytes": live_memory_used_total,
|
||||||
|
"live_memory_limit_bytes": live_memory_limit_total,
|
||||||
|
"workspace_used_bytes": workspace_used_total,
|
||||||
|
"workspace_limit_bytes": workspace_limit_total,
|
||||||
|
},
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,262 @@
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from core.database import engine
|
||||||
|
from models.platform import PlatformSetting
|
||||||
|
from schemas.platform import LoadingPageSettings, PlatformSettingsPayload, SystemSettingPayload
|
||||||
|
from services.platform_common import utcnow
|
||||||
|
|
||||||
|
from services.platform_settings_support import (
|
||||||
|
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
|
||||||
|
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS,
|
||||||
|
DEPRECATED_SETTING_KEYS,
|
||||||
|
PROTECTED_SETTING_KEYS,
|
||||||
|
SETTING_KEYS,
|
||||||
|
SYS_AUTH_TOKEN_TTL_DAYS_SETTING_KEY,
|
||||||
|
SYSTEM_SETTING_DEFINITIONS,
|
||||||
|
bootstrap_platform_setting_values,
|
||||||
|
build_speech_runtime_settings,
|
||||||
|
default_platform_settings,
|
||||||
|
normalize_activity_event_retention_days,
|
||||||
|
normalize_extension_list,
|
||||||
|
normalize_setting_key,
|
||||||
|
read_setting_value,
|
||||||
|
setting_item_from_row,
|
||||||
|
upsert_setting_row,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_system_settings(session: Session) -> None:
|
||||||
|
bootstrap_values = bootstrap_platform_setting_values()
|
||||||
|
legacy_row = session.get(PlatformSetting, "global")
|
||||||
|
if legacy_row is not None:
|
||||||
|
try:
|
||||||
|
legacy_data = json.loads(legacy_row.value_json or "{}")
|
||||||
|
except Exception:
|
||||||
|
legacy_data = {}
|
||||||
|
if isinstance(legacy_data, dict):
|
||||||
|
for key in SETTING_KEYS:
|
||||||
|
meta = SYSTEM_SETTING_DEFINITIONS[key]
|
||||||
|
upsert_setting_row(
|
||||||
|
session,
|
||||||
|
key,
|
||||||
|
name=str(meta["name"]),
|
||||||
|
category=str(meta["category"]),
|
||||||
|
description=str(meta["description"]),
|
||||||
|
value_type=str(meta["value_type"]),
|
||||||
|
value=legacy_data.get(key, bootstrap_values.get(key, meta["value"])),
|
||||||
|
is_public=bool(meta["is_public"]),
|
||||||
|
sort_order=int(meta["sort_order"]),
|
||||||
|
)
|
||||||
|
session.delete(legacy_row)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
dirty = False
|
||||||
|
for key in DEPRECATED_SETTING_KEYS:
|
||||||
|
legacy_row = session.get(PlatformSetting, key)
|
||||||
|
if legacy_row is not None:
|
||||||
|
session.delete(legacy_row)
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
for key, meta in SYSTEM_SETTING_DEFINITIONS.items():
|
||||||
|
row = session.get(PlatformSetting, key)
|
||||||
|
if row is None:
|
||||||
|
upsert_setting_row(
|
||||||
|
session,
|
||||||
|
key,
|
||||||
|
name=str(meta["name"]),
|
||||||
|
category=str(meta["category"]),
|
||||||
|
description=str(meta["description"]),
|
||||||
|
value_type=str(meta["value_type"]),
|
||||||
|
value=bootstrap_values.get(key, meta["value"]),
|
||||||
|
is_public=bool(meta["is_public"]),
|
||||||
|
sort_order=int(meta["sort_order"]),
|
||||||
|
)
|
||||||
|
dirty = True
|
||||||
|
continue
|
||||||
|
changed = False
|
||||||
|
for field in ("name", "category", "description", "value_type"):
|
||||||
|
value = str(meta[field])
|
||||||
|
if not getattr(row, field):
|
||||||
|
setattr(row, field, value)
|
||||||
|
changed = True
|
||||||
|
if getattr(row, "sort_order", None) is None:
|
||||||
|
row.sort_order = int(meta["sort_order"])
|
||||||
|
changed = True
|
||||||
|
if getattr(row, "is_public", None) is None:
|
||||||
|
row.is_public = bool(meta["is_public"])
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
row.updated_at = utcnow()
|
||||||
|
session.add(row)
|
||||||
|
dirty = True
|
||||||
|
if dirty:
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def list_system_settings(session: Session, search: str = "") -> List[Dict[str, Any]]:
|
||||||
|
ensure_default_system_settings(session)
|
||||||
|
stmt = select(PlatformSetting).order_by(PlatformSetting.sort_order.asc(), PlatformSetting.key.asc())
|
||||||
|
rows = session.exec(stmt).all()
|
||||||
|
keyword = str(search or "").strip().lower()
|
||||||
|
items = [setting_item_from_row(row) for row in rows]
|
||||||
|
if not keyword:
|
||||||
|
return items
|
||||||
|
return [
|
||||||
|
item
|
||||||
|
for item in items
|
||||||
|
if keyword in str(item["key"]).lower()
|
||||||
|
or keyword in str(item["name"]).lower()
|
||||||
|
or keyword in str(item["category"]).lower()
|
||||||
|
or keyword in str(item["description"]).lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def create_or_update_system_setting(session: Session, payload: SystemSettingPayload) -> Dict[str, Any]:
|
||||||
|
ensure_default_system_settings(session)
|
||||||
|
normalized_key = normalize_setting_key(payload.key)
|
||||||
|
definition = SYSTEM_SETTING_DEFINITIONS.get(normalized_key, {})
|
||||||
|
row = upsert_setting_row(
|
||||||
|
session,
|
||||||
|
payload.key,
|
||||||
|
name=payload.name or str(definition.get("name") or payload.key),
|
||||||
|
category=payload.category or str(definition.get("category") or "general"),
|
||||||
|
description=payload.description or str(definition.get("description") or ""),
|
||||||
|
value_type=payload.value_type or str(definition.get("value_type") or "json"),
|
||||||
|
value=payload.value if payload.value is not None else definition.get("value"),
|
||||||
|
is_public=payload.is_public,
|
||||||
|
sort_order=payload.sort_order or int(definition.get("sort_order") or 100),
|
||||||
|
)
|
||||||
|
if normalized_key == ACTIVITY_EVENT_RETENTION_SETTING_KEY:
|
||||||
|
from services.platform_activity_service import prune_expired_activity_events
|
||||||
|
|
||||||
|
prune_expired_activity_events(session, force=True)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return setting_item_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_system_setting(session: Session, key: str) -> None:
|
||||||
|
normalized_key = normalize_setting_key(key)
|
||||||
|
if normalized_key in PROTECTED_SETTING_KEYS:
|
||||||
|
raise ValueError("Core platform settings cannot be deleted")
|
||||||
|
row = session.get(PlatformSetting, normalized_key)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("Setting not found")
|
||||||
|
session.delete(row)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_platform_settings(session: Session) -> PlatformSettingsPayload:
|
||||||
|
defaults = default_platform_settings()
|
||||||
|
ensure_default_system_settings(session)
|
||||||
|
rows = session.exec(select(PlatformSetting).where(PlatformSetting.key.in_(SETTING_KEYS))).all()
|
||||||
|
data: Dict[str, Any] = {row.key: read_setting_value(row) for row in rows}
|
||||||
|
|
||||||
|
merged = defaults.model_dump()
|
||||||
|
merged["page_size"] = max(1, min(100, int(data.get("page_size") or merged["page_size"])))
|
||||||
|
merged["chat_pull_page_size"] = max(10, min(500, int(data.get("chat_pull_page_size") or merged["chat_pull_page_size"])))
|
||||||
|
merged["command_auto_unlock_seconds"] = max(
|
||||||
|
1,
|
||||||
|
min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"])),
|
||||||
|
)
|
||||||
|
merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"])
|
||||||
|
merged["allowed_attachment_extensions"] = normalize_extension_list(
|
||||||
|
data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"]),
|
||||||
|
)
|
||||||
|
merged["workspace_download_extensions"] = normalize_extension_list(
|
||||||
|
data.get("workspace_download_extensions", merged["workspace_download_extensions"]),
|
||||||
|
)
|
||||||
|
merged["speech_enabled"] = bool(data.get("speech_enabled", merged["speech_enabled"]))
|
||||||
|
loading_page = data.get("loading_page")
|
||||||
|
if isinstance(loading_page, dict):
|
||||||
|
current = dict(merged["loading_page"])
|
||||||
|
for key in ("title", "subtitle", "description"):
|
||||||
|
value = str(loading_page.get(key) or "").strip()
|
||||||
|
if value:
|
||||||
|
current[key] = value
|
||||||
|
merged["loading_page"] = current
|
||||||
|
return PlatformSettingsPayload.model_validate(merged)
|
||||||
|
|
||||||
|
|
||||||
|
def save_platform_settings(session: Session, payload: PlatformSettingsPayload) -> PlatformSettingsPayload:
|
||||||
|
normalized = PlatformSettingsPayload(
|
||||||
|
page_size=max(1, min(100, int(payload.page_size))),
|
||||||
|
chat_pull_page_size=max(10, min(500, int(payload.chat_pull_page_size))),
|
||||||
|
command_auto_unlock_seconds=max(1, min(600, int(payload.command_auto_unlock_seconds))),
|
||||||
|
upload_max_mb=payload.upload_max_mb,
|
||||||
|
allowed_attachment_extensions=normalize_extension_list(payload.allowed_attachment_extensions),
|
||||||
|
workspace_download_extensions=normalize_extension_list(payload.workspace_download_extensions),
|
||||||
|
speech_enabled=bool(payload.speech_enabled),
|
||||||
|
loading_page=LoadingPageSettings.model_validate(payload.loading_page.model_dump()),
|
||||||
|
)
|
||||||
|
payload_by_key = normalized.model_dump()
|
||||||
|
for key in SETTING_KEYS:
|
||||||
|
definition = SYSTEM_SETTING_DEFINITIONS[key]
|
||||||
|
upsert_setting_row(
|
||||||
|
session,
|
||||||
|
key,
|
||||||
|
name=str(definition["name"]),
|
||||||
|
category=str(definition["category"]),
|
||||||
|
description=str(definition["description"]),
|
||||||
|
value_type=str(definition["value_type"]),
|
||||||
|
value=payload_by_key[key],
|
||||||
|
is_public=bool(definition["is_public"]),
|
||||||
|
sort_order=int(definition["sort_order"]),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def get_platform_settings_snapshot() -> PlatformSettingsPayload:
|
||||||
|
with Session(engine) as session:
|
||||||
|
return get_platform_settings(session)
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload_max_mb() -> int:
|
||||||
|
return get_platform_settings_snapshot().upload_max_mb
|
||||||
|
|
||||||
|
|
||||||
|
def get_allowed_attachment_extensions() -> List[str]:
|
||||||
|
return get_platform_settings_snapshot().allowed_attachment_extensions
|
||||||
|
|
||||||
|
|
||||||
|
def get_workspace_download_extensions() -> List[str]:
|
||||||
|
return get_platform_settings_snapshot().workspace_download_extensions
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_size() -> int:
|
||||||
|
return get_platform_settings_snapshot().page_size
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_pull_page_size() -> int:
|
||||||
|
return get_platform_settings_snapshot().chat_pull_page_size
|
||||||
|
|
||||||
|
|
||||||
|
def get_speech_runtime_settings() -> Dict[str, Any]:
|
||||||
|
return build_speech_runtime_settings(get_platform_settings_snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_event_retention_days(session: Session) -> int:
|
||||||
|
row = session.get(PlatformSetting, ACTIVITY_EVENT_RETENTION_SETTING_KEY)
|
||||||
|
if row is None:
|
||||||
|
return DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS
|
||||||
|
try:
|
||||||
|
value = read_setting_value(row)
|
||||||
|
except Exception:
|
||||||
|
value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS
|
||||||
|
return normalize_activity_event_retention_days(value)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sys_auth_token_ttl_days(session: Session) -> int:
|
||||||
|
ensure_default_system_settings(session)
|
||||||
|
row = session.get(PlatformSetting, SYS_AUTH_TOKEN_TTL_DAYS_SETTING_KEY)
|
||||||
|
if row is None:
|
||||||
|
return 7
|
||||||
|
try:
|
||||||
|
value = int(read_setting_value(row))
|
||||||
|
except Exception:
|
||||||
|
value = 7
|
||||||
|
return max(1, min(365, value))
|
||||||
|
|
@ -0,0 +1,352 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from core.settings import (
|
||||||
|
DEFAULT_CHAT_PULL_PAGE_SIZE,
|
||||||
|
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
DEFAULT_STT_AUDIO_FILTER,
|
||||||
|
DEFAULT_STT_AUDIO_PREPROCESS,
|
||||||
|
DEFAULT_STT_DEFAULT_LANGUAGE,
|
||||||
|
DEFAULT_STT_FORCE_SIMPLIFIED,
|
||||||
|
DEFAULT_STT_INITIAL_PROMPT,
|
||||||
|
DEFAULT_STT_MAX_AUDIO_SECONDS,
|
||||||
|
DEFAULT_UPLOAD_MAX_MB,
|
||||||
|
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
|
||||||
|
STT_DEVICE,
|
||||||
|
STT_ENABLED_DEFAULT,
|
||||||
|
STT_MODEL,
|
||||||
|
)
|
||||||
|
from models.platform import PlatformSetting
|
||||||
|
from schemas.platform import LoadingPageSettings, PlatformSettingsPayload, SystemSettingItem
|
||||||
|
|
||||||
|
from services.platform_common import utcnow
|
||||||
|
|
||||||
|
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS: tuple[str, ...] = ()
|
||||||
|
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS = 7
|
||||||
|
ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days"
|
||||||
|
SYS_AUTH_TOKEN_TTL_DAYS_SETTING_KEY = "sys_auth_token_ttl_days"
|
||||||
|
SETTING_KEYS = (
|
||||||
|
"page_size",
|
||||||
|
"chat_pull_page_size",
|
||||||
|
"command_auto_unlock_seconds",
|
||||||
|
"upload_max_mb",
|
||||||
|
"allowed_attachment_extensions",
|
||||||
|
"workspace_download_extensions",
|
||||||
|
"speech_enabled",
|
||||||
|
)
|
||||||
|
PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {
|
||||||
|
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
|
||||||
|
}
|
||||||
|
DEPRECATED_SETTING_KEYS = {
|
||||||
|
"loading_page",
|
||||||
|
"speech_max_audio_seconds",
|
||||||
|
"speech_default_language",
|
||||||
|
"speech_force_simplified",
|
||||||
|
"speech_audio_preprocess",
|
||||||
|
"speech_audio_filter",
|
||||||
|
"speech_initial_prompt",
|
||||||
|
"dashboard_activity_page_size",
|
||||||
|
}
|
||||||
|
SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
|
"page_size": {
|
||||||
|
"name": "分页大小",
|
||||||
|
"category": "ui",
|
||||||
|
"description": "平台各类列表默认每页条数。",
|
||||||
|
"value_type": "integer",
|
||||||
|
"value": DEFAULT_PAGE_SIZE,
|
||||||
|
"is_public": True,
|
||||||
|
"sort_order": 5,
|
||||||
|
},
|
||||||
|
"chat_pull_page_size": {
|
||||||
|
"name": "对话懒加载条数",
|
||||||
|
"category": "chat",
|
||||||
|
"description": "Bot 对话区向上懒加载时每次读取的消息条数。",
|
||||||
|
"value_type": "integer",
|
||||||
|
"value": DEFAULT_CHAT_PULL_PAGE_SIZE,
|
||||||
|
"is_public": True,
|
||||||
|
"sort_order": 8,
|
||||||
|
},
|
||||||
|
"command_auto_unlock_seconds": {
|
||||||
|
"name": "发送按钮自动恢复秒数",
|
||||||
|
"category": "chat",
|
||||||
|
"description": "对话发送后按钮保持停止态的最长秒数,超时后自动恢复为可发送状态。",
|
||||||
|
"value_type": "integer",
|
||||||
|
"value": DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
|
||||||
|
"is_public": True,
|
||||||
|
"sort_order": 9,
|
||||||
|
},
|
||||||
|
"upload_max_mb": {
|
||||||
|
"name": "上传大小限制",
|
||||||
|
"category": "upload",
|
||||||
|
"description": "单文件上传大小限制,单位 MB。",
|
||||||
|
"value_type": "integer",
|
||||||
|
"value": DEFAULT_UPLOAD_MAX_MB,
|
||||||
|
"is_public": False,
|
||||||
|
"sort_order": 10,
|
||||||
|
},
|
||||||
|
"allowed_attachment_extensions": {
|
||||||
|
"name": "允许附件后缀",
|
||||||
|
"category": "upload",
|
||||||
|
"description": "允许上传的附件后缀列表,留空表示不限制。",
|
||||||
|
"value_type": "json",
|
||||||
|
"value": list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS),
|
||||||
|
"is_public": False,
|
||||||
|
"sort_order": 20,
|
||||||
|
},
|
||||||
|
"workspace_download_extensions": {
|
||||||
|
"name": "工作区下载后缀",
|
||||||
|
"category": "workspace",
|
||||||
|
"description": "命中后缀的工作区文件默认走下载模式。",
|
||||||
|
"value_type": "json",
|
||||||
|
"value": list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS),
|
||||||
|
"is_public": False,
|
||||||
|
"sort_order": 30,
|
||||||
|
},
|
||||||
|
"speech_enabled": {
|
||||||
|
"name": "语音识别开关",
|
||||||
|
"category": "speech",
|
||||||
|
"description": "控制 Bot 语音转写功能是否启用。",
|
||||||
|
"value_type": "boolean",
|
||||||
|
"value": STT_ENABLED_DEFAULT,
|
||||||
|
"is_public": True,
|
||||||
|
"sort_order": 32,
|
||||||
|
},
|
||||||
|
ACTIVITY_EVENT_RETENTION_SETTING_KEY: {
|
||||||
|
"name": "活动事件保留天数",
|
||||||
|
"category": "maintenance",
|
||||||
|
"description": "bot_activity_event 运维事件的保留天数,超期记录会自动清理。",
|
||||||
|
"value_type": "integer",
|
||||||
|
"value": DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS,
|
||||||
|
"is_public": False,
|
||||||
|
"sort_order": 34,
|
||||||
|
},
|
||||||
|
SYS_AUTH_TOKEN_TTL_DAYS_SETTING_KEY: {
|
||||||
|
"name": "登录令牌有效天数",
|
||||||
|
"category": "auth",
|
||||||
|
"description": "用户登录 JWT 的失效天数,默认 7 天,同时作为 Redis 会话 TTL。",
|
||||||
|
"value_type": "integer",
|
||||||
|
"value": 7,
|
||||||
|
"is_public": False,
|
||||||
|
"sort_order": 36,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_activity_event_retention_days(raw: Any) -> int:
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except Exception:
|
||||||
|
value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS
|
||||||
|
return max(1, min(3650, value))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_extension(raw: Any) -> str:
|
||||||
|
text = str(raw or "").strip().lower()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if text.startswith("*."):
|
||||||
|
text = text[1:]
|
||||||
|
if not text.startswith("."):
|
||||||
|
text = f".{text}"
|
||||||
|
if not re.fullmatch(r"\.[a-z0-9][a-z0-9._+-]{0,31}", text):
|
||||||
|
return ""
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_extension_list(rows: Any) -> List[str]:
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return []
|
||||||
|
normalized: List[str] = []
|
||||||
|
for item in rows:
|
||||||
|
ext = normalize_extension(item)
|
||||||
|
if ext and ext not in normalized:
|
||||||
|
normalized.append(ext)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_env_int(name: str, default: int, min_value: int, max_value: int) -> int:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
value = int(str(raw).strip())
|
||||||
|
except Exception:
|
||||||
|
value = default
|
||||||
|
return max(min_value, min(max_value, value))
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_env_bool(name: str, default: bool) -> bool:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_env_extensions(name: str, default: List[str]) -> List[str]:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None:
|
||||||
|
return list(default)
|
||||||
|
source = re.split(r"[,;\s]+", str(raw))
|
||||||
|
normalized: List[str] = []
|
||||||
|
for item in source:
|
||||||
|
ext = normalize_extension(item)
|
||||||
|
if ext and ext not in normalized:
|
||||||
|
normalized.append(ext)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_platform_setting_values() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"page_size": legacy_env_int("PAGE_SIZE", DEFAULT_PAGE_SIZE, 1, 100),
|
||||||
|
"chat_pull_page_size": legacy_env_int(
|
||||||
|
"CHAT_PULL_PAGE_SIZE",
|
||||||
|
DEFAULT_CHAT_PULL_PAGE_SIZE,
|
||||||
|
10,
|
||||||
|
500,
|
||||||
|
),
|
||||||
|
"command_auto_unlock_seconds": legacy_env_int(
|
||||||
|
"COMMAND_AUTO_UNLOCK_SECONDS",
|
||||||
|
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
|
||||||
|
1,
|
||||||
|
600,
|
||||||
|
),
|
||||||
|
"upload_max_mb": legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048),
|
||||||
|
"allowed_attachment_extensions": legacy_env_extensions(
|
||||||
|
"ALLOWED_ATTACHMENT_EXTENSIONS",
|
||||||
|
list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS),
|
||||||
|
),
|
||||||
|
"workspace_download_extensions": legacy_env_extensions(
|
||||||
|
"WORKSPACE_DOWNLOAD_EXTENSIONS",
|
||||||
|
list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS),
|
||||||
|
),
|
||||||
|
"speech_enabled": legacy_env_bool("STT_ENABLED", STT_ENABLED_DEFAULT),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_platform_settings() -> PlatformSettingsPayload:
|
||||||
|
bootstrap = bootstrap_platform_setting_values()
|
||||||
|
return PlatformSettingsPayload(
|
||||||
|
page_size=int(bootstrap["page_size"]),
|
||||||
|
chat_pull_page_size=int(bootstrap["chat_pull_page_size"]),
|
||||||
|
command_auto_unlock_seconds=int(bootstrap["command_auto_unlock_seconds"]),
|
||||||
|
upload_max_mb=int(bootstrap["upload_max_mb"]),
|
||||||
|
allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]),
|
||||||
|
workspace_download_extensions=list(bootstrap["workspace_download_extensions"]),
|
||||||
|
speech_enabled=bool(bootstrap["speech_enabled"]),
|
||||||
|
speech_max_audio_seconds=DEFAULT_STT_MAX_AUDIO_SECONDS,
|
||||||
|
speech_default_language=DEFAULT_STT_DEFAULT_LANGUAGE,
|
||||||
|
speech_force_simplified=DEFAULT_STT_FORCE_SIMPLIFIED,
|
||||||
|
speech_audio_preprocess=DEFAULT_STT_AUDIO_PREPROCESS,
|
||||||
|
speech_audio_filter=DEFAULT_STT_AUDIO_FILTER,
|
||||||
|
speech_initial_prompt=DEFAULT_STT_INITIAL_PROMPT,
|
||||||
|
loading_page=LoadingPageSettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_setting_key(raw: Any) -> str:
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", text).strip("._-").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_setting_value(value: Any, value_type: str) -> Any:
|
||||||
|
normalized_type = str(value_type or "json").strip().lower() or "json"
|
||||||
|
if normalized_type == "integer":
|
||||||
|
return int(value or 0)
|
||||||
|
if normalized_type == "float":
|
||||||
|
return float(value or 0)
|
||||||
|
if normalized_type == "boolean":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
if normalized_type == "string":
|
||||||
|
return str(value or "")
|
||||||
|
if normalized_type == "json":
|
||||||
|
return value
|
||||||
|
raise ValueError(f"Unsupported value_type: {normalized_type}")
|
||||||
|
|
||||||
|
|
||||||
|
def read_setting_value(row: PlatformSetting) -> Any:
|
||||||
|
try:
|
||||||
|
value = json.loads(row.value_json or "null")
|
||||||
|
except Exception:
|
||||||
|
value = None
|
||||||
|
return normalize_setting_value(value, row.value_type)
|
||||||
|
|
||||||
|
|
||||||
|
def setting_item_from_row(row: PlatformSetting) -> Dict[str, Any]:
|
||||||
|
return SystemSettingItem(
|
||||||
|
key=row.key,
|
||||||
|
name=row.name,
|
||||||
|
category=row.category,
|
||||||
|
description=row.description,
|
||||||
|
value_type=row.value_type,
|
||||||
|
value=read_setting_value(row),
|
||||||
|
is_public=bool(row.is_public),
|
||||||
|
sort_order=int(row.sort_order or 100),
|
||||||
|
created_at=row.created_at.isoformat() + "Z",
|
||||||
|
updated_at=row.updated_at.isoformat() + "Z",
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_setting_row(
|
||||||
|
session: Session,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
category: str,
|
||||||
|
description: str,
|
||||||
|
value_type: str,
|
||||||
|
value: Any,
|
||||||
|
is_public: bool,
|
||||||
|
sort_order: int,
|
||||||
|
) -> PlatformSetting:
|
||||||
|
normalized_key = normalize_setting_key(key)
|
||||||
|
if not normalized_key:
|
||||||
|
raise ValueError("Setting key is required")
|
||||||
|
normalized_type = str(value_type or "json").strip().lower() or "json"
|
||||||
|
normalized_value = normalize_setting_value(value, normalized_type)
|
||||||
|
now = utcnow()
|
||||||
|
row = session.get(PlatformSetting, normalized_key)
|
||||||
|
if row is None:
|
||||||
|
row = PlatformSetting(
|
||||||
|
key=normalized_key,
|
||||||
|
name=str(name or normalized_key),
|
||||||
|
category=str(category or "general"),
|
||||||
|
description=str(description or ""),
|
||||||
|
value_type=normalized_type,
|
||||||
|
value_json=json.dumps(normalized_value, ensure_ascii=False),
|
||||||
|
is_public=bool(is_public),
|
||||||
|
sort_order=int(sort_order or 100),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.name = str(name or row.name or normalized_key)
|
||||||
|
row.category = str(category or row.category or "general")
|
||||||
|
row.description = str(description or row.description or "")
|
||||||
|
row.value_type = normalized_type
|
||||||
|
row.value_json = json.dumps(normalized_value, ensure_ascii=False)
|
||||||
|
row.is_public = bool(is_public)
|
||||||
|
row.sort_order = int(sort_order or row.sort_order or 100)
|
||||||
|
row.updated_at = now
|
||||||
|
session.add(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def build_speech_runtime_settings(settings: PlatformSettingsPayload) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"enabled": bool(settings.speech_enabled),
|
||||||
|
"max_audio_seconds": int(DEFAULT_STT_MAX_AUDIO_SECONDS),
|
||||||
|
"default_language": str(DEFAULT_STT_DEFAULT_LANGUAGE or "zh").strip().lower() or "zh",
|
||||||
|
"force_simplified": bool(DEFAULT_STT_FORCE_SIMPLIFIED),
|
||||||
|
"audio_preprocess": bool(DEFAULT_STT_AUDIO_PREPROCESS),
|
||||||
|
"audio_filter": str(DEFAULT_STT_AUDIO_FILTER or "").strip(),
|
||||||
|
"initial_prompt": str(DEFAULT_STT_INITIAL_PROMPT or "").strip(),
|
||||||
|
"model": STT_MODEL,
|
||||||
|
"device": STT_DEVICE,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from models.platform import BotRequestUsage
|
||||||
|
from schemas.platform import PlatformUsageItem, PlatformUsageResponse, PlatformUsageSummary
|
||||||
|
|
||||||
|
from services.platform_common import estimate_tokens, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def create_usage_request(
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
command: str,
|
||||||
|
attachments: Optional[List[str]] = None,
|
||||||
|
channel: str = "dashboard",
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
provider: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
request_id = uuid.uuid4().hex
|
||||||
|
rows = [str(item).strip() for item in (attachments or []) if str(item).strip()]
|
||||||
|
input_tokens = estimate_tokens(command)
|
||||||
|
usage = BotRequestUsage(
|
||||||
|
bot_id=bot_id,
|
||||||
|
request_id=request_id,
|
||||||
|
channel=channel,
|
||||||
|
status="PENDING",
|
||||||
|
provider=(str(provider or "").strip() or None),
|
||||||
|
model=(str(model or "").strip() or None),
|
||||||
|
token_source="estimated",
|
||||||
|
input_tokens=input_tokens,
|
||||||
|
output_tokens=0,
|
||||||
|
total_tokens=input_tokens,
|
||||||
|
input_text_preview=str(command or "")[:400],
|
||||||
|
attachments_json=json.dumps(rows, ensure_ascii=False) if rows else None,
|
||||||
|
metadata_json=json.dumps(metadata or {}, ensure_ascii=False),
|
||||||
|
started_at=utcnow(),
|
||||||
|
created_at=utcnow(),
|
||||||
|
updated_at=utcnow(),
|
||||||
|
)
|
||||||
|
session.add(usage)
|
||||||
|
session.flush()
|
||||||
|
return request_id
|
||||||
|
|
||||||
|
|
||||||
|
def bind_usage_message(
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
request_id: str,
|
||||||
|
message_id: Optional[int],
|
||||||
|
) -> Optional[BotRequestUsage]:
|
||||||
|
if not request_id or not message_id:
|
||||||
|
return None
|
||||||
|
usage_row = find_pending_usage_by_request_id(session, bot_id, request_id)
|
||||||
|
if not usage_row:
|
||||||
|
return None
|
||||||
|
usage_row.message_id = int(message_id)
|
||||||
|
usage_row.updated_at = utcnow()
|
||||||
|
session.add(usage_row)
|
||||||
|
return usage_row
|
||||||
|
|
||||||
|
|
||||||
|
def find_latest_pending_usage(session: Session, bot_id: str) -> Optional[BotRequestUsage]:
|
||||||
|
stmt = (
|
||||||
|
select(BotRequestUsage)
|
||||||
|
.where(BotRequestUsage.bot_id == bot_id)
|
||||||
|
.where(BotRequestUsage.status == "PENDING")
|
||||||
|
.order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return session.exec(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
|
def find_pending_usage_by_request_id(session: Session, bot_id: str, request_id: str) -> Optional[BotRequestUsage]:
|
||||||
|
if not request_id:
|
||||||
|
return None
|
||||||
|
stmt = (
|
||||||
|
select(BotRequestUsage)
|
||||||
|
.where(BotRequestUsage.bot_id == bot_id)
|
||||||
|
.where(BotRequestUsage.request_id == request_id)
|
||||||
|
.where(BotRequestUsage.status == "PENDING")
|
||||||
|
.order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return session.exec(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_usage_from_packet(session: Session, bot_id: str, packet: Dict[str, Any]) -> Optional[BotRequestUsage]:
|
||||||
|
request_id = str(packet.get("request_id") or "").strip()
|
||||||
|
usage_row = find_pending_usage_by_request_id(session, bot_id, request_id) or find_latest_pending_usage(session, bot_id)
|
||||||
|
if not usage_row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_usage = packet.get("usage")
|
||||||
|
input_tokens: Optional[int] = None
|
||||||
|
output_tokens: Optional[int] = None
|
||||||
|
source = "estimated"
|
||||||
|
if isinstance(raw_usage, dict):
|
||||||
|
for key in ("input_tokens", "prompt_tokens", "promptTokens"):
|
||||||
|
if raw_usage.get(key) is not None:
|
||||||
|
try:
|
||||||
|
input_tokens = int(raw_usage.get(key) or 0)
|
||||||
|
except Exception:
|
||||||
|
input_tokens = None
|
||||||
|
break
|
||||||
|
for key in ("output_tokens", "completion_tokens", "completionTokens"):
|
||||||
|
if raw_usage.get(key) is not None:
|
||||||
|
try:
|
||||||
|
output_tokens = int(raw_usage.get(key) or 0)
|
||||||
|
except Exception:
|
||||||
|
output_tokens = None
|
||||||
|
break
|
||||||
|
if input_tokens is not None or output_tokens is not None:
|
||||||
|
source = "exact"
|
||||||
|
|
||||||
|
text = str(packet.get("text") or packet.get("content") or "").strip()
|
||||||
|
provider = str(packet.get("provider") or "").strip()
|
||||||
|
model = str(packet.get("model") or "").strip()
|
||||||
|
message_id = packet.get("message_id")
|
||||||
|
if input_tokens is None:
|
||||||
|
input_tokens = usage_row.input_tokens
|
||||||
|
if output_tokens is None:
|
||||||
|
output_tokens = estimate_tokens(text)
|
||||||
|
if source == "exact":
|
||||||
|
source = "mixed"
|
||||||
|
|
||||||
|
if provider:
|
||||||
|
usage_row.provider = provider[:120]
|
||||||
|
if model:
|
||||||
|
usage_row.model = model[:255]
|
||||||
|
if message_id is not None:
|
||||||
|
try:
|
||||||
|
usage_row.message_id = int(message_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
usage_row.output_tokens = max(0, int(output_tokens or 0))
|
||||||
|
usage_row.input_tokens = max(0, int(input_tokens or 0))
|
||||||
|
usage_row.total_tokens = usage_row.input_tokens + usage_row.output_tokens
|
||||||
|
usage_row.output_text_preview = text[:400] if text else usage_row.output_text_preview
|
||||||
|
usage_row.status = "COMPLETED"
|
||||||
|
usage_row.token_source = source
|
||||||
|
usage_row.completed_at = utcnow()
|
||||||
|
usage_row.updated_at = utcnow()
|
||||||
|
session.add(usage_row)
|
||||||
|
return usage_row
|
||||||
|
|
||||||
|
|
||||||
|
def fail_latest_usage(session: Session, bot_id: str, detail: str) -> Optional[BotRequestUsage]:
|
||||||
|
usage_row = find_latest_pending_usage(session, bot_id)
|
||||||
|
if not usage_row:
|
||||||
|
return None
|
||||||
|
usage_row.status = "ERROR"
|
||||||
|
usage_row.error_text = str(detail or "")[:500]
|
||||||
|
usage_row.completed_at = utcnow()
|
||||||
|
usage_row.updated_at = utcnow()
|
||||||
|
session.add(usage_row)
|
||||||
|
return usage_row
|
||||||
|
|
||||||
|
|
||||||
|
def list_usage(
|
||||||
|
session: Session,
|
||||||
|
bot_id: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
safe_limit = max(1, min(int(limit), 500))
|
||||||
|
safe_offset = max(0, int(offset or 0))
|
||||||
|
stmt = (
|
||||||
|
select(BotRequestUsage)
|
||||||
|
.order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc())
|
||||||
|
.offset(safe_offset)
|
||||||
|
.limit(safe_limit)
|
||||||
|
)
|
||||||
|
summary_stmt = select(
|
||||||
|
func.count(BotRequestUsage.id),
|
||||||
|
func.coalesce(func.sum(BotRequestUsage.input_tokens), 0),
|
||||||
|
func.coalesce(func.sum(BotRequestUsage.output_tokens), 0),
|
||||||
|
func.coalesce(func.sum(BotRequestUsage.total_tokens), 0),
|
||||||
|
)
|
||||||
|
total_stmt = select(func.count(BotRequestUsage.id))
|
||||||
|
if bot_id:
|
||||||
|
stmt = stmt.where(BotRequestUsage.bot_id == bot_id)
|
||||||
|
summary_stmt = summary_stmt.where(BotRequestUsage.bot_id == bot_id)
|
||||||
|
total_stmt = total_stmt.where(BotRequestUsage.bot_id == bot_id)
|
||||||
|
else:
|
||||||
|
since = utcnow() - timedelta(days=1)
|
||||||
|
summary_stmt = summary_stmt.where(BotRequestUsage.created_at >= since)
|
||||||
|
rows = session.exec(stmt).all()
|
||||||
|
count, input_sum, output_sum, total_sum = session.exec(summary_stmt).one()
|
||||||
|
total = int(session.exec(total_stmt).one() or 0)
|
||||||
|
items = [
|
||||||
|
PlatformUsageItem(
|
||||||
|
id=int(row.id or 0),
|
||||||
|
bot_id=row.bot_id,
|
||||||
|
message_id=int(row.message_id) if row.message_id is not None else None,
|
||||||
|
request_id=row.request_id,
|
||||||
|
channel=row.channel,
|
||||||
|
status=row.status,
|
||||||
|
provider=row.provider,
|
||||||
|
model=row.model,
|
||||||
|
token_source=row.token_source,
|
||||||
|
content=row.input_text_preview or row.output_text_preview,
|
||||||
|
input_tokens=int(row.input_tokens or 0),
|
||||||
|
output_tokens=int(row.output_tokens or 0),
|
||||||
|
total_tokens=int(row.total_tokens or 0),
|
||||||
|
input_text_preview=row.input_text_preview,
|
||||||
|
output_text_preview=row.output_text_preview,
|
||||||
|
started_at=row.started_at.isoformat() + "Z",
|
||||||
|
completed_at=row.completed_at.isoformat() + "Z" if row.completed_at else None,
|
||||||
|
).model_dump()
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
return PlatformUsageResponse(
|
||||||
|
summary=PlatformUsageSummary(
|
||||||
|
request_count=int(count or 0),
|
||||||
|
input_tokens=int(input_sum or 0),
|
||||||
|
output_tokens=int(output_sum or 0),
|
||||||
|
total_tokens=int(total_sum or 0),
|
||||||
|
),
|
||||||
|
items=[PlatformUsageItem.model_validate(item) for item in items],
|
||||||
|
total=total,
|
||||||
|
limit=safe_limit,
|
||||||
|
offset=safe_offset,
|
||||||
|
has_more=safe_offset + len(items) < total,
|
||||||
|
).model_dump()
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderTestService:
|
||||||
|
def __init__(self, *, provider_defaults: Optional[Callable[[str], Tuple[str, str]]] = None) -> None:
|
||||||
|
self._provider_defaults = provider_defaults or self.provider_defaults
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def provider_defaults(provider: str) -> tuple[str, str]:
|
||||||
|
normalized = provider.lower().strip()
|
||||||
|
if normalized in {"openrouter"}:
|
||||||
|
return "openrouter", "https://openrouter.ai/api/v1"
|
||||||
|
if normalized in {"dashscope", "aliyun", "qwen", "aliyun-qwen"}:
|
||||||
|
return "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
|
if normalized in {"xunfei", "iflytek", "xfyun"}:
|
||||||
|
return "openai", "https://spark-api-open.xf-yun.com/v1"
|
||||||
|
if normalized in {"kimi", "moonshot"}:
|
||||||
|
return "kimi", "https://api.moonshot.cn/v1"
|
||||||
|
if normalized in {"minimax"}:
|
||||||
|
return "minimax", "https://api.minimax.chat/v1"
|
||||||
|
if normalized in {"vllm"}:
|
||||||
|
return "vllm", ""
|
||||||
|
return normalized, ""
|
||||||
|
|
||||||
|
async def test_provider(self, *, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
provider = str(payload.get("provider") or "").strip()
|
||||||
|
api_key = str(payload.get("api_key") or "").strip()
|
||||||
|
model = str(payload.get("model") or "").strip()
|
||||||
|
api_base = str(payload.get("api_base") or "").strip()
|
||||||
|
|
||||||
|
if not provider or not api_key:
|
||||||
|
raise HTTPException(status_code=400, detail="provider and api_key are required")
|
||||||
|
|
||||||
|
normalized_provider, default_base = self._provider_defaults(provider)
|
||||||
|
base = (api_base or default_base).rstrip("/")
|
||||||
|
|
||||||
|
if normalized_provider not in {"openrouter", "dashscope", "kimi", "minimax", "openai", "deepseek", "vllm"}:
|
||||||
|
raise HTTPException(status_code=400, detail=f"provider not supported for test: {provider}")
|
||||||
|
|
||||||
|
if not base:
|
||||||
|
raise HTTPException(status_code=400, detail=f"api_base is required for provider: {provider}")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
timeout = httpx.Timeout(20.0, connect=10.0)
|
||||||
|
url = f"{base}/models"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.get(url, headers=headers)
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"provider": normalized_provider,
|
||||||
|
"status_code": resp.status_code,
|
||||||
|
"detail": resp.text[:500],
|
||||||
|
}
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
models_raw = data.get("data", []) if isinstance(data, dict) else []
|
||||||
|
model_ids: List[str] = []
|
||||||
|
for item in models_raw[:20]:
|
||||||
|
if isinstance(item, dict) and item.get("id"):
|
||||||
|
model_ids.append(str(item["id"]))
|
||||||
|
|
||||||
|
model_hint = ""
|
||||||
|
if model:
|
||||||
|
model_hint = "model_found" if any(model in value for value in model_ids) else "model_not_listed"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"provider": normalized_provider,
|
||||||
|
"endpoint": url,
|
||||||
|
"models_preview": model_ids[:8],
|
||||||
|
"model_hint": model_hint,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"provider": normalized_provider,
|
||||||
|
"endpoint": url,
|
||||||
|
"detail": str(exc),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,289 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, WebSocket
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from models.bot import BotInstance, BotMessage
|
||||||
|
|
||||||
|
|
||||||
|
class WSConnectionManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.connections: Dict[str, List[WebSocket]] = {}
|
||||||
|
|
||||||
|
async def connect(self, bot_id: str, websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
self.connections.setdefault(bot_id, []).append(websocket)
|
||||||
|
|
||||||
|
def disconnect(self, bot_id: str, websocket: WebSocket):
|
||||||
|
conns = self.connections.get(bot_id, [])
|
||||||
|
if websocket in conns:
|
||||||
|
conns.remove(websocket)
|
||||||
|
if not conns and bot_id in self.connections:
|
||||||
|
del self.connections[bot_id]
|
||||||
|
|
||||||
|
async def broadcast(self, bot_id: str, data: Dict[str, Any]):
|
||||||
|
conns = list(self.connections.get(bot_id, []))
|
||||||
|
for ws in conns:
|
||||||
|
try:
|
||||||
|
await ws.send_json(data)
|
||||||
|
except Exception:
|
||||||
|
self.disconnect(bot_id, ws)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeEventService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app: Any,
|
||||||
|
engine: Any,
|
||||||
|
cache: Any,
|
||||||
|
logger: Any,
|
||||||
|
publish_runtime_topic_packet: Callable[..., None],
|
||||||
|
bind_usage_message: Callable[..., None],
|
||||||
|
finalize_usage_from_packet: Callable[..., Any],
|
||||||
|
workspace_root: Callable[[str], str],
|
||||||
|
parse_message_media: Callable[[str, Optional[str]], List[str]],
|
||||||
|
) -> None:
|
||||||
|
self._app = app
|
||||||
|
self._engine = engine
|
||||||
|
self._cache = cache
|
||||||
|
self._logger = logger
|
||||||
|
self._publish_runtime_topic_packet = publish_runtime_topic_packet
|
||||||
|
self._bind_usage_message = bind_usage_message
|
||||||
|
self._finalize_usage_from_packet = finalize_usage_from_packet
|
||||||
|
self._workspace_root = workspace_root
|
||||||
|
self._parse_message_media = parse_message_media
|
||||||
|
self.manager = WSConnectionManager()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cache_key_bots_list(user_id: Optional[int] = None) -> str:
|
||||||
|
normalized_user_id = int(user_id or 0)
|
||||||
|
return f"bots:list:user:{normalized_user_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cache_key_bot_detail(bot_id: str) -> str:
|
||||||
|
return f"bot:detail:{bot_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cache_key_bot_messages(bot_id: str, limit: int) -> str:
|
||||||
|
return f"bot:messages:v2:{bot_id}:limit:{limit}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cache_key_bot_messages_page(bot_id: str, limit: int, before_id: Optional[int]) -> str:
|
||||||
|
cursor = str(int(before_id)) if isinstance(before_id, int) and before_id > 0 else "latest"
|
||||||
|
return f"bot:messages:page:v2:{bot_id}:before:{cursor}:limit:{limit}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cache_key_images() -> str:
|
||||||
|
return "images:list"
|
||||||
|
|
||||||
|
def invalidate_bot_detail_cache(self, bot_id: str) -> None:
|
||||||
|
self._cache.delete(self.cache_key_bot_detail(bot_id))
|
||||||
|
self._cache.delete_prefix("bots:list:user:")
|
||||||
|
|
||||||
|
def invalidate_bot_messages_cache(self, bot_id: str) -> None:
|
||||||
|
self._cache.delete_prefix(f"bot:messages:{bot_id}:")
|
||||||
|
|
||||||
|
def invalidate_images_cache(self) -> None:
|
||||||
|
self._cache.delete(self.cache_key_images())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_last_action_text(value: Any) -> str:
|
||||||
|
text = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = __import__("re").sub(r"\n{4,}", "\n\n\n", text)
|
||||||
|
return text[:16000]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_packet_channel(packet: Dict[str, Any]) -> str:
|
||||||
|
raw = str(packet.get("channel") or packet.get("source") or "").strip().lower()
|
||||||
|
if raw in {"dashboard", "dashboard_channel", "dashboard-channel"}:
|
||||||
|
return "dashboard"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
def normalize_media_item(self, bot_id: str, value: Any) -> str:
|
||||||
|
raw = str(value or "").strip().replace("\\", "/")
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
if raw.startswith("/root/.nanobot/workspace/"):
|
||||||
|
return raw[len("/root/.nanobot/workspace/") :].lstrip("/")
|
||||||
|
root = self._workspace_root(bot_id)
|
||||||
|
if os.path.isabs(raw):
|
||||||
|
try:
|
||||||
|
if os.path.commonpath([root, raw]) == root:
|
||||||
|
return os.path.relpath(raw, root).replace("\\", "/")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return raw.lstrip("/")
|
||||||
|
|
||||||
|
def normalize_media_list(self, raw: Any, bot_id: str) -> List[str]:
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
rows: List[str] = []
|
||||||
|
for value in raw:
|
||||||
|
normalized = self.normalize_media_item(bot_id, value)
|
||||||
|
if normalized:
|
||||||
|
rows.append(normalized)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def serialize_bot_message_row(self, bot_id: str, row: BotMessage) -> Dict[str, Any]:
|
||||||
|
created_at = row.created_at
|
||||||
|
if created_at.tzinfo is None:
|
||||||
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"bot_id": row.bot_id,
|
||||||
|
"role": row.role,
|
||||||
|
"text": row.text,
|
||||||
|
"media": self._parse_message_media(bot_id, getattr(row, "media_json", None)),
|
||||||
|
"feedback": str(getattr(row, "feedback", "") or "").strip() or None,
|
||||||
|
"ts": int(created_at.timestamp() * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_local_day_range(date_text: str, tz_offset_minutes: Optional[int]) -> tuple[datetime, datetime]:
|
||||||
|
try:
|
||||||
|
local_day = datetime.strptime(str(date_text or "").strip(), "%Y-%m-%d")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date, expected YYYY-MM-DD") from exc
|
||||||
|
|
||||||
|
offset_minutes = 0
|
||||||
|
if tz_offset_minutes is not None:
|
||||||
|
try:
|
||||||
|
offset_minutes = int(tz_offset_minutes)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid timezone offset") from exc
|
||||||
|
|
||||||
|
utc_start = local_day + timedelta(minutes=offset_minutes)
|
||||||
|
utc_end = utc_start + timedelta(days=1)
|
||||||
|
return utc_start, utc_end
|
||||||
|
|
||||||
|
def persist_runtime_packet(self, bot_id: str, packet: Dict[str, Any]) -> Optional[int]:
|
||||||
|
packet_type = str(packet.get("type", "")).upper()
|
||||||
|
if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}:
|
||||||
|
return None
|
||||||
|
source_channel = self.normalize_packet_channel(packet)
|
||||||
|
if source_channel != "dashboard":
|
||||||
|
return None
|
||||||
|
persisted_message_id: Optional[int] = None
|
||||||
|
with Session(self._engine) as session:
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
return None
|
||||||
|
if packet_type == "AGENT_STATE":
|
||||||
|
payload = packet.get("payload") or {}
|
||||||
|
state = str(payload.get("state") or "").strip()
|
||||||
|
action = self.normalize_last_action_text(payload.get("action_msg") or payload.get("msg") or "")
|
||||||
|
if state:
|
||||||
|
bot.current_state = state
|
||||||
|
if action:
|
||||||
|
bot.last_action = action
|
||||||
|
elif packet_type == "ASSISTANT_MESSAGE":
|
||||||
|
bot.current_state = "IDLE"
|
||||||
|
text_msg = str(packet.get("text") or "").strip()
|
||||||
|
media_list = self.normalize_media_list(packet.get("media"), bot_id)
|
||||||
|
if text_msg or media_list:
|
||||||
|
if text_msg:
|
||||||
|
bot.last_action = self.normalize_last_action_text(text_msg)
|
||||||
|
message_row = BotMessage(
|
||||||
|
bot_id=bot_id,
|
||||||
|
role="assistant",
|
||||||
|
text=text_msg,
|
||||||
|
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None,
|
||||||
|
)
|
||||||
|
session.add(message_row)
|
||||||
|
session.flush()
|
||||||
|
persisted_message_id = message_row.id
|
||||||
|
self._finalize_usage_from_packet(
|
||||||
|
session,
|
||||||
|
bot_id,
|
||||||
|
{
|
||||||
|
**packet,
|
||||||
|
"message_id": persisted_message_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif packet_type == "USER_COMMAND":
|
||||||
|
text_msg = str(packet.get("text") or "").strip()
|
||||||
|
media_list = self.normalize_media_list(packet.get("media"), bot_id)
|
||||||
|
if text_msg or media_list:
|
||||||
|
message_row = BotMessage(
|
||||||
|
bot_id=bot_id,
|
||||||
|
role="user",
|
||||||
|
text=text_msg,
|
||||||
|
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None,
|
||||||
|
)
|
||||||
|
session.add(message_row)
|
||||||
|
session.flush()
|
||||||
|
persisted_message_id = message_row.id
|
||||||
|
self._bind_usage_message(
|
||||||
|
session,
|
||||||
|
bot_id,
|
||||||
|
str(packet.get("request_id") or "").strip(),
|
||||||
|
persisted_message_id,
|
||||||
|
)
|
||||||
|
elif packet_type == "BUS_EVENT":
|
||||||
|
is_progress = bool(packet.get("is_progress"))
|
||||||
|
detail_text = str(packet.get("content") or packet.get("text") or "").strip()
|
||||||
|
if not is_progress:
|
||||||
|
text_msg = detail_text
|
||||||
|
media_list = self.normalize_media_list(packet.get("media"), bot_id)
|
||||||
|
if text_msg or media_list:
|
||||||
|
bot.current_state = "IDLE"
|
||||||
|
if text_msg:
|
||||||
|
bot.last_action = self.normalize_last_action_text(text_msg)
|
||||||
|
message_row = BotMessage(
|
||||||
|
bot_id=bot_id,
|
||||||
|
role="assistant",
|
||||||
|
text=text_msg,
|
||||||
|
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None,
|
||||||
|
)
|
||||||
|
session.add(message_row)
|
||||||
|
session.flush()
|
||||||
|
persisted_message_id = message_row.id
|
||||||
|
self._finalize_usage_from_packet(
|
||||||
|
session,
|
||||||
|
bot_id,
|
||||||
|
{
|
||||||
|
"text": text_msg,
|
||||||
|
"usage": packet.get("usage"),
|
||||||
|
"request_id": packet.get("request_id"),
|
||||||
|
"provider": packet.get("provider"),
|
||||||
|
"model": packet.get("model"),
|
||||||
|
"message_id": persisted_message_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
bot.updated_at = datetime.utcnow()
|
||||||
|
session.add(bot)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self._publish_runtime_topic_packet(
|
||||||
|
self._engine,
|
||||||
|
bot_id,
|
||||||
|
packet,
|
||||||
|
source_channel,
|
||||||
|
persisted_message_id,
|
||||||
|
self._logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
if persisted_message_id:
|
||||||
|
packet["message_id"] = persisted_message_id
|
||||||
|
if packet_type in {"ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}:
|
||||||
|
self.invalidate_bot_messages_cache(bot_id)
|
||||||
|
self.invalidate_bot_detail_cache(bot_id)
|
||||||
|
return persisted_message_id
|
||||||
|
|
||||||
|
def broadcast_runtime_packet(self, bot_id: str, packet: Dict[str, Any], loop: Any) -> None:
|
||||||
|
asyncio.run_coroutine_threadsafe(self.manager.broadcast(bot_id, packet), loop)
|
||||||
|
|
||||||
|
def docker_callback(self, bot_id: str, packet: Dict[str, Any]):
|
||||||
|
self.persist_runtime_packet(bot_id, packet)
|
||||||
|
loop = getattr(self._app.state, "main_loop", None)
|
||||||
|
if not loop or not loop.is_running():
|
||||||
|
return
|
||||||
|
asyncio.run_coroutine_threadsafe(self.manager.broadcast(bot_id, packet), loop)
|
||||||
|
|
@ -4,6 +4,7 @@ from typing import Any, Callable, Dict
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from models.bot import BotInstance, BotMessage
|
from models.bot import BotInstance, BotMessage
|
||||||
|
from fastapi import HTTPException
|
||||||
from providers.runtime.base import RuntimeProvider
|
from providers.runtime.base import RuntimeProvider
|
||||||
from services.bot_command_service import BotCommandService
|
from services.bot_command_service import BotCommandService
|
||||||
|
|
||||||
|
|
@ -28,6 +29,12 @@ class RuntimeService:
|
||||||
self._invalidate_bot_messages_cache = invalidate_bot_messages_cache
|
self._invalidate_bot_messages_cache = invalidate_bot_messages_cache
|
||||||
self._record_activity_event = record_activity_event
|
self._record_activity_event = record_activity_event
|
||||||
|
|
||||||
|
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
|
return bot
|
||||||
|
|
||||||
async def start_bot(self, *, app_state: Any, session: Session, bot: BotInstance) -> Dict[str, Any]:
|
async def start_bot(self, *, app_state: Any, session: Session, bot: BotInstance) -> Dict[str, Any]:
|
||||||
result = await self._resolve_runtime_provider(app_state, bot).start_bot(session=session, bot=bot)
|
result = await self._resolve_runtime_provider(app_state, bot).start_bot(session=session, bot=bot)
|
||||||
self._invalidate_bot_detail_cache(str(bot.id or ""))
|
self._invalidate_bot_detail_cache(str(bot.id or ""))
|
||||||
|
|
@ -38,6 +45,35 @@ class RuntimeService:
|
||||||
self._invalidate_bot_detail_cache(str(bot.id or ""))
|
self._invalidate_bot_detail_cache(str(bot.id or ""))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def clear_messages_for_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.clear_messages(app_state=app_state, session=session, bot=bot)
|
||||||
|
|
||||||
|
def clear_dashboard_direct_session_for_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.clear_dashboard_direct_session(app_state=app_state, session=session, bot=bot)
|
||||||
|
|
||||||
|
def get_logs_for_bot(self, *, app_state: Any, session: Session, bot_id: str, tail: int = 300) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.get_logs(app_state=app_state, bot=bot, tail=tail)
|
||||||
|
|
||||||
|
def send_command_for_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app_state: Any,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
payload: Any,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.send_command(
|
||||||
|
app_state=app_state,
|
||||||
|
session=session,
|
||||||
|
bot_id=bot_id,
|
||||||
|
bot=bot,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
def send_command(
|
def send_command(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,898 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from clients.edge.errors import log_edge_failure
|
||||||
|
from core.settings import BOTS_WORKSPACE_ROOT, DATA_ROOT
|
||||||
|
from models.bot import BotInstance
|
||||||
|
from models.skill import BotSkillInstall, SkillMarketItem
|
||||||
|
from services.platform_settings_service import get_platform_settings_snapshot
|
||||||
|
|
||||||
|
EdgeStateContextResolver = Callable[[str], Optional[tuple[Any, Optional[str], str]]]
|
||||||
|
|
||||||
|
|
||||||
|
class SkillService:
|
||||||
|
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def _workspace_root(self, bot_id: str) -> str:
|
||||||
|
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace"))
|
||||||
|
|
||||||
|
def _skills_root(self, bot_id: str) -> str:
|
||||||
|
return os.path.join(self._workspace_root(bot_id), "skills")
|
||||||
|
|
||||||
|
def _skill_market_root(self) -> str:
|
||||||
|
return os.path.abspath(os.path.join(DATA_ROOT, "skills"))
|
||||||
|
|
||||||
|
def _is_valid_top_level_skill_name(self, name: str) -> bool:
|
||||||
|
text = str(name or "").strip()
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
if "/" in text or "\\" in text:
|
||||||
|
return False
|
||||||
|
if text in {".", ".."}:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _read_skill_description(self, entry_path: str) -> str:
|
||||||
|
candidates: List[str] = []
|
||||||
|
if os.path.isdir(entry_path):
|
||||||
|
candidates = [
|
||||||
|
os.path.join(entry_path, "SKILL.md"),
|
||||||
|
os.path.join(entry_path, "skill.md"),
|
||||||
|
os.path.join(entry_path, "README.md"),
|
||||||
|
os.path.join(entry_path, "readme.md"),
|
||||||
|
]
|
||||||
|
elif entry_path.lower().endswith(".md"):
|
||||||
|
candidates = [entry_path]
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if not os.path.isfile(candidate):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(candidate, "r", encoding="utf-8") as file:
|
||||||
|
for line in file:
|
||||||
|
text = line.strip()
|
||||||
|
if text and not text.startswith("#"):
|
||||||
|
return text[:240]
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def list_workspace_skills(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
resolve_edge_state_context: EdgeStateContextResolver,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
edge_context = resolve_edge_state_context(bot_id)
|
||||||
|
if edge_context is not None:
|
||||||
|
client, workspace_root, node_id = edge_context
|
||||||
|
try:
|
||||||
|
payload = client.list_tree(
|
||||||
|
bot_id=bot_id,
|
||||||
|
path="skills",
|
||||||
|
recursive=False,
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log_edge_failure(
|
||||||
|
logger,
|
||||||
|
key=f"skills-list:{node_id}:{bot_id}",
|
||||||
|
exc=exc,
|
||||||
|
message=f"Failed to list skills from edge workspace for bot_id={bot_id}",
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
for entry in list(payload.get("entries") or []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
name = str(entry.get("name") or "").strip()
|
||||||
|
if not name or name.startswith("."):
|
||||||
|
continue
|
||||||
|
if not self._is_valid_top_level_skill_name(name):
|
||||||
|
continue
|
||||||
|
entry_type = str(entry.get("type") or "").strip().lower()
|
||||||
|
if entry_type not in {"dir", "file"}:
|
||||||
|
continue
|
||||||
|
mtime = str(entry.get("mtime") or "").strip() or (datetime.utcnow().isoformat() + "Z")
|
||||||
|
size = entry.get("size")
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"id": name,
|
||||||
|
"name": name,
|
||||||
|
"type": entry_type,
|
||||||
|
"path": f"skills/{name}",
|
||||||
|
"size": int(size) if isinstance(size, (int, float)) and entry_type == "file" else None,
|
||||||
|
"mtime": mtime,
|
||||||
|
"description": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.sort(key=lambda row: (row.get("type") != "dir", str(row.get("name") or "").lower()))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
root = self._skills_root(bot_id)
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
return []
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
names = sorted(os.listdir(root), key=lambda name: (not os.path.isdir(os.path.join(root, name)), name.lower()))
|
||||||
|
for name in names:
|
||||||
|
if not name or name.startswith("."):
|
||||||
|
continue
|
||||||
|
if not self._is_valid_top_level_skill_name(name):
|
||||||
|
continue
|
||||||
|
abs_path = os.path.join(root, name)
|
||||||
|
if not os.path.exists(abs_path):
|
||||||
|
continue
|
||||||
|
stat = os.stat(abs_path)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"id": name,
|
||||||
|
"name": name,
|
||||||
|
"type": "dir" if os.path.isdir(abs_path) else "file",
|
||||||
|
"path": f"skills/{name}",
|
||||||
|
"size": stat.st_size if os.path.isfile(abs_path) else None,
|
||||||
|
"mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z",
|
||||||
|
"description": self._read_skill_description(abs_path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def list_workspace_skills_for_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
resolve_edge_state_context: EdgeStateContextResolver,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.list_workspace_skills(
|
||||||
|
bot_id=bot_id,
|
||||||
|
resolve_edge_state_context=resolve_edge_state_context,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_json_string_list(self, raw: Any) -> List[str]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(str(raw))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
rows: List[str] = []
|
||||||
|
for item in data:
|
||||||
|
text = str(item or "").strip()
|
||||||
|
if text and text not in rows:
|
||||||
|
rows.append(text)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _is_ignored_skill_zip_top_level(self, name: str) -> bool:
|
||||||
|
text = str(name or "").strip()
|
||||||
|
if not text:
|
||||||
|
return True
|
||||||
|
lowered = text.lower()
|
||||||
|
if lowered == "__macosx":
|
||||||
|
return True
|
||||||
|
if text.startswith("."):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _read_description_from_text(self, raw: str) -> str:
|
||||||
|
for line in str(raw or "").splitlines():
|
||||||
|
text = line.strip()
|
||||||
|
if text and not text.startswith("#"):
|
||||||
|
return text[:240]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _extract_skill_zip_summary(self, zip_path: str) -> Dict[str, Any]:
|
||||||
|
entry_names: List[str] = []
|
||||||
|
description = ""
|
||||||
|
with zipfile.ZipFile(zip_path) as archive:
|
||||||
|
members = archive.infolist()
|
||||||
|
file_members = [member for member in members if not member.is_dir()]
|
||||||
|
for member in file_members:
|
||||||
|
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
||||||
|
if not raw_name:
|
||||||
|
continue
|
||||||
|
first = raw_name.split("/", 1)[0].strip()
|
||||||
|
if self._is_ignored_skill_zip_top_level(first):
|
||||||
|
continue
|
||||||
|
if self._is_valid_top_level_skill_name(first) and first not in entry_names:
|
||||||
|
entry_names.append(first)
|
||||||
|
|
||||||
|
candidates = sorted(
|
||||||
|
[
|
||||||
|
str(member.filename or "").replace("\\", "/").lstrip("/")
|
||||||
|
for member in file_members
|
||||||
|
if str(member.filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower() in {"skill.md", "readme.md"}
|
||||||
|
],
|
||||||
|
key=lambda value: (value.count("/"), value.lower()),
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
with archive.open(candidate, "r") as file:
|
||||||
|
preview = file.read(4096).decode("utf-8", errors="ignore")
|
||||||
|
description = self._read_description_from_text(preview)
|
||||||
|
if description:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"entry_names": entry_names,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _sanitize_skill_market_key(self, raw: Any) -> str:
|
||||||
|
value = str(raw or "").strip().lower()
|
||||||
|
value = re.sub(r"[^a-z0-9._-]+", "-", value)
|
||||||
|
value = re.sub(r"-{2,}", "-", value).strip("._-")
|
||||||
|
return value[:120]
|
||||||
|
|
||||||
|
def _sanitize_zip_filename(self, raw: Any) -> str:
|
||||||
|
filename = os.path.basename(str(raw or "").strip())
|
||||||
|
if not filename:
|
||||||
|
return ""
|
||||||
|
filename = filename.replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
stem, ext = os.path.splitext(filename)
|
||||||
|
safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("._-")
|
||||||
|
if not safe_stem:
|
||||||
|
safe_stem = "skill-package"
|
||||||
|
safe_ext = ".zip" if ext.lower() == ".zip" else ""
|
||||||
|
return f"{safe_stem[:180]}{safe_ext}"
|
||||||
|
|
||||||
|
def _resolve_unique_skill_market_key(self, session: Session, preferred_key: str, exclude_id: Optional[int] = None) -> str:
|
||||||
|
base_key = self._sanitize_skill_market_key(preferred_key) or "skill"
|
||||||
|
candidate = base_key
|
||||||
|
counter = 2
|
||||||
|
while True:
|
||||||
|
stmt = select(SkillMarketItem).where(SkillMarketItem.skill_key == candidate)
|
||||||
|
rows = session.exec(stmt).all()
|
||||||
|
conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
|
||||||
|
if not conflict:
|
||||||
|
return candidate
|
||||||
|
candidate = f"{base_key}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
def _resolve_unique_skill_market_zip_filename(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
filename: str,
|
||||||
|
*,
|
||||||
|
exclude_filename: Optional[str] = None,
|
||||||
|
exclude_id: Optional[int] = None,
|
||||||
|
) -> str:
|
||||||
|
root = self._skill_market_root()
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
safe_name = self._sanitize_zip_filename(filename)
|
||||||
|
if not safe_name.lower().endswith(".zip"):
|
||||||
|
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
|
||||||
|
candidate = safe_name
|
||||||
|
stem, ext = os.path.splitext(safe_name)
|
||||||
|
counter = 2
|
||||||
|
while True:
|
||||||
|
file_conflict = os.path.exists(os.path.join(root, candidate)) and candidate != str(exclude_filename or "").strip()
|
||||||
|
rows = session.exec(select(SkillMarketItem).where(SkillMarketItem.zip_filename == candidate)).all()
|
||||||
|
db_conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
|
||||||
|
if not file_conflict and not db_conflict:
|
||||||
|
return candidate
|
||||||
|
candidate = f"{stem}-{counter}{ext}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
async def _store_skill_market_zip_upload(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
upload: UploadFile,
|
||||||
|
*,
|
||||||
|
exclude_filename: Optional[str] = None,
|
||||||
|
exclude_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
root = self._skill_market_root()
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
|
||||||
|
incoming_name = self._sanitize_zip_filename(upload.filename or "")
|
||||||
|
if not incoming_name.lower().endswith(".zip"):
|
||||||
|
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
|
||||||
|
|
||||||
|
target_filename = self._resolve_unique_skill_market_zip_filename(
|
||||||
|
session,
|
||||||
|
incoming_name,
|
||||||
|
exclude_filename=exclude_filename,
|
||||||
|
exclude_id=exclude_id,
|
||||||
|
)
|
||||||
|
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
|
||||||
|
total_size = 0
|
||||||
|
tmp_path: Optional[str] = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(prefix=".skill_market_", suffix=".zip", dir=root, delete=False) as tmp_zip:
|
||||||
|
tmp_path = tmp_zip.name
|
||||||
|
while True:
|
||||||
|
chunk = await upload.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total_size += len(chunk)
|
||||||
|
if total_size > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
|
||||||
|
)
|
||||||
|
tmp_zip.write(chunk)
|
||||||
|
if total_size == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Zip package is empty")
|
||||||
|
summary = self._extract_skill_zip_summary(tmp_path)
|
||||||
|
if not summary["entry_names"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
|
||||||
|
final_path = os.path.join(root, target_filename)
|
||||||
|
os.replace(tmp_path, final_path)
|
||||||
|
tmp_path = None
|
||||||
|
return {
|
||||||
|
"zip_filename": target_filename,
|
||||||
|
"zip_size_bytes": total_size,
|
||||||
|
"entry_names": summary["entry_names"],
|
||||||
|
"description": summary["description"],
|
||||||
|
}
|
||||||
|
except zipfile.BadZipFile as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
|
||||||
|
finally:
|
||||||
|
await upload.close()
|
||||||
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
def serialize_skill_market_item(
|
||||||
|
self,
|
||||||
|
item: SkillMarketItem,
|
||||||
|
*,
|
||||||
|
install_count: int = 0,
|
||||||
|
install_row: Optional[BotSkillInstall] = None,
|
||||||
|
workspace_installed: Optional[bool] = None,
|
||||||
|
installed_entries: Optional[List[str]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
zip_path = os.path.join(self._skill_market_root(), str(item.zip_filename or ""))
|
||||||
|
entry_names = self._parse_json_string_list(item.entry_names_json)
|
||||||
|
payload = {
|
||||||
|
"id": item.id,
|
||||||
|
"skill_key": item.skill_key,
|
||||||
|
"display_name": item.display_name or item.skill_key,
|
||||||
|
"description": item.description or "",
|
||||||
|
"zip_filename": item.zip_filename,
|
||||||
|
"zip_size_bytes": int(item.zip_size_bytes or 0),
|
||||||
|
"entry_names": entry_names,
|
||||||
|
"entry_count": len(entry_names),
|
||||||
|
"zip_exists": os.path.isfile(zip_path),
|
||||||
|
"install_count": int(install_count or 0),
|
||||||
|
"created_at": item.created_at.isoformat() + "Z" if item.created_at else None,
|
||||||
|
"updated_at": item.updated_at.isoformat() + "Z" if item.updated_at else None,
|
||||||
|
}
|
||||||
|
if install_row is not None:
|
||||||
|
resolved_entries = installed_entries if installed_entries is not None else self._parse_json_string_list(install_row.installed_entries_json)
|
||||||
|
resolved_installed = workspace_installed if workspace_installed is not None else install_row.status == "INSTALLED"
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"installed": resolved_installed,
|
||||||
|
"install_status": install_row.status,
|
||||||
|
"installed_at": install_row.installed_at.isoformat() + "Z" if install_row.installed_at else None,
|
||||||
|
"installed_entries": resolved_entries,
|
||||||
|
"install_error": install_row.last_error,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def list_market_items(self, *, session: Session) -> List[Dict[str, Any]]:
|
||||||
|
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
|
||||||
|
installs = session.exec(select(BotSkillInstall)).all()
|
||||||
|
install_count_by_skill: Dict[int, int] = {}
|
||||||
|
for row in installs:
|
||||||
|
skill_id = int(row.skill_market_item_id or 0)
|
||||||
|
if skill_id <= 0 or row.status != "INSTALLED":
|
||||||
|
continue
|
||||||
|
install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1
|
||||||
|
return [
|
||||||
|
self.serialize_skill_market_item(item, install_count=install_count_by_skill.get(int(item.id or 0), 0))
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create_market_item(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
skill_key: str,
|
||||||
|
display_name: str,
|
||||||
|
description: str,
|
||||||
|
file: UploadFile,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
upload_meta = await self._store_skill_market_zip_upload(session, file)
|
||||||
|
try:
|
||||||
|
preferred_key = skill_key or display_name or os.path.splitext(upload_meta["zip_filename"])[0]
|
||||||
|
next_key = self._resolve_unique_skill_market_key(session, preferred_key)
|
||||||
|
item = SkillMarketItem(
|
||||||
|
skill_key=next_key,
|
||||||
|
display_name=str(display_name or next_key).strip() or next_key,
|
||||||
|
description=str(description or upload_meta["description"] or "").strip(),
|
||||||
|
zip_filename=upload_meta["zip_filename"],
|
||||||
|
zip_size_bytes=int(upload_meta["zip_size_bytes"] or 0),
|
||||||
|
entry_names_json=json.dumps(upload_meta["entry_names"], ensure_ascii=False),
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return self.serialize_skill_market_item(item, install_count=0)
|
||||||
|
except Exception:
|
||||||
|
target_path = os.path.join(self._skill_market_root(), upload_meta["zip_filename"])
|
||||||
|
if os.path.exists(target_path):
|
||||||
|
os.remove(target_path)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def update_market_item(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
skill_id: int,
|
||||||
|
skill_key: str,
|
||||||
|
display_name: str,
|
||||||
|
description: str,
|
||||||
|
file: Optional[UploadFile],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
item = session.get(SkillMarketItem, skill_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Skill market item not found")
|
||||||
|
|
||||||
|
old_filename = str(item.zip_filename or "").strip()
|
||||||
|
upload_meta: Optional[Dict[str, Any]] = None
|
||||||
|
if file is not None:
|
||||||
|
upload_meta = await self._store_skill_market_zip_upload(
|
||||||
|
session,
|
||||||
|
file,
|
||||||
|
exclude_filename=old_filename or None,
|
||||||
|
exclude_id=item.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
next_key = self._resolve_unique_skill_market_key(
|
||||||
|
session,
|
||||||
|
skill_key or item.skill_key or display_name or os.path.splitext(upload_meta["zip_filename"] if upload_meta else old_filename)[0],
|
||||||
|
exclude_id=item.id,
|
||||||
|
)
|
||||||
|
item.skill_key = next_key
|
||||||
|
item.display_name = str(display_name or item.display_name or next_key).strip() or next_key
|
||||||
|
item.description = str(description or (upload_meta["description"] if upload_meta else item.description) or "").strip()
|
||||||
|
item.updated_at = datetime.utcnow()
|
||||||
|
if upload_meta:
|
||||||
|
item.zip_filename = upload_meta["zip_filename"]
|
||||||
|
item.zip_size_bytes = int(upload_meta["zip_size_bytes"] or 0)
|
||||||
|
item.entry_names_json = json.dumps(upload_meta["entry_names"], ensure_ascii=False)
|
||||||
|
session.add(item)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
|
||||||
|
if upload_meta and old_filename and old_filename != upload_meta["zip_filename"]:
|
||||||
|
old_path = os.path.join(self._skill_market_root(), old_filename)
|
||||||
|
if os.path.exists(old_path):
|
||||||
|
os.remove(old_path)
|
||||||
|
|
||||||
|
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
|
||||||
|
install_count = sum(1 for row in installs if row.status == "INSTALLED")
|
||||||
|
return self.serialize_skill_market_item(item, install_count=install_count)
|
||||||
|
|
||||||
|
def delete_market_item(self, *, session: Session, skill_id: int) -> Dict[str, Any]:
|
||||||
|
item = session.get(SkillMarketItem, skill_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Skill market item not found")
|
||||||
|
zip_filename = str(item.zip_filename or "").strip()
|
||||||
|
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
|
||||||
|
for row in installs:
|
||||||
|
session.delete(row)
|
||||||
|
session.delete(item)
|
||||||
|
session.commit()
|
||||||
|
if zip_filename:
|
||||||
|
zip_path = os.path.join(self._skill_market_root(), zip_filename)
|
||||||
|
if os.path.exists(zip_path):
|
||||||
|
os.remove(zip_path)
|
||||||
|
return {"status": "deleted", "id": skill_id}
|
||||||
|
|
||||||
|
def list_bot_market_items(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
session: Session,
|
||||||
|
resolve_edge_state_context: EdgeStateContextResolver,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
|
||||||
|
install_rows = session.exec(select(BotSkillInstall).where(BotSkillInstall.bot_id == bot_id)).all()
|
||||||
|
install_lookup = {int(row.skill_market_item_id): row for row in install_rows}
|
||||||
|
all_install_rows = session.exec(select(BotSkillInstall)).all()
|
||||||
|
install_count_by_skill: Dict[int, int] = {}
|
||||||
|
for row in all_install_rows:
|
||||||
|
skill_id = int(row.skill_market_item_id or 0)
|
||||||
|
if skill_id <= 0 or row.status != "INSTALLED":
|
||||||
|
continue
|
||||||
|
install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1
|
||||||
|
workspace_skill_names = {
|
||||||
|
str(row.get("name") or "").strip()
|
||||||
|
for row in self.list_workspace_skills(bot_id=bot_id, resolve_edge_state_context=resolve_edge_state_context, logger=logger)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
self.serialize_skill_market_item(
|
||||||
|
item,
|
||||||
|
install_count=install_count_by_skill.get(int(item.id or 0), 0),
|
||||||
|
install_row=install_lookup.get(int(item.id or 0)),
|
||||||
|
workspace_installed=(
|
||||||
|
None
|
||||||
|
if install_lookup.get(int(item.id or 0)) is None
|
||||||
|
else (
|
||||||
|
install_lookup[int(item.id or 0)].status == "INSTALLED"
|
||||||
|
and all(
|
||||||
|
name in workspace_skill_names
|
||||||
|
for name in self._parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
installed_entries=(
|
||||||
|
None
|
||||||
|
if install_lookup.get(int(item.id or 0)) is None
|
||||||
|
else self._parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_bot_market_items_for_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
resolve_edge_state_context: EdgeStateContextResolver,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.list_bot_market_items(
|
||||||
|
bot_id=bot_id,
|
||||||
|
session=session,
|
||||||
|
resolve_edge_state_context=resolve_edge_state_context,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _install_skill_zip_into_workspace(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
zip_path: str,
|
||||||
|
resolve_edge_state_context: EdgeStateContextResolver,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
archive = zipfile.ZipFile(zip_path)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
|
||||||
|
|
||||||
|
edge_context = resolve_edge_state_context(bot_id)
|
||||||
|
skills_root = self._skills_root(bot_id)
|
||||||
|
installed: List[str] = []
|
||||||
|
with archive:
|
||||||
|
members = archive.infolist()
|
||||||
|
file_members = [member for member in members if not member.is_dir()]
|
||||||
|
if not file_members:
|
||||||
|
raise HTTPException(status_code=400, detail="Zip package has no files")
|
||||||
|
|
||||||
|
top_names: List[str] = []
|
||||||
|
for member in file_members:
|
||||||
|
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
||||||
|
if not raw_name:
|
||||||
|
continue
|
||||||
|
first = raw_name.split("/", 1)[0].strip()
|
||||||
|
if self._is_ignored_skill_zip_top_level(first):
|
||||||
|
continue
|
||||||
|
if not self._is_valid_top_level_skill_name(first):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid skill entry name in zip: {first}")
|
||||||
|
if first not in top_names:
|
||||||
|
top_names.append(first)
|
||||||
|
|
||||||
|
if not top_names:
|
||||||
|
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
|
||||||
|
|
||||||
|
if edge_context is not None:
|
||||||
|
existing_names = {
|
||||||
|
str(item.get("name") or "").strip()
|
||||||
|
for item in self.list_workspace_skills(
|
||||||
|
bot_id=bot_id,
|
||||||
|
resolve_edge_state_context=resolve_edge_state_context,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
conflicts = [name for name in top_names if name in existing_names]
|
||||||
|
else:
|
||||||
|
os.makedirs(skills_root, exist_ok=True)
|
||||||
|
conflicts = [name for name in top_names if os.path.exists(os.path.join(skills_root, name))]
|
||||||
|
if conflicts:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Skill already exists: {', '.join(conflicts)}")
|
||||||
|
|
||||||
|
temp_dir_root = skills_root if edge_context is None else None
|
||||||
|
with tempfile.TemporaryDirectory(prefix=".skill_upload_", dir=temp_dir_root) as tmp_dir:
|
||||||
|
tmp_root = os.path.abspath(tmp_dir)
|
||||||
|
for member in members:
|
||||||
|
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
||||||
|
if not raw_name:
|
||||||
|
continue
|
||||||
|
target = os.path.abspath(os.path.join(tmp_root, raw_name))
|
||||||
|
if os.path.commonpath([tmp_root, target]) != tmp_root:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsafe zip entry path: {raw_name}")
|
||||||
|
if member.is_dir():
|
||||||
|
os.makedirs(target, exist_ok=True)
|
||||||
|
continue
|
||||||
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||||
|
with archive.open(member, "r") as source, open(target, "wb") as dest:
|
||||||
|
shutil.copyfileobj(source, dest)
|
||||||
|
|
||||||
|
if edge_context is not None:
|
||||||
|
client, workspace_root, _node_id = edge_context
|
||||||
|
upload_groups: Dict[str, List[str]] = {}
|
||||||
|
for name in top_names:
|
||||||
|
src = os.path.join(tmp_root, name)
|
||||||
|
if not os.path.exists(src):
|
||||||
|
continue
|
||||||
|
if os.path.isfile(src):
|
||||||
|
upload_groups.setdefault("skills", []).append(src)
|
||||||
|
installed.append(name)
|
||||||
|
continue
|
||||||
|
for walk_root, _dirs, files in os.walk(src):
|
||||||
|
for filename in files:
|
||||||
|
local_path = os.path.join(walk_root, filename)
|
||||||
|
relative_path = os.path.relpath(local_path, tmp_root).replace("\\", "/")
|
||||||
|
relative_dir = os.path.dirname(relative_path).strip("/")
|
||||||
|
target_dir = f"skills/{relative_dir}" if relative_dir else "skills"
|
||||||
|
upload_groups.setdefault(target_dir, []).append(local_path)
|
||||||
|
installed.append(name)
|
||||||
|
for target_dir, local_paths in upload_groups.items():
|
||||||
|
client.upload_local_files(
|
||||||
|
bot_id=bot_id,
|
||||||
|
local_paths=local_paths,
|
||||||
|
path=target_dir,
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for name in top_names:
|
||||||
|
src = os.path.join(tmp_root, name)
|
||||||
|
dst = os.path.join(skills_root, name)
|
||||||
|
if not os.path.exists(src):
|
||||||
|
continue
|
||||||
|
shutil.move(src, dst)
|
||||||
|
installed.append(name)
|
||||||
|
|
||||||
|
if not installed:
|
||||||
|
raise HTTPException(status_code=400, detail="No skill entries installed from zip")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"installed": installed,
|
||||||
|
"skills": self.list_workspace_skills(
|
||||||
|
bot_id=bot_id,
|
||||||
|
resolve_edge_state_context=resolve_edge_state_context,
|
||||||
|
logger=logger,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def install_market_item_for_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
skill_id: int,
|
||||||
|
session: Session,
|
||||||
|
resolve_edge_state_context: EdgeStateContextResolver,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
item = session.get(SkillMarketItem, skill_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Skill market item not found")
|
||||||
|
|
||||||
|
zip_path = os.path.join(self._skill_market_root(), str(item.zip_filename or ""))
|
||||||
|
if not os.path.isfile(zip_path):
|
||||||
|
raise HTTPException(status_code=404, detail="Skill zip package not found")
|
||||||
|
|
||||||
|
install_row = session.exec(
|
||||||
|
select(BotSkillInstall).where(
|
||||||
|
BotSkillInstall.bot_id == bot_id,
|
||||||
|
BotSkillInstall.skill_market_item_id == skill_id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
try:
|
||||||
|
install_result = self._install_skill_zip_into_workspace(
|
||||||
|
bot_id=bot_id,
|
||||||
|
zip_path=zip_path,
|
||||||
|
resolve_edge_state_context=resolve_edge_state_context,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if not install_row:
|
||||||
|
install_row = BotSkillInstall(
|
||||||
|
bot_id=bot_id,
|
||||||
|
skill_market_item_id=skill_id,
|
||||||
|
)
|
||||||
|
install_row.installed_entries_json = json.dumps(install_result["installed"], ensure_ascii=False)
|
||||||
|
install_row.source_zip_filename = str(item.zip_filename or "")
|
||||||
|
install_row.status = "INSTALLED"
|
||||||
|
install_row.last_error = None
|
||||||
|
install_row.installed_at = now
|
||||||
|
install_row.updated_at = now
|
||||||
|
session.add(install_row)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(install_row)
|
||||||
|
return {
|
||||||
|
"status": "installed",
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"skill_market_item_id": skill_id,
|
||||||
|
"installed": install_result["installed"],
|
||||||
|
"skills": install_result["skills"],
|
||||||
|
"market_item": self.serialize_skill_market_item(item, install_count=0, install_row=install_row),
|
||||||
|
}
|
||||||
|
except HTTPException as exc:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if not install_row:
|
||||||
|
install_row = BotSkillInstall(
|
||||||
|
bot_id=bot_id,
|
||||||
|
skill_market_item_id=skill_id,
|
||||||
|
installed_at=now,
|
||||||
|
)
|
||||||
|
install_row.source_zip_filename = str(item.zip_filename or "")
|
||||||
|
install_row.status = "FAILED"
|
||||||
|
install_row.last_error = str(exc.detail or "Install failed")
|
||||||
|
install_row.updated_at = now
|
||||||
|
session.add(install_row)
|
||||||
|
session.commit()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if not install_row:
|
||||||
|
install_row = BotSkillInstall(
|
||||||
|
bot_id=bot_id,
|
||||||
|
skill_market_item_id=skill_id,
|
||||||
|
installed_at=now,
|
||||||
|
)
|
||||||
|
install_row.source_zip_filename = str(item.zip_filename or "")
|
||||||
|
install_row.status = "FAILED"
|
||||||
|
install_row.last_error = str(exc or "Install failed")[:1000]
|
||||||
|
install_row.updated_at = now
|
||||||
|
session.add(install_row)
|
||||||
|
session.commit()
|
||||||
|
raise HTTPException(status_code=500, detail="Skill install failed unexpectedly") from exc
|
||||||
|
|
||||||
|
def install_market_item_for_bot_checked(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
skill_id: int,
|
||||||
|
resolve_edge_state_context: EdgeStateContextResolver,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.install_market_item_for_bot(
|
||||||
|
bot_id=bot_id,
|
||||||
|
skill_id=skill_id,
|
||||||
|
session=session,
|
||||||
|
resolve_edge_state_context=resolve_edge_state_context,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upload_bot_skill_zip(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
file: UploadFile,
|
||||||
|
resolve_edge_state_context: EdgeStateContextResolver,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
tmp_zip_path: Optional[str] = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(prefix=".skill_upload_", suffix=".zip", delete=False) as tmp_zip:
|
||||||
|
tmp_zip_path = tmp_zip.name
|
||||||
|
filename = str(file.filename or "").strip()
|
||||||
|
if not filename.lower().endswith(".zip"):
|
||||||
|
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
|
||||||
|
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
|
||||||
|
total_size = 0
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total_size += len(chunk)
|
||||||
|
if total_size > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
|
||||||
|
)
|
||||||
|
tmp_zip.write(chunk)
|
||||||
|
if total_size == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Zip package is empty")
|
||||||
|
finally:
|
||||||
|
await file.close()
|
||||||
|
try:
|
||||||
|
install_result = self._install_skill_zip_into_workspace(
|
||||||
|
bot_id=bot_id,
|
||||||
|
zip_path=tmp_zip_path,
|
||||||
|
resolve_edge_state_context=resolve_edge_state_context,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if tmp_zip_path and os.path.exists(tmp_zip_path):
|
||||||
|
os.remove(tmp_zip_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "installed",
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"installed": install_result["installed"],
|
||||||
|
"skills": install_result["skills"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def upload_bot_skill_zip_for_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
file: UploadFile,
|
||||||
|
resolve_edge_state_context: EdgeStateContextResolver,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return await self.upload_bot_skill_zip(
|
||||||
|
bot_id=bot_id,
|
||||||
|
file=file,
|
||||||
|
resolve_edge_state_context=resolve_edge_state_context,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_workspace_skill(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
skill_name: str,
|
||||||
|
resolve_edge_state_context: EdgeStateContextResolver,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if resolve_edge_state_context(bot_id) is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Edge bot skill delete is disabled here. Use edge workspace file management.",
|
||||||
|
)
|
||||||
|
name = str(skill_name or "").strip()
|
||||||
|
if not self._is_valid_top_level_skill_name(name):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid skill name")
|
||||||
|
root = self._skills_root(bot_id)
|
||||||
|
target = os.path.abspath(os.path.join(root, name))
|
||||||
|
if os.path.commonpath([os.path.abspath(root), target]) != os.path.abspath(root):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid skill path")
|
||||||
|
if not os.path.exists(target):
|
||||||
|
raise HTTPException(status_code=404, detail="Skill not found in workspace")
|
||||||
|
if os.path.isdir(target):
|
||||||
|
shutil.rmtree(target, ignore_errors=False)
|
||||||
|
else:
|
||||||
|
os.remove(target)
|
||||||
|
return {"status": "deleted", "bot_id": bot_id, "skill": name}
|
||||||
|
|
||||||
|
def delete_workspace_skill_for_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
skill_name: str,
|
||||||
|
resolve_edge_state_context: EdgeStateContextResolver,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.delete_workspace_skill(
|
||||||
|
bot_id=bot_id,
|
||||||
|
skill_name=skill_name,
|
||||||
|
resolve_edge_state_context=resolve_edge_state_context,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from core.speech_service import SpeechDisabledError, SpeechDurationError, SpeechServiceError
|
||||||
|
from models.bot import BotInstance
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechTranscriptionService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
data_root: str,
|
||||||
|
speech_service: Any,
|
||||||
|
get_speech_runtime_settings: Callable[[], Dict[str, Any]],
|
||||||
|
logger: Any,
|
||||||
|
) -> None:
|
||||||
|
self._data_root = data_root
|
||||||
|
self._speech_service = speech_service
|
||||||
|
self._get_speech_runtime_settings = get_speech_runtime_settings
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
|
return bot
|
||||||
|
|
||||||
|
async def transcribe(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
file: UploadFile,
|
||||||
|
language: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
speech_settings = self._get_speech_runtime_settings()
|
||||||
|
if not speech_settings["enabled"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Speech recognition is disabled")
|
||||||
|
if not file:
|
||||||
|
raise HTTPException(status_code=400, detail="no audio file uploaded")
|
||||||
|
|
||||||
|
original_name = str(file.filename or "audio.webm").strip() or "audio.webm"
|
||||||
|
safe_name = os.path.basename(original_name).replace("\\", "_").replace("/", "_")
|
||||||
|
ext = os.path.splitext(safe_name)[1].strip().lower() or ".webm"
|
||||||
|
if len(ext) > 12:
|
||||||
|
ext = ".webm"
|
||||||
|
|
||||||
|
tmp_path = ""
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=ext, prefix=".speech_", dir=self._data_root) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
tmp.write(chunk)
|
||||||
|
|
||||||
|
if not tmp_path or not os.path.exists(tmp_path) or os.path.getsize(tmp_path) <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="audio payload is empty")
|
||||||
|
|
||||||
|
resolved_language = str(language or "").strip() or speech_settings["default_language"]
|
||||||
|
result = await asyncio.to_thread(self._speech_service.transcribe_file, tmp_path, resolved_language)
|
||||||
|
text = str(result.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=400, detail="No speech detected")
|
||||||
|
return {
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"text": text,
|
||||||
|
"duration_seconds": result.get("duration_seconds"),
|
||||||
|
"max_audio_seconds": speech_settings["max_audio_seconds"],
|
||||||
|
"model": speech_settings["model"],
|
||||||
|
"device": speech_settings["device"],
|
||||||
|
"language": result.get("language") or resolved_language,
|
||||||
|
}
|
||||||
|
except SpeechDisabledError as exc:
|
||||||
|
self._logger.warning(
|
||||||
|
"speech transcribe disabled bot_id=%s file=%s language=%s detail=%s",
|
||||||
|
bot_id,
|
||||||
|
safe_name,
|
||||||
|
language,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except SpeechDurationError:
|
||||||
|
self._logger.warning(
|
||||||
|
"speech transcribe too long bot_id=%s file=%s language=%s max_seconds=%s",
|
||||||
|
bot_id,
|
||||||
|
safe_name,
|
||||||
|
language,
|
||||||
|
speech_settings["max_audio_seconds"],
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=413, detail=f"Audio duration exceeds {speech_settings['max_audio_seconds']} seconds")
|
||||||
|
except SpeechServiceError as exc:
|
||||||
|
self._logger.exception(
|
||||||
|
"speech transcribe failed bot_id=%s file=%s language=%s",
|
||||||
|
bot_id,
|
||||||
|
safe_name,
|
||||||
|
language,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.exception(
|
||||||
|
"speech transcribe unexpected error bot_id=%s file=%s language=%s",
|
||||||
|
bot_id,
|
||||||
|
safe_name,
|
||||||
|
language,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=500, detail=f"speech transcription failed: {exc}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await file.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,155 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Callable, Dict
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from models.bot import BotInstance
|
||||||
|
|
||||||
|
|
||||||
|
class SystemService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
engine: Any,
|
||||||
|
cache: Any,
|
||||||
|
database_engine: str,
|
||||||
|
redis_enabled: bool,
|
||||||
|
redis_url: str,
|
||||||
|
redis_prefix: str,
|
||||||
|
agent_md_templates_file: str,
|
||||||
|
topic_presets_templates_file: str,
|
||||||
|
default_soul_md: str,
|
||||||
|
default_agents_md: str,
|
||||||
|
default_user_md: str,
|
||||||
|
default_tools_md: str,
|
||||||
|
default_identity_md: str,
|
||||||
|
topic_preset_templates: Any,
|
||||||
|
get_default_system_timezone: Callable[[], str],
|
||||||
|
load_agent_md_templates: Callable[[], Dict[str, Any]],
|
||||||
|
load_topic_presets_template: Callable[[], Dict[str, Any]],
|
||||||
|
get_platform_settings_snapshot: Callable[[], Any],
|
||||||
|
get_speech_runtime_settings: Callable[[], Dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
self._engine = engine
|
||||||
|
self._cache = cache
|
||||||
|
self._database_engine = database_engine
|
||||||
|
self._redis_enabled = redis_enabled
|
||||||
|
self._redis_url = redis_url
|
||||||
|
self._redis_prefix = redis_prefix
|
||||||
|
self._agent_md_templates_file = agent_md_templates_file
|
||||||
|
self._topic_presets_templates_file = topic_presets_templates_file
|
||||||
|
self._default_soul_md = default_soul_md
|
||||||
|
self._default_agents_md = default_agents_md
|
||||||
|
self._default_user_md = default_user_md
|
||||||
|
self._default_tools_md = default_tools_md
|
||||||
|
self._default_identity_md = default_identity_md
|
||||||
|
self._topic_preset_templates = topic_preset_templates
|
||||||
|
self._get_default_system_timezone = get_default_system_timezone
|
||||||
|
self._load_agent_md_templates = load_agent_md_templates
|
||||||
|
self._load_topic_presets_template = load_topic_presets_template
|
||||||
|
self._get_platform_settings_snapshot = get_platform_settings_snapshot
|
||||||
|
self._get_speech_runtime_settings = get_speech_runtime_settings
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
tmp = f"{path}.tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as file:
|
||||||
|
json.dump(payload, file, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
def get_system_defaults(self) -> Dict[str, Any]:
|
||||||
|
md_templates = self._load_agent_md_templates()
|
||||||
|
topic_presets = self._load_topic_presets_template()
|
||||||
|
platform_settings = self._get_platform_settings_snapshot()
|
||||||
|
speech_settings = self._get_speech_runtime_settings()
|
||||||
|
return {
|
||||||
|
"templates": {
|
||||||
|
"soul_md": md_templates.get("soul_md") or self._default_soul_md,
|
||||||
|
"agents_md": md_templates.get("agents_md") or self._default_agents_md,
|
||||||
|
"user_md": md_templates.get("user_md") or self._default_user_md,
|
||||||
|
"tools_md": md_templates.get("tools_md") or self._default_tools_md,
|
||||||
|
"identity_md": md_templates.get("identity_md") or self._default_identity_md,
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"upload_max_mb": platform_settings.upload_max_mb,
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"download_extensions": list(platform_settings.workspace_download_extensions),
|
||||||
|
"allowed_attachment_extensions": list(platform_settings.allowed_attachment_extensions),
|
||||||
|
},
|
||||||
|
"bot": {
|
||||||
|
"system_timezone": self._get_default_system_timezone(),
|
||||||
|
},
|
||||||
|
"loading_page": platform_settings.loading_page.model_dump(),
|
||||||
|
"chat": {
|
||||||
|
"pull_page_size": platform_settings.chat_pull_page_size,
|
||||||
|
"page_size": platform_settings.page_size,
|
||||||
|
"command_auto_unlock_seconds": platform_settings.command_auto_unlock_seconds,
|
||||||
|
},
|
||||||
|
"topic_presets": topic_presets.get("presets") or self._topic_preset_templates,
|
||||||
|
"speech": {
|
||||||
|
"enabled": speech_settings["enabled"],
|
||||||
|
"model": speech_settings["model"],
|
||||||
|
"device": speech_settings["device"],
|
||||||
|
"max_audio_seconds": speech_settings["max_audio_seconds"],
|
||||||
|
"default_language": speech_settings["default_language"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_system_templates(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"agent_md_templates": self._load_agent_md_templates(),
|
||||||
|
"topic_presets": self._load_topic_presets_template(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_system_templates(self, *, payload: Any) -> Dict[str, Any]:
|
||||||
|
if payload.agent_md_templates is not None:
|
||||||
|
sanitized_agent: Dict[str, str] = {}
|
||||||
|
for key in ("agents_md", "soul_md", "user_md", "tools_md", "identity_md"):
|
||||||
|
sanitized_agent[key] = str(payload.agent_md_templates.get(key, "") or "").replace("\r\n", "\n")
|
||||||
|
self._write_json_atomic(str(self._agent_md_templates_file), sanitized_agent)
|
||||||
|
|
||||||
|
if payload.topic_presets is not None:
|
||||||
|
presets = payload.topic_presets.get("presets") if isinstance(payload.topic_presets, dict) else None
|
||||||
|
if presets is None:
|
||||||
|
normalized_topic: Dict[str, Any] = {"presets": []}
|
||||||
|
elif isinstance(presets, list):
|
||||||
|
normalized_topic = {"presets": [dict(row) for row in presets if isinstance(row, dict)]}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="topic_presets.presets must be an array")
|
||||||
|
self._write_json_atomic(str(self._topic_presets_templates_file), normalized_topic)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"agent_md_templates": self._load_agent_md_templates(),
|
||||||
|
"topic_presets": self._load_topic_presets_template(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_health(self) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with Session(self._engine) as session:
|
||||||
|
session.exec(select(BotInstance).limit(1)).first()
|
||||||
|
return {"status": "ok", "database": self._database_engine}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=f"database check failed: {exc}")
|
||||||
|
|
||||||
|
def get_cache_health(self) -> Dict[str, Any]:
|
||||||
|
redis_url = str(self._redis_url or "").strip()
|
||||||
|
configured = bool(self._redis_enabled and redis_url)
|
||||||
|
client_enabled = bool(getattr(self._cache, "enabled", False))
|
||||||
|
reachable = bool(self._cache.ping()) if client_enabled else False
|
||||||
|
status = "ok"
|
||||||
|
if configured and not reachable:
|
||||||
|
status = "degraded"
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"cache": {
|
||||||
|
"configured": configured,
|
||||||
|
"enabled": client_enabled,
|
||||||
|
"reachable": reachable,
|
||||||
|
"prefix": self._redis_prefix,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import Request, UploadFile
|
from fastapi import HTTPException, Request, UploadFile
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
from models.bot import BotInstance
|
from models.bot import BotInstance
|
||||||
from providers.selector import get_workspace_provider
|
from providers.selector import get_workspace_provider
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceService:
|
class WorkspaceService:
|
||||||
|
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
|
return bot
|
||||||
|
|
||||||
def list_tree(
|
def list_tree(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -66,3 +73,74 @@ class WorkspaceService:
|
||||||
public=public,
|
public=public,
|
||||||
redirect_html_to_raw=redirect_html_to_raw,
|
redirect_html_to_raw=redirect_html_to_raw,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list_tree_for_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app_state: Any,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
recursive: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.list_tree(app_state=app_state, bot=bot, path=path, recursive=recursive)
|
||||||
|
|
||||||
|
def read_file_for_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app_state: Any,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
path: str,
|
||||||
|
max_bytes: int = 200000,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.read_file(app_state=app_state, bot=bot, path=path, max_bytes=max_bytes)
|
||||||
|
|
||||||
|
def write_markdown_for_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app_state: Any,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
path: str,
|
||||||
|
content: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.write_markdown(app_state=app_state, bot=bot, path=path, content=content)
|
||||||
|
|
||||||
|
def serve_file_for_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app_state: Any,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
path: str,
|
||||||
|
download: bool,
|
||||||
|
request: Request,
|
||||||
|
public: bool = False,
|
||||||
|
redirect_html_to_raw: bool = False,
|
||||||
|
):
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return self.serve_file(
|
||||||
|
app_state=app_state,
|
||||||
|
bot=bot,
|
||||||
|
path=path,
|
||||||
|
download=download,
|
||||||
|
request=request,
|
||||||
|
public=public,
|
||||||
|
redirect_html_to_raw=redirect_html_to_raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upload_files_for_bot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app_state: Any,
|
||||||
|
session: Session,
|
||||||
|
bot_id: str,
|
||||||
|
files: List[UploadFile],
|
||||||
|
path: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
bot = self._require_bot(session=session, bot_id=bot_id)
|
||||||
|
return await self.upload_files(app_state=app_state, bot=bot, files=files, path=path)
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,23 @@ def write_workspace_markdown(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/edge/bots/{bot_id}/workspace/file/text")
|
||||||
|
def write_workspace_text(
|
||||||
|
bot_id: str,
|
||||||
|
path: str = Query(...),
|
||||||
|
payload: EdgeMarkdownWriteRequest = None,
|
||||||
|
workspace_root: str | None = None,
|
||||||
|
):
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(status_code=400, detail="text payload is required")
|
||||||
|
return workspace_service_module.edge_workspace_service.write_text_file(
|
||||||
|
bot_id=bot_id,
|
||||||
|
path=path,
|
||||||
|
content=payload.content,
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/edge/bots/{bot_id}/workspace/upload")
|
@router.post("/api/edge/bots/{bot_id}/workspace/upload")
|
||||||
async def upload_workspace_files(
|
async def upload_workspace_files(
|
||||||
bot_id: str,
|
bot_id: str,
|
||||||
|
|
@ -177,6 +194,19 @@ async def upload_workspace_files(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/edge/bots/{bot_id}/workspace/file")
|
||||||
|
def delete_workspace_path(
|
||||||
|
bot_id: str,
|
||||||
|
path: str = Query(...),
|
||||||
|
workspace_root: str | None = None,
|
||||||
|
):
|
||||||
|
return workspace_service_module.edge_workspace_service.delete_path(
|
||||||
|
bot_id=bot_id,
|
||||||
|
path=path,
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/edge/bots/{bot_id}/workspace/download")
|
@router.get("/api/edge/bots/{bot_id}/workspace/download")
|
||||||
def download_workspace_file(
|
def download_workspace_file(
|
||||||
bot_id: str,
|
bot_id: str,
|
||||||
|
|
|
||||||
|
|
@ -552,6 +552,12 @@ class EdgeDockerManager(EdgeRuntimeBackend):
|
||||||
if response_match:
|
if response_match:
|
||||||
channel = response_match.group(1).strip().lower()
|
channel = response_match.group(1).strip().lower()
|
||||||
action_msg = response_match.group(2).strip()
|
action_msg = response_match.group(2).strip()
|
||||||
|
if channel == "dashboard":
|
||||||
|
return {
|
||||||
|
"type": "ASSISTANT_MESSAGE",
|
||||||
|
"channel": "dashboard",
|
||||||
|
"text": action_msg[:4000],
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"type": "AGENT_STATE",
|
"type": "AGENT_STATE",
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
|
|
|
||||||
|
|
@ -748,6 +748,12 @@ class EdgeNativeRuntimeBackend(EdgeRuntimeBackend):
|
||||||
if response_match:
|
if response_match:
|
||||||
channel = response_match.group(1).strip().lower()
|
channel = response_match.group(1).strip().lower()
|
||||||
action_msg = response_match.group(2).strip()
|
action_msg = response_match.group(2).strip()
|
||||||
|
if channel == "dashboard":
|
||||||
|
return {
|
||||||
|
"type": "ASSISTANT_MESSAGE",
|
||||||
|
"channel": "dashboard",
|
||||||
|
"text": action_msg[:4000],
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"type": "AGENT_STATE",
|
"type": "AGENT_STATE",
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ class EdgeProvisionService:
|
||||||
"qwen": "dashscope",
|
"qwen": "dashscope",
|
||||||
"aliyun-qwen": "dashscope",
|
"aliyun-qwen": "dashscope",
|
||||||
"moonshot": "kimi",
|
"moonshot": "kimi",
|
||||||
"vllm": "openai",
|
|
||||||
"xunfei": "openai",
|
"xunfei": "openai",
|
||||||
"iflytek": "openai",
|
"iflytek": "openai",
|
||||||
"xfyun": "openai",
|
"xfyun": "openai",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,9 @@ class EdgeStateStoreService:
|
||||||
inferred_workspace_root = self._workspace_root_from_runtime_target(primary)
|
inferred_workspace_root = self._workspace_root_from_runtime_target(primary)
|
||||||
if inferred_workspace_root:
|
if inferred_workspace_root:
|
||||||
return os.path.abspath(os.path.join(inferred_workspace_root, bot_id, ".nanobot"))
|
return os.path.abspath(os.path.join(inferred_workspace_root, bot_id, ".nanobot"))
|
||||||
|
inferred_bot_root = self._bot_root_from_config(primary)
|
||||||
|
if inferred_bot_root:
|
||||||
|
return os.path.abspath(os.path.join(inferred_bot_root, ".nanobot"))
|
||||||
return primary
|
return primary
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -76,6 +79,33 @@ class EdgeStateStoreService:
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bot_root_from_config(primary_nanobot_root: str) -> str:
|
||||||
|
path = os.path.join(primary_nanobot_root, "config.json")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
payload = json.load(fh)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return ""
|
||||||
|
agents = payload.get("agents")
|
||||||
|
if not isinstance(agents, dict):
|
||||||
|
return ""
|
||||||
|
defaults = agents.get("defaults")
|
||||||
|
if not isinstance(defaults, dict):
|
||||||
|
return ""
|
||||||
|
workspace = str(defaults.get("workspace") or "").strip()
|
||||||
|
if not workspace:
|
||||||
|
return ""
|
||||||
|
normalized_workspace = os.path.abspath(os.path.expanduser(workspace))
|
||||||
|
suffix = os.path.join(".nanobot", "workspace")
|
||||||
|
if normalized_workspace.endswith(suffix):
|
||||||
|
return os.path.abspath(os.path.dirname(os.path.dirname(normalized_workspace)))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_state_key(cls, state_key: str) -> str:
|
def _normalize_state_key(cls, state_key: str) -> str:
|
||||||
normalized = str(state_key or "").strip().lower()
|
normalized = str(state_key or "").strip().lower()
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,31 @@ class EdgeWorkspaceService:
|
||||||
"content": str(content or ""),
|
"content": str(content or ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def write_text_file(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
path: str,
|
||||||
|
content: str,
|
||||||
|
workspace_root: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
root, target = self._resolve_workspace_path(bot_id, path, workspace_root=workspace_root)
|
||||||
|
encoded = str(content or "").encode("utf-8")
|
||||||
|
if len(encoded) > 2_000_000:
|
||||||
|
raise HTTPException(status_code=413, detail="text file too large to save")
|
||||||
|
if "\x00" in str(content or ""):
|
||||||
|
raise HTTPException(status_code=400, detail="text content contains invalid null bytes")
|
||||||
|
self._write_text_atomic(target, str(content or ""))
|
||||||
|
rel_path = os.path.relpath(target, root).replace("\\", "/")
|
||||||
|
return {
|
||||||
|
"bot_id": bot_id,
|
||||||
|
"path": rel_path,
|
||||||
|
"size": os.path.getsize(target),
|
||||||
|
"is_markdown": os.path.splitext(target)[1].lower() in {".md", ".markdown"},
|
||||||
|
"truncated": False,
|
||||||
|
"content": str(content or ""),
|
||||||
|
}
|
||||||
|
|
||||||
async def upload_files(
|
async def upload_files(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -181,6 +206,25 @@ class EdgeWorkspaceService:
|
||||||
|
|
||||||
return {"bot_id": bot_id, "files": rows}
|
return {"bot_id": bot_id, "files": rows}
|
||||||
|
|
||||||
|
def delete_path(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
bot_id: str,
|
||||||
|
path: str,
|
||||||
|
workspace_root: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
root, target = self._resolve_workspace_path(bot_id, path, workspace_root=workspace_root)
|
||||||
|
rel_path = os.path.relpath(target, root).replace("\\", "/")
|
||||||
|
existed = os.path.exists(target)
|
||||||
|
if existed:
|
||||||
|
if os.path.isdir(target):
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(target, ignore_errors=False)
|
||||||
|
else:
|
||||||
|
os.remove(target)
|
||||||
|
return {"bot_id": bot_id, "path": rel_path, "deleted": bool(existed)}
|
||||||
|
|
||||||
def serve_file(
|
def serve_file(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
- 引擎零侵入:不修改 nanobot 源码,仅通过 workspace 与容器管理接入。
|
- 引擎零侵入:不修改 nanobot 源码,仅通过 workspace 与容器管理接入。
|
||||||
- 镜像显式登记:系统不自动构建,不扫描 `engines/`,只使用 Docker 本地镜像 + DB 注册。
|
- 镜像显式登记:系统不自动构建,不扫描 `engines/`,只使用 Docker 本地镜像 + DB 注册。
|
||||||
- 可观测性优先:通过容器日志流解析状态并推送到 WebSocket。
|
- 可观测性优先:通过容器日志流解析状态并推送到 WebSocket。
|
||||||
|
- 代码结构治理纳入正式架构约束;后续前后端拆分与目录边界以 `design/code-structure-standards.md` 为准。
|
||||||
|
|
||||||
## 2. 核心组件
|
## 2. 核心组件
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,358 @@
|
||||||
|
# Dashboard Nanobot 代码结构规范(强制执行)
|
||||||
|
|
||||||
|
本文档定义后续前端、后端、`dashboard-edge` 的结构边界与拆分规则。
|
||||||
|
|
||||||
|
目标不是“尽可能多拆文件”,而是:
|
||||||
|
|
||||||
|
- 保持装配层足够薄
|
||||||
|
- 保持业务边界清晰
|
||||||
|
- 避免再次出现单文件多职责膨胀
|
||||||
|
- 让后续迭代继续走低风险、小步验证路线
|
||||||
|
|
||||||
|
本文档自落地起作为**后续开发强制规范**执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总原则
|
||||||
|
|
||||||
|
### 1.1 先分层,再分文件
|
||||||
|
|
||||||
|
- 优先先把“页面装配 / 业务编排 / 基础设施 / 纯视图”分开,再决定是否继续拆文件。
|
||||||
|
- 不允许为了“看起来模块化”而把强耦合逻辑拆成大量碎文件。
|
||||||
|
- 允许保留中等体量的“单主题控制器”文件,但不允许继续把多个主题堆进一个文件。
|
||||||
|
|
||||||
|
### 1.2 低风险重构优先
|
||||||
|
|
||||||
|
- 结构重构优先做“搬运与收口”,不顺手修改业务行为。
|
||||||
|
- 同一轮改动里,默认**不要**同时做:
|
||||||
|
- 大规模结构调整
|
||||||
|
- 新功能
|
||||||
|
- 行为修复
|
||||||
|
- 如果确实需要行为修复,只允许修复拆分直接引入的问题。
|
||||||
|
|
||||||
|
### 1.3 装配层必须薄
|
||||||
|
|
||||||
|
- 页面层、路由层、应用启动层都只负责装配。
|
||||||
|
- 装配层可以做依赖注入、状态接线、事件转发。
|
||||||
|
- 装配层不允许承载复杂业务判断、持久化细节、长流程编排。
|
||||||
|
|
||||||
|
### 1.4 新文件必须按主题命名
|
||||||
|
|
||||||
|
- 文件名必须直接表达职责。
|
||||||
|
- 禁止模糊命名,例如:
|
||||||
|
- `helpers2.py`
|
||||||
|
- `misc.ts`
|
||||||
|
- `commonPage.tsx`
|
||||||
|
- `temp_service.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 前端结构规范
|
||||||
|
|
||||||
|
### 2.1 目录分层
|
||||||
|
|
||||||
|
前端统一按以下层次组织:
|
||||||
|
|
||||||
|
- `frontend/src/app`
|
||||||
|
- 应用壳、全局路由视图、全局初始化
|
||||||
|
- `frontend/src/modules/<domain>`
|
||||||
|
- 领域模块入口
|
||||||
|
- `frontend/src/modules/<domain>/components`
|
||||||
|
- 纯视图组件、弹层、区块组件
|
||||||
|
- `frontend/src/modules/<domain>/hooks`
|
||||||
|
- 领域内控制器 hook、状态编排 hook
|
||||||
|
- `frontend/src/modules/<domain>/api`
|
||||||
|
- 仅该领域使用的 API 请求封装
|
||||||
|
- `frontend/src/modules/<domain>/shared`
|
||||||
|
- 领域内共享的纯函数、常量、类型桥接
|
||||||
|
- `frontend/src/components`
|
||||||
|
- 跨模块通用 UI 组件
|
||||||
|
- `frontend/src/utils`
|
||||||
|
- 真正跨领域的通用工具
|
||||||
|
|
||||||
|
### 2.2 页面文件职责
|
||||||
|
|
||||||
|
页面文件如:
|
||||||
|
|
||||||
|
- `frontend/src/modules/platform/PlatformDashboardPage.tsx`
|
||||||
|
- `frontend/src/modules/platform/NodeWorkspacePage.tsx`
|
||||||
|
- `frontend/src/modules/platform/NodeHomePage.tsx`
|
||||||
|
|
||||||
|
必须遵守:
|
||||||
|
|
||||||
|
- 只做页面装配
|
||||||
|
- 只组织已有区块、弹层、控制器 hook
|
||||||
|
- 不直接承载长段 API 请求、副作用、数据清洗逻辑
|
||||||
|
|
||||||
|
页面文件目标体量:
|
||||||
|
|
||||||
|
- 目标:`< 250` 行
|
||||||
|
- 可接受上限:`350` 行
|
||||||
|
- 超过 `350` 行必须优先拆出页面控制器 hook 或区块装配组件
|
||||||
|
|
||||||
|
### 2.3 控制器 hook 规范
|
||||||
|
|
||||||
|
控制器 hook 用于承载:
|
||||||
|
|
||||||
|
- 页面状态
|
||||||
|
- 副作用
|
||||||
|
- API 调用编排
|
||||||
|
- 事件处理
|
||||||
|
- 派生数据
|
||||||
|
|
||||||
|
典型命名:
|
||||||
|
|
||||||
|
- `useNodeHomePage`
|
||||||
|
- `useNodeWorkspacePage`
|
||||||
|
- `usePlatformDashboardPage`
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 一个 hook 只服务一个明确页面或一个明确子流程
|
||||||
|
- hook 不直接产出大量 JSX
|
||||||
|
- hook 内部允许组合更小的子 hook,但不要为了拆分而拆分
|
||||||
|
|
||||||
|
控制器 hook 目标体量:
|
||||||
|
|
||||||
|
- 目标:`< 350` 行
|
||||||
|
- 可接受上限:`500` 行
|
||||||
|
- 超过 `500` 行时,必须再按主题拆成子 hook 或把重复逻辑提到 `shared`/`api`
|
||||||
|
|
||||||
|
### 2.4 视图组件规范
|
||||||
|
|
||||||
|
组件分为两类:
|
||||||
|
|
||||||
|
- 区块组件:例如列表区、详情区、摘要卡片区
|
||||||
|
- 弹层组件:例如 Drawer、Modal、Sheet
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 视图组件默认不直接请求接口
|
||||||
|
- 视图组件只接收已经整理好的 props
|
||||||
|
- 纯视图组件内部不保留与页面强耦合的业务缓存
|
||||||
|
|
||||||
|
### 2.5 前端复用原则
|
||||||
|
|
||||||
|
- 优先提炼“稳定复用的模式”,不要提炼“碰巧重复一次的代码”
|
||||||
|
- 三处以上重复,优先考虑抽取
|
||||||
|
- 同域复用优先放 `modules/<domain>/shared`
|
||||||
|
- 跨域复用优先放 `src/components` 或 `src/utils`
|
||||||
|
|
||||||
|
### 2.6 前端禁止事项
|
||||||
|
|
||||||
|
- 禁止再次把页面做成“一个文件管状态、接口、弹层、列表、详情、搜索、分页”
|
||||||
|
- 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件
|
||||||
|
- 禁止创建无明确职责的超通用组件
|
||||||
|
- 禁止为减少行数而做不可读的过度抽象
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 后端结构规范
|
||||||
|
|
||||||
|
### 3.1 目录分层
|
||||||
|
|
||||||
|
后端统一按以下边界组织:
|
||||||
|
|
||||||
|
- `backend/main.py`
|
||||||
|
- 仅启动入口
|
||||||
|
- `backend/app_factory.py`
|
||||||
|
- 应用实例创建
|
||||||
|
- `backend/bootstrap`
|
||||||
|
- 依赖装配、应用初始化、生命周期拼装
|
||||||
|
- `backend/api`
|
||||||
|
- FastAPI 路由层
|
||||||
|
- `backend/services`
|
||||||
|
- 业务用例与领域服务
|
||||||
|
- `backend/core`
|
||||||
|
- 数据库、缓存、配置、基础设施适配
|
||||||
|
- `backend/models`
|
||||||
|
- ORM 模型
|
||||||
|
- `backend/schemas`
|
||||||
|
- 请求/响应 DTO
|
||||||
|
- `backend/providers`
|
||||||
|
- runtime/workspace/provision 适配层
|
||||||
|
|
||||||
|
### 3.2 启动与装配层规范
|
||||||
|
|
||||||
|
以下文件必须保持装配层属性:
|
||||||
|
|
||||||
|
- `backend/main.py`
|
||||||
|
- `backend/app_factory.py`
|
||||||
|
- `backend/bootstrap/app_runtime.py`
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 只做依赖创建、注入、路由注册、生命周期绑定
|
||||||
|
- 不写业务 SQL
|
||||||
|
- 不写领域规则判断
|
||||||
|
- 不写长流程编排
|
||||||
|
|
||||||
|
### 3.3 Router 规范
|
||||||
|
|
||||||
|
`backend/api/*.py` 只允许承担:
|
||||||
|
|
||||||
|
- HTTP 参数接收
|
||||||
|
- schema 校验
|
||||||
|
- 调用 service
|
||||||
|
- 把领域异常转换成 HTTP 异常
|
||||||
|
|
||||||
|
Router 不允许承担:
|
||||||
|
|
||||||
|
- 多步业务编排
|
||||||
|
- 大量数据聚合
|
||||||
|
- 数据库表间拼装
|
||||||
|
- 本地文件系统读写细节
|
||||||
|
|
||||||
|
Router 文件体量规则:
|
||||||
|
|
||||||
|
- 目标:`< 300` 行
|
||||||
|
- 可接受上限:`400` 行
|
||||||
|
- 超过 `400` 行必须拆成子 router,并由装配层统一 `include_router`
|
||||||
|
|
||||||
|
### 3.4 Service 规范
|
||||||
|
|
||||||
|
Service 必须按业务主题拆分。
|
||||||
|
|
||||||
|
允许的 service 类型:
|
||||||
|
|
||||||
|
- `*_settings_service.py`
|
||||||
|
- `*_usage_service.py`
|
||||||
|
- `*_activity_service.py`
|
||||||
|
- `*_analytics_service.py`
|
||||||
|
- `*_overview_service.py`
|
||||||
|
- `*_query_service.py`
|
||||||
|
- `*_command_service.py`
|
||||||
|
- `*_lifecycle_service.py`
|
||||||
|
|
||||||
|
Service 文件规则:
|
||||||
|
|
||||||
|
- 一个文件只负责一个主题
|
||||||
|
- 同一文件内允许有私有 helper,但 helper 只能服务当前主题
|
||||||
|
- 如果一个主题明显包含“读模型 + 写模型 + 统计 + 配置”,应继续拆为多个 service
|
||||||
|
|
||||||
|
Service 体量规则:
|
||||||
|
|
||||||
|
- 目标:`< 350` 行
|
||||||
|
- 可接受上限:`500` 行
|
||||||
|
- 超过 `500` 行必须继续拆
|
||||||
|
|
||||||
|
### 3.5 当前 platform 域的标准拆法
|
||||||
|
|
||||||
|
`platform` 域后续固定按如下职责组织:
|
||||||
|
|
||||||
|
- `backend/api/platform_router.py`
|
||||||
|
- 仅负责平台路由总装配
|
||||||
|
- `backend/api/platform_admin_router.py`
|
||||||
|
- 仅负责平台后台管理路由装配
|
||||||
|
- `backend/api/platform_overview_router.py`
|
||||||
|
- 平台概览、统计、事件、usage、缓存刷新
|
||||||
|
- `backend/api/platform_settings_router.py`
|
||||||
|
- 平台设置、system settings
|
||||||
|
- `backend/api/platform_nodes_router.py`
|
||||||
|
- 节点相关路由总装配
|
||||||
|
- `backend/api/platform_node_catalog_router.py`
|
||||||
|
- 节点列表、增删改、连通性测试
|
||||||
|
- `backend/api/platform_node_probe_router.py`
|
||||||
|
- 节点探测、心跳、自检类接口
|
||||||
|
- `backend/api/platform_node_resource_router.py`
|
||||||
|
- 节点资源、工作区、运行态资源接口
|
||||||
|
- `backend/services/platform_settings_service.py`
|
||||||
|
- 平台设置与系统设置
|
||||||
|
- `backend/services/platform_usage_service.py`
|
||||||
|
- request usage 记账与查询
|
||||||
|
- `backend/services/platform_activity_service.py`
|
||||||
|
- activity event 记录与清理
|
||||||
|
- `backend/services/platform_analytics_service.py`
|
||||||
|
- dashboard analytics 聚合
|
||||||
|
- `backend/services/platform_overview_service.py`
|
||||||
|
- platform / node overview 聚合
|
||||||
|
- `backend/services/platform_common.py`
|
||||||
|
- 仅放当前 platform 域内部共享的纯工具
|
||||||
|
|
||||||
|
`backend/services/platform_service.py` 只允许保留为**薄兼容导出层**,不得再新增业务逻辑。
|
||||||
|
|
||||||
|
`backend/api/platform_admin_router.py` 与 `backend/api/platform_nodes_router.py` 只允许继续承担子路由装配职责,不得重新回填具体业务接口实现。
|
||||||
|
|
||||||
|
### 3.6 Schema 规范
|
||||||
|
|
||||||
|
- `schemas` 只定义 DTO
|
||||||
|
- 不允许在 schema 中直接读数据库、读文件、发网络请求
|
||||||
|
- schema 字段演进必须保持前后端契约可追踪
|
||||||
|
|
||||||
|
### 3.7 Core 规范
|
||||||
|
|
||||||
|
`core` 只允许放:
|
||||||
|
|
||||||
|
- 数据库与 Session 管理
|
||||||
|
- 缓存
|
||||||
|
- 配置
|
||||||
|
- 基础设施适配器
|
||||||
|
|
||||||
|
不允许把领域业务塞回 `core` 来“躲避 service 变大”。
|
||||||
|
|
||||||
|
### 3.8 Provider 规范
|
||||||
|
|
||||||
|
`providers` 只处理运行时/工作区/部署目标差异。
|
||||||
|
|
||||||
|
不允许把平台业务逻辑塞进 provider。
|
||||||
|
|
||||||
|
### 3.9 dashboard-edge 规范
|
||||||
|
|
||||||
|
`dashboard-edge` 按与主后端相同的规则执行:
|
||||||
|
|
||||||
|
- `app/main.py` 仅启动
|
||||||
|
- `app/api/router.py` 仅路由
|
||||||
|
- `app/services` 仅业务编排
|
||||||
|
- `app/runtime` 仅 runtime 适配
|
||||||
|
|
||||||
|
`dashboard-edge/app/runtime/docker_manager.py` 与 `dashboard-edge/app/runtime/native_manager.py`
|
||||||
|
后续必须按以下方向拆分:
|
||||||
|
|
||||||
|
- 生命周期控制
|
||||||
|
- 资源采样
|
||||||
|
- preflight / 诊断
|
||||||
|
- workspace / 文件交互
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 本项目后续开发的执行规则
|
||||||
|
|
||||||
|
### 4.1 每轮改动的默认顺序
|
||||||
|
|
||||||
|
1. 先审计职责边界
|
||||||
|
2. 先做装配层变薄
|
||||||
|
3. 再提炼稳定复用块
|
||||||
|
4. 最后再考虑继续细拆
|
||||||
|
|
||||||
|
### 4.2 校验规则
|
||||||
|
|
||||||
|
- 前端结构改动后,默认执行 `frontend` 构建校验
|
||||||
|
- 后端结构改动后,默认至少执行 `python3 -m py_compile`
|
||||||
|
- 如果改动触达运行时或边界协议,再考虑追加更高层验证
|
||||||
|
|
||||||
|
### 4.3 文档同步规则
|
||||||
|
|
||||||
|
以下情况必须同步设计文档:
|
||||||
|
|
||||||
|
- 新增一层目录边界
|
||||||
|
- 新增一个领域的标准拆法
|
||||||
|
- 改变页面/服务的职责划分
|
||||||
|
- 把兼容层正式降级为装配/导出层
|
||||||
|
|
||||||
|
### 4.4 禁止事项
|
||||||
|
|
||||||
|
- 禁止回到“大文件集中堆功能”的开发方式
|
||||||
|
- 禁止为了图省事把新逻辑加回兼容层
|
||||||
|
- 禁止在没有明确复用收益时过度抽象
|
||||||
|
- 禁止在一次改动里同时重写 UI、重写数据流、重写接口协议
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 当前执行基线(2026-03)
|
||||||
|
|
||||||
|
当前结构治理目标分两层:
|
||||||
|
|
||||||
|
- 第一层:主入口、页面入口、路由入口必须变薄
|
||||||
|
- 第二层:领域内部的 service / hook / overlays / sections 必须按主题稳定收口
|
||||||
|
|
||||||
|
后续所有新增功能与重构,均以本文档为准执行。
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
本文档用于指导当前项目的结构性重构,并为后续“支持同机/远端龙虾 + Docker/Native 双运行模式”升级提前抽离边界。
|
本文档用于指导当前项目的结构性重构,并为后续“支持同机/远端龙虾 + Docker/Native 双运行模式”升级提前抽离边界。
|
||||||
|
|
||||||
|
补充约束:本路线图负责说明“为什么拆、先拆什么”;具体“怎么拆、拆到什么边界”为强制执行项,统一以下文为准:
|
||||||
|
|
||||||
|
- `design/code-structure-standards.md`
|
||||||
|
|
||||||
目标不是一次性大改所有代码,而是先把未来 2 个核心问题理顺:
|
目标不是一次性大改所有代码,而是先把未来 2 个核心问题理顺:
|
||||||
|
|
||||||
- 当前前端/后端过于集中,后续功能迭代成本越来越高
|
- 当前前端/后端过于集中,后续功能迭代成本越来越高
|
||||||
|
|
|
||||||
3183
frontend/src/App.css
3183
frontend/src/App.css
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,530 @@
|
||||||
|
.mobile-user-shell {
|
||||||
|
padding: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in oklab, var(--bg) 96%, white 4%) 0%, color-mix(in oklab, var(--bg) 88%, var(--panel-soft) 12%) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell.mobile-user-shell {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-header {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 4px max(8px, env(safe-area-inset-right)) 4px max(8px, env(safe-area-inset-left));
|
||||||
|
border-bottom: 1px solid color-mix(in oklab, var(--line) 72%, transparent);
|
||||||
|
background: color-mix(in oklab, var(--panel) 96%, transparent);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-brand-logo {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-brand strong {
|
||||||
|
color: var(--title);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-header-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-header-switches {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-header-pill {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 78%, transparent);
|
||||||
|
color: var(--title);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-header-pill-text {
|
||||||
|
width: auto;
|
||||||
|
min-width: 52px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-header-bot-switcher {
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-header-action,
|
||||||
|
.mobile-user-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 78%, transparent);
|
||||||
|
color: var(--title);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-avatar {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 3px max(6px, env(safe-area-inset-right)) 0 max(6px, env(safe-area-inset-left));
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content > * {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .chat-workspace-page,
|
||||||
|
.mobile-user-content .chat-workspace-main,
|
||||||
|
.mobile-user-content .chat-workspace-main-attached,
|
||||||
|
.mobile-user-content .grid-ops.grid-ops-compact,
|
||||||
|
.mobile-user-content .ops-chat-panel,
|
||||||
|
.mobile-user-content .ops-chat-shell,
|
||||||
|
.mobile-user-content .ops-main-content-shell,
|
||||||
|
.mobile-user-content .ops-main-content-frame,
|
||||||
|
.mobile-user-content .ops-main-content-body,
|
||||||
|
.mobile-user-content .ops-chat-frame,
|
||||||
|
.mobile-user-content .ops-runtime-panel,
|
||||||
|
.mobile-user-content .ops-runtime-shell {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-chat-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .platform-page-stack,
|
||||||
|
.mobile-user-content .platform-template-page {
|
||||||
|
min-height: 100%;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .platform-page-stack > .panel,
|
||||||
|
.mobile-user-content > .platform-template-page,
|
||||||
|
.mobile-user-content .ops-chat-panel.panel,
|
||||||
|
.mobile-user-content .ops-runtime-panel.panel {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
padding-top: 5px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .page-section-head {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .platform-settings-toolbar,
|
||||||
|
.mobile-user-content .platform-profile-grid,
|
||||||
|
.mobile-user-content .platform-profile-meta,
|
||||||
|
.mobile-user-content .platform-profile-actions,
|
||||||
|
.mobile-user-content .platform-settings-actions,
|
||||||
|
.mobile-user-content .platform-settings-pager {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .platform-settings-info-card {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .platform-profile-meta-row {
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .platform-profile-actions {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-chat-panel,
|
||||||
|
.mobile-user-content .ops-runtime-panel {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-main-content-frame {
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 52%, transparent);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-main-content-head {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 3px 4px 3px;
|
||||||
|
border-bottom: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-main-mode-rail {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-main-content-body .ops-chat-scroll {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-main-content-body .ops-chat-dock,
|
||||||
|
.mobile-user-content .ops-main-content-body .ops-topic-feed.is-panel {
|
||||||
|
padding-left: 4px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-main-content-body .ops-chat-dock {
|
||||||
|
padding-top: 1px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-main-content-body .ops-topic-feed.is-panel {
|
||||||
|
padding-top: 3px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-topic-feed-list.is-panel {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-chat-item {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-chat-row {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-chat-date-divider {
|
||||||
|
margin: 2px 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-chat-row.is-user .ops-avatar.user {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-composer {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-chat-bubble {
|
||||||
|
max-width: calc(100% - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-thinking-bubble {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: calc(100% - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-runtime-shell {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-runtime-head {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .ops-runtime-scroll {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-content .workspace-panel {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-bottom-nav {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
padding:
|
||||||
|
3px
|
||||||
|
max(6px, env(safe-area-inset-right))
|
||||||
|
calc(3px + env(safe-area-inset-bottom))
|
||||||
|
max(6px, env(safe-area-inset-left));
|
||||||
|
border-top: 1px solid color-mix(in oklab, var(--line) 72%, transparent);
|
||||||
|
background: color-mix(in oklab, var(--panel) 97%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-bottom-item-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-trigger {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 76%, transparent);
|
||||||
|
color: var(--title);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-trigger-avatar,
|
||||||
|
.bot-switcher-item-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in oklab, var(--panel) 82%, white 18%);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-trigger.is-running {
|
||||||
|
background: linear-gradient(145deg, color-mix(in oklab, var(--ok) 14%, var(--panel-soft) 86%), color-mix(in oklab, var(--ok) 8%, var(--panel) 92%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-trigger.is-stopped {
|
||||||
|
background: linear-gradient(145deg, color-mix(in oklab, #b79aa2 14%, var(--panel-soft) 86%), color-mix(in oklab, #b79aa2 7%, var(--panel) 93%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-trigger.is-disabled {
|
||||||
|
background: linear-gradient(145deg, color-mix(in oklab, #9ca3b5 14%, var(--panel-soft) 86%), color-mix(in oklab, #9ca3b5 7%, var(--panel) 93%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-trigger.is-open {
|
||||||
|
border-color: color-mix(in oklab, var(--brand) 42%, var(--line) 58%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(3, 9, 20, 0.46);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-portal-shell {
|
||||||
|
padding: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-popover {
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
max-height: min(520px, calc(100vh - 48px));
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: color-mix(in oklab, var(--panel) 98%, transparent);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-popover-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-popover-head strong {
|
||||||
|
color: var(--title);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-popover-list {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 72%, transparent);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-item.is-running {
|
||||||
|
background: linear-gradient(145deg, color-mix(in oklab, var(--ok) 14%, var(--panel-soft) 86%), color-mix(in oklab, var(--ok) 8%, var(--panel) 92%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-item.is-stopped {
|
||||||
|
background: linear-gradient(145deg, color-mix(in oklab, #b79aa2 14%, var(--panel-soft) 86%), color-mix(in oklab, #b79aa2 7%, var(--panel) 93%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-item.is-disabled {
|
||||||
|
background: linear-gradient(145deg, color-mix(in oklab, #9ca3b5 14%, var(--panel-soft) 86%), color-mix(in oklab, #9ca3b5 7%, var(--panel) 93%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-item.is-active {
|
||||||
|
border-color: color-mix(in oklab, var(--brand) 42%, var(--line) 58%);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--brand) 52%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-item-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-item-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-item-copy strong {
|
||||||
|
color: var(--title);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-switcher-item-copy span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-topbar-bot-switcher {
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-topbar-bot-switcher .bot-switcher-trigger {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-bottom-item {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 52px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
align-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-bottom-item.is-active {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-settings-popover {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
min-width: 190px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: color-mix(in oklab, var(--panel) 98%, transparent);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-settings-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-settings-item:hover {
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 52%, transparent);
|
||||||
|
border-color: color-mix(in oklab, var(--brand) 28%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-settings-item.danger {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
@ -1,446 +1,205 @@
|
||||||
import { useEffect, useState, type ReactElement } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { ChevronDown, ChevronUp, MoonStar, SunMedium } from 'lucide-react';
|
|
||||||
import { useAppStore } from './store/appStore';
|
|
||||||
import { useBotsSync } from './hooks/useBotsSync';
|
|
||||||
import { APP_ENDPOINTS } from './config/env';
|
import { APP_ENDPOINTS } from './config/env';
|
||||||
import { pickLocale } from './i18n';
|
import { useAppStore } from './store/appStore';
|
||||||
import { appZhCn } from './i18n/app.zh-cn';
|
import { clearSessionToken, getSessionToken, SESSION_AUTH_REQUIRED_EVENT, setSessionToken } from './utils/sessionAuth';
|
||||||
import { appEn } from './i18n/app.en';
|
import { readCompactModeFromUrl, useAppRoute, type AppRoute } from './utils/appRoute';
|
||||||
import { LucentTooltip } from './components/lucent/LucentTooltip';
|
import type { SysAuthBootstrap } from './types/sys';
|
||||||
import { PasswordInput } from './components/PasswordInput';
|
import { DashboardLogin } from './app/AppChrome';
|
||||||
import { clearBotAccessPassword, getBotAccessPassword, setBotAccessPassword } from './utils/botAccess';
|
import { AuthenticatedDashboardApp } from './app/AppShellViews';
|
||||||
import {
|
import { getRouteMeta, navigateTo } from './app/appRouteMeta';
|
||||||
PANEL_AUTH_REQUIRED_EVENT,
|
|
||||||
clearPanelAccessPassword,
|
|
||||||
getPanelAccessPassword,
|
|
||||||
setPanelAccessPassword,
|
|
||||||
} from './utils/panelAccess';
|
|
||||||
import { BotHomePage } from './modules/bot-home/BotHomePage';
|
|
||||||
import { NodeHomePage } from './modules/platform/NodeHomePage';
|
|
||||||
import { NodeWorkspacePage } from './modules/platform/NodeWorkspacePage';
|
|
||||||
import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal';
|
|
||||||
import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute';
|
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import './App.mobile.css';
|
||||||
|
|
||||||
const defaultLoadingPage = {
|
function SessionShell({ route }: { route: AppRoute }) {
|
||||||
title: 'Dashboard Nanobot',
|
const { theme, locale } = useAppStore();
|
||||||
subtitle: '平台正在准备管理面板',
|
const isZh = locale === 'zh';
|
||||||
description: '请稍候,正在加载 Bot 平台数据。',
|
|
||||||
};
|
|
||||||
|
|
||||||
function AuthenticatedApp() {
|
|
||||||
const route = useAppRoute();
|
|
||||||
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
|
|
||||||
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
|
||||||
const [viewportCompact, setViewportCompact] = useState(() => {
|
const [viewportCompact, setViewportCompact] = useState(() => {
|
||||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||||
return window.matchMedia('(max-width: 980px)').matches;
|
return readCompactModeFromUrl() || window.matchMedia('(max-width: 980px)').matches;
|
||||||
});
|
});
|
||||||
const [headerCollapsed, setHeaderCollapsed] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [singleBotPassword, setSingleBotPassword] = useState('');
|
const [checking, setChecking] = useState(true);
|
||||||
const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
|
const [authBootstrap, setAuthBootstrap] = useState<SysAuthBootstrap | null>(null);
|
||||||
const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
|
const [username, setUsername] = useState('');
|
||||||
const passwordToggleLabels = locale === 'zh'
|
const [password, setPassword] = useState('');
|
||||||
? { show: '显示密码', hide: '隐藏密码' }
|
const [defaultUsername, setDefaultUsername] = useState('admin');
|
||||||
: { show: 'Show password', hide: 'Hide password' };
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const forcedBotId = route.kind === 'bot' ? route.botId : '';
|
|
||||||
const forcedNodeId = route.kind === 'dashboard-node' ? route.nodeId : '';
|
|
||||||
useBotsSync(forcedBotId || undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||||
const media = window.matchMedia('(max-width: 980px)');
|
const media = window.matchMedia('(max-width: 980px)');
|
||||||
const apply = () => setViewportCompact(media.matches);
|
const apply = () => setViewportCompact(readCompactModeFromUrl() || media.matches);
|
||||||
apply();
|
apply();
|
||||||
media.addEventListener('change', apply);
|
media.addEventListener('change', apply);
|
||||||
return () => media.removeEventListener('change', apply);
|
return () => media.removeEventListener('change', apply);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHeaderCollapsed(readCompactModeFromUrl() || viewportCompact);
|
|
||||||
}, [viewportCompact, route.kind, forcedBotId]);
|
|
||||||
|
|
||||||
const compactMode = readCompactModeFromUrl() || viewportCompact;
|
|
||||||
const isCompactShell = compactMode;
|
|
||||||
const hideHeader = route.kind === 'dashboard' && compactMode;
|
|
||||||
const forcedBot = forcedBotId ? activeBots[forcedBotId] : undefined;
|
|
||||||
const shouldPromptSingleBotPassword = Boolean(
|
|
||||||
route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked,
|
|
||||||
);
|
|
||||||
const headerTitle =
|
|
||||||
route.kind === 'bot'
|
|
||||||
? (forcedBot?.name || defaultLoadingPage.title)
|
|
||||||
: route.kind === 'dashboard-node'
|
|
||||||
? `${t.nodeWorkspace} · ${forcedNodeId || 'local'}`
|
|
||||||
: route.kind === 'dashboard-skills'
|
|
||||||
? t.skillMarketplace
|
|
||||||
: t.title;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (route.kind === 'dashboard') {
|
|
||||||
document.title = t.title;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (route.kind === 'dashboard-skills') {
|
|
||||||
document.title = `${t.title} - ${t.skillMarketplace}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (route.kind === 'dashboard-node') {
|
|
||||||
document.title = `${t.title} - ${t.nodeWorkspace} - ${forcedNodeId || 'local'}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const botName = String(forcedBot?.name || '').trim();
|
|
||||||
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`;
|
|
||||||
}, [forcedBot?.name, forcedBotId, forcedNodeId, route.kind, t.nodeWorkspace, t.skillMarketplace, t.title]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSingleBotUnlocked(false);
|
|
||||||
setSingleBotPassword('');
|
|
||||||
setSingleBotPasswordError('');
|
|
||||||
}, [forcedBotId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return;
|
|
||||||
const stored = getBotAccessPassword(forcedBotId);
|
|
||||||
if (!stored) return;
|
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
let retryTimer: number | null = null;
|
||||||
const boot = async () => {
|
const boot = async () => {
|
||||||
|
let keepChecking = false;
|
||||||
|
setChecking(true);
|
||||||
try {
|
try {
|
||||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: stored });
|
const status = await axios.get<{ default_username?: string }>(`${APP_ENDPOINTS.apiBase}/sys/auth/status`);
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setBotAccessPassword(forcedBotId, stored);
|
setDefaultUsername(String(status.data?.default_username || 'admin'));
|
||||||
setSingleBotUnlocked(true);
|
const token = getSessionToken();
|
||||||
setSingleBotPassword('');
|
if (!token) {
|
||||||
setSingleBotPasswordError('');
|
setChecking(false);
|
||||||
} catch {
|
return;
|
||||||
clearBotAccessPassword(forcedBotId);
|
}
|
||||||
|
const me = await axios.get<SysAuthBootstrap>(`${APP_ENDPOINTS.apiBase}/sys/auth/me`);
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.');
|
setAuthBootstrap({ ...me.data, token });
|
||||||
|
setError('');
|
||||||
|
} catch (error: any) {
|
||||||
|
const status = Number(error?.response?.status || 0);
|
||||||
|
const hasToken = Boolean(getSessionToken());
|
||||||
|
if (status === 401) {
|
||||||
|
clearSessionToken();
|
||||||
|
if (!alive) return;
|
||||||
|
setAuthBootstrap(null);
|
||||||
|
} else if (alive && hasToken) {
|
||||||
|
keepChecking = true;
|
||||||
|
if (retryTimer !== null) {
|
||||||
|
window.clearTimeout(retryTimer);
|
||||||
|
}
|
||||||
|
retryTimer = window.setTimeout(() => {
|
||||||
|
retryTimer = null;
|
||||||
|
void boot();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (alive) setChecking(keepChecking);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void boot();
|
void boot();
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
if (retryTimer !== null) {
|
||||||
}, [forcedBot?.has_access_password, forcedBotId, locale, route.kind, singleBotUnlocked]);
|
window.clearTimeout(retryTimer);
|
||||||
|
|
||||||
const unlockSingleBot = async () => {
|
|
||||||
const entered = String(singleBotPassword || '').trim();
|
|
||||||
if (!entered || route.kind !== 'bot' || !forcedBotId) {
|
|
||||||
setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSingleBotSubmitting(true);
|
|
||||||
try {
|
|
||||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: entered });
|
|
||||||
setBotAccessPassword(forcedBotId, entered);
|
|
||||||
setSingleBotPasswordError('');
|
|
||||||
setSingleBotUnlocked(true);
|
|
||||||
setSingleBotPassword('');
|
|
||||||
} catch {
|
|
||||||
clearBotAccessPassword(forcedBotId);
|
|
||||||
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSingleBotSubmitting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const navigateToDashboard = () => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
window.history.pushState({}, '', '/dashboard');
|
const onAuthRequired = (event: Event) => {
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
|
|
||||||
<div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}>
|
|
||||||
{!hideHeader ? (
|
|
||||||
<header
|
|
||||||
className={`app-header ${isCompactShell ? 'app-header-collapsible' : ''} ${isCompactShell && headerCollapsed ? 'is-collapsed' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (isCompactShell && headerCollapsed) setHeaderCollapsed(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="row-between app-header-top">
|
|
||||||
<div className="app-title">
|
|
||||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
|
|
||||||
<div className="app-title-main">
|
|
||||||
<h1>{headerTitle}</h1>
|
|
||||||
{route.kind === 'dashboard-skills' ? (
|
|
||||||
<button type="button" className="app-route-subtitle app-route-crumb" onClick={navigateToDashboard}>
|
|
||||||
{t.platformHome}
|
|
||||||
</button>
|
|
||||||
) : route.kind === 'dashboard-node' ? (
|
|
||||||
<button type="button" className="app-route-subtitle app-route-crumb" onClick={navigateToDashboard}>
|
|
||||||
{t.platformHome}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="app-route-subtitle">
|
|
||||||
{route.kind === 'dashboard'
|
|
||||||
? t.platformHome
|
|
||||||
: t.botHome}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isCompactShell ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="app-header-toggle-inline"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
setHeaderCollapsed((value) => !value);
|
|
||||||
}}
|
|
||||||
title={headerCollapsed ? t.expandHeader : t.collapseHeader}
|
|
||||||
aria-label={headerCollapsed ? t.expandHeader : t.collapseHeader}
|
|
||||||
>
|
|
||||||
{headerCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="app-header-actions">
|
|
||||||
{!headerCollapsed ? (
|
|
||||||
<div className="global-switches">
|
|
||||||
<div className="switch-compact">
|
|
||||||
<LucentTooltip content={t.dark}>
|
|
||||||
<button className={`switch-btn ${theme === 'dark' ? 'active' : ''}`} onClick={() => setTheme('dark')} aria-label={t.dark}>
|
|
||||||
<MoonStar size={14} />
|
|
||||||
</button>
|
|
||||||
</LucentTooltip>
|
|
||||||
<LucentTooltip content={t.light}>
|
|
||||||
<button className={`switch-btn ${theme === 'light' ? 'active' : ''}`} onClick={() => setTheme('light')} aria-label={t.light}>
|
|
||||||
<SunMedium size={14} />
|
|
||||||
</button>
|
|
||||||
</LucentTooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="switch-compact">
|
|
||||||
<LucentTooltip content={t.zh}>
|
|
||||||
<button className={`switch-btn switch-btn-lang ${locale === 'zh' ? 'active' : ''}`} onClick={() => setLocale('zh')} aria-label={t.zh}>
|
|
||||||
<span>ZH</span>
|
|
||||||
</button>
|
|
||||||
</LucentTooltip>
|
|
||||||
<LucentTooltip content={t.en}>
|
|
||||||
<button className={`switch-btn switch-btn-lang ${locale === 'en' ? 'active' : ''}`} onClick={() => setLocale('en')} aria-label={t.en}>
|
|
||||||
<span>EN</span>
|
|
||||||
</button>
|
|
||||||
</LucentTooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<main className="main-stage">
|
|
||||||
{route.kind === 'dashboard' ? (
|
|
||||||
<NodeHomePage compactMode={compactMode} />
|
|
||||||
) : route.kind === 'dashboard-node' ? (
|
|
||||||
<NodeWorkspacePage nodeId={forcedNodeId} compactMode={compactMode} />
|
|
||||||
) : route.kind === 'dashboard-skills' ? (
|
|
||||||
<SkillMarketManagerPage isZh={locale === 'zh'} />
|
|
||||||
) : (
|
|
||||||
<BotHomePage botId={forcedBotId} compactMode={compactMode} />
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{shouldPromptSingleBotPassword ? (
|
|
||||||
<div className="modal-mask app-modal-mask">
|
|
||||||
<div className="app-login-card" onClick={(event) => event.stopPropagation()}>
|
|
||||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
|
|
||||||
<h1>{forcedBot?.name || forcedBotId}</h1>
|
|
||||||
<p>{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}</p>
|
|
||||||
<div className="app-login-form">
|
|
||||||
<PasswordInput
|
|
||||||
className="input"
|
|
||||||
value={singleBotPassword}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSingleBotPassword(event.target.value);
|
|
||||||
if (singleBotPasswordError) setSingleBotPasswordError('');
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') void unlockSingleBot();
|
|
||||||
}}
|
|
||||||
placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'}
|
|
||||||
autoFocus
|
|
||||||
toggleLabels={passwordToggleLabels}
|
|
||||||
/>
|
|
||||||
{singleBotPasswordError ? <div className="app-login-error">{singleBotPasswordError}</div> : null}
|
|
||||||
<button className="btn btn-primary app-login-submit" onClick={() => void unlockSingleBot()} disabled={singleBotSubmitting}>
|
|
||||||
{singleBotSubmitting ? (locale === 'zh' ? '校验中...' : 'Checking...') : (locale === 'zh' ? '进入' : 'Continue')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PanelLoginGate({ children }: { children: ReactElement }) {
|
|
||||||
const route = useAppRoute();
|
|
||||||
const { theme, locale } = useAppStore();
|
|
||||||
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
|
||||||
const [checking, setChecking] = useState(true);
|
|
||||||
const [required, setRequired] = useState(false);
|
|
||||||
const [authenticated, setAuthenticated] = useState(false);
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const passwordToggleLabels = locale === 'zh'
|
|
||||||
? { show: '显示密码', hide: '隐藏密码' }
|
|
||||||
: { show: 'Show password', hide: 'Hide password' };
|
|
||||||
const bypassPanelGate = route.kind === 'bot';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (bypassPanelGate) {
|
|
||||||
setRequired(false);
|
|
||||||
setAuthenticated(true);
|
|
||||||
setChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let alive = true;
|
|
||||||
const boot = async () => {
|
|
||||||
try {
|
|
||||||
const status = await axios.get<{ enabled: boolean }>(`${APP_ENDPOINTS.apiBase}/panel/auth/status`);
|
|
||||||
if (!alive) return;
|
|
||||||
const enabled = Boolean(status.data?.enabled);
|
|
||||||
if (!enabled) {
|
|
||||||
setRequired(false);
|
|
||||||
setAuthenticated(true);
|
|
||||||
setChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRequired(true);
|
|
||||||
const stored = getPanelAccessPassword();
|
|
||||||
if (!stored) {
|
|
||||||
setChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: stored });
|
|
||||||
if (!alive) return;
|
|
||||||
setAuthenticated(true);
|
|
||||||
} catch {
|
|
||||||
clearPanelAccessPassword();
|
|
||||||
if (!alive) return;
|
|
||||||
setError(locale === 'zh' ? '面板访问密码错误,请重新输入。' : 'Invalid panel access password. Please try again.');
|
|
||||||
} finally {
|
|
||||||
if (alive) setChecking(false);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!alive) return;
|
|
||||||
setRequired(true);
|
|
||||||
setAuthenticated(false);
|
|
||||||
setError(
|
|
||||||
locale === 'zh'
|
|
||||||
? '无法确认面板访问状态,请重新输入面板密码。若仍失败,请检查 Dashboard Backend 是否已重启并应用最新配置。'
|
|
||||||
: 'Unable to verify panel access. Enter the panel password again. If it still fails, restart the Dashboard backend and ensure the latest config is loaded.',
|
|
||||||
);
|
|
||||||
setChecking(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void boot();
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, [bypassPanelGate, locale]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined' || bypassPanelGate) return;
|
|
||||||
const onPanelAuthRequired = (event: Event) => {
|
|
||||||
const detail = String((event as CustomEvent<string>)?.detail || '').trim();
|
const detail = String((event as CustomEvent<string>)?.detail || '').trim();
|
||||||
setRequired(true);
|
clearSessionToken();
|
||||||
setAuthenticated(false);
|
setAuthBootstrap(null);
|
||||||
setChecking(false);
|
|
||||||
setSubmitting(false);
|
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setError(
|
setError(detail || (isZh ? '登录状态已失效,请重新登录。' : 'Session expired. Please sign in again.'));
|
||||||
detail || (locale === 'zh' ? '面板访问密码已失效,请重新输入。' : 'Panel access password expired. Please sign in again.'),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
window.addEventListener(PANEL_AUTH_REQUIRED_EVENT, onPanelAuthRequired as EventListener);
|
window.addEventListener(SESSION_AUTH_REQUIRED_EVENT, onAuthRequired as EventListener);
|
||||||
return () => window.removeEventListener(PANEL_AUTH_REQUIRED_EVENT, onPanelAuthRequired as EventListener);
|
return () => window.removeEventListener(SESSION_AUTH_REQUIRED_EVENT, onAuthRequired as EventListener);
|
||||||
}, [bypassPanelGate, locale]);
|
}, [isZh]);
|
||||||
|
|
||||||
const onSubmit = async () => {
|
useEffect(() => {
|
||||||
const next = String(password || '').trim();
|
setSidebarOpen(false);
|
||||||
if (!next) {
|
}, [route.kind]);
|
||||||
setError(locale === 'zh' ? '请输入面板访问密码。' : 'Enter the panel access password.');
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
const normalizedUsername = String(username || defaultUsername || '').trim().toLowerCase();
|
||||||
|
if (!normalizedUsername || !password.trim()) {
|
||||||
|
setError(isZh ? '请输入用户名和密码。' : 'Enter both username and password.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: next });
|
const res = await axios.post<SysAuthBootstrap>(`${APP_ENDPOINTS.apiBase}/sys/auth/login`, {
|
||||||
setPanelAccessPassword(next);
|
username: normalizedUsername,
|
||||||
setAuthenticated(true);
|
password,
|
||||||
} catch {
|
});
|
||||||
clearPanelAccessPassword();
|
setSessionToken(String(res.data?.token || ''));
|
||||||
setError(locale === 'zh' ? '面板访问密码错误。' : 'Invalid panel access password.');
|
setAuthBootstrap(res.data);
|
||||||
|
setPassword('');
|
||||||
|
if (route.kind === 'dashboard') {
|
||||||
|
navigateTo(res.data?.home_path || '/dashboard');
|
||||||
|
}
|
||||||
|
} catch (loginError: any) {
|
||||||
|
setError(loginError?.response?.data?.detail || (isZh ? '用户名或密码错误。' : 'Invalid username or password.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${APP_ENDPOINTS.apiBase}/sys/auth/logout`);
|
||||||
|
} catch {
|
||||||
|
// ignore logout failure and clear local session anyway
|
||||||
|
} finally {
|
||||||
|
clearSessionToken();
|
||||||
|
setAuthBootstrap(null);
|
||||||
|
setPassword('');
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBootstrapChange = (nextBootstrap: SysAuthBootstrap) => {
|
||||||
|
const currentToken = String(nextBootstrap.token || authBootstrap?.token || getSessionToken() || '');
|
||||||
|
if (currentToken) setSessionToken(currentToken);
|
||||||
|
setAuthBootstrap({
|
||||||
|
...nextBootstrap,
|
||||||
|
token: currentToken,
|
||||||
|
expires_at: nextBootstrap.expires_at || authBootstrap?.expires_at || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeMeta = getRouteMeta(route, isZh);
|
||||||
|
const compactMode = viewportCompact;
|
||||||
|
|
||||||
if (checking) {
|
if (checking) {
|
||||||
return (
|
return (
|
||||||
<div className="app-shell" data-theme={theme}>
|
<div className="app-shell" data-theme={theme}>
|
||||||
<div className="app-login-shell">
|
<div className="app-login-shell">
|
||||||
<div className="app-login-card">
|
<div className="app-login-card">
|
||||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
|
||||||
<h1>{t.title}</h1>
|
<h1>Nanobot</h1>
|
||||||
<p>{locale === 'zh' ? '正在校验面板访问权限...' : 'Checking panel access...'}</p>
|
<p>{isZh ? '正在检查登录状态...' : 'Checking session...'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (required && !authenticated) {
|
if (!authBootstrap) {
|
||||||
return (
|
return (
|
||||||
<div className="app-shell" data-theme={theme}>
|
<DashboardLogin
|
||||||
<div className="app-login-shell">
|
username={username}
|
||||||
<div className="app-login-card">
|
password={password}
|
||||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
|
submitting={submitting}
|
||||||
<h1>{t.title}</h1>
|
error={error}
|
||||||
<p>{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}</p>
|
onUsernameChange={setUsername}
|
||||||
<div className="app-login-form">
|
onPasswordChange={setPassword}
|
||||||
<PasswordInput
|
onSubmit={() => void handleLogin()}
|
||||||
className="input"
|
defaultUsername={defaultUsername}
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') void onSubmit();
|
|
||||||
}}
|
|
||||||
placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'}
|
|
||||||
toggleLabels={passwordToggleLabels}
|
|
||||||
/>
|
/>
|
||||||
{error ? <div className="app-login-error">{error}</div> : null}
|
|
||||||
<button className="btn btn-primary app-login-submit" onClick={() => void onSubmit()} disabled={submitting}>
|
|
||||||
{submitting ? (locale === 'zh' ? '登录中...' : 'Signing in...') : (locale === 'zh' ? '登录' : 'Sign In')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return children;
|
return (
|
||||||
|
<AuthenticatedDashboardApp
|
||||||
|
route={route}
|
||||||
|
authBootstrap={authBootstrap}
|
||||||
|
compactMode={compactMode}
|
||||||
|
sidebarOpen={sidebarOpen}
|
||||||
|
setSidebarOpen={setSidebarOpen}
|
||||||
|
routeMeta={routeMeta}
|
||||||
|
onLogout={() => void handleLogout()}
|
||||||
|
onAuthBootstrapChange={handleBootstrapChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
const route = useAppRoute();
|
||||||
<PanelLoginGate>
|
return <SessionShell route={route} />;
|
||||||
<AuthenticatedApp />
|
|
||||||
</PanelLoginGate>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,362 @@
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BadgeCheck,
|
||||||
|
Files,
|
||||||
|
History,
|
||||||
|
KeyRound,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
MessageCircle,
|
||||||
|
MoonStar,
|
||||||
|
Rocket,
|
||||||
|
Settings2,
|
||||||
|
Shield,
|
||||||
|
SunMedium,
|
||||||
|
UserRound,
|
||||||
|
Users,
|
||||||
|
Waypoints,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { PasswordInput } from '../components/PasswordInput';
|
||||||
|
import { pickLocale } from '../i18n';
|
||||||
|
import { appZhCn } from '../i18n/app.zh-cn';
|
||||||
|
import { appEn } from '../i18n/app.en';
|
||||||
|
import { useAppStore } from '../store/appStore';
|
||||||
|
import type { SysAuthBootstrap, SysMenuItem } from '../types/sys';
|
||||||
|
|
||||||
|
const iconMap: Record<string, LucideIcon> = {
|
||||||
|
'layout-dashboard': LayoutDashboard,
|
||||||
|
waypoints: Waypoints,
|
||||||
|
wrench: Wrench,
|
||||||
|
'sliders-horizontal': Settings2,
|
||||||
|
files: Files,
|
||||||
|
rocket: Rocket,
|
||||||
|
shield: Shield,
|
||||||
|
users: Users,
|
||||||
|
'badge-check': BadgeCheck,
|
||||||
|
'layout-grid': LayoutDashboard,
|
||||||
|
'message-circle': MessageCircle,
|
||||||
|
'user-round': UserRound,
|
||||||
|
history: History,
|
||||||
|
'key-round': KeyRound,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemeLocaleSwitches() {
|
||||||
|
const { theme, setTheme, locale, setLocale } = useAppStore();
|
||||||
|
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="global-switches">
|
||||||
|
<div className="switch-compact">
|
||||||
|
<button className={`switch-btn ${theme === 'dark' ? 'active' : ''}`} onClick={() => setTheme('dark')} aria-label={t.dark}>
|
||||||
|
<MoonStar size={14} />
|
||||||
|
</button>
|
||||||
|
<button className={`switch-btn ${theme === 'light' ? 'active' : ''}`} onClick={() => setTheme('light')} aria-label={t.light}>
|
||||||
|
<SunMedium size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="switch-compact">
|
||||||
|
<button className={`switch-btn switch-btn-lang ${locale === 'zh' ? 'active' : ''}`} onClick={() => setLocale('zh')} aria-label={t.zh}>
|
||||||
|
<span>ZH</span>
|
||||||
|
</button>
|
||||||
|
<button className={`switch-btn switch-btn-lang ${locale === 'en' ? 'active' : ''}`} onClick={() => setLocale('en')} aria-label={t.en}>
|
||||||
|
<span>EN</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompactHeaderSwitches() {
|
||||||
|
const { theme, setTheme, locale, setLocale } = useAppStore();
|
||||||
|
const isZh = locale === 'zh';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="compact-header-switches">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compact-header-pill"
|
||||||
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
aria-label={isZh ? '切换主题' : 'Toggle theme'}
|
||||||
|
title={isZh ? '切换主题' : 'Toggle theme'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <MoonStar size={16} /> : <SunMedium size={16} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compact-header-pill compact-header-pill-text"
|
||||||
|
onClick={() => setLocale(isZh ? 'en' : 'zh')}
|
||||||
|
aria-label={isZh ? '切换语言' : 'Toggle language'}
|
||||||
|
title={isZh ? '切换语言' : 'Toggle language'}
|
||||||
|
>
|
||||||
|
<span>{isZh ? 'ZH' : 'EN'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardLoginProps = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
submitting: boolean;
|
||||||
|
error: string;
|
||||||
|
onUsernameChange: (value: string) => void;
|
||||||
|
onPasswordChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
defaultUsername: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardLogin({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
submitting,
|
||||||
|
error,
|
||||||
|
onUsernameChange,
|
||||||
|
onPasswordChange,
|
||||||
|
onSubmit,
|
||||||
|
defaultUsername,
|
||||||
|
}: DashboardLoginProps) {
|
||||||
|
const { theme, locale } = useAppStore();
|
||||||
|
const passwordToggleLabels = locale === 'zh'
|
||||||
|
? { show: '显示密码', hide: '隐藏密码' }
|
||||||
|
: { show: 'Show password', hide: 'Hide password' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell sys-login-shell" data-theme={theme}>
|
||||||
|
<div className="sys-login-layout">
|
||||||
|
<div className="app-login-card sys-login-card">
|
||||||
|
<div className="sys-login-brand">
|
||||||
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon sys-login-logo" />
|
||||||
|
<h1>Nanobot</h1>
|
||||||
|
<p>{locale === 'zh' ? '用户登录' : 'User Sign In'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="app-login-form">
|
||||||
|
<label className="field-label">{locale === 'zh' ? '用户名' : 'Username'}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => onUsernameChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') onSubmit();
|
||||||
|
}}
|
||||||
|
placeholder={defaultUsername || (locale === 'zh' ? '用户名' : 'Username')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<label className="field-label">{locale === 'zh' ? '密码' : 'Password'}</label>
|
||||||
|
<PasswordInput
|
||||||
|
className="input"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => onPasswordChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') onSubmit();
|
||||||
|
}}
|
||||||
|
placeholder={locale === 'zh' ? '密码' : 'Password'}
|
||||||
|
toggleLabels={passwordToggleLabels}
|
||||||
|
/>
|
||||||
|
{error ? <div className="app-login-error">{error}</div> : null}
|
||||||
|
<button className="btn btn-primary app-login-submit sys-login-submit" onClick={onSubmit} disabled={submitting}>
|
||||||
|
{submitting ? (locale === 'zh' ? '登录中...' : 'Signing in...') : (locale === 'zh' ? '登录' : 'Sign In')}
|
||||||
|
</button>
|
||||||
|
<div className="sys-login-hint">
|
||||||
|
{locale === 'zh'
|
||||||
|
? `首次初始化默认账号:${defaultUsername || 'admin'}`
|
||||||
|
: `Initial seeded account: ${defaultUsername || 'admin'}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidebarMenuProps = {
|
||||||
|
menus: SysMenuItem[];
|
||||||
|
activeMenuKey: string;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SidebarMenu({ menus, activeMenuKey, onNavigate }: SidebarMenuProps) {
|
||||||
|
const { locale } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="sys-sidebar-nav">
|
||||||
|
{menus.map((group) => (
|
||||||
|
<section key={group.menu_key} className="sys-sidebar-group">
|
||||||
|
<div className="sys-sidebar-group-title">
|
||||||
|
{locale === 'zh' ? group.title : (group.title_en || group.title)}
|
||||||
|
</div>
|
||||||
|
<div className="sys-sidebar-group-items">
|
||||||
|
{(group.children || []).map((item) => {
|
||||||
|
const Icon = iconMap[item.icon || 'layout-dashboard'] || LayoutDashboard;
|
||||||
|
const active = item.menu_key === activeMenuKey;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.menu_key}
|
||||||
|
type="button"
|
||||||
|
className={`sys-sidebar-item ${active ? 'is-active' : ''}`}
|
||||||
|
onClick={() => onNavigate(item.route_path)}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
<span>{locale === 'zh' ? item.title : (item.title_en || item.title)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidebarAccountPillProps = {
|
||||||
|
authBootstrap: SysAuthBootstrap;
|
||||||
|
isZh: boolean;
|
||||||
|
onOpenProfile: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SidebarAccountPill({ authBootstrap, isZh, onOpenProfile, onLogout }: SidebarAccountPillProps) {
|
||||||
|
const username = String(authBootstrap.user.display_name || authBootstrap.user.username || '');
|
||||||
|
const subtitle = authBootstrap.user.role?.name || (isZh ? '账户设置' : 'Account settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sys-user-pill sys-user-pill-sidebar">
|
||||||
|
<button className="sys-user-pill-main" type="button" onClick={onOpenProfile}>
|
||||||
|
<span className="sys-user-pill-avatar">{username.slice(0, 1).toUpperCase()}</span>
|
||||||
|
<span className="sys-user-pill-copy">
|
||||||
|
<strong>{username}</strong>
|
||||||
|
<span>{subtitle}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button className="sys-user-pill-action" type="button" onClick={onLogout} aria-label={isZh ? '退出登录' : 'Logout'} title={isZh ? '退出登录' : 'Logout'}>
|
||||||
|
<LogOut size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNormalUserRole(authBootstrap: SysAuthBootstrap) {
|
||||||
|
return String(authBootstrap.user.role?.role_key || '').trim().toLowerCase() === 'normal_user';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAssignedBotTone(enabled?: boolean, dockerStatus?: string) {
|
||||||
|
if (enabled === false) return 'is-disabled';
|
||||||
|
return String(dockerStatus || '').toUpperCase() === 'RUNNING' ? 'is-running' : 'is-stopped';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResolvedAssignedBots(authBootstrap: SysAuthBootstrap) {
|
||||||
|
const activeBots = useAppStore((state) => state.activeBots);
|
||||||
|
return useMemo(() => {
|
||||||
|
const assigned = Array.isArray(authBootstrap.assigned_bots) ? authBootstrap.assigned_bots : [];
|
||||||
|
if (assigned.length === 0) return [];
|
||||||
|
const liveBotIds = new Set(Object.keys(activeBots).filter((botId) => String(botId || '').trim()));
|
||||||
|
const preferLiveIntersection = liveBotIds.size > 0;
|
||||||
|
return assigned
|
||||||
|
.filter((item) => !preferLiveIntersection || liveBotIds.has(String(item.id || '').trim()))
|
||||||
|
.map((item) => {
|
||||||
|
const live = activeBots[item.id];
|
||||||
|
return live
|
||||||
|
? {
|
||||||
|
id: live.id,
|
||||||
|
name: live.name || item.name,
|
||||||
|
enabled: live.enabled,
|
||||||
|
docker_status: live.docker_status,
|
||||||
|
node_id: live.node_id || item.node_id,
|
||||||
|
node_display_name: live.node_display_name || item.node_display_name || item.node_id,
|
||||||
|
}
|
||||||
|
: item;
|
||||||
|
})
|
||||||
|
.filter((item) => String(item.id || '').trim());
|
||||||
|
}, [activeBots, authBootstrap.assigned_bots]);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BotSwitcherTriggerProps = {
|
||||||
|
authBootstrap: SysAuthBootstrap;
|
||||||
|
isZh: boolean;
|
||||||
|
selectedBotId: string;
|
||||||
|
onSelectBot: (botId: string) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BotSwitcherTrigger({
|
||||||
|
authBootstrap,
|
||||||
|
isZh,
|
||||||
|
selectedBotId,
|
||||||
|
onSelectBot,
|
||||||
|
className,
|
||||||
|
}: BotSwitcherTriggerProps) {
|
||||||
|
const bots = useResolvedAssignedBots(authBootstrap);
|
||||||
|
const theme = useAppStore((state) => state.theme);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selectedBot = bots.find((bot) => bot.id === selectedBotId) || bots[0];
|
||||||
|
const shortName = String(selectedBot?.name || selectedBot?.id || '').slice(0, 1).toUpperCase() || 'B';
|
||||||
|
const tone = normalizeAssignedBotTone(selectedBot?.enabled, selectedBot?.docker_status);
|
||||||
|
const canRenderPortal = typeof document !== 'undefined';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bots.length <= 1) setOpen(false);
|
||||||
|
}, [bots.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bot-switcher-wrap ${className || ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bot-switcher-trigger ${tone}${open ? ' is-open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (bots.length > 1) {
|
||||||
|
setOpen((value) => !value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={selectedBot?.name || selectedBot?.id || (isZh ? '当前 Bot' : 'Current Bot')}
|
||||||
|
aria-label={selectedBot?.name || selectedBot?.id || (isZh ? '当前 Bot' : 'Current Bot')}
|
||||||
|
>
|
||||||
|
<span className="bot-switcher-trigger-avatar">{shortName}</span>
|
||||||
|
</button>
|
||||||
|
{open && canRenderPortal ? createPortal(
|
||||||
|
<div className="app-shell bot-switcher-portal-shell" data-theme={theme}>
|
||||||
|
<div className="bot-switcher-overlay" onClick={() => setOpen(false)}>
|
||||||
|
<div className="bot-switcher-popover" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="bot-switcher-popover-head">
|
||||||
|
<strong>{isZh ? '切换 Bot' : 'Switch Bot'}</strong>
|
||||||
|
<span>{isZh ? `${bots.length} 个` : `${bots.length}`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bot-switcher-popover-list">
|
||||||
|
{bots.map((bot) => {
|
||||||
|
const active = bot.id === selectedBot?.id;
|
||||||
|
const itemTone = normalizeAssignedBotTone(bot.enabled, bot.docker_status);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={bot.id}
|
||||||
|
type="button"
|
||||||
|
className={`bot-switcher-item ${itemTone}${active ? ' is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectBot(bot.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bot-switcher-item-main">
|
||||||
|
<span className="bot-switcher-item-avatar">{String(bot.name || bot.id || '').slice(0, 1).toUpperCase() || 'B'}</span>
|
||||||
|
<div className="bot-switcher-item-copy">
|
||||||
|
<strong>{bot.name || bot.id}</strong>
|
||||||
|
<span className="mono">{bot.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bot-switcher-item-meta">
|
||||||
|
<span className={`badge ${itemTone === 'is-running' ? 'badge-ok' : itemTone === 'is-disabled' ? 'badge-err' : 'badge-unknown'}`}>
|
||||||
|
{itemTone === 'is-running' ? (isZh ? '运行中' : 'Running') : itemTone === 'is-disabled' ? (isZh ? '已停用' : 'Disabled') : (isZh ? '已停止' : 'Stopped')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,491 @@
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
History,
|
||||||
|
KeyRound,
|
||||||
|
LayoutDashboard,
|
||||||
|
Menu,
|
||||||
|
MessageCircle,
|
||||||
|
Settings2,
|
||||||
|
UserRound,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useBotsSync } from '../hooks/useBotsSync';
|
||||||
|
import { useAppStore } from '../store/appStore';
|
||||||
|
import type { SysAuthBootstrap } from '../types/sys';
|
||||||
|
import type { AppRoute } from '../utils/appRoute';
|
||||||
|
import { AdminAccessPlaceholderPage } from '../modules/platform/components/AdminAccessPlaceholderPage';
|
||||||
|
import { PlatformSettingsPage } from '../modules/platform/components/PlatformSettingsPage';
|
||||||
|
import { RoleManagementPage } from '../modules/platform/components/RoleManagementPage';
|
||||||
|
import { SkillMarketManagerPage } from '../modules/platform/components/SkillMarketManagerModal';
|
||||||
|
import { TemplateManagerPage } from '../modules/platform/components/TemplateManagerPage';
|
||||||
|
import { UserManagementPage } from '../modules/platform/components/UserManagementPage';
|
||||||
|
import { UserProfilePage } from '../modules/platform/components/UserProfilePage';
|
||||||
|
import { BotHomePage } from '../modules/bot-home/BotHomePage';
|
||||||
|
import { ChatWorkspacePage } from '../modules/chat/ChatWorkspacePage';
|
||||||
|
import { NodeHomePage } from '../modules/platform/NodeHomePage';
|
||||||
|
import { NodeWorkspacePage } from '../modules/platform/NodeWorkspacePage';
|
||||||
|
import { PlatformDashboardPage } from '../modules/platform/PlatformDashboardPage';
|
||||||
|
import { PlatformHomePage } from '../modules/platform/PlatformHomePage';
|
||||||
|
import type { AppRouteMeta } from './appRouteMeta';
|
||||||
|
import { collectMenuKeys, navigateTo } from './appRouteMeta';
|
||||||
|
import {
|
||||||
|
BotSwitcherTrigger,
|
||||||
|
CompactHeaderSwitches,
|
||||||
|
isNormalUserRole,
|
||||||
|
SidebarAccountPill,
|
||||||
|
SidebarMenu,
|
||||||
|
ThemeLocaleSwitches,
|
||||||
|
useResolvedAssignedBots,
|
||||||
|
} from './AppChrome';
|
||||||
|
|
||||||
|
type PersonalMobileShellProps = {
|
||||||
|
route: AppRoute;
|
||||||
|
authBootstrap: SysAuthBootstrap;
|
||||||
|
onLogout: () => void;
|
||||||
|
onAuthBootstrapChange: (value: SysAuthBootstrap) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PersonalMobileShell({
|
||||||
|
route,
|
||||||
|
authBootstrap,
|
||||||
|
onLogout,
|
||||||
|
onAuthBootstrapChange,
|
||||||
|
}: PersonalMobileShellProps) {
|
||||||
|
const { theme, locale } = useAppStore();
|
||||||
|
const isZh = locale === 'zh';
|
||||||
|
const [mobilePrimaryTab, setMobilePrimaryTab] = useState<'chat' | 'runtime'>('chat');
|
||||||
|
const [selectedChatBotId, setSelectedChatBotId] = useState('');
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const assignedBots = useResolvedAssignedBots(authBootstrap);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!assignedBots.length) {
|
||||||
|
setSelectedChatBotId('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedChatBotId || !assignedBots.some((bot) => bot.id === selectedChatBotId)) {
|
||||||
|
setSelectedChatBotId(assignedBots[0].id);
|
||||||
|
}
|
||||||
|
}, [assignedBots, selectedChatBotId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSettingsOpen(false);
|
||||||
|
}, [route.kind]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
route.kind !== 'general-chat'
|
||||||
|
&& route.kind !== 'admin-profile'
|
||||||
|
&& route.kind !== 'profile-usage-logs'
|
||||||
|
&& route.kind !== 'profile-api-tokens'
|
||||||
|
) {
|
||||||
|
navigateTo('/chat');
|
||||||
|
}
|
||||||
|
}, [route.kind]);
|
||||||
|
|
||||||
|
const mainContent = useMemo(() => {
|
||||||
|
switch (route.kind) {
|
||||||
|
case 'admin-profile':
|
||||||
|
return (
|
||||||
|
<UserProfilePage
|
||||||
|
isZh={isZh}
|
||||||
|
authBootstrap={authBootstrap}
|
||||||
|
onUpdated={onAuthBootstrapChange}
|
||||||
|
onLogout={onLogout}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'profile-usage-logs':
|
||||||
|
return (
|
||||||
|
<AdminAccessPlaceholderPage
|
||||||
|
title={isZh ? '使用日志' : 'Usage Logs'}
|
||||||
|
subtitle={isZh ? '这里将聚合当前用户的 Bot 使用明细、调用轨迹与消耗记录。' : 'This page will aggregate the current user’s bot usage details and usage history.'}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: isZh ? '调用记录' : 'Request Logs',
|
||||||
|
description: isZh ? '展示按时间、Bot 和模型维度的个人使用记录。' : 'Show personal request logs by time, bot, and model.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: isZh ? '会话轨迹' : 'Session Timeline',
|
||||||
|
description: isZh ? '汇总近期对话、运行事件和操作痕迹。' : 'Summarize recent chat, runtime events, and operation traces.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'profile-api-tokens':
|
||||||
|
return (
|
||||||
|
<AdminAccessPlaceholderPage
|
||||||
|
title={isZh ? 'API 令牌' : 'API Tokens'}
|
||||||
|
subtitle={isZh ? '这里将提供个人 API 令牌的申请、轮换和吊销能力。' : 'This page will manage personal API token issuance, rotation, and revocation.'}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: isZh ? '令牌列表' : 'Token List',
|
||||||
|
description: isZh ? '查看当前用户已创建的访问令牌。' : 'View access tokens created for the current user.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: isZh ? '轮换策略' : 'Rotation Policy',
|
||||||
|
description: isZh ? '后续接入过期控制、权限范围和调用限制。' : 'Later this will support expiration control, scopes, and rate policies.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'general-chat':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<ChatWorkspacePage
|
||||||
|
authBootstrap={authBootstrap}
|
||||||
|
isZh={isZh}
|
||||||
|
compactMode
|
||||||
|
initialCompactPanelTab={mobilePrimaryTab}
|
||||||
|
hideCompactFab
|
||||||
|
selectedBotId={selectedChatBotId}
|
||||||
|
onSelectedBotChange={setSelectedChatBotId}
|
||||||
|
hideRail
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [authBootstrap, isZh, mobilePrimaryTab, onAuthBootstrapChange, onLogout, route.kind, selectedChatBotId]);
|
||||||
|
|
||||||
|
const activeNav = route.kind === 'admin-profile'
|
||||||
|
? 'profile'
|
||||||
|
: route.kind === 'profile-usage-logs' || route.kind === 'profile-api-tokens'
|
||||||
|
? 'settings'
|
||||||
|
: mobilePrimaryTab === 'runtime'
|
||||||
|
? 'panel'
|
||||||
|
: 'chat';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell app-shell-compact mobile-user-shell" data-theme={theme}>
|
||||||
|
<div className="mobile-user-frame">
|
||||||
|
<header className="mobile-user-header">
|
||||||
|
<div className="mobile-user-brand">
|
||||||
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="mobile-user-brand-logo" />
|
||||||
|
<strong>Nanobot</strong>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-user-header-actions">
|
||||||
|
<CompactHeaderSwitches />
|
||||||
|
<BotSwitcherTrigger
|
||||||
|
authBootstrap={authBootstrap}
|
||||||
|
isZh={isZh}
|
||||||
|
selectedBotId={selectedChatBotId}
|
||||||
|
onSelectBot={(botId) => {
|
||||||
|
setSelectedChatBotId(botId);
|
||||||
|
navigateTo('/chat');
|
||||||
|
}}
|
||||||
|
className="mobile-user-header-bot-switcher"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mobile-user-content">
|
||||||
|
{mainContent}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav className="mobile-user-bottom-nav" aria-label={isZh ? '移动底部导航' : 'Mobile bottom navigation'}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`mobile-user-bottom-item ${activeNav === 'chat' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setMobilePrimaryTab('chat');
|
||||||
|
navigateTo('/chat');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageCircle size={18} />
|
||||||
|
<span>{isZh ? 'Chat' : 'Chat'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`mobile-user-bottom-item ${activeNav === 'panel' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setMobilePrimaryTab('runtime');
|
||||||
|
navigateTo('/chat');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayoutDashboard size={18} />
|
||||||
|
<span>{isZh ? '面板' : 'Panel'}</span>
|
||||||
|
</button>
|
||||||
|
<div className="mobile-user-bottom-item-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`mobile-user-bottom-item ${activeNav === 'settings' || settingsOpen ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setSettingsOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<Settings2 size={18} />
|
||||||
|
<span>{isZh ? '设置' : 'Settings'}</span>
|
||||||
|
</button>
|
||||||
|
{settingsOpen ? (
|
||||||
|
<div className="mobile-user-settings-popover">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mobile-user-settings-item"
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsOpen(false);
|
||||||
|
navigateTo('/profile/usage-logs');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<History size={15} />
|
||||||
|
<span>{isZh ? '使用日志' : 'Usage Logs'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mobile-user-settings-item"
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsOpen(false);
|
||||||
|
navigateTo('/profile/api-tokens');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyRound size={15} />
|
||||||
|
<span>{isZh ? 'API 令牌' : 'API Tokens'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`mobile-user-bottom-item ${activeNav === 'profile' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => navigateTo('/admin/profile')}
|
||||||
|
>
|
||||||
|
<UserRound size={18} />
|
||||||
|
<span>{isZh ? '用户设置' : 'Profile'}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthenticatedDashboardAppProps = {
|
||||||
|
route: AppRoute;
|
||||||
|
authBootstrap: SysAuthBootstrap;
|
||||||
|
compactMode: boolean;
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
setSidebarOpen: (value: boolean) => void;
|
||||||
|
routeMeta: AppRouteMeta;
|
||||||
|
onLogout: () => void;
|
||||||
|
onAuthBootstrapChange: (value: SysAuthBootstrap) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthenticatedDashboardApp({
|
||||||
|
route,
|
||||||
|
authBootstrap,
|
||||||
|
compactMode,
|
||||||
|
sidebarOpen,
|
||||||
|
setSidebarOpen,
|
||||||
|
routeMeta,
|
||||||
|
onLogout,
|
||||||
|
onAuthBootstrapChange,
|
||||||
|
}: AuthenticatedDashboardAppProps) {
|
||||||
|
const { theme, locale } = useAppStore();
|
||||||
|
const isZh = locale === 'zh';
|
||||||
|
useBotsSync();
|
||||||
|
const authorizedMenuKeys = useMemo(() => collectMenuKeys(authBootstrap.menus || []), [authBootstrap.menus]);
|
||||||
|
const isNormalUser = useMemo(() => isNormalUserRole(authBootstrap), [authBootstrap]);
|
||||||
|
const assignedBots = useResolvedAssignedBots(authBootstrap);
|
||||||
|
const [selectedChatBotId, setSelectedChatBotId] = useState('');
|
||||||
|
const headerMeta = useMemo(() => {
|
||||||
|
if (route.kind !== 'general-chat') return routeMeta;
|
||||||
|
const selectedChatBot = assignedBots.find((bot) => bot.id === selectedChatBotId) || assignedBots[0];
|
||||||
|
return {
|
||||||
|
...routeMeta,
|
||||||
|
title: String(selectedChatBot?.name || 'Bot Name'),
|
||||||
|
subtitle: String(selectedChatBot?.id || 'Bot ID'),
|
||||||
|
};
|
||||||
|
}, [assignedBots, route.kind, routeMeta, selectedChatBotId]);
|
||||||
|
|
||||||
|
const handleSidebarNavigate = (path: string) => {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
navigateTo(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openProfile = () => handleSidebarNavigate('/admin/profile');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!assignedBots.length) {
|
||||||
|
setSelectedChatBotId('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedChatBotId || !assignedBots.some((bot) => bot.id === selectedChatBotId)) {
|
||||||
|
setSelectedChatBotId(assignedBots[0].id);
|
||||||
|
}
|
||||||
|
}, [assignedBots, selectedChatBotId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!routeMeta.activeMenuKey) return;
|
||||||
|
if (authorizedMenuKeys.has(routeMeta.activeMenuKey)) return;
|
||||||
|
const fallbackPath = String(authBootstrap.home_path || '/dashboard').trim() || '/dashboard';
|
||||||
|
if (typeof window !== 'undefined' && window.location.pathname !== fallbackPath) {
|
||||||
|
navigateTo(fallbackPath);
|
||||||
|
}
|
||||||
|
}, [authBootstrap.home_path, authorizedMenuKeys, routeMeta.activeMenuKey]);
|
||||||
|
|
||||||
|
const mainContent: ReactNode = useMemo(() => {
|
||||||
|
switch (route.kind) {
|
||||||
|
case 'dashboard':
|
||||||
|
return <PlatformHomePage isZh={isZh} onNavigate={navigateTo} />;
|
||||||
|
case 'general-chat':
|
||||||
|
return (
|
||||||
|
<ChatWorkspacePage
|
||||||
|
authBootstrap={authBootstrap}
|
||||||
|
isZh={isZh}
|
||||||
|
compactMode={compactMode}
|
||||||
|
selectedBotId={selectedChatBotId}
|
||||||
|
onSelectedBotChange={setSelectedChatBotId}
|
||||||
|
hideRail={isNormalUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'dashboard-edges':
|
||||||
|
return <NodeHomePage compactMode={compactMode} />;
|
||||||
|
case 'dashboard-node':
|
||||||
|
return <NodeWorkspacePage nodeId={route.nodeId} compactMode={compactMode} />;
|
||||||
|
case 'admin-skills':
|
||||||
|
return <SkillMarketManagerPage isZh={isZh} />;
|
||||||
|
case 'admin-profile':
|
||||||
|
return (
|
||||||
|
<UserProfilePage
|
||||||
|
isZh={isZh}
|
||||||
|
authBootstrap={authBootstrap}
|
||||||
|
onUpdated={onAuthBootstrapChange}
|
||||||
|
onLogout={onLogout}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'admin-settings':
|
||||||
|
return <PlatformSettingsPage isZh={isZh} />;
|
||||||
|
case 'admin-templates':
|
||||||
|
return <TemplateManagerPage isZh={isZh} />;
|
||||||
|
case 'admin-deploy':
|
||||||
|
return <PlatformDashboardPage compactMode={compactMode} />;
|
||||||
|
case 'admin-users':
|
||||||
|
return <UserManagementPage isZh={isZh} />;
|
||||||
|
case 'admin-roles':
|
||||||
|
return <RoleManagementPage isZh={isZh} />;
|
||||||
|
case 'profile-usage-logs':
|
||||||
|
return (
|
||||||
|
<AdminAccessPlaceholderPage
|
||||||
|
title={isZh ? '使用日志' : 'Usage Logs'}
|
||||||
|
subtitle={isZh ? '这里将聚合当前用户的 Bot 使用明细、调用轨迹与消耗记录。' : 'This page will aggregate the current user’s bot usage details and usage history.'}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: isZh ? '调用记录' : 'Request Logs',
|
||||||
|
description: isZh ? '展示按时间、Bot 和模型维度的个人使用记录。' : 'Show personal request logs by time, bot, and model.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: isZh ? '会话轨迹' : 'Session Timeline',
|
||||||
|
description: isZh ? '汇总近期对话、运行事件和操作痕迹。' : 'Summarize recent chat, runtime events, and operation traces.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'profile-api-tokens':
|
||||||
|
return (
|
||||||
|
<AdminAccessPlaceholderPage
|
||||||
|
title={isZh ? 'API 令牌' : 'API Tokens'}
|
||||||
|
subtitle={isZh ? '这里将提供个人 API 令牌的申请、轮换和吊销能力。' : 'This page will manage personal API token issuance, rotation, and revocation.'}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: isZh ? '令牌列表' : 'Token List',
|
||||||
|
description: isZh ? '查看当前用户已创建的访问令牌。' : 'View access tokens created for the current user.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: isZh ? '轮换策略' : 'Rotation Policy',
|
||||||
|
description: isZh ? '后续接入过期控制、权限范围和调用限制。' : 'Later this will support expiration control, scopes, and rate policies.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'bot':
|
||||||
|
return <BotHomePage botId={route.botId} compactMode={compactMode} />;
|
||||||
|
}
|
||||||
|
}, [authBootstrap, compactMode, isNormalUser, isZh, onAuthBootstrapChange, onLogout, route, selectedChatBotId]);
|
||||||
|
|
||||||
|
if (compactMode && isNormalUser) {
|
||||||
|
return (
|
||||||
|
<PersonalMobileShell
|
||||||
|
route={route}
|
||||||
|
authBootstrap={authBootstrap}
|
||||||
|
onLogout={onLogout}
|
||||||
|
onAuthBootstrapChange={onAuthBootstrapChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
|
||||||
|
<div className="sys-shell">
|
||||||
|
{compactMode ? (
|
||||||
|
<>
|
||||||
|
<div className={`sys-sidebar-backdrop ${sidebarOpen ? 'is-open' : ''}`} onClick={() => setSidebarOpen(false)} />
|
||||||
|
<aside className={`sys-sidebar sys-sidebar-drawer ${sidebarOpen ? 'is-open' : ''}`}>
|
||||||
|
<div className="sys-sidebar-header">
|
||||||
|
<button className="btn btn-secondary btn-sm icon-btn" type="button" onClick={() => setSidebarOpen(false)}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="sys-brand">
|
||||||
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="sys-brand-logo" />
|
||||||
|
<div>
|
||||||
|
<strong>Nanobot</strong>
|
||||||
|
<span>{isZh ? '控制台' : 'Console'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SidebarMenu menus={authBootstrap.menus || []} activeMenuKey={routeMeta.activeMenuKey} onNavigate={handleSidebarNavigate} />
|
||||||
|
<div className="sys-sidebar-footer">
|
||||||
|
<SidebarAccountPill authBootstrap={authBootstrap} isZh={isZh} onOpenProfile={openProfile} onLogout={onLogout} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<aside className="sys-sidebar">
|
||||||
|
<div className="sys-brand">
|
||||||
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="sys-brand-logo" />
|
||||||
|
<div>
|
||||||
|
<strong>Nanobot</strong>
|
||||||
|
<span>{isZh ? '控制台' : 'Console'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SidebarMenu menus={authBootstrap.menus || []} activeMenuKey={routeMeta.activeMenuKey} onNavigate={handleSidebarNavigate} />
|
||||||
|
<div className="sys-sidebar-footer">
|
||||||
|
<SidebarAccountPill authBootstrap={authBootstrap} isZh={isZh} onOpenProfile={openProfile} onLogout={onLogout} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sys-main">
|
||||||
|
<header className="app-header sys-topbar">
|
||||||
|
<div className="row-between app-header-top">
|
||||||
|
<div className="sys-topbar-title">
|
||||||
|
{compactMode ? (
|
||||||
|
<button className="btn btn-secondary btn-sm icon-btn" type="button" onClick={() => setSidebarOpen(true)}>
|
||||||
|
<Menu size={16} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<h1>{headerMeta.title}</h1>
|
||||||
|
<div className="app-route-subtitle">{headerMeta.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sys-topbar-actions">
|
||||||
|
<ThemeLocaleSwitches />
|
||||||
|
{isNormalUser ? (
|
||||||
|
<BotSwitcherTrigger
|
||||||
|
authBootstrap={authBootstrap}
|
||||||
|
isZh={isZh}
|
||||||
|
selectedBotId={selectedChatBotId}
|
||||||
|
onSelectBot={(botId) => {
|
||||||
|
setSelectedChatBotId(botId);
|
||||||
|
navigateTo('/chat');
|
||||||
|
}}
|
||||||
|
className="sys-topbar-bot-switcher"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={`main-stage sys-main-stage ${route.kind === 'general-chat' || route.kind === 'bot' ? 'is-chat-route' : ''}`}>
|
||||||
|
{mainContent}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import type { SysMenuItem } from '../types/sys';
|
||||||
|
import type { AppRoute } from '../utils/appRoute';
|
||||||
|
|
||||||
|
export type AppRouteMeta = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
activeMenuKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function navigateTo(path: string) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.history.pushState({}, '', path);
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRouteMeta(route: AppRoute, isZh: boolean): AppRouteMeta {
|
||||||
|
switch (route.kind) {
|
||||||
|
case 'dashboard':
|
||||||
|
return {
|
||||||
|
title: isZh ? 'Dashboard' : 'Dashboard',
|
||||||
|
subtitle: isZh ? '平台总览与快捷入口' : 'Platform overview and quick access',
|
||||||
|
activeMenuKey: 'general_dashboard',
|
||||||
|
};
|
||||||
|
case 'general-chat':
|
||||||
|
return {
|
||||||
|
title: 'Bot Name',
|
||||||
|
subtitle: 'Bot ID',
|
||||||
|
activeMenuKey: 'general_chat',
|
||||||
|
};
|
||||||
|
case 'dashboard-edges':
|
||||||
|
return {
|
||||||
|
title: isZh ? 'Edge 管理' : 'Edge Management',
|
||||||
|
subtitle: isZh ? '管理节点连通性、能力与运行状态' : 'Manage node connectivity, capabilities, and runtime state',
|
||||||
|
activeMenuKey: 'general_edge',
|
||||||
|
};
|
||||||
|
case 'dashboard-node':
|
||||||
|
return {
|
||||||
|
title: isZh ? `节点工作区 · ${route.nodeId}` : `Node Workspace · ${route.nodeId}`,
|
||||||
|
subtitle: isZh ? '查看当前节点资源与 Bot 列表' : 'Inspect node resources and bot list',
|
||||||
|
activeMenuKey: 'general_edge',
|
||||||
|
};
|
||||||
|
case 'admin-skills':
|
||||||
|
return {
|
||||||
|
title: isZh ? '技能管理' : 'Skill Management',
|
||||||
|
subtitle: isZh ? '维护技能市场 ZIP 包与元数据' : 'Maintain marketplace ZIP packages and metadata',
|
||||||
|
activeMenuKey: 'admin_skills',
|
||||||
|
};
|
||||||
|
case 'admin-profile':
|
||||||
|
return {
|
||||||
|
title: isZh ? '用户设置' : 'User Settings',
|
||||||
|
subtitle: isZh ? '维护当前登录账号的资料与密码' : 'Manage the current account profile and password',
|
||||||
|
activeMenuKey: '',
|
||||||
|
};
|
||||||
|
case 'admin-settings':
|
||||||
|
return {
|
||||||
|
title: isZh ? '系统参数' : 'System Settings',
|
||||||
|
subtitle: isZh ? '维护平台运行参数与前端可见设置' : 'Maintain runtime settings and public configuration',
|
||||||
|
activeMenuKey: 'admin_settings',
|
||||||
|
};
|
||||||
|
case 'admin-templates':
|
||||||
|
return {
|
||||||
|
title: isZh ? '模版管理' : 'Template Management',
|
||||||
|
subtitle: isZh ? '维护平台级模版、规则与 Topic 预设' : 'Maintain platform templates, rules, and topic presets',
|
||||||
|
activeMenuKey: 'admin_templates',
|
||||||
|
};
|
||||||
|
case 'admin-deploy':
|
||||||
|
return {
|
||||||
|
title: isZh ? '迁移 / 部署' : 'Migration / Deploy',
|
||||||
|
subtitle: isZh ? '集中处理 Bot 创建、镜像与部署工作流' : 'Handle bot creation, images, and deployment workflows',
|
||||||
|
activeMenuKey: 'admin_deploy',
|
||||||
|
};
|
||||||
|
case 'admin-users':
|
||||||
|
return {
|
||||||
|
title: isZh ? '用户管理' : 'User Management',
|
||||||
|
subtitle: isZh ? '管理登录用户、启用状态与基础资料' : 'Manage users, status, and profile basics',
|
||||||
|
activeMenuKey: 'admin_users',
|
||||||
|
};
|
||||||
|
case 'admin-roles':
|
||||||
|
return {
|
||||||
|
title: isZh ? '角色管理' : 'Role Management',
|
||||||
|
subtitle: isZh ? '管理角色以及菜单、权限分配' : 'Manage roles and their menu / permission grants',
|
||||||
|
activeMenuKey: 'admin_roles',
|
||||||
|
};
|
||||||
|
case 'profile-usage-logs':
|
||||||
|
return {
|
||||||
|
title: isZh ? '使用日志' : 'Usage Logs',
|
||||||
|
subtitle: isZh ? '个人使用日志入口占位,后续将接入明细能力' : 'Placeholder for personal usage logs',
|
||||||
|
activeMenuKey: 'profile_usage_logs',
|
||||||
|
};
|
||||||
|
case 'profile-api-tokens':
|
||||||
|
return {
|
||||||
|
title: isZh ? 'API 令牌' : 'API Tokens',
|
||||||
|
subtitle: isZh ? '个人 API 令牌入口占位,后续将接入管理能力' : 'Placeholder for personal API token management',
|
||||||
|
activeMenuKey: 'profile_api_tokens',
|
||||||
|
};
|
||||||
|
case 'bot':
|
||||||
|
return {
|
||||||
|
title: isZh ? 'Bot 工作台' : 'Bot Workspace',
|
||||||
|
subtitle: isZh ? '单个 Bot 的聊天与运行面板' : 'Chat and runtime panel for a single bot',
|
||||||
|
activeMenuKey: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectMenuKeys(menus: SysMenuItem[]): Set<string> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const visit = (items: SysMenuItem[]) => {
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.menu_key) keys.add(item.menu_key);
|
||||||
|
if (Array.isArray(item.children) && item.children.length > 0) visit(item.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
visit(menus || []);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useEffect, type CSSProperties, type ReactNode } from 'react';
|
||||||
|
import { Maximize2, Minimize2, X } from 'lucide-react';
|
||||||
|
import { LucentIconButton } from './LucentIconButton';
|
||||||
|
|
||||||
|
export type LucentDrawerSize = 'default' | 'expand';
|
||||||
|
|
||||||
|
interface LucentDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
size?: LucentDrawerSize;
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleSize?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
headerActions?: ReactNode;
|
||||||
|
topOffset?: number;
|
||||||
|
panelClassName?: string;
|
||||||
|
bodyClassName?: string;
|
||||||
|
closeLabel?: string;
|
||||||
|
expandLabel?: string;
|
||||||
|
collapseLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LucentDrawer({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
size = 'default',
|
||||||
|
onClose,
|
||||||
|
onToggleSize,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
headerActions,
|
||||||
|
topOffset = 0,
|
||||||
|
panelClassName = '',
|
||||||
|
bodyClassName = '',
|
||||||
|
closeLabel = 'Close panel',
|
||||||
|
expandLabel = 'Expand panel',
|
||||||
|
collapseLabel = 'Collapse panel',
|
||||||
|
}: LucentDrawerProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose, open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`lucent-drawer-mask ${open ? 'is-open' : ''} ${size === 'expand' ? 'is-expand' : ''}`}
|
||||||
|
onClick={open ? onClose : undefined}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<aside
|
||||||
|
className={`lucent-drawer ${open ? 'is-open' : ''} is-${size}`}
|
||||||
|
style={{ '--lucent-drawer-top': `${topOffset}px` } as CSSProperties}
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
<div className={`lucent-drawer-panel card ${panelClassName}`.trim()} onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="lucent-drawer-header">
|
||||||
|
<div className="lucent-drawer-header-copy">
|
||||||
|
<div className="section-mini-title">{title}</div>
|
||||||
|
{description ? <div className="field-label">{description}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="lucent-drawer-header-actions">
|
||||||
|
{headerActions}
|
||||||
|
{onToggleSize ? (
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
onClick={onToggleSize}
|
||||||
|
tooltip={size === 'expand' ? collapseLabel : expandLabel}
|
||||||
|
aria-label={size === 'expand' ? collapseLabel : expandLabel}
|
||||||
|
>
|
||||||
|
{size === 'expand' ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||||
|
</LucentIconButton>
|
||||||
|
) : null}
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
onClick={onClose}
|
||||||
|
tooltip={closeLabel}
|
||||||
|
aria-label={closeLabel}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`lucent-drawer-body ${bodyClassName}`.trim()}>{children}</div>
|
||||||
|
{footer ? <div className="lucent-drawer-footer">{footer}</div> : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,7 +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 } from '../utils/botAccess';
|
import { buildMonitorWsUrl, isBotUnauthorizedError } 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();
|
||||||
|
|
@ -84,6 +84,7 @@ export function useBotsSync(forcedBotId?: string) {
|
||||||
const lastAssistantRef = useRef<Record<string, { text: string; ts: number }>>({});
|
const lastAssistantRef = useRef<Record<string, { text: string; ts: number }>>({});
|
||||||
const lastProgressRef = useRef<Record<string, { text: string; ts: number }>>({});
|
const lastProgressRef = useRef<Record<string, { text: string; ts: number }>>({});
|
||||||
const hydratedMessagesRef = useRef<Record<string, boolean>>({});
|
const hydratedMessagesRef = useRef<Record<string, boolean>>({});
|
||||||
|
const unauthorizedBotsRef = useRef<Record<string, boolean>>({});
|
||||||
const isZh = useAppStore((s) => s.locale === 'zh');
|
const isZh = useAppStore((s) => s.locale === 'zh');
|
||||||
const locale = useAppStore((s) => s.locale);
|
const locale = useAppStore((s) => s.locale);
|
||||||
const t = pickLocale(locale, { 'zh-cn': botsSyncZhCn, en: botsSyncEn });
|
const t = pickLocale(locale, { 'zh-cn': botsSyncZhCn, en: botsSyncEn });
|
||||||
|
|
@ -97,6 +98,7 @@ export function useBotsSync(forcedBotId?: string) {
|
||||||
async (botId: string) => {
|
async (botId: string) => {
|
||||||
const target = String(botId || '').trim();
|
const target = String(botId || '').trim();
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
if (unauthorizedBotsRef.current[target]) return;
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<{ items?: any[] }>(`${APP_ENDPOINTS.apiBase}/bots/${target}/messages/page`);
|
const res = await axios.get<{ items?: any[] }>(`${APP_ENDPOINTS.apiBase}/bots/${target}/messages/page`);
|
||||||
const rows = Array.isArray(res.data?.items) ? res.data.items : [];
|
const rows = Array.isArray(res.data?.items) ? res.data.items : [];
|
||||||
|
|
@ -136,6 +138,10 @@ export function useBotsSync(forcedBotId?: string) {
|
||||||
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
|
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
|
||||||
if (lastAssistant) lastAssistantRef.current[target] = { text: lastAssistant.text, ts: lastAssistant.ts };
|
if (lastAssistant) lastAssistantRef.current[target] = { text: lastAssistant.text, ts: lastAssistant.ts };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isBotUnauthorizedError(error, target)) {
|
||||||
|
unauthorizedBotsRef.current[target] = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error(`Failed to sync bot messages for ${target}`, error);
|
console.error(`Failed to sync bot messages for ${target}`, error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -147,10 +153,15 @@ export function useBotsSync(forcedBotId?: string) {
|
||||||
try {
|
try {
|
||||||
if (forced) {
|
if (forced) {
|
||||||
const res = await axios.get<BotState>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}`);
|
const res = await axios.get<BotState>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}`);
|
||||||
|
delete unauthorizedBotsRef.current[forced];
|
||||||
setBots(res.data ? [res.data] : []);
|
setBots(res.data ? [res.data] : []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
|
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
|
||||||
|
(Array.isArray(res.data) ? res.data : []).forEach((bot) => {
|
||||||
|
const botId = String(bot?.id || '').trim();
|
||||||
|
if (botId) delete unauthorizedBotsRef.current[botId];
|
||||||
|
});
|
||||||
setBots(res.data);
|
setBots(res.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(forced ? `Failed to fetch bot ${forced}` : 'Failed to fetch bots', error);
|
console.error(forced ? `Failed to fetch bot ${forced}` : 'Failed to fetch bots', error);
|
||||||
|
|
@ -171,6 +182,7 @@ export function useBotsSync(forcedBotId?: string) {
|
||||||
Object.keys(hydratedMessagesRef.current).forEach((botId) => {
|
Object.keys(hydratedMessagesRef.current).forEach((botId) => {
|
||||||
if (!aliveIds.has(botId)) {
|
if (!aliveIds.has(botId)) {
|
||||||
delete hydratedMessagesRef.current[botId];
|
delete hydratedMessagesRef.current[botId];
|
||||||
|
delete unauthorizedBotsRef.current[botId];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -204,6 +216,7 @@ export function useBotsSync(forcedBotId?: string) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runningIds = new Set(
|
const runningIds = new Set(
|
||||||
Object.values(activeBots)
|
Object.values(activeBots)
|
||||||
|
.filter((bot) => !unauthorizedBotsRef.current[bot.id])
|
||||||
.filter((bot) => bot.docker_status === 'RUNNING')
|
.filter((bot) => bot.docker_status === 'RUNNING')
|
||||||
.map((bot) => bot.id),
|
.map((bot) => bot.id),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,9 @@ export const dashboardEn = {
|
||||||
nodeGroupCount: (count: number) => `${count} bot${count === 1 ? '' : 's'}`,
|
nodeGroupCount: (count: number) => `${count} bot${count === 1 ? '' : 's'}`,
|
||||||
botSearchPlaceholder: 'Search by bot name or ID',
|
botSearchPlaceholder: 'Search by bot name or ID',
|
||||||
botSearchNoResult: 'No matching bots.',
|
botSearchNoResult: 'No matching bots.',
|
||||||
workspaceSearchPlaceholder: 'Search by file name or path',
|
workspaceSearchPlaceholder: 'Search workspace files',
|
||||||
workspaceSearchNoResult: 'No matching files or folders.',
|
workspaceSearchNoResult: 'No matching files or folders.',
|
||||||
searchAction: 'Search',
|
searchAction: 'Search workspace files',
|
||||||
clearSearch: 'Clear search',
|
clearSearch: 'Clear search',
|
||||||
syncingPageSize: 'Syncing page size...',
|
syncingPageSize: 'Syncing page size...',
|
||||||
paginationPrev: 'Prev',
|
paginationPrev: 'Prev',
|
||||||
|
|
@ -125,6 +125,8 @@ export const dashboardEn = {
|
||||||
clearHistoryFail: 'Failed to clear conversation history.',
|
clearHistoryFail: 'Failed to clear conversation history.',
|
||||||
exportHistory: 'Export JSON',
|
exportHistory: 'Export JSON',
|
||||||
exportHistoryFail: 'Failed to export conversation.',
|
exportHistoryFail: 'Failed to export conversation.',
|
||||||
|
quickActions: 'Quick Actions',
|
||||||
|
resourceMonitor: 'Resource Monitor',
|
||||||
you: 'You',
|
you: 'You',
|
||||||
user: 'User',
|
user: 'User',
|
||||||
inputPlaceholder: 'Type a command and press Enter (Shift+Enter for newline)',
|
inputPlaceholder: 'Type a command and press Enter (Shift+Enter for newline)',
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,10 @@ export const dashboardZhCn = {
|
||||||
nodeGroupCount: (count: number) => `${count} 个 Bot`,
|
nodeGroupCount: (count: number) => `${count} 个 Bot`,
|
||||||
botSearchPlaceholder: '按 Bot 名称或 ID 搜索',
|
botSearchPlaceholder: '按 Bot 名称或 ID 搜索',
|
||||||
botSearchNoResult: '没有匹配的 Bot。',
|
botSearchNoResult: '没有匹配的 Bot。',
|
||||||
workspaceSearchPlaceholder: '按文件名或路径搜索',
|
workspaceSearchPlaceholder: '搜索工作区文件',
|
||||||
workspaceSearchNoResult: '没有匹配的文件或目录。',
|
workspaceSearchNoResult: '没有匹配的文件或目录。',
|
||||||
searchAction: '搜索',
|
searchAction: '搜索工作区文件',
|
||||||
clearSearch: '清除搜索',
|
clearSearch: '清空搜索',
|
||||||
syncingPageSize: '正在同步分页设置...',
|
syncingPageSize: '正在同步分页设置...',
|
||||||
paginationPrev: '上一页',
|
paginationPrev: '上一页',
|
||||||
paginationNext: '下一页',
|
paginationNext: '下一页',
|
||||||
|
|
@ -125,6 +125,8 @@ export const dashboardZhCn = {
|
||||||
clearHistoryFail: '清空对话历史失败。',
|
clearHistoryFail: '清空对话历史失败。',
|
||||||
exportHistory: '导出对话',
|
exportHistory: '导出对话',
|
||||||
exportHistoryFail: '导出对话失败。',
|
exportHistoryFail: '导出对话失败。',
|
||||||
|
quickActions: '快捷操作',
|
||||||
|
resourceMonitor: '资源监控',
|
||||||
you: '你',
|
you: '你',
|
||||||
user: '用户',
|
user: '用户',
|
||||||
inputPlaceholder: '输入指令后回车发送(Shift+Enter 换行)',
|
inputPlaceholder: '输入指令后回车发送(Shift+Enter 换行)',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export const wizardEn = {
|
export const wizardEn = {
|
||||||
title: 'Guided Bot Creation',
|
title: '',
|
||||||
sub: 'Select image first, then configure model and agent files.',
|
sub: '',
|
||||||
s1: '1. Select READY image',
|
s1: '1. Select READY image',
|
||||||
s2: '2. Model and params',
|
s2: '2. Model and params',
|
||||||
s3: '3. Agent files',
|
s3: '3. Agent files',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export const wizardZhCn = {
|
export const wizardZhCn = {
|
||||||
title: '引导式 Bot 创建',
|
title: '',
|
||||||
sub: '先选镜像,再配置模型与代理文件。',
|
sub: '',
|
||||||
s1: '1. 选择 READY 镜像',
|
s1: '1. 选择 READY 镜像',
|
||||||
s2: '2. 模型与参数',
|
s2: '2. 模型与参数',
|
||||||
s3: '3. 代理文件配置',
|
s3: '3. 代理文件配置',
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ 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'
|
import { setupBotAccessAuth } from './utils/botAccess.ts'
|
||||||
import { setupPanelAccessAuth } from './utils/panelAccess.ts'
|
import { setupSessionAuth } from './utils/sessionAuth.ts'
|
||||||
|
|
||||||
setupPanelAccessAuth();
|
setupSessionAuth();
|
||||||
setupBotAccessAuth();
|
setupBotAccessAuth();
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,17 @@ import { BotDashboardModule } from '../dashboard/BotDashboardModule';
|
||||||
interface BotHomePageProps {
|
interface BotHomePageProps {
|
||||||
botId: string;
|
botId: string;
|
||||||
compactMode: boolean;
|
compactMode: boolean;
|
||||||
|
initialCompactPanelTab?: 'chat' | 'runtime';
|
||||||
|
hideCompactFab?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BotHomePage({ botId, compactMode }: BotHomePageProps) {
|
export function BotHomePage({ botId, compactMode, initialCompactPanelTab, hideCompactFab }: BotHomePageProps) {
|
||||||
return <BotDashboardModule forcedBotId={botId} compactMode={compactMode} />;
|
return (
|
||||||
|
<BotDashboardModule
|
||||||
|
forcedBotId={botId}
|
||||||
|
compactMode={compactMode}
|
||||||
|
initialCompactPanelTab={initialCompactPanelTab}
|
||||||
|
hideCompactFab={hideCompactFab}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ChevronRight, ChevronsLeftRight, MessageCircle } from 'lucide-react';
|
||||||
|
import { BotHomePage } from '../bot-home/BotHomePage';
|
||||||
|
import { useAppStore } from '../../store/appStore';
|
||||||
|
import type { BotState } from '../../types/bot';
|
||||||
|
import type { SysAuthBootstrap } from '../../types/sys';
|
||||||
|
|
||||||
|
interface ChatWorkspacePageProps {
|
||||||
|
authBootstrap: SysAuthBootstrap;
|
||||||
|
isZh: boolean;
|
||||||
|
compactMode: boolean;
|
||||||
|
initialCompactPanelTab?: 'chat' | 'runtime';
|
||||||
|
hideCompactFab?: boolean;
|
||||||
|
selectedBotId?: string;
|
||||||
|
onSelectedBotChange?: (botId: string) => void;
|
||||||
|
hideRail?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatusTone(bot?: BotState) {
|
||||||
|
if (!bot) return 'is-stopped';
|
||||||
|
if (bot.enabled === false) return 'is-disabled';
|
||||||
|
if (String(bot.docker_status || '').toUpperCase() === 'RUNNING') return 'is-running';
|
||||||
|
return 'is-stopped';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatWorkspacePage({
|
||||||
|
authBootstrap,
|
||||||
|
isZh,
|
||||||
|
compactMode,
|
||||||
|
initialCompactPanelTab,
|
||||||
|
hideCompactFab,
|
||||||
|
selectedBotId: controlledSelectedBotId,
|
||||||
|
onSelectedBotChange,
|
||||||
|
hideRail = false,
|
||||||
|
}: ChatWorkspacePageProps) {
|
||||||
|
const activeBots = useAppStore((state) => state.activeBots);
|
||||||
|
const [internalSelectedBotId, setInternalSelectedBotId] = useState('');
|
||||||
|
const [railExpanded, setRailExpanded] = useState(false);
|
||||||
|
|
||||||
|
const orderedBots = useMemo(() => {
|
||||||
|
const assigned = Array.isArray(authBootstrap.assigned_bots) ? authBootstrap.assigned_bots : [];
|
||||||
|
if (assigned.length > 0) {
|
||||||
|
const liveBotIds = new Set(Object.keys(activeBots).filter((botId) => String(botId || '').trim()));
|
||||||
|
const preferLiveIntersection = liveBotIds.size > 0;
|
||||||
|
return assigned
|
||||||
|
.filter((item) => !preferLiveIntersection || liveBotIds.has(String(item.id || '').trim()))
|
||||||
|
.map((item) => {
|
||||||
|
const live = activeBots[item.id];
|
||||||
|
return live
|
||||||
|
? { ...live, node_display_name: live.node_display_name || item.node_display_name || item.node_id }
|
||||||
|
: ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
enabled: item.enabled,
|
||||||
|
docker_status: item.docker_status,
|
||||||
|
node_id: item.node_id,
|
||||||
|
node_display_name: item.node_display_name,
|
||||||
|
image_tag: item.image_tag,
|
||||||
|
avatar_model: 'base',
|
||||||
|
logs: [],
|
||||||
|
} as BotState);
|
||||||
|
})
|
||||||
|
.filter((bot) => String(bot.id || '').trim());
|
||||||
|
}
|
||||||
|
return Object.values(activeBots).sort((left, right) => String(left.name || left.id).localeCompare(String(right.name || right.id)));
|
||||||
|
}, [activeBots, authBootstrap.assigned_bots]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderedBots.length) {
|
||||||
|
setInternalSelectedBotId('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetId = String(controlledSelectedBotId || internalSelectedBotId || '').trim();
|
||||||
|
if (!targetId || !orderedBots.some((bot) => bot.id === targetId)) {
|
||||||
|
const fallbackId = orderedBots[0]?.id || '';
|
||||||
|
setInternalSelectedBotId(fallbackId);
|
||||||
|
onSelectedBotChange?.(fallbackId);
|
||||||
|
}
|
||||||
|
}, [controlledSelectedBotId, internalSelectedBotId, onSelectedBotChange, orderedBots]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (orderedBots.length <= 1) {
|
||||||
|
setRailExpanded(false);
|
||||||
|
}
|
||||||
|
}, [orderedBots.length]);
|
||||||
|
|
||||||
|
const selectedBotId = String(controlledSelectedBotId || internalSelectedBotId || '').trim();
|
||||||
|
|
||||||
|
const selectedBot = orderedBots.find((bot) => bot.id === selectedBotId) || orderedBots[0];
|
||||||
|
const selectedTone = normalizeStatusTone(selectedBot);
|
||||||
|
const selectedShortName = String(selectedBot?.name || selectedBot?.id || '')
|
||||||
|
.slice(0, 1)
|
||||||
|
.toUpperCase() || 'B';
|
||||||
|
|
||||||
|
if (!orderedBots.length) {
|
||||||
|
return (
|
||||||
|
<div className="platform-page-stack">
|
||||||
|
<section className="panel stack chat-workspace-empty-panel">
|
||||||
|
<div className="ops-empty-inline">
|
||||||
|
{isZh ? '当前账号还没有分配可用的 Bot。' : 'No bots are assigned to this account yet.'}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`chat-workspace-page ${hideRail ? 'is-rail-hidden' : ''}`}>
|
||||||
|
{!hideRail ? (
|
||||||
|
<aside className="chat-workspace-rail">
|
||||||
|
<div className="chat-workspace-rail-stack">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chat-workspace-rail-tab ${selectedTone} is-active`}
|
||||||
|
onClick={() => {
|
||||||
|
if (orderedBots.length > 1) {
|
||||||
|
setRailExpanded((value) => !value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={selectedBot?.name || selectedBot?.id}
|
||||||
|
aria-label={selectedBot?.name || selectedBot?.id || (isZh ? '当前 Bot' : 'Current bot')}
|
||||||
|
>
|
||||||
|
<span>{selectedShortName}</span>
|
||||||
|
</button>
|
||||||
|
{orderedBots.length > 1 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chat-workspace-rail-toggle ${railExpanded ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setRailExpanded((value) => !value)}
|
||||||
|
title={isZh ? '展开 Bot 切换' : 'Expand bot switcher'}
|
||||||
|
aria-label={isZh ? '展开 Bot 切换' : 'Expand bot switcher'}
|
||||||
|
>
|
||||||
|
{railExpanded ? <ChevronRight size={14} /> : <ChevronsLeftRight size={14} />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{railExpanded ? (
|
||||||
|
<div className="chat-workspace-popout">
|
||||||
|
<div className="chat-workspace-popout-head">
|
||||||
|
<strong>{isZh ? '切换 Bot' : 'Switch Bot'}</strong>
|
||||||
|
<span>{isZh ? `${orderedBots.length} 个` : `${orderedBots.length}`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="chat-workspace-popout-list">
|
||||||
|
{orderedBots.map((bot) => {
|
||||||
|
const selected = bot.id === selectedBotId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={bot.id}
|
||||||
|
type="button"
|
||||||
|
className={`chat-workspace-popout-item ${selected ? 'is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setInternalSelectedBotId(bot.id);
|
||||||
|
onSelectedBotChange?.(bot.id);
|
||||||
|
setRailExpanded(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{bot.name || bot.id}</strong>
|
||||||
|
<span className="mono">{bot.id}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className={`chat-workspace-main chat-workspace-main-attached ${compactMode ? 'is-compact' : ''}`}>
|
||||||
|
{selectedBotId ? (
|
||||||
|
<BotHomePage
|
||||||
|
botId={selectedBotId}
|
||||||
|
compactMode={compactMode}
|
||||||
|
initialCompactPanelTab={initialCompactPanelTab}
|
||||||
|
hideCompactFab={hideCompactFab}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="panel stack chat-workspace-empty-panel">
|
||||||
|
<div className="ops-empty-inline">
|
||||||
|
<MessageCircle size={16} />
|
||||||
|
<span>{isZh ? '从左侧选择一个 Bot 开始使用。' : 'Select a bot from the left to start.'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -144,6 +144,11 @@
|
||||||
|
|
||||||
.ops-search-input {
|
.ops-search-input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
height: 36px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-search-input-with-icon {
|
.ops-search-input-with-icon {
|
||||||
|
|
@ -604,7 +609,7 @@
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 12px;
|
left: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
top: 58px;
|
top: 12px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
z-index: 72;
|
z-index: 72;
|
||||||
animation: ops-compact-sheet-in 220ms ease;
|
animation: ops-compact-sheet-in 220ms ease;
|
||||||
|
|
@ -612,7 +617,7 @@
|
||||||
|
|
||||||
.ops-compact-close-btn {
|
.ops-compact-close-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 18px;
|
top: 16px;
|
||||||
right: 14px;
|
right: 14px;
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
|
@ -2068,6 +2073,80 @@
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ops-runtime-shortcuts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-runtime-shortcut {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 74%, transparent);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 78%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-runtime-shortcut:hover {
|
||||||
|
border-color: color-mix(in oklab, var(--brand) 42%, var(--line) 58%);
|
||||||
|
background: color-mix(in oklab, var(--brand-soft) 16%, var(--panel-soft) 84%);
|
||||||
|
transform: translateY(var(--hover-lift-y));
|
||||||
|
box-shadow: var(--hover-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-runtime-shortcut:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-runtime-shortcut.is-danger:hover {
|
||||||
|
border-color: rgba(215, 102, 102, 0.34);
|
||||||
|
background: rgba(215, 102, 102, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-runtime-shortcut-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: color-mix(in oklab, var(--brand-soft) 60%, var(--panel) 40%);
|
||||||
|
color: var(--title);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-runtime-shortcut.is-danger .ops-runtime-shortcut-icon {
|
||||||
|
background: rgba(215, 102, 102, 0.16);
|
||||||
|
color: #d76666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-runtime-shortcut-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-runtime-shortcut-copy strong {
|
||||||
|
color: var(--title);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-runtime-shortcut-copy span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.ops-topic-feed {
|
.ops-topic-feed {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -2730,10 +2809,43 @@
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-modal {
|
.ops-bot-panel-drawer-body {
|
||||||
min-height: clamp(480px, 68vh, 760px);
|
min-height: 0;
|
||||||
|
padding-top: 16px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-bot-panel-drawer-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-modal {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-bot-panel-drawer-footer {
|
||||||
|
width: 100%;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lucent-drawer-footer .ops-skill-add-bar {
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-bot-panel-drawer-body .ops-skills-list-scroll,
|
||||||
|
.ops-bot-panel-drawer-body .ops-config-list-scroll,
|
||||||
|
.ops-bot-panel-drawer-body .ops-cron-list-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-list-scroll {
|
.ops-config-list-scroll {
|
||||||
|
|
@ -2825,10 +2937,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-footer {
|
.ops-config-footer {
|
||||||
position: sticky;
|
position: static;
|
||||||
bottom: 0;
|
background: transparent;
|
||||||
background: var(--panel);
|
border-top: 0;
|
||||||
border-top: 1px solid color-mix(in oklab, var(--line) 78%, transparent);
|
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2898,6 +3009,14 @@
|
||||||
min-height: clamp(420px, 62vh, 640px);
|
min-height: clamp(420px, 62vh, 640px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ops-bot-panel-drawer-body {
|
||||||
|
padding: 14px 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-bot-panel-drawer-body .row-between {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.ops-config-list-scroll {
|
.ops-config-list-scroll {
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
}
|
}
|
||||||
|
|
@ -2924,10 +3043,13 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-config-footer {
|
.ops-bot-panel-drawer-footer {
|
||||||
position: static;
|
align-items: stretch;
|
||||||
border-top: 0;
|
}
|
||||||
padding-top: 0;
|
|
||||||
|
.ops-bot-panel-drawer-footer .ops-topic-create-menu-wrap,
|
||||||
|
.ops-bot-panel-drawer-footer .row-actions-inline {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,126 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { APP_ENDPOINTS } from '../../../config/env';
|
||||||
|
import type { BotSkillMarketItem } from '../../platform/types';
|
||||||
|
import type {
|
||||||
|
BotChannel,
|
||||||
|
BotTopic,
|
||||||
|
CronJobsResponse,
|
||||||
|
MCPConfigResponse,
|
||||||
|
MarketSkillInstallResponse,
|
||||||
|
SkillUploadResponse,
|
||||||
|
TopicFeedListResponse,
|
||||||
|
TopicFeedStatsResponse,
|
||||||
|
WorkspaceSkillOption,
|
||||||
|
} from '../botDashboardShared';
|
||||||
|
|
||||||
|
export function getBotTopics(botId: string) {
|
||||||
|
return axios.get<BotTopic[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topics`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBotTopic(botId: string, topicKey: string, payload: Record<string, unknown>) {
|
||||||
|
return axios.put(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topics/${encodeURIComponent(topicKey)}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBotTopic(botId: string, payload: Record<string, unknown>) {
|
||||||
|
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topics`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBotTopic(botId: string, topicKey: string) {
|
||||||
|
return axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topics/${encodeURIComponent(topicKey)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTopicFeedItems(botId: string, params: Record<string, string | number>) {
|
||||||
|
return axios.get<TopicFeedListResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topic-items`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTopicFeedStatsApi(botId: string) {
|
||||||
|
return axios.get<TopicFeedStatsResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topic-items/stats`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markTopicFeedItemReadApi(botId: string, itemId: number) {
|
||||||
|
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topic-items/${itemId}/read`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTopicFeedItemApi(botId: string, itemId: number) {
|
||||||
|
return axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topic-items/${itemId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBotChannels(botId: string) {
|
||||||
|
return axios.get<BotChannel[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBotChannel(botId: string, channelId: string | number, payload: Record<string, unknown>) {
|
||||||
|
return axios.put(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels/${channelId}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBotChannel(botId: string, payload: Record<string, unknown>) {
|
||||||
|
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBotChannel(botId: string, channelId: string | number) {
|
||||||
|
return axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels/${channelId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBotSkills(botId: string) {
|
||||||
|
return axios.get<WorkspaceSkillOption[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skills`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSkillMarket(botId: string) {
|
||||||
|
return axios.get<BotSkillMarketItem[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skill-market`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBotSkillApi(botId: string, skillId: string) {
|
||||||
|
return axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skills/${encodeURIComponent(skillId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installMarketSkillApi(botId: string, skillId: number) {
|
||||||
|
return axios.post<MarketSkillInstallResponse>(
|
||||||
|
`${APP_ENDPOINTS.apiBase}/bots/${botId}/skill-market/${skillId}/install`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadBotSkillZipApi(botId: string, formData: FormData) {
|
||||||
|
return axios.post<SkillUploadResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skills/upload`, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBotEnvParams(botId: string) {
|
||||||
|
return axios.get<{ env_params?: Record<string, string> }>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/env-params`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBotEnvParams(botId: string, envParams: Record<string, string>) {
|
||||||
|
return axios.put(`${APP_ENDPOINTS.apiBase}/bots/${botId}/env-params`, { env_params: envParams });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBotMcpConfig(botId: string) {
|
||||||
|
return axios.get<MCPConfigResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/mcp-config`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBotMcpConfig(botId: string, mcpServers: Record<string, unknown>) {
|
||||||
|
return axios.put(`${APP_ENDPOINTS.apiBase}/bots/${botId}/mcp-config`, { mcp_servers: mcpServers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBotCronJobs(botId: string) {
|
||||||
|
return axios.get<CronJobsResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/cron/jobs`, {
|
||||||
|
params: { include_disabled: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopBotCronJobApi(botId: string, jobId: string) {
|
||||||
|
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/cron/jobs/${jobId}/stop`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBotCronJobApi(botId: string, jobId: string) {
|
||||||
|
return axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${botId}/cron/jobs/${jobId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBotGlobalDelivery(botId: string, payload: { send_progress: boolean; send_tool_hints: boolean }) {
|
||||||
|
return axios.put(`${APP_ENDPOINTS.apiBase}/bots/${botId}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopBotRuntimeApi(botId: string) {
|
||||||
|
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/stop`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startBotRuntimeApi(botId: string) {
|
||||||
|
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/start`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { ChatMessage } from '../../types/bot';
|
||||||
|
import { normalizeAttachmentPaths } from './shared/botDashboardWorkspace';
|
||||||
|
|
||||||
|
export * from './shared/botDashboardTypes';
|
||||||
|
export * from './shared/botDashboardConstants';
|
||||||
|
export * from './shared/botDashboardWorkspace';
|
||||||
|
export * from './shared/botDashboardUtils';
|
||||||
|
export * from './shared/botDashboardConversation';
|
||||||
|
export * from './shared/botDashboardTopicUtils';
|
||||||
|
|
||||||
|
export function mapBotMessageResponseRow(row: any): ChatMessage {
|
||||||
|
const roleRaw = String(row?.role || '').toLowerCase();
|
||||||
|
const role: ChatMessage['role'] =
|
||||||
|
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
|
||||||
|
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
|
||||||
|
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
|
||||||
|
return {
|
||||||
|
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
|
||||||
|
role,
|
||||||
|
text: String(row?.text || ''),
|
||||||
|
attachments: normalizeAttachmentPaths(row?.media),
|
||||||
|
ts: Number(row?.ts || Date.now()),
|
||||||
|
feedback,
|
||||||
|
kind: 'final',
|
||||||
|
} as ChatMessage;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
export type BotPanelKey = 'base' | 'params' | 'channels' | 'topic' | 'env' | 'skills' | 'mcp' | 'cron' | 'agent';
|
||||||
|
|
||||||
|
export interface BotPanelDefinition {
|
||||||
|
key: BotPanelKey;
|
||||||
|
labelZh: string;
|
||||||
|
labelEn: string;
|
||||||
|
iconKey: 'settings' | 'sliders' | 'channels' | 'topic' | 'env' | 'skills' | 'mcp' | 'cron' | 'agent';
|
||||||
|
routePanel: BotPanelKey;
|
||||||
|
visibility: 'admin' | 'user' | 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOT_PANEL_DEFINITIONS: BotPanelDefinition[] = [
|
||||||
|
{ key: 'base', labelZh: '基础', labelEn: 'Basics', iconKey: 'settings', routePanel: 'base', visibility: 'both' },
|
||||||
|
{ key: 'params', labelZh: '模型', labelEn: 'Model', iconKey: 'sliders', routePanel: 'params', visibility: 'both' },
|
||||||
|
{ key: 'channels', labelZh: '渠道', labelEn: 'Channels', iconKey: 'channels', routePanel: 'channels', visibility: 'both' },
|
||||||
|
{ key: 'topic', labelZh: '主题', labelEn: 'Topic', iconKey: 'topic', routePanel: 'topic', visibility: 'both' },
|
||||||
|
{ key: 'env', labelZh: '环境变量', labelEn: 'Env', iconKey: 'env', routePanel: 'env', visibility: 'both' },
|
||||||
|
{ key: 'skills', labelZh: '技能', labelEn: 'Skills', iconKey: 'skills', routePanel: 'skills', visibility: 'both' },
|
||||||
|
{ key: 'mcp', labelZh: 'MCP', labelEn: 'MCP', iconKey: 'mcp', routePanel: 'mcp', visibility: 'both' },
|
||||||
|
{ key: 'cron', labelZh: '定时任务', labelEn: 'Cron', iconKey: 'cron', routePanel: 'cron', visibility: 'both' },
|
||||||
|
{ key: 'agent', labelZh: '代理', labelEn: 'Agent', iconKey: 'agent', routePanel: 'agent', visibility: 'both' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeDeepLinkPanel(value: string | null | undefined, entry?: string | null | undefined): BotPanelKey | null {
|
||||||
|
const source = String(entry || '').trim().toLowerCase();
|
||||||
|
if (source && source !== 'node-shortcut') return null;
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
const found = BOT_PANEL_DEFINITIONS.find((item) => item.key === normalized);
|
||||||
|
return found ? found.key : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBotPanelHref(botId: string, panel?: BotPanelKey) {
|
||||||
|
const encodedId = encodeURIComponent(String(botId || '').trim());
|
||||||
|
if (!panel) return `/bot/${encodedId}`;
|
||||||
|
const params = new URLSearchParams({ panel, entry: 'node-shortcut' });
|
||||||
|
return `/bot/${encodedId}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
import { PasswordInput } from '../../../components/PasswordInput';
|
||||||
|
import type { channelsEn } from '../../../i18n/channels.en';
|
||||||
|
import type { channelsZhCn } from '../../../i18n/channels.zh-cn';
|
||||||
|
import type { BotChannel } from '../botDashboardShared';
|
||||||
|
import type { PasswordToggleLabels } from './BotConfigDrawerParts';
|
||||||
|
|
||||||
|
type ChannelMessages = typeof channelsZhCn | typeof channelsEn;
|
||||||
|
|
||||||
|
const parseChannelListValue = (raw: unknown): string => {
|
||||||
|
if (!Array.isArray(raw)) return '';
|
||||||
|
return raw
|
||||||
|
.map((item) => String(item || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseChannelListInput = (raw: string): string[] => {
|
||||||
|
const rows: string[] = [];
|
||||||
|
String(raw || '')
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.forEach((item) => {
|
||||||
|
const text = String(item || '').trim();
|
||||||
|
if (text && !rows.includes(text)) rows.push(text);
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isChannelConfigured(channel: BotChannel): boolean {
|
||||||
|
const ctype = String(channel.channel_type || '').trim().toLowerCase();
|
||||||
|
if (ctype === 'email') {
|
||||||
|
const extra = channel.extra_config || {};
|
||||||
|
return Boolean(
|
||||||
|
String(extra.imapHost || '').trim() &&
|
||||||
|
String(extra.imapUsername || '').trim() &&
|
||||||
|
String(extra.imapPassword || '').trim() &&
|
||||||
|
String(extra.smtpHost || '').trim() &&
|
||||||
|
String(extra.smtpUsername || '').trim() &&
|
||||||
|
String(extra.smtpPassword || '').trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Boolean(String(channel.external_app_id || '').trim() || String(channel.app_secret || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeChannelExtra(channelType: string, extra: Record<string, unknown>) {
|
||||||
|
const type = String(channelType || '').toLowerCase();
|
||||||
|
if (type === 'dashboard') return extra || {};
|
||||||
|
const next = { ...(extra || {}) };
|
||||||
|
delete next.sendProgress;
|
||||||
|
delete next.sendToolHints;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderBotChannelFieldsOptions {
|
||||||
|
channel: BotChannel;
|
||||||
|
lc: ChannelMessages;
|
||||||
|
onPatch: (patch: Partial<BotChannel>) => void;
|
||||||
|
passwordToggleLabels: PasswordToggleLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderBotChannelFields({
|
||||||
|
channel,
|
||||||
|
lc,
|
||||||
|
onPatch,
|
||||||
|
passwordToggleLabels,
|
||||||
|
}: RenderBotChannelFieldsOptions) {
|
||||||
|
const ctype = String(channel.channel_type).toLowerCase();
|
||||||
|
if (ctype === 'telegram') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PasswordInput className="input" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={lc.proxy}
|
||||||
|
value={String((channel.extra_config || {}).proxy || '')}
|
||||||
|
onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean((channel.extra_config || {}).replyToMessage)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPatch({ extra_config: { ...(channel.extra_config || {}), replyToMessage: e.target.checked } })
|
||||||
|
}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{lc.replyToMessage}
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype === 'feishu') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
||||||
|
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||||
|
<input className="input" placeholder={lc.encryptKey} value={String((channel.extra_config || {}).encryptKey || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} autoComplete="off" />
|
||||||
|
<input className="input" placeholder={lc.verificationToken} value={String((channel.extra_config || {}).verificationToken || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} autoComplete="off" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype === 'dingtalk') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input className="input" placeholder={lc.clientId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
||||||
|
<PasswordInput className="input" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype === 'slack') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input className="input" placeholder={lc.botToken} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
||||||
|
<PasswordInput className="input" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype === 'qq') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
||||||
|
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype === 'email') {
|
||||||
|
const extra = channel.extra_config || {};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(extra.consentGranted)}
|
||||||
|
onChange={(e) => onPatch({ extra_config: { ...extra, consentGranted: e.target.checked } })}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{lc.emailConsentGranted}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailFromAddress}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={String(extra.fromAddress || '')}
|
||||||
|
onChange={(e) => onPatch({ extra_config: { ...extra, fromAddress: e.target.value } })}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field ops-config-field-full">
|
||||||
|
<label className="field-label">{lc.emailAllowFrom}</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea"
|
||||||
|
rows={3}
|
||||||
|
value={parseChannelListValue(extra.allowFrom)}
|
||||||
|
onChange={(e) => onPatch({ extra_config: { ...extra, allowFrom: parseChannelListInput(e.target.value) } })}
|
||||||
|
placeholder={lc.emailAllowFromPlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailImapHost}</label>
|
||||||
|
<input className="input" value={String(extra.imapHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapHost: e.target.value } })} autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailImapPort}</label>
|
||||||
|
<input className="input mono" type="number" min="1" max="65535" value={String(extra.imapPort ?? 993)} onChange={(e) => onPatch({ extra_config: { ...extra, imapPort: Number(e.target.value || 993) } })} />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailImapUsername}</label>
|
||||||
|
<input className="input" value={String(extra.imapUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapUsername: e.target.value } })} autoComplete="username" />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailImapPassword}</label>
|
||||||
|
<PasswordInput className="input" value={String(extra.imapPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailImapMailbox}</label>
|
||||||
|
<input className="input" value={String(extra.imapMailbox || 'INBOX')} onChange={(e) => onPatch({ extra_config: { ...extra, imapMailbox: e.target.value } })} autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(extra.imapUseSsl ?? true)}
|
||||||
|
onChange={(e) => onPatch({ extra_config: { ...extra, imapUseSsl: e.target.checked } })}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{lc.emailImapUseSsl}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailSmtpHost}</label>
|
||||||
|
<input className="input" value={String(extra.smtpHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpHost: e.target.value } })} autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailSmtpPort}</label>
|
||||||
|
<input className="input mono" type="number" min="1" max="65535" value={String(extra.smtpPort ?? 587)} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPort: Number(e.target.value || 587) } })} />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailSmtpUsername}</label>
|
||||||
|
<input className="input" value={String(extra.smtpUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpUsername: e.target.value } })} autoComplete="username" />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailSmtpPassword}</label>
|
||||||
|
<PasswordInput className="input" value={String(extra.smtpPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(extra.smtpUseTls ?? true)}
|
||||||
|
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseTls: e.target.checked } })}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{lc.emailSmtpUseTls}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(extra.smtpUseSsl)}
|
||||||
|
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseSsl: e.target.checked } })}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{lc.emailSmtpUseSsl}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(extra.autoReplyEnabled ?? true)}
|
||||||
|
onChange={(e) => onPatch({ extra_config: { ...extra, autoReplyEnabled: e.target.checked } })}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{lc.emailAutoReplyEnabled}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailPollIntervalSeconds}</label>
|
||||||
|
<input className="input mono" type="number" min="5" max="3600" value={String(extra.pollIntervalSeconds ?? 30)} onChange={(e) => onPatch({ extra_config: { ...extra, pollIntervalSeconds: Number(e.target.value || 30) } })} />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailMaxBodyChars}</label>
|
||||||
|
<input className="input mono" type="number" min="1" max="50000" value={String(extra.maxBodyChars ?? 12000)} onChange={(e) => onPatch({ extra_config: { ...extra, maxBodyChars: Number(e.target.value || 12000) } })} />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{lc.emailSubjectPrefix}</label>
|
||||||
|
<input className="input" value={String(extra.subjectPrefix || 'Re: ')} onChange={(e) => onPatch({ extra_config: { ...extra, subjectPrefix: e.target.value } })} autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(extra.markSeen ?? true)}
|
||||||
|
onChange={(e) => onPatch({ extra_config: { ...extra, markSeen: e.target.checked } })}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{lc.emailMarkSeen}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { AgentFilesDrawerContent, BaseConfigDrawerContent, CronJobsDrawerContent, EnvParamsDrawerContent, ModelParamsDrawerContent } from './config-drawers/GeneralDrawers';
|
||||||
|
export { ChannelDrawerContent, TopicDrawerContent } from './config-drawers/ChannelTopicDrawers';
|
||||||
|
export { McpDrawerContent, SkillsAddBar, SkillsDrawerContent } from './config-drawers/SkillsMcpDrawers';
|
||||||
|
export type { PasswordToggleLabels, SystemTimezoneOption } from './config-drawers/shared';
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
EllipsisVertical,
|
||||||
|
ExternalLink,
|
||||||
|
Gauge,
|
||||||
|
Power,
|
||||||
|
Search,
|
||||||
|
Square,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
|
import type { DashboardMessages } from './dashboard-drawers/types';
|
||||||
|
import type { NodeBotGroup } from '../botDashboardShared';
|
||||||
|
|
||||||
|
interface BotDashboardBotListPanelProps {
|
||||||
|
botCount: number;
|
||||||
|
botListMenuOpen: boolean;
|
||||||
|
botListMenuRef: RefObject<HTMLDivElement | null>;
|
||||||
|
botListPage: number;
|
||||||
|
botListPageSizeReady: boolean;
|
||||||
|
botListQuery: string;
|
||||||
|
botListTotalPages: number;
|
||||||
|
botSearchInputName: string;
|
||||||
|
controlStateByBot: Record<string, string | undefined>;
|
||||||
|
filteredBotCount: number;
|
||||||
|
isBatchOperating: boolean;
|
||||||
|
isZh: boolean;
|
||||||
|
normalizedBotListQuery: string;
|
||||||
|
onBatchStartBots: () => void;
|
||||||
|
onBatchStopBots: () => void;
|
||||||
|
onNextPage: () => void;
|
||||||
|
onOpenCreateWizard?: () => void;
|
||||||
|
onOpenImageFactory?: () => void;
|
||||||
|
onOpenResourceMonitor: (botId: string) => void;
|
||||||
|
onPrevPage: () => void;
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
onRemoveBot: (botId: string) => void;
|
||||||
|
onSelectBot: (botId: string) => void;
|
||||||
|
onSetBotEnabled: (botId: string, enabled: boolean) => void;
|
||||||
|
onToggleBotListMenu: () => void;
|
||||||
|
onToggleBotRunning: (botId: string, dockerStatus: string) => void;
|
||||||
|
operatingBotId: string | null;
|
||||||
|
pagedBotGroups: NodeBotGroup[];
|
||||||
|
selectedBotId: string;
|
||||||
|
t: DashboardMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BotDashboardBotListPanel({
|
||||||
|
botCount,
|
||||||
|
botListMenuOpen,
|
||||||
|
botListMenuRef,
|
||||||
|
botListPage,
|
||||||
|
botListPageSizeReady,
|
||||||
|
botListQuery,
|
||||||
|
botListTotalPages,
|
||||||
|
botSearchInputName,
|
||||||
|
controlStateByBot,
|
||||||
|
filteredBotCount,
|
||||||
|
isBatchOperating,
|
||||||
|
isZh,
|
||||||
|
normalizedBotListQuery,
|
||||||
|
onBatchStartBots,
|
||||||
|
onBatchStopBots,
|
||||||
|
onNextPage,
|
||||||
|
onOpenCreateWizard,
|
||||||
|
onOpenImageFactory,
|
||||||
|
onOpenResourceMonitor,
|
||||||
|
onPrevPage,
|
||||||
|
onQueryChange,
|
||||||
|
onRemoveBot,
|
||||||
|
onSelectBot,
|
||||||
|
onSetBotEnabled,
|
||||||
|
onToggleBotListMenu,
|
||||||
|
onToggleBotRunning,
|
||||||
|
operatingBotId,
|
||||||
|
pagedBotGroups,
|
||||||
|
selectedBotId,
|
||||||
|
t,
|
||||||
|
}: BotDashboardBotListPanelProps) {
|
||||||
|
return (
|
||||||
|
<section className="panel stack ops-bot-list">
|
||||||
|
<div className="row-between">
|
||||||
|
<h2 style={{ fontSize: 18 }}>
|
||||||
|
{normalizedBotListQuery
|
||||||
|
? `${t.titleBots} (${filteredBotCount}/${botCount})`
|
||||||
|
: `${t.titleBots} (${botCount})`}
|
||||||
|
</h2>
|
||||||
|
<div className="ops-list-actions" ref={botListMenuRef}>
|
||||||
|
{onOpenImageFactory ? (
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
onClick={onOpenImageFactory}
|
||||||
|
tooltip={t.manageImages}
|
||||||
|
aria-label={t.manageImages}
|
||||||
|
>
|
||||||
|
<Boxes size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
) : null}
|
||||||
|
{onOpenCreateWizard ? (
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={onOpenCreateWizard}>
|
||||||
|
{t.newBot}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
onClick={onToggleBotListMenu}
|
||||||
|
tooltip={t.extensions}
|
||||||
|
aria-label={t.extensions}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={botListMenuOpen}
|
||||||
|
>
|
||||||
|
<EllipsisVertical size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
{botListMenuOpen ? (
|
||||||
|
<div className="ops-more-menu" role="menu" aria-label={t.extensions}>
|
||||||
|
<button
|
||||||
|
className="ops-more-item"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={isBatchOperating}
|
||||||
|
onClick={onBatchStartBots}
|
||||||
|
>
|
||||||
|
<Power size={14} />
|
||||||
|
<span>{t.batchStart}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ops-more-item"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={isBatchOperating}
|
||||||
|
onClick={onBatchStopBots}
|
||||||
|
>
|
||||||
|
<Square size={14} />
|
||||||
|
<span>{t.batchStop}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ops-bot-list-toolbar">
|
||||||
|
<div className="ops-searchbar">
|
||||||
|
<input
|
||||||
|
className="input ops-search-input ops-search-input-with-icon"
|
||||||
|
type="search"
|
||||||
|
value={botListQuery}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
placeholder={t.botSearchPlaceholder}
|
||||||
|
aria-label={t.botSearchPlaceholder}
|
||||||
|
autoComplete="new-password"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
inputMode="search"
|
||||||
|
name={botSearchInputName}
|
||||||
|
id={botSearchInputName}
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-search-inline-btn"
|
||||||
|
onClick={() => onQueryChange('')}
|
||||||
|
title={botListQuery.trim() ? t.clearSearch : t.searchAction}
|
||||||
|
aria-label={botListQuery.trim() ? t.clearSearch : t.searchAction}
|
||||||
|
>
|
||||||
|
{botListQuery.trim() ? <X size={14} /> : <Search size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="list-scroll">
|
||||||
|
{!botListPageSizeReady ? <div className="ops-bot-list-empty">{t.syncingPageSize}</div> : null}
|
||||||
|
{botListPageSizeReady
|
||||||
|
? pagedBotGroups.map((group) => {
|
||||||
|
const modeSet = Array.from(
|
||||||
|
new Set(
|
||||||
|
group.bots.map((bot) => {
|
||||||
|
const transport = String(bot.transport_kind || '').trim() || 'direct';
|
||||||
|
const runtime = String(bot.runtime_kind || '').trim() || 'docker';
|
||||||
|
return `${transport}/${runtime}`;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={group.key} className="ops-bot-group">
|
||||||
|
<div className="ops-bot-group-head">
|
||||||
|
<div className="ops-bot-group-title-wrap">
|
||||||
|
<div className="ops-bot-group-title">{group.label}</div>
|
||||||
|
<div className="ops-bot-group-subtitle">
|
||||||
|
<span className="ops-bot-group-chip">{t.nodeLabel}</span>
|
||||||
|
<span className="mono">{group.nodeId}</span>
|
||||||
|
<span className="ops-bot-group-sep">•</span>
|
||||||
|
<span>{t.nodeGroupCount(group.bots.length)}</span>
|
||||||
|
{modeSet.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="ops-bot-group-sep">•</span>
|
||||||
|
<span className="mono">{modeSet.join(', ')}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{group.bots.map((bot) => {
|
||||||
|
const selected = selectedBotId === bot.id;
|
||||||
|
const controlState = controlStateByBot[bot.id];
|
||||||
|
const isOperating = operatingBotId === bot.id;
|
||||||
|
const isEnabled = bot.enabled !== false;
|
||||||
|
const isStarting = controlState === 'starting';
|
||||||
|
const isStopping = controlState === 'stopping';
|
||||||
|
const isEnabling = controlState === 'enabling';
|
||||||
|
const isDisabling = controlState === 'disabling';
|
||||||
|
const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING';
|
||||||
|
const runtimeKind = String(bot.runtime_kind || '').trim().toLowerCase() || 'docker';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={bot.id}
|
||||||
|
className={`ops-bot-card ${selected ? 'is-active' : ''} ${isEnabled ? (isRunning ? 'state-running' : 'state-stopped') : 'state-disabled'}`}
|
||||||
|
onClick={() => onSelectBot(bot.id)}
|
||||||
|
>
|
||||||
|
<span className={`ops-bot-strip ${isEnabled ? (isRunning ? 'is-running' : 'is-stopped') : 'is-disabled'}`} aria-hidden="true" />
|
||||||
|
<div className="row-between ops-bot-top">
|
||||||
|
<div className="ops-bot-name-wrap">
|
||||||
|
<div className="ops-bot-name-row">
|
||||||
|
<div className="ops-bot-name">{bot.name}</div>
|
||||||
|
<LucentIconButton
|
||||||
|
className="ops-bot-open-inline"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`;
|
||||||
|
window.open(target, '_blank', 'noopener,noreferrer');
|
||||||
|
}}
|
||||||
|
tooltip={isZh ? '新页面打开' : 'Open in new page'}
|
||||||
|
aria-label={isZh ? '新页面打开' : 'Open in new page'}
|
||||||
|
>
|
||||||
|
<ExternalLink size={11} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
<div className="mono ops-bot-id">{bot.id}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ops-bot-top-actions">
|
||||||
|
{!isEnabled ? <span className="badge badge-err">{t.disabled}</span> : null}
|
||||||
|
<span className={`ops-runtime-kind-badge ${runtimeKind === 'native' ? 'is-native' : 'is-docker'}`}>
|
||||||
|
{runtimeKind === 'native' ? 'NATIVE' : 'DOCKER'}
|
||||||
|
</span>
|
||||||
|
<span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ops-bot-meta">{t.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
|
||||||
|
<div className="ops-bot-meta">
|
||||||
|
<span className="mono">
|
||||||
|
{(String(bot.transport_kind || '').trim() || 'direct')}/{(String(bot.runtime_kind || '').trim() || 'docker')}
|
||||||
|
</span>
|
||||||
|
<span> · </span>
|
||||||
|
<span className="mono">{String(bot.core_adapter || '').trim() || 'nanobot'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ops-bot-actions">
|
||||||
|
<label
|
||||||
|
className="ops-bot-enable-switch"
|
||||||
|
title={isEnabled ? t.disable : t.enable}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isEnabled}
|
||||||
|
disabled={isOperating || isEnabling || isDisabling}
|
||||||
|
onChange={(event) => onSetBotEnabled(bot.id, event.target.checked)}
|
||||||
|
aria-label={isZh ? '启用/停用' : 'Enable/Disable'}
|
||||||
|
/>
|
||||||
|
<span className="ops-bot-enable-switch-track" />
|
||||||
|
</label>
|
||||||
|
<div className="ops-bot-actions-main">
|
||||||
|
<LucentIconButton
|
||||||
|
className={`btn btn-sm ops-bot-icon-btn ${isRunning ? 'ops-bot-action-stop' : 'ops-bot-action-start'}`}
|
||||||
|
disabled={isOperating || !isEnabled}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleBotRunning(bot.id, bot.docker_status);
|
||||||
|
}}
|
||||||
|
tooltip={isRunning ? t.stop : t.start}
|
||||||
|
aria-label={isRunning ? t.stop : t.start}
|
||||||
|
>
|
||||||
|
{isStarting || isStopping ? (
|
||||||
|
<span className="ops-control-pending">
|
||||||
|
<span className="ops-control-dots" aria-hidden="true">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : isRunning ? <Square size={14} /> : <Power size={14} />}
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
|
||||||
|
disabled={isOperating || !isEnabled}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenResourceMonitor(bot.id);
|
||||||
|
}}
|
||||||
|
tooltip={isZh ? '资源监测' : 'Resource Monitor'}
|
||||||
|
aria-label={isZh ? '资源监测' : 'Resource Monitor'}
|
||||||
|
>
|
||||||
|
<Gauge size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
|
||||||
|
disabled={isOperating || !isEnabled}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onRemoveBot(bot.id);
|
||||||
|
}}
|
||||||
|
tooltip={t.delete}
|
||||||
|
aria-label={t.delete}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
{botListPageSizeReady && filteredBotCount === 0 ? (
|
||||||
|
<div className="ops-bot-list-empty">{t.botSearchNoResult}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{botListPageSizeReady ? (
|
||||||
|
<div className="ops-bot-list-pagination">
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
|
||||||
|
onClick={onPrevPage}
|
||||||
|
disabled={botListPage <= 1}
|
||||||
|
tooltip={t.paginationPrev}
|
||||||
|
aria-label={t.paginationPrev}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<div className="ops-bot-list-page-indicator pager-status">{t.paginationPage(botListPage, botListTotalPages)}</div>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
|
||||||
|
onClick={onNextPage}
|
||||||
|
disabled={botListPage >= botListTotalPages}
|
||||||
|
tooltip={t.paginationNext}
|
||||||
|
aria-label={t.paginationNext}
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { BotDashboardChannelTopicDrawers } from './dashboard-drawers/BotDashboardChannelTopicDrawers';
|
||||||
|
import { BotDashboardOperationsDrawers } from './dashboard-drawers/BotDashboardOperationsDrawers';
|
||||||
|
import { BotDashboardPrimaryDrawers } from './dashboard-drawers/BotDashboardPrimaryDrawers';
|
||||||
|
import type { BotDashboardConfigDrawersProps } from './dashboard-drawers/types';
|
||||||
|
|
||||||
|
export function BotDashboardConfigDrawers(props: BotDashboardConfigDrawersProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BotDashboardPrimaryDrawers {...props} />
|
||||||
|
<BotDashboardChannelTopicDrawers {...props} />
|
||||||
|
<BotDashboardOperationsDrawers {...props} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Pencil,
|
||||||
|
Reply,
|
||||||
|
ThumbsDown,
|
||||||
|
ThumbsUp,
|
||||||
|
UserRound,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import nanobotLogo from '../../../assets/nanobot-logo.png';
|
||||||
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
|
import type { ChatMessage } from '../../../types/bot';
|
||||||
|
import type { DashboardMessages } from './dashboard-drawers/types';
|
||||||
|
import { WorkspaceMarkdownView } from '../shared/workspaceMarkdown';
|
||||||
|
import {
|
||||||
|
formatClock,
|
||||||
|
formatConversationDate,
|
||||||
|
normalizeDashboardAttachmentPath,
|
||||||
|
workspaceFileAction,
|
||||||
|
} from '../botDashboardShared';
|
||||||
|
import {
|
||||||
|
normalizeAssistantMessageText,
|
||||||
|
normalizeUserMessageText,
|
||||||
|
summarizeProgressText,
|
||||||
|
} from '../messageParser';
|
||||||
|
|
||||||
|
interface BotDashboardConversationNodesProps {
|
||||||
|
conversation: ChatMessage[];
|
||||||
|
expandedProgressByKey: Record<string, boolean>;
|
||||||
|
expandedUserByKey: Record<string, boolean>;
|
||||||
|
feedbackSavingByMessageId: Record<number, boolean>;
|
||||||
|
isZh: boolean;
|
||||||
|
onCopyAssistantReply: (text: string) => Promise<unknown> | void;
|
||||||
|
onCopyUserPrompt: (text: string) => Promise<unknown> | void;
|
||||||
|
onEditUserPrompt: (text: string) => void;
|
||||||
|
onOpenWorkspacePath: (path: string) => void;
|
||||||
|
onQuoteAssistantReply: (item: ChatMessage) => void;
|
||||||
|
onResolveMediaSrc: (src: string, baseFilePath?: string) => string;
|
||||||
|
onSubmitAssistantFeedback: (item: ChatMessage, value: 'up' | 'down') => Promise<unknown> | void;
|
||||||
|
onToggleProgressExpansion: (key: string) => void;
|
||||||
|
onToggleUserExpansion: (key: string) => void;
|
||||||
|
t: DashboardMessages;
|
||||||
|
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCollapseProgress(text: string) {
|
||||||
|
const normalized = String(text || '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
const lines = normalized.split('\n').length;
|
||||||
|
return lines > 6 || normalized.length > 520;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BotDashboardConversationNodes({
|
||||||
|
conversation,
|
||||||
|
expandedProgressByKey,
|
||||||
|
expandedUserByKey,
|
||||||
|
feedbackSavingByMessageId,
|
||||||
|
isZh,
|
||||||
|
onCopyAssistantReply,
|
||||||
|
onCopyUserPrompt,
|
||||||
|
onEditUserPrompt,
|
||||||
|
onOpenWorkspacePath,
|
||||||
|
onQuoteAssistantReply,
|
||||||
|
onResolveMediaSrc,
|
||||||
|
onSubmitAssistantFeedback,
|
||||||
|
onToggleProgressExpansion,
|
||||||
|
onToggleUserExpansion,
|
||||||
|
t,
|
||||||
|
workspaceDownloadExtensionSet,
|
||||||
|
}: BotDashboardConversationNodesProps) {
|
||||||
|
const nodes = useMemo(
|
||||||
|
() =>
|
||||||
|
conversation.map((item, index) => {
|
||||||
|
const itemKey = `${item.id || item.ts}-${index}`;
|
||||||
|
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
|
||||||
|
const isUserBubble = item.role === 'user';
|
||||||
|
const fullText = String(item.text || '');
|
||||||
|
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
|
||||||
|
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
|
||||||
|
const progressCollapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText));
|
||||||
|
const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : '';
|
||||||
|
const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0;
|
||||||
|
const userCollapsible = isUserBubble && userLineCount > 5;
|
||||||
|
const collapsible = isProgressBubble ? progressCollapsible : userCollapsible;
|
||||||
|
const expanded = isProgressBubble ? Boolean(expandedProgressByKey[itemKey]) : Boolean(expandedUserByKey[itemKey]);
|
||||||
|
const displayText = isProgressBubble && !expanded ? summaryText : fullText;
|
||||||
|
const currentDayKey = new Date(item.ts).toDateString();
|
||||||
|
const prevDayKey = index > 0 ? new Date(conversation[index - 1].ts).toDateString() : '';
|
||||||
|
const showDateDivider = index === 0 || currentDayKey !== prevDayKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={itemKey} data-chat-message-id={item.id ? String(item.id) : undefined}>
|
||||||
|
{showDateDivider ? (
|
||||||
|
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
|
||||||
|
<span>{formatConversationDate(item.ts, isZh)}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
|
||||||
|
<div className={`ops-chat-item ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
|
||||||
|
{item.role !== 'user' ? (
|
||||||
|
<div className="ops-avatar bot" title="Nanobot">
|
||||||
|
<img src={nanobotLogo} alt="Nanobot" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.role === 'user' ? (
|
||||||
|
<div className="ops-chat-hover-actions ops-chat-hover-actions-user">
|
||||||
|
<LucentIconButton
|
||||||
|
className="ops-chat-inline-action"
|
||||||
|
onClick={() => onEditUserPrompt(item.text)}
|
||||||
|
tooltip={t.editPrompt}
|
||||||
|
aria-label={t.editPrompt}
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="ops-chat-inline-action"
|
||||||
|
onClick={() => void onCopyUserPrompt(item.text)}
|
||||||
|
tooltip={t.copyPrompt}
|
||||||
|
aria-label={t.copyPrompt}
|
||||||
|
>
|
||||||
|
<Copy size={13} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={`ops-chat-bubble ${item.role === 'user' ? 'user' : 'assistant'} ${(item.kind || 'final') === 'progress' ? 'progress' : ''}`}>
|
||||||
|
<div className="ops-chat-meta">
|
||||||
|
<span>{item.role === 'user' ? t.you : 'Nanobot'}</span>
|
||||||
|
<div className="ops-chat-meta-right">
|
||||||
|
<span className="mono">{formatClock(item.ts)}</span>
|
||||||
|
{collapsible ? (
|
||||||
|
<LucentIconButton
|
||||||
|
className="ops-chat-expand-icon-btn"
|
||||||
|
onClick={() => {
|
||||||
|
if (isProgressBubble) {
|
||||||
|
onToggleProgressExpansion(itemKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onToggleUserExpansion(itemKey);
|
||||||
|
}}
|
||||||
|
tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
|
||||||
|
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</LucentIconButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`ops-chat-text ${collapsible && !expanded ? (isUserBubble ? 'is-collapsed-user' : 'is-collapsed') : ''}`}>
|
||||||
|
{item.text ? (
|
||||||
|
item.role === 'user' ? (
|
||||||
|
<>
|
||||||
|
{item.quoted_reply ? (
|
||||||
|
<div className="ops-user-quoted-reply">
|
||||||
|
<div className="ops-user-quoted-label">{t.quotedReplyLabel}</div>
|
||||||
|
<div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<WorkspaceMarkdownView
|
||||||
|
content={displayText}
|
||||||
|
openWorkspacePath={onOpenWorkspacePath}
|
||||||
|
resolveMediaSrc={onResolveMediaSrc}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{(item.attachments || []).length > 0 ? (
|
||||||
|
<div className="ops-chat-attachments">
|
||||||
|
{(item.attachments || []).map((rawPath) => {
|
||||||
|
const filePath = normalizeDashboardAttachmentPath(rawPath);
|
||||||
|
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
|
||||||
|
const filename = filePath.split('/').pop() || filePath;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={`${item.ts}-${filePath}`}
|
||||||
|
className="ops-attach-link mono"
|
||||||
|
href="#"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onOpenWorkspacePath(filePath);
|
||||||
|
}}
|
||||||
|
title={fileAction === 'download' ? t.download : fileAction === 'preview' ? t.previewTitle : t.fileNotPreviewable}
|
||||||
|
>
|
||||||
|
{fileAction === 'download' ? (
|
||||||
|
<Download size={12} className="ops-attach-link-icon" />
|
||||||
|
) : fileAction === 'preview' ? (
|
||||||
|
<Eye size={12} className="ops-attach-link-icon" />
|
||||||
|
) : (
|
||||||
|
<FileText size={12} className="ops-attach-link-icon" />
|
||||||
|
)}
|
||||||
|
<span className="ops-attach-link-name">{filename}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.role === 'assistant' && !isProgressBubble ? (
|
||||||
|
<div className="ops-chat-reply-actions">
|
||||||
|
<LucentIconButton
|
||||||
|
className={`ops-chat-inline-action ${item.feedback === 'up' ? 'active-up' : ''}`}
|
||||||
|
onClick={() => void onSubmitAssistantFeedback(item, 'up')}
|
||||||
|
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
|
||||||
|
tooltip={t.goodReply}
|
||||||
|
aria-label={t.goodReply}
|
||||||
|
>
|
||||||
|
<ThumbsUp size={13} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className={`ops-chat-inline-action ${item.feedback === 'down' ? 'active-down' : ''}`}
|
||||||
|
onClick={() => void onSubmitAssistantFeedback(item, 'down')}
|
||||||
|
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
|
||||||
|
tooltip={t.badReply}
|
||||||
|
aria-label={t.badReply}
|
||||||
|
>
|
||||||
|
<ThumbsDown size={13} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="ops-chat-inline-action"
|
||||||
|
onClick={() => onQuoteAssistantReply(item)}
|
||||||
|
tooltip={t.quoteReply}
|
||||||
|
aria-label={t.quoteReply}
|
||||||
|
>
|
||||||
|
<Reply size={13} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="ops-chat-inline-action"
|
||||||
|
onClick={() => void onCopyAssistantReply(item.text)}
|
||||||
|
tooltip={t.copyReply}
|
||||||
|
aria-label={t.copyReply}
|
||||||
|
>
|
||||||
|
<Copy size={13} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{item.role === 'user' ? (
|
||||||
|
<div className="ops-avatar user" title={t.user}>
|
||||||
|
<UserRound size={18} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
conversation,
|
||||||
|
expandedProgressByKey,
|
||||||
|
expandedUserByKey,
|
||||||
|
feedbackSavingByMessageId,
|
||||||
|
isZh,
|
||||||
|
onCopyAssistantReply,
|
||||||
|
onCopyUserPrompt,
|
||||||
|
onEditUserPrompt,
|
||||||
|
onOpenWorkspacePath,
|
||||||
|
onQuoteAssistantReply,
|
||||||
|
onResolveMediaSrc,
|
||||||
|
onSubmitAssistantFeedback,
|
||||||
|
onToggleProgressExpansion,
|
||||||
|
onToggleUserExpansion,
|
||||||
|
t,
|
||||||
|
workspaceDownloadExtensionSet,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <>{nodes}</>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
import type { BotState } from '../../../types/bot';
|
||||||
|
import type { WorkspaceNode } from '../botDashboardShared';
|
||||||
|
import { RuntimePanelHeader, RuntimeStateCard } from './RuntimePanelParts';
|
||||||
|
import { WorkspaceExplorerPanel } from './WorkspacePanelParts';
|
||||||
|
import type { ChannelMessages, DashboardMessages } from './dashboard-drawers/types';
|
||||||
|
|
||||||
|
interface BotDashboardRuntimePanelProps {
|
||||||
|
compactMode: boolean;
|
||||||
|
compactPanelTab: 'chat' | 'runtime';
|
||||||
|
displayState: string;
|
||||||
|
filteredWorkspaceEntries: WorkspaceNode[];
|
||||||
|
forcedBotId?: string;
|
||||||
|
forcedBotMissing: boolean;
|
||||||
|
hideWorkspaceHoverCard: () => void;
|
||||||
|
isCompactListPage: boolean;
|
||||||
|
lc: ChannelMessages;
|
||||||
|
loadBotEnvParams: (botId: string) => Promise<unknown> | void;
|
||||||
|
loadBotSkills: (botId: string) => Promise<unknown> | void;
|
||||||
|
loadCronJobs: (botId: string) => Promise<unknown> | void;
|
||||||
|
loadWorkspaceTree: (botId: string, path: string) => Promise<unknown> | void;
|
||||||
|
onClearConversationHistory: () => void;
|
||||||
|
onEnsureSelectedBotDetail: () => Promise<BotState | undefined>;
|
||||||
|
onExportConversationJson: () => void;
|
||||||
|
onOpenChannelModal: (botId: string) => void;
|
||||||
|
onOpenMcpModal: (botId: string) => void;
|
||||||
|
onOpenTopicModal: (botId: string) => void;
|
||||||
|
onOpenWorkspaceFilePreview: (path: string) => void;
|
||||||
|
onRestartBot: (botId: string, dockerStatus: string) => void;
|
||||||
|
onSetRuntimeMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
|
onShowWorkspaceHoverCard: (node: WorkspaceNode, anchor: HTMLElement) => void;
|
||||||
|
operatingBotId: string | null;
|
||||||
|
runtimeMenuOpen: boolean;
|
||||||
|
runtimeMenuRef: RefObject<HTMLDivElement | null>;
|
||||||
|
runtimeMoreLabel: string;
|
||||||
|
selectedBot?: BotState;
|
||||||
|
selectedBotEnabled: boolean;
|
||||||
|
selectedBotId: string;
|
||||||
|
setProviderTestResult: (value: string) => void;
|
||||||
|
setShowAgentModal: (value: boolean) => void;
|
||||||
|
setShowBaseModal: (value: boolean) => void;
|
||||||
|
setShowCronModal: (value: boolean) => void;
|
||||||
|
setShowEnvParamsModal: (value: boolean) => void;
|
||||||
|
setShowParamModal: (value: boolean) => void;
|
||||||
|
setShowSkillsModal: (value: boolean) => void;
|
||||||
|
setWorkspaceAutoRefresh: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
|
setWorkspaceQuery: (value: string) => void;
|
||||||
|
showCompactBotPageClose: boolean;
|
||||||
|
t: DashboardMessages;
|
||||||
|
workspaceAutoRefresh: boolean;
|
||||||
|
workspaceCurrentPath: string;
|
||||||
|
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
||||||
|
workspaceError: string;
|
||||||
|
workspaceFileLoading: boolean;
|
||||||
|
workspaceFiles: WorkspaceNode[];
|
||||||
|
workspaceLoading: boolean;
|
||||||
|
workspaceParentPath: string | null;
|
||||||
|
workspacePathDisplay: string;
|
||||||
|
workspaceQuery: string;
|
||||||
|
workspaceSearchInputName: string;
|
||||||
|
workspaceSearchLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BotDashboardRuntimePanel({
|
||||||
|
compactMode,
|
||||||
|
compactPanelTab,
|
||||||
|
displayState,
|
||||||
|
filteredWorkspaceEntries,
|
||||||
|
forcedBotId,
|
||||||
|
forcedBotMissing,
|
||||||
|
hideWorkspaceHoverCard,
|
||||||
|
isCompactListPage,
|
||||||
|
lc,
|
||||||
|
loadBotEnvParams,
|
||||||
|
loadBotSkills,
|
||||||
|
loadCronJobs,
|
||||||
|
loadWorkspaceTree,
|
||||||
|
onClearConversationHistory,
|
||||||
|
onEnsureSelectedBotDetail,
|
||||||
|
onExportConversationJson,
|
||||||
|
onOpenChannelModal,
|
||||||
|
onOpenMcpModal,
|
||||||
|
onOpenTopicModal,
|
||||||
|
onOpenWorkspaceFilePreview,
|
||||||
|
onRestartBot,
|
||||||
|
onSetRuntimeMenuOpen,
|
||||||
|
onShowWorkspaceHoverCard,
|
||||||
|
operatingBotId,
|
||||||
|
runtimeMenuOpen,
|
||||||
|
runtimeMenuRef,
|
||||||
|
runtimeMoreLabel,
|
||||||
|
selectedBot,
|
||||||
|
selectedBotEnabled,
|
||||||
|
selectedBotId,
|
||||||
|
setProviderTestResult,
|
||||||
|
setShowAgentModal,
|
||||||
|
setShowBaseModal,
|
||||||
|
setShowCronModal,
|
||||||
|
setShowEnvParamsModal,
|
||||||
|
setShowParamModal,
|
||||||
|
setShowSkillsModal,
|
||||||
|
setWorkspaceAutoRefresh,
|
||||||
|
setWorkspaceQuery,
|
||||||
|
showCompactBotPageClose,
|
||||||
|
t,
|
||||||
|
workspaceAutoRefresh,
|
||||||
|
workspaceCurrentPath,
|
||||||
|
workspaceDownloadExtensionSet,
|
||||||
|
workspaceError,
|
||||||
|
workspaceFileLoading,
|
||||||
|
workspaceFiles,
|
||||||
|
workspaceLoading,
|
||||||
|
workspaceParentPath,
|
||||||
|
workspacePathDisplay,
|
||||||
|
workspaceQuery,
|
||||||
|
workspaceSearchInputName,
|
||||||
|
workspaceSearchLoading,
|
||||||
|
}: BotDashboardRuntimePanelProps) {
|
||||||
|
return (
|
||||||
|
<section className={`panel stack ops-runtime-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'runtime') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
|
||||||
|
{selectedBot ? (
|
||||||
|
<div className="ops-runtime-shell">
|
||||||
|
<RuntimePanelHeader
|
||||||
|
labels={{
|
||||||
|
agent: t.agent,
|
||||||
|
base: t.base,
|
||||||
|
channels: t.channels,
|
||||||
|
clearHistory: t.clearHistory,
|
||||||
|
cronViewer: t.cronViewer,
|
||||||
|
envParams: t.envParams,
|
||||||
|
exportHistory: t.exportHistory,
|
||||||
|
mcp: t.mcp,
|
||||||
|
more: runtimeMoreLabel,
|
||||||
|
params: t.params,
|
||||||
|
restart: t.restart,
|
||||||
|
runtime: t.runtime,
|
||||||
|
skills: t.skills,
|
||||||
|
topic: t.topic,
|
||||||
|
}}
|
||||||
|
menuDisabled={!selectedBotEnabled}
|
||||||
|
menuOpen={runtimeMenuOpen}
|
||||||
|
menuRef={runtimeMenuRef}
|
||||||
|
onClearHistory={() => {
|
||||||
|
onSetRuntimeMenuOpen(false);
|
||||||
|
onClearConversationHistory();
|
||||||
|
}}
|
||||||
|
onExportHistory={() => {
|
||||||
|
onSetRuntimeMenuOpen(false);
|
||||||
|
onExportConversationJson();
|
||||||
|
}}
|
||||||
|
onOpenAgent={() => {
|
||||||
|
onSetRuntimeMenuOpen(false);
|
||||||
|
void (async () => {
|
||||||
|
await onEnsureSelectedBotDetail();
|
||||||
|
setShowAgentModal(true);
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
onOpenBase={() => {
|
||||||
|
onSetRuntimeMenuOpen(false);
|
||||||
|
void (async () => {
|
||||||
|
await onEnsureSelectedBotDetail();
|
||||||
|
setProviderTestResult('');
|
||||||
|
setShowBaseModal(true);
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
onOpenChannels={() => {
|
||||||
|
onSetRuntimeMenuOpen(false);
|
||||||
|
onOpenChannelModal(selectedBot.id);
|
||||||
|
}}
|
||||||
|
onOpenCron={() => {
|
||||||
|
onSetRuntimeMenuOpen(false);
|
||||||
|
void loadCronJobs(selectedBot.id);
|
||||||
|
setShowCronModal(true);
|
||||||
|
}}
|
||||||
|
onOpenEnvParams={() => {
|
||||||
|
onSetRuntimeMenuOpen(false);
|
||||||
|
void (async () => {
|
||||||
|
await loadBotEnvParams(selectedBot.id);
|
||||||
|
setShowEnvParamsModal(true);
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
onOpenMcp={() => {
|
||||||
|
onSetRuntimeMenuOpen(false);
|
||||||
|
onOpenMcpModal(selectedBot.id);
|
||||||
|
}}
|
||||||
|
onOpenParams={() => {
|
||||||
|
onSetRuntimeMenuOpen(false);
|
||||||
|
void (async () => {
|
||||||
|
await onEnsureSelectedBotDetail();
|
||||||
|
setShowParamModal(true);
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
onOpenSkills={() => {
|
||||||
|
onSetRuntimeMenuOpen(false);
|
||||||
|
void loadBotSkills(selectedBot.id);
|
||||||
|
setShowSkillsModal(true);
|
||||||
|
}}
|
||||||
|
onOpenTopic={() => {
|
||||||
|
onSetRuntimeMenuOpen(false);
|
||||||
|
onOpenTopicModal(selectedBot.id);
|
||||||
|
}}
|
||||||
|
onRestart={() => onRestartBot(selectedBot.id, selectedBot.docker_status)}
|
||||||
|
onToggleMenu={() => onSetRuntimeMenuOpen((prev) => !prev)}
|
||||||
|
restartDisabled={operatingBotId === selectedBot.id || !selectedBotEnabled}
|
||||||
|
restartLoading={operatingBotId === selectedBot.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="ops-runtime-scroll">
|
||||||
|
<RuntimeStateCard
|
||||||
|
displayState={displayState}
|
||||||
|
modelName={selectedBot.llm_model || '-'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkspaceExplorerPanel
|
||||||
|
entries={filteredWorkspaceEntries}
|
||||||
|
labels={{
|
||||||
|
autoRefresh: lc.autoRefresh,
|
||||||
|
clearSearch: t.clearSearch,
|
||||||
|
download: t.download,
|
||||||
|
emptyDir: t.emptyDir,
|
||||||
|
fileNotPreviewable: t.fileNotPreviewable,
|
||||||
|
folder: t.folder,
|
||||||
|
goUp: t.goUp,
|
||||||
|
goUpTitle: t.goUpTitle,
|
||||||
|
loadingDir: t.loadingDir,
|
||||||
|
noPreviewFile: t.noPreviewFile,
|
||||||
|
openFolderTitle: t.openFolderTitle,
|
||||||
|
openingPreview: t.openingPreview,
|
||||||
|
previewTitle: t.previewTitle,
|
||||||
|
refreshHint: lc.refreshHint,
|
||||||
|
searchAction: t.searchAction,
|
||||||
|
workspaceHint: t.workspaceHint,
|
||||||
|
workspaceSearchNoResult: t.workspaceSearchNoResult,
|
||||||
|
workspaceSearchPlaceholder: t.workspaceSearchPlaceholder,
|
||||||
|
workspaceTitle: t.workspaceOutputs,
|
||||||
|
}}
|
||||||
|
normalizedWorkspaceQuery={workspaceQuery.trim().toLowerCase()}
|
||||||
|
onHoverNode={onShowWorkspaceHoverCard}
|
||||||
|
onLeaveNode={hideWorkspaceHoverCard}
|
||||||
|
onOpenFile={onOpenWorkspaceFilePreview}
|
||||||
|
onOpenFolder={(path) => {
|
||||||
|
void loadWorkspaceTree(selectedBotId, path);
|
||||||
|
}}
|
||||||
|
onQueryChange={setWorkspaceQuery}
|
||||||
|
onRefresh={() => {
|
||||||
|
void loadWorkspaceTree(selectedBot.id, workspaceCurrentPath);
|
||||||
|
}}
|
||||||
|
onToggleAutoRefresh={() => setWorkspaceAutoRefresh((prev) => !prev)}
|
||||||
|
query={workspaceQuery}
|
||||||
|
queryInputName={workspaceSearchInputName}
|
||||||
|
showParentEntry={workspaceParentPath !== null}
|
||||||
|
showPreviewFileHint={workspaceFiles.length === 0}
|
||||||
|
workspaceAutoRefresh={workspaceAutoRefresh}
|
||||||
|
workspaceError={workspaceError}
|
||||||
|
workspaceFileLoading={workspaceFileLoading}
|
||||||
|
workspaceLoading={workspaceLoading || !selectedBotId}
|
||||||
|
workspaceParentPath={workspaceParentPath}
|
||||||
|
workspacePathDisplay={workspacePathDisplay}
|
||||||
|
workspaceSearchLoading={workspaceSearchLoading}
|
||||||
|
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: 'var(--muted)' }}>
|
||||||
|
{forcedBotMissing ? `${t.noTelemetry}: ${String(forcedBotId).trim()}` : t.noTelemetry}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,314 @@
|
||||||
|
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react';
|
||||||
|
import { ArrowUp, ChevronLeft, Clock3, Command, Mic, Paperclip, Plus, RefreshCw, RotateCcw, Square } from 'lucide-react';
|
||||||
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
|
import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn';
|
||||||
|
import type { QuotedReply } from '../botDashboardShared';
|
||||||
|
import { ChatTopContextBar } from './ChatPanelParts';
|
||||||
|
|
||||||
|
interface ChatComposerDockProps {
|
||||||
|
activeControlCommand: string;
|
||||||
|
allowedAttachmentExtensions: string[];
|
||||||
|
attachmentUploadPercent: number | null;
|
||||||
|
canChat: boolean;
|
||||||
|
canSendControlCommand: boolean;
|
||||||
|
chatDateJumping: boolean;
|
||||||
|
chatDatePanelPosition: { bottom: number; right: number } | null;
|
||||||
|
chatDatePickerOpen: boolean;
|
||||||
|
chatDateTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
chatDateValue: string;
|
||||||
|
command: string;
|
||||||
|
composerTextareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
controlCommandPanelOpen: boolean;
|
||||||
|
controlCommandPanelRef: RefObject<HTMLDivElement | null>;
|
||||||
|
disabledSend: boolean;
|
||||||
|
filePickerRef: RefObject<HTMLInputElement | null>;
|
||||||
|
isCompactMobile: boolean;
|
||||||
|
isInterrupting: boolean;
|
||||||
|
isUploadingAttachments: boolean;
|
||||||
|
isVoiceRecording: boolean;
|
||||||
|
isVoiceTranscribing: boolean;
|
||||||
|
isZh: boolean;
|
||||||
|
onClearQuotedReply: () => void;
|
||||||
|
onCommandChange: (value: string) => void;
|
||||||
|
onCloseChatDatePicker: () => void;
|
||||||
|
onComposerKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
|
onDateValueChange: (value: string) => void;
|
||||||
|
onInterruptExecution: () => void;
|
||||||
|
onJumpConversationToDate: () => void;
|
||||||
|
onOpenWorkspacePath: (path: string) => void;
|
||||||
|
onPickAttachments: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onRemovePendingAttachment: (path: string) => void;
|
||||||
|
onSend: () => void;
|
||||||
|
onSendControlCommand: (command: '/restart' | '/new') => void;
|
||||||
|
onToggleChatDatePicker: () => void;
|
||||||
|
onToggleControlPanel: () => void;
|
||||||
|
onTriggerPickAttachments: () => void;
|
||||||
|
onVoiceInput: () => void;
|
||||||
|
pendingAttachments: string[];
|
||||||
|
quotedReply: QuotedReply | null;
|
||||||
|
selectedBotId: string;
|
||||||
|
showInterruptSubmitAction: boolean;
|
||||||
|
t: typeof dashboardZhCn;
|
||||||
|
voiceCountdown: number;
|
||||||
|
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatComposerDock({
|
||||||
|
activeControlCommand,
|
||||||
|
allowedAttachmentExtensions,
|
||||||
|
attachmentUploadPercent,
|
||||||
|
canChat,
|
||||||
|
canSendControlCommand,
|
||||||
|
chatDateJumping,
|
||||||
|
chatDatePanelPosition,
|
||||||
|
chatDatePickerOpen,
|
||||||
|
chatDateTriggerRef,
|
||||||
|
chatDateValue,
|
||||||
|
command,
|
||||||
|
composerTextareaRef,
|
||||||
|
controlCommandPanelOpen,
|
||||||
|
controlCommandPanelRef,
|
||||||
|
disabledSend,
|
||||||
|
filePickerRef,
|
||||||
|
isCompactMobile,
|
||||||
|
isInterrupting,
|
||||||
|
isUploadingAttachments,
|
||||||
|
isVoiceRecording,
|
||||||
|
isVoiceTranscribing,
|
||||||
|
isZh,
|
||||||
|
onClearQuotedReply,
|
||||||
|
onCommandChange,
|
||||||
|
onCloseChatDatePicker,
|
||||||
|
onComposerKeyDown,
|
||||||
|
onDateValueChange,
|
||||||
|
onInterruptExecution,
|
||||||
|
onJumpConversationToDate,
|
||||||
|
onOpenWorkspacePath,
|
||||||
|
onPickAttachments,
|
||||||
|
onRemovePendingAttachment,
|
||||||
|
onSend,
|
||||||
|
onSendControlCommand,
|
||||||
|
onToggleChatDatePicker,
|
||||||
|
onToggleControlPanel,
|
||||||
|
onTriggerPickAttachments,
|
||||||
|
onVoiceInput,
|
||||||
|
pendingAttachments,
|
||||||
|
quotedReply,
|
||||||
|
selectedBotId,
|
||||||
|
showInterruptSubmitAction,
|
||||||
|
t,
|
||||||
|
voiceCountdown,
|
||||||
|
workspaceDownloadExtensionSet,
|
||||||
|
}: ChatComposerDockProps) {
|
||||||
|
return (
|
||||||
|
<div className="ops-chat-dock">
|
||||||
|
<ChatTopContextBar
|
||||||
|
onClearQuotedReply={onClearQuotedReply}
|
||||||
|
onOpenWorkspacePath={onOpenWorkspacePath}
|
||||||
|
onRemovePendingAttachment={onRemovePendingAttachment}
|
||||||
|
pendingAttachments={pendingAttachments}
|
||||||
|
quotedReply={quotedReply}
|
||||||
|
quotedReplyLabel={t.quotedReplyLabel}
|
||||||
|
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||||
|
/>
|
||||||
|
{isUploadingAttachments ? (
|
||||||
|
<div className="ops-upload-progress" aria-live="polite">
|
||||||
|
<div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
|
||||||
|
<div
|
||||||
|
className="ops-upload-progress-fill"
|
||||||
|
style={{ width: `${Math.max(3, Number(attachmentUploadPercent ?? 24))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="ops-upload-progress-text mono">
|
||||||
|
{attachmentUploadPercent === null
|
||||||
|
? t.uploadingFile
|
||||||
|
: `${t.uploadingFile} ${attachmentUploadPercent}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="ops-composer">
|
||||||
|
<input
|
||||||
|
ref={filePickerRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined}
|
||||||
|
onChange={onPickAttachments}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
|
||||||
|
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
|
||||||
|
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-control-command-chip"
|
||||||
|
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
|
||||||
|
onClick={() => onSendControlCommand('/restart')}
|
||||||
|
aria-label="/restart"
|
||||||
|
title="/restart"
|
||||||
|
>
|
||||||
|
{activeControlCommand === '/restart' ? <RefreshCw size={11} className="animate-spin" /> : <RotateCcw size={11} />}
|
||||||
|
<span className="mono">/restart</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-control-command-chip"
|
||||||
|
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
|
||||||
|
onClick={() => onSendControlCommand('/new')}
|
||||||
|
aria-label="/new"
|
||||||
|
title="/new"
|
||||||
|
>
|
||||||
|
{activeControlCommand === '/new' ? <RefreshCw size={11} className="animate-spin" /> : <Plus size={11} />}
|
||||||
|
<span className="mono">/new</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-control-command-chip"
|
||||||
|
disabled={!selectedBotId || !canChat || Boolean(activeControlCommand) || isInterrupting}
|
||||||
|
onClick={onInterruptExecution}
|
||||||
|
aria-label="/stop"
|
||||||
|
title="/stop"
|
||||||
|
>
|
||||||
|
{isInterrupting ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
|
||||||
|
<span className="mono">/stop</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-control-command-chip"
|
||||||
|
ref={chatDateTriggerRef}
|
||||||
|
disabled={!selectedBotId || chatDateJumping}
|
||||||
|
onClick={onToggleChatDatePicker}
|
||||||
|
aria-label={isZh ? '按日期定位对话' : 'Jump to date'}
|
||||||
|
title={isZh ? '按日期定位对话' : 'Jump to date'}
|
||||||
|
>
|
||||||
|
{chatDateJumping ? <RefreshCw size={11} className="animate-spin" /> : <Clock3 size={11} />}
|
||||||
|
<span className="mono">/time</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{chatDatePickerOpen ? (
|
||||||
|
<div
|
||||||
|
className="ops-control-date-panel"
|
||||||
|
style={chatDatePanelPosition ? { bottom: chatDatePanelPosition.bottom, right: chatDatePanelPosition.right } : undefined}
|
||||||
|
>
|
||||||
|
<label className="ops-control-date-label">
|
||||||
|
<span>{isZh ? '选择日期' : 'Select date'}</span>
|
||||||
|
<input
|
||||||
|
className="input ops-control-date-input"
|
||||||
|
type="date"
|
||||||
|
value={chatDateValue}
|
||||||
|
max={new Date().toISOString().slice(0, 10)}
|
||||||
|
onChange={(event) => onDateValueChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="ops-control-date-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={onCloseChatDatePicker}
|
||||||
|
>
|
||||||
|
{isZh ? '取消' : 'Cancel'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={chatDateJumping || !chatDateValue}
|
||||||
|
onClick={onJumpConversationToDate}
|
||||||
|
>
|
||||||
|
{chatDateJumping ? <RefreshCw size={14} className="animate-spin" /> : null}
|
||||||
|
<span style={{ marginLeft: chatDateJumping ? 6 : 0 }}>
|
||||||
|
{isZh ? '跳转' : 'Jump'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`}
|
||||||
|
onClick={onToggleControlPanel}
|
||||||
|
aria-label={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
|
||||||
|
title={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
|
||||||
|
>
|
||||||
|
{controlCommandPanelOpen ? <Command size={12} /> : <ChevronLeft size={13} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={composerTextareaRef}
|
||||||
|
className="input ops-composer-input"
|
||||||
|
value={command}
|
||||||
|
onChange={(event) => onCommandChange(event.target.value)}
|
||||||
|
onKeyDown={onComposerKeyDown}
|
||||||
|
disabled={!canChat || isVoiceRecording || isVoiceTranscribing}
|
||||||
|
placeholder={canChat ? t.inputPlaceholder : t.disabledPlaceholder}
|
||||||
|
/>
|
||||||
|
<div className="ops-composer-tools-right">
|
||||||
|
{(isVoiceRecording || isVoiceTranscribing) ? (
|
||||||
|
<div className="ops-voice-inline" aria-live="polite">
|
||||||
|
<div className={`ops-voice-wave ${isVoiceRecording ? 'is-live' : ''} ${isCompactMobile ? 'is-mobile' : 'is-desktop'}`}>
|
||||||
|
{Array.from({ length: isCompactMobile ? 1 : 5 }).map((_, segmentIdx) => (
|
||||||
|
<div key={`vw-segment-${segmentIdx}`} className="ops-voice-wave-segment">
|
||||||
|
{Array.from({ length: isCompactMobile ? 28 : 18 }).map((_, idx) => {
|
||||||
|
const delayIndex = isCompactMobile ? idx : (segmentIdx * 18) + idx;
|
||||||
|
return (
|
||||||
|
<i
|
||||||
|
key={`vw-inline-${segmentIdx}-${idx}`}
|
||||||
|
style={{ animationDelay: `${(delayIndex % 14) * 0.06}s` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ops-voice-countdown mono">
|
||||||
|
{isVoiceRecording ? `${voiceCountdown}s` : t.voiceTranscribing}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
|
||||||
|
disabled={!canChat || isVoiceTranscribing}
|
||||||
|
onClick={onVoiceInput}
|
||||||
|
aria-label={isVoiceRecording ? t.voiceStop : t.voiceStart}
|
||||||
|
title={
|
||||||
|
isVoiceTranscribing
|
||||||
|
? t.voiceTranscribing
|
||||||
|
: isVoiceRecording
|
||||||
|
? t.voiceStop
|
||||||
|
: t.voiceStart
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isVoiceTranscribing ? (
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
) : isVoiceRecording ? (
|
||||||
|
<Square size={16} />
|
||||||
|
) : (
|
||||||
|
<Mic size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<LucentIconButton
|
||||||
|
className="ops-composer-inline-btn"
|
||||||
|
disabled={!canChat || isUploadingAttachments || isVoiceRecording || isVoiceTranscribing}
|
||||||
|
onClick={onTriggerPickAttachments}
|
||||||
|
tooltip={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
|
||||||
|
aria-label={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
|
||||||
|
>
|
||||||
|
<Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<button
|
||||||
|
className={`ops-composer-submit-btn ${showInterruptSubmitAction ? 'is-interrupt' : ''}`}
|
||||||
|
disabled={disabledSend}
|
||||||
|
onClick={onSend}
|
||||||
|
aria-label={showInterruptSubmitAction ? t.interrupt : t.send}
|
||||||
|
title={showInterruptSubmitAction ? t.interrupt : t.send}
|
||||||
|
>
|
||||||
|
{showInterruptSubmitAction ? (
|
||||||
|
<Square size={15} />
|
||||||
|
) : (
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
import type { ReactNode, RefObject } from 'react';
|
||||||
|
import { Download, Eye, FileText, MessageCircle, MessageSquareText, X } from 'lucide-react';
|
||||||
|
import nanobotLogo from '../../../assets/nanobot-logo.png';
|
||||||
|
import { normalizeAssistantMessageText } from '../messageParser';
|
||||||
|
import { normalizeDashboardAttachmentPath, workspaceFileAction } from '../botDashboardShared';
|
||||||
|
import type { QuotedReply } from '../botDashboardShared';
|
||||||
|
|
||||||
|
interface ChatModeRailProps {
|
||||||
|
hasTopicUnread: boolean;
|
||||||
|
isZh: boolean;
|
||||||
|
runtimeViewMode: 'visual' | 'topic';
|
||||||
|
onChange: (mode: 'visual' | 'topic') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatModeRail({ hasTopicUnread, isZh, runtimeViewMode, onChange }: ChatModeRailProps) {
|
||||||
|
return (
|
||||||
|
<div className="ops-main-mode-rail" role="tablist" aria-label={isZh ? '主面板视图切换' : 'Main panel view switch'}>
|
||||||
|
<button
|
||||||
|
className={`ops-main-mode-tab ${runtimeViewMode === 'visual' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => onChange('visual')}
|
||||||
|
aria-label={isZh ? '对话视图' : 'Conversation view'}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={runtimeViewMode === 'visual'}
|
||||||
|
>
|
||||||
|
<MessageCircle size={14} />
|
||||||
|
<span className="ops-main-mode-label">{isZh ? '对话' : 'Chat'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`ops-main-mode-tab has-dot ${runtimeViewMode === 'topic' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => onChange('topic')}
|
||||||
|
aria-label={isZh ? '主题视图' : 'Topic view'}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={runtimeViewMode === 'topic'}
|
||||||
|
>
|
||||||
|
<MessageSquareText size={14} />
|
||||||
|
<span className="ops-main-mode-label-wrap">
|
||||||
|
<span className="ops-main-mode-label">{isZh ? '主题' : 'Topic'}</span>
|
||||||
|
{hasTopicUnread ? <span className="ops-switch-dot" aria-hidden="true" /> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversationViewportProps {
|
||||||
|
bottomRef: RefObject<HTMLDivElement | null>;
|
||||||
|
chatScrollRef: RefObject<HTMLDivElement | null>;
|
||||||
|
conversationNodes: ReactNode;
|
||||||
|
emptyLabel: string;
|
||||||
|
hasConversation: boolean;
|
||||||
|
isChatEnabled: boolean;
|
||||||
|
isThinking: boolean;
|
||||||
|
onScroll: () => void;
|
||||||
|
thinkingLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationViewport({
|
||||||
|
bottomRef,
|
||||||
|
chatScrollRef,
|
||||||
|
conversationNodes,
|
||||||
|
emptyLabel,
|
||||||
|
hasConversation,
|
||||||
|
isChatEnabled,
|
||||||
|
isThinking,
|
||||||
|
onScroll,
|
||||||
|
thinkingLabel,
|
||||||
|
}: ConversationViewportProps) {
|
||||||
|
return (
|
||||||
|
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
|
||||||
|
<div className="ops-chat-scroll" ref={chatScrollRef} onScroll={onScroll}>
|
||||||
|
{hasConversation ? conversationNodes : <div className="ops-chat-empty">{emptyLabel}</div>}
|
||||||
|
|
||||||
|
{isThinking ? (
|
||||||
|
<div className="ops-chat-row is-assistant">
|
||||||
|
<div className="ops-chat-item is-assistant">
|
||||||
|
<div className="ops-avatar bot" title="Nanobot">
|
||||||
|
<img src={nanobotLogo} alt="Nanobot" />
|
||||||
|
</div>
|
||||||
|
<div className="ops-thinking-bubble">
|
||||||
|
<div className="ops-thinking-cloud">
|
||||||
|
<span className="dot" />
|
||||||
|
<span className="dot" />
|
||||||
|
<span className="dot" />
|
||||||
|
</div>
|
||||||
|
<div className="ops-thinking-text">{thinkingLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatTopContextBarProps {
|
||||||
|
onClearQuotedReply: () => void;
|
||||||
|
onOpenWorkspacePath: (path: string) => void;
|
||||||
|
onRemovePendingAttachment: (path: string) => void;
|
||||||
|
pendingAttachments: string[];
|
||||||
|
quotedReply: QuotedReply | null;
|
||||||
|
quotedReplyLabel: string;
|
||||||
|
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatTopContextBar({
|
||||||
|
onClearQuotedReply,
|
||||||
|
onOpenWorkspacePath,
|
||||||
|
onRemovePendingAttachment,
|
||||||
|
pendingAttachments,
|
||||||
|
quotedReply,
|
||||||
|
quotedReplyLabel,
|
||||||
|
workspaceDownloadExtensionSet,
|
||||||
|
}: ChatTopContextBarProps) {
|
||||||
|
if (!quotedReply && pendingAttachments.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-chat-top-context">
|
||||||
|
{quotedReply ? (
|
||||||
|
<div className="ops-composer-quote" aria-live="polite">
|
||||||
|
<div className="ops-composer-quote-head">
|
||||||
|
<span>{quotedReplyLabel}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-chat-inline-action ops-no-tip-icon-btn"
|
||||||
|
onClick={onClearQuotedReply}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ops-composer-quote-text">{normalizeAssistantMessageText(quotedReply.text)}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{pendingAttachments.length > 0 ? (
|
||||||
|
<div className="ops-pending-files">
|
||||||
|
{pendingAttachments.map((path) => (
|
||||||
|
<span key={path} className="ops-pending-chip mono">
|
||||||
|
{(() => {
|
||||||
|
const filePath = normalizeDashboardAttachmentPath(path);
|
||||||
|
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
|
||||||
|
const filename = filePath.split('/').pop() || filePath;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="ops-attach-link mono ops-pending-open"
|
||||||
|
href="#"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenWorkspacePath(filePath);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fileAction === 'download' ? (
|
||||||
|
<Download size={12} className="ops-attach-link-icon" />
|
||||||
|
) : fileAction === 'preview' ? (
|
||||||
|
<Eye size={12} className="ops-attach-link-icon" />
|
||||||
|
) : (
|
||||||
|
<FileText size={12} className="ops-attach-link-icon" />
|
||||||
|
)}
|
||||||
|
<span className="ops-attach-link-name">{filename}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-chat-inline-action ops-no-tip-icon-btn"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onRemovePendingAttachment(path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatDisabledMaskProps {
|
||||||
|
botDisabledHint: string;
|
||||||
|
botStarting: string;
|
||||||
|
botStopping: string;
|
||||||
|
canChat: boolean;
|
||||||
|
chatDisabled: string;
|
||||||
|
selectedBotEnabled: boolean;
|
||||||
|
selectedBotControlState?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatDisabledMask({
|
||||||
|
botDisabledHint,
|
||||||
|
botStarting,
|
||||||
|
botStopping,
|
||||||
|
canChat,
|
||||||
|
chatDisabled,
|
||||||
|
selectedBotEnabled,
|
||||||
|
selectedBotControlState,
|
||||||
|
}: ChatDisabledMaskProps) {
|
||||||
|
if (canChat) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-chat-disabled-mask">
|
||||||
|
<div className="ops-chat-disabled-card">
|
||||||
|
{selectedBotControlState === 'starting'
|
||||||
|
? botStarting
|
||||||
|
: selectedBotControlState === 'stopping'
|
||||||
|
? botStopping
|
||||||
|
: !selectedBotEnabled
|
||||||
|
? botDisabledHint
|
||||||
|
: chatDisabled}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
Check,
|
||||||
|
Clock3,
|
||||||
|
EllipsisVertical,
|
||||||
|
FileText,
|
||||||
|
Hammer,
|
||||||
|
MessageSquareText,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Save,
|
||||||
|
Settings2,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Trash2,
|
||||||
|
TriangleAlert,
|
||||||
|
Waypoints,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
|
import { formatBytes, formatPercent } from '../botDashboardShared';
|
||||||
|
import type { BotResourceSnapshot } from '../botDashboardShared';
|
||||||
|
|
||||||
|
interface RuntimePanelHeaderLabels {
|
||||||
|
agent: string;
|
||||||
|
base: string;
|
||||||
|
channels: string;
|
||||||
|
clearHistory: string;
|
||||||
|
cronViewer: string;
|
||||||
|
envParams: string;
|
||||||
|
exportHistory: string;
|
||||||
|
mcp: string;
|
||||||
|
more: string;
|
||||||
|
params: string;
|
||||||
|
restart: string;
|
||||||
|
runtime: string;
|
||||||
|
skills: string;
|
||||||
|
topic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimePanelHeaderProps {
|
||||||
|
labels: RuntimePanelHeaderLabels;
|
||||||
|
menuDisabled: boolean;
|
||||||
|
menuOpen: boolean;
|
||||||
|
menuRef: RefObject<HTMLDivElement | null>;
|
||||||
|
onClearHistory: () => void;
|
||||||
|
onExportHistory: () => void;
|
||||||
|
onOpenAgent: () => void;
|
||||||
|
onOpenBase: () => void;
|
||||||
|
onOpenChannels: () => void;
|
||||||
|
onOpenCron: () => void;
|
||||||
|
onOpenEnvParams: () => void;
|
||||||
|
onOpenMcp: () => void;
|
||||||
|
onOpenParams: () => void;
|
||||||
|
onOpenSkills: () => void;
|
||||||
|
onOpenTopic: () => void;
|
||||||
|
onRestart: () => void;
|
||||||
|
onToggleMenu: () => void;
|
||||||
|
restartDisabled: boolean;
|
||||||
|
restartLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuntimePanelHeader({
|
||||||
|
labels,
|
||||||
|
menuDisabled,
|
||||||
|
menuOpen,
|
||||||
|
menuRef,
|
||||||
|
onClearHistory,
|
||||||
|
onExportHistory,
|
||||||
|
onOpenAgent,
|
||||||
|
onOpenBase,
|
||||||
|
onOpenChannels,
|
||||||
|
onOpenCron,
|
||||||
|
onOpenEnvParams,
|
||||||
|
onOpenMcp,
|
||||||
|
onOpenParams,
|
||||||
|
onOpenSkills,
|
||||||
|
onOpenTopic,
|
||||||
|
onRestart,
|
||||||
|
onToggleMenu,
|
||||||
|
restartDisabled,
|
||||||
|
restartLoading,
|
||||||
|
}: RuntimePanelHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="row-between ops-runtime-head">
|
||||||
|
<h2 style={{ fontSize: 18 }}>{labels.runtime}</h2>
|
||||||
|
<div className="ops-panel-tools" ref={menuRef}>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
onClick={onRestart}
|
||||||
|
disabled={restartDisabled}
|
||||||
|
tooltip={labels.restart}
|
||||||
|
aria-label={labels.restart}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} className={restartLoading ? 'animate-spin' : ''} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
onClick={onToggleMenu}
|
||||||
|
disabled={menuDisabled}
|
||||||
|
tooltip={labels.more}
|
||||||
|
aria-label={labels.more}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
>
|
||||||
|
<EllipsisVertical size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
{menuOpen ? (
|
||||||
|
<div className="ops-more-menu" role="menu" aria-label={labels.more}>
|
||||||
|
<button className="ops-more-item" role="menuitem" onClick={onOpenBase}>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
<span>{labels.base}</span>
|
||||||
|
</button>
|
||||||
|
<button className="ops-more-item" role="menuitem" onClick={onOpenParams}>
|
||||||
|
<SlidersHorizontal size={14} />
|
||||||
|
<span>{labels.params}</span>
|
||||||
|
</button>
|
||||||
|
<button className="ops-more-item" role="menuitem" onClick={onOpenChannels}>
|
||||||
|
<Waypoints size={14} />
|
||||||
|
<span>{labels.channels}</span>
|
||||||
|
</button>
|
||||||
|
<button className="ops-more-item" role="menuitem" onClick={onOpenTopic}>
|
||||||
|
<MessageSquareText size={14} />
|
||||||
|
<span>{labels.topic}</span>
|
||||||
|
</button>
|
||||||
|
<button className="ops-more-item" role="menuitem" onClick={onOpenEnvParams}>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
<span>{labels.envParams}</span>
|
||||||
|
</button>
|
||||||
|
<button className="ops-more-item" role="menuitem" onClick={onOpenSkills}>
|
||||||
|
<Hammer size={14} />
|
||||||
|
<span>{labels.skills}</span>
|
||||||
|
</button>
|
||||||
|
<button className="ops-more-item" role="menuitem" onClick={onOpenMcp}>
|
||||||
|
<Boxes size={14} />
|
||||||
|
<span>{labels.mcp}</span>
|
||||||
|
</button>
|
||||||
|
<button className="ops-more-item" role="menuitem" onClick={onOpenCron}>
|
||||||
|
<Clock3 size={14} />
|
||||||
|
<span>{labels.cronViewer}</span>
|
||||||
|
</button>
|
||||||
|
<button className="ops-more-item" role="menuitem" onClick={onOpenAgent}>
|
||||||
|
<FileText size={14} />
|
||||||
|
<span>{labels.agent}</span>
|
||||||
|
</button>
|
||||||
|
<button className="ops-more-item" role="menuitem" onClick={onExportHistory}>
|
||||||
|
<Save size={14} />
|
||||||
|
<span>{labels.exportHistory}</span>
|
||||||
|
</button>
|
||||||
|
<button className="ops-more-item danger" role="menuitem" onClick={onClearHistory}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
<span>{labels.clearHistory}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeStateCardProps {
|
||||||
|
displayState: string;
|
||||||
|
modelName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuntimeStateCard({ displayState, modelName }: RuntimeStateCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="card ops-runtime-card ops-runtime-state-card is-visual">
|
||||||
|
<div className={`ops-state-stage ops-state-${String(displayState).toLowerCase()}`}>
|
||||||
|
<div className="ops-state-model mono">{modelName || '-'}</div>
|
||||||
|
<div className="ops-state-face" aria-hidden="true">
|
||||||
|
<span className="ops-state-eye left" />
|
||||||
|
<span className="ops-state-eye right" />
|
||||||
|
</div>
|
||||||
|
<div className="ops-state-caption mono">{String(displayState || 'IDLE').toUpperCase()}</div>
|
||||||
|
{displayState === 'TOOL_CALL' ? <Hammer size={18} className="ops-state-float state-tool" /> : null}
|
||||||
|
{displayState === 'SUCCESS' ? <Check size={18} className="ops-state-float state-success" /> : null}
|
||||||
|
{displayState === 'ERROR' ? <TriangleAlert size={18} className="ops-state-float state-error" /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceMonitorLabels {
|
||||||
|
baseImage: string;
|
||||||
|
blockIo: string;
|
||||||
|
close: string;
|
||||||
|
collected: string;
|
||||||
|
configuredLimits: string;
|
||||||
|
container: string;
|
||||||
|
containerName: string;
|
||||||
|
cpu: string;
|
||||||
|
dockerRuntimeLimits: string;
|
||||||
|
liveUsage: string;
|
||||||
|
loading: string;
|
||||||
|
memory: string;
|
||||||
|
memoryPercent: string;
|
||||||
|
noMetrics: string;
|
||||||
|
policy: string;
|
||||||
|
providerModel: string;
|
||||||
|
refreshNow: string;
|
||||||
|
resourceMonitor: string;
|
||||||
|
sizeUnlimited: string;
|
||||||
|
storage: string;
|
||||||
|
uiRuleHint: string;
|
||||||
|
workspaceUsage: string;
|
||||||
|
workspacePercent: string;
|
||||||
|
networkIo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceMonitorModalProps {
|
||||||
|
botId: string;
|
||||||
|
botImageTag: string;
|
||||||
|
botName: string;
|
||||||
|
botProvider: string;
|
||||||
|
botModel: string;
|
||||||
|
error: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
labels: ResourceMonitorLabels;
|
||||||
|
loading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
snapshot: BotResourceSnapshot | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceMonitorModal({
|
||||||
|
botId,
|
||||||
|
botImageTag,
|
||||||
|
botModel,
|
||||||
|
botName,
|
||||||
|
botProvider,
|
||||||
|
error,
|
||||||
|
isOpen,
|
||||||
|
labels,
|
||||||
|
loading,
|
||||||
|
onClose,
|
||||||
|
onRefresh,
|
||||||
|
snapshot,
|
||||||
|
}: ResourceMonitorModalProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-mask" onClick={onClose}>
|
||||||
|
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-title-row modal-title-with-close">
|
||||||
|
<div className="modal-title-main">
|
||||||
|
<h3>{labels.resourceMonitor}</h3>
|
||||||
|
<span className="modal-sub mono">{botName || botId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="modal-title-actions">
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
onClick={onRefresh}
|
||||||
|
tooltip={labels.refreshNow}
|
||||||
|
aria-label={labels.refreshNow}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
onClick={onClose}
|
||||||
|
tooltip={labels.close}
|
||||||
|
aria-label={labels.close}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="card">{error}</div> : null}
|
||||||
|
{snapshot ? (
|
||||||
|
<div className="stack">
|
||||||
|
<div className="card summary-grid">
|
||||||
|
<div>{labels.container}: <strong className="mono">{snapshot.docker_status}</strong></div>
|
||||||
|
<div>{labels.containerName}: <span className="mono">{snapshot.bot_id ? `worker_${snapshot.bot_id}` : '-'}</span></div>
|
||||||
|
<div>{labels.baseImage}: <span className="mono">{botImageTag || '-'}</span></div>
|
||||||
|
<div>{labels.providerModel}: <span className="mono">{botProvider || '-'} / {botModel || '-'}</span></div>
|
||||||
|
<div>{labels.collected}: <span className="mono">{snapshot.collected_at}</span></div>
|
||||||
|
<div>{labels.policy}: <strong>{labels.sizeUnlimited}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
||||||
|
<div className="card stack">
|
||||||
|
<div className="section-mini-title">{labels.configuredLimits}</div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.cpu}</span><strong>{Number(snapshot.configured.cpu_cores) === 0 ? labels.sizeUnlimited : snapshot.configured.cpu_cores}</strong></div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.memory}</span><strong>{Number(snapshot.configured.memory_mb) === 0 ? labels.sizeUnlimited : `${snapshot.configured.memory_mb} MB`}</strong></div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.storage}</span><strong>{Number(snapshot.configured.storage_gb) === 0 ? labels.sizeUnlimited : `${snapshot.configured.storage_gb} GB`}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card stack">
|
||||||
|
<div className="section-mini-title">{labels.dockerRuntimeLimits}</div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.cpu}</span><strong>{snapshot.runtime.limits.cpu_cores ? snapshot.runtime.limits.cpu_cores.toFixed(2) : (Number(snapshot.configured.cpu_cores) === 0 ? labels.sizeUnlimited : '-')}</strong></div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.memory}</span><strong>{snapshot.runtime.limits.memory_bytes ? formatBytes(snapshot.runtime.limits.memory_bytes) : (Number(snapshot.configured.memory_mb) === 0 ? labels.sizeUnlimited : '-')}</strong></div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.storage}</span><strong>{snapshot.runtime.limits.storage_bytes ? formatBytes(snapshot.runtime.limits.storage_bytes) : (snapshot.runtime.limits.storage_opt_raw || (Number(snapshot.configured.storage_gb) === 0 ? labels.sizeUnlimited : '-'))}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card stack">
|
||||||
|
<div className="section-mini-title">{labels.liveUsage}</div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.cpu}</span><strong>{formatPercent(snapshot.runtime.usage.cpu_percent)}</strong></div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.memory}</span><strong>{formatBytes(snapshot.runtime.usage.memory_bytes)} / {snapshot.runtime.usage.memory_limit_bytes > 0 ? formatBytes(snapshot.runtime.usage.memory_limit_bytes) : '-'}</strong></div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.memoryPercent}</span><strong>{formatPercent(snapshot.runtime.usage.memory_percent)}</strong></div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.workspaceUsage}</span><strong>{formatBytes(snapshot.workspace.usage_bytes)} / {snapshot.workspace.configured_limit_bytes ? formatBytes(snapshot.workspace.configured_limit_bytes) : '-'}</strong></div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.workspacePercent}</span><strong>{formatPercent(snapshot.workspace.usage_percent)}</strong></div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.networkIo}</span><strong>RX {formatBytes(snapshot.runtime.usage.network_rx_bytes)} · TX {formatBytes(snapshot.runtime.usage.network_tx_bytes)}</strong></div>
|
||||||
|
<div className="ops-runtime-row"><span>{labels.blockIo}</span><strong>R {formatBytes(snapshot.runtime.usage.blk_read_bytes)} · W {formatBytes(snapshot.runtime.usage.blk_write_bytes)}</strong></div>
|
||||||
|
<div className="ops-runtime-row"><span>PIDs</span><strong>{snapshot.runtime.usage.pids || 0}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-label">
|
||||||
|
{snapshot.note}
|
||||||
|
{labels.uiRuleHint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ops-empty-inline">{loading ? labels.loading : labels.noMetrics}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,506 @@
|
||||||
|
import { Copy, FileText, FolderOpen, Maximize2, Minimize2, RefreshCw, Save, Search, X } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
|
import { MarkdownLiteEditor } from '../../../components/markdown/MarkdownLiteEditor';
|
||||||
|
import { WorkspaceMarkdownView } from '../shared/workspaceMarkdown';
|
||||||
|
import {
|
||||||
|
formatBytes,
|
||||||
|
formatWorkspaceTime,
|
||||||
|
isPreviewableWorkspaceFile,
|
||||||
|
renderWorkspacePathSegments,
|
||||||
|
workspaceFileAction,
|
||||||
|
} from '../botDashboardShared';
|
||||||
|
import type { WorkspaceHoverCardState, WorkspaceNode, WorkspacePreviewState } from '../botDashboardShared';
|
||||||
|
|
||||||
|
interface WorkspaceExplorerLabels {
|
||||||
|
autoRefresh: string;
|
||||||
|
clearSearch: string;
|
||||||
|
download: string;
|
||||||
|
emptyDir: string;
|
||||||
|
fileNotPreviewable: string;
|
||||||
|
folder: string;
|
||||||
|
goUp: string;
|
||||||
|
goUpTitle: string;
|
||||||
|
loadingDir: string;
|
||||||
|
noPreviewFile: string;
|
||||||
|
openFolderTitle: string;
|
||||||
|
openingPreview: string;
|
||||||
|
previewTitle: string;
|
||||||
|
refreshHint: string;
|
||||||
|
searchAction: string;
|
||||||
|
workspaceHint: string;
|
||||||
|
workspaceSearchNoResult: string;
|
||||||
|
workspaceSearchPlaceholder: string;
|
||||||
|
workspaceTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceExplorerPanelProps {
|
||||||
|
entries: WorkspaceNode[];
|
||||||
|
labels: WorkspaceExplorerLabels;
|
||||||
|
normalizedWorkspaceQuery: string;
|
||||||
|
onHoverNode: (node: WorkspaceNode, anchor: HTMLElement) => void;
|
||||||
|
onLeaveNode: () => void;
|
||||||
|
onOpenFile: (path: string) => void;
|
||||||
|
onOpenFolder: (path: string) => void;
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onToggleAutoRefresh: () => void;
|
||||||
|
query: string;
|
||||||
|
queryInputName: string;
|
||||||
|
showParentEntry: boolean;
|
||||||
|
showPreviewFileHint: boolean;
|
||||||
|
workspaceAutoRefresh: boolean;
|
||||||
|
workspaceError: string;
|
||||||
|
workspaceFileLoading: boolean;
|
||||||
|
workspaceLoading: boolean;
|
||||||
|
workspaceParentPath: string | null;
|
||||||
|
workspacePathDisplay: string;
|
||||||
|
workspaceSearchLoading: boolean;
|
||||||
|
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceExplorerPanel({
|
||||||
|
entries,
|
||||||
|
labels,
|
||||||
|
normalizedWorkspaceQuery,
|
||||||
|
onHoverNode,
|
||||||
|
onLeaveNode,
|
||||||
|
onOpenFile,
|
||||||
|
onOpenFolder,
|
||||||
|
onQueryChange,
|
||||||
|
onRefresh,
|
||||||
|
onToggleAutoRefresh,
|
||||||
|
query,
|
||||||
|
queryInputName,
|
||||||
|
showParentEntry,
|
||||||
|
showPreviewFileHint,
|
||||||
|
workspaceAutoRefresh,
|
||||||
|
workspaceError,
|
||||||
|
workspaceFileLoading,
|
||||||
|
workspaceLoading,
|
||||||
|
workspaceParentPath,
|
||||||
|
workspacePathDisplay,
|
||||||
|
workspaceSearchLoading,
|
||||||
|
workspaceDownloadExtensionSet,
|
||||||
|
}: WorkspaceExplorerPanelProps) {
|
||||||
|
const renderedNodes: ReactNode[] = [];
|
||||||
|
|
||||||
|
if (showParentEntry) {
|
||||||
|
renderedNodes.push(
|
||||||
|
<button
|
||||||
|
key="dir:.."
|
||||||
|
className="workspace-entry dir nav-up"
|
||||||
|
onClick={() => onOpenFolder(workspaceParentPath || '')}
|
||||||
|
title={labels.goUpTitle}
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
<span className="workspace-entry-name">..</span>
|
||||||
|
<span className="workspace-entry-meta">{labels.goUp}</span>
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach((node) => {
|
||||||
|
const key = `${node.type}:${node.path}`;
|
||||||
|
if (node.type === 'dir') {
|
||||||
|
renderedNodes.push(
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className="workspace-entry dir"
|
||||||
|
onClick={() => onOpenFolder(node.path)}
|
||||||
|
title={labels.openFolderTitle}
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
|
||||||
|
<span className="workspace-entry-meta">{labels.folder}</span>
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewable = isPreviewableWorkspaceFile(node, workspaceDownloadExtensionSet);
|
||||||
|
const downloadOnlyFile = workspaceFileAction(node.path, workspaceDownloadExtensionSet) === 'download';
|
||||||
|
renderedNodes.push(
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className={`workspace-entry file ${previewable ? '' : 'disabled'}`}
|
||||||
|
disabled={workspaceFileLoading}
|
||||||
|
aria-disabled={!previewable || workspaceFileLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (workspaceFileLoading || !previewable) return;
|
||||||
|
onOpenFile(node.path);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(event) => onHoverNode(node, event.currentTarget)}
|
||||||
|
onMouseLeave={onLeaveNode}
|
||||||
|
onFocus={(event) => onHoverNode(node, event.currentTarget)}
|
||||||
|
onBlur={onLeaveNode}
|
||||||
|
title={previewable ? (downloadOnlyFile ? labels.download : labels.previewTitle) : labels.fileNotPreviewable}
|
||||||
|
>
|
||||||
|
<FileText size={14} />
|
||||||
|
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
|
||||||
|
<span className="workspace-entry-meta mono">{node.ext || '-'}</span>
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card ops-runtime-card">
|
||||||
|
<div className="section-mini-title">{labels.workspaceTitle}</div>
|
||||||
|
{workspaceError ? <div className="ops-empty-inline">{workspaceError}</div> : null}
|
||||||
|
<div className="workspace-toolbar">
|
||||||
|
<div className="workspace-path-wrap">
|
||||||
|
<div className="workspace-path mono" title={workspacePathDisplay}>
|
||||||
|
{workspacePathDisplay}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-toolbar-actions">
|
||||||
|
<LucentIconButton
|
||||||
|
className="workspace-refresh-icon-btn"
|
||||||
|
disabled={workspaceLoading}
|
||||||
|
onClick={onRefresh}
|
||||||
|
tooltip={labels.refreshHint}
|
||||||
|
aria-label={labels.refreshHint}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={workspaceLoading ? 'animate-spin' : ''} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<label className="workspace-auto-switch" title={labels.autoRefresh}>
|
||||||
|
<span className="workspace-auto-switch-label">{labels.autoRefresh}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={workspaceAutoRefresh}
|
||||||
|
onChange={onToggleAutoRefresh}
|
||||||
|
aria-label={labels.autoRefresh}
|
||||||
|
/>
|
||||||
|
<span className="workspace-auto-switch-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-search-toolbar">
|
||||||
|
<div className="ops-searchbar">
|
||||||
|
<input
|
||||||
|
className="input ops-search-input ops-search-input-with-icon"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
placeholder={labels.workspaceSearchPlaceholder}
|
||||||
|
aria-label={labels.workspaceSearchPlaceholder}
|
||||||
|
autoComplete="new-password"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
inputMode="search"
|
||||||
|
name={queryInputName}
|
||||||
|
id={queryInputName}
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-search-inline-btn"
|
||||||
|
onClick={() => {
|
||||||
|
if (query.trim()) {
|
||||||
|
onQueryChange('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onQueryChange(query.trim());
|
||||||
|
}}
|
||||||
|
title={query.trim() ? labels.clearSearch : labels.searchAction}
|
||||||
|
aria-label={query.trim() ? labels.clearSearch : labels.searchAction}
|
||||||
|
>
|
||||||
|
{query.trim() ? <X size={14} /> : <Search size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-panel">
|
||||||
|
<div className="workspace-list">
|
||||||
|
{workspaceLoading || workspaceSearchLoading ? (
|
||||||
|
<div className="ops-empty-inline">{labels.loadingDir}</div>
|
||||||
|
) : renderedNodes.length === 0 ? (
|
||||||
|
<div className="ops-empty-inline">{normalizedWorkspaceQuery ? labels.workspaceSearchNoResult : labels.emptyDir}</div>
|
||||||
|
) : (
|
||||||
|
renderedNodes
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="workspace-hint">
|
||||||
|
{workspaceFileLoading
|
||||||
|
? labels.openingPreview
|
||||||
|
: labels.workspaceHint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showPreviewFileHint ? (
|
||||||
|
<div className="ops-empty-inline">{labels.noPreviewFile}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspacePreviewModalProps {
|
||||||
|
buildWorkspaceDownloadHref: (path: string, forceDownload?: boolean) => string;
|
||||||
|
buildWorkspaceRawHref: (path: string, forceDownload?: boolean) => string;
|
||||||
|
closeLabel: string;
|
||||||
|
closePreview: () => void;
|
||||||
|
copyAddressLabel: string;
|
||||||
|
copyPathLabel: string;
|
||||||
|
downloadLabel: string;
|
||||||
|
editFileLabel: string;
|
||||||
|
filePreviewLabel: string;
|
||||||
|
fileTruncatedLabel: string;
|
||||||
|
fullscreenEditLabel: string;
|
||||||
|
fullscreenExitLabel: string;
|
||||||
|
fullscreenPreviewLabel: string;
|
||||||
|
isZh: boolean;
|
||||||
|
onCopyPreviewPath: (path: string) => void;
|
||||||
|
onCopyPreviewUrl: (path: string) => void;
|
||||||
|
onOpenWorkspacePath: (path: string) => void;
|
||||||
|
onSaveShortcut: () => void;
|
||||||
|
preview: WorkspacePreviewState;
|
||||||
|
previewCanEdit: boolean;
|
||||||
|
previewDraft: string;
|
||||||
|
previewEditorEnabled: boolean;
|
||||||
|
previewFullscreen: boolean;
|
||||||
|
previewSaving: boolean;
|
||||||
|
resolveMediaSrc: (src: string, baseFilePath?: string) => string;
|
||||||
|
saveLabel: string;
|
||||||
|
setPreviewDraft: (value: string) => void;
|
||||||
|
setPreviewFullscreen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
|
setPreviewMode: (mode: 'preview' | 'edit') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspacePreviewModal({
|
||||||
|
buildWorkspaceDownloadHref,
|
||||||
|
buildWorkspaceRawHref,
|
||||||
|
closeLabel,
|
||||||
|
closePreview,
|
||||||
|
copyAddressLabel,
|
||||||
|
copyPathLabel,
|
||||||
|
downloadLabel,
|
||||||
|
editFileLabel,
|
||||||
|
filePreviewLabel,
|
||||||
|
fileTruncatedLabel,
|
||||||
|
fullscreenEditLabel,
|
||||||
|
fullscreenExitLabel,
|
||||||
|
fullscreenPreviewLabel,
|
||||||
|
onCopyPreviewPath,
|
||||||
|
onCopyPreviewUrl,
|
||||||
|
onOpenWorkspacePath,
|
||||||
|
onSaveShortcut,
|
||||||
|
preview,
|
||||||
|
previewCanEdit,
|
||||||
|
previewDraft,
|
||||||
|
previewEditorEnabled,
|
||||||
|
previewFullscreen,
|
||||||
|
previewSaving,
|
||||||
|
resolveMediaSrc,
|
||||||
|
saveLabel,
|
||||||
|
setPreviewDraft,
|
||||||
|
setPreviewFullscreen,
|
||||||
|
setPreviewMode,
|
||||||
|
}: WorkspacePreviewModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="modal-mask" onClick={closePreview}>
|
||||||
|
<div className={`modal-card modal-preview ${previewFullscreen ? 'modal-preview-fullscreen' : ''}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-title-row workspace-preview-header">
|
||||||
|
<div className="workspace-preview-header-text">
|
||||||
|
<h3>{previewEditorEnabled ? editFileLabel : filePreviewLabel}</h3>
|
||||||
|
<span className="modal-sub mono workspace-preview-path-row">
|
||||||
|
<span className="workspace-path-segments" title={preview.path}>
|
||||||
|
{renderWorkspacePathSegments(preview.path, 'preview-path')}
|
||||||
|
</span>
|
||||||
|
<LucentIconButton
|
||||||
|
className="workspace-preview-copy-name"
|
||||||
|
onClick={() => void onCopyPreviewPath(preview.path)}
|
||||||
|
tooltip={copyPathLabel}
|
||||||
|
aria-label={copyPathLabel}
|
||||||
|
>
|
||||||
|
<Copy size={12} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-preview-header-actions">
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
onClick={() => setPreviewFullscreen((v) => !v)}
|
||||||
|
tooltip={
|
||||||
|
previewFullscreen
|
||||||
|
? fullscreenExitLabel
|
||||||
|
: previewEditorEnabled
|
||||||
|
? fullscreenEditLabel
|
||||||
|
: fullscreenPreviewLabel
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
previewFullscreen
|
||||||
|
? fullscreenExitLabel
|
||||||
|
: previewEditorEnabled
|
||||||
|
? fullscreenEditLabel
|
||||||
|
: fullscreenPreviewLabel
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{previewFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
onClick={closePreview}
|
||||||
|
tooltip={closeLabel}
|
||||||
|
aria-label={closeLabel}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`workspace-preview-body ${preview.isMarkdown ? 'markdown' : ''} ${previewEditorEnabled ? 'is-editing' : ''} ${preview.isImage || preview.isVideo || preview.isAudio ? 'media' : ''}`}
|
||||||
|
>
|
||||||
|
{preview.isImage ? (
|
||||||
|
<img
|
||||||
|
className="workspace-preview-image"
|
||||||
|
src={buildWorkspaceDownloadHref(preview.path, false)}
|
||||||
|
alt={preview.path.split('/').pop() || 'workspace-image'}
|
||||||
|
/>
|
||||||
|
) : preview.isVideo ? (
|
||||||
|
<video
|
||||||
|
className="workspace-preview-media"
|
||||||
|
src={buildWorkspaceDownloadHref(preview.path, false)}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
) : preview.isAudio ? (
|
||||||
|
<audio
|
||||||
|
className="workspace-preview-audio"
|
||||||
|
src={buildWorkspaceDownloadHref(preview.path, false)}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
) : preview.isHtml ? (
|
||||||
|
<iframe
|
||||||
|
className="workspace-preview-embed"
|
||||||
|
src={buildWorkspaceRawHref(preview.path, false)}
|
||||||
|
title={preview.path}
|
||||||
|
/>
|
||||||
|
) : previewEditorEnabled ? (
|
||||||
|
<MarkdownLiteEditor
|
||||||
|
className="workspace-preview-editor-shell"
|
||||||
|
textareaClassName="workspace-preview-editor"
|
||||||
|
value={previewDraft}
|
||||||
|
onChange={setPreviewDraft}
|
||||||
|
spellCheck={false}
|
||||||
|
fullHeight
|
||||||
|
onSaveShortcut={onSaveShortcut}
|
||||||
|
/>
|
||||||
|
) : preview.isMarkdown ? (
|
||||||
|
<WorkspaceMarkdownView
|
||||||
|
className="workspace-markdown"
|
||||||
|
content={preview.content || ''}
|
||||||
|
openWorkspacePath={(path) => {
|
||||||
|
void onOpenWorkspacePath(path);
|
||||||
|
}}
|
||||||
|
resolveMediaSrc={resolveMediaSrc}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre>{preview.content}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{preview.truncated ? (
|
||||||
|
<div className="ops-empty-inline">{fileTruncatedLabel}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="row-between">
|
||||||
|
<span className="workspace-preview-meta mono">{preview.ext || '-'}</span>
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{previewEditorEnabled ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewDraft(preview.content || '');
|
||||||
|
setPreviewMode('preview');
|
||||||
|
}}
|
||||||
|
disabled={previewSaving}
|
||||||
|
>
|
||||||
|
{closeLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={onSaveShortcut}
|
||||||
|
disabled={previewSaving}
|
||||||
|
>
|
||||||
|
{previewSaving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
<span style={{ marginLeft: 6 }}>{saveLabel}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : previewCanEdit ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setPreviewMode('edit')}
|
||||||
|
>
|
||||||
|
{editFileLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{preview.isHtml ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => void onCopyPreviewUrl(preview.path)}
|
||||||
|
>
|
||||||
|
{copyAddressLabel}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
className="btn btn-secondary"
|
||||||
|
href={buildWorkspaceDownloadHref(preview.path, true)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
download={preview.path.split('/').pop() || 'workspace-file'}
|
||||||
|
>
|
||||||
|
{downloadLabel}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceHoverTooltipProps {
|
||||||
|
hoverCard: WorkspaceHoverCardState;
|
||||||
|
isZh: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceHoverTooltip({ hoverCard, isZh }: WorkspaceHoverTooltipProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`workspace-hover-panel ${hoverCard.above ? 'is-above' : ''}`}
|
||||||
|
style={{ top: hoverCard.top, left: hoverCard.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">{hoverCard.node.name || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-entry-info-row">
|
||||||
|
<span className="workspace-entry-info-label">{isZh ? '完整路径' : 'Full Path'}</span>
|
||||||
|
<span
|
||||||
|
className="workspace-entry-info-value workspace-entry-info-path mono"
|
||||||
|
title={`/root/.nanobot/workspace/${String(hoverCard.node.path || '').replace(/^\/+/, '')}`}
|
||||||
|
>
|
||||||
|
{renderWorkspacePathSegments(
|
||||||
|
`/root/.nanobot/workspace/${String(hoverCard.node.path || '').replace(/^\/+/, '')}`,
|
||||||
|
'hover-path',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-entry-info-row">
|
||||||
|
<span className="workspace-entry-info-label">{isZh ? '创建时间' : 'Created'}</span>
|
||||||
|
<span className="workspace-entry-info-value">{formatWorkspaceTime(hoverCard.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(hoverCard.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(hoverCard.node.size)) ? formatBytes(Number(hoverCard.node.size)) : '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,594 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { ChevronDown, ChevronUp, RefreshCw, Save, Trash2, X } from 'lucide-react';
|
||||||
|
import { LucentIconButton } from '../../../../components/lucent/LucentIconButton';
|
||||||
|
import type { BotChannel, BotTopic } from '../../botDashboardShared';
|
||||||
|
import { normalizeRoutingTextList } from './shared';
|
||||||
|
|
||||||
|
interface ChannelDrawerLabels {
|
||||||
|
addChannel: string;
|
||||||
|
cancel: string;
|
||||||
|
channelConfigured: string;
|
||||||
|
channelEmpty: string;
|
||||||
|
channelPending: string;
|
||||||
|
channelType: string;
|
||||||
|
customChannel: string;
|
||||||
|
disabled: string;
|
||||||
|
enabled: string;
|
||||||
|
globalDeliveryDesc: string;
|
||||||
|
globalDeliveryTitle: string;
|
||||||
|
remove: string;
|
||||||
|
saveChannel: string;
|
||||||
|
sendProgress: string;
|
||||||
|
sendToolHints: string;
|
||||||
|
collapse: string;
|
||||||
|
expand: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelDrawerContentProps {
|
||||||
|
channels: BotChannel[];
|
||||||
|
expandedChannelByKey: Record<string, boolean>;
|
||||||
|
globalDelivery: {
|
||||||
|
sendProgress?: boolean;
|
||||||
|
sendToolHints?: boolean;
|
||||||
|
};
|
||||||
|
isSavingChannel: boolean;
|
||||||
|
isSavingGlobalDelivery: boolean;
|
||||||
|
labels: ChannelDrawerLabels;
|
||||||
|
newChannelDraft: BotChannel;
|
||||||
|
newChannelPanelOpen: boolean;
|
||||||
|
onCancelNewChannel: () => void;
|
||||||
|
onSaveChannel: (channel: BotChannel) => void;
|
||||||
|
onSaveGlobalDelivery: () => void;
|
||||||
|
onToggleChannelExpanded: (uiKey: string, expanded: boolean) => void;
|
||||||
|
onUpdateChannelLocal: (index: number, patch: Partial<BotChannel>) => void;
|
||||||
|
onUpdateGlobalDeliveryFlag: (key: 'sendProgress' | 'sendToolHints', value: boolean) => void;
|
||||||
|
onUpdateNewChannelDraft: (patch: Partial<BotChannel>) => void;
|
||||||
|
onRemoveChannel: (channel: BotChannel) => void;
|
||||||
|
renderChannelFields: (channel: BotChannel, onPatch: (patch: Partial<BotChannel>) => void) => ReactNode;
|
||||||
|
resolveChannelHasCredential: (channel: BotChannel) => boolean;
|
||||||
|
resolveChannelUiKey: (channel: BotChannel, index: number) => string;
|
||||||
|
shouldHideChannel: (channel: BotChannel) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelDrawerContent({
|
||||||
|
channels,
|
||||||
|
expandedChannelByKey,
|
||||||
|
globalDelivery,
|
||||||
|
isSavingChannel,
|
||||||
|
isSavingGlobalDelivery,
|
||||||
|
labels,
|
||||||
|
newChannelDraft,
|
||||||
|
newChannelPanelOpen,
|
||||||
|
onCancelNewChannel,
|
||||||
|
onSaveChannel,
|
||||||
|
onSaveGlobalDelivery,
|
||||||
|
onToggleChannelExpanded,
|
||||||
|
onUpdateChannelLocal,
|
||||||
|
onUpdateGlobalDeliveryFlag,
|
||||||
|
onUpdateNewChannelDraft,
|
||||||
|
onRemoveChannel,
|
||||||
|
renderChannelFields,
|
||||||
|
resolveChannelHasCredential,
|
||||||
|
resolveChannelUiKey,
|
||||||
|
shouldHideChannel,
|
||||||
|
}: ChannelDrawerContentProps) {
|
||||||
|
const visibleChannels = channels.filter((channel) => !shouldHideChannel(channel));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-bot-panel-drawer-shell ops-config-modal">
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-mini-title">{labels.globalDeliveryTitle}</div>
|
||||||
|
<div className="field-label">{labels.globalDeliveryDesc}</div>
|
||||||
|
<div className="wizard-dashboard-switches" style={{ marginTop: 8 }}>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(globalDelivery.sendProgress)}
|
||||||
|
onChange={(event) => onUpdateGlobalDeliveryFlag('sendProgress', event.target.checked)}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{labels.sendProgress}
|
||||||
|
</label>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(globalDelivery.sendToolHints)}
|
||||||
|
onChange={(event) => onUpdateGlobalDeliveryFlag('sendToolHints', event.target.checked)}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{labels.sendToolHints}
|
||||||
|
</label>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-primary btn-sm icon-btn"
|
||||||
|
disabled={isSavingGlobalDelivery}
|
||||||
|
onClick={onSaveGlobalDelivery}
|
||||||
|
tooltip={labels.saveChannel}
|
||||||
|
aria-label={labels.saveChannel}
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="wizard-channel-list ops-config-list-scroll">
|
||||||
|
{visibleChannels.length === 0 ? (
|
||||||
|
<div className="ops-empty-inline">{labels.channelEmpty}</div>
|
||||||
|
) : (
|
||||||
|
channels.map((channel, index) => {
|
||||||
|
if (shouldHideChannel(channel)) return null;
|
||||||
|
|
||||||
|
const uiKey = resolveChannelUiKey(channel, index);
|
||||||
|
const expanded = expandedChannelByKey[uiKey] ?? index === 0;
|
||||||
|
const hasCredential = resolveChannelHasCredential(channel);
|
||||||
|
const summary = [
|
||||||
|
String(channel.channel_type || '').toUpperCase(),
|
||||||
|
channel.is_active ? labels.enabled : labels.disabled,
|
||||||
|
hasCredential ? labels.channelConfigured : labels.channelPending,
|
||||||
|
].join(' · ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${channel.id}-${channel.channel_type}`} className="card wizard-channel-card wizard-channel-compact">
|
||||||
|
<div className="ops-config-card-header">
|
||||||
|
<div className="ops-config-card-main">
|
||||||
|
<strong>{String(channel.channel_type || '').toUpperCase()}</strong>
|
||||||
|
<div className="ops-config-collapsed-meta">{summary}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-card-actions">
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={channel.is_active}
|
||||||
|
onChange={(event) => onUpdateChannelLocal(index, { is_active: event.target.checked })}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{labels.enabled}
|
||||||
|
</label>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||||
|
disabled={isSavingChannel}
|
||||||
|
onClick={() => onRemoveChannel(channel)}
|
||||||
|
tooltip={labels.remove}
|
||||||
|
aria-label={labels.remove}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="ops-plain-icon-btn"
|
||||||
|
onClick={() => onToggleChannelExpanded(uiKey, expanded)}
|
||||||
|
tooltip={expanded ? labels.collapse : labels.expand}
|
||||||
|
aria-label={expanded ? labels.collapse : labels.expand}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
<div className="ops-topic-grid">
|
||||||
|
{renderChannelFields(channel, (patch) => onUpdateChannelLocal(index, patch))}
|
||||||
|
</div>
|
||||||
|
<div className="row-between ops-config-footer">
|
||||||
|
<span className="field-label">{labels.customChannel}</span>
|
||||||
|
<button className="btn btn-primary btn-sm" disabled={isSavingChannel} onClick={() => onSaveChannel(channel)}>
|
||||||
|
<Save size={14} />
|
||||||
|
<span style={{ marginLeft: 6 }}>{labels.saveChannel}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{newChannelPanelOpen ? (
|
||||||
|
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
|
||||||
|
<div className="ops-config-card-header">
|
||||||
|
<div className="ops-config-card-main">
|
||||||
|
<strong>{labels.addChannel}</strong>
|
||||||
|
<div className="ops-config-collapsed-meta">{String(newChannelDraft.channel_type || '').toUpperCase()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-card-actions">
|
||||||
|
<LucentIconButton className="ops-plain-icon-btn" onClick={onCancelNewChannel} tooltip={labels.cancel} aria-label={labels.cancel}>
|
||||||
|
<X size={15} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ops-topic-grid">
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.channelType}</label>
|
||||||
|
<input className="input mono" value={String(newChannelDraft.channel_type || '').toUpperCase()} readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
||||||
|
<label className="field-label" style={{ visibility: 'hidden' }}>
|
||||||
|
{labels.enabled}
|
||||||
|
</label>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newChannelDraft.is_active}
|
||||||
|
onChange={(event) => onUpdateNewChannelDraft({ is_active: event.target.checked })}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{labels.enabled}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{renderChannelFields(newChannelDraft, onUpdateNewChannelDraft)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicDrawerLabels {
|
||||||
|
cancel: string;
|
||||||
|
collapse: string;
|
||||||
|
delete: string;
|
||||||
|
expand: string;
|
||||||
|
hideAdvanced: string;
|
||||||
|
includeSummary: string;
|
||||||
|
excludeSummary: string;
|
||||||
|
save: string;
|
||||||
|
showAdvanced: string;
|
||||||
|
topicActive: string;
|
||||||
|
topicAdd: string;
|
||||||
|
topicAddHint: string;
|
||||||
|
topicDescription: string;
|
||||||
|
topicEmpty: string;
|
||||||
|
topicExamplesNegative: string;
|
||||||
|
topicExamplesPositive: string;
|
||||||
|
topicExcludeWhen: string;
|
||||||
|
topicIncludeWhen: string;
|
||||||
|
topicKey: string;
|
||||||
|
topicKeyPlaceholder: string;
|
||||||
|
topicListHint: string;
|
||||||
|
topicName: string;
|
||||||
|
topicPriority: string;
|
||||||
|
topicPurpose: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicDrawerContentProps {
|
||||||
|
expandedTopicByKey: Record<string, boolean>;
|
||||||
|
isSavingTopic: boolean;
|
||||||
|
labels: TopicDrawerLabels;
|
||||||
|
newTopicAdvancedOpen: boolean;
|
||||||
|
newTopicDescription: string;
|
||||||
|
newTopicExamplesNegative: string;
|
||||||
|
newTopicExamplesPositive: string;
|
||||||
|
newTopicExcludeWhen: string;
|
||||||
|
newTopicIncludeWhen: string;
|
||||||
|
newTopicKey: string;
|
||||||
|
newTopicName: string;
|
||||||
|
newTopicPanelOpen: boolean;
|
||||||
|
newTopicPriority: string;
|
||||||
|
newTopicPurpose: string;
|
||||||
|
newTopicSourceLabel: string;
|
||||||
|
onCancelNewTopic: () => void;
|
||||||
|
onChangeNewTopicDescription: (value: string) => void;
|
||||||
|
onChangeNewTopicExamplesNegative: (value: string) => void;
|
||||||
|
onChangeNewTopicExamplesPositive: (value: string) => void;
|
||||||
|
onChangeNewTopicExcludeWhen: (value: string) => void;
|
||||||
|
onChangeNewTopicIncludeWhen: (value: string) => void;
|
||||||
|
onChangeNewTopicKey: (value: string) => void;
|
||||||
|
onChangeNewTopicName: (value: string) => void;
|
||||||
|
onChangeNewTopicPriority: (value: string) => void;
|
||||||
|
onChangeNewTopicPurpose: (value: string) => void;
|
||||||
|
onRemoveTopic: (topic: BotTopic) => void;
|
||||||
|
onSaveTopic: (topic: BotTopic) => void;
|
||||||
|
onToggleNewTopicAdvanced: () => void;
|
||||||
|
onToggleTopicExpanded: (uiKey: string, expanded: boolean) => void;
|
||||||
|
onUpdateTopicLocal: (index: number, patch: Partial<BotTopic>) => void;
|
||||||
|
resolveTopicUiKey: (topic: BotTopic, index: number) => string;
|
||||||
|
topics: BotTopic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopicDrawerContent({
|
||||||
|
expandedTopicByKey,
|
||||||
|
isSavingTopic,
|
||||||
|
labels,
|
||||||
|
newTopicAdvancedOpen,
|
||||||
|
newTopicDescription,
|
||||||
|
newTopicExamplesNegative,
|
||||||
|
newTopicExamplesPositive,
|
||||||
|
newTopicExcludeWhen,
|
||||||
|
newTopicIncludeWhen,
|
||||||
|
newTopicKey,
|
||||||
|
newTopicName,
|
||||||
|
newTopicPanelOpen,
|
||||||
|
newTopicPriority,
|
||||||
|
newTopicPurpose,
|
||||||
|
newTopicSourceLabel,
|
||||||
|
onCancelNewTopic,
|
||||||
|
onChangeNewTopicDescription,
|
||||||
|
onChangeNewTopicExamplesNegative,
|
||||||
|
onChangeNewTopicExamplesPositive,
|
||||||
|
onChangeNewTopicExcludeWhen,
|
||||||
|
onChangeNewTopicIncludeWhen,
|
||||||
|
onChangeNewTopicKey,
|
||||||
|
onChangeNewTopicName,
|
||||||
|
onChangeNewTopicPriority,
|
||||||
|
onChangeNewTopicPurpose,
|
||||||
|
onRemoveTopic,
|
||||||
|
onSaveTopic,
|
||||||
|
onToggleNewTopicAdvanced,
|
||||||
|
onToggleTopicExpanded,
|
||||||
|
onUpdateTopicLocal,
|
||||||
|
resolveTopicUiKey,
|
||||||
|
topics,
|
||||||
|
}: TopicDrawerContentProps) {
|
||||||
|
return (
|
||||||
|
<div className="ops-bot-panel-drawer-shell ops-config-modal">
|
||||||
|
<div className="wizard-channel-list ops-config-list-scroll">
|
||||||
|
{topics.length === 0 ? (
|
||||||
|
<div className="ops-empty-inline">{labels.topicEmpty}</div>
|
||||||
|
) : (
|
||||||
|
topics.map((topic, index) => {
|
||||||
|
const uiKey = resolveTopicUiKey(topic, index);
|
||||||
|
const expanded = expandedTopicByKey[uiKey] ?? index === 0;
|
||||||
|
const includeCount = normalizeRoutingTextList(String(topic.routing_include_when || '')).length;
|
||||||
|
const excludeCount = normalizeRoutingTextList(String(topic.routing_exclude_when || '')).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${topic.id}-${topic.topic_key}`} className="card wizard-channel-card wizard-channel-compact">
|
||||||
|
<div className="ops-config-card-header">
|
||||||
|
<div className="ops-config-card-main">
|
||||||
|
<strong className="mono">{topic.topic_key}</strong>
|
||||||
|
<div className="field-label">{topic.name || topic.topic_key}</div>
|
||||||
|
{!expanded ? (
|
||||||
|
<div className="ops-config-collapsed-meta">
|
||||||
|
{`${labels.topicPriority}: ${topic.routing_priority || '50'} · ${labels.includeSummary} ${includeCount} · ${labels.excludeSummary} ${excludeCount}`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-card-actions">
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(topic.is_active)}
|
||||||
|
onChange={(event) => onUpdateTopicLocal(index, { is_active: event.target.checked })}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{labels.topicActive}
|
||||||
|
</label>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||||
|
disabled={isSavingTopic}
|
||||||
|
onClick={() => onRemoveTopic(topic)}
|
||||||
|
tooltip={labels.delete}
|
||||||
|
aria-label={labels.delete}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="ops-plain-icon-btn"
|
||||||
|
onClick={() => onToggleTopicExpanded(uiKey, expanded)}
|
||||||
|
tooltip={expanded ? labels.collapse : labels.expand}
|
||||||
|
aria-label={expanded ? labels.collapse : labels.expand}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
<div className="ops-topic-grid">
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicName}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={topic.name || ''}
|
||||||
|
onChange={(event) => onUpdateTopicLocal(index, { name: event.target.value })}
|
||||||
|
placeholder={labels.topicName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicPriority}</label>
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={topic.routing_priority || '50'}
|
||||||
|
onChange={(event) => onUpdateTopicLocal(index, { routing_priority: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field ops-config-field-full">
|
||||||
|
<label className="field-label">{labels.topicDescription}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={3}
|
||||||
|
value={topic.description || ''}
|
||||||
|
onChange={(event) => onUpdateTopicLocal(index, { description: event.target.value })}
|
||||||
|
placeholder={labels.topicDescription}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field ops-config-field-full">
|
||||||
|
<label className="field-label">{labels.topicPurpose}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={3}
|
||||||
|
value={topic.routing_purpose || ''}
|
||||||
|
onChange={(event) => onUpdateTopicLocal(index, { routing_purpose: event.target.value })}
|
||||||
|
placeholder={labels.topicPurpose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicIncludeWhen}</label>
|
||||||
|
<textarea
|
||||||
|
className="input mono"
|
||||||
|
rows={4}
|
||||||
|
value={topic.routing_include_when || ''}
|
||||||
|
onChange={(event) => onUpdateTopicLocal(index, { routing_include_when: event.target.value })}
|
||||||
|
placeholder={labels.topicListHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicExcludeWhen}</label>
|
||||||
|
<textarea
|
||||||
|
className="input mono"
|
||||||
|
rows={4}
|
||||||
|
value={topic.routing_exclude_when || ''}
|
||||||
|
onChange={(event) => onUpdateTopicLocal(index, { routing_exclude_when: event.target.value })}
|
||||||
|
placeholder={labels.topicListHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicExamplesPositive}</label>
|
||||||
|
<textarea
|
||||||
|
className="input mono"
|
||||||
|
rows={4}
|
||||||
|
value={topic.routing_examples_positive || ''}
|
||||||
|
onChange={(event) => onUpdateTopicLocal(index, { routing_examples_positive: event.target.value })}
|
||||||
|
placeholder={labels.topicListHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicExamplesNegative}</label>
|
||||||
|
<textarea
|
||||||
|
className="input mono"
|
||||||
|
rows={4}
|
||||||
|
value={topic.routing_examples_negative || ''}
|
||||||
|
onChange={(event) => onUpdateTopicLocal(index, { routing_examples_negative: event.target.value })}
|
||||||
|
placeholder={labels.topicListHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row-between ops-config-footer">
|
||||||
|
<span className="field-label">{labels.topicAddHint}</span>
|
||||||
|
<button className="btn btn-primary btn-sm" disabled={isSavingTopic} onClick={() => onSaveTopic(topic)}>
|
||||||
|
{isSavingTopic ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
<span style={{ marginLeft: 6 }}>{labels.save}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{newTopicPanelOpen ? (
|
||||||
|
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
|
||||||
|
<div className="ops-config-card-header">
|
||||||
|
<div className="ops-config-card-main">
|
||||||
|
<strong>{labels.topicAdd}</strong>
|
||||||
|
<div className="ops-config-collapsed-meta">{newTopicSourceLabel}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-card-actions">
|
||||||
|
<LucentIconButton
|
||||||
|
className="ops-plain-icon-btn"
|
||||||
|
onClick={onToggleNewTopicAdvanced}
|
||||||
|
tooltip={newTopicAdvancedOpen ? labels.hideAdvanced : labels.showAdvanced}
|
||||||
|
aria-label={newTopicAdvancedOpen ? labels.hideAdvanced : labels.showAdvanced}
|
||||||
|
>
|
||||||
|
{newTopicAdvancedOpen ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton className="ops-plain-icon-btn" onClick={onCancelNewTopic} tooltip={labels.cancel} aria-label={labels.cancel}>
|
||||||
|
<X size={15} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ops-topic-grid">
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicKey}</label>
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
value={newTopicKey}
|
||||||
|
onChange={(event) => onChangeNewTopicKey(event.target.value)}
|
||||||
|
placeholder={labels.topicKeyPlaceholder}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicName}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={newTopicName}
|
||||||
|
onChange={(event) => onChangeNewTopicName(event.target.value)}
|
||||||
|
placeholder={labels.topicName}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field ops-config-field-full">
|
||||||
|
<label className="field-label">{labels.topicDescription}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={3}
|
||||||
|
value={newTopicDescription}
|
||||||
|
onChange={(event) => onChangeNewTopicDescription(event.target.value)}
|
||||||
|
placeholder={labels.topicDescription}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{newTopicAdvancedOpen ? (
|
||||||
|
<>
|
||||||
|
<div className="ops-config-field ops-config-field-full">
|
||||||
|
<label className="field-label">{labels.topicPurpose}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={3}
|
||||||
|
value={newTopicPurpose}
|
||||||
|
onChange={(event) => onChangeNewTopicPurpose(event.target.value)}
|
||||||
|
placeholder={labels.topicPurpose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicIncludeWhen}</label>
|
||||||
|
<textarea
|
||||||
|
className="input mono"
|
||||||
|
rows={4}
|
||||||
|
value={newTopicIncludeWhen}
|
||||||
|
onChange={(event) => onChangeNewTopicIncludeWhen(event.target.value)}
|
||||||
|
placeholder={labels.topicListHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicExcludeWhen}</label>
|
||||||
|
<textarea
|
||||||
|
className="input mono"
|
||||||
|
rows={4}
|
||||||
|
value={newTopicExcludeWhen}
|
||||||
|
onChange={(event) => onChangeNewTopicExcludeWhen(event.target.value)}
|
||||||
|
placeholder={labels.topicListHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicExamplesPositive}</label>
|
||||||
|
<textarea
|
||||||
|
className="input mono"
|
||||||
|
rows={4}
|
||||||
|
value={newTopicExamplesPositive}
|
||||||
|
onChange={(event) => onChangeNewTopicExamplesPositive(event.target.value)}
|
||||||
|
placeholder={labels.topicListHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicExamplesNegative}</label>
|
||||||
|
<textarea
|
||||||
|
className="input mono"
|
||||||
|
rows={4}
|
||||||
|
value={newTopicExamplesNegative}
|
||||||
|
onChange={(event) => onChangeNewTopicExamplesNegative(event.target.value)}
|
||||||
|
placeholder={labels.topicListHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ops-config-field">
|
||||||
|
<label className="field-label">{labels.topicPriority}</label>
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={newTopicPriority}
|
||||||
|
onChange={(event) => onChangeNewTopicPriority(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue