v0.1.4-p5
parent
ca1f941e4c
commit
ae34bfc6a0
|
|
@ -16,9 +16,9 @@ from services.bot_management_service import (
|
|||
create_bot_record,
|
||||
get_bot_detail_cached,
|
||||
list_bots_with_cache,
|
||||
test_provider_connection,
|
||||
update_bot_record,
|
||||
)
|
||||
from services.provider_service import test_provider_connection
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
from fastapi import APIRouter, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from core.cache import cache
|
||||
from core.database import engine
|
||||
from core.settings import DATABASE_ENGINE, REDIS_ENABLED, REDIS_PREFIX, REDIS_URL
|
||||
from models.bot import BotInstance
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/health")
|
||||
def get_health():
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
session.exec(select(BotInstance).limit(1)).first()
|
||||
return {"status": "ok", "database": DATABASE_ENGINE}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail=f"database check failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/api/health/cache")
|
||||
def get_cache_health():
|
||||
redis_url = str(REDIS_URL or "").strip()
|
||||
configured = bool(REDIS_ENABLED and redis_url)
|
||||
client_enabled = bool(getattr(cache, "enabled", False))
|
||||
reachable = bool(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": REDIS_PREFIX,
|
||||
},
|
||||
}
|
||||
|
|
@ -1,121 +1,31 @@
|
|||
from typing import Any, Dict, List
|
||||
from typing import Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from core.cache import cache
|
||||
from core.database import get_session
|
||||
from core.docker_instance import docker_manager
|
||||
from models.bot import BotInstance, NanobotImage
|
||||
from services.cache_service import _cache_key_images, _invalidate_images_cache
|
||||
from services.image_service import (
|
||||
delete_registered_image,
|
||||
list_docker_images_by_repository,
|
||||
list_registered_images,
|
||||
register_image as register_image_record,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_image(row: NanobotImage) -> Dict[str, Any]:
|
||||
created_at = row.created_at.isoformat() + "Z" if row.created_at else None
|
||||
return {
|
||||
"tag": row.tag,
|
||||
"image_id": row.image_id,
|
||||
"version": row.version,
|
||||
"status": row.status,
|
||||
"source_dir": row.source_dir,
|
||||
"created_at": created_at,
|
||||
}
|
||||
|
||||
|
||||
def _reconcile_registered_images(session: Session) -> None:
|
||||
rows = session.exec(select(NanobotImage)).all()
|
||||
dirty = False
|
||||
for row in rows:
|
||||
docker_exists = docker_manager.has_image(row.tag)
|
||||
next_status = "READY" if docker_exists else "ERROR"
|
||||
next_image_id = row.image_id
|
||||
if docker_exists and docker_manager.client:
|
||||
try:
|
||||
next_image_id = docker_manager.client.images.get(row.tag).id
|
||||
except Exception:
|
||||
next_image_id = row.image_id
|
||||
if row.status != next_status or row.image_id != next_image_id:
|
||||
row.status = next_status
|
||||
row.image_id = next_image_id
|
||||
session.add(row)
|
||||
dirty = True
|
||||
if dirty:
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.get("/api/images")
|
||||
def list_images(session: Session = Depends(get_session)):
|
||||
cached = cache.get_json(_cache_key_images())
|
||||
if isinstance(cached, list) and all(isinstance(row, dict) for row in cached):
|
||||
return cached
|
||||
if isinstance(cached, list):
|
||||
_invalidate_images_cache()
|
||||
try:
|
||||
_reconcile_registered_images(session)
|
||||
except Exception as exc:
|
||||
# Docker status probing should not break image management in dev mode.
|
||||
print(f"[image_router] reconcile images skipped: {exc}")
|
||||
rows = session.exec(select(NanobotImage).order_by(NanobotImage.created_at.desc())).all()
|
||||
payload = [_serialize_image(row) for row in rows]
|
||||
cache.set_json(_cache_key_images(), payload, ttl=60)
|
||||
return payload
|
||||
return list_registered_images(session)
|
||||
|
||||
@router.delete("/api/images/{tag:path}")
|
||||
def delete_image(tag: str, session: Session = Depends(get_session)):
|
||||
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()
|
||||
_invalidate_images_cache()
|
||||
return {"status": "deleted"}
|
||||
return delete_registered_image(session, tag=tag)
|
||||
|
||||
@router.get("/api/docker-images")
|
||||
def list_docker_images(repository: str = "nanobot-base"):
|
||||
rows = docker_manager.list_images_by_repo(repository)
|
||||
return rows
|
||||
return list_docker_images_by_repository(repository)
|
||||
|
||||
@router.post("/api/images/register")
|
||||
def register_image(payload: dict, session: Session = Depends(get_session)):
|
||||
tag = (payload.get("tag") or "").strip()
|
||||
source_dir = (payload.get("source_dir") or "manual").strip() or "manual"
|
||||
if not tag:
|
||||
raise HTTPException(status_code=400, detail="tag is required")
|
||||
|
||||
if not 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 = docker_manager.client.images.get(tag) if 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)
|
||||
_invalidate_images_cache()
|
||||
return _serialize_image(row)
|
||||
return register_image_record(session, payload)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlmodel import Session
|
||||
|
||||
from core.database import get_session
|
||||
from core.settings import PANEL_ACCESS_PASSWORD
|
||||
from schemas.system import PanelLoginRequest
|
||||
from services.platform_auth_service import (
|
||||
clear_panel_token_cookie,
|
||||
create_panel_token,
|
||||
resolve_panel_request_auth,
|
||||
revoke_panel_token,
|
||||
set_panel_token_cookie,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/panel/auth/status")
|
||||
def get_panel_auth_status(request: Request, session: Session = Depends(get_session)):
|
||||
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
|
||||
principal = resolve_panel_request_auth(session, request)
|
||||
return {
|
||||
"enabled": bool(configured),
|
||||
"authenticated": bool(principal.authenticated),
|
||||
"auth_source": principal.auth_source if principal.authenticated else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/panel/auth/login")
|
||||
def panel_login(
|
||||
payload: PanelLoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
|
||||
if not configured:
|
||||
clear_panel_token_cookie(response)
|
||||
return {"success": True, "enabled": False}
|
||||
supplied = str(payload.password or "").strip()
|
||||
if supplied != configured:
|
||||
raise HTTPException(status_code=401, detail="Invalid panel access password")
|
||||
try:
|
||||
raw_token = create_panel_token(session, request)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
set_panel_token_cookie(response, request, raw_token, session)
|
||||
return {"success": True, "enabled": True, "authenticated": True}
|
||||
|
||||
|
||||
@router.post("/api/panel/auth/logout")
|
||||
def panel_logout(request: Request, response: Response, session: Session = Depends(get_session)):
|
||||
revoke_panel_token(session, request)
|
||||
clear_panel_token_cookie(response)
|
||||
return {"success": True}
|
||||
|
|
@ -3,6 +3,7 @@ from typing import Optional
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from bootstrap.app_runtime import reload_platform_runtime
|
||||
from core.cache import cache
|
||||
from core.database import get_session
|
||||
from schemas.platform import PlatformSettingsPayload, SystemSettingPayload
|
||||
|
|
@ -22,13 +23,6 @@ from services.platform_service import (
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def _apply_platform_runtime_changes(request: Request) -> None:
|
||||
cache.delete_prefix("")
|
||||
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)):
|
||||
docker_manager = getattr(request.app.state, "docker_manager", None)
|
||||
|
|
@ -43,7 +37,7 @@ def get_platform_settings_api(session: Session = Depends(get_session)):
|
|||
@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)
|
||||
reload_platform_runtime(request.app)
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -54,8 +48,8 @@ def clear_platform_cache():
|
|||
|
||||
|
||||
@router.post("/api/platform/reload")
|
||||
def reload_platform_runtime(request: Request):
|
||||
_apply_platform_runtime_changes(request)
|
||||
def reload_platform_runtime_api(request: Request):
|
||||
reload_platform_runtime(request.app)
|
||||
return {"status": "reloaded"}
|
||||
|
||||
|
||||
|
|
@ -107,7 +101,7 @@ def get_system_settings(search: str = "", session: Session = Depends(get_session
|
|||
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)
|
||||
reload_platform_runtime(request.app)
|
||||
return result
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
|
@ -117,7 +111,7 @@ def create_system_setting(payload: SystemSettingPayload, request: Request, sessi
|
|||
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)
|
||||
reload_platform_runtime(request.app)
|
||||
return result
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
|
@ -127,7 +121,7 @@ def update_system_setting(key: str, payload: SystemSettingPayload, request: Requ
|
|||
def remove_system_setting(key: str, request: Request, session: Session = Depends(get_session)):
|
||||
try:
|
||||
delete_system_setting(session, key)
|
||||
_apply_platform_runtime_changes(request)
|
||||
reload_platform_runtime(request.app)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"status": "deleted", "key": key}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,7 @@
|
|||
from typing import Any, Dict
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from core.database import engine, get_session
|
||||
from core.settings import DATABASE_ENGINE, PANEL_ACCESS_PASSWORD, REDIS_ENABLED, REDIS_PREFIX, REDIS_URL
|
||||
from core.utils import _get_default_system_timezone
|
||||
from models.bot import BotInstance
|
||||
from schemas.system import PanelLoginRequest, SystemTemplatesUpdateRequest
|
||||
from core.cache import cache
|
||||
from services.platform_auth_service import (
|
||||
clear_panel_token_cookie,
|
||||
create_panel_token,
|
||||
resolve_panel_request_auth,
|
||||
revoke_panel_token,
|
||||
set_panel_token_cookie,
|
||||
)
|
||||
from schemas.system import SystemTemplatesUpdateRequest
|
||||
from services.platform_service import get_platform_settings_snapshot, get_speech_runtime_settings
|
||||
from services.template_service import (
|
||||
get_agent_md_templates,
|
||||
|
|
@ -26,40 +12,6 @@ from services.template_service import (
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/panel/auth/status")
|
||||
def get_panel_auth_status(request: Request, session: Session = Depends(get_session)):
|
||||
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
|
||||
principal = resolve_panel_request_auth(session, request)
|
||||
return {
|
||||
"enabled": bool(configured),
|
||||
"authenticated": bool(principal.authenticated),
|
||||
"auth_source": principal.auth_source if principal.authenticated else None,
|
||||
}
|
||||
|
||||
@router.post("/api/panel/auth/login")
|
||||
def panel_login(payload: PanelLoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
|
||||
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
|
||||
if not configured:
|
||||
clear_panel_token_cookie(response)
|
||||
return {"success": True, "enabled": False}
|
||||
supplied = str(payload.password or "").strip()
|
||||
if supplied != configured:
|
||||
raise HTTPException(status_code=401, detail="Invalid panel access password")
|
||||
try:
|
||||
raw_token = create_panel_token(session, request)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
set_panel_token_cookie(response, request, raw_token, session)
|
||||
return {"success": True, "enabled": True, "authenticated": True}
|
||||
|
||||
|
||||
@router.post("/api/panel/auth/logout")
|
||||
def panel_logout(request: Request, response: Response, session: Session = Depends(get_session)):
|
||||
revoke_panel_token(session, request)
|
||||
clear_panel_token_cookie(response)
|
||||
return {"success": True}
|
||||
|
||||
@router.get("/api/system/defaults")
|
||||
def get_system_defaults():
|
||||
md_templates = get_agent_md_templates()
|
||||
|
|
@ -115,31 +67,3 @@ def update_system_templates(payload: SystemTemplatesUpdateRequest):
|
|||
"agent_md_templates": get_agent_md_templates(),
|
||||
"topic_presets": get_topic_presets(),
|
||||
}
|
||||
|
||||
@router.get("/api/health")
|
||||
def get_health():
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
session.exec(select(BotInstance).limit(1)).first()
|
||||
return {"status": "ok", "database": DATABASE_ENGINE}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"database check failed: {e}")
|
||||
|
||||
@router.get("/api/health/cache")
|
||||
def get_cache_health():
|
||||
redis_url = str(REDIS_URL or "").strip()
|
||||
configured = bool(REDIS_ENABLED and redis_url)
|
||||
client_enabled = bool(getattr(cache, "enabled", False))
|
||||
reachable = bool(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": REDIS_PREFIX,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,24 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session
|
||||
|
||||
from core.database import get_session
|
||||
from models.bot import BotInstance
|
||||
from models.topic import TopicItem, TopicTopic
|
||||
from services.topic_service import (
|
||||
_TOPIC_KEY_RE,
|
||||
_list_topics,
|
||||
_normalize_topic_key,
|
||||
_topic_item_to_dict,
|
||||
_topic_to_dict,
|
||||
create_topic,
|
||||
delete_topic,
|
||||
delete_topic_item,
|
||||
get_topic_item_stats,
|
||||
list_topic_items,
|
||||
list_topics,
|
||||
mark_topic_item_read,
|
||||
update_topic,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _count_topic_items(
|
||||
session: Session,
|
||||
bot_id: str,
|
||||
topic_key: Optional[str] = None,
|
||||
unread_only: bool = False,
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(TopicItem).where(TopicItem.bot_id == bot_id)
|
||||
normalized_topic_key = _normalize_topic_key(topic_key or "")
|
||||
if normalized_topic_key:
|
||||
stmt = stmt.where(TopicItem.topic_key == normalized_topic_key)
|
||||
if unread_only:
|
||||
stmt = stmt.where(TopicItem.is_read == False) # noqa: E712
|
||||
value = session.exec(stmt).one()
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
class TopicCreateRequest(BaseModel):
|
||||
topic_key: str
|
||||
name: Optional[str] = None
|
||||
|
|
@ -56,112 +38,31 @@ class TopicUpdateRequest(BaseModel):
|
|||
|
||||
@router.get("/api/bots/{bot_id}/topics")
|
||||
def list_bot_topics(bot_id: str, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
return _list_topics(session, bot_id)
|
||||
return list_topics(session, bot_id)
|
||||
|
||||
|
||||
@router.post("/api/bots/{bot_id}/topics")
|
||||
def create_bot_topic(bot_id: str, payload: TopicCreateRequest, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
topic_key = _normalize_topic_key(payload.topic_key)
|
||||
if not topic_key:
|
||||
raise HTTPException(status_code=400, detail="topic_key is required")
|
||||
if not _TOPIC_KEY_RE.fullmatch(topic_key):
|
||||
raise HTTPException(status_code=400, detail="invalid topic_key")
|
||||
exists = session.exec(
|
||||
select(TopicTopic)
|
||||
.where(TopicTopic.bot_id == bot_id)
|
||||
.where(TopicTopic.topic_key == topic_key)
|
||||
.limit(1)
|
||||
).first()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail=f"Topic already exists: {topic_key}")
|
||||
|
||||
now = datetime.utcnow()
|
||||
row = TopicTopic(
|
||||
return create_topic(
|
||||
session,
|
||||
bot_id=bot_id,
|
||||
topic_key=topic_key,
|
||||
name=str(payload.name or topic_key).strip() or topic_key,
|
||||
description=str(payload.description or "").strip(),
|
||||
is_active=bool(payload.is_active),
|
||||
is_default_fallback=False,
|
||||
routing_json=json.dumps(payload.routing or {}, ensure_ascii=False),
|
||||
view_schema_json=json.dumps(payload.view_schema or {}, ensure_ascii=False),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
topic_key=payload.topic_key,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
is_active=payload.is_active,
|
||||
routing=payload.routing,
|
||||
view_schema=payload.view_schema,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return _topic_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/api/bots/{bot_id}/topics/{topic_key}")
|
||||
def update_bot_topic(bot_id: str, topic_key: str, payload: TopicUpdateRequest, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
normalized_key = _normalize_topic_key(topic_key)
|
||||
if not normalized_key:
|
||||
raise HTTPException(status_code=400, detail="topic_key is required")
|
||||
row = session.exec(
|
||||
select(TopicTopic)
|
||||
.where(TopicTopic.bot_id == bot_id)
|
||||
.where(TopicTopic.topic_key == normalized_key)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
if "name" in update_data:
|
||||
row.name = str(update_data.get("name") or "").strip() or row.topic_key
|
||||
if "description" in update_data:
|
||||
row.description = str(update_data.get("description") or "").strip()
|
||||
if "is_active" in update_data:
|
||||
row.is_active = bool(update_data.get("is_active"))
|
||||
if "routing" in update_data:
|
||||
row.routing_json = json.dumps(update_data.get("routing") or {}, ensure_ascii=False)
|
||||
if "view_schema" in update_data:
|
||||
row.view_schema_json = json.dumps(update_data.get("view_schema") or {}, ensure_ascii=False)
|
||||
row.is_default_fallback = False
|
||||
row.updated_at = datetime.utcnow()
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return _topic_to_dict(row)
|
||||
return update_topic(session, bot_id=bot_id, topic_key=topic_key, updates=payload.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@router.delete("/api/bots/{bot_id}/topics/{topic_key}")
|
||||
def delete_bot_topic(bot_id: str, topic_key: str, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
normalized_key = _normalize_topic_key(topic_key)
|
||||
if not normalized_key:
|
||||
raise HTTPException(status_code=400, detail="topic_key is required")
|
||||
row = session.exec(
|
||||
select(TopicTopic)
|
||||
.where(TopicTopic.bot_id == bot_id)
|
||||
.where(TopicTopic.topic_key == normalized_key)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
items = session.exec(
|
||||
select(TopicItem)
|
||||
.where(TopicItem.bot_id == bot_id)
|
||||
.where(TopicItem.topic_key == normalized_key)
|
||||
).all()
|
||||
for item in items:
|
||||
session.delete(item)
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return {"status": "deleted", "bot_id": bot_id, "topic_key": normalized_key}
|
||||
return delete_topic(session, bot_id=bot_id, topic_key=topic_key)
|
||||
|
||||
|
||||
@router.get("/api/bots/{bot_id}/topic-items")
|
||||
|
|
@ -172,97 +73,19 @@ def list_bot_topic_items(
|
|||
limit: int = 50,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
normalized_limit = max(1, min(int(limit or 50), 100))
|
||||
stmt = select(TopicItem).where(TopicItem.bot_id == bot_id)
|
||||
normalized_topic_key = _normalize_topic_key(topic_key or "")
|
||||
if normalized_topic_key:
|
||||
stmt = stmt.where(TopicItem.topic_key == normalized_topic_key)
|
||||
if cursor is not None:
|
||||
normalized_cursor = int(cursor)
|
||||
if normalized_cursor > 0:
|
||||
stmt = stmt.where(TopicItem.id < normalized_cursor)
|
||||
rows = session.exec(
|
||||
stmt.order_by(TopicItem.id.desc()).limit(normalized_limit + 1)
|
||||
).all()
|
||||
next_cursor: Optional[int] = None
|
||||
if len(rows) > normalized_limit:
|
||||
next_cursor = rows[-1].id
|
||||
rows = rows[:normalized_limit]
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"topic_key": normalized_topic_key or None,
|
||||
"items": [_topic_item_to_dict(row) for row in rows],
|
||||
"next_cursor": next_cursor,
|
||||
"unread_count": _count_topic_items(session, bot_id, normalized_topic_key, unread_only=True),
|
||||
"total_unread_count": _count_topic_items(session, bot_id, unread_only=True),
|
||||
}
|
||||
return list_topic_items(session, bot_id=bot_id, topic_key=topic_key, cursor=cursor, limit=limit)
|
||||
|
||||
|
||||
@router.get("/api/bots/{bot_id}/topic-items/stats")
|
||||
def get_bot_topic_item_stats(bot_id: str, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
latest_item = session.exec(
|
||||
select(TopicItem)
|
||||
.where(TopicItem.bot_id == bot_id)
|
||||
.order_by(TopicItem.id.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"total_count": _count_topic_items(session, bot_id),
|
||||
"unread_count": _count_topic_items(session, bot_id, unread_only=True),
|
||||
"latest_item_id": int(latest_item.id or 0) if latest_item and latest_item.id else None,
|
||||
}
|
||||
return get_topic_item_stats(session, bot_id=bot_id)
|
||||
|
||||
|
||||
@router.post("/api/bots/{bot_id}/topic-items/{item_id}/read")
|
||||
def mark_bot_topic_item_read(bot_id: str, item_id: int, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
row = session.exec(
|
||||
select(TopicItem)
|
||||
.where(TopicItem.bot_id == bot_id)
|
||||
.where(TopicItem.id == item_id)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Topic item not found")
|
||||
if not bool(row.is_read):
|
||||
row.is_read = True
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return {
|
||||
"status": "updated",
|
||||
"bot_id": bot_id,
|
||||
"item": _topic_item_to_dict(row),
|
||||
}
|
||||
return mark_topic_item_read(session, bot_id=bot_id, item_id=item_id)
|
||||
|
||||
|
||||
@router.delete("/api/bots/{bot_id}/topic-items/{item_id}")
|
||||
def delete_bot_topic_item(bot_id: str, item_id: int, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
row = session.exec(
|
||||
select(TopicItem)
|
||||
.where(TopicItem.bot_id == bot_id)
|
||||
.where(TopicItem.id == item_id)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Topic item not found")
|
||||
payload = _topic_item_to_dict(row)
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return {
|
||||
"status": "deleted",
|
||||
"bot_id": bot_id,
|
||||
"item": payload,
|
||||
}
|
||||
return delete_topic_item(session, bot_id=bot_id, item_id=item_id)
|
||||
|
|
|
|||
|
|
@ -10,14 +10,16 @@ from api.bot_runtime_router import router as bot_runtime_router
|
|||
from api.bot_speech_router import router as bot_speech_router
|
||||
from api.chat_history_router import router as chat_history_router
|
||||
from api.chat_router import router as chat_router
|
||||
from api.health_router import router as health_router
|
||||
from api.image_router import router as image_router
|
||||
from api.panel_auth_router import router as panel_auth_router
|
||||
from api.platform_router import router as platform_router
|
||||
from api.skill_router import router as skill_router
|
||||
from api.system_router import router as system_router
|
||||
from api.topic_router import router as topic_router
|
||||
from api.workspace_router import router as workspace_router
|
||||
from bootstrap.app_runtime import register_app_runtime
|
||||
from core.auth_middleware import PasswordProtectionMiddleware
|
||||
from core.auth_middleware import AuthAccessMiddleware
|
||||
from core.docker_instance import docker_manager
|
||||
from core.settings import BOTS_WORKSPACE_ROOT, CORS_ALLOWED_ORIGINS, DATA_ROOT
|
||||
from core.speech_service import WhisperSpeechService
|
||||
|
|
@ -30,7 +32,7 @@ def create_app() -> FastAPI:
|
|||
app.state.docker_manager = docker_manager
|
||||
app.state.speech_service = speech_service
|
||||
|
||||
app.add_middleware(PasswordProtectionMiddleware)
|
||||
app.add_middleware(AuthAccessMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=list(CORS_ALLOWED_ORIGINS),
|
||||
|
|
@ -39,6 +41,8 @@ def create_app() -> FastAPI:
|
|||
allow_credentials=True,
|
||||
)
|
||||
|
||||
app.include_router(panel_auth_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(platform_router)
|
||||
app.include_router(topic_router)
|
||||
app.include_router(system_router)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import asyncio
|
|||
from fastapi import FastAPI
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from core.cache import cache
|
||||
from core.database import engine, init_database
|
||||
from core.docker_instance import docker_manager
|
||||
from core.settings import DATABASE_URL_DISPLAY, REDIS_ENABLED
|
||||
|
|
@ -12,6 +13,13 @@ from services.platform_service import prune_expired_activity_events
|
|||
from services.runtime_service import docker_callback, set_main_loop
|
||||
|
||||
|
||||
def reload_platform_runtime(app: FastAPI) -> None:
|
||||
cache.delete_prefix("")
|
||||
speech_service = getattr(app.state, "speech_service", None)
|
||||
if speech_service is not None and hasattr(speech_service, "reset_runtime"):
|
||||
speech_service.reset_runtime()
|
||||
|
||||
|
||||
def register_app_runtime(app: FastAPI) -> None:
|
||||
@app.on_event("startup")
|
||||
async def _on_startup() -> None:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -12,46 +11,84 @@ class RouteAccessMode(str, Enum):
|
|||
PUBLIC_BOT_OR_PANEL = "public_bot_or_panel"
|
||||
|
||||
|
||||
_BOT_ID_API_RE = re.compile(r"^/api/bots/([^/]+)(?:/.*)?$")
|
||||
_BOT_ID_PUBLIC_RE = re.compile(r"^/public/bots/([^/]+)(?:/.*)?$")
|
||||
_BOT_PANEL_ONLY_ROUTE_METHODS = [
|
||||
(re.compile(r"^/api/bots/[^/]+$"), {"DELETE"}),
|
||||
(re.compile(r"^/api/bots/[^/]+/(?:enable|disable|deactivate)$"), {"POST"}),
|
||||
]
|
||||
|
||||
_PUBLIC_PATHS = {
|
||||
"/api/panel/auth/status",
|
||||
"/api/panel/auth/login",
|
||||
"/api/panel/auth/logout",
|
||||
_PUBLIC_EXACT_PATHS = {
|
||||
"/api/health",
|
||||
"/api/health/cache",
|
||||
"/api/system/defaults",
|
||||
}
|
||||
|
||||
_BOT_PUBLIC_AUTH_RE = re.compile(r"^/api/bots/[^/]+/auth/(?:login|logout|status)$")
|
||||
_PANEL_AUTH_SEGMENTS = ("api", "panel", "auth")
|
||||
_BOT_PUBLIC_SEGMENTS = ("public", "bots")
|
||||
_BOT_API_SEGMENTS = ("api", "bots")
|
||||
_BOT_AUTH_SEGMENT = "auth"
|
||||
_BOT_PANEL_ONLY_ACTIONS = {"enable", "disable", "deactivate"}
|
||||
_BOT_PUBLIC_AUTH_ACTIONS = {"login", "logout", "status"}
|
||||
|
||||
|
||||
def _path_segments(path: str) -> list[str]:
|
||||
raw = str(path or "").strip().strip("/")
|
||||
if not raw:
|
||||
return []
|
||||
return [segment for segment in raw.split("/") if segment]
|
||||
|
||||
|
||||
def extract_bot_id(path: str) -> Optional[str]:
|
||||
raw = str(path or "").strip()
|
||||
match = _BOT_ID_API_RE.match(raw) or _BOT_ID_PUBLIC_RE.match(raw)
|
||||
if not match or not match.group(1):
|
||||
segments = _path_segments(path)
|
||||
if len(segments) < 3:
|
||||
return None
|
||||
return match.group(1).strip() or None
|
||||
if tuple(segments[:2]) not in {_BOT_API_SEGMENTS, _BOT_PUBLIC_SEGMENTS}:
|
||||
return None
|
||||
bot_id = str(segments[2] or "").strip()
|
||||
return bot_id or None
|
||||
|
||||
|
||||
def _is_panel_auth_route(segments: list[str]) -> bool:
|
||||
return tuple(segments[:3]) == _PANEL_AUTH_SEGMENTS
|
||||
|
||||
|
||||
def _is_public_bot_route(segments: list[str]) -> bool:
|
||||
return tuple(segments[:2]) == _BOT_PUBLIC_SEGMENTS and len(segments) >= 3
|
||||
|
||||
|
||||
def _is_bot_auth_route(segments: list[str]) -> bool:
|
||||
return (
|
||||
tuple(segments[:2]) == _BOT_API_SEGMENTS
|
||||
and len(segments) >= 5
|
||||
and segments[3] == _BOT_AUTH_SEGMENT
|
||||
and segments[4] in _BOT_PUBLIC_AUTH_ACTIONS
|
||||
)
|
||||
|
||||
|
||||
def _is_panel_only_bot_action(segments: list[str], method: str) -> bool:
|
||||
if tuple(segments[:2]) != _BOT_API_SEGMENTS or len(segments) < 3:
|
||||
return False
|
||||
if len(segments) == 3 and method == "DELETE":
|
||||
return True
|
||||
return len(segments) >= 4 and method == "POST" and segments[3] in _BOT_PANEL_ONLY_ACTIONS
|
||||
|
||||
|
||||
def _is_bot_scoped_api_route(segments: list[str]) -> bool:
|
||||
return tuple(segments[:2]) == _BOT_API_SEGMENTS and len(segments) >= 3
|
||||
|
||||
|
||||
def resolve_route_access_mode(path: str, method: str) -> RouteAccessMode:
|
||||
raw_path = str(path or "").strip()
|
||||
verb = str(method or "GET").strip().upper()
|
||||
segments = _path_segments(raw_path)
|
||||
|
||||
if raw_path in _PUBLIC_PATHS or _BOT_PUBLIC_AUTH_RE.fullmatch(raw_path):
|
||||
if raw_path in _PUBLIC_EXACT_PATHS:
|
||||
return RouteAccessMode.PUBLIC
|
||||
|
||||
if raw_path.startswith("/public/bots/"):
|
||||
if _is_panel_auth_route(segments) or _is_bot_auth_route(segments):
|
||||
return RouteAccessMode.PUBLIC
|
||||
|
||||
if _is_public_bot_route(segments):
|
||||
return RouteAccessMode.PUBLIC_BOT_OR_PANEL
|
||||
|
||||
if _BOT_ID_API_RE.fullmatch(raw_path):
|
||||
if any(pattern.fullmatch(raw_path) and verb in methods for pattern, methods in _BOT_PANEL_ONLY_ROUTE_METHODS):
|
||||
return RouteAccessMode.PANEL_ONLY
|
||||
if _is_panel_only_bot_action(segments, verb):
|
||||
return RouteAccessMode.PANEL_ONLY
|
||||
|
||||
if _is_bot_scoped_api_route(segments):
|
||||
return RouteAccessMode.BOT_OR_PANEL
|
||||
|
||||
if raw_path.startswith("/api/"):
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ def _unauthorized(detail: str) -> JSONResponse:
|
|||
return JSONResponse(status_code=401, content={"detail": detail})
|
||||
|
||||
|
||||
class PasswordProtectionMiddleware(BaseHTTPMiddleware):
|
||||
class AuthAccessMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
|
|
|||
|
|
@ -1,366 +0,0 @@
|
|||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from core.config_manager import BotConfigManager
|
||||
from core.settings import BOTS_WORKSPACE_ROOT
|
||||
from models.bot import BotInstance
|
||||
from schemas.bot import ChannelConfigRequest
|
||||
from services.bot_storage_service import (
|
||||
_normalize_resource_limits,
|
||||
_read_bot_config,
|
||||
_write_bot_resources,
|
||||
)
|
||||
from services.template_service import get_agent_md_templates
|
||||
|
||||
config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT)
|
||||
|
||||
|
||||
def _normalize_channel_extra(raw: Any) -> Dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return raw
|
||||
|
||||
|
||||
def _normalize_allow_from(raw: Any) -> List[str]:
|
||||
rows: List[str] = []
|
||||
if isinstance(raw, list):
|
||||
for item in raw:
|
||||
text = str(item or "").strip()
|
||||
if text and text not in rows:
|
||||
rows.append(text)
|
||||
return rows or ["*"]
|
||||
|
||||
|
||||
def _read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]:
|
||||
if not isinstance(channels_cfg, dict):
|
||||
return False, False
|
||||
send_progress = channels_cfg.get("sendProgress")
|
||||
send_tool_hints = channels_cfg.get("sendToolHints")
|
||||
dashboard_cfg = channels_cfg.get("dashboard")
|
||||
if isinstance(dashboard_cfg, dict):
|
||||
if send_progress is None and "sendProgress" in dashboard_cfg:
|
||||
send_progress = dashboard_cfg.get("sendProgress")
|
||||
if send_tool_hints is None and "sendToolHints" in dashboard_cfg:
|
||||
send_tool_hints = dashboard_cfg.get("sendToolHints")
|
||||
return bool(send_progress), bool(send_tool_hints)
|
||||
|
||||
|
||||
def _channel_cfg_to_api_dict(bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ctype = str(ctype or "").strip().lower()
|
||||
enabled = bool(cfg.get("enabled", True))
|
||||
port = max(1, min(int(cfg.get("port", 8080) or 8080), 65535))
|
||||
extra: Dict[str, Any] = {}
|
||||
external_app_id = ""
|
||||
app_secret = ""
|
||||
|
||||
if ctype == "feishu":
|
||||
external_app_id = str(cfg.get("appId") or "")
|
||||
app_secret = str(cfg.get("appSecret") or "")
|
||||
extra = {
|
||||
"encryptKey": cfg.get("encryptKey", ""),
|
||||
"verificationToken": cfg.get("verificationToken", ""),
|
||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||
}
|
||||
elif ctype == "dingtalk":
|
||||
external_app_id = str(cfg.get("clientId") or "")
|
||||
app_secret = str(cfg.get("clientSecret") or "")
|
||||
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
|
||||
elif ctype == "telegram":
|
||||
app_secret = str(cfg.get("token") or "")
|
||||
extra = {
|
||||
"proxy": cfg.get("proxy", ""),
|
||||
"replyToMessage": bool(cfg.get("replyToMessage", False)),
|
||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||
}
|
||||
elif ctype == "slack":
|
||||
external_app_id = str(cfg.get("botToken") or "")
|
||||
app_secret = str(cfg.get("appToken") or "")
|
||||
extra = {
|
||||
"mode": cfg.get("mode", "socket"),
|
||||
"replyInThread": bool(cfg.get("replyInThread", True)),
|
||||
"groupPolicy": cfg.get("groupPolicy", "mention"),
|
||||
"groupAllowFrom": cfg.get("groupAllowFrom", []),
|
||||
"reactEmoji": cfg.get("reactEmoji", "eyes"),
|
||||
}
|
||||
elif ctype == "qq":
|
||||
external_app_id = str(cfg.get("appId") or "")
|
||||
app_secret = str(cfg.get("secret") or "")
|
||||
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
|
||||
elif ctype == "weixin":
|
||||
app_secret = ""
|
||||
extra = {
|
||||
"hasSavedState": (Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json").is_file(),
|
||||
}
|
||||
elif ctype == "email":
|
||||
extra = {
|
||||
"consentGranted": bool(cfg.get("consentGranted", False)),
|
||||
"imapHost": str(cfg.get("imapHost") or ""),
|
||||
"imapPort": int(cfg.get("imapPort") or 993),
|
||||
"imapUsername": str(cfg.get("imapUsername") or ""),
|
||||
"imapPassword": str(cfg.get("imapPassword") or ""),
|
||||
"imapMailbox": str(cfg.get("imapMailbox") or "INBOX"),
|
||||
"imapUseSsl": bool(cfg.get("imapUseSsl", True)),
|
||||
"smtpHost": str(cfg.get("smtpHost") or ""),
|
||||
"smtpPort": int(cfg.get("smtpPort") or 587),
|
||||
"smtpUsername": str(cfg.get("smtpUsername") or ""),
|
||||
"smtpPassword": str(cfg.get("smtpPassword") or ""),
|
||||
"smtpUseTls": bool(cfg.get("smtpUseTls", True)),
|
||||
"smtpUseSsl": bool(cfg.get("smtpUseSsl", False)),
|
||||
"fromAddress": str(cfg.get("fromAddress") or ""),
|
||||
"autoReplyEnabled": bool(cfg.get("autoReplyEnabled", True)),
|
||||
"pollIntervalSeconds": int(cfg.get("pollIntervalSeconds") or 30),
|
||||
"markSeen": bool(cfg.get("markSeen", True)),
|
||||
"maxBodyChars": int(cfg.get("maxBodyChars") or 12000),
|
||||
"subjectPrefix": str(cfg.get("subjectPrefix") or "Re: "),
|
||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||
}
|
||||
else:
|
||||
external_app_id = str(cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or "")
|
||||
app_secret = str(cfg.get("appSecret") or cfg.get("clientSecret") or cfg.get("secret") or cfg.get("token") or cfg.get("appToken") or "")
|
||||
extra = {
|
||||
key: value
|
||||
for key, value in cfg.items()
|
||||
if key not in {"enabled", "port", "appId", "clientId", "botToken", "externalAppId", "appSecret", "clientSecret", "secret", "token", "appToken"}
|
||||
}
|
||||
|
||||
return {
|
||||
"id": ctype,
|
||||
"bot_id": bot_id,
|
||||
"channel_type": ctype,
|
||||
"external_app_id": external_app_id,
|
||||
"app_secret": app_secret,
|
||||
"internal_port": port,
|
||||
"is_active": enabled,
|
||||
"extra_config": extra,
|
||||
"locked": ctype == "dashboard",
|
||||
}
|
||||
|
||||
|
||||
def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ctype = str(row.get("channel_type") or "").strip().lower()
|
||||
enabled = bool(row.get("is_active", True))
|
||||
extra = _normalize_channel_extra(row.get("extra_config"))
|
||||
external_app_id = str(row.get("external_app_id") or "")
|
||||
app_secret = str(row.get("app_secret") or "")
|
||||
port = max(1, min(int(row.get("internal_port") or 8080), 65535))
|
||||
|
||||
if ctype == "feishu":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"appId": external_app_id,
|
||||
"appSecret": app_secret,
|
||||
"encryptKey": extra.get("encryptKey", ""),
|
||||
"verificationToken": extra.get("verificationToken", ""),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
if ctype == "dingtalk":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"clientId": external_app_id,
|
||||
"clientSecret": app_secret,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
if ctype == "telegram":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"token": app_secret,
|
||||
"proxy": extra.get("proxy", ""),
|
||||
"replyToMessage": bool(extra.get("replyToMessage", False)),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
if ctype == "slack":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"mode": extra.get("mode", "socket"),
|
||||
"botToken": external_app_id,
|
||||
"appToken": app_secret,
|
||||
"replyInThread": bool(extra.get("replyInThread", True)),
|
||||
"groupPolicy": extra.get("groupPolicy", "mention"),
|
||||
"groupAllowFrom": extra.get("groupAllowFrom", []),
|
||||
"reactEmoji": extra.get("reactEmoji", "eyes"),
|
||||
}
|
||||
if ctype == "qq":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"appId": external_app_id,
|
||||
"secret": app_secret,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
if ctype == "weixin":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"token": app_secret,
|
||||
}
|
||||
if ctype == "email":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"consentGranted": bool(extra.get("consentGranted", False)),
|
||||
"imapHost": str(extra.get("imapHost") or ""),
|
||||
"imapPort": max(1, min(int(extra.get("imapPort") or 993), 65535)),
|
||||
"imapUsername": str(extra.get("imapUsername") or ""),
|
||||
"imapPassword": str(extra.get("imapPassword") or ""),
|
||||
"imapMailbox": str(extra.get("imapMailbox") or "INBOX"),
|
||||
"imapUseSsl": bool(extra.get("imapUseSsl", True)),
|
||||
"smtpHost": str(extra.get("smtpHost") or ""),
|
||||
"smtpPort": max(1, min(int(extra.get("smtpPort") or 587), 65535)),
|
||||
"smtpUsername": str(extra.get("smtpUsername") or ""),
|
||||
"smtpPassword": str(extra.get("smtpPassword") or ""),
|
||||
"smtpUseTls": bool(extra.get("smtpUseTls", True)),
|
||||
"smtpUseSsl": bool(extra.get("smtpUseSsl", False)),
|
||||
"fromAddress": str(extra.get("fromAddress") or ""),
|
||||
"autoReplyEnabled": bool(extra.get("autoReplyEnabled", True)),
|
||||
"pollIntervalSeconds": max(5, int(extra.get("pollIntervalSeconds") or 30)),
|
||||
"markSeen": bool(extra.get("markSeen", True)),
|
||||
"maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)),
|
||||
"subjectPrefix": str(extra.get("subjectPrefix") or "Re: "),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
merged = dict(extra)
|
||||
merged.update(
|
||||
{
|
||||
"enabled": enabled,
|
||||
"appId": external_app_id,
|
||||
"appSecret": app_secret,
|
||||
"port": port,
|
||||
}
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def _get_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]:
|
||||
config_data = _read_bot_config(bot.id)
|
||||
channels_cfg = config_data.get("channels")
|
||||
if not isinstance(channels_cfg, dict):
|
||||
channels_cfg = {}
|
||||
send_progress, send_tool_hints = _read_global_delivery_flags(channels_cfg)
|
||||
rows: List[Dict[str, Any]] = [
|
||||
{
|
||||
"id": "dashboard",
|
||||
"bot_id": bot.id,
|
||||
"channel_type": "dashboard",
|
||||
"external_app_id": f"dashboard-{bot.id}",
|
||||
"app_secret": "",
|
||||
"internal_port": 9000,
|
||||
"is_active": True,
|
||||
"extra_config": {
|
||||
"sendProgress": send_progress,
|
||||
"sendToolHints": send_tool_hints,
|
||||
},
|
||||
"locked": True,
|
||||
}
|
||||
]
|
||||
for ctype, cfg in channels_cfg.items():
|
||||
if ctype in {"sendProgress", "sendToolHints", "dashboard"} or not isinstance(cfg, dict):
|
||||
continue
|
||||
rows.append(_channel_cfg_to_api_dict(bot.id, ctype, cfg))
|
||||
return rows
|
||||
|
||||
|
||||
def _normalize_initial_channels(bot_id: str, channels: Optional[List[ChannelConfigRequest]]) -> List[Dict[str, Any]]:
|
||||
rows: List[Dict[str, Any]] = []
|
||||
seen_types: set[str] = set()
|
||||
for channel in channels or []:
|
||||
ctype = (channel.channel_type or "").strip().lower()
|
||||
if not ctype or ctype == "dashboard" or ctype in seen_types:
|
||||
continue
|
||||
seen_types.add(ctype)
|
||||
rows.append(
|
||||
{
|
||||
"id": ctype,
|
||||
"bot_id": bot_id,
|
||||
"channel_type": ctype,
|
||||
"external_app_id": (channel.external_app_id or "").strip() or f"{ctype}-{bot_id}",
|
||||
"app_secret": (channel.app_secret or "").strip(),
|
||||
"internal_port": max(1, min(int(channel.internal_port or 8080), 65535)),
|
||||
"is_active": bool(channel.is_active),
|
||||
"extra_config": _normalize_channel_extra(channel.extra_config),
|
||||
"locked": False,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _sync_workspace_channels_impl(
|
||||
session: Session,
|
||||
bot_id: str,
|
||||
snapshot: Dict[str, Any],
|
||||
*,
|
||||
channels_override: Optional[List[Dict[str, Any]]] = None,
|
||||
global_delivery_override: Optional[Dict[str, Any]] = None,
|
||||
runtime_overrides: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
return
|
||||
template_defaults = get_agent_md_templates()
|
||||
bot_data: Dict[str, Any] = {
|
||||
"name": bot.name,
|
||||
"system_prompt": snapshot.get("system_prompt") or template_defaults.get("soul_md", ""),
|
||||
"soul_md": snapshot.get("soul_md") or template_defaults.get("soul_md", ""),
|
||||
"agents_md": snapshot.get("agents_md") or template_defaults.get("agents_md", ""),
|
||||
"user_md": snapshot.get("user_md") or template_defaults.get("user_md", ""),
|
||||
"tools_md": snapshot.get("tools_md") or template_defaults.get("tools_md", ""),
|
||||
"identity_md": snapshot.get("identity_md") or template_defaults.get("identity_md", ""),
|
||||
"llm_provider": snapshot.get("llm_provider") or "",
|
||||
"llm_model": snapshot.get("llm_model") or "",
|
||||
"api_key": snapshot.get("api_key") or "",
|
||||
"api_base": snapshot.get("api_base") or "",
|
||||
"temperature": snapshot.get("temperature"),
|
||||
"top_p": snapshot.get("top_p"),
|
||||
"max_tokens": snapshot.get("max_tokens"),
|
||||
"cpu_cores": snapshot.get("cpu_cores"),
|
||||
"memory_mb": snapshot.get("memory_mb"),
|
||||
"storage_gb": snapshot.get("storage_gb"),
|
||||
"send_progress": bool(snapshot.get("send_progress")),
|
||||
"send_tool_hints": bool(snapshot.get("send_tool_hints")),
|
||||
}
|
||||
if isinstance(runtime_overrides, dict):
|
||||
for key, value in runtime_overrides.items():
|
||||
if key in {"api_key", "llm_provider", "llm_model"}:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
bot_data[key] = text
|
||||
continue
|
||||
if key == "api_base":
|
||||
bot_data[key] = str(value or "").strip()
|
||||
continue
|
||||
bot_data[key] = value
|
||||
|
||||
resources = _normalize_resource_limits(
|
||||
bot_data.get("cpu_cores"),
|
||||
bot_data.get("memory_mb"),
|
||||
bot_data.get("storage_gb"),
|
||||
)
|
||||
bot_data.update(resources)
|
||||
send_progress = bool(bot_data.get("send_progress", False))
|
||||
send_tool_hints = bool(bot_data.get("send_tool_hints", False))
|
||||
if isinstance(global_delivery_override, dict):
|
||||
if "sendProgress" in global_delivery_override:
|
||||
send_progress = bool(global_delivery_override.get("sendProgress"))
|
||||
if "sendToolHints" in global_delivery_override:
|
||||
send_tool_hints = bool(global_delivery_override.get("sendToolHints"))
|
||||
|
||||
channels_data = channels_override if channels_override is not None else _get_bot_channels_from_config(bot)
|
||||
bot_data["send_progress"] = send_progress
|
||||
bot_data["send_tool_hints"] = send_tool_hints
|
||||
normalized_channels: List[Dict[str, Any]] = []
|
||||
for row in channels_data:
|
||||
ctype = str(row.get("channel_type") or "").strip().lower()
|
||||
if not ctype or ctype == "dashboard":
|
||||
continue
|
||||
normalized_channels.append(
|
||||
{
|
||||
"channel_type": ctype,
|
||||
"external_app_id": str(row.get("external_app_id") or ""),
|
||||
"app_secret": str(row.get("app_secret") or ""),
|
||||
"internal_port": max(1, min(int(row.get("internal_port") or 8080), 65535)),
|
||||
"is_active": bool(row.get("is_active", True)),
|
||||
"extra_config": _normalize_channel_extra(row.get("extra_config")),
|
||||
}
|
||||
)
|
||||
|
||||
config_manager.update_workspace(bot_id=bot_id, bot_data=bot_data, channels=normalized_channels)
|
||||
_write_bot_resources(bot_id, bot_data.get("cpu_cores"), bot_data.get("memory_mb"), bot_data.get("storage_gb"))
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import Session
|
||||
|
||||
from core.docker_instance import docker_manager
|
||||
from core.utils import _calc_dir_size_bytes
|
||||
from core.settings import BOTS_WORKSPACE_ROOT
|
||||
from models.bot import BotInstance
|
||||
from schemas.bot import (
|
||||
BotEnvParamsUpdateRequest,
|
||||
|
|
@ -13,28 +14,30 @@ from schemas.bot import (
|
|||
ChannelConfigRequest,
|
||||
ChannelConfigUpdateRequest,
|
||||
)
|
||||
from services.bot_channel_service import (
|
||||
_channel_api_to_cfg,
|
||||
_get_bot_channels_from_config,
|
||||
_normalize_channel_extra,
|
||||
_read_global_delivery_flags,
|
||||
from services.bot_service import (
|
||||
channel_api_to_config,
|
||||
list_bot_channels_from_config,
|
||||
normalize_channel_extra,
|
||||
read_global_delivery_flags,
|
||||
sync_bot_workspace_channels,
|
||||
)
|
||||
from services.bot_service import _sync_workspace_channels
|
||||
from services.bot_mcp_service import (
|
||||
_merge_mcp_servers_preserving_extras,
|
||||
_normalize_mcp_servers,
|
||||
)
|
||||
from services.bot_storage_service import (
|
||||
_normalize_env_params,
|
||||
_read_bot_config,
|
||||
_read_bot_resources,
|
||||
_read_env_store,
|
||||
_workspace_root,
|
||||
_write_bot_config,
|
||||
_write_env_store,
|
||||
get_bot_resource_limits,
|
||||
get_bot_workspace_snapshot,
|
||||
normalize_bot_env_params,
|
||||
read_bot_config_data,
|
||||
read_bot_env_params,
|
||||
write_bot_config_data,
|
||||
write_bot_env_params,
|
||||
)
|
||||
from services.cache_service import _invalidate_bot_detail_cache
|
||||
|
||||
MANAGED_WORKSPACE_FILENAMES = ("AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md")
|
||||
|
||||
|
||||
def _get_bot_or_404(session: Session, bot_id: str) -> BotInstance:
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
|
|
@ -43,14 +46,103 @@ def _get_bot_or_404(session: Session, bot_id: str) -> BotInstance:
|
|||
return bot
|
||||
|
||||
|
||||
def _read_bot_config_object(bot_id: str) -> Dict[str, Any]:
|
||||
config_data = read_bot_config_data(bot_id)
|
||||
return config_data if isinstance(config_data, dict) else {}
|
||||
|
||||
|
||||
def _read_bot_tools_cfg(bot_id: str) -> tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
config_data = _read_bot_config_object(bot_id)
|
||||
tools_cfg = config_data.get("tools")
|
||||
if not isinstance(tools_cfg, dict):
|
||||
tools_cfg = {}
|
||||
config_data["tools"] = tools_cfg
|
||||
return config_data, tools_cfg
|
||||
|
||||
|
||||
def _read_bot_channels_cfg(bot_id: str) -> tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
config_data = _read_bot_config_object(bot_id)
|
||||
channels_cfg = config_data.get("channels")
|
||||
if not isinstance(channels_cfg, dict):
|
||||
channels_cfg = {}
|
||||
config_data["channels"] = channels_cfg
|
||||
return config_data, channels_cfg
|
||||
|
||||
|
||||
def _managed_bot_file_paths(bot_id: str) -> Dict[str, str]:
|
||||
bot_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot")
|
||||
workspace_root = os.path.join(bot_root, "workspace")
|
||||
paths = {
|
||||
"config": os.path.join(bot_root, "config.json"),
|
||||
"resources": os.path.join(bot_root, "resources.json"),
|
||||
}
|
||||
for filename in MANAGED_WORKSPACE_FILENAMES:
|
||||
paths[f"workspace:{filename}"] = os.path.join(workspace_root, filename)
|
||||
return paths
|
||||
|
||||
|
||||
def _snapshot_managed_bot_files(bot_id: str) -> Dict[str, Optional[bytes]]:
|
||||
snapshot: Dict[str, Optional[bytes]] = {}
|
||||
for key, path in _managed_bot_file_paths(bot_id).items():
|
||||
if os.path.isfile(path):
|
||||
with open(path, "rb") as file:
|
||||
snapshot[key] = file.read()
|
||||
else:
|
||||
snapshot[key] = None
|
||||
return snapshot
|
||||
|
||||
|
||||
def _restore_managed_bot_files(bot_id: str, snapshot: Dict[str, Optional[bytes]]) -> None:
|
||||
for key, path in _managed_bot_file_paths(bot_id).items():
|
||||
payload = snapshot.get(key)
|
||||
if payload is None:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
continue
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp_path = f"{path}.tmp"
|
||||
with open(tmp_path, "wb") as file:
|
||||
file.write(payload)
|
||||
os.replace(tmp_path, path)
|
||||
|
||||
|
||||
def _write_bot_config_state(
|
||||
session: Session,
|
||||
*,
|
||||
bot_id: str,
|
||||
config_data: Dict[str, Any],
|
||||
sync_workspace: bool = False,
|
||||
) -> None:
|
||||
managed_file_snapshot = _snapshot_managed_bot_files(bot_id) if sync_workspace else None
|
||||
try:
|
||||
write_bot_config_data(bot_id, config_data)
|
||||
if sync_workspace:
|
||||
sync_bot_workspace_channels(session, bot_id)
|
||||
except Exception:
|
||||
if managed_file_snapshot is not None:
|
||||
_restore_managed_bot_files(bot_id, managed_file_snapshot)
|
||||
session.rollback()
|
||||
raise
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
|
||||
|
||||
def _find_channel_row(rows: list[Dict[str, Any]], channel_id: str) -> Dict[str, Any]:
|
||||
channel_key = str(channel_id or "").strip().lower()
|
||||
row = next((item for item in rows if str(item.get("id") or "").lower() == channel_key), None)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return row
|
||||
|
||||
|
||||
def get_bot_resources_snapshot(session: Session, *, bot_id: str) -> Dict[str, Any]:
|
||||
bot = _get_bot_or_404(session, bot_id)
|
||||
|
||||
configured = _read_bot_resources(bot_id)
|
||||
configured = get_bot_resource_limits(bot_id)
|
||||
runtime = docker_manager.get_bot_resource_snapshot(bot_id)
|
||||
workspace_root = _workspace_root(bot_id)
|
||||
workspace_bytes = _calc_dir_size_bytes(workspace_root)
|
||||
configured_storage_bytes = int(configured.get("storage_gb", 0) or 0) * 1024 * 1024 * 1024
|
||||
workspace = get_bot_workspace_snapshot(bot_id)
|
||||
workspace_root = str(workspace.get("path") or "")
|
||||
workspace_bytes = int(workspace.get("usage_bytes") or 0)
|
||||
configured_storage_bytes = int(workspace.get("configured_limit_bytes") or 0)
|
||||
workspace_percent = 0.0
|
||||
if configured_storage_bytes > 0:
|
||||
workspace_percent = (workspace_bytes / configured_storage_bytes) * 100.0
|
||||
|
|
@ -86,7 +178,7 @@ def get_bot_resources_snapshot(session: Session, *, bot_id: str) -> Dict[str, An
|
|||
|
||||
def list_bot_channels_config(session: Session, *, bot_id: str):
|
||||
bot = _get_bot_or_404(session, bot_id)
|
||||
return _get_bot_channels_from_config(bot)
|
||||
return list_bot_channels_from_config(bot)
|
||||
|
||||
|
||||
def get_bot_tools_config_state(session: Session, *, bot_id: str) -> Dict[str, Any]:
|
||||
|
|
@ -114,10 +206,7 @@ def reject_bot_tools_config_update(
|
|||
|
||||
def get_bot_mcp_config_state(session: Session, *, bot_id: str) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
config_data = _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 = {}
|
||||
_config_data, tools_cfg = _read_bot_tools_cfg(bot_id)
|
||||
mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
|
|
@ -134,20 +223,13 @@ def update_bot_mcp_config_state(
|
|||
payload: BotMcpConfigUpdateRequest,
|
||||
) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
config_data = _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 = {}
|
||||
config_data, tools_cfg = _read_bot_tools_cfg(bot_id)
|
||||
normalized_mcp_servers = _normalize_mcp_servers(payload.mcp_servers or {})
|
||||
current_mcp_servers = tools_cfg.get("mcpServers")
|
||||
merged_mcp_servers = _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 = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
|
||||
_write_bot_config(bot_id, config_data)
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
_write_bot_config_state(session, bot_id=bot_id, config_data=config_data)
|
||||
return {
|
||||
"status": "updated",
|
||||
"bot_id": bot_id,
|
||||
|
|
@ -161,7 +243,7 @@ def get_bot_env_params_state(session: Session, *, bot_id: str) -> Dict[str, Any]
|
|||
_get_bot_or_404(session, bot_id)
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"env_params": _read_env_store(bot_id),
|
||||
"env_params": read_bot_env_params(bot_id),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -172,8 +254,8 @@ def update_bot_env_params_state(
|
|||
payload: BotEnvParamsUpdateRequest,
|
||||
) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
normalized = _normalize_env_params(payload.env_params)
|
||||
_write_env_store(bot_id, normalized)
|
||||
normalized = normalize_bot_env_params(payload.env_params)
|
||||
write_bot_env_params(bot_id, normalized)
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
return {
|
||||
"status": "updated",
|
||||
|
|
@ -196,7 +278,7 @@ def create_bot_channel_config(
|
|||
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 = _get_bot_channels_from_config(bot)
|
||||
current_rows = list_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}")
|
||||
|
||||
|
|
@ -208,19 +290,13 @@ def create_bot_channel_config(
|
|||
"app_secret": (payload.app_secret or "").strip(),
|
||||
"internal_port": max(1, min(int(payload.internal_port or 8080), 65535)),
|
||||
"is_active": bool(payload.is_active),
|
||||
"extra_config": _normalize_channel_extra(payload.extra_config),
|
||||
"extra_config": normalize_channel_extra(payload.extra_config),
|
||||
"locked": False,
|
||||
}
|
||||
|
||||
config_data = _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] = _channel_api_to_cfg(new_row)
|
||||
_write_bot_config(bot_id, config_data)
|
||||
_sync_workspace_channels(session, bot_id)
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
config_data, channels_cfg = _read_bot_channels_cfg(bot_id)
|
||||
channels_cfg[ctype] = channel_api_to_config(new_row)
|
||||
_write_bot_config_state(session, bot_id=bot_id, config_data=config_data, sync_workspace=True)
|
||||
return new_row
|
||||
|
||||
|
||||
|
|
@ -233,11 +309,8 @@ def update_bot_channel_config(
|
|||
) -> Dict[str, Any]:
|
||||
bot = _get_bot_or_404(session, bot_id)
|
||||
|
||||
channel_key = str(channel_id or "").strip().lower()
|
||||
rows = _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")
|
||||
rows = list_bot_channels_from_config(bot)
|
||||
row = _find_channel_row(rows, channel_id)
|
||||
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")
|
||||
|
||||
|
|
@ -265,19 +338,15 @@ def update_bot_channel_config(
|
|||
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"] = _normalize_channel_extra(update_data.get("extra_config"))
|
||||
row["extra_config"] = normalize_channel_extra(update_data.get("extra_config"))
|
||||
row["channel_type"] = new_type
|
||||
row["id"] = new_type
|
||||
row["locked"] = new_type == "dashboard"
|
||||
|
||||
config_data = _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 = _read_global_delivery_flags(channels_cfg)
|
||||
config_data, channels_cfg = _read_bot_channels_cfg(bot_id)
|
||||
current_send_progress, current_send_tool_hints = read_global_delivery_flags(channels_cfg)
|
||||
if new_type == "dashboard":
|
||||
extra = _normalize_channel_extra(row.get("extra_config"))
|
||||
extra = 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:
|
||||
|
|
@ -287,11 +356,8 @@ def update_bot_channel_config(
|
|||
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] = _channel_api_to_cfg(row)
|
||||
_write_bot_config(bot_id, config_data)
|
||||
session.commit()
|
||||
_sync_workspace_channels(session, bot_id)
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
channels_cfg[new_type] = channel_api_to_config(row)
|
||||
_write_bot_config_state(session, bot_id=bot_id, config_data=config_data, sync_workspace=True)
|
||||
return row
|
||||
|
||||
|
||||
|
|
@ -303,22 +369,12 @@ def delete_bot_channel_config(
|
|||
) -> Dict[str, Any]:
|
||||
bot = _get_bot_or_404(session, bot_id)
|
||||
|
||||
channel_key = str(channel_id or "").strip().lower()
|
||||
rows = _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")
|
||||
rows = list_bot_channels_from_config(bot)
|
||||
row = _find_channel_row(rows, channel_id)
|
||||
if str(row.get("channel_type") or "").lower() == "dashboard":
|
||||
raise HTTPException(status_code=400, detail="dashboard channel cannot be deleted")
|
||||
|
||||
config_data = _read_bot_config(bot_id)
|
||||
channels_cfg = config_data.get("channels")
|
||||
if not isinstance(channels_cfg, dict):
|
||||
channels_cfg = {}
|
||||
config_data["channels"] = channels_cfg
|
||||
config_data, channels_cfg = _read_bot_channels_cfg(bot_id)
|
||||
channels_cfg.pop(str(row.get("channel_type") or "").lower(), None)
|
||||
_write_bot_config(bot_id, config_data)
|
||||
session.commit()
|
||||
_sync_workspace_channels(session, bot_id)
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
_write_bot_config_state(session, bot_id=bot_id, config_data=config_data, sync_workspace=True)
|
||||
return {"status": "deleted"}
|
||||
|
|
|
|||
|
|
@ -12,16 +12,16 @@ from models.platform import BotActivityEvent, BotRequestUsage
|
|||
from models.skill import BotSkillInstall
|
||||
from models.topic import TopicItem, TopicTopic
|
||||
from services.bot_service import (
|
||||
_read_bot_runtime_snapshot,
|
||||
_resolve_bot_env_params,
|
||||
_safe_float,
|
||||
_safe_int,
|
||||
_sync_workspace_channels,
|
||||
read_bot_runtime_snapshot,
|
||||
resolve_bot_runtime_env_params,
|
||||
sync_bot_workspace_channels,
|
||||
)
|
||||
from services.bot_storage_service import _write_env_store
|
||||
from services.bot_storage_service import write_bot_env_params
|
||||
from services.cache_service import _invalidate_bot_detail_cache, _invalidate_bot_messages_cache
|
||||
from services.platform_service import record_activity_event
|
||||
from services.runtime_service import _record_agent_loop_ready_warning, docker_callback
|
||||
from services.runtime_service import docker_callback, record_agent_loop_ready_warning
|
||||
|
||||
|
||||
def _get_bot_or_404(session: Session, bot_id: str) -> BotInstance:
|
||||
|
|
@ -36,10 +36,10 @@ async def start_bot_instance(session: Session, bot_id: str) -> Dict[str, Any]:
|
|||
if not bool(getattr(bot, "enabled", True)):
|
||||
raise PermissionError("Bot is disabled. Enable it first.")
|
||||
|
||||
_sync_workspace_channels(session, bot_id)
|
||||
runtime_snapshot = _read_bot_runtime_snapshot(bot)
|
||||
env_params = _resolve_bot_env_params(bot_id)
|
||||
_write_env_store(bot_id, env_params)
|
||||
sync_bot_workspace_channels(session, bot_id)
|
||||
runtime_snapshot = read_bot_runtime_snapshot(bot)
|
||||
env_params = resolve_bot_runtime_env_params(bot_id)
|
||||
write_bot_env_params(bot_id, env_params)
|
||||
success = docker_manager.start_bot(
|
||||
bot_id,
|
||||
image_tag=bot.image_tag,
|
||||
|
|
@ -63,7 +63,7 @@ async def start_bot_instance(session: Session, bot_id: str) -> Dict[str, Any]:
|
|||
_invalidate_bot_detail_cache(bot_id)
|
||||
raise RuntimeError("Bot container failed shortly after startup. Check bot logs/config.")
|
||||
|
||||
asyncio.create_task(_record_agent_loop_ready_warning(bot_id))
|
||||
asyncio.create_task(record_agent_loop_ready_warning(bot_id))
|
||||
session.add(bot)
|
||||
record_activity_event(session, bot_id, "bot_started", channel="system", detail=f"Container started for {bot_id}")
|
||||
session.commit()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import re
|
|||
import shutil
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
|
|
@ -13,19 +12,21 @@ from core.settings import BOTS_WORKSPACE_ROOT
|
|||
from models.bot import BotInstance, NanobotImage
|
||||
from schemas.bot import BotCreateRequest, BotUpdateRequest
|
||||
from services.bot_service import (
|
||||
_normalize_env_params,
|
||||
_normalize_initial_channels,
|
||||
_normalize_resource_limits,
|
||||
_normalize_system_timezone,
|
||||
_provider_defaults,
|
||||
_resolve_bot_env_params,
|
||||
_serialize_bot,
|
||||
_serialize_bot_list_item,
|
||||
_sync_workspace_channels,
|
||||
normalize_initial_bot_channels,
|
||||
normalize_bot_system_timezone,
|
||||
resolve_bot_runtime_env_params,
|
||||
serialize_bot_detail,
|
||||
serialize_bot_list_entry,
|
||||
sync_bot_workspace_channels,
|
||||
)
|
||||
from services.bot_storage_service import (
|
||||
normalize_bot_env_params,
|
||||
normalize_bot_resource_limits,
|
||||
write_bot_env_params,
|
||||
)
|
||||
from services.bot_storage_service import _write_env_store
|
||||
from services.cache_service import _cache_key_bot_detail, _cache_key_bots_list, _invalidate_bot_detail_cache
|
||||
from services.platform_service import record_activity_event
|
||||
from services.provider_service import get_provider_defaults
|
||||
from services.template_service import get_agent_md_templates
|
||||
|
||||
BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$")
|
||||
|
|
@ -76,60 +77,6 @@ def _cleanup_bot_workspace_root(bot_id: str) -> None:
|
|||
shutil.rmtree(bot_root, ignore_errors=True)
|
||||
|
||||
|
||||
async def test_provider_connection(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
provider = (payload.get("provider") or "").strip()
|
||||
api_key = (payload.get("api_key") or "").strip()
|
||||
model = (payload.get("model") or "").strip()
|
||||
api_base = (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 = _provider_defaults(provider)
|
||||
base = (api_base or default_base).rstrip("/")
|
||||
if normalized_provider not in {"openrouter", "dashscope", "kimi", "minimax", "openai", "deepseek"}:
|
||||
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:
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
return {
|
||||
"ok": False,
|
||||
"provider": normalized_provider,
|
||||
"status_code": response.status_code,
|
||||
"detail": response.text[:500],
|
||||
}
|
||||
data = response.json()
|
||||
models_raw = data.get("data", []) if isinstance(data, dict) else []
|
||||
model_ids: List[str] = [
|
||||
str(item["id"]) for item in models_raw[:20] if isinstance(item, dict) and item.get("id")
|
||||
]
|
||||
return {
|
||||
"ok": True,
|
||||
"provider": normalized_provider,
|
||||
"endpoint": url,
|
||||
"models_preview": model_ids[:8],
|
||||
"model_hint": (
|
||||
"model_found"
|
||||
if model and any(model in item for item in model_ids)
|
||||
else ("model_not_listed" if model else "")
|
||||
),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"provider": normalized_provider,
|
||||
"endpoint": url,
|
||||
"detail": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[str, Any]:
|
||||
normalized_bot_id = str(payload.id or "").strip()
|
||||
if not normalized_bot_id:
|
||||
|
|
@ -147,9 +94,9 @@ def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[st
|
|||
if not docker_manager.has_image(payload.image_tag):
|
||||
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {payload.image_tag}")
|
||||
|
||||
normalized_env_params = _normalize_env_params(payload.env_params)
|
||||
normalized_env_params = normalize_bot_env_params(payload.env_params)
|
||||
try:
|
||||
normalized_env_params["TZ"] = _normalize_system_timezone(payload.system_timezone)
|
||||
normalized_env_params["TZ"] = normalize_bot_system_timezone(payload.system_timezone)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
|
@ -162,15 +109,15 @@ def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[st
|
|||
workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, normalized_bot_id),
|
||||
)
|
||||
template_defaults = get_agent_md_templates()
|
||||
resource_limits = _normalize_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb)
|
||||
resource_limits = normalize_bot_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb)
|
||||
try:
|
||||
session.add(bot)
|
||||
session.flush()
|
||||
_write_env_store(normalized_bot_id, normalized_env_params)
|
||||
_sync_workspace_channels(
|
||||
write_bot_env_params(normalized_bot_id, normalized_env_params)
|
||||
sync_bot_workspace_channels(
|
||||
session,
|
||||
normalized_bot_id,
|
||||
channels_override=_normalize_initial_channels(normalized_bot_id, payload.channels),
|
||||
channels_override=normalize_initial_bot_channels(normalized_bot_id, payload.channels),
|
||||
global_delivery_override={
|
||||
"sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False,
|
||||
"sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False,
|
||||
|
|
@ -211,7 +158,7 @@ def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[st
|
|||
_cleanup_bot_workspace_root(normalized_bot_id)
|
||||
raise
|
||||
_invalidate_bot_detail_cache(normalized_bot_id)
|
||||
return _serialize_bot(bot)
|
||||
return serialize_bot_detail(bot)
|
||||
|
||||
|
||||
def list_bots_with_cache(session: Session) -> List[Dict[str, Any]]:
|
||||
|
|
@ -234,7 +181,7 @@ def list_bots_with_cache(session: Session) -> List[Dict[str, Any]]:
|
|||
session.commit()
|
||||
for bot in bots:
|
||||
session.refresh(bot)
|
||||
rows = [_serialize_bot_list_item(bot) for bot in bots]
|
||||
rows = [serialize_bot_list_entry(bot) for bot in bots]
|
||||
cache.set_json(_cache_key_bots_list(), rows, ttl=30)
|
||||
return rows
|
||||
|
||||
|
|
@ -246,7 +193,7 @@ def get_bot_detail_cached(session: Session, *, bot_id: str) -> Dict[str, Any]:
|
|||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
row = _serialize_bot(bot)
|
||||
row = serialize_bot_detail(bot)
|
||||
cache.set_json(_cache_key_bot_detail(bot_id), row, ttl=30)
|
||||
return row
|
||||
|
||||
|
|
@ -290,7 +237,7 @@ def update_bot_record(session: Session, *, bot_id: str, payload: BotUpdateReques
|
|||
normalized_system_timezone: Optional[str] = None
|
||||
if system_timezone is not None:
|
||||
try:
|
||||
normalized_system_timezone = _normalize_system_timezone(system_timezone)
|
||||
normalized_system_timezone = normalize_bot_system_timezone(system_timezone)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
|
@ -335,7 +282,7 @@ def update_bot_record(session: Session, *, bot_id: str, payload: BotUpdateReques
|
|||
runtime_overrides["system_prompt"] = runtime_overrides["soul_md"]
|
||||
if {"cpu_cores", "memory_mb", "storage_gb"} & set(runtime_overrides.keys()):
|
||||
runtime_overrides.update(
|
||||
_normalize_resource_limits(
|
||||
normalize_bot_resource_limits(
|
||||
runtime_overrides.get("cpu_cores"),
|
||||
runtime_overrides.get("memory_mb"),
|
||||
runtime_overrides.get("storage_gb"),
|
||||
|
|
@ -350,12 +297,12 @@ def update_bot_record(session: Session, *, bot_id: str, payload: BotUpdateReques
|
|||
session.flush()
|
||||
|
||||
if env_params is not None or normalized_system_timezone is not None:
|
||||
next_env_params = _resolve_bot_env_params(bot_id)
|
||||
next_env_params = resolve_bot_runtime_env_params(bot_id)
|
||||
if env_params is not None:
|
||||
next_env_params = _normalize_env_params(env_params)
|
||||
next_env_params = normalize_bot_env_params(env_params)
|
||||
if normalized_system_timezone is not None:
|
||||
next_env_params["TZ"] = normalized_system_timezone
|
||||
_write_env_store(bot_id, next_env_params)
|
||||
write_bot_env_params(bot_id, next_env_params)
|
||||
|
||||
global_delivery_override: Optional[Dict[str, Any]] = None
|
||||
if "send_progress" in runtime_overrides or "send_tool_hints" in runtime_overrides:
|
||||
|
|
@ -365,7 +312,7 @@ def update_bot_record(session: Session, *, bot_id: str, payload: BotUpdateReques
|
|||
if "send_tool_hints" in runtime_overrides:
|
||||
global_delivery_override["sendToolHints"] = bool(runtime_overrides.get("send_tool_hints"))
|
||||
|
||||
_sync_workspace_channels(
|
||||
sync_bot_workspace_channels(
|
||||
session,
|
||||
bot_id,
|
||||
runtime_overrides=runtime_overrides if runtime_overrides else None,
|
||||
|
|
@ -382,4 +329,4 @@ def update_bot_record(session: Session, *, bot_id: str, payload: BotUpdateReques
|
|||
bot = refreshed_bot
|
||||
raise
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
return _serialize_bot(bot)
|
||||
return serialize_bot_detail(bot)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,14 @@ from sqlmodel import Session
|
|||
from core.docker_instance import docker_manager
|
||||
from core.settings import BOTS_WORKSPACE_ROOT
|
||||
from models.bot import BotInstance
|
||||
from services.bot_channel_service import _get_bot_channels_from_config
|
||||
from services.bot_lifecycle_service import start_bot_instance, stop_bot_instance
|
||||
from services.bot_storage_service import _read_bot_config, _read_cron_store, _write_bot_config, _write_cron_store
|
||||
from services.bot_service import list_bot_channels_from_config
|
||||
from services.bot_storage_service import (
|
||||
read_bot_config_data,
|
||||
read_bot_cron_jobs_store,
|
||||
write_bot_config_data,
|
||||
write_bot_cron_jobs_store,
|
||||
)
|
||||
from services.platform_auth_service import resolve_bot_websocket_auth, resolve_panel_websocket_auth
|
||||
|
||||
|
||||
|
|
@ -90,7 +95,7 @@ async def relogin_weixin(session: Session, *, bot_id: str) -> Dict[str, Any]:
|
|||
weixin_channel = next(
|
||||
(
|
||||
row
|
||||
for row in _get_bot_channels_from_config(bot)
|
||||
for row in list_bot_channels_from_config(bot)
|
||||
if str(row.get("channel_type") or "").strip().lower() == "weixin"
|
||||
),
|
||||
None,
|
||||
|
|
@ -107,12 +112,12 @@ async def relogin_weixin(session: Session, *, bot_id: str) -> Dict[str, Any]:
|
|||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to remove weixin state: {exc}") from exc
|
||||
|
||||
config_data = _read_bot_config(bot_id)
|
||||
config_data = read_bot_config_data(bot_id)
|
||||
channels_cfg = config_data.get("channels") if isinstance(config_data, dict) else {}
|
||||
weixin_cfg = channels_cfg.get("weixin") if isinstance(channels_cfg, dict) else None
|
||||
if isinstance(weixin_cfg, dict) and "token" in weixin_cfg:
|
||||
weixin_cfg.pop("token", None)
|
||||
_write_bot_config(bot_id, config_data)
|
||||
write_bot_config_data(bot_id, config_data)
|
||||
|
||||
restarted = False
|
||||
if str(bot.docker_status or "").upper() == "RUNNING":
|
||||
|
|
@ -130,7 +135,7 @@ async def relogin_weixin(session: Session, *, bot_id: str) -> Dict[str, Any]:
|
|||
|
||||
def list_cron_jobs(session: Session, *, bot_id: str, include_disabled: bool = True) -> Dict[str, Any]:
|
||||
_get_bot_or_raise(session, bot_id)
|
||||
store = _read_cron_store(bot_id)
|
||||
store = read_bot_cron_jobs_store(bot_id)
|
||||
rows = []
|
||||
for row in store.get("jobs", []):
|
||||
if not isinstance(row, dict):
|
||||
|
|
@ -145,7 +150,7 @@ def list_cron_jobs(session: Session, *, bot_id: str, include_disabled: bool = Tr
|
|||
|
||||
def stop_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]:
|
||||
_get_bot_or_raise(session, bot_id)
|
||||
store = _read_cron_store(bot_id)
|
||||
store = read_bot_cron_jobs_store(bot_id)
|
||||
jobs = store.get("jobs", [])
|
||||
if not isinstance(jobs, list):
|
||||
jobs = []
|
||||
|
|
@ -159,13 +164,13 @@ def stop_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, An
|
|||
state = {}
|
||||
found["state"] = state
|
||||
state["nextRunAtMs"] = None
|
||||
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
|
||||
write_bot_cron_jobs_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
|
||||
return {"status": "stopped", "job_id": job_id}
|
||||
|
||||
|
||||
def start_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]:
|
||||
_get_bot_or_raise(session, bot_id)
|
||||
store = _read_cron_store(bot_id)
|
||||
store = read_bot_cron_jobs_store(bot_id)
|
||||
jobs = store.get("jobs", [])
|
||||
if not isinstance(jobs, list):
|
||||
jobs = []
|
||||
|
|
@ -180,20 +185,20 @@ def start_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, A
|
|||
found["state"] = state
|
||||
schedule = found.get("schedule")
|
||||
state["nextRunAtMs"] = _compute_cron_next_run(schedule if isinstance(schedule, dict) else {})
|
||||
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
|
||||
write_bot_cron_jobs_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
|
||||
return {"status": "started", "job_id": job_id}
|
||||
|
||||
|
||||
def delete_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]:
|
||||
_get_bot_or_raise(session, bot_id)
|
||||
store = _read_cron_store(bot_id)
|
||||
store = read_bot_cron_jobs_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 LookupError("Cron job not found")
|
||||
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": kept})
|
||||
write_bot_cron_jobs_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": kept})
|
||||
return {"status": "deleted", "job_id": job_id}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,81 +1,32 @@
|
|||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from core.settings import DEFAULT_BOT_SYSTEM_TIMEZONE
|
||||
from core.config_manager import BotConfigManager
|
||||
from core.settings import BOTS_WORKSPACE_ROOT, DEFAULT_BOT_SYSTEM_TIMEZONE
|
||||
from models.bot import BotInstance
|
||||
from schemas.bot import ChannelConfigRequest
|
||||
from services.bot_storage_service import (
|
||||
_bot_data_root,
|
||||
_clear_bot_dashboard_direct_session,
|
||||
_clear_bot_sessions,
|
||||
_migrate_bot_resources_store,
|
||||
_normalize_env_params,
|
||||
_normalize_resource_limits,
|
||||
_read_bot_config,
|
||||
_read_bot_resources,
|
||||
_read_cron_store,
|
||||
_read_env_store,
|
||||
_safe_float,
|
||||
_safe_int,
|
||||
_workspace_root,
|
||||
_write_bot_config,
|
||||
_write_bot_resources,
|
||||
_write_cron_store,
|
||||
_write_env_store,
|
||||
)
|
||||
from services.bot_channel_service import (
|
||||
_channel_api_to_cfg,
|
||||
_get_bot_channels_from_config,
|
||||
_normalize_channel_extra,
|
||||
_normalize_initial_channels,
|
||||
_read_global_delivery_flags,
|
||||
_sync_workspace_channels_impl,
|
||||
)
|
||||
from services.bot_mcp_service import (
|
||||
_merge_mcp_servers_preserving_extras,
|
||||
_normalize_mcp_servers,
|
||||
_sanitize_mcp_servers_in_config_data,
|
||||
normalize_bot_resource_limits,
|
||||
write_bot_resource_limits,
|
||||
)
|
||||
from services.template_service import get_agent_md_templates
|
||||
|
||||
__all__ = [
|
||||
"_bot_data_root",
|
||||
"_channel_api_to_cfg",
|
||||
"_clear_bot_dashboard_direct_session",
|
||||
"_clear_bot_sessions",
|
||||
"_get_bot_channels_from_config",
|
||||
"_migrate_bot_resources_store",
|
||||
"_normalize_channel_extra",
|
||||
"_normalize_env_params",
|
||||
"_normalize_initial_channels",
|
||||
"_normalize_mcp_servers",
|
||||
"_normalize_resource_limits",
|
||||
"_normalize_system_timezone",
|
||||
"_provider_defaults",
|
||||
"_read_bot_config",
|
||||
"_read_bot_resources",
|
||||
"_read_bot_runtime_snapshot",
|
||||
"_read_cron_store",
|
||||
"_read_env_store",
|
||||
"_read_global_delivery_flags",
|
||||
"_resolve_bot_env_params",
|
||||
"_safe_float",
|
||||
"_safe_int",
|
||||
"_sanitize_mcp_servers_in_config_data",
|
||||
"_serialize_bot",
|
||||
"_serialize_bot_list_item",
|
||||
"_sync_workspace_channels",
|
||||
"_workspace_root",
|
||||
"_write_bot_config",
|
||||
"_write_bot_resources",
|
||||
"_write_cron_store",
|
||||
"_write_env_store",
|
||||
"_merge_mcp_servers_preserving_extras",
|
||||
]
|
||||
def _get_default_system_timezone() -> str:
|
||||
config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT)
|
||||
|
||||
|
||||
def get_default_bot_system_timezone() -> str:
|
||||
value = str(DEFAULT_BOT_SYSTEM_TIMEZONE or "").strip() or "Asia/Shanghai"
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
|
|
@ -84,10 +35,10 @@ def _get_default_system_timezone() -> str:
|
|||
return "Asia/Shanghai"
|
||||
|
||||
|
||||
def _normalize_system_timezone(raw: Any) -> str:
|
||||
def normalize_bot_system_timezone(raw: Any) -> str:
|
||||
value = str(raw or "").strip()
|
||||
if not value:
|
||||
return _get_default_system_timezone()
|
||||
return get_default_bot_system_timezone()
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except Exception as exc:
|
||||
|
|
@ -95,47 +46,316 @@ def _normalize_system_timezone(raw: Any) -> str:
|
|||
return value
|
||||
|
||||
|
||||
def _resolve_bot_env_params(bot_id: str, raw: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||
def resolve_bot_runtime_env_params(bot_id: str, raw: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||
env_params = _normalize_env_params(raw if isinstance(raw, dict) else _read_env_store(bot_id))
|
||||
try:
|
||||
env_params["TZ"] = _normalize_system_timezone(env_params.get("TZ"))
|
||||
env_params["TZ"] = normalize_bot_system_timezone(env_params.get("TZ"))
|
||||
except ValueError:
|
||||
env_params["TZ"] = _get_default_system_timezone()
|
||||
env_params["TZ"] = get_default_bot_system_timezone()
|
||||
return env_params
|
||||
|
||||
|
||||
def _provider_defaults(provider: str) -> tuple[str, str]:
|
||||
normalized = provider.lower().strip()
|
||||
if normalized in {"openai"}:
|
||||
return "openai", "https://api.openai.com/v1"
|
||||
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 {"deepseek"}:
|
||||
return "deepseek", "https://api.deepseek.com/v1"
|
||||
if normalized in {"xunfei", "iflytek", "xfyun"}:
|
||||
return "openai", "https://spark-api-open.xf-yun.com/v1"
|
||||
if normalized in {"vllm"}:
|
||||
return "openai", ""
|
||||
if normalized in {"kimi", "moonshot"}:
|
||||
return "kimi", "https://api.moonshot.cn/v1"
|
||||
if normalized in {"minimax"}:
|
||||
return "minimax", "https://api.minimax.chat/v1"
|
||||
return normalized, ""
|
||||
def normalize_channel_extra(raw: Any) -> Dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return raw
|
||||
|
||||
|
||||
def _normalize_allow_from(raw: Any) -> List[str]:
|
||||
rows: List[str] = []
|
||||
if isinstance(raw, list):
|
||||
for item in raw:
|
||||
text = str(item or "").strip()
|
||||
if text and text not in rows:
|
||||
rows.append(text)
|
||||
return rows or ["*"]
|
||||
|
||||
|
||||
def read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]:
|
||||
if not isinstance(channels_cfg, dict):
|
||||
return False, False
|
||||
send_progress = channels_cfg.get("sendProgress")
|
||||
send_tool_hints = channels_cfg.get("sendToolHints")
|
||||
dashboard_cfg = channels_cfg.get("dashboard")
|
||||
if isinstance(dashboard_cfg, dict):
|
||||
if send_progress is None and "sendProgress" in dashboard_cfg:
|
||||
send_progress = dashboard_cfg.get("sendProgress")
|
||||
if send_tool_hints is None and "sendToolHints" in dashboard_cfg:
|
||||
send_tool_hints = dashboard_cfg.get("sendToolHints")
|
||||
return bool(send_progress), bool(send_tool_hints)
|
||||
|
||||
|
||||
def channel_config_to_api(bot_id: str, channel_type: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ctype = str(channel_type or "").strip().lower()
|
||||
enabled = bool(cfg.get("enabled", True))
|
||||
port = max(1, min(int(cfg.get("port", 8080) or 8080), 65535))
|
||||
extra: Dict[str, Any] = {}
|
||||
external_app_id = ""
|
||||
app_secret = ""
|
||||
|
||||
if ctype == "feishu":
|
||||
external_app_id = str(cfg.get("appId") or "")
|
||||
app_secret = str(cfg.get("appSecret") or "")
|
||||
extra = {
|
||||
"encryptKey": cfg.get("encryptKey", ""),
|
||||
"verificationToken": cfg.get("verificationToken", ""),
|
||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||
}
|
||||
elif ctype == "dingtalk":
|
||||
external_app_id = str(cfg.get("clientId") or "")
|
||||
app_secret = str(cfg.get("clientSecret") or "")
|
||||
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
|
||||
elif ctype == "telegram":
|
||||
app_secret = str(cfg.get("token") or "")
|
||||
extra = {
|
||||
"proxy": cfg.get("proxy", ""),
|
||||
"replyToMessage": bool(cfg.get("replyToMessage", False)),
|
||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||
}
|
||||
elif ctype == "slack":
|
||||
external_app_id = str(cfg.get("botToken") or "")
|
||||
app_secret = str(cfg.get("appToken") or "")
|
||||
extra = {
|
||||
"mode": cfg.get("mode", "socket"),
|
||||
"replyInThread": bool(cfg.get("replyInThread", True)),
|
||||
"groupPolicy": cfg.get("groupPolicy", "mention"),
|
||||
"groupAllowFrom": cfg.get("groupAllowFrom", []),
|
||||
"reactEmoji": cfg.get("reactEmoji", "eyes"),
|
||||
}
|
||||
elif ctype == "qq":
|
||||
external_app_id = str(cfg.get("appId") or "")
|
||||
app_secret = str(cfg.get("secret") or "")
|
||||
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
|
||||
elif ctype == "weixin":
|
||||
app_secret = ""
|
||||
extra = {
|
||||
"hasSavedState": (Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json").is_file(),
|
||||
}
|
||||
elif ctype == "email":
|
||||
extra = {
|
||||
"consentGranted": bool(cfg.get("consentGranted", False)),
|
||||
"imapHost": str(cfg.get("imapHost") or ""),
|
||||
"imapPort": int(cfg.get("imapPort") or 993),
|
||||
"imapUsername": str(cfg.get("imapUsername") or ""),
|
||||
"imapPassword": str(cfg.get("imapPassword") or ""),
|
||||
"imapMailbox": str(cfg.get("imapMailbox") or "INBOX"),
|
||||
"imapUseSsl": bool(cfg.get("imapUseSsl", True)),
|
||||
"smtpHost": str(cfg.get("smtpHost") or ""),
|
||||
"smtpPort": int(cfg.get("smtpPort") or 587),
|
||||
"smtpUsername": str(cfg.get("smtpUsername") or ""),
|
||||
"smtpPassword": str(cfg.get("smtpPassword") or ""),
|
||||
"smtpUseTls": bool(cfg.get("smtpUseTls", True)),
|
||||
"smtpUseSsl": bool(cfg.get("smtpUseSsl", False)),
|
||||
"fromAddress": str(cfg.get("fromAddress") or ""),
|
||||
"autoReplyEnabled": bool(cfg.get("autoReplyEnabled", True)),
|
||||
"pollIntervalSeconds": int(cfg.get("pollIntervalSeconds") or 30),
|
||||
"markSeen": bool(cfg.get("markSeen", True)),
|
||||
"maxBodyChars": int(cfg.get("maxBodyChars") or 12000),
|
||||
"subjectPrefix": str(cfg.get("subjectPrefix") or "Re: "),
|
||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||
}
|
||||
else:
|
||||
external_app_id = str(
|
||||
cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or ""
|
||||
)
|
||||
app_secret = str(
|
||||
cfg.get("appSecret")
|
||||
or cfg.get("clientSecret")
|
||||
or cfg.get("secret")
|
||||
or cfg.get("token")
|
||||
or cfg.get("appToken")
|
||||
or ""
|
||||
)
|
||||
extra = {
|
||||
key: value
|
||||
for key, value in cfg.items()
|
||||
if key
|
||||
not in {
|
||||
"enabled",
|
||||
"port",
|
||||
"appId",
|
||||
"clientId",
|
||||
"botToken",
|
||||
"externalAppId",
|
||||
"appSecret",
|
||||
"clientSecret",
|
||||
"secret",
|
||||
"token",
|
||||
"appToken",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"id": ctype,
|
||||
"bot_id": bot_id,
|
||||
"channel_type": ctype,
|
||||
"external_app_id": external_app_id,
|
||||
"app_secret": app_secret,
|
||||
"internal_port": port,
|
||||
"is_active": enabled,
|
||||
"extra_config": extra,
|
||||
"locked": ctype == "dashboard",
|
||||
}
|
||||
|
||||
|
||||
def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
ctype = str(row.get("channel_type") or "").strip().lower()
|
||||
enabled = bool(row.get("is_active", True))
|
||||
extra = normalize_channel_extra(row.get("extra_config"))
|
||||
external_app_id = str(row.get("external_app_id") or "")
|
||||
app_secret = str(row.get("app_secret") or "")
|
||||
port = max(1, min(int(row.get("internal_port") or 8080), 65535))
|
||||
|
||||
if ctype == "feishu":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"appId": external_app_id,
|
||||
"appSecret": app_secret,
|
||||
"encryptKey": extra.get("encryptKey", ""),
|
||||
"verificationToken": extra.get("verificationToken", ""),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
if ctype == "dingtalk":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"clientId": external_app_id,
|
||||
"clientSecret": app_secret,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
if ctype == "telegram":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"token": app_secret,
|
||||
"proxy": extra.get("proxy", ""),
|
||||
"replyToMessage": bool(extra.get("replyToMessage", False)),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
if ctype == "slack":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"mode": extra.get("mode", "socket"),
|
||||
"botToken": external_app_id,
|
||||
"appToken": app_secret,
|
||||
"replyInThread": bool(extra.get("replyInThread", True)),
|
||||
"groupPolicy": extra.get("groupPolicy", "mention"),
|
||||
"groupAllowFrom": extra.get("groupAllowFrom", []),
|
||||
"reactEmoji": extra.get("reactEmoji", "eyes"),
|
||||
}
|
||||
if ctype == "qq":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"appId": external_app_id,
|
||||
"secret": app_secret,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
if ctype == "weixin":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"token": app_secret,
|
||||
}
|
||||
if ctype == "email":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"consentGranted": bool(extra.get("consentGranted", False)),
|
||||
"imapHost": str(extra.get("imapHost") or ""),
|
||||
"imapPort": max(1, min(int(extra.get("imapPort") or 993), 65535)),
|
||||
"imapUsername": str(extra.get("imapUsername") or ""),
|
||||
"imapPassword": str(extra.get("imapPassword") or ""),
|
||||
"imapMailbox": str(extra.get("imapMailbox") or "INBOX"),
|
||||
"imapUseSsl": bool(extra.get("imapUseSsl", True)),
|
||||
"smtpHost": str(extra.get("smtpHost") or ""),
|
||||
"smtpPort": max(1, min(int(extra.get("smtpPort") or 587), 65535)),
|
||||
"smtpUsername": str(extra.get("smtpUsername") or ""),
|
||||
"smtpPassword": str(extra.get("smtpPassword") or ""),
|
||||
"smtpUseTls": bool(extra.get("smtpUseTls", True)),
|
||||
"smtpUseSsl": bool(extra.get("smtpUseSsl", False)),
|
||||
"fromAddress": str(extra.get("fromAddress") or ""),
|
||||
"autoReplyEnabled": bool(extra.get("autoReplyEnabled", True)),
|
||||
"pollIntervalSeconds": max(5, int(extra.get("pollIntervalSeconds") or 30)),
|
||||
"markSeen": bool(extra.get("markSeen", True)),
|
||||
"maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)),
|
||||
"subjectPrefix": str(extra.get("subjectPrefix") or "Re: "),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
merged = dict(extra)
|
||||
merged.update(
|
||||
{
|
||||
"enabled": enabled,
|
||||
"appId": external_app_id,
|
||||
"appSecret": app_secret,
|
||||
"port": port,
|
||||
}
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def list_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]:
|
||||
config_data = _read_bot_config(bot.id)
|
||||
channels_cfg = config_data.get("channels")
|
||||
if not isinstance(channels_cfg, dict):
|
||||
channels_cfg = {}
|
||||
send_progress, send_tool_hints = read_global_delivery_flags(channels_cfg)
|
||||
rows: List[Dict[str, Any]] = [
|
||||
{
|
||||
"id": "dashboard",
|
||||
"bot_id": bot.id,
|
||||
"channel_type": "dashboard",
|
||||
"external_app_id": f"dashboard-{bot.id}",
|
||||
"app_secret": "",
|
||||
"internal_port": 9000,
|
||||
"is_active": True,
|
||||
"extra_config": {
|
||||
"sendProgress": send_progress,
|
||||
"sendToolHints": send_tool_hints,
|
||||
},
|
||||
"locked": True,
|
||||
}
|
||||
]
|
||||
for ctype, cfg in channels_cfg.items():
|
||||
if ctype in {"sendProgress", "sendToolHints", "dashboard"} or not isinstance(cfg, dict):
|
||||
continue
|
||||
rows.append(channel_config_to_api(bot.id, ctype, cfg))
|
||||
return rows
|
||||
|
||||
|
||||
def normalize_initial_bot_channels(bot_id: str, channels: Optional[List[ChannelConfigRequest]]) -> List[Dict[str, Any]]:
|
||||
rows: List[Dict[str, Any]] = []
|
||||
seen_types: set[str] = set()
|
||||
for channel in channels or []:
|
||||
ctype = (channel.channel_type or "").strip().lower()
|
||||
if not ctype or ctype == "dashboard" or ctype in seen_types:
|
||||
continue
|
||||
seen_types.add(ctype)
|
||||
rows.append(
|
||||
{
|
||||
"id": ctype,
|
||||
"bot_id": bot_id,
|
||||
"channel_type": ctype,
|
||||
"external_app_id": (channel.external_app_id or "").strip() or f"{ctype}-{bot_id}",
|
||||
"app_secret": (channel.app_secret or "").strip(),
|
||||
"internal_port": max(1, min(int(channel.internal_port or 8080), 65535)),
|
||||
"is_active": bool(channel.is_active),
|
||||
"extra_config": normalize_channel_extra(channel.extra_config),
|
||||
"locked": False,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _read_workspace_md(bot_id: str, filename: str, default_value: str) -> str:
|
||||
path = os.path.join(_workspace_root(bot_id), filename)
|
||||
if not os.path.isfile(path):
|
||||
return default_value
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
return file.read().strip()
|
||||
except Exception:
|
||||
return default_value
|
||||
|
||||
def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
|
||||
|
||||
def read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
|
||||
config_data = _read_bot_config(bot.id)
|
||||
env_params = _resolve_bot_env_params(bot.id)
|
||||
env_params = resolve_bot_runtime_env_params(bot.id)
|
||||
template_defaults = get_agent_md_templates()
|
||||
|
||||
provider_name = ""
|
||||
|
|
@ -156,7 +376,7 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
|
|||
agents_defaults = defaults
|
||||
|
||||
channels_cfg = config_data.get("channels")
|
||||
send_progress, send_tool_hints = _read_global_delivery_flags(channels_cfg)
|
||||
send_progress, send_tool_hints = read_global_delivery_flags(channels_cfg)
|
||||
|
||||
llm_provider = provider_name or ""
|
||||
llm_model = str(agents_defaults.get("model") or "")
|
||||
|
|
@ -182,7 +402,7 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
|
|||
"cpu_cores": resources["cpu_cores"],
|
||||
"memory_mb": resources["memory_mb"],
|
||||
"storage_gb": resources["storage_gb"],
|
||||
"system_timezone": env_params.get("TZ") or _get_default_system_timezone(),
|
||||
"system_timezone": env_params.get("TZ") or get_default_bot_system_timezone(),
|
||||
"send_progress": send_progress,
|
||||
"send_tool_hints": send_tool_hints,
|
||||
"soul_md": soul_md,
|
||||
|
|
@ -193,8 +413,9 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
|
|||
"system_prompt": soul_md,
|
||||
}
|
||||
|
||||
def _serialize_bot(bot: BotInstance) -> Dict[str, Any]:
|
||||
runtime = _read_bot_runtime_snapshot(bot)
|
||||
|
||||
def serialize_bot_detail(bot: BotInstance) -> Dict[str, Any]:
|
||||
runtime = read_bot_runtime_snapshot(bot)
|
||||
created_at = bot.created_at.isoformat() + "Z" if bot.created_at else None
|
||||
updated_at = bot.updated_at.isoformat() + "Z" if bot.updated_at else None
|
||||
return {
|
||||
|
|
@ -216,7 +437,7 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]:
|
|||
"cpu_cores": _safe_float(runtime.get("cpu_cores"), 1.0),
|
||||
"memory_mb": _safe_int(runtime.get("memory_mb"), 1024),
|
||||
"storage_gb": _safe_int(runtime.get("storage_gb"), 10),
|
||||
"system_timezone": str(runtime.get("system_timezone") or _get_default_system_timezone()),
|
||||
"system_timezone": str(runtime.get("system_timezone") or get_default_bot_system_timezone()),
|
||||
"send_progress": bool(runtime.get("send_progress")),
|
||||
"send_tool_hints": bool(runtime.get("send_tool_hints")),
|
||||
"soul_md": runtime.get("soul_md") or "",
|
||||
|
|
@ -232,7 +453,8 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]:
|
|||
"updated_at": updated_at,
|
||||
}
|
||||
|
||||
def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]:
|
||||
|
||||
def serialize_bot_list_entry(bot: BotInstance) -> Dict[str, Any]:
|
||||
created_at = bot.created_at.isoformat() + "Z" if bot.created_at else None
|
||||
updated_at = bot.updated_at.isoformat() + "Z" if bot.updated_at else None
|
||||
return {
|
||||
|
|
@ -248,7 +470,8 @@ def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]:
|
|||
"updated_at": updated_at,
|
||||
}
|
||||
|
||||
def _sync_workspace_channels(
|
||||
|
||||
def sync_bot_workspace_channels(
|
||||
session: Session,
|
||||
bot_id: str,
|
||||
channels_override: Optional[List[Dict[str, Any]]] = None,
|
||||
|
|
@ -258,12 +481,75 @@ def _sync_workspace_channels(
|
|||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
return
|
||||
snapshot = _read_bot_runtime_snapshot(bot)
|
||||
_sync_workspace_channels_impl(
|
||||
session,
|
||||
bot_id,
|
||||
snapshot,
|
||||
channels_override=channels_override,
|
||||
global_delivery_override=global_delivery_override,
|
||||
runtime_overrides=runtime_overrides,
|
||||
|
||||
snapshot = read_bot_runtime_snapshot(bot)
|
||||
template_defaults = get_agent_md_templates()
|
||||
bot_data: Dict[str, Any] = {
|
||||
"name": bot.name,
|
||||
"system_prompt": snapshot.get("system_prompt") or template_defaults.get("soul_md", ""),
|
||||
"soul_md": snapshot.get("soul_md") or template_defaults.get("soul_md", ""),
|
||||
"agents_md": snapshot.get("agents_md") or template_defaults.get("agents_md", ""),
|
||||
"user_md": snapshot.get("user_md") or template_defaults.get("user_md", ""),
|
||||
"tools_md": snapshot.get("tools_md") or template_defaults.get("tools_md", ""),
|
||||
"identity_md": snapshot.get("identity_md") or template_defaults.get("identity_md", ""),
|
||||
"llm_provider": snapshot.get("llm_provider") or "",
|
||||
"llm_model": snapshot.get("llm_model") or "",
|
||||
"api_key": snapshot.get("api_key") or "",
|
||||
"api_base": snapshot.get("api_base") or "",
|
||||
"temperature": snapshot.get("temperature"),
|
||||
"top_p": snapshot.get("top_p"),
|
||||
"max_tokens": snapshot.get("max_tokens"),
|
||||
"cpu_cores": snapshot.get("cpu_cores"),
|
||||
"memory_mb": snapshot.get("memory_mb"),
|
||||
"storage_gb": snapshot.get("storage_gb"),
|
||||
"send_progress": bool(snapshot.get("send_progress")),
|
||||
"send_tool_hints": bool(snapshot.get("send_tool_hints")),
|
||||
}
|
||||
if isinstance(runtime_overrides, dict):
|
||||
for key, value in runtime_overrides.items():
|
||||
if key in {"api_key", "llm_provider", "llm_model"}:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
bot_data[key] = text
|
||||
continue
|
||||
if key == "api_base":
|
||||
bot_data[key] = str(value or "").strip()
|
||||
continue
|
||||
bot_data[key] = value
|
||||
|
||||
resources = normalize_bot_resource_limits(
|
||||
bot_data.get("cpu_cores"),
|
||||
bot_data.get("memory_mb"),
|
||||
bot_data.get("storage_gb"),
|
||||
)
|
||||
bot_data.update(resources)
|
||||
send_progress = bool(bot_data.get("send_progress", False))
|
||||
send_tool_hints = bool(bot_data.get("send_tool_hints", False))
|
||||
if isinstance(global_delivery_override, dict):
|
||||
if "sendProgress" in global_delivery_override:
|
||||
send_progress = bool(global_delivery_override.get("sendProgress"))
|
||||
if "sendToolHints" in global_delivery_override:
|
||||
send_tool_hints = bool(global_delivery_override.get("sendToolHints"))
|
||||
|
||||
channels_data = channels_override if channels_override is not None else list_bot_channels_from_config(bot)
|
||||
bot_data["send_progress"] = send_progress
|
||||
bot_data["send_tool_hints"] = send_tool_hints
|
||||
normalized_channels: List[Dict[str, Any]] = []
|
||||
for row in channels_data:
|
||||
ctype = str(row.get("channel_type") or "").strip().lower()
|
||||
if not ctype or ctype == "dashboard":
|
||||
continue
|
||||
normalized_channels.append(
|
||||
{
|
||||
"channel_type": ctype,
|
||||
"external_app_id": str(row.get("external_app_id") or ""),
|
||||
"app_secret": str(row.get("app_secret") or ""),
|
||||
"internal_port": max(1, min(int(row.get("internal_port") or 8080), 65535)),
|
||||
"is_active": bool(row.get("is_active", True)),
|
||||
"extra_config": normalize_channel_extra(row.get("extra_config")),
|
||||
}
|
||||
)
|
||||
|
||||
config_manager.update_workspace(bot_id=bot_id, bot_data=bot_data, channels=normalized_channels)
|
||||
write_bot_resource_limits(bot_id, bot_data.get("cpu_cores"), bot_data.get("memory_mb"), bot_data.get("storage_gb"))
|
||||
|
|
|
|||
|
|
@ -5,11 +5,27 @@ import os
|
|||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from core.utils import _calc_dir_size_bytes
|
||||
from core.settings import BOTS_WORKSPACE_ROOT
|
||||
|
||||
_ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$")
|
||||
_BYTES_PER_GB = 1024 * 1024 * 1024
|
||||
|
||||
__all__ = [
|
||||
"get_bot_data_root",
|
||||
"normalize_bot_env_params",
|
||||
"normalize_bot_resource_limits",
|
||||
"read_bot_config_data",
|
||||
"read_bot_cron_jobs_store",
|
||||
"read_bot_env_params",
|
||||
"get_bot_resource_limits",
|
||||
"get_bot_workspace_root",
|
||||
"get_bot_workspace_snapshot",
|
||||
"get_bot_workspace_usage_bytes",
|
||||
"write_bot_config_data",
|
||||
"write_bot_cron_jobs_store",
|
||||
"write_bot_env_params",
|
||||
"write_bot_resource_limits",
|
||||
"_bot_data_root",
|
||||
"_clear_bot_dashboard_direct_session",
|
||||
"_clear_bot_sessions",
|
||||
|
|
@ -30,10 +46,18 @@ __all__ = [
|
|||
]
|
||||
|
||||
|
||||
def get_bot_workspace_root(bot_id: str) -> str:
|
||||
return _workspace_root(bot_id)
|
||||
|
||||
|
||||
def _workspace_root(bot_id: str) -> str:
|
||||
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace"))
|
||||
|
||||
|
||||
def get_bot_data_root(bot_id: str) -> str:
|
||||
return _bot_data_root(bot_id)
|
||||
|
||||
|
||||
def _bot_data_root(bot_id: str) -> str:
|
||||
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot"))
|
||||
|
||||
|
|
@ -72,6 +96,10 @@ def _normalize_resource_limits(cpu_cores: Any, memory_mb: Any, storage_gb: Any)
|
|||
}
|
||||
|
||||
|
||||
def normalize_bot_resource_limits(cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> Dict[str, Any]:
|
||||
return _normalize_resource_limits(cpu_cores, memory_mb, storage_gb)
|
||||
|
||||
|
||||
def _normalize_env_params(raw: Any) -> Dict[str, str]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
|
|
@ -84,6 +112,10 @@ def _normalize_env_params(raw: Any) -> Dict[str, str]:
|
|||
return rows
|
||||
|
||||
|
||||
def normalize_bot_env_params(raw: Any) -> Dict[str, str]:
|
||||
return _normalize_env_params(raw)
|
||||
|
||||
|
||||
def _read_json_object(path: str) -> Dict[str, Any]:
|
||||
if not os.path.isfile(path):
|
||||
return {}
|
||||
|
|
@ -121,10 +153,18 @@ def _read_bot_config(bot_id: str) -> Dict[str, Any]:
|
|||
return _read_json_object(_config_json_path(bot_id))
|
||||
|
||||
|
||||
def read_bot_config_data(bot_id: str) -> Dict[str, Any]:
|
||||
return _read_bot_config(bot_id)
|
||||
|
||||
|
||||
def _write_bot_config(bot_id: str, config_data: Dict[str, Any]) -> None:
|
||||
_write_json_atomic(_config_json_path(bot_id), config_data)
|
||||
|
||||
|
||||
def write_bot_config_data(bot_id: str, config_data: Dict[str, Any]) -> None:
|
||||
_write_bot_config(bot_id, config_data)
|
||||
|
||||
|
||||
def _resources_json_path(bot_id: str) -> str:
|
||||
return os.path.join(_bot_data_root(bot_id), "resources.json")
|
||||
|
||||
|
|
@ -141,6 +181,10 @@ def _write_bot_resources(bot_id: str, cpu_cores: Any, memory_mb: Any, storage_gb
|
|||
)
|
||||
|
||||
|
||||
def write_bot_resource_limits(bot_id: str, cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> None:
|
||||
_write_bot_resources(bot_id, cpu_cores, memory_mb, storage_gb)
|
||||
|
||||
|
||||
def _read_bot_resources(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
cpu_raw: Any = None
|
||||
memory_raw: Any = None
|
||||
|
|
@ -168,6 +212,24 @@ def _read_bot_resources(bot_id: str, config_data: Optional[Dict[str, Any]] = Non
|
|||
return _normalize_resource_limits(cpu_raw, memory_raw, storage_raw)
|
||||
|
||||
|
||||
def get_bot_resource_limits(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
return _read_bot_resources(bot_id, config_data=config_data)
|
||||
|
||||
|
||||
def get_bot_workspace_usage_bytes(bot_id: str) -> int:
|
||||
return _calc_dir_size_bytes(_workspace_root(bot_id))
|
||||
|
||||
|
||||
def get_bot_workspace_snapshot(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
resources = get_bot_resource_limits(bot_id, config_data=config_data)
|
||||
configured_limit_bytes = int(resources.get("storage_gb") or 0) * _BYTES_PER_GB
|
||||
return {
|
||||
"path": get_bot_workspace_root(bot_id),
|
||||
"usage_bytes": get_bot_workspace_usage_bytes(bot_id),
|
||||
"configured_limit_bytes": configured_limit_bytes if configured_limit_bytes > 0 else None,
|
||||
}
|
||||
|
||||
|
||||
def _migrate_bot_resources_store(bot_id: str) -> None:
|
||||
config_data = _read_bot_config(bot_id)
|
||||
runtime_cfg = config_data.get("runtime")
|
||||
|
|
@ -201,10 +263,18 @@ def _read_env_store(bot_id: str) -> Dict[str, str]:
|
|||
return _normalize_env_params(_read_json_object(_env_store_path(bot_id)))
|
||||
|
||||
|
||||
def read_bot_env_params(bot_id: str) -> Dict[str, str]:
|
||||
return _read_env_store(bot_id)
|
||||
|
||||
|
||||
def _write_env_store(bot_id: str, env_params: Dict[str, str]) -> None:
|
||||
_write_json_atomic(_env_store_path(bot_id), _normalize_env_params(env_params))
|
||||
|
||||
|
||||
def write_bot_env_params(bot_id: str, env_params: Dict[str, str]) -> None:
|
||||
_write_env_store(bot_id, env_params)
|
||||
|
||||
|
||||
def _cron_store_path(bot_id: str) -> str:
|
||||
return os.path.join(_workspace_root(bot_id), "cron", "jobs.json")
|
||||
|
||||
|
|
@ -229,11 +299,19 @@ def _read_cron_store(bot_id: str) -> Dict[str, Any]:
|
|||
return _normalize_cron_store_payload(_read_json_value(_cron_store_path(bot_id)))
|
||||
|
||||
|
||||
def read_bot_cron_jobs_store(bot_id: str) -> Dict[str, Any]:
|
||||
return _read_cron_store(bot_id)
|
||||
|
||||
|
||||
def _write_cron_store(bot_id: str, store: Dict[str, Any]) -> None:
|
||||
normalized = _normalize_cron_store_payload(store)
|
||||
_write_json_atomic(_cron_store_path(bot_id), normalized)
|
||||
|
||||
|
||||
def write_bot_cron_jobs_store(bot_id: str, store: Dict[str, Any]) -> None:
|
||||
_write_cron_store(bot_id, store)
|
||||
|
||||
|
||||
def _sessions_root(bot_id: str) -> str:
|
||||
return os.path.join(_workspace_root(bot_id), "sessions")
|
||||
|
||||
|
|
|
|||
|
|
@ -6,16 +6,16 @@ from fastapi import HTTPException
|
|||
from sqlmodel import Session
|
||||
|
||||
from core.docker_instance import docker_manager
|
||||
from core.utils import _is_video_attachment_path, _is_visual_attachment_path
|
||||
from models.bot import BotInstance
|
||||
from services.bot_service import _read_bot_runtime_snapshot
|
||||
from services.bot_service import read_bot_runtime_snapshot
|
||||
from services.platform_service import (
|
||||
create_usage_request,
|
||||
fail_latest_usage,
|
||||
record_activity_event,
|
||||
)
|
||||
from services.runtime_service import _persist_runtime_packet, _queue_runtime_broadcast
|
||||
from services.workspace_service import _resolve_workspace_path
|
||||
from core.utils import _is_video_attachment_path, _is_visual_attachment_path
|
||||
from services.runtime_service import broadcast_runtime_packet, persist_runtime_packet
|
||||
from services.workspace_service import resolve_workspace_path
|
||||
|
||||
logger = logging.getLogger("dashboard.backend")
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ def send_bot_command(session: Session, bot_id: str, command: str, attachments: A
|
|||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
runtime_snapshot = _read_bot_runtime_snapshot(bot)
|
||||
runtime_snapshot = read_bot_runtime_snapshot(bot)
|
||||
|
||||
normalized_attachments = _normalize_message_media_list(attachments)
|
||||
text_command = str(command or "").strip()
|
||||
|
|
@ -103,7 +103,7 @@ def send_bot_command(session: Session, bot_id: str, command: str, attachments: A
|
|||
|
||||
checked_attachments: List[str] = []
|
||||
for rel_path in normalized_attachments:
|
||||
_, target = _resolve_workspace_path(bot_id, rel_path)
|
||||
_, target = resolve_workspace_path(bot_id, rel_path)
|
||||
if not os.path.isfile(target):
|
||||
raise HTTPException(status_code=400, detail=f"attachment not found: {rel_path}")
|
||||
checked_attachments.append(rel_path)
|
||||
|
|
@ -142,10 +142,10 @@ def send_bot_command(session: Session, bot_id: str, command: str, attachments: A
|
|||
"media": checked_attachments,
|
||||
"request_id": request_id,
|
||||
}
|
||||
_persist_runtime_packet(bot_id, outbound_user_packet)
|
||||
persist_runtime_packet(bot_id, outbound_user_packet)
|
||||
|
||||
if outbound_user_packet:
|
||||
_queue_runtime_broadcast(bot_id, outbound_user_packet)
|
||||
broadcast_runtime_packet(bot_id, outbound_user_packet)
|
||||
|
||||
success = docker_manager.send_command(bot_id, delivery_command, media=delivery_media)
|
||||
if success:
|
||||
|
|
@ -162,7 +162,7 @@ def send_bot_command(session: Session, bot_id: str, command: str, attachments: A
|
|||
detail=(detail or "command delivery failed")[:400],
|
||||
)
|
||||
session.commit()
|
||||
_queue_runtime_broadcast(
|
||||
broadcast_runtime_packet(
|
||||
bot_id,
|
||||
{
|
||||
"type": "AGENT_STATE",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ from core.cache import cache
|
|||
from core.docker_instance import docker_manager
|
||||
from core.utils import _resolve_local_day_range
|
||||
from models.bot import BotInstance, BotMessage
|
||||
from services.bot_storage_service import _clear_bot_dashboard_direct_session, _clear_bot_sessions, _workspace_root
|
||||
from services.bot_storage_service import (
|
||||
_clear_bot_dashboard_direct_session,
|
||||
_clear_bot_sessions,
|
||||
get_bot_workspace_root,
|
||||
)
|
||||
from services.cache_service import (
|
||||
_cache_key_bot_messages,
|
||||
_cache_key_bot_messages_page,
|
||||
|
|
@ -33,7 +37,7 @@ def _normalize_message_media_item(bot_id: str, value: Any) -> str:
|
|||
return ""
|
||||
if raw.startswith("/root/.nanobot/workspace/"):
|
||||
return raw[len("/root/.nanobot/workspace/") :].lstrip("/")
|
||||
root = _workspace_root(bot_id)
|
||||
root = get_bot_workspace_root(bot_id)
|
||||
if os.path.isabs(raw):
|
||||
try:
|
||||
if os.path.commonpath([root, raw]) == root:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from core.cache import cache
|
||||
from core.docker_instance import docker_manager
|
||||
from models.bot import BotInstance, NanobotImage
|
||||
from services.cache_service import _cache_key_images, _invalidate_images_cache
|
||||
|
||||
logger = logging.getLogger("dashboard.backend")
|
||||
|
||||
|
||||
def _serialize_image(row: NanobotImage) -> Dict[str, Any]:
|
||||
created_at = row.created_at.isoformat() + "Z" if row.created_at else None
|
||||
return {
|
||||
"tag": row.tag,
|
||||
"image_id": row.image_id,
|
||||
"version": row.version,
|
||||
"status": row.status,
|
||||
"source_dir": row.source_dir,
|
||||
"created_at": created_at,
|
||||
}
|
||||
|
||||
|
||||
def _reconcile_registered_images(session: Session) -> None:
|
||||
rows = session.exec(select(NanobotImage)).all()
|
||||
dirty = False
|
||||
for row in rows:
|
||||
docker_exists = docker_manager.has_image(row.tag)
|
||||
next_status = "READY" if docker_exists else "ERROR"
|
||||
next_image_id = row.image_id
|
||||
if docker_exists and docker_manager.client:
|
||||
try:
|
||||
next_image_id = docker_manager.client.images.get(row.tag).id
|
||||
except Exception:
|
||||
next_image_id = row.image_id
|
||||
if row.status != next_status or row.image_id != next_image_id:
|
||||
row.status = next_status
|
||||
row.image_id = next_image_id
|
||||
session.add(row)
|
||||
dirty = True
|
||||
if dirty:
|
||||
session.commit()
|
||||
|
||||
|
||||
def list_registered_images(session: Session) -> List[Dict[str, Any]]:
|
||||
cached = cache.get_json(_cache_key_images())
|
||||
if isinstance(cached, list) and all(isinstance(row, dict) for row in cached):
|
||||
return cached
|
||||
if isinstance(cached, list):
|
||||
_invalidate_images_cache()
|
||||
try:
|
||||
_reconcile_registered_images(session)
|
||||
except Exception as exc:
|
||||
logger.warning("image reconcile skipped: %s", exc)
|
||||
rows = session.exec(select(NanobotImage).order_by(NanobotImage.created_at.desc())).all()
|
||||
payload = [_serialize_image(row) for row in rows]
|
||||
cache.set_json(_cache_key_images(), payload, ttl=60)
|
||||
return payload
|
||||
|
||||
|
||||
def delete_registered_image(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()
|
||||
_invalidate_images_cache()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
def list_docker_images_by_repository(repository: str = "nanobot-base") -> List[Dict[str, Any]]:
|
||||
return docker_manager.list_images_by_repo(repository)
|
||||
|
||||
|
||||
def register_image(session: Session, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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 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 = docker_manager.client.images.get(tag) if 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)
|
||||
_invalidate_images_cache()
|
||||
return _serialize_image(row)
|
||||
|
|
@ -2,9 +2,8 @@ from typing import Any, Dict
|
|||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from core.utils import _calc_dir_size_bytes
|
||||
from models.bot import BotInstance, NanobotImage
|
||||
from services.bot_storage_service import _read_bot_resources, _workspace_root
|
||||
from services.bot_storage_service import get_bot_resource_limits, get_bot_workspace_snapshot
|
||||
from services.platform_activity_service import (
|
||||
get_bot_activity_stats,
|
||||
list_activity_events,
|
||||
|
|
@ -39,15 +38,15 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str,
|
|||
for bot in bots:
|
||||
enabled = bool(getattr(bot, "enabled", True))
|
||||
runtime_status = docker_manager.get_bot_status(bot.id) if docker_manager else str(bot.docker_status or "STOPPED")
|
||||
resources = _read_bot_resources(bot.id)
|
||||
resources = get_bot_resource_limits(bot.id)
|
||||
runtime = (
|
||||
docker_manager.get_bot_resource_snapshot(bot.id)
|
||||
if docker_manager
|
||||
else {"usage": {}, "limits": {}, "docker_status": runtime_status}
|
||||
)
|
||||
workspace_root = _workspace_root(bot.id)
|
||||
workspace_used = _calc_dir_size_bytes(workspace_root)
|
||||
workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024
|
||||
workspace = get_bot_workspace_snapshot(bot.id, config_data=None)
|
||||
workspace_used = int(workspace.get("usage_bytes") or 0)
|
||||
workspace_limit = int(workspace.get("configured_limit_bytes") or 0)
|
||||
|
||||
configured_cpu_total += float(resources["cpu_cores"] or 0)
|
||||
configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
from typing import Any, Dict, List
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from core.database import engine
|
||||
from core.settings import (
|
||||
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,
|
||||
STT_DEVICE,
|
||||
STT_MODEL,
|
||||
)
|
||||
from models.platform import PlatformSetting
|
||||
from schemas.platform import PlatformSettingsPayload
|
||||
from services.platform_settings_core import (
|
||||
SETTING_KEYS,
|
||||
SYSTEM_SETTING_DEFINITIONS,
|
||||
_bootstrap_platform_setting_values,
|
||||
_normalize_extension_list,
|
||||
_read_setting_value,
|
||||
_upsert_setting_row,
|
||||
)
|
||||
from services.platform_system_settings_service import ensure_default_system_settings
|
||||
|
||||
|
||||
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"]),
|
||||
auth_token_ttl_hours=int(bootstrap["auth_token_ttl_hours"]),
|
||||
auth_token_max_active=int(bootstrap["auth_token_max_active"]),
|
||||
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"]),
|
||||
)
|
||||
|
||||
|
||||
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["auth_token_ttl_hours"] = max(
|
||||
1,
|
||||
min(720, int(data.get("auth_token_ttl_hours") or merged["auth_token_ttl_hours"])),
|
||||
)
|
||||
merged["auth_token_max_active"] = max(
|
||||
1,
|
||||
min(20, int(data.get("auth_token_max_active") or merged["auth_token_max_active"])),
|
||||
)
|
||||
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"]))
|
||||
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))),
|
||||
auth_token_ttl_hours=max(1, min(720, int(payload.auth_token_ttl_hours))),
|
||||
auth_token_max_active=max(1, min(20, int(payload.auth_token_max_active))),
|
||||
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),
|
||||
)
|
||||
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_auth_token_ttl_hours(session: Session) -> int:
|
||||
return get_platform_settings(session).auth_token_ttl_hours
|
||||
|
||||
|
||||
def get_auth_token_max_active(session: Session) -> int:
|
||||
return get_platform_settings(session).auth_token_max_active
|
||||
|
||||
|
||||
def get_speech_runtime_settings() -> Dict[str, Any]:
|
||||
settings = get_platform_settings_snapshot()
|
||||
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,
|
||||
}
|
||||
|
|
@ -1,23 +1,30 @@
|
|||
from services.platform_runtime_settings_service import (
|
||||
get_auth_token_max_active,
|
||||
get_auth_token_ttl_hours,
|
||||
default_platform_settings,
|
||||
get_allowed_attachment_extensions,
|
||||
get_chat_pull_page_size,
|
||||
get_page_size,
|
||||
get_platform_settings,
|
||||
get_platform_settings_snapshot,
|
||||
get_speech_runtime_settings,
|
||||
get_upload_max_mb,
|
||||
get_workspace_download_extensions,
|
||||
save_platform_settings,
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from core.database import engine
|
||||
from core.settings import (
|
||||
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,
|
||||
STT_DEVICE,
|
||||
STT_MODEL,
|
||||
)
|
||||
from models.platform import PlatformSetting
|
||||
from schemas.platform import PlatformSettingsPayload
|
||||
from services.platform_settings_core import (
|
||||
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
|
||||
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS,
|
||||
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS,
|
||||
SETTING_KEYS,
|
||||
SYSTEM_SETTING_DEFINITIONS,
|
||||
_bootstrap_platform_setting_values,
|
||||
_normalize_extension_list,
|
||||
_read_setting_value,
|
||||
_upsert_setting_row,
|
||||
)
|
||||
from services.platform_system_settings_service import (
|
||||
create_or_update_system_setting,
|
||||
|
|
@ -26,3 +33,128 @@ from services.platform_system_settings_service import (
|
|||
get_activity_event_retention_days,
|
||||
list_system_settings,
|
||||
)
|
||||
|
||||
|
||||
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"]),
|
||||
auth_token_ttl_hours=int(bootstrap["auth_token_ttl_hours"]),
|
||||
auth_token_max_active=int(bootstrap["auth_token_max_active"]),
|
||||
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"]),
|
||||
)
|
||||
|
||||
|
||||
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["auth_token_ttl_hours"] = max(
|
||||
1,
|
||||
min(720, int(data.get("auth_token_ttl_hours") or merged["auth_token_ttl_hours"])),
|
||||
)
|
||||
merged["auth_token_max_active"] = max(
|
||||
1,
|
||||
min(20, int(data.get("auth_token_max_active") or merged["auth_token_max_active"])),
|
||||
)
|
||||
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"]))
|
||||
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))),
|
||||
auth_token_ttl_hours=max(1, min(720, int(payload.auth_token_ttl_hours))),
|
||||
auth_token_max_active=max(1, min(20, int(payload.auth_token_max_active))),
|
||||
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),
|
||||
)
|
||||
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_auth_token_ttl_hours(session: Session) -> int:
|
||||
return get_platform_settings(session).auth_token_ttl_hours
|
||||
|
||||
|
||||
def get_auth_token_max_active(session: Session) -> int:
|
||||
return get_platform_settings(session).auth_token_max_active
|
||||
|
||||
|
||||
def get_speech_runtime_settings() -> Dict[str, Any]:
|
||||
settings = get_platform_settings_snapshot()
|
||||
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,79 @@
|
|||
from typing import Any, Dict, List
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def get_provider_defaults(provider: str) -> tuple[str, str]:
|
||||
normalized = str(provider or "").lower().strip()
|
||||
if normalized in {"openai"}:
|
||||
return "openai", "https://api.openai.com/v1"
|
||||
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 {"deepseek"}:
|
||||
return "deepseek", "https://api.deepseek.com/v1"
|
||||
if normalized in {"xunfei", "iflytek", "xfyun"}:
|
||||
return "openai", "https://spark-api-open.xf-yun.com/v1"
|
||||
if normalized in {"vllm"}:
|
||||
return "openai", ""
|
||||
if normalized in {"kimi", "moonshot"}:
|
||||
return "kimi", "https://api.moonshot.cn/v1"
|
||||
if normalized in {"minimax"}:
|
||||
return "minimax", "https://api.minimax.chat/v1"
|
||||
return normalized, ""
|
||||
|
||||
|
||||
async def test_provider_connection(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 = get_provider_defaults(provider)
|
||||
base = (api_base or default_base).rstrip("/")
|
||||
if normalized_provider not in {"openrouter", "dashscope", "kimi", "minimax", "openai", "deepseek"}:
|
||||
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:
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
return {
|
||||
"ok": False,
|
||||
"provider": normalized_provider,
|
||||
"status_code": response.status_code,
|
||||
"detail": response.text[:500],
|
||||
}
|
||||
data = response.json()
|
||||
models_raw = data.get("data", []) if isinstance(data, dict) else []
|
||||
model_ids: List[str] = [
|
||||
str(item["id"]) for item in models_raw[:20] if isinstance(item, dict) and item.get("id")
|
||||
]
|
||||
return {
|
||||
"ok": True,
|
||||
"provider": normalized_provider,
|
||||
"endpoint": url,
|
||||
"models_preview": model_ids[:8],
|
||||
"model_hint": (
|
||||
"model_found"
|
||||
if model and any(model in item for item in model_ids)
|
||||
else ("model_not_listed" if model else "")
|
||||
),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"provider": normalized_provider,
|
||||
"endpoint": url,
|
||||
"detail": str(exc),
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ from core.database import engine
|
|||
from core.docker_instance import docker_manager
|
||||
from core.websocket_manager import manager
|
||||
from models.bot import BotInstance, BotMessage
|
||||
from services.bot_service import _workspace_root
|
||||
from services.bot_storage_service import get_bot_workspace_root
|
||||
from services.cache_service import _invalidate_bot_detail_cache, _invalidate_bot_messages_cache
|
||||
from services.platform_service import bind_usage_message, finalize_usage_from_packet, record_activity_event
|
||||
from services.topic_runtime import publish_runtime_topic_packet
|
||||
|
|
@ -41,6 +41,10 @@ def _queue_runtime_broadcast(bot_id: str, packet: Dict[str, Any]) -> None:
|
|||
asyncio.run_coroutine_threadsafe(manager.broadcast(bot_id, packet), loop)
|
||||
|
||||
|
||||
def broadcast_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> None:
|
||||
_queue_runtime_broadcast(bot_id, packet)
|
||||
|
||||
|
||||
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"}:
|
||||
|
|
@ -54,7 +58,7 @@ def _normalize_media_item(bot_id: str, value: Any) -> str:
|
|||
return ""
|
||||
if raw.startswith("/root/.nanobot/workspace/"):
|
||||
return raw[len("/root/.nanobot/workspace/") :].lstrip("/")
|
||||
root = _workspace_root(bot_id)
|
||||
root = get_bot_workspace_root(bot_id)
|
||||
if os.path.isabs(raw):
|
||||
try:
|
||||
if os.path.commonpath([root, raw]) == root:
|
||||
|
|
@ -205,6 +209,10 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int
|
|||
return persisted_message_id
|
||||
|
||||
|
||||
def persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int]:
|
||||
return _persist_runtime_packet(bot_id, packet)
|
||||
|
||||
|
||||
def docker_callback(bot_id: str, packet: Dict[str, Any]) -> None:
|
||||
packet_type = str(packet.get("type", "")).upper()
|
||||
if packet_type == "RAW_LOG":
|
||||
|
|
@ -272,3 +280,15 @@ async def _record_agent_loop_ready_warning(
|
|||
_invalidate_bot_detail_cache(bot_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to record agent loop readiness warning for bot_id=%s", bot_id)
|
||||
|
||||
|
||||
async def record_agent_loop_ready_warning(
|
||||
bot_id: str,
|
||||
timeout_seconds: float = 12.0,
|
||||
poll_interval_seconds: float = 0.5,
|
||||
) -> None:
|
||||
await _record_agent_loop_ready_warning(
|
||||
bot_id,
|
||||
timeout_seconds=timeout_seconds,
|
||||
poll_interval_seconds=poll_interval_seconds,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from core.utils import (
|
|||
)
|
||||
from models.skill import BotSkillInstall, SkillMarketItem
|
||||
from services.platform_service import get_platform_settings_snapshot
|
||||
from services.skill_service import _install_skill_zip_into_workspace, _skills_root
|
||||
from services.skill_service import get_bot_skills_root, install_skill_zip_into_workspace
|
||||
|
||||
|
||||
def _skill_market_root() -> str:
|
||||
|
|
@ -341,7 +341,7 @@ def list_bot_skill_market_items(session: Session, *, bot_id: str) -> List[Dict[s
|
|||
else (
|
||||
install_lookup[int(item.id or 0)].status == "INSTALLED"
|
||||
and all(
|
||||
os.path.exists(os.path.join(_skills_root(bot_id), name))
|
||||
os.path.exists(os.path.join(get_bot_skills_root(bot_id), name))
|
||||
for name in _parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
|
||||
)
|
||||
)
|
||||
|
|
@ -378,7 +378,7 @@ def install_skill_market_item_for_bot(
|
|||
).first()
|
||||
|
||||
try:
|
||||
install_result = _install_skill_zip_into_workspace(bot_id, zip_path)
|
||||
install_result = install_skill_zip_into_workspace(bot_id, zip_path)
|
||||
now = datetime.utcnow()
|
||||
if not install_row:
|
||||
install_row = BotSkillInstall(
|
||||
|
|
|
|||
|
|
@ -11,12 +11,16 @@ from core.utils import (
|
|||
_is_ignored_skill_zip_top_level,
|
||||
_is_valid_top_level_skill_name,
|
||||
)
|
||||
from services.bot_storage_service import _workspace_root
|
||||
from services.bot_storage_service import get_bot_workspace_root
|
||||
from services.platform_service import get_platform_settings_snapshot
|
||||
|
||||
|
||||
def get_bot_skills_root(bot_id: str) -> str:
|
||||
return _skills_root(bot_id)
|
||||
|
||||
|
||||
def _skills_root(bot_id: str) -> str:
|
||||
return os.path.join(_workspace_root(bot_id), "skills")
|
||||
return os.path.join(get_bot_workspace_root(bot_id), "skills")
|
||||
|
||||
def _read_skill_description(entry_path: str) -> str:
|
||||
candidates: List[str] = []
|
||||
|
|
@ -139,6 +143,10 @@ def _install_skill_zip_into_workspace(bot_id: str, zip_path: str) -> Dict[str, A
|
|||
}
|
||||
|
||||
|
||||
def install_skill_zip_into_workspace(bot_id: str, zip_path: str) -> Dict[str, Any]:
|
||||
return _install_skill_zip_into_workspace(bot_id, zip_path)
|
||||
|
||||
|
||||
def list_bot_skills(bot_id: str) -> List[Dict[str, Any]]:
|
||||
return _list_workspace_skills(bot_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from typing import Any, Dict, Optional
|
|||
|
||||
from sqlmodel import Session
|
||||
|
||||
from services.topic_service import _topic_publish_internal
|
||||
from services.topic_service import publish_topic_item
|
||||
|
||||
from .publisher import build_topic_publish_payload
|
||||
|
||||
|
|
@ -30,6 +30,6 @@ def publish_runtime_topic_packet(
|
|||
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
_topic_publish_internal(session, bot_id, topic_payload)
|
||||
publish_topic_item(session, bot_id, topic_payload)
|
||||
except Exception:
|
||||
logger.exception("topic auto publish failed for bot %s packet %s", bot_id, packet_type)
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ import re
|
|||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from models.bot import BotInstance
|
||||
from models.topic import TopicItem, TopicTopic
|
||||
|
||||
TOPIC_DEDUPE_WINDOW_SECONDS = 10 * 60
|
||||
TOPIC_LEVEL_SET = {"info", "warn", "error", "success"}
|
||||
_TOPIC_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,63}$")
|
||||
TOPIC_KEY_RE = _TOPIC_KEY_RE
|
||||
|
||||
|
||||
def _as_bool(value: Any) -> bool:
|
||||
|
|
@ -101,6 +104,13 @@ def _topic_get_row(session: Session, bot_id: str, topic_key: str) -> Optional[To
|
|||
).first()
|
||||
|
||||
|
||||
def _get_bot_or_404(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 _normalize_topic_keywords(raw: Any) -> List[str]:
|
||||
rows: List[str] = []
|
||||
if isinstance(raw, list):
|
||||
|
|
@ -338,3 +348,217 @@ def _topic_publish_internal(session: Session, bot_id: str, payload: Dict[str, An
|
|||
"item": _topic_item_to_dict(item),
|
||||
"route": route_result,
|
||||
}
|
||||
|
||||
|
||||
def normalize_topic_key(raw: Any) -> str:
|
||||
return _normalize_topic_key(raw)
|
||||
|
||||
|
||||
def list_topics(session: Session, bot_id: str) -> List[Dict[str, Any]]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
return _list_topics(session, bot_id)
|
||||
|
||||
|
||||
def create_topic(
|
||||
session: Session,
|
||||
*,
|
||||
bot_id: str,
|
||||
topic_key: str,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
is_active: bool = True,
|
||||
routing: Optional[Dict[str, Any]] = None,
|
||||
view_schema: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
normalized_key = _normalize_topic_key(topic_key)
|
||||
if not normalized_key:
|
||||
raise HTTPException(status_code=400, detail="topic_key is required")
|
||||
if not TOPIC_KEY_RE.fullmatch(normalized_key):
|
||||
raise HTTPException(status_code=400, detail="invalid topic_key")
|
||||
exists = _topic_get_row(session, bot_id, normalized_key)
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail=f"Topic already exists: {normalized_key}")
|
||||
|
||||
now = datetime.utcnow()
|
||||
row = TopicTopic(
|
||||
bot_id=bot_id,
|
||||
topic_key=normalized_key,
|
||||
name=str(name or normalized_key).strip() or normalized_key,
|
||||
description=str(description or "").strip(),
|
||||
is_active=bool(is_active),
|
||||
is_default_fallback=False,
|
||||
routing_json=json.dumps(routing or {}, ensure_ascii=False),
|
||||
view_schema_json=json.dumps(view_schema or {}, ensure_ascii=False),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return _topic_to_dict(row)
|
||||
|
||||
|
||||
def update_topic(
|
||||
session: Session,
|
||||
*,
|
||||
bot_id: str,
|
||||
topic_key: str,
|
||||
updates: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
normalized_key = _normalize_topic_key(topic_key)
|
||||
if not normalized_key:
|
||||
raise HTTPException(status_code=400, detail="topic_key is required")
|
||||
row = _topic_get_row(session, bot_id, normalized_key)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
|
||||
if "name" in updates:
|
||||
row.name = str(updates.get("name") or "").strip() or row.topic_key
|
||||
if "description" in updates:
|
||||
row.description = str(updates.get("description") or "").strip()
|
||||
if "is_active" in updates:
|
||||
row.is_active = bool(updates.get("is_active"))
|
||||
if "routing" in updates:
|
||||
row.routing_json = json.dumps(updates.get("routing") or {}, ensure_ascii=False)
|
||||
if "view_schema" in updates:
|
||||
row.view_schema_json = json.dumps(updates.get("view_schema") or {}, ensure_ascii=False)
|
||||
row.is_default_fallback = False
|
||||
row.updated_at = datetime.utcnow()
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return _topic_to_dict(row)
|
||||
|
||||
|
||||
def delete_topic(session: Session, *, bot_id: str, topic_key: str) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
normalized_key = _normalize_topic_key(topic_key)
|
||||
if not normalized_key:
|
||||
raise HTTPException(status_code=400, detail="topic_key is required")
|
||||
row = _topic_get_row(session, bot_id, normalized_key)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
items = session.exec(
|
||||
select(TopicItem)
|
||||
.where(TopicItem.bot_id == bot_id)
|
||||
.where(TopicItem.topic_key == normalized_key)
|
||||
).all()
|
||||
for item in items:
|
||||
session.delete(item)
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return {"status": "deleted", "bot_id": bot_id, "topic_key": normalized_key}
|
||||
|
||||
|
||||
def _count_topic_items(
|
||||
session: Session,
|
||||
bot_id: str,
|
||||
topic_key: Optional[str] = None,
|
||||
unread_only: bool = False,
|
||||
) -> int:
|
||||
stmt = select(TopicItem).where(TopicItem.bot_id == bot_id)
|
||||
normalized_topic_key = _normalize_topic_key(topic_key or "")
|
||||
if normalized_topic_key:
|
||||
stmt = stmt.where(TopicItem.topic_key == normalized_topic_key)
|
||||
rows = session.exec(stmt).all()
|
||||
if unread_only:
|
||||
return sum(1 for row in rows if not bool(row.is_read))
|
||||
return len(rows)
|
||||
|
||||
|
||||
def list_topic_items(
|
||||
session: Session,
|
||||
*,
|
||||
bot_id: str,
|
||||
topic_key: Optional[str] = None,
|
||||
cursor: Optional[int] = None,
|
||||
limit: int = 50,
|
||||
) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
normalized_limit = max(1, min(int(limit or 50), 100))
|
||||
stmt = select(TopicItem).where(TopicItem.bot_id == bot_id)
|
||||
normalized_topic_key = _normalize_topic_key(topic_key or "")
|
||||
if normalized_topic_key:
|
||||
stmt = stmt.where(TopicItem.topic_key == normalized_topic_key)
|
||||
if cursor is not None:
|
||||
normalized_cursor = int(cursor)
|
||||
if normalized_cursor > 0:
|
||||
stmt = stmt.where(TopicItem.id < normalized_cursor)
|
||||
rows = session.exec(stmt.order_by(TopicItem.id.desc()).limit(normalized_limit + 1)).all()
|
||||
next_cursor: Optional[int] = None
|
||||
if len(rows) > normalized_limit:
|
||||
next_cursor = rows[-1].id
|
||||
rows = rows[:normalized_limit]
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"topic_key": normalized_topic_key or None,
|
||||
"items": [_topic_item_to_dict(row) for row in rows],
|
||||
"next_cursor": next_cursor,
|
||||
"unread_count": _count_topic_items(session, bot_id, normalized_topic_key, unread_only=True),
|
||||
"total_unread_count": _count_topic_items(session, bot_id, unread_only=True),
|
||||
}
|
||||
|
||||
|
||||
def get_topic_item_stats(session: Session, *, bot_id: str) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
latest_item = session.exec(
|
||||
select(TopicItem)
|
||||
.where(TopicItem.bot_id == bot_id)
|
||||
.order_by(TopicItem.id.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"total_count": _count_topic_items(session, bot_id),
|
||||
"unread_count": _count_topic_items(session, bot_id, unread_only=True),
|
||||
"latest_item_id": int(latest_item.id or 0) if latest_item and latest_item.id else None,
|
||||
}
|
||||
|
||||
|
||||
def mark_topic_item_read(session: Session, *, bot_id: str, item_id: int) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
row = session.exec(
|
||||
select(TopicItem)
|
||||
.where(TopicItem.bot_id == bot_id)
|
||||
.where(TopicItem.id == item_id)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Topic item not found")
|
||||
if not bool(row.is_read):
|
||||
row.is_read = True
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return {
|
||||
"status": "updated",
|
||||
"bot_id": bot_id,
|
||||
"item": _topic_item_to_dict(row),
|
||||
}
|
||||
|
||||
|
||||
def delete_topic_item(session: Session, *, bot_id: str, item_id: int) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
row = session.exec(
|
||||
select(TopicItem)
|
||||
.where(TopicItem.bot_id == bot_id)
|
||||
.where(TopicItem.id == item_id)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Topic item not found")
|
||||
payload = _topic_item_to_dict(row)
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return {
|
||||
"status": "deleted",
|
||||
"bot_id": bot_id,
|
||||
"item": payload,
|
||||
}
|
||||
|
||||
|
||||
def publish_topic_item(session: Session, bot_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
return _topic_publish_internal(session, bot_id, payload)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from fastapi import HTTPException, Request, UploadFile
|
|||
from fastapi.responses import FileResponse, RedirectResponse, Response, StreamingResponse
|
||||
|
||||
from core.utils import _workspace_stat_ctime_iso
|
||||
from services.bot_storage_service import _workspace_root
|
||||
from services.bot_storage_service import get_bot_workspace_root
|
||||
from services.platform_service import get_platform_settings_snapshot
|
||||
|
||||
TEXT_PREVIEW_EXTENSIONS = {
|
||||
|
|
@ -32,7 +32,7 @@ TEXT_PREVIEW_EXTENSIONS = {
|
|||
MARKDOWN_EXTENSIONS = {".md", ".markdown"}
|
||||
|
||||
def _resolve_workspace_path(bot_id: str, rel_path: Optional[str] = None) -> tuple[str, str]:
|
||||
root = _workspace_root(bot_id)
|
||||
root = get_bot_workspace_root(bot_id)
|
||||
rel = (rel_path or "").strip().replace("\\", "/")
|
||||
target = os.path.abspath(os.path.join(root, rel))
|
||||
if os.path.commonpath([root, target]) != root:
|
||||
|
|
@ -40,6 +40,10 @@ def _resolve_workspace_path(bot_id: str, rel_path: Optional[str] = None) -> tupl
|
|||
return root, target
|
||||
|
||||
|
||||
def resolve_workspace_path(bot_id: str, rel_path: Optional[str] = None) -> tuple[str, str]:
|
||||
return _resolve_workspace_path(bot_id, rel_path)
|
||||
|
||||
|
||||
def _write_text_atomic(target: str, content: str) -> None:
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
tmp = f"{target}.tmp"
|
||||
|
|
@ -249,7 +253,7 @@ def get_workspace_tree_data(
|
|||
path: Optional[str] = None,
|
||||
recursive: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
root = _workspace_root(bot_id)
|
||||
root = get_bot_workspace_root(bot_id)
|
||||
if not os.path.isdir(root):
|
||||
return {"bot_id": bot_id, "root": root, "cwd": "", "parent": None, "entries": []}
|
||||
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@
|
|||
|
||||
### 2.6 前端禁止事项
|
||||
|
||||
- 禁止再次把页面做成“一个文件管状态、接口、弹层、列表、详情、搜索、分页”
|
||||
- 禁止把页面做成“一个文件管状态、接口、弹层、列表、详情、搜索、分页”
|
||||
- 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件
|
||||
- 禁止创建无明确职责的超通用组件
|
||||
- 禁止为减少行数而做不可读的过度抽象
|
||||
|
|
@ -226,12 +226,6 @@ Router 不允许承担:
|
|||
- 数据库表间拼装
|
||||
- 本地文件系统读写细节
|
||||
|
||||
Router 文件体量规则:
|
||||
|
||||
- 目标:`< 300` 行
|
||||
- 可接受上限:`400` 行
|
||||
- 超过 `400` 行必须拆成子 router,并由装配层统一 `include_router`
|
||||
|
||||
### 3.4 Service 规范
|
||||
|
||||
Service 必须按业务域内聚组织,而不是为了压缩行数而机械切碎。
|
||||
|
|
|
|||
|
|
@ -68,26 +68,18 @@ function AppShell() {
|
|||
const showNavRail = route.kind !== 'bot' && !compactMode;
|
||||
const showAppNavDrawerEntry = route.kind !== 'bot' && compactMode;
|
||||
const showBotPanelDrawerEntry = route.kind === 'bot' && compactMode;
|
||||
const appNavDrawerVisible = showAppNavDrawerEntry && appNavDrawerOpen;
|
||||
const botPanelDrawerVisible = showBotPanelDrawerEntry && botPanelDrawerOpen;
|
||||
const activeCompactPanelTab = showBotPanelDrawerEntry ? botCompactPanelTab : 'chat';
|
||||
const useCompactSimpleHeader = showBotPanelDrawerEntry || showAppNavDrawerEntry;
|
||||
const headerTitle = showBotPanelDrawerEntry
|
||||
? (botCompactPanelTab === 'runtime' ? t.botPanels.runtime : t.botPanels.chat)
|
||||
? (activeCompactPanelTab === 'runtime' ? t.botPanels.runtime : t.botPanels.chat)
|
||||
: routeMeta.title;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t.title} - ${route.kind === 'bot' ? botDocumentTitle : routeMeta.title}`;
|
||||
}, [botDocumentTitle, route.kind, routeMeta.title, t.title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBotPanelDrawerEntry) {
|
||||
setBotPanelDrawerOpen(false);
|
||||
setBotCompactPanelTab('chat');
|
||||
}
|
||||
}, [forcedBotId, showBotPanelDrawerEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAppNavDrawerEntry) setAppNavDrawerOpen(false);
|
||||
}, [route.kind, showAppNavDrawerEntry]);
|
||||
|
||||
const botPanelLabels = t.botPanels;
|
||||
const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingTitle;
|
||||
const drawerBotId = String(forcedBotId || '').trim() || '-';
|
||||
|
|
@ -152,7 +144,7 @@ function AppShell() {
|
|||
<LazyBotHomePage
|
||||
botId={forcedBotId}
|
||||
compactMode={compactMode}
|
||||
compactPanelTab={botCompactPanelTab}
|
||||
compactPanelTab={activeCompactPanelTab}
|
||||
onCompactPanelTabChange={setBotCompactPanelTab}
|
||||
/>
|
||||
</BotRouteAccessGate>
|
||||
|
|
@ -299,7 +291,7 @@ function AppShell() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{showAppNavDrawerEntry && appNavDrawerOpen ? (
|
||||
{appNavDrawerVisible ? (
|
||||
<div className="app-bot-panel-drawer-mask" onClick={() => setAppNavDrawerOpen(false)}>
|
||||
<aside
|
||||
className="app-bot-panel-drawer app-nav-drawer"
|
||||
|
|
@ -354,7 +346,7 @@ function AppShell() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{showBotPanelDrawerEntry && botPanelDrawerOpen ? (
|
||||
{botPanelDrawerVisible ? (
|
||||
<div className="app-bot-panel-drawer-mask" onClick={() => setBotPanelDrawerOpen(false)}>
|
||||
<aside
|
||||
className="app-bot-panel-drawer"
|
||||
|
|
@ -380,26 +372,26 @@ function AppShell() {
|
|||
<div className="app-bot-panel-drawer-list" role="tablist" aria-label={botPanelLabels.title}>
|
||||
<button
|
||||
type="button"
|
||||
className={`app-bot-panel-drawer-item ${botCompactPanelTab === 'chat' ? 'is-active' : ''}`}
|
||||
className={`app-bot-panel-drawer-item ${activeCompactPanelTab === 'chat' ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setBotCompactPanelTab('chat');
|
||||
setBotPanelDrawerOpen(false);
|
||||
}}
|
||||
role="tab"
|
||||
aria-selected={botCompactPanelTab === 'chat'}
|
||||
aria-selected={activeCompactPanelTab === 'chat'}
|
||||
>
|
||||
<MessageSquareText size={16} />
|
||||
<span>{botPanelLabels.chat}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`app-bot-panel-drawer-item ${botCompactPanelTab === 'runtime' ? 'is-active' : ''}`}
|
||||
className={`app-bot-panel-drawer-item ${activeCompactPanelTab === 'runtime' ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setBotCompactPanelTab('runtime');
|
||||
setBotPanelDrawerOpen(false);
|
||||
}}
|
||||
role="tab"
|
||||
aria-selected={botCompactPanelTab === 'runtime'}
|
||||
aria-selected={activeCompactPanelTab === 'runtime'}
|
||||
>
|
||||
<Activity size={16} />
|
||||
<span>{botPanelLabels.runtime}</span>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,6 @@ import { useAppStore } from '../store/appStore';
|
|||
import type { BotState } from '../types/bot';
|
||||
import {
|
||||
BOT_AUTH_INVALID_EVENT,
|
||||
clearBotAccessPassword,
|
||||
getBotAccessPassword,
|
||||
setBotAccessPassword,
|
||||
} from '../utils/botAccess';
|
||||
|
||||
interface BotRouteAccessGateProps {
|
||||
|
|
@ -133,32 +130,6 @@ export function BotRouteAccessGate({
|
|||
return () => window.removeEventListener(BOT_AUTH_INVALID_EVENT, handleBotAuthInvalid as EventListener);
|
||||
}, [copy.errorExpired, normalizedBotId, passwordEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedBotId || !passwordEnabled || unlocked) return;
|
||||
const stored = getBotAccessPassword(normalizedBotId);
|
||||
if (!stored) return;
|
||||
let alive = true;
|
||||
const boot = async () => {
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/login`, { password: stored });
|
||||
if (!alive) return;
|
||||
setBotAccessPassword(normalizedBotId, stored);
|
||||
setUnlocked(true);
|
||||
setPassword('');
|
||||
setPasswordError('');
|
||||
await refreshBotDetail();
|
||||
} catch {
|
||||
clearBotAccessPassword(normalizedBotId);
|
||||
if (!alive) return;
|
||||
setPasswordError(copy.errorInvalid);
|
||||
}
|
||||
};
|
||||
void boot();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [copy.errorInvalid, normalizedBotId, passwordEnabled, refreshBotDetail, unlocked]);
|
||||
|
||||
const unlockBot = async () => {
|
||||
const entered = String(password || '').trim();
|
||||
if (!entered || !normalizedBotId) {
|
||||
|
|
@ -168,13 +139,11 @@ export function BotRouteAccessGate({
|
|||
setSubmitting(true);
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/login`, { password: entered });
|
||||
setBotAccessPassword(normalizedBotId, entered);
|
||||
setPasswordError('');
|
||||
setUnlocked(true);
|
||||
setPassword('');
|
||||
await refreshBotDetail();
|
||||
} catch {
|
||||
clearBotAccessPassword(normalizedBotId);
|
||||
setPasswordError(copy.errorInvalid);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
|
|
@ -184,10 +153,13 @@ export function BotRouteAccessGate({
|
|||
const shouldPromptPassword = Boolean(
|
||||
normalizedBotId && passwordEnabled && !authChecking && !unlocked,
|
||||
);
|
||||
const canRenderChildren = normalizedBotId
|
||||
? (!authChecking && (unlocked || !passwordEnabled))
|
||||
: true;
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{canRenderChildren ? children : null}
|
||||
{shouldPromptPassword ? (
|
||||
<div className="modal-mask app-modal-mask">
|
||||
<div className="app-login-card" onClick={(event) => event.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,6 @@ import { pickLocale } from '../i18n';
|
|||
import { useAppStore } from '../store/appStore';
|
||||
import {
|
||||
PANEL_AUTH_INVALID_EVENT,
|
||||
clearPanelAccessPassword,
|
||||
getPanelAccessPassword,
|
||||
setPanelAccessPassword,
|
||||
} from '../utils/panelAccess';
|
||||
|
||||
interface PanelLoginGateProps {
|
||||
|
|
@ -46,9 +43,10 @@ export function PanelLoginGate({
|
|||
let alive = true;
|
||||
const boot = async () => {
|
||||
try {
|
||||
const status = await axios.get<{ enabled: boolean }>(`${APP_ENDPOINTS.apiBase}/panel/auth/status`);
|
||||
const status = await axios.get<{ enabled?: boolean; authenticated?: boolean }>(`${APP_ENDPOINTS.apiBase}/panel/auth/status`);
|
||||
if (!alive) return;
|
||||
const enabled = Boolean(status.data?.enabled);
|
||||
const authenticated = Boolean(status.data?.authenticated);
|
||||
if (!enabled) {
|
||||
setRequired(false);
|
||||
setAuthenticated(true);
|
||||
|
|
@ -56,25 +54,15 @@ export function PanelLoginGate({
|
|||
return;
|
||||
}
|
||||
setRequired(true);
|
||||
const stored = getPanelAccessPassword();
|
||||
if (!stored) {
|
||||
if (!authenticated) {
|
||||
setAuthenticated(false);
|
||||
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(isZh ? '面板访问密码错误,请重新输入。' : 'Invalid panel access password. Please try again.');
|
||||
} finally {
|
||||
if (alive) setChecking(false);
|
||||
}
|
||||
setAuthenticated(true);
|
||||
setChecking(false);
|
||||
} catch {
|
||||
if (!alive) return;
|
||||
clearPanelAccessPassword();
|
||||
setRequired(true);
|
||||
setAuthenticated(false);
|
||||
setError(
|
||||
|
|
@ -118,12 +106,13 @@ export function PanelLoginGate({
|
|||
setError('');
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: next });
|
||||
setPanelAccessPassword(next);
|
||||
setAuthenticated(true);
|
||||
} catch (error: any) {
|
||||
clearPanelAccessPassword();
|
||||
} catch (error: unknown) {
|
||||
const resolvedError = (error && typeof error === 'object'
|
||||
? error
|
||||
: {}) as { response?: { data?: { detail?: string } } };
|
||||
setError(
|
||||
error?.response?.data?.detail
|
||||
resolvedError.response?.data?.detail
|
||||
|| (isZh ? '面板访问密码错误。' : 'Invalid panel access password.'),
|
||||
);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -40,10 +40,11 @@ export function DrawerShell({
|
|||
bodyClassName,
|
||||
}: DrawerShellProps) {
|
||||
const [mounted, setMounted] = useState(open);
|
||||
const [visible, setVisible] = useState(open);
|
||||
const visible = open;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
if (!mounted) return undefined;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
|
|
@ -55,27 +56,27 @@ export function DrawerShell({
|
|||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
}, [mounted, onClose, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
if (mounted) return undefined;
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
setVisible(true);
|
||||
setMounted(true);
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
if (!mounted) return undefined;
|
||||
const timerId = window.setTimeout(() => {
|
||||
setMounted(false);
|
||||
}, DRAWER_ANIMATION_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timerId);
|
||||
};
|
||||
}, [open]);
|
||||
}, [mounted, open]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
.ops-searchbar {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ops-searchbar-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ops-autofill-trap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ops-search-input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ops-search-input-with-icon {
|
||||
padding-right: 38px;
|
||||
}
|
||||
|
||||
.ops-search-inline-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--icon);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ops-search-inline-btn:hover {
|
||||
border-color: color-mix(in oklab, var(--brand) 56%, var(--line) 44%);
|
||||
background: color-mix(in oklab, var(--brand-soft) 42%, var(--panel-soft) 58%);
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
import './ProtectedSearchInput.css';
|
||||
|
||||
interface ProtectedSearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
|
|
@ -47,16 +49,18 @@ export function ProtectedSearchInput({
|
|||
const hasValue = currentValue.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceMs <= 0) return;
|
||||
if (value === latestExternalValueRef.current) return;
|
||||
latestExternalValueRef.current = value;
|
||||
setDraftValue(value);
|
||||
}, [debounceMs, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceMs > 0) return;
|
||||
setDraftValue(value);
|
||||
if (debounceMs <= 0) {
|
||||
latestExternalValueRef.current = value;
|
||||
return undefined;
|
||||
}
|
||||
if (value === latestExternalValueRef.current) return undefined;
|
||||
latestExternalValueRef.current = value;
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
setDraftValue(value);
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [debounceMs, value]);
|
||||
|
||||
useEffect(() => () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,52 @@ import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn';
|
|||
import { botsSyncEn } from '../i18n/bots-sync.en';
|
||||
import { buildMonitorWsUrl, notifyBotAuthInvalid } from '../utils/botAccess';
|
||||
|
||||
interface BotMessageRow {
|
||||
id?: unknown;
|
||||
role?: unknown;
|
||||
text?: unknown;
|
||||
media?: unknown;
|
||||
ts?: unknown;
|
||||
feedback?: unknown;
|
||||
}
|
||||
|
||||
interface BotMessagesPageResponse {
|
||||
items?: BotMessageRow[];
|
||||
}
|
||||
|
||||
interface RequestErrorShape {
|
||||
response?: {
|
||||
status?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface MonitorEventPayload {
|
||||
state?: unknown;
|
||||
action_msg?: unknown;
|
||||
msg?: unknown;
|
||||
text?: unknown;
|
||||
content?: unknown;
|
||||
media?: unknown;
|
||||
message_id?: unknown;
|
||||
command?: unknown;
|
||||
}
|
||||
|
||||
interface MonitorWsMessage {
|
||||
type?: unknown;
|
||||
channel?: unknown;
|
||||
source?: unknown;
|
||||
payload?: MonitorEventPayload | null;
|
||||
state?: unknown;
|
||||
action_msg?: unknown;
|
||||
msg?: unknown;
|
||||
text?: unknown;
|
||||
media?: unknown;
|
||||
message_id?: unknown;
|
||||
content?: unknown;
|
||||
is_progress?: unknown;
|
||||
is_tool?: unknown;
|
||||
}
|
||||
|
||||
function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' {
|
||||
const s = (v || '').toUpperCase();
|
||||
if (s === 'THINKING' || s === 'TOOL_CALL' || s === 'SUCCESS' || s === 'ERROR') return s;
|
||||
|
|
@ -98,7 +144,7 @@ export function useBotsSync(forcedBotId?: string) {
|
|||
const target = String(botId || '').trim();
|
||||
if (!target) return;
|
||||
try {
|
||||
const res = await axios.get<{ items?: any[] }>(`${APP_ENDPOINTS.apiBase}/bots/${target}/messages/page`);
|
||||
const res = await axios.get<BotMessagesPageResponse>(`${APP_ENDPOINTS.apiBase}/bots/${target}/messages/page`);
|
||||
const rows = Array.isArray(res.data?.items) ? res.data.items : [];
|
||||
const latestPage: ChatMessage[] = rows
|
||||
.map((row) => {
|
||||
|
|
@ -152,8 +198,9 @@ export function useBotsSync(forcedBotId?: string) {
|
|||
}
|
||||
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
|
||||
setBots(res.data);
|
||||
} catch (error: any) {
|
||||
const status = Number(error?.response?.status || 0);
|
||||
} catch (error: unknown) {
|
||||
const resolvedError = (error && typeof error === 'object' ? error : {}) as RequestErrorShape;
|
||||
const status = Number(resolvedError.response?.status || 0);
|
||||
if (forced && status === 401) {
|
||||
setBots([]);
|
||||
return;
|
||||
|
|
@ -240,9 +287,9 @@ export function useBotsSync(forcedBotId?: string) {
|
|||
void syncBotMessages(bot.id);
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
let data: any;
|
||||
let data: MonitorWsMessage;
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
data = JSON.parse(event.data) as MonitorWsMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
|
@ -374,7 +421,7 @@ export function useBotsSync(forcedBotId?: string) {
|
|||
return () => {
|
||||
// no-op: clean in unmount effect below
|
||||
};
|
||||
}, [activeBots, addBotEvent, addBotLog, addBotMessage, isZh, syncBotMessages, t.progress, t.replied, t.stateUpdated, updateBotState]);
|
||||
}, [activeBots, addBotEvent, addBotLog, addBotMessage, forced, isZh, syncBotMessages, t.progress, t.replied, t.stateUpdated, updateBotState]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { BotDashboardView } from './components/BotDashboardView';
|
||||
import { useBotDashboardModule } from './hooks/useBotDashboardModule';
|
||||
import type { BotDashboardModuleProps } from './types';
|
||||
import {
|
||||
formatBytes,
|
||||
formatWorkspaceTime,
|
||||
} from './utils';
|
||||
import { useBotDashboardViewProps } from './useBotDashboardViewProps';
|
||||
import './BotDashboardModule.css';
|
||||
import './components/DashboardShared.css';
|
||||
|
||||
|
|
@ -21,487 +18,11 @@ export function BotDashboardModule({
|
|||
compactPanelTab: compactPanelTabProp,
|
||||
onCompactPanelTabChange,
|
||||
});
|
||||
const runtimeMoreLabel = dashboard.isZh ? '更多' : 'More';
|
||||
|
||||
const botListPanelProps = {
|
||||
bots: dashboard.bots,
|
||||
filteredBots: dashboard.filteredBots,
|
||||
pagedBots: dashboard.pagedBots,
|
||||
selectedBotId: dashboard.selectedBotId,
|
||||
normalizedBotListQuery: dashboard.normalizedBotListQuery,
|
||||
botListQuery: dashboard.botListQuery,
|
||||
botListPageSizeReady: dashboard.botListPageSizeReady,
|
||||
botListPage: dashboard.botListPage,
|
||||
botListTotalPages: dashboard.botListTotalPages,
|
||||
botListMenuOpen: dashboard.botListMenuOpen,
|
||||
controlStateByBot: dashboard.controlStateByBot,
|
||||
operatingBotId: dashboard.operatingBotId,
|
||||
compactMode: dashboard.compactMode,
|
||||
isZh: dashboard.isZh,
|
||||
isLoadingTemplates: dashboard.isLoadingTemplates,
|
||||
isBatchOperating: dashboard.isBatchOperating,
|
||||
labels: {
|
||||
batchStart: dashboard.t.batchStart,
|
||||
batchStop: dashboard.t.batchStop,
|
||||
botSearchNoResult: dashboard.t.botSearchNoResult,
|
||||
botSearchPlaceholder: dashboard.t.botSearchPlaceholder,
|
||||
clearSearch: dashboard.t.clearSearch,
|
||||
delete: dashboard.t.delete,
|
||||
disable: dashboard.t.disable,
|
||||
disabled: dashboard.t.disabled,
|
||||
enable: dashboard.t.enable,
|
||||
extensions: dashboard.t.extensions,
|
||||
image: dashboard.t.image,
|
||||
manageImages: dashboard.t.manageImages,
|
||||
newBot: dashboard.t.newBot,
|
||||
paginationNext: dashboard.t.paginationNext,
|
||||
paginationPage: dashboard.t.paginationPage,
|
||||
paginationPrev: dashboard.t.paginationPrev,
|
||||
searchAction: dashboard.t.searchAction,
|
||||
start: dashboard.t.start,
|
||||
stop: dashboard.t.stop,
|
||||
syncingPageSize: dashboard.t.syncingPageSize,
|
||||
templateManager: dashboard.t.templateManager,
|
||||
titleBots: dashboard.t.titleBots,
|
||||
},
|
||||
botSearchInputName: dashboard.botSearchInputName,
|
||||
botListMenuRef: dashboard.botListMenuRef,
|
||||
onOpenCreateWizard: () => dashboard.setShowCreateBotModal(true),
|
||||
const viewProps = useBotDashboardViewProps({
|
||||
dashboard,
|
||||
onOpenImageFactory,
|
||||
onToggleMenu: () => dashboard.setBotListMenuOpen((prev) => !prev),
|
||||
onCloseMenu: () => dashboard.setBotListMenuOpen(false),
|
||||
onOpenTemplateManager: dashboard.openTemplateManager,
|
||||
onBatchStartBots: dashboard.batchStartBots,
|
||||
onBatchStopBots: dashboard.batchStopBots,
|
||||
onBotListQueryChange: dashboard.setBotListQuery,
|
||||
onBotListPageChange: dashboard.setBotListPage,
|
||||
onSelectBot: dashboard.setSelectedBotId,
|
||||
onSetCompactPanelTab: (tab: 'chat' | 'runtime') => dashboard.setCompactPanelTab(tab),
|
||||
onSetBotEnabled: dashboard.setBotEnabled,
|
||||
onStartBot: dashboard.startBot,
|
||||
onStopBot: dashboard.stopBot,
|
||||
onOpenResourceMonitor: dashboard.openResourceMonitor,
|
||||
onRemoveBot: dashboard.removeBot,
|
||||
};
|
||||
});
|
||||
|
||||
const topicFeedPanelProps = {
|
||||
isZh: dashboard.isZh,
|
||||
topicKey: dashboard.topicFeedTopicKey,
|
||||
topicOptions: dashboard.activeTopicOptions,
|
||||
topicState: dashboard.topicPanelState,
|
||||
items: dashboard.topicFeedItems,
|
||||
loading: dashboard.topicFeedLoading,
|
||||
loadingMore: dashboard.topicFeedLoadingMore,
|
||||
nextCursor: dashboard.topicFeedNextCursor,
|
||||
error: dashboard.topicFeedError,
|
||||
readSavingById: dashboard.topicFeedReadSavingById,
|
||||
deleteSavingById: dashboard.topicFeedDeleteSavingById,
|
||||
onTopicChange: dashboard.setTopicFeedTopicKey,
|
||||
onRefresh: () => void dashboard.loadTopicFeed({ append: false, topicKey: dashboard.topicFeedTopicKey }),
|
||||
onMarkRead: (itemId: number) => void dashboard.markTopicFeedItemRead(itemId),
|
||||
onDeleteItem: (item: (typeof dashboard.topicFeedItems)[number]) => void dashboard.deleteTopicFeedItem(item),
|
||||
onLoadMore: () => void dashboard.loadTopicFeed({ append: true, cursor: dashboard.topicFeedNextCursor, topicKey: dashboard.topicFeedTopicKey }),
|
||||
onOpenWorkspacePath: (path: string) => void dashboard.openWorkspacePathFromChat(path),
|
||||
resolveWorkspaceMediaSrc: dashboard.resolveWorkspaceMediaSrc,
|
||||
onOpenTopicSettings: dashboard.openTopicConfigModal,
|
||||
onDetailOpenChange: dashboard.setTopicDetailOpen,
|
||||
layout: 'panel' as const,
|
||||
};
|
||||
|
||||
const dashboardChatPanelProps = {
|
||||
conversation: dashboard.conversation,
|
||||
isZh: dashboard.isZh,
|
||||
labels: {
|
||||
badReply: dashboard.t.badReply,
|
||||
botDisabledHint: dashboard.t.botDisabledHint,
|
||||
botStarting: dashboard.t.botStarting,
|
||||
botStopping: dashboard.t.botStopping,
|
||||
chatDisabled: dashboard.t.chatDisabled,
|
||||
controlCommandsHide: dashboard.t.controlCommandsHide,
|
||||
controlCommandsShow: dashboard.t.controlCommandsShow,
|
||||
copyPrompt: dashboard.t.copyPrompt,
|
||||
copyReply: dashboard.t.copyReply,
|
||||
deleteMessage: dashboard.t.deleteMessage,
|
||||
disabledPlaceholder: dashboard.t.disabledPlaceholder,
|
||||
download: dashboard.t.download,
|
||||
editPrompt: dashboard.t.editPrompt,
|
||||
fileNotPreviewable: dashboard.t.fileNotPreviewable,
|
||||
goodReply: dashboard.t.goodReply,
|
||||
inputPlaceholder: dashboard.t.inputPlaceholder,
|
||||
interrupt: dashboard.t.interrupt,
|
||||
noConversation: dashboard.t.noConversation,
|
||||
previewTitle: dashboard.t.previewTitle,
|
||||
stagedSubmissionAttachmentCount: dashboard.t.stagedSubmissionAttachmentCount,
|
||||
stagedSubmissionEmpty: dashboard.t.stagedSubmissionEmpty,
|
||||
stagedSubmissionRestore: dashboard.t.stagedSubmissionRestore,
|
||||
stagedSubmissionRemove: dashboard.t.stagedSubmissionRemove,
|
||||
quoteReply: dashboard.t.quoteReply,
|
||||
quotedReplyLabel: dashboard.t.quotedReplyLabel,
|
||||
send: dashboard.t.send,
|
||||
thinking: dashboard.t.thinking,
|
||||
uploadFile: dashboard.t.uploadFile,
|
||||
uploadingFile: dashboard.t.uploadingFile,
|
||||
user: dashboard.t.user,
|
||||
voiceStart: dashboard.t.voiceStart,
|
||||
voiceStop: dashboard.t.voiceStop,
|
||||
voiceTranscribing: dashboard.t.voiceTranscribing,
|
||||
you: dashboard.t.you,
|
||||
},
|
||||
chatScrollRef: dashboard.chatScrollRef,
|
||||
onChatScroll: dashboard.onChatScroll,
|
||||
expandedProgressByKey: dashboard.expandedProgressByKey,
|
||||
expandedUserByKey: dashboard.expandedUserByKey,
|
||||
deletingMessageIdMap: dashboard.deletingMessageIdMap,
|
||||
feedbackSavingByMessageId: dashboard.feedbackSavingByMessageId,
|
||||
markdownComponents: dashboard.markdownComponents,
|
||||
workspaceDownloadExtensionSet: dashboard.workspaceDownloadExtensionSet,
|
||||
onToggleProgressExpand: dashboard.toggleProgressExpanded,
|
||||
onToggleUserExpand: dashboard.toggleUserExpanded,
|
||||
onEditUserPrompt: dashboard.editUserPrompt,
|
||||
onCopyUserPrompt: dashboard.copyUserPrompt,
|
||||
onDeleteConversationMessage: dashboard.deleteConversationMessage,
|
||||
onOpenWorkspacePath: dashboard.openWorkspacePathFromChat,
|
||||
onSubmitAssistantFeedback: dashboard.submitAssistantFeedback,
|
||||
onQuoteAssistantReply: dashboard.quoteAssistantReply,
|
||||
onCopyAssistantReply: dashboard.copyAssistantReply,
|
||||
isThinking: dashboard.isThinking,
|
||||
canChat: dashboard.canChat,
|
||||
isChatEnabled: dashboard.isChatEnabled,
|
||||
speechEnabled: dashboard.speechEnabled,
|
||||
selectedBotEnabled: dashboard.selectedBotEnabled,
|
||||
selectedBotControlState: dashboard.selectedBotControlState,
|
||||
quotedReply: dashboard.quotedReply,
|
||||
onClearQuotedReply: () => dashboard.setQuotedReply(null),
|
||||
stagedSubmissions: dashboard.selectedBotStagedSubmissions,
|
||||
onRestoreStagedSubmission: dashboard.restoreStagedSubmission,
|
||||
onRemoveStagedSubmission: dashboard.removeStagedSubmission,
|
||||
pendingAttachments: dashboard.pendingAttachments,
|
||||
onRemovePendingAttachment: (path: string) =>
|
||||
dashboard.setPendingAttachments((prev) => prev.filter((value) => value !== path)),
|
||||
attachmentUploadPercent: dashboard.attachmentUploadPercent,
|
||||
isUploadingAttachments: dashboard.isUploadingAttachments,
|
||||
filePickerRef: dashboard.filePickerRef,
|
||||
allowedAttachmentExtensions: dashboard.allowedAttachmentExtensions,
|
||||
onPickAttachments: dashboard.onPickAttachments,
|
||||
controlCommandPanelOpen: dashboard.controlCommandPanelOpen,
|
||||
controlCommandPanelRef: dashboard.controlCommandPanelRef,
|
||||
onToggleControlCommandPanel: () => {
|
||||
dashboard.setChatDatePickerOpen(false);
|
||||
dashboard.setControlCommandPanelOpen((prev) => !prev);
|
||||
},
|
||||
activeControlCommand: dashboard.activeControlCommand,
|
||||
canSendControlCommand: dashboard.canSendControlCommand,
|
||||
isInterrupting: dashboard.isInterrupting,
|
||||
onSendControlCommand: dashboard.sendControlCommand,
|
||||
onInterruptExecution: dashboard.interruptExecution,
|
||||
chatDateTriggerRef: dashboard.chatDateTriggerRef,
|
||||
hasSelectedBot: Boolean(dashboard.selectedBotId),
|
||||
chatDateJumping: dashboard.chatDateJumping,
|
||||
onToggleChatDatePicker: dashboard.toggleChatDatePicker,
|
||||
chatDatePickerOpen: dashboard.chatDatePickerOpen,
|
||||
chatDatePanelPosition: dashboard.chatDatePanelPosition,
|
||||
chatDateValue: dashboard.chatDateValue,
|
||||
onChatDateValueChange: dashboard.setChatDateValue,
|
||||
onCloseChatDatePicker: () => dashboard.setChatDatePickerOpen(false),
|
||||
onJumpConversationToDate: dashboard.jumpConversationToDate,
|
||||
command: dashboard.command,
|
||||
onCommandChange: dashboard.setCommand,
|
||||
composerTextareaRef: dashboard.composerTextareaRef,
|
||||
onComposerKeyDown: dashboard.onComposerKeyDown,
|
||||
isVoiceRecording: dashboard.isVoiceRecording,
|
||||
isVoiceTranscribing: dashboard.isVoiceTranscribing,
|
||||
isCompactMobile: dashboard.isCompactMobile,
|
||||
voiceCountdown: dashboard.voiceCountdown,
|
||||
onVoiceInput: dashboard.onVoiceInput,
|
||||
onTriggerPickAttachments: dashboard.triggerPickAttachments,
|
||||
submitActionMode: dashboard.submitActionMode,
|
||||
onSubmitAction: dashboard.handlePrimarySubmitAction,
|
||||
};
|
||||
|
||||
const runtimePanelProps = {
|
||||
selectedBot: dashboard.selectedBot,
|
||||
selectedBotEnabled: dashboard.selectedBotEnabled,
|
||||
operatingBotId: dashboard.operatingBotId,
|
||||
runtimeMenuOpen: dashboard.runtimeMenuOpen,
|
||||
runtimeMenuRef: dashboard.runtimeMenuRef,
|
||||
displayState: dashboard.displayState,
|
||||
workspaceError: dashboard.workspaceError,
|
||||
workspacePathDisplay: dashboard.workspacePathDisplay,
|
||||
workspaceLoading: dashboard.workspaceLoading,
|
||||
workspaceQuery: dashboard.workspaceQuery,
|
||||
workspaceSearchInputName: dashboard.workspaceSearchInputName,
|
||||
workspaceSearchLoading: dashboard.workspaceSearchLoading,
|
||||
filteredWorkspaceEntries: dashboard.filteredWorkspaceEntries,
|
||||
workspaceParentPath: dashboard.workspaceParentPath,
|
||||
workspaceFileLoading: dashboard.workspaceFileLoading,
|
||||
workspaceDownloadExtensionSet: dashboard.workspaceDownloadExtensionSet,
|
||||
workspaceAutoRefresh: dashboard.workspaceAutoRefresh,
|
||||
isCompactHidden: dashboard.compactMode && (dashboard.isCompactListPage || dashboard.compactPanelTab !== 'runtime'),
|
||||
showCompactSurface: dashboard.showCompactBotPageClose,
|
||||
emptyStateText: dashboard.forcedBotMissing ? `${dashboard.t.noTelemetry}: ${String(dashboard.forcedBotId).trim()}` : dashboard.t.noTelemetry,
|
||||
labels: {
|
||||
agent: dashboard.t.agent,
|
||||
autoRefresh: dashboard.lc.autoRefresh,
|
||||
base: dashboard.t.base,
|
||||
channels: dashboard.t.channels,
|
||||
clearHistory: dashboard.t.clearHistory,
|
||||
clearSearch: dashboard.t.clearSearch,
|
||||
cronViewer: dashboard.t.cronViewer,
|
||||
download: dashboard.t.download,
|
||||
emptyDir: dashboard.t.emptyDir,
|
||||
envParams: dashboard.t.envParams,
|
||||
exportHistory: dashboard.t.exportHistory,
|
||||
fileNotPreviewable: dashboard.t.fileNotPreviewable,
|
||||
folder: dashboard.t.folder,
|
||||
goUp: dashboard.t.goUp,
|
||||
goUpTitle: dashboard.t.goUpTitle,
|
||||
loadingDir: dashboard.t.loadingDir,
|
||||
mcp: dashboard.t.mcp,
|
||||
more: runtimeMoreLabel,
|
||||
noPreviewFile: dashboard.t.noPreviewFile,
|
||||
openingPreview: dashboard.t.openingPreview,
|
||||
openFolderTitle: dashboard.t.openFolderTitle,
|
||||
params: dashboard.t.params,
|
||||
previewTitle: dashboard.t.previewTitle,
|
||||
refreshHint: dashboard.lc.refreshHint,
|
||||
restart: dashboard.t.restart,
|
||||
runtime: dashboard.t.runtime,
|
||||
searchAction: dashboard.t.searchAction,
|
||||
skills: dashboard.t.skills,
|
||||
topic: dashboard.t.topic,
|
||||
workspaceHint: dashboard.t.workspaceHint,
|
||||
workspaceOutputs: dashboard.t.workspaceOutputs,
|
||||
workspaceSearchNoResult: dashboard.t.workspaceSearchNoResult,
|
||||
workspaceSearchPlaceholder: dashboard.t.workspaceSearchPlaceholder,
|
||||
},
|
||||
onRestartBot: dashboard.restartBot,
|
||||
onToggleRuntimeMenu: () => dashboard.setRuntimeMenuOpen((prev) => !prev),
|
||||
onOpenBaseConfig: dashboard.openBaseConfigModal,
|
||||
onOpenParamConfig: dashboard.openParamConfigModal,
|
||||
onOpenChannelConfig: dashboard.openChannelConfigModal,
|
||||
onOpenTopicConfig: dashboard.openTopicConfigModal,
|
||||
onOpenEnvParams: dashboard.openEnvParamsConfigModal,
|
||||
onOpenSkills: dashboard.openSkillsConfigModal,
|
||||
onOpenMcpConfig: dashboard.openMcpConfigModal,
|
||||
onOpenCronJobs: dashboard.openCronJobsModal,
|
||||
onOpenAgentFiles: dashboard.openAgentFilesModal,
|
||||
onExportHistory: () => {
|
||||
dashboard.setRuntimeMenuOpen(false);
|
||||
dashboard.exportConversationJson();
|
||||
},
|
||||
onClearHistory: async () => {
|
||||
dashboard.setRuntimeMenuOpen(false);
|
||||
await dashboard.clearConversationHistory();
|
||||
},
|
||||
onRefreshWorkspace: () => dashboard.selectedBot ? dashboard.loadWorkspaceTree(dashboard.selectedBot.id, dashboard.workspaceCurrentPath) : undefined,
|
||||
onWorkspaceQueryChange: dashboard.setWorkspaceQuery,
|
||||
onWorkspaceQueryClear: () => dashboard.setWorkspaceQuery(''),
|
||||
onWorkspaceQuerySearch: () => dashboard.setWorkspaceQuery((value) => value.trim()),
|
||||
onToggleWorkspaceAutoRefresh: () => dashboard.setWorkspaceAutoRefresh((prev) => !prev),
|
||||
onLoadWorkspaceTree: dashboard.loadWorkspaceTree,
|
||||
onOpenWorkspaceFilePreview: dashboard.openWorkspaceFilePreview,
|
||||
onShowWorkspaceHoverCard: dashboard.showWorkspaceHoverCard,
|
||||
onHideWorkspaceHoverCard: dashboard.hideWorkspaceHoverCard,
|
||||
};
|
||||
|
||||
const dashboardModalStackProps = {
|
||||
resourceMonitorModal: {
|
||||
open: dashboard.showResourceModal,
|
||||
botId: dashboard.resourceBotId,
|
||||
resourceBot: dashboard.resourceBot,
|
||||
resourceSnapshot: dashboard.resourceSnapshot,
|
||||
resourceLoading: dashboard.resourceLoading,
|
||||
resourceError: dashboard.resourceError,
|
||||
isZh: dashboard.isZh,
|
||||
closeLabel: dashboard.t.close,
|
||||
onClose: () => dashboard.setShowResourceModal(false),
|
||||
onRefresh: dashboard.loadResourceSnapshot,
|
||||
},
|
||||
baseConfigModal: {
|
||||
open: dashboard.showBaseModal,
|
||||
selectedBotId: dashboard.selectedBot?.id || '',
|
||||
editForm: dashboard.editForm,
|
||||
paramDraft: dashboard.paramDraft,
|
||||
baseImageOptions: dashboard.baseImageOptions,
|
||||
systemTimezoneOptions: dashboard.systemTimezoneOptions,
|
||||
defaultSystemTimezone: dashboard.defaultSystemTimezone,
|
||||
passwordToggleLabels: dashboard.passwordToggleLabels,
|
||||
isSaving: dashboard.isSaving,
|
||||
isZh: dashboard.isZh,
|
||||
labels: {
|
||||
accessPassword: dashboard.t.accessPassword,
|
||||
accessPasswordPlaceholder: dashboard.t.accessPasswordPlaceholder,
|
||||
baseConfig: dashboard.t.baseConfig,
|
||||
baseImageReadonly: dashboard.t.baseImageReadonly,
|
||||
botIdReadonly: dashboard.t.botIdReadonly,
|
||||
botName: dashboard.t.botName,
|
||||
botNamePlaceholder: dashboard.t.botNamePlaceholder,
|
||||
cancel: dashboard.t.cancel,
|
||||
close: dashboard.t.close,
|
||||
save: dashboard.t.save,
|
||||
},
|
||||
onClose: () => dashboard.setShowBaseModal(false),
|
||||
onEditFormChange: dashboard.updateEditForm,
|
||||
onParamDraftChange: dashboard.updateParamDraft,
|
||||
onSave: () => dashboard.saveBot('base'),
|
||||
},
|
||||
paramConfigModal: {
|
||||
open: dashboard.showParamModal,
|
||||
editForm: dashboard.editForm,
|
||||
paramDraft: dashboard.paramDraft,
|
||||
passwordToggleLabels: dashboard.passwordToggleLabels,
|
||||
isZh: dashboard.isZh,
|
||||
isTestingProvider: dashboard.isTestingProvider,
|
||||
providerTestResult: dashboard.providerTestResult,
|
||||
isSaving: dashboard.isSaving,
|
||||
labels: {
|
||||
cancel: dashboard.t.cancel,
|
||||
close: dashboard.t.close,
|
||||
modelName: dashboard.t.modelName,
|
||||
modelNamePlaceholder: dashboard.t.modelNamePlaceholder,
|
||||
modelParams: dashboard.t.modelParams,
|
||||
newApiKey: dashboard.t.newApiKey,
|
||||
newApiKeyPlaceholder: dashboard.t.newApiKeyPlaceholder,
|
||||
saveParams: dashboard.t.saveParams,
|
||||
testModelConnection: dashboard.t.testModelConnection,
|
||||
testing: dashboard.t.testing,
|
||||
},
|
||||
onClose: () => dashboard.setShowParamModal(false),
|
||||
onEditFormChange: dashboard.updateEditForm,
|
||||
onParamDraftChange: dashboard.updateParamDraft,
|
||||
onProviderChange: dashboard.onBaseProviderChange,
|
||||
onTestProviderConnection: dashboard.testProviderConnection,
|
||||
onSave: () => dashboard.saveBot('params'),
|
||||
},
|
||||
channelConfigModal: dashboard.channelConfigModalProps,
|
||||
topicConfigModal: dashboard.topicConfigModalProps,
|
||||
skillsModal: dashboard.skillsModalProps,
|
||||
skillMarketInstallModal: dashboard.skillMarketInstallModalProps,
|
||||
mcpConfigModal: dashboard.mcpConfigModalProps,
|
||||
envParamsModal: dashboard.envParamsModalProps,
|
||||
cronJobsModal: dashboard.cronJobsModalProps,
|
||||
templateManagerModal: {
|
||||
open: dashboard.showTemplateModal,
|
||||
templateTab: dashboard.templateTab,
|
||||
templateAgentCount: dashboard.templateAgentCount,
|
||||
templateTopicCount: dashboard.templateTopicCount,
|
||||
templateAgentText: dashboard.templateAgentText,
|
||||
templateTopicText: dashboard.templateTopicText,
|
||||
isSavingTemplates: dashboard.isSavingTemplates,
|
||||
labels: {
|
||||
cancel: dashboard.t.cancel,
|
||||
close: dashboard.t.close,
|
||||
processing: dashboard.t.processing,
|
||||
save: dashboard.t.save,
|
||||
templateManagerTitle: dashboard.t.templateManagerTitle,
|
||||
templateTabAgent: dashboard.t.templateTabAgent,
|
||||
templateTabTopic: dashboard.t.templateTabTopic,
|
||||
},
|
||||
onClose: () => dashboard.setShowTemplateModal(false),
|
||||
onTemplateTabChange: dashboard.setTemplateTab,
|
||||
onTemplateAgentTextChange: dashboard.setTemplateAgentText,
|
||||
onTemplateTopicTextChange: dashboard.setTemplateTopicText,
|
||||
onSave: dashboard.saveTemplateManager,
|
||||
},
|
||||
agentFilesModal: {
|
||||
open: dashboard.showAgentModal,
|
||||
agentTab: dashboard.agentTab,
|
||||
tabValue: String(dashboard.editForm[dashboard.agentFieldByTab[dashboard.agentTab]]),
|
||||
isSaving: dashboard.isSaving,
|
||||
labels: {
|
||||
agentFiles: dashboard.t.agentFiles,
|
||||
cancel: dashboard.t.cancel,
|
||||
close: dashboard.t.close,
|
||||
saveFiles: dashboard.t.saveFiles,
|
||||
},
|
||||
onClose: () => dashboard.setShowAgentModal(false),
|
||||
onAgentTabChange: dashboard.setAgentTab,
|
||||
onTabValueChange: (nextValue: string) => dashboard.updateAgentTabValue(dashboard.agentTab, nextValue),
|
||||
onSave: () => dashboard.saveBot('agent'),
|
||||
},
|
||||
runtimeActionModal: {
|
||||
open: dashboard.showRuntimeActionModal,
|
||||
runtimeAction: dashboard.runtimeAction,
|
||||
labels: {
|
||||
close: dashboard.t.close,
|
||||
lastAction: dashboard.t.lastAction,
|
||||
},
|
||||
onClose: () => dashboard.setShowRuntimeActionModal(false),
|
||||
},
|
||||
workspacePreviewModal: {
|
||||
isZh: dashboard.isZh,
|
||||
labels: {
|
||||
cancel: dashboard.t.cancel,
|
||||
close: dashboard.t.close,
|
||||
copyAddress: dashboard.t.copyAddress,
|
||||
download: dashboard.t.download,
|
||||
editFile: dashboard.t.editFile,
|
||||
filePreview: dashboard.t.filePreview,
|
||||
fileTruncated: dashboard.t.fileTruncated,
|
||||
save: dashboard.t.save,
|
||||
},
|
||||
preview: dashboard.workspacePreview,
|
||||
previewFullscreen: dashboard.workspacePreviewFullscreen,
|
||||
previewEditorEnabled: dashboard.workspacePreviewEditorEnabled,
|
||||
previewCanEdit: dashboard.workspacePreviewCanEdit,
|
||||
previewDraft: dashboard.workspacePreviewDraft,
|
||||
previewSaving: dashboard.workspacePreviewSaving,
|
||||
markdownComponents: dashboard.workspacePreviewMarkdownComponents,
|
||||
onClose: dashboard.closeWorkspacePreview,
|
||||
onToggleFullscreen: () => dashboard.setWorkspacePreviewFullscreen((prev) => !prev),
|
||||
onCopyPreviewPath: dashboard.copyWorkspacePreviewPath,
|
||||
onCopyPreviewUrl: dashboard.copyWorkspacePreviewUrl,
|
||||
onPreviewDraftChange: dashboard.setWorkspacePreviewDraft,
|
||||
onSavePreviewMarkdown: dashboard.saveWorkspacePreviewMarkdown,
|
||||
onEnterEditMode: () => dashboard.setWorkspacePreviewMode('edit'),
|
||||
onExitEditMode: () => {
|
||||
dashboard.setWorkspacePreviewDraft(dashboard.workspacePreview?.content || '');
|
||||
dashboard.setWorkspacePreviewMode('preview');
|
||||
},
|
||||
getWorkspaceDownloadHref: dashboard.getWorkspaceDownloadHref,
|
||||
getWorkspaceRawHref: dashboard.getWorkspaceRawHref,
|
||||
},
|
||||
workspaceHoverCard: {
|
||||
state: dashboard.workspaceHoverCard,
|
||||
isZh: dashboard.isZh,
|
||||
formatWorkspaceTime,
|
||||
formatBytes,
|
||||
},
|
||||
};
|
||||
|
||||
const createBotModalProps = {
|
||||
open: dashboard.showCreateBotModal,
|
||||
onClose: () => dashboard.setShowCreateBotModal(false),
|
||||
onCreated: () => {
|
||||
void dashboard.refresh();
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<BotDashboardView
|
||||
compactMode={dashboard.compactMode}
|
||||
hasForcedBot={dashboard.hasForcedBot}
|
||||
showBotListPanel={dashboard.showBotListPanel}
|
||||
botListPanelProps={botListPanelProps}
|
||||
hasSelectedBot={Boolean(dashboard.selectedBot)}
|
||||
isCompactListPage={dashboard.isCompactListPage}
|
||||
compactPanelTab={dashboard.compactPanelTab}
|
||||
showCompactBotPageClose={dashboard.showCompactBotPageClose}
|
||||
forcedBotId={dashboard.forcedBotId}
|
||||
selectBotText={dashboard.forcedBotMissing ? `${dashboard.t.selectBot}: ${String(dashboard.forcedBotId).trim()}` : dashboard.t.selectBot}
|
||||
isZh={dashboard.isZh}
|
||||
runtimeViewMode={dashboard.runtimeViewMode}
|
||||
hasTopicUnread={dashboard.hasTopicUnread}
|
||||
onRuntimeViewModeChange={dashboard.setRuntimeViewMode}
|
||||
topicFeedPanelProps={topicFeedPanelProps}
|
||||
dashboardChatPanelProps={dashboardChatPanelProps}
|
||||
runtimePanelProps={runtimePanelProps}
|
||||
onCompactClose={() => {
|
||||
dashboard.setSelectedBotId('');
|
||||
dashboard.setCompactPanelTab('chat');
|
||||
}}
|
||||
dashboardModalStackProps={dashboardModalStackProps}
|
||||
createBotModalProps={createBotModalProps}
|
||||
/>
|
||||
);
|
||||
return <BotDashboardView {...viewProps} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ import { MessageCircle, MessageSquareText, X } from 'lucide-react';
|
|||
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import type { CompactPanelTab, RuntimeViewMode } from '../types';
|
||||
import { BotListPanel } from './BotListPanel';
|
||||
import { DashboardChatPanel } from './DashboardChatPanel';
|
||||
import { RuntimePanel } from './RuntimePanel';
|
||||
|
||||
const LazyBotListPanel = lazy(() =>
|
||||
import('./BotListPanel').then((module) => ({ default: module.BotListPanel })),
|
||||
);
|
||||
const LazyRuntimePanel = lazy(() =>
|
||||
import('./RuntimePanel').then((module) => ({ default: module.RuntimePanel })),
|
||||
);
|
||||
const LazyCreateBotWizardModal = lazy(() =>
|
||||
import('../../onboarding/CreateBotWizardModal').then((module) => ({ default: module.CreateBotWizardModal })),
|
||||
);
|
||||
|
|
@ -25,7 +29,7 @@ export interface BotDashboardViewProps {
|
|||
compactMode: boolean;
|
||||
hasForcedBot: boolean;
|
||||
showBotListPanel: boolean;
|
||||
botListPanelProps: ComponentProps<typeof BotListPanel>;
|
||||
botListPanelProps: Parameters<typeof import('./BotListPanel').BotListPanel>[0];
|
||||
hasSelectedBot: boolean;
|
||||
isCompactListPage: boolean;
|
||||
compactPanelTab: CompactPanelTab;
|
||||
|
|
@ -38,7 +42,7 @@ export interface BotDashboardViewProps {
|
|||
onRuntimeViewModeChange: (mode: RuntimeViewMode) => void;
|
||||
topicFeedPanelProps: TopicFeedPanelProps;
|
||||
dashboardChatPanelProps: ComponentProps<typeof DashboardChatPanel>;
|
||||
runtimePanelProps: ComponentProps<typeof RuntimePanel>;
|
||||
runtimePanelProps: Parameters<typeof import('./RuntimePanel').RuntimePanel>[0];
|
||||
onCompactClose: () => void;
|
||||
dashboardModalStackProps: DashboardModalStackProps;
|
||||
createBotModalProps: CreateBotWizardModalProps;
|
||||
|
|
@ -86,7 +90,11 @@ export function BotDashboardView({
|
|||
return (
|
||||
<>
|
||||
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}>
|
||||
{showBotListPanel ? <BotListPanel {...botListPanelProps} /> : null}
|
||||
{showBotListPanel ? (
|
||||
<Suspense fallback={<section className="panel stack ops-bot-list"><div className="ops-bot-list-empty">{isZh ? '读取 Bot 列表中...' : 'Loading bots...'}</div></section>}>
|
||||
<LazyBotListPanel {...botListPanelProps} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
<section className={`panel ops-chat-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'chat') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
|
||||
{hasSelectedBot ? (
|
||||
|
|
@ -137,7 +145,9 @@ export function BotDashboardView({
|
|||
)}
|
||||
</section>
|
||||
|
||||
<RuntimePanel {...runtimePanelProps} />
|
||||
<Suspense fallback={<section className={`panel stack ops-runtime-panel ${runtimePanelProps.isCompactHidden ? 'ops-compact-hidden' : ''} ${runtimePanelProps.showCompactSurface ? 'ops-compact-bot-surface' : ''}`}><div className="ops-panel-empty-copy">{isZh ? '读取运行面板中...' : 'Loading runtime...'}</div></section>}>
|
||||
<LazyRuntimePanel {...runtimePanelProps} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{showCompactBotPageClose ? (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Boxes, ChevronLeft, ChevronRight, EllipsisVertical, ExternalLink, FileText, Gauge, Lock, Plus, Power, Square, Trash2 } from 'lucide-react';
|
||||
import type { RefObject } from 'react';
|
||||
import { memo, type RefObject } from 'react';
|
||||
|
||||
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
|
|
@ -70,7 +70,7 @@ interface BotListPanelProps {
|
|||
onRemoveBot: (botId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function BotListPanel({
|
||||
export const BotListPanel = memo(function BotListPanel({
|
||||
bots,
|
||||
filteredBots,
|
||||
pagedBots,
|
||||
|
|
@ -360,4 +360,4 @@ export function BotListPanel({
|
|||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { workspaceFileAction } from '../../../shared/workspace/utils';
|
|||
import { formatDateInputValue } from '../chat/chatUtils';
|
||||
import type { DashboardChatPanelLabels } from './DashboardChatPanel.types';
|
||||
|
||||
const TODAY_DATE_INPUT_MAX = formatDateInputValue(Date.now());
|
||||
|
||||
interface DashboardChatComposerProps {
|
||||
isZh: boolean;
|
||||
labels: DashboardChatPanelLabels;
|
||||
|
|
@ -276,7 +278,7 @@ export function DashboardChatComposer({
|
|||
className="input ops-control-date-input"
|
||||
type="date"
|
||||
value={chatDateValue}
|
||||
max={formatDateInputValue(Date.now())}
|
||||
max={TODAY_DATE_INPUT_MAX}
|
||||
onChange={(event) => onChatDateValueChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import type { Components } from 'react-markdown';
|
||||
import { memo, type ChangeEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
|
||||
import { Suspense, lazy, memo, useMemo, type ChangeEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
|
||||
|
||||
import nanobotLogo from '../../../assets/nanobot-logo.png';
|
||||
import type { ChatMessage } from '../../../types/bot';
|
||||
import type { StagedSubmissionDraft } from '../types';
|
||||
import { DashboardChatComposer } from './DashboardChatComposer';
|
||||
import type { DashboardChatPanelLabels } from './DashboardChatPanel.types';
|
||||
import { DashboardConversationMessages } from './DashboardConversationMessages';
|
||||
import { DashboardStagedSubmissionQueue } from './DashboardStagedSubmissionQueue';
|
||||
import './DashboardChatPanel.css';
|
||||
|
||||
const LazyDashboardConversationMessages = lazy(() =>
|
||||
import('./DashboardConversationMessages').then((module) => ({ default: module.DashboardConversationMessages })),
|
||||
);
|
||||
|
||||
interface DashboardChatPanelProps {
|
||||
conversation: ChatMessage[];
|
||||
isZh: boolean;
|
||||
|
|
@ -84,7 +87,7 @@ interface DashboardChatPanelProps {
|
|||
interface DashboardChatTranscriptProps {
|
||||
conversation: ChatMessage[];
|
||||
isZh: boolean;
|
||||
labels: DashboardChatPanelLabels;
|
||||
labels: DashboardConversationTranscriptLabels;
|
||||
chatScrollRef: RefObject<HTMLDivElement | null>;
|
||||
onChatScroll: () => void;
|
||||
expandedProgressByKey: Record<string, boolean>;
|
||||
|
|
@ -105,6 +108,24 @@ interface DashboardChatTranscriptProps {
|
|||
isThinking: boolean;
|
||||
}
|
||||
|
||||
interface DashboardConversationTranscriptLabels {
|
||||
badReply: string;
|
||||
copyPrompt: string;
|
||||
copyReply: string;
|
||||
deleteMessage: string;
|
||||
download: string;
|
||||
editPrompt: string;
|
||||
fileNotPreviewable: string;
|
||||
goodReply: string;
|
||||
noConversation: string;
|
||||
previewTitle: string;
|
||||
quoteReply: string;
|
||||
quotedReplyLabel: string;
|
||||
thinking: string;
|
||||
user: string;
|
||||
you: string;
|
||||
}
|
||||
|
||||
const MemoizedChatTranscript = memo(function MemoizedChatTranscript({
|
||||
conversation,
|
||||
isZh,
|
||||
|
|
@ -135,40 +156,28 @@ const MemoizedChatTranscript = memo(function MemoizedChatTranscript({
|
|||
{labels.noConversation}
|
||||
</div>
|
||||
) : (
|
||||
<DashboardConversationMessages
|
||||
conversation={conversation}
|
||||
isZh={isZh}
|
||||
labels={{
|
||||
badReply: labels.badReply,
|
||||
copyPrompt: labels.copyPrompt,
|
||||
copyReply: labels.copyReply,
|
||||
deleteMessage: labels.deleteMessage,
|
||||
download: labels.download,
|
||||
editPrompt: labels.editPrompt,
|
||||
fileNotPreviewable: labels.fileNotPreviewable,
|
||||
goodReply: labels.goodReply,
|
||||
previewTitle: labels.previewTitle,
|
||||
quoteReply: labels.quoteReply,
|
||||
quotedReplyLabel: labels.quotedReplyLabel,
|
||||
user: labels.user,
|
||||
you: labels.you,
|
||||
}}
|
||||
expandedProgressByKey={expandedProgressByKey}
|
||||
expandedUserByKey={expandedUserByKey}
|
||||
deletingMessageIdMap={deletingMessageIdMap}
|
||||
feedbackSavingByMessageId={feedbackSavingByMessageId}
|
||||
markdownComponents={markdownComponents}
|
||||
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||
onToggleProgressExpand={onToggleProgressExpand}
|
||||
onToggleUserExpand={onToggleUserExpand}
|
||||
onEditUserPrompt={onEditUserPrompt}
|
||||
onCopyUserPrompt={onCopyUserPrompt}
|
||||
onDeleteConversationMessage={onDeleteConversationMessage}
|
||||
onOpenWorkspacePath={onOpenWorkspacePath}
|
||||
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
|
||||
onQuoteAssistantReply={onQuoteAssistantReply}
|
||||
onCopyAssistantReply={onCopyAssistantReply}
|
||||
/>
|
||||
<Suspense fallback={<div className="ops-empty-inline">{isZh ? '读取对话内容中...' : 'Loading conversation...'}</div>}>
|
||||
<LazyDashboardConversationMessages
|
||||
conversation={conversation}
|
||||
isZh={isZh}
|
||||
labels={labels}
|
||||
expandedProgressByKey={expandedProgressByKey}
|
||||
expandedUserByKey={expandedUserByKey}
|
||||
deletingMessageIdMap={deletingMessageIdMap}
|
||||
feedbackSavingByMessageId={feedbackSavingByMessageId}
|
||||
markdownComponents={markdownComponents}
|
||||
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||
onToggleProgressExpand={onToggleProgressExpand}
|
||||
onToggleUserExpand={onToggleUserExpand}
|
||||
onEditUserPrompt={onEditUserPrompt}
|
||||
onCopyUserPrompt={onCopyUserPrompt}
|
||||
onDeleteConversationMessage={onDeleteConversationMessage}
|
||||
onOpenWorkspacePath={onOpenWorkspacePath}
|
||||
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
|
||||
onQuoteAssistantReply={onQuoteAssistantReply}
|
||||
onCopyAssistantReply={onCopyAssistantReply}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{isThinking ? (
|
||||
|
|
@ -192,33 +201,7 @@ const MemoizedChatTranscript = memo(function MemoizedChatTranscript({
|
|||
<div />
|
||||
</div>
|
||||
);
|
||||
}, (prev, next) => (
|
||||
prev.conversation === next.conversation
|
||||
&& prev.isZh === next.isZh
|
||||
&& prev.isThinking === next.isThinking
|
||||
&& prev.chatScrollRef === next.chatScrollRef
|
||||
&& prev.expandedProgressByKey === next.expandedProgressByKey
|
||||
&& prev.expandedUserByKey === next.expandedUserByKey
|
||||
&& prev.deletingMessageIdMap === next.deletingMessageIdMap
|
||||
&& prev.feedbackSavingByMessageId === next.feedbackSavingByMessageId
|
||||
&& prev.markdownComponents === next.markdownComponents
|
||||
&& prev.workspaceDownloadExtensionSet === next.workspaceDownloadExtensionSet
|
||||
&& prev.labels.badReply === next.labels.badReply
|
||||
&& prev.labels.copyPrompt === next.labels.copyPrompt
|
||||
&& prev.labels.copyReply === next.labels.copyReply
|
||||
&& prev.labels.deleteMessage === next.labels.deleteMessage
|
||||
&& prev.labels.download === next.labels.download
|
||||
&& prev.labels.editPrompt === next.labels.editPrompt
|
||||
&& prev.labels.fileNotPreviewable === next.labels.fileNotPreviewable
|
||||
&& prev.labels.goodReply === next.labels.goodReply
|
||||
&& prev.labels.noConversation === next.labels.noConversation
|
||||
&& prev.labels.previewTitle === next.labels.previewTitle
|
||||
&& prev.labels.quoteReply === next.labels.quoteReply
|
||||
&& prev.labels.quotedReplyLabel === next.labels.quotedReplyLabel
|
||||
&& prev.labels.thinking === next.labels.thinking
|
||||
&& prev.labels.user === next.labels.user
|
||||
&& prev.labels.you === next.labels.you
|
||||
));
|
||||
});
|
||||
|
||||
export function DashboardChatPanel({
|
||||
conversation,
|
||||
|
|
@ -290,12 +273,60 @@ export function DashboardChatPanel({
|
|||
submitActionMode,
|
||||
onSubmitAction,
|
||||
}: DashboardChatPanelProps) {
|
||||
const transcriptLabels = useMemo<DashboardConversationTranscriptLabels>(() => ({
|
||||
badReply: labels.badReply,
|
||||
copyPrompt: labels.copyPrompt,
|
||||
copyReply: labels.copyReply,
|
||||
deleteMessage: labels.deleteMessage,
|
||||
download: labels.download,
|
||||
editPrompt: labels.editPrompt,
|
||||
fileNotPreviewable: labels.fileNotPreviewable,
|
||||
goodReply: labels.goodReply,
|
||||
noConversation: labels.noConversation,
|
||||
previewTitle: labels.previewTitle,
|
||||
quoteReply: labels.quoteReply,
|
||||
quotedReplyLabel: labels.quotedReplyLabel,
|
||||
thinking: labels.thinking,
|
||||
user: labels.user,
|
||||
you: labels.you,
|
||||
}), [
|
||||
labels.badReply,
|
||||
labels.copyPrompt,
|
||||
labels.copyReply,
|
||||
labels.deleteMessage,
|
||||
labels.download,
|
||||
labels.editPrompt,
|
||||
labels.fileNotPreviewable,
|
||||
labels.goodReply,
|
||||
labels.noConversation,
|
||||
labels.previewTitle,
|
||||
labels.quoteReply,
|
||||
labels.quotedReplyLabel,
|
||||
labels.thinking,
|
||||
labels.user,
|
||||
labels.you,
|
||||
]);
|
||||
|
||||
const stagedSubmissionLabels = useMemo(() => ({
|
||||
quotedReplyLabel: labels.quotedReplyLabel,
|
||||
stagedSubmissionAttachmentCount: labels.stagedSubmissionAttachmentCount,
|
||||
stagedSubmissionEmpty: labels.stagedSubmissionEmpty,
|
||||
stagedSubmissionRestore: labels.stagedSubmissionRestore,
|
||||
stagedSubmissionRemove: labels.stagedSubmissionRemove,
|
||||
}), [
|
||||
labels.quotedReplyLabel,
|
||||
labels.stagedSubmissionAttachmentCount,
|
||||
labels.stagedSubmissionEmpty,
|
||||
labels.stagedSubmissionRestore,
|
||||
labels.stagedSubmissionRemove,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
|
||||
<MemoizedChatTranscript
|
||||
conversation={conversation}
|
||||
isZh={isZh}
|
||||
labels={labels}
|
||||
labels={transcriptLabels}
|
||||
chatScrollRef={chatScrollRef}
|
||||
onChatScroll={onChatScroll}
|
||||
expandedProgressByKey={expandedProgressByKey}
|
||||
|
|
@ -318,7 +349,7 @@ export function DashboardChatPanel({
|
|||
|
||||
<div className="ops-chat-dock">
|
||||
<DashboardStagedSubmissionQueue
|
||||
labels={labels}
|
||||
labels={stagedSubmissionLabels}
|
||||
stagedSubmissions={stagedSubmissions}
|
||||
onRestoreStagedSubmission={onRestoreStagedSubmission}
|
||||
onRemoveStagedSubmission={onRemoveStagedSubmission}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import { ChevronDown, ChevronUp, Copy, Download, Eye, FileText, Pencil, Reply, ThumbsDown, ThumbsUp, Trash2, UserRound } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Suspense, lazy, memo } from 'react';
|
||||
import type { Components } from 'react-markdown';
|
||||
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import nanobotLogo from '../../../assets/nanobot-logo.png';
|
||||
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../../../shared/text/messageText';
|
||||
import { MARKDOWN_SANITIZE_SCHEMA } from '../../../shared/workspace/constants';
|
||||
import { decorateWorkspacePathsForMarkdown, normalizeDashboardAttachmentPath } from '../../../shared/workspace/workspaceMarkdown';
|
||||
import { workspaceFileAction } from '../../../shared/workspace/utils';
|
||||
import type { ChatMessage } from '../../../types/bot';
|
||||
import { formatClock, formatConversationDate } from '../chat/chatUtils';
|
||||
import './DashboardConversationMessages.css';
|
||||
|
||||
const LazyMarkdownRenderer = lazy(() =>
|
||||
import('../../../shared/markdown/MarkdownRenderer').then((module) => ({ default: module.MarkdownRenderer })),
|
||||
);
|
||||
|
||||
interface DashboardConversationLabels {
|
||||
badReply: string;
|
||||
copyPrompt: string;
|
||||
|
|
@ -215,13 +215,14 @@ const DashboardConversationMessageRow = memo(function DashboardConversationMessa
|
|||
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
|
||||
</>
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
|
||||
components={markdownComponents}
|
||||
<Suspense
|
||||
fallback={<div className="whitespace-pre-wrap">{normalizeAssistantMessageText(displayText)}</div>}
|
||||
>
|
||||
{decorateWorkspacePathsForMarkdown(displayText)}
|
||||
</ReactMarkdown>
|
||||
<LazyMarkdownRenderer
|
||||
components={markdownComponents}
|
||||
content={decorateWorkspacePathsForMarkdown(displayText)}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
) : null}
|
||||
{(item.attachments || []).length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Plus, Save, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { DrawerShell } from '../../../components/DrawerShell';
|
||||
|
|
@ -57,26 +57,28 @@ export function EnvParamsModal({
|
|||
const [createPanelOpen, setCreatePanelOpen] = useState(false);
|
||||
const [envEditDrafts, setEnvEditDrafts] = useState<Record<string, { key: string; value: string }>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
const resetLocalState = () => {
|
||||
setCreatePanelOpen(false);
|
||||
}, [open]);
|
||||
setEnvEditDrafts({});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const mergedEnvDrafts = useMemo(() => {
|
||||
const nextDrafts: Record<string, { key: string; value: string }> = {};
|
||||
envEntries.forEach(([key, value]) => {
|
||||
nextDrafts[key] = { key, value };
|
||||
nextDrafts[key] = envEditDrafts[key] || { key, value };
|
||||
});
|
||||
setEnvEditDrafts(nextDrafts);
|
||||
}, [envEntries, open]);
|
||||
return nextDrafts;
|
||||
}, [envEditDrafts, envEntries]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<DrawerShell
|
||||
<DrawerShell
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onClose={() => {
|
||||
resetLocalState();
|
||||
onClose();
|
||||
}}
|
||||
title={labels.envParams}
|
||||
size="standard"
|
||||
bodyClassName="ops-config-drawer-body"
|
||||
|
|
@ -99,7 +101,7 @@ export function EnvParamsModal({
|
|||
<div className="ops-empty-inline">{labels.noEnvParams}</div>
|
||||
) : (
|
||||
envEntries.map(([key, value]) => {
|
||||
const draft = envEditDrafts[key] || { key, value };
|
||||
const draft = mergedEnvDrafts[key] || { key, value };
|
||||
return (
|
||||
<div key={key} className="card wizard-channel-card wizard-channel-compact">
|
||||
<div className="ops-config-card-header">
|
||||
|
|
@ -110,7 +112,16 @@ export function EnvParamsModal({
|
|||
<div className="ops-config-card-actions">
|
||||
<LucentIconButton
|
||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||
onClick={() => void onDeleteEnvParam(key)}
|
||||
onClick={async () => {
|
||||
const deleted = await onDeleteEnvParam(key);
|
||||
if (!deleted) return;
|
||||
setEnvEditDrafts((prev) => {
|
||||
if (!(key in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
tooltip={labels.removeEnvParam}
|
||||
aria-label={labels.removeEnvParam}
|
||||
>
|
||||
|
|
@ -165,7 +176,19 @@ export function EnvParamsModal({
|
|||
</div>
|
||||
<div className="row-between ops-config-footer">
|
||||
<span className="field-label">{labels.envParamsHint}</span>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void onSaveEnvParam(key, draft.key, draft.value)}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={async () => {
|
||||
const saved = await onSaveEnvParam(key, draft.key, draft.value);
|
||||
if (!saved) return;
|
||||
setEnvEditDrafts((prev) => {
|
||||
if (!(key in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Save size={14} />
|
||||
<span style={{ marginLeft: 6 }}>{labels.save}</span>
|
||||
</button>
|
||||
|
|
@ -186,7 +209,7 @@ export function EnvParamsModal({
|
|||
<LucentIconButton
|
||||
className="ops-plain-icon-btn"
|
||||
onClick={() => {
|
||||
setCreatePanelOpen(false);
|
||||
resetLocalState();
|
||||
onEnvDraftKeyChange('');
|
||||
onEnvDraftValueChange('');
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { normalizeUserMessageText } from '../../../shared/text/messageText';
|
||||
import type { StagedSubmissionDraft } from '../types';
|
||||
import type { DashboardChatPanelLabels } from './DashboardChatPanel.types';
|
||||
|
||||
interface DashboardStagedSubmissionQueueLabels {
|
||||
quotedReplyLabel: string;
|
||||
stagedSubmissionAttachmentCount: (count: number) => string;
|
||||
stagedSubmissionEmpty: string;
|
||||
stagedSubmissionRestore: string;
|
||||
stagedSubmissionRemove: string;
|
||||
}
|
||||
|
||||
interface DashboardStagedSubmissionQueueProps {
|
||||
labels: DashboardChatPanelLabels;
|
||||
labels: DashboardStagedSubmissionQueueLabels;
|
||||
stagedSubmissions: StagedSubmissionDraft[];
|
||||
onRestoreStagedSubmission: (stagedSubmissionId: string) => void;
|
||||
onRemoveStagedSubmission: (stagedSubmissionId: string) => void;
|
||||
}
|
||||
|
||||
export function DashboardStagedSubmissionQueue({
|
||||
export const DashboardStagedSubmissionQueue = memo(function DashboardStagedSubmissionQueue({
|
||||
labels,
|
||||
stagedSubmissions,
|
||||
onRestoreStagedSubmission,
|
||||
|
|
@ -67,4 +75,4 @@ export function DashboardStagedSubmissionQueue({
|
|||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Boxes, Check, Clock3, EllipsisVertical, FileText, Hammer, MessageSquareText, RefreshCw, RotateCcw, Save, Settings2, SlidersHorizontal, TriangleAlert, Trash2, Waypoints } from 'lucide-react';
|
||||
import type { RefObject } from 'react';
|
||||
import { memo, type RefObject } from 'react';
|
||||
|
||||
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
|
|
@ -92,7 +92,7 @@ interface RuntimePanelProps {
|
|||
onHideWorkspaceHoverCard: () => void;
|
||||
}
|
||||
|
||||
export function RuntimePanel({
|
||||
export const RuntimePanel = memo(function RuntimePanel({
|
||||
selectedBot,
|
||||
selectedBotEnabled,
|
||||
operatingBotId,
|
||||
|
|
@ -336,4 +336,4 @@ export function RuntimePanel({
|
|||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||
import { pickLocale } from '../../../i18n';
|
||||
|
|
@ -44,14 +44,8 @@ export function useBotDashboardModule({
|
|||
const [botListPageSize, setBotListPageSize] = useState(10);
|
||||
const [chatPullPageSize, setChatPullPageSize] = useState(60);
|
||||
const [commandAutoUnlockSeconds, setCommandAutoUnlockSeconds] = useState(10);
|
||||
const botSearchInputName = useMemo(
|
||||
() => `nbot-search-${Math.random().toString(36).slice(2, 10)}`,
|
||||
[],
|
||||
);
|
||||
const workspaceSearchInputName = useMemo(
|
||||
() => `nbot-workspace-search-${Math.random().toString(36).slice(2, 10)}`,
|
||||
[],
|
||||
);
|
||||
const botSearchInputName = `nbot-search-${useId().replace(/:/g, '-')}`;
|
||||
const workspaceSearchInputName = `nbot-workspace-search-${useId().replace(/:/g, '-')}`;
|
||||
const {
|
||||
allowedAttachmentExtensions,
|
||||
botListPageSizeReady,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,498 @@
|
|||
import type { BotDashboardModuleProps } from './types';
|
||||
import { formatBytes, formatWorkspaceTime } from './utils';
|
||||
import type { BotDashboardViewProps } from './components/BotDashboardView';
|
||||
import type { useBotDashboardModule } from './hooks/useBotDashboardModule';
|
||||
|
||||
type BotDashboardState = ReturnType<typeof useBotDashboardModule>;
|
||||
|
||||
interface UseBotDashboardViewPropsOptions {
|
||||
dashboard: BotDashboardState;
|
||||
onOpenImageFactory?: BotDashboardModuleProps['onOpenImageFactory'];
|
||||
}
|
||||
|
||||
export function useBotDashboardViewProps({
|
||||
dashboard,
|
||||
onOpenImageFactory,
|
||||
}: UseBotDashboardViewPropsOptions): BotDashboardViewProps {
|
||||
const runtimeMoreLabel = dashboard.isZh ? '更多' : 'More';
|
||||
|
||||
const botListPanelProps = {
|
||||
bots: dashboard.bots,
|
||||
filteredBots: dashboard.filteredBots,
|
||||
pagedBots: dashboard.pagedBots,
|
||||
selectedBotId: dashboard.selectedBotId,
|
||||
normalizedBotListQuery: dashboard.normalizedBotListQuery,
|
||||
botListQuery: dashboard.botListQuery,
|
||||
botListPageSizeReady: dashboard.botListPageSizeReady,
|
||||
botListPage: dashboard.botListPage,
|
||||
botListTotalPages: dashboard.botListTotalPages,
|
||||
botListMenuOpen: dashboard.botListMenuOpen,
|
||||
controlStateByBot: dashboard.controlStateByBot,
|
||||
operatingBotId: dashboard.operatingBotId,
|
||||
compactMode: dashboard.compactMode,
|
||||
isZh: dashboard.isZh,
|
||||
isLoadingTemplates: dashboard.isLoadingTemplates,
|
||||
isBatchOperating: dashboard.isBatchOperating,
|
||||
labels: {
|
||||
batchStart: dashboard.t.batchStart,
|
||||
batchStop: dashboard.t.batchStop,
|
||||
botSearchNoResult: dashboard.t.botSearchNoResult,
|
||||
botSearchPlaceholder: dashboard.t.botSearchPlaceholder,
|
||||
clearSearch: dashboard.t.clearSearch,
|
||||
delete: dashboard.t.delete,
|
||||
disable: dashboard.t.disable,
|
||||
disabled: dashboard.t.disabled,
|
||||
enable: dashboard.t.enable,
|
||||
extensions: dashboard.t.extensions,
|
||||
image: dashboard.t.image,
|
||||
manageImages: dashboard.t.manageImages,
|
||||
newBot: dashboard.t.newBot,
|
||||
paginationNext: dashboard.t.paginationNext,
|
||||
paginationPage: dashboard.t.paginationPage,
|
||||
paginationPrev: dashboard.t.paginationPrev,
|
||||
searchAction: dashboard.t.searchAction,
|
||||
start: dashboard.t.start,
|
||||
stop: dashboard.t.stop,
|
||||
syncingPageSize: dashboard.t.syncingPageSize,
|
||||
templateManager: dashboard.t.templateManager,
|
||||
titleBots: dashboard.t.titleBots,
|
||||
},
|
||||
botSearchInputName: dashboard.botSearchInputName,
|
||||
botListMenuRef: dashboard.botListMenuRef,
|
||||
onOpenCreateWizard: () => dashboard.setShowCreateBotModal(true),
|
||||
onOpenImageFactory,
|
||||
onToggleMenu: () => dashboard.setBotListMenuOpen((prev) => !prev),
|
||||
onCloseMenu: () => dashboard.setBotListMenuOpen(false),
|
||||
onOpenTemplateManager: dashboard.openTemplateManager,
|
||||
onBatchStartBots: dashboard.batchStartBots,
|
||||
onBatchStopBots: dashboard.batchStopBots,
|
||||
onBotListQueryChange: dashboard.setBotListQuery,
|
||||
onBotListPageChange: dashboard.setBotListPage,
|
||||
onSelectBot: dashboard.setSelectedBotId,
|
||||
onSetCompactPanelTab: (tab: 'chat' | 'runtime') => dashboard.setCompactPanelTab(tab),
|
||||
onSetBotEnabled: dashboard.setBotEnabled,
|
||||
onStartBot: dashboard.startBot,
|
||||
onStopBot: dashboard.stopBot,
|
||||
onOpenResourceMonitor: dashboard.openResourceMonitor,
|
||||
onRemoveBot: dashboard.removeBot,
|
||||
};
|
||||
|
||||
const topicFeedPanelProps = {
|
||||
isZh: dashboard.isZh,
|
||||
topicKey: dashboard.topicFeedTopicKey,
|
||||
topicOptions: dashboard.activeTopicOptions,
|
||||
topicState: dashboard.topicPanelState,
|
||||
items: dashboard.topicFeedItems,
|
||||
loading: dashboard.topicFeedLoading,
|
||||
loadingMore: dashboard.topicFeedLoadingMore,
|
||||
nextCursor: dashboard.topicFeedNextCursor,
|
||||
error: dashboard.topicFeedError,
|
||||
readSavingById: dashboard.topicFeedReadSavingById,
|
||||
deleteSavingById: dashboard.topicFeedDeleteSavingById,
|
||||
onTopicChange: dashboard.setTopicFeedTopicKey,
|
||||
onRefresh: () => void dashboard.loadTopicFeed({ append: false, topicKey: dashboard.topicFeedTopicKey }),
|
||||
onMarkRead: (itemId: number) => void dashboard.markTopicFeedItemRead(itemId),
|
||||
onDeleteItem: (item: (typeof dashboard.topicFeedItems)[number]) => void dashboard.deleteTopicFeedItem(item),
|
||||
onLoadMore: () => void dashboard.loadTopicFeed({ append: true, cursor: dashboard.topicFeedNextCursor, topicKey: dashboard.topicFeedTopicKey }),
|
||||
onOpenWorkspacePath: (path: string) => void dashboard.openWorkspacePathFromChat(path),
|
||||
resolveWorkspaceMediaSrc: dashboard.resolveWorkspaceMediaSrc,
|
||||
onOpenTopicSettings: dashboard.openTopicConfigModal,
|
||||
onDetailOpenChange: dashboard.setTopicDetailOpen,
|
||||
layout: 'panel' as const,
|
||||
};
|
||||
|
||||
const dashboardChatPanelProps = {
|
||||
conversation: dashboard.conversation,
|
||||
isZh: dashboard.isZh,
|
||||
labels: {
|
||||
badReply: dashboard.t.badReply,
|
||||
botDisabledHint: dashboard.t.botDisabledHint,
|
||||
botStarting: dashboard.t.botStarting,
|
||||
botStopping: dashboard.t.botStopping,
|
||||
chatDisabled: dashboard.t.chatDisabled,
|
||||
controlCommandsHide: dashboard.t.controlCommandsHide,
|
||||
controlCommandsShow: dashboard.t.controlCommandsShow,
|
||||
copyPrompt: dashboard.t.copyPrompt,
|
||||
copyReply: dashboard.t.copyReply,
|
||||
deleteMessage: dashboard.t.deleteMessage,
|
||||
disabledPlaceholder: dashboard.t.disabledPlaceholder,
|
||||
download: dashboard.t.download,
|
||||
editPrompt: dashboard.t.editPrompt,
|
||||
fileNotPreviewable: dashboard.t.fileNotPreviewable,
|
||||
goodReply: dashboard.t.goodReply,
|
||||
inputPlaceholder: dashboard.t.inputPlaceholder,
|
||||
interrupt: dashboard.t.interrupt,
|
||||
noConversation: dashboard.t.noConversation,
|
||||
previewTitle: dashboard.t.previewTitle,
|
||||
stagedSubmissionAttachmentCount: dashboard.t.stagedSubmissionAttachmentCount,
|
||||
stagedSubmissionEmpty: dashboard.t.stagedSubmissionEmpty,
|
||||
stagedSubmissionRestore: dashboard.t.stagedSubmissionRestore,
|
||||
stagedSubmissionRemove: dashboard.t.stagedSubmissionRemove,
|
||||
quoteReply: dashboard.t.quoteReply,
|
||||
quotedReplyLabel: dashboard.t.quotedReplyLabel,
|
||||
send: dashboard.t.send,
|
||||
thinking: dashboard.t.thinking,
|
||||
uploadFile: dashboard.t.uploadFile,
|
||||
uploadingFile: dashboard.t.uploadingFile,
|
||||
user: dashboard.t.user,
|
||||
voiceStart: dashboard.t.voiceStart,
|
||||
voiceStop: dashboard.t.voiceStop,
|
||||
voiceTranscribing: dashboard.t.voiceTranscribing,
|
||||
you: dashboard.t.you,
|
||||
},
|
||||
chatScrollRef: dashboard.chatScrollRef,
|
||||
onChatScroll: dashboard.onChatScroll,
|
||||
expandedProgressByKey: dashboard.expandedProgressByKey,
|
||||
expandedUserByKey: dashboard.expandedUserByKey,
|
||||
deletingMessageIdMap: dashboard.deletingMessageIdMap,
|
||||
feedbackSavingByMessageId: dashboard.feedbackSavingByMessageId,
|
||||
markdownComponents: dashboard.markdownComponents,
|
||||
workspaceDownloadExtensionSet: dashboard.workspaceDownloadExtensionSet,
|
||||
onToggleProgressExpand: dashboard.toggleProgressExpanded,
|
||||
onToggleUserExpand: dashboard.toggleUserExpanded,
|
||||
onEditUserPrompt: dashboard.editUserPrompt,
|
||||
onCopyUserPrompt: dashboard.copyUserPrompt,
|
||||
onDeleteConversationMessage: dashboard.deleteConversationMessage,
|
||||
onOpenWorkspacePath: dashboard.openWorkspacePathFromChat,
|
||||
onSubmitAssistantFeedback: dashboard.submitAssistantFeedback,
|
||||
onQuoteAssistantReply: dashboard.quoteAssistantReply,
|
||||
onCopyAssistantReply: dashboard.copyAssistantReply,
|
||||
isThinking: dashboard.isThinking,
|
||||
canChat: dashboard.canChat,
|
||||
isChatEnabled: dashboard.isChatEnabled,
|
||||
speechEnabled: dashboard.speechEnabled,
|
||||
selectedBotEnabled: dashboard.selectedBotEnabled,
|
||||
selectedBotControlState: dashboard.selectedBotControlState,
|
||||
quotedReply: dashboard.quotedReply,
|
||||
onClearQuotedReply: () => dashboard.setQuotedReply(null),
|
||||
stagedSubmissions: dashboard.selectedBotStagedSubmissions,
|
||||
onRestoreStagedSubmission: dashboard.restoreStagedSubmission,
|
||||
onRemoveStagedSubmission: dashboard.removeStagedSubmission,
|
||||
pendingAttachments: dashboard.pendingAttachments,
|
||||
onRemovePendingAttachment: (path: string) =>
|
||||
dashboard.setPendingAttachments((prev) => prev.filter((value) => value !== path)),
|
||||
attachmentUploadPercent: dashboard.attachmentUploadPercent,
|
||||
isUploadingAttachments: dashboard.isUploadingAttachments,
|
||||
filePickerRef: dashboard.filePickerRef,
|
||||
allowedAttachmentExtensions: dashboard.allowedAttachmentExtensions,
|
||||
onPickAttachments: dashboard.onPickAttachments,
|
||||
controlCommandPanelOpen: dashboard.controlCommandPanelOpen,
|
||||
controlCommandPanelRef: dashboard.controlCommandPanelRef,
|
||||
onToggleControlCommandPanel: () => {
|
||||
dashboard.setChatDatePickerOpen(false);
|
||||
dashboard.setControlCommandPanelOpen((prev) => !prev);
|
||||
},
|
||||
activeControlCommand: dashboard.activeControlCommand,
|
||||
canSendControlCommand: dashboard.canSendControlCommand,
|
||||
isInterrupting: dashboard.isInterrupting,
|
||||
onSendControlCommand: dashboard.sendControlCommand,
|
||||
onInterruptExecution: dashboard.interruptExecution,
|
||||
chatDateTriggerRef: dashboard.chatDateTriggerRef,
|
||||
hasSelectedBot: Boolean(dashboard.selectedBotId),
|
||||
chatDateJumping: dashboard.chatDateJumping,
|
||||
onToggleChatDatePicker: dashboard.toggleChatDatePicker,
|
||||
chatDatePickerOpen: dashboard.chatDatePickerOpen,
|
||||
chatDatePanelPosition: dashboard.chatDatePanelPosition,
|
||||
chatDateValue: dashboard.chatDateValue,
|
||||
onChatDateValueChange: dashboard.setChatDateValue,
|
||||
onCloseChatDatePicker: () => dashboard.setChatDatePickerOpen(false),
|
||||
onJumpConversationToDate: dashboard.jumpConversationToDate,
|
||||
command: dashboard.command,
|
||||
onCommandChange: dashboard.setCommand,
|
||||
composerTextareaRef: dashboard.composerTextareaRef,
|
||||
onComposerKeyDown: dashboard.onComposerKeyDown,
|
||||
isVoiceRecording: dashboard.isVoiceRecording,
|
||||
isVoiceTranscribing: dashboard.isVoiceTranscribing,
|
||||
isCompactMobile: dashboard.isCompactMobile,
|
||||
voiceCountdown: dashboard.voiceCountdown,
|
||||
onVoiceInput: dashboard.onVoiceInput,
|
||||
onTriggerPickAttachments: dashboard.triggerPickAttachments,
|
||||
submitActionMode: dashboard.submitActionMode,
|
||||
onSubmitAction: dashboard.handlePrimarySubmitAction,
|
||||
};
|
||||
|
||||
const runtimePanelProps = {
|
||||
selectedBot: dashboard.selectedBot,
|
||||
selectedBotEnabled: dashboard.selectedBotEnabled,
|
||||
operatingBotId: dashboard.operatingBotId,
|
||||
runtimeMenuOpen: dashboard.runtimeMenuOpen,
|
||||
runtimeMenuRef: dashboard.runtimeMenuRef,
|
||||
displayState: dashboard.displayState,
|
||||
workspaceError: dashboard.workspaceError,
|
||||
workspacePathDisplay: dashboard.workspacePathDisplay,
|
||||
workspaceLoading: dashboard.workspaceLoading,
|
||||
workspaceQuery: dashboard.workspaceQuery,
|
||||
workspaceSearchInputName: dashboard.workspaceSearchInputName,
|
||||
workspaceSearchLoading: dashboard.workspaceSearchLoading,
|
||||
filteredWorkspaceEntries: dashboard.filteredWorkspaceEntries,
|
||||
workspaceParentPath: dashboard.workspaceParentPath,
|
||||
workspaceFileLoading: dashboard.workspaceFileLoading,
|
||||
workspaceDownloadExtensionSet: dashboard.workspaceDownloadExtensionSet,
|
||||
workspaceAutoRefresh: dashboard.workspaceAutoRefresh,
|
||||
isCompactHidden: dashboard.compactMode && (dashboard.isCompactListPage || dashboard.compactPanelTab !== 'runtime'),
|
||||
showCompactSurface: dashboard.showCompactBotPageClose,
|
||||
emptyStateText: dashboard.forcedBotMissing ? `${dashboard.t.noTelemetry}: ${String(dashboard.forcedBotId).trim()}` : dashboard.t.noTelemetry,
|
||||
labels: {
|
||||
agent: dashboard.t.agent,
|
||||
autoRefresh: dashboard.lc.autoRefresh,
|
||||
base: dashboard.t.base,
|
||||
channels: dashboard.t.channels,
|
||||
clearHistory: dashboard.t.clearHistory,
|
||||
clearSearch: dashboard.t.clearSearch,
|
||||
cronViewer: dashboard.t.cronViewer,
|
||||
download: dashboard.t.download,
|
||||
emptyDir: dashboard.t.emptyDir,
|
||||
envParams: dashboard.t.envParams,
|
||||
exportHistory: dashboard.t.exportHistory,
|
||||
fileNotPreviewable: dashboard.t.fileNotPreviewable,
|
||||
folder: dashboard.t.folder,
|
||||
goUp: dashboard.t.goUp,
|
||||
goUpTitle: dashboard.t.goUpTitle,
|
||||
loadingDir: dashboard.t.loadingDir,
|
||||
mcp: dashboard.t.mcp,
|
||||
more: runtimeMoreLabel,
|
||||
noPreviewFile: dashboard.t.noPreviewFile,
|
||||
openingPreview: dashboard.t.openingPreview,
|
||||
openFolderTitle: dashboard.t.openFolderTitle,
|
||||
params: dashboard.t.params,
|
||||
previewTitle: dashboard.t.previewTitle,
|
||||
refreshHint: dashboard.lc.refreshHint,
|
||||
restart: dashboard.t.restart,
|
||||
runtime: dashboard.t.runtime,
|
||||
searchAction: dashboard.t.searchAction,
|
||||
skills: dashboard.t.skills,
|
||||
topic: dashboard.t.topic,
|
||||
workspaceHint: dashboard.t.workspaceHint,
|
||||
workspaceOutputs: dashboard.t.workspaceOutputs,
|
||||
workspaceSearchNoResult: dashboard.t.workspaceSearchNoResult,
|
||||
workspaceSearchPlaceholder: dashboard.t.workspaceSearchPlaceholder,
|
||||
},
|
||||
onRestartBot: dashboard.restartBot,
|
||||
onToggleRuntimeMenu: () => dashboard.setRuntimeMenuOpen((prev) => !prev),
|
||||
onOpenBaseConfig: dashboard.openBaseConfigModal,
|
||||
onOpenParamConfig: dashboard.openParamConfigModal,
|
||||
onOpenChannelConfig: dashboard.openChannelConfigModal,
|
||||
onOpenTopicConfig: dashboard.openTopicConfigModal,
|
||||
onOpenEnvParams: dashboard.openEnvParamsConfigModal,
|
||||
onOpenSkills: dashboard.openSkillsConfigModal,
|
||||
onOpenMcpConfig: dashboard.openMcpConfigModal,
|
||||
onOpenCronJobs: dashboard.openCronJobsModal,
|
||||
onOpenAgentFiles: dashboard.openAgentFilesModal,
|
||||
onExportHistory: () => {
|
||||
dashboard.setRuntimeMenuOpen(false);
|
||||
dashboard.exportConversationJson();
|
||||
},
|
||||
onClearHistory: async () => {
|
||||
dashboard.setRuntimeMenuOpen(false);
|
||||
await dashboard.clearConversationHistory();
|
||||
},
|
||||
onRefreshWorkspace: () => dashboard.selectedBot ? dashboard.loadWorkspaceTree(dashboard.selectedBot.id, dashboard.workspaceCurrentPath) : undefined,
|
||||
onWorkspaceQueryChange: dashboard.setWorkspaceQuery,
|
||||
onWorkspaceQueryClear: () => dashboard.setWorkspaceQuery(''),
|
||||
onWorkspaceQuerySearch: () => dashboard.setWorkspaceQuery((value) => value.trim()),
|
||||
onToggleWorkspaceAutoRefresh: () => dashboard.setWorkspaceAutoRefresh((prev) => !prev),
|
||||
onLoadWorkspaceTree: dashboard.loadWorkspaceTree,
|
||||
onOpenWorkspaceFilePreview: dashboard.openWorkspaceFilePreview,
|
||||
onShowWorkspaceHoverCard: dashboard.showWorkspaceHoverCard,
|
||||
onHideWorkspaceHoverCard: dashboard.hideWorkspaceHoverCard,
|
||||
};
|
||||
|
||||
const dashboardModalStackProps = {
|
||||
resourceMonitorModal: {
|
||||
open: dashboard.showResourceModal,
|
||||
botId: dashboard.resourceBotId,
|
||||
resourceBot: dashboard.resourceBot,
|
||||
resourceSnapshot: dashboard.resourceSnapshot,
|
||||
resourceLoading: dashboard.resourceLoading,
|
||||
resourceError: dashboard.resourceError,
|
||||
isZh: dashboard.isZh,
|
||||
closeLabel: dashboard.t.close,
|
||||
onClose: () => dashboard.setShowResourceModal(false),
|
||||
onRefresh: dashboard.loadResourceSnapshot,
|
||||
},
|
||||
baseConfigModal: {
|
||||
open: dashboard.showBaseModal,
|
||||
selectedBotId: dashboard.selectedBot?.id || '',
|
||||
editForm: dashboard.editForm,
|
||||
paramDraft: dashboard.paramDraft,
|
||||
baseImageOptions: dashboard.baseImageOptions,
|
||||
systemTimezoneOptions: dashboard.systemTimezoneOptions,
|
||||
defaultSystemTimezone: dashboard.defaultSystemTimezone,
|
||||
passwordToggleLabels: dashboard.passwordToggleLabels,
|
||||
isSaving: dashboard.isSaving,
|
||||
isZh: dashboard.isZh,
|
||||
labels: {
|
||||
accessPassword: dashboard.t.accessPassword,
|
||||
accessPasswordPlaceholder: dashboard.t.accessPasswordPlaceholder,
|
||||
baseConfig: dashboard.t.baseConfig,
|
||||
baseImageReadonly: dashboard.t.baseImageReadonly,
|
||||
botIdReadonly: dashboard.t.botIdReadonly,
|
||||
botName: dashboard.t.botName,
|
||||
botNamePlaceholder: dashboard.t.botNamePlaceholder,
|
||||
cancel: dashboard.t.cancel,
|
||||
close: dashboard.t.close,
|
||||
save: dashboard.t.save,
|
||||
},
|
||||
onClose: () => dashboard.setShowBaseModal(false),
|
||||
onEditFormChange: dashboard.updateEditForm,
|
||||
onParamDraftChange: dashboard.updateParamDraft,
|
||||
onSave: () => dashboard.saveBot('base'),
|
||||
},
|
||||
paramConfigModal: {
|
||||
open: dashboard.showParamModal,
|
||||
editForm: dashboard.editForm,
|
||||
paramDraft: dashboard.paramDraft,
|
||||
passwordToggleLabels: dashboard.passwordToggleLabels,
|
||||
isZh: dashboard.isZh,
|
||||
isTestingProvider: dashboard.isTestingProvider,
|
||||
providerTestResult: dashboard.providerTestResult,
|
||||
isSaving: dashboard.isSaving,
|
||||
labels: {
|
||||
cancel: dashboard.t.cancel,
|
||||
close: dashboard.t.close,
|
||||
modelName: dashboard.t.modelName,
|
||||
modelNamePlaceholder: dashboard.t.modelNamePlaceholder,
|
||||
modelParams: dashboard.t.modelParams,
|
||||
newApiKey: dashboard.t.newApiKey,
|
||||
newApiKeyPlaceholder: dashboard.t.newApiKeyPlaceholder,
|
||||
saveParams: dashboard.t.saveParams,
|
||||
testModelConnection: dashboard.t.testModelConnection,
|
||||
testing: dashboard.t.testing,
|
||||
},
|
||||
onClose: () => dashboard.setShowParamModal(false),
|
||||
onEditFormChange: dashboard.updateEditForm,
|
||||
onParamDraftChange: dashboard.updateParamDraft,
|
||||
onProviderChange: dashboard.onBaseProviderChange,
|
||||
onTestProviderConnection: dashboard.testProviderConnection,
|
||||
onSave: () => dashboard.saveBot('params'),
|
||||
},
|
||||
channelConfigModal: dashboard.channelConfigModalProps,
|
||||
topicConfigModal: dashboard.topicConfigModalProps,
|
||||
skillsModal: dashboard.skillsModalProps,
|
||||
skillMarketInstallModal: dashboard.skillMarketInstallModalProps,
|
||||
mcpConfigModal: dashboard.mcpConfigModalProps,
|
||||
envParamsModal: dashboard.envParamsModalProps,
|
||||
cronJobsModal: dashboard.cronJobsModalProps,
|
||||
templateManagerModal: {
|
||||
open: dashboard.showTemplateModal,
|
||||
templateTab: dashboard.templateTab,
|
||||
templateAgentCount: dashboard.templateAgentCount,
|
||||
templateTopicCount: dashboard.templateTopicCount,
|
||||
templateAgentText: dashboard.templateAgentText,
|
||||
templateTopicText: dashboard.templateTopicText,
|
||||
isSavingTemplates: dashboard.isSavingTemplates,
|
||||
labels: {
|
||||
cancel: dashboard.t.cancel,
|
||||
close: dashboard.t.close,
|
||||
processing: dashboard.t.processing,
|
||||
save: dashboard.t.save,
|
||||
templateManagerTitle: dashboard.t.templateManagerTitle,
|
||||
templateTabAgent: dashboard.t.templateTabAgent,
|
||||
templateTabTopic: dashboard.t.templateTabTopic,
|
||||
},
|
||||
onClose: () => dashboard.setShowTemplateModal(false),
|
||||
onTemplateTabChange: dashboard.setTemplateTab,
|
||||
onTemplateAgentTextChange: dashboard.setTemplateAgentText,
|
||||
onTemplateTopicTextChange: dashboard.setTemplateTopicText,
|
||||
onSave: dashboard.saveTemplateManager,
|
||||
},
|
||||
agentFilesModal: {
|
||||
open: dashboard.showAgentModal,
|
||||
agentTab: dashboard.agentTab,
|
||||
tabValue: String(dashboard.editForm[dashboard.agentFieldByTab[dashboard.agentTab]]),
|
||||
isSaving: dashboard.isSaving,
|
||||
labels: {
|
||||
agentFiles: dashboard.t.agentFiles,
|
||||
cancel: dashboard.t.cancel,
|
||||
close: dashboard.t.close,
|
||||
saveFiles: dashboard.t.saveFiles,
|
||||
},
|
||||
onClose: () => dashboard.setShowAgentModal(false),
|
||||
onAgentTabChange: dashboard.setAgentTab,
|
||||
onTabValueChange: (nextValue: string) => dashboard.updateAgentTabValue(dashboard.agentTab, nextValue),
|
||||
onSave: () => dashboard.saveBot('agent'),
|
||||
},
|
||||
runtimeActionModal: {
|
||||
open: dashboard.showRuntimeActionModal,
|
||||
runtimeAction: dashboard.runtimeAction,
|
||||
labels: {
|
||||
close: dashboard.t.close,
|
||||
lastAction: dashboard.t.lastAction,
|
||||
},
|
||||
onClose: () => dashboard.setShowRuntimeActionModal(false),
|
||||
},
|
||||
workspacePreviewModal: {
|
||||
isZh: dashboard.isZh,
|
||||
labels: {
|
||||
cancel: dashboard.t.cancel,
|
||||
close: dashboard.t.close,
|
||||
copyAddress: dashboard.t.copyAddress,
|
||||
download: dashboard.t.download,
|
||||
editFile: dashboard.t.editFile,
|
||||
filePreview: dashboard.t.filePreview,
|
||||
fileTruncated: dashboard.t.fileTruncated,
|
||||
save: dashboard.t.save,
|
||||
},
|
||||
preview: dashboard.workspacePreview,
|
||||
previewFullscreen: dashboard.workspacePreviewFullscreen,
|
||||
previewEditorEnabled: dashboard.workspacePreviewEditorEnabled,
|
||||
previewCanEdit: dashboard.workspacePreviewCanEdit,
|
||||
previewDraft: dashboard.workspacePreviewDraft,
|
||||
previewSaving: dashboard.workspacePreviewSaving,
|
||||
markdownComponents: dashboard.workspacePreviewMarkdownComponents,
|
||||
onClose: dashboard.closeWorkspacePreview,
|
||||
onToggleFullscreen: () => dashboard.setWorkspacePreviewFullscreen((prev) => !prev),
|
||||
onCopyPreviewPath: dashboard.copyWorkspacePreviewPath,
|
||||
onCopyPreviewUrl: dashboard.copyWorkspacePreviewUrl,
|
||||
onPreviewDraftChange: dashboard.setWorkspacePreviewDraft,
|
||||
onSavePreviewMarkdown: dashboard.saveWorkspacePreviewMarkdown,
|
||||
onEnterEditMode: () => dashboard.setWorkspacePreviewMode('edit'),
|
||||
onExitEditMode: () => {
|
||||
dashboard.setWorkspacePreviewDraft(dashboard.workspacePreview?.content || '');
|
||||
dashboard.setWorkspacePreviewMode('preview');
|
||||
},
|
||||
getWorkspaceDownloadHref: dashboard.getWorkspaceDownloadHref,
|
||||
getWorkspaceRawHref: dashboard.getWorkspaceRawHref,
|
||||
},
|
||||
workspaceHoverCard: {
|
||||
state: dashboard.workspaceHoverCard,
|
||||
isZh: dashboard.isZh,
|
||||
formatWorkspaceTime,
|
||||
formatBytes,
|
||||
},
|
||||
};
|
||||
|
||||
const createBotModalProps = {
|
||||
open: dashboard.showCreateBotModal,
|
||||
onClose: () => dashboard.setShowCreateBotModal(false),
|
||||
onCreated: () => {
|
||||
void dashboard.refresh();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
compactMode: dashboard.compactMode,
|
||||
hasForcedBot: dashboard.hasForcedBot,
|
||||
showBotListPanel: dashboard.showBotListPanel,
|
||||
botListPanelProps,
|
||||
hasSelectedBot: Boolean(dashboard.selectedBot),
|
||||
isCompactListPage: dashboard.isCompactListPage,
|
||||
compactPanelTab: dashboard.compactPanelTab,
|
||||
showCompactBotPageClose: dashboard.showCompactBotPageClose,
|
||||
forcedBotId: dashboard.forcedBotId,
|
||||
selectBotText: dashboard.forcedBotMissing ? `${dashboard.t.selectBot}: ${String(dashboard.forcedBotId).trim()}` : dashboard.t.selectBot,
|
||||
isZh: dashboard.isZh,
|
||||
runtimeViewMode: dashboard.runtimeViewMode,
|
||||
hasTopicUnread: dashboard.hasTopicUnread,
|
||||
onRuntimeViewModeChange: dashboard.setRuntimeViewMode,
|
||||
topicFeedPanelProps,
|
||||
dashboardChatPanelProps,
|
||||
runtimePanelProps,
|
||||
onCompactClose: () => {
|
||||
dashboard.setSelectedBotId('');
|
||||
dashboard.setCompactPanelTab('chat');
|
||||
},
|
||||
dashboardModalStackProps,
|
||||
createBotModalProps,
|
||||
};
|
||||
}
|
||||
|
|
@ -31,7 +31,10 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
|
|||
isZh={dashboard.isZh}
|
||||
lastActionPreview={dashboard.lastActionPreview}
|
||||
operatingBotId={dashboard.operatingBotId}
|
||||
selectedBotEnabledChannels={dashboard.selectedBotEnabledChannels}
|
||||
selectedBotInfo={dashboard.selectedBotInfo}
|
||||
selectedBotMcpCount={dashboard.selectedBotMcpCount}
|
||||
selectedBotSkillCount={dashboard.selectedBotSkillCount}
|
||||
selectedBotUsageSummary={dashboard.selectedBotUsageSummary}
|
||||
onClearDashboardDirectSession={dashboard.clearDashboardDirectSession}
|
||||
onOpenBotPanel={dashboard.openBotPanel}
|
||||
|
|
|
|||
|
|
@ -624,8 +624,9 @@
|
|||
}
|
||||
|
||||
.platform-selected-bot-name {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
color: var(--title);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
|
@ -643,14 +644,14 @@
|
|||
}
|
||||
|
||||
.platform-selected-bot-name-id {
|
||||
font-size: 0.78em;
|
||||
font-size: 0.74em;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.platform-selected-bot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px 14px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
|
@ -668,15 +669,19 @@
|
|||
|
||||
.platform-selected-bot-info-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.platform-selected-bot-info-value {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
color: var(--title);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.platform-selected-bot-last-row {
|
||||
|
|
@ -702,6 +707,8 @@
|
|||
|
||||
.platform-selected-bot-last-preview {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
@ -1264,6 +1271,22 @@
|
|||
max-width: 460px;
|
||||
}
|
||||
|
||||
.platform-login-log-toolbar {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.platform-login-log-search {
|
||||
flex: 1 1 420px;
|
||||
min-width: min(420px, 100%);
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.platform-login-log-select {
|
||||
flex: 0 0 180px;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.platform-settings-table-wrap {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
|
|
@ -1308,6 +1331,19 @@
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.platform-login-log-search {
|
||||
min-width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.platform-login-log-select {
|
||||
flex: 1 1 180px;
|
||||
width: auto;
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.platform-template-shell {
|
||||
max-width: min(1400px, 96vw);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,9 +116,9 @@ export function PlatformLoginLogPage({ isZh }: PlatformLoginLogPageProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-settings-toolbar">
|
||||
<div className="platform-settings-toolbar platform-login-log-toolbar">
|
||||
<ProtectedSearchInput
|
||||
className="platform-searchbar platform-settings-search"
|
||||
className="platform-searchbar platform-settings-search platform-login-log-search"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
onClear={() => setSearch('')}
|
||||
|
|
@ -128,12 +128,20 @@ export function PlatformLoginLogPage({ isZh }: PlatformLoginLogPageProps) {
|
|||
clearTitle={isZh ? '清除搜索' : 'Clear search'}
|
||||
searchTitle={isZh ? '搜索' : 'Search'}
|
||||
/>
|
||||
<LucentSelect value={authType} onChange={(event) => setAuthType(event.target.value as 'all' | 'panel' | 'bot')}>
|
||||
<LucentSelect
|
||||
wrapperClassName="platform-login-log-select"
|
||||
value={authType}
|
||||
onChange={(event) => setAuthType(event.target.value as 'all' | 'panel' | 'bot')}
|
||||
>
|
||||
<option value="all">{isZh ? '全部类型' : 'All Types'}</option>
|
||||
<option value="panel">{isZh ? 'Panel' : 'Panel'}</option>
|
||||
<option value="bot">{isZh ? 'Bot' : 'Bot'}</option>
|
||||
</LucentSelect>
|
||||
<LucentSelect value={status} onChange={(event) => setStatus(event.target.value as 'all' | 'active' | 'revoked')}>
|
||||
<LucentSelect
|
||||
wrapperClassName="platform-login-log-select"
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value as 'all' | 'active' | 'revoked')}
|
||||
>
|
||||
<option value="all">{isZh ? '全部状态' : 'All Statuses'}</option>
|
||||
<option value="active">{isZh ? '活跃中' : 'Active'}</option>
|
||||
<option value="revoked">{isZh ? '已失效' : 'Revoked'}</option>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ interface PlatformBotOverviewSectionProps {
|
|||
isZh: boolean;
|
||||
lastActionPreview: string;
|
||||
operatingBotId: string;
|
||||
selectedBotEnabledChannels: string[];
|
||||
selectedBotInfo?: BotState;
|
||||
selectedBotMcpCount: number;
|
||||
selectedBotSkillCount: number;
|
||||
selectedBotUsageSummary: PlatformUsageResponse['summary'] | null;
|
||||
onClearDashboardDirectSession: (bot: BotState) => Promise<void> | void;
|
||||
onOpenBotPanel: (botId: string) => void;
|
||||
|
|
@ -18,12 +21,21 @@ interface PlatformBotOverviewSectionProps {
|
|||
onRemoveBot: (bot: BotState) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function formatChannelTypeLabel(channelType: string) {
|
||||
const normalized = String(channelType || '').trim().toLowerCase();
|
||||
if (!normalized) return '-';
|
||||
return normalized.toUpperCase();
|
||||
}
|
||||
|
||||
export function PlatformBotOverviewSection({
|
||||
compactSheet = false,
|
||||
isZh,
|
||||
lastActionPreview,
|
||||
operatingBotId,
|
||||
selectedBotEnabledChannels,
|
||||
selectedBotInfo,
|
||||
selectedBotMcpCount,
|
||||
selectedBotSkillCount,
|
||||
selectedBotUsageSummary,
|
||||
onClearDashboardDirectSession,
|
||||
onOpenBotPanel,
|
||||
|
|
@ -31,6 +43,14 @@ export function PlatformBotOverviewSection({
|
|||
onOpenResourceMonitor,
|
||||
onRemoveBot,
|
||||
}: PlatformBotOverviewSectionProps) {
|
||||
const enabledChannelSummary = selectedBotEnabledChannels.length > 0
|
||||
? selectedBotEnabledChannels.map(formatChannelTypeLabel).join(', ')
|
||||
: (isZh ? '未启用外部渠道' : 'No external channels enabled');
|
||||
|
||||
const skillMcpSummary = isZh
|
||||
? `技能 ${selectedBotSkillCount} / MCP ${selectedBotMcpCount}`
|
||||
: `Skills ${selectedBotSkillCount} / MCP ${selectedBotMcpCount}`;
|
||||
|
||||
return (
|
||||
<section className={`${compactSheet ? 'platform-compact-overview' : 'panel stack'}`}>
|
||||
<div className={compactSheet ? 'platform-compact-overview-head' : undefined}>
|
||||
|
|
@ -108,11 +128,16 @@ export function PlatformBotOverviewSection({
|
|||
<div className="platform-selected-bot-grid">
|
||||
<div className="platform-selected-bot-info">
|
||||
<span className="platform-selected-bot-info-label">{isZh ? '镜像' : 'Image'}</span>
|
||||
<span className="mono platform-selected-bot-info-value">{selectedBotInfo.image_tag || '-'}</span>
|
||||
<span className="mono platform-selected-bot-info-value" title={selectedBotInfo.image_tag || '-'}>
|
||||
{selectedBotInfo.image_tag || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="platform-selected-bot-info">
|
||||
<span className="platform-selected-bot-info-label">{isZh ? 'Provider / 模型' : 'Provider / Model'}</span>
|
||||
<span className="mono platform-selected-bot-info-value">
|
||||
<span
|
||||
className="mono platform-selected-bot-info-value"
|
||||
title={`${selectedBotInfo.llm_provider || '-'} / ${selectedBotInfo.llm_model || '-'}`}
|
||||
>
|
||||
{selectedBotInfo.llm_provider || '-'} / {selectedBotInfo.llm_model || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -120,9 +145,24 @@ export function PlatformBotOverviewSection({
|
|||
<span className="platform-selected-bot-info-label">{isZh ? 'Bot 状态' : 'Bot State'}</span>
|
||||
<span className="platform-selected-bot-info-value">{selectedBotInfo.current_state || 'IDLE'}</span>
|
||||
</div>
|
||||
<div className="platform-selected-bot-info">
|
||||
<span className="platform-selected-bot-info-label">{isZh ? '已启用渠道' : 'Enabled Channels'}</span>
|
||||
<span className="platform-selected-bot-info-value" title={enabledChannelSummary}>
|
||||
{enabledChannelSummary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="platform-selected-bot-info">
|
||||
<span className="platform-selected-bot-info-label">{isZh ? '已启用技能 / MCP' : 'Enabled Skills / MCP'}</span>
|
||||
<span className="platform-selected-bot-info-value">{skillMcpSummary}</span>
|
||||
</div>
|
||||
<div className="platform-selected-bot-info">
|
||||
<span className="platform-selected-bot-info-label">{isZh ? 'Token 用量合计' : 'Token Usage Summary'}</span>
|
||||
<span className="platform-selected-bot-info-value">
|
||||
<span
|
||||
className="platform-selected-bot-info-value"
|
||||
title={isZh
|
||||
? `请求 ${selectedBotUsageSummary?.request_count || 0} / 输入 ${selectedBotUsageSummary?.input_tokens || 0} / 输出 ${selectedBotUsageSummary?.output_tokens || 0} / 总计 ${selectedBotUsageSummary?.total_tokens || 0}`
|
||||
: `Req ${selectedBotUsageSummary?.request_count || 0} / In ${selectedBotUsageSummary?.input_tokens || 0} / Out ${selectedBotUsageSummary?.output_tokens || 0} / Total ${selectedBotUsageSummary?.total_tokens || 0}`}
|
||||
>
|
||||
{isZh
|
||||
? `请求 ${selectedBotUsageSummary?.request_count || 0} / 输入 ${selectedBotUsageSummary?.input_tokens || 0} / 输出 ${selectedBotUsageSummary?.output_tokens || 0} / 总计 ${selectedBotUsageSummary?.total_tokens || 0}`
|
||||
: `Req ${selectedBotUsageSummary?.request_count || 0} / In ${selectedBotUsageSummary?.input_tokens || 0} / Out ${selectedBotUsageSummary?.output_tokens || 0} / Total ${selectedBotUsageSummary?.total_tokens || 0}`}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||
|
|
@ -6,317 +6,75 @@ import { APP_ENDPOINTS } from '../../../config/env';
|
|||
import { sortBotsByCreatedAtDesc } from '../../../shared/bot/sortBots';
|
||||
import { useAppStore } from '../../../store/appStore';
|
||||
import type { BotState } from '../../../types/bot';
|
||||
import {
|
||||
normalizePlatformPageSize,
|
||||
readCachedPlatformPageSize,
|
||||
writeCachedPlatformPageSize,
|
||||
} from '../../../utils/platformPageSize';
|
||||
import type {
|
||||
BotActivityStatsItem,
|
||||
PlatformBotResourceSnapshot,
|
||||
PlatformOverviewResponse,
|
||||
PlatformUsageAnalyticsSeriesItem,
|
||||
PlatformUsageResponse,
|
||||
} from '../types';
|
||||
import {
|
||||
buildBotPanelHref,
|
||||
buildPlatformUsageAnalyticsSeries,
|
||||
buildPlatformUsageAnalyticsTicks,
|
||||
getPlatformChartCeiling,
|
||||
} from '../utils';
|
||||
import { buildBotPanelHref } from '../utils';
|
||||
import { usePlatformManagementState } from './usePlatformManagementState';
|
||||
import { usePlatformOverviewState } from './usePlatformOverviewState';
|
||||
|
||||
interface UsePlatformDashboardOptions {
|
||||
compactMode: boolean;
|
||||
mode?: 'admin' | 'management';
|
||||
}
|
||||
|
||||
interface RequestErrorShape {
|
||||
response?: {
|
||||
data?: {
|
||||
detail?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function getRequestErrorDetail(error: unknown): string {
|
||||
const resolvedError = (error && typeof error === 'object' ? error : {}) as RequestErrorShape;
|
||||
return String(resolvedError.response?.data?.detail || '').trim();
|
||||
}
|
||||
|
||||
export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePlatformDashboardOptions) {
|
||||
const { activeBots, setBots, updateBotStatus, locale } = useAppStore();
|
||||
const { notify, confirm } = useLucentPrompt();
|
||||
const isZh = locale === 'zh';
|
||||
const isAdminMode = mode === 'admin';
|
||||
const isManagementMode = mode === 'management';
|
||||
const [overview, setOverview] = useState<PlatformOverviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedBotId, setSelectedBotId] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [operatingBotId, setOperatingBotId] = useState('');
|
||||
const [showBotLastActionModal, setShowBotLastActionModal] = useState(false);
|
||||
const [showResourceModal, setShowResourceModal] = useState(false);
|
||||
const [selectedBotDetail, setSelectedBotDetail] = useState<BotState | null>(null);
|
||||
const [selectedBotUsageSummary, setSelectedBotUsageSummary] = useState<PlatformUsageResponse['summary'] | null>(null);
|
||||
const [resourceBotId, setResourceBotId] = useState('');
|
||||
const [resourceSnapshot, setResourceSnapshot] = useState<PlatformBotResourceSnapshot | null>(null);
|
||||
const [resourceLoading, setResourceLoading] = useState(false);
|
||||
const [resourceError, setResourceError] = useState('');
|
||||
const [usageData, setUsageData] = useState<PlatformUsageResponse | null>(null);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [activityStatsData, setActivityStatsData] = useState<BotActivityStatsItem[] | null>(null);
|
||||
const [activityLoading, setActivityLoading] = useState(false);
|
||||
const [usagePageSize, setUsagePageSize] = useState(() => readCachedPlatformPageSize(10));
|
||||
const [botListPage, setBotListPage] = useState(1);
|
||||
const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10));
|
||||
const [showCompactBotSheet, setShowCompactBotSheet] = useState(false);
|
||||
const [compactSheetClosing, setCompactSheetClosing] = useState(false);
|
||||
const [compactSheetMounted, setCompactSheetMounted] = useState(false);
|
||||
const compactSheetTimerRef = useRef<number | null>(null);
|
||||
|
||||
const botList = useMemo(() => {
|
||||
return sortBotsByCreatedAtDesc(Object.values(activeBots)) as BotState[];
|
||||
}, [activeBots]);
|
||||
const overviewState = usePlatformOverviewState({
|
||||
isAdminMode,
|
||||
isZh,
|
||||
notify,
|
||||
});
|
||||
|
||||
const filteredBots = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) return botList;
|
||||
return botList.filter((bot) => `${bot.name} ${bot.id}`.toLowerCase().includes(keyword));
|
||||
}, [botList, search]);
|
||||
|
||||
const botListPageCount = useMemo(
|
||||
() => Math.max(1, Math.ceil(filteredBots.length / botListPageSize)),
|
||||
[filteredBots.length, botListPageSize],
|
||||
const botList = useMemo(
|
||||
() => sortBotsByCreatedAtDesc(Object.values(activeBots)) as BotState[],
|
||||
[activeBots],
|
||||
);
|
||||
|
||||
const pagedBots = useMemo(() => {
|
||||
const page = Math.min(Math.max(1, botListPage), botListPageCount);
|
||||
const start = (page - 1) * botListPageSize;
|
||||
return filteredBots.slice(start, start + botListPageSize);
|
||||
}, [filteredBots, botListPage, botListPageCount, botListPageSize]);
|
||||
|
||||
const selectedBot = useMemo(
|
||||
() => (selectedBotId ? botList.find((bot) => bot.id === selectedBotId) : undefined),
|
||||
[botList, selectedBotId],
|
||||
);
|
||||
const management = usePlatformManagementState({
|
||||
botList,
|
||||
compactMode,
|
||||
isManagementMode,
|
||||
isZh,
|
||||
platformPageSize: overviewState.platformPageSize,
|
||||
});
|
||||
|
||||
const loadBots = useCallback(async () => {
|
||||
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
|
||||
setBots(res.data);
|
||||
}, [setBots]);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get<PlatformOverviewResponse>(`${APP_ENDPOINTS.apiBase}/platform/overview`);
|
||||
setOverview(res.data);
|
||||
const normalizedPageSize = normalizePlatformPageSize(
|
||||
res.data?.settings?.page_size,
|
||||
readCachedPlatformPageSize(10),
|
||||
);
|
||||
writeCachedPlatformPageSize(normalizedPageSize);
|
||||
setUsagePageSize(normalizedPageSize);
|
||||
setBotListPageSize(normalizedPageSize);
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '读取平台总览失败。' : 'Failed to load platform overview.'), { tone: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isZh, notify]);
|
||||
|
||||
const loadUsage = useCallback(async (page = 1) => {
|
||||
setUsageLoading(true);
|
||||
try {
|
||||
const res = await axios.get<PlatformUsageResponse>(`${APP_ENDPOINTS.apiBase}/platform/usage`, {
|
||||
params: {
|
||||
limit: usagePageSize,
|
||||
offset: Math.max(0, page - 1) * usagePageSize,
|
||||
},
|
||||
});
|
||||
setUsageData(res.data);
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '读取用量统计失败。' : 'Failed to load usage analytics.'), { tone: 'error' });
|
||||
} finally {
|
||||
setUsageLoading(false);
|
||||
}
|
||||
}, [isZh, notify, usagePageSize]);
|
||||
|
||||
const loadActivityStats = useCallback(async () => {
|
||||
setActivityLoading(true);
|
||||
try {
|
||||
const res = await axios.get<{ items: BotActivityStatsItem[] }>(`${APP_ENDPOINTS.apiBase}/platform/activity-stats`);
|
||||
setActivityStatsData(Array.isArray(res.data?.items) ? res.data.items : []);
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '读取 Bot 活跃度统计失败。' : 'Failed to load bot activity analytics.'), { tone: 'error' });
|
||||
} finally {
|
||||
setActivityLoading(false);
|
||||
}
|
||||
}, [isZh, notify]);
|
||||
|
||||
const loadSelectedBotUsageSummary = useCallback(async (botId: string) => {
|
||||
if (!botId) {
|
||||
setSelectedBotUsageSummary(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await axios.get<PlatformUsageResponse>(`${APP_ENDPOINTS.apiBase}/platform/usage`, {
|
||||
params: {
|
||||
bot_id: botId,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
setSelectedBotUsageSummary(res.data?.summary || null);
|
||||
} catch {
|
||||
setSelectedBotUsageSummary(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadResourceSnapshot = useCallback(async (botId: string) => {
|
||||
if (!botId) return;
|
||||
setResourceLoading(true);
|
||||
setResourceError('');
|
||||
try {
|
||||
const res = await axios.get<PlatformBotResourceSnapshot>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(botId)}/resources`);
|
||||
setResourceSnapshot(res.data);
|
||||
} catch (error: any) {
|
||||
const msg = error?.response?.data?.detail || (isZh ? '读取资源监控失败。' : 'Failed to load resource metrics.');
|
||||
setResourceError(String(msg));
|
||||
} finally {
|
||||
setResourceLoading(false);
|
||||
}
|
||||
}, [isZh]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
}, [loadOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdminMode) {
|
||||
setUsageData(null);
|
||||
setUsageLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadUsage(1);
|
||||
}, [isAdminMode, loadUsage, usagePageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdminMode) {
|
||||
setActivityStatsData(null);
|
||||
setActivityLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadActivityStats();
|
||||
}, [isAdminMode, loadActivityStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) return;
|
||||
setBotListPage(1);
|
||||
}, [botListPageSize, isManagementMode, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) return;
|
||||
setBotListPage((prev) => Math.min(Math.max(prev, 1), botListPageCount));
|
||||
}, [botListPageCount, isManagementMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) return;
|
||||
if (!selectedBotId && filteredBots[0]?.id) setSelectedBotId(filteredBots[0].id);
|
||||
}, [filteredBots, isManagementMode, selectedBotId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) {
|
||||
setShowCompactBotSheet(false);
|
||||
setCompactSheetClosing(false);
|
||||
setCompactSheetMounted(false);
|
||||
return;
|
||||
}
|
||||
if (!compactMode) {
|
||||
setShowCompactBotSheet(false);
|
||||
setCompactSheetClosing(false);
|
||||
setCompactSheetMounted(false);
|
||||
return;
|
||||
}
|
||||
if (selectedBotId && showCompactBotSheet) return;
|
||||
if (!selectedBotId) setShowCompactBotSheet(false);
|
||||
}, [compactMode, isManagementMode, selectedBotId, showCompactBotSheet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) {
|
||||
setSelectedBotDetail(null);
|
||||
setSelectedBotUsageSummary(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedBotId) {
|
||||
setSelectedBotDetail(null);
|
||||
setSelectedBotUsageSummary(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await axios.get<BotState>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(selectedBotId)}`);
|
||||
if (alive) {
|
||||
setSelectedBotDetail(res.data);
|
||||
}
|
||||
} catch {
|
||||
if (alive) {
|
||||
setSelectedBotDetail(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
void loadSelectedBotUsageSummary(selectedBotId);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [isManagementMode, loadSelectedBotUsageSummary, selectedBotId]);
|
||||
|
||||
const resourceBot = useMemo(
|
||||
() => (resourceBotId ? botList.find((bot) => bot.id === resourceBotId) : undefined),
|
||||
[botList, resourceBotId],
|
||||
);
|
||||
|
||||
const selectedBotInfo = useMemo(() => {
|
||||
if (selectedBotDetail && selectedBotDetail.id === selectedBotId) {
|
||||
return {
|
||||
...selectedBot,
|
||||
...selectedBotDetail,
|
||||
logs: (selectedBotDetail.logs && selectedBotDetail.logs.length > 0)
|
||||
? selectedBotDetail.logs
|
||||
: (selectedBot?.logs || []),
|
||||
messages: (selectedBotDetail.messages && selectedBotDetail.messages.length > 0)
|
||||
? selectedBotDetail.messages
|
||||
: (selectedBot?.messages || []),
|
||||
events: (selectedBotDetail.events && selectedBotDetail.events.length > 0)
|
||||
? selectedBotDetail.events
|
||||
: (selectedBot?.events || []),
|
||||
} as BotState;
|
||||
}
|
||||
return selectedBot;
|
||||
}, [selectedBot, selectedBotDetail, selectedBotId]);
|
||||
|
||||
const lastActionPreview = useMemo(
|
||||
() => selectedBotInfo?.last_action?.trim() || '',
|
||||
[selectedBotInfo?.last_action],
|
||||
);
|
||||
|
||||
const overviewBots = overview?.summary.bots;
|
||||
const overviewImages = overview?.summary.images;
|
||||
const overviewResources = overview?.summary.resources;
|
||||
const activityStats = activityStatsData || overview?.activity_stats;
|
||||
const usageSummary = usageData?.summary || overview?.usage.summary;
|
||||
const usageAnalytics = usageData?.analytics || overview?.usage.analytics || null;
|
||||
|
||||
const usageAnalyticsSeries = useMemo<PlatformUsageAnalyticsSeriesItem[]>(
|
||||
() => buildPlatformUsageAnalyticsSeries(usageAnalytics, isZh),
|
||||
[isZh, usageAnalytics],
|
||||
);
|
||||
|
||||
const usageAnalyticsMax = useMemo(() => {
|
||||
const maxDailyRequests = usageAnalyticsSeries.reduce(
|
||||
(max, item) => Math.max(max, ...item.daily_counts.map((count) => Number(count || 0))),
|
||||
0,
|
||||
);
|
||||
return getPlatformChartCeiling(maxDailyRequests);
|
||||
}, [usageAnalyticsSeries]);
|
||||
|
||||
const usageAnalyticsTicks = useMemo(() => buildPlatformUsageAnalyticsTicks(usageAnalyticsMax), [usageAnalyticsMax]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
const jobs: Promise<unknown>[] = [loadOverview(), loadBots()];
|
||||
const jobs: Promise<unknown>[] = [overviewState.loadOverview(), loadBots()];
|
||||
if (isAdminMode) {
|
||||
jobs.push(loadUsage(), loadActivityStats());
|
||||
jobs.push(overviewState.loadUsage(), overviewState.loadActivityStats());
|
||||
}
|
||||
if (management.selectedBotId) {
|
||||
jobs.push(management.refreshSelectedBot());
|
||||
}
|
||||
if (selectedBotId) jobs.push(loadSelectedBotUsageSummary(selectedBotId));
|
||||
await Promise.allSettled(jobs);
|
||||
}, [isAdminMode, loadActivityStats, loadBots, loadOverview, loadSelectedBotUsageSummary, loadUsage, selectedBotId]);
|
||||
}, [
|
||||
isAdminMode,
|
||||
loadBots,
|
||||
management,
|
||||
overviewState,
|
||||
]);
|
||||
|
||||
const toggleBot = useCallback(async (bot: BotState) => {
|
||||
setOperatingBotId(bot.id);
|
||||
|
|
@ -329,8 +87,8 @@ export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePl
|
|||
updateBotStatus(bot.id, 'RUNNING');
|
||||
}
|
||||
await refreshAll();
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? 'Bot 操作失败。' : 'Bot action failed.'), { tone: 'error' });
|
||||
} catch (error: unknown) {
|
||||
notify(getRequestErrorDetail(error) || (isZh ? 'Bot 操作失败。' : 'Bot action failed.'), { tone: 'error' });
|
||||
} finally {
|
||||
setOperatingBotId('');
|
||||
}
|
||||
|
|
@ -341,8 +99,8 @@ export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePl
|
|||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${bot.id}/${enabled ? 'enable' : 'disable'}`);
|
||||
await refreshAll();
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '更新 Bot 状态失败。' : 'Failed to update bot status.'), { tone: 'error' });
|
||||
} catch (error: unknown) {
|
||||
notify(getRequestErrorDetail(error) || (isZh ? '更新 Bot 状态失败。' : 'Failed to update bot status.'), { tone: 'error' });
|
||||
} finally {
|
||||
setOperatingBotId('');
|
||||
}
|
||||
|
|
@ -363,19 +121,19 @@ export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePl
|
|||
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}`, {
|
||||
params: { delete_workspace: true },
|
||||
});
|
||||
if (selectedBotId === targetId) {
|
||||
setSelectedBotId('');
|
||||
setSelectedBotDetail(null);
|
||||
setShowBotLastActionModal(false);
|
||||
if (management.selectedBotId === targetId) {
|
||||
management.setSelectedBotId('');
|
||||
management.setSelectedBotDetail(null);
|
||||
management.setShowBotLastActionModal(false);
|
||||
}
|
||||
await refreshAll();
|
||||
notify(isZh ? 'Bot 已删除。' : 'Bot deleted.', { tone: 'success' });
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '删除 Bot 失败。' : 'Failed to delete bot.'), { tone: 'error' });
|
||||
} catch (error: unknown) {
|
||||
notify(getRequestErrorDetail(error) || (isZh ? '删除 Bot 失败。' : 'Failed to delete bot.'), { tone: 'error' });
|
||||
} finally {
|
||||
setOperatingBotId('');
|
||||
}
|
||||
}, [confirm, isZh, notify, refreshAll, selectedBotId]);
|
||||
}, [confirm, isZh, management, notify, refreshAll]);
|
||||
|
||||
const clearDashboardDirectSession = useCallback(async (bot: BotState) => {
|
||||
const targetId = String(bot.id || '').trim();
|
||||
|
|
@ -395,131 +153,69 @@ export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePl
|
|||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}/sessions/dashboard-direct/clear`);
|
||||
notify(isZh ? '面板 Session 已清空。' : 'Dashboard session cleared.', { tone: 'success' });
|
||||
await refreshAll();
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '清空面板 Session 失败。' : 'Failed to clear dashboard session.'), { tone: 'error' });
|
||||
} catch (error: unknown) {
|
||||
notify(getRequestErrorDetail(error) || (isZh ? '清空面板 Session 失败。' : 'Failed to clear dashboard session.'), { tone: 'error' });
|
||||
} finally {
|
||||
setOperatingBotId('');
|
||||
}
|
||||
}, [confirm, isZh, notify, refreshAll]);
|
||||
|
||||
const openResourceMonitor = useCallback((botId: string) => {
|
||||
setResourceBotId(botId);
|
||||
setShowResourceModal(true);
|
||||
void loadResourceSnapshot(botId);
|
||||
}, [loadResourceSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) {
|
||||
setCompactSheetMounted(false);
|
||||
setCompactSheetClosing(false);
|
||||
if (compactSheetTimerRef.current) {
|
||||
window.clearTimeout(compactSheetTimerRef.current);
|
||||
compactSheetTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (compactMode && showCompactBotSheet && selectedBotInfo) {
|
||||
if (compactSheetTimerRef.current) {
|
||||
window.clearTimeout(compactSheetTimerRef.current);
|
||||
compactSheetTimerRef.current = null;
|
||||
}
|
||||
setCompactSheetMounted(true);
|
||||
setCompactSheetClosing(false);
|
||||
return;
|
||||
}
|
||||
if (!compactSheetMounted) return;
|
||||
setCompactSheetClosing(true);
|
||||
compactSheetTimerRef.current = window.setTimeout(() => {
|
||||
setCompactSheetMounted(false);
|
||||
setCompactSheetClosing(false);
|
||||
compactSheetTimerRef.current = null;
|
||||
}, 240);
|
||||
return () => {
|
||||
if (compactSheetTimerRef.current) {
|
||||
window.clearTimeout(compactSheetTimerRef.current);
|
||||
compactSheetTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [compactMode, compactSheetMounted, isManagementMode, selectedBotInfo, showCompactBotSheet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showResourceModal || !resourceBotId) return;
|
||||
let stopped = false;
|
||||
const tick = async () => {
|
||||
if (stopped) return;
|
||||
await loadResourceSnapshot(resourceBotId);
|
||||
};
|
||||
const timer = window.setInterval(() => {
|
||||
void tick();
|
||||
}, 2000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [loadResourceSnapshot, resourceBotId, showResourceModal]);
|
||||
|
||||
const handleSelectBot = useCallback((botId: string) => {
|
||||
setSelectedBotId(botId);
|
||||
if (compactMode) setShowCompactBotSheet(true);
|
||||
}, [compactMode]);
|
||||
|
||||
const closeCompactBotSheet = useCallback(() => setShowCompactBotSheet(false), []);
|
||||
|
||||
const openBotPanel = useCallback((botId: string) => {
|
||||
if (!botId || typeof window === 'undefined') return;
|
||||
window.open(buildBotPanelHref(botId), '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
const closeResourceModal = useCallback(() => setShowResourceModal(false), []);
|
||||
|
||||
return {
|
||||
botListPage,
|
||||
botListPageCount,
|
||||
botListPageSize,
|
||||
closeCompactBotSheet,
|
||||
closeResourceModal,
|
||||
botListPage: management.botListPage,
|
||||
botListPageCount: management.botListPageCount,
|
||||
botListPageSize: overviewState.platformPageSize,
|
||||
closeCompactBotSheet: management.closeCompactBotSheet,
|
||||
closeResourceModal: management.closeResourceModal,
|
||||
clearDashboardDirectSession,
|
||||
compactSheetClosing,
|
||||
compactSheetMounted,
|
||||
filteredBots,
|
||||
handleSelectBot,
|
||||
compactSheetClosing: management.compactSheetClosing,
|
||||
compactSheetMounted: management.compactSheetMounted,
|
||||
filteredBots: management.filteredBots,
|
||||
handleSelectBot: management.handleSelectBot,
|
||||
isZh,
|
||||
lastActionPreview,
|
||||
loadResourceSnapshot,
|
||||
loading,
|
||||
lastActionPreview: management.lastActionPreview,
|
||||
loadResourceSnapshot: management.loadResourceSnapshot,
|
||||
loading: overviewState.loading,
|
||||
openBotPanel,
|
||||
openResourceMonitor,
|
||||
openResourceMonitor: management.openResourceMonitor,
|
||||
operatingBotId,
|
||||
overview,
|
||||
overviewBots,
|
||||
overviewImages,
|
||||
overviewResources,
|
||||
pagedBots,
|
||||
overview: overviewState.overview,
|
||||
overviewBots: overviewState.overviewBots,
|
||||
overviewImages: overviewState.overviewImages,
|
||||
overviewResources: overviewState.overviewResources,
|
||||
pagedBots: management.pagedBots,
|
||||
refreshAll,
|
||||
removeBot,
|
||||
resourceBot,
|
||||
resourceBotId,
|
||||
resourceError,
|
||||
resourceLoading,
|
||||
resourceSnapshot,
|
||||
search,
|
||||
selectedBotId,
|
||||
selectedBotInfo,
|
||||
selectedBotUsageSummary,
|
||||
resourceBot: management.resourceBot,
|
||||
resourceBotId: management.resourceBotId,
|
||||
resourceError: management.resourceError,
|
||||
resourceLoading: management.resourceLoading,
|
||||
resourceSnapshot: management.resourceSnapshot,
|
||||
search: management.search,
|
||||
selectedBotId: management.selectedBotId,
|
||||
selectedBotEnabledChannels: management.selectedBotEnabledChannels,
|
||||
selectedBotInfo: management.selectedBotInfo,
|
||||
selectedBotMcpCount: management.selectedBotMcpCount,
|
||||
selectedBotSkillCount: management.selectedBotSkillCount,
|
||||
selectedBotUsageSummary: management.selectedBotUsageSummary,
|
||||
setBotEnabled,
|
||||
setBotListPage,
|
||||
setSearch,
|
||||
setShowBotLastActionModal,
|
||||
showBotLastActionModal,
|
||||
showResourceModal,
|
||||
setBotListPage: management.setBotListPage,
|
||||
setSearch: management.setSearch,
|
||||
setShowBotLastActionModal: management.setShowBotLastActionModal,
|
||||
showBotLastActionModal: management.showBotLastActionModal,
|
||||
showResourceModal: management.showResourceModal,
|
||||
toggleBot,
|
||||
usageAnalytics,
|
||||
activityStats,
|
||||
activityLoading,
|
||||
usageAnalyticsMax,
|
||||
usageAnalyticsSeries,
|
||||
usageAnalyticsTicks,
|
||||
usageLoading,
|
||||
usageSummary,
|
||||
usageAnalytics: overviewState.usageAnalytics,
|
||||
activityStats: overviewState.activityStats,
|
||||
activityLoading: overviewState.activityLoading,
|
||||
usageAnalyticsMax: overviewState.usageAnalyticsMax,
|
||||
usageAnalyticsSeries: overviewState.usageAnalyticsSeries,
|
||||
usageAnalyticsTicks: overviewState.usageAnalyticsTicks,
|
||||
usageLoading: overviewState.usageLoading,
|
||||
usageSummary: overviewState.usageSummary,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,370 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
import { APP_ENDPOINTS } from '../../../config/env';
|
||||
import type { BotChannel, MCPConfigResponse, WorkspaceSkillOption } from '../../dashboard/types';
|
||||
import type { BotState } from '../../../types/bot';
|
||||
import type { PlatformBotResourceSnapshot, PlatformUsageResponse } from '../types';
|
||||
|
||||
interface UsePlatformManagementStateOptions {
|
||||
botList: BotState[];
|
||||
compactMode: boolean;
|
||||
isManagementMode: boolean;
|
||||
isZh: boolean;
|
||||
platformPageSize: number;
|
||||
}
|
||||
|
||||
export function usePlatformManagementState({
|
||||
botList,
|
||||
compactMode,
|
||||
isManagementMode,
|
||||
isZh,
|
||||
platformPageSize,
|
||||
}: UsePlatformManagementStateOptions) {
|
||||
const [selectedBotId, setSelectedBotId] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [botListPage, setBotListPage] = useState(1);
|
||||
const [showBotLastActionModal, setShowBotLastActionModal] = useState(false);
|
||||
const [showResourceModal, setShowResourceModal] = useState(false);
|
||||
const [selectedBotDetail, setSelectedBotDetail] = useState<BotState | null>(null);
|
||||
const [selectedBotUsageSummary, setSelectedBotUsageSummary] = useState<PlatformUsageResponse['summary'] | null>(null);
|
||||
const [selectedBotEnabledChannels, setSelectedBotEnabledChannels] = useState<string[]>([]);
|
||||
const [selectedBotSkillCount, setSelectedBotSkillCount] = useState(0);
|
||||
const [selectedBotMcpCount, setSelectedBotMcpCount] = useState(0);
|
||||
const [resourceBotId, setResourceBotId] = useState('');
|
||||
const [resourceSnapshot, setResourceSnapshot] = useState<PlatformBotResourceSnapshot | null>(null);
|
||||
const [resourceLoading, setResourceLoading] = useState(false);
|
||||
const [resourceError, setResourceError] = useState('');
|
||||
const [showCompactBotSheet, setShowCompactBotSheet] = useState(false);
|
||||
const [compactSheetClosing, setCompactSheetClosing] = useState(false);
|
||||
const [compactSheetMounted, setCompactSheetMounted] = useState(false);
|
||||
|
||||
const compactSheetTimerRef = useRef<number | null>(null);
|
||||
const selectedBotExtrasRequestRef = useRef(0);
|
||||
|
||||
const filteredBots = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) return botList;
|
||||
return botList.filter((bot) => `${bot.name} ${bot.id}`.toLowerCase().includes(keyword));
|
||||
}, [botList, search]);
|
||||
|
||||
const botListPageCount = useMemo(
|
||||
() => Math.max(1, Math.ceil(filteredBots.length / platformPageSize)),
|
||||
[filteredBots.length, platformPageSize],
|
||||
);
|
||||
|
||||
const pagedBots = useMemo(() => {
|
||||
const page = Math.min(Math.max(1, botListPage), botListPageCount);
|
||||
const start = (page - 1) * platformPageSize;
|
||||
return filteredBots.slice(start, start + platformPageSize);
|
||||
}, [filteredBots, botListPage, botListPageCount, platformPageSize]);
|
||||
|
||||
const selectedBot = useMemo(
|
||||
() => (selectedBotId ? botList.find((bot) => bot.id === selectedBotId) : undefined),
|
||||
[botList, selectedBotId],
|
||||
);
|
||||
|
||||
const loadSelectedBotUsageSummary = useCallback(async (botId: string) => {
|
||||
if (!botId) {
|
||||
setSelectedBotUsageSummary(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await axios.get<PlatformUsageResponse>(`${APP_ENDPOINTS.apiBase}/platform/usage`, {
|
||||
params: {
|
||||
bot_id: botId,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
setSelectedBotUsageSummary(res.data?.summary || null);
|
||||
} catch {
|
||||
setSelectedBotUsageSummary(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSelectedBotDetail = useCallback(async (botId: string) => {
|
||||
const targetBotId = String(botId || '').trim();
|
||||
if (!targetBotId) {
|
||||
setSelectedBotDetail(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await axios.get<BotState>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetBotId)}`);
|
||||
setSelectedBotDetail(res.data);
|
||||
} catch {
|
||||
setSelectedBotDetail(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSelectedBotOverviewExtras = useCallback(async (botId: string) => {
|
||||
const requestId = selectedBotExtrasRequestRef.current + 1;
|
||||
selectedBotExtrasRequestRef.current = requestId;
|
||||
|
||||
if (!botId) {
|
||||
setSelectedBotEnabledChannels([]);
|
||||
setSelectedBotSkillCount(0);
|
||||
setSelectedBotMcpCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const [channelsResult, skillsResult, mcpResult] = await Promise.allSettled([
|
||||
axios.get<BotChannel[]>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(botId)}/channels`),
|
||||
axios.get<WorkspaceSkillOption[]>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(botId)}/skills`),
|
||||
axios.get<MCPConfigResponse>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(botId)}/mcp-config`),
|
||||
]);
|
||||
|
||||
if (selectedBotExtrasRequestRef.current !== requestId) return;
|
||||
|
||||
const enabledChannels = channelsResult.status === 'fulfilled'
|
||||
? Array.from(
|
||||
new Set(
|
||||
(Array.isArray(channelsResult.value.data) ? channelsResult.value.data : [])
|
||||
.filter((channel) => channel.is_active && String(channel.channel_type || '').trim().toLowerCase() !== 'dashboard')
|
||||
.map((channel) => String(channel.channel_type || '').trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
const skillCount = skillsResult.status === 'fulfilled'
|
||||
? (Array.isArray(skillsResult.value.data) ? skillsResult.value.data.length : 0)
|
||||
: 0;
|
||||
|
||||
const mcpCount = mcpResult.status === 'fulfilled'
|
||||
? Object.keys(mcpResult.value.data?.mcp_servers || {}).filter((name) => String(name || '').trim().length > 0).length
|
||||
: 0;
|
||||
|
||||
setSelectedBotEnabledChannels(enabledChannels);
|
||||
setSelectedBotSkillCount(skillCount);
|
||||
setSelectedBotMcpCount(mcpCount);
|
||||
}, []);
|
||||
|
||||
const loadResourceSnapshot = useCallback(async (botId: string) => {
|
||||
if (!botId) return;
|
||||
setResourceLoading(true);
|
||||
setResourceError('');
|
||||
try {
|
||||
const res = await axios.get<PlatformBotResourceSnapshot>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(botId)}/resources`);
|
||||
setResourceSnapshot(res.data);
|
||||
} catch (error: any) {
|
||||
const msg = error?.response?.data?.detail || (isZh ? '读取资源监控失败。' : 'Failed to load resource metrics.');
|
||||
setResourceError(String(msg));
|
||||
} finally {
|
||||
setResourceLoading(false);
|
||||
}
|
||||
}, [isZh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) return;
|
||||
setBotListPage(1);
|
||||
}, [platformPageSize, isManagementMode, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) return;
|
||||
setBotListPage((prev) => Math.min(Math.max(prev, 1), botListPageCount));
|
||||
}, [botListPageCount, isManagementMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) return;
|
||||
if (!selectedBotId && filteredBots[0]?.id) setSelectedBotId(filteredBots[0].id);
|
||||
}, [filteredBots, isManagementMode, selectedBotId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) {
|
||||
setShowCompactBotSheet(false);
|
||||
setCompactSheetClosing(false);
|
||||
setCompactSheetMounted(false);
|
||||
return;
|
||||
}
|
||||
if (!compactMode) {
|
||||
setShowCompactBotSheet(false);
|
||||
setCompactSheetClosing(false);
|
||||
setCompactSheetMounted(false);
|
||||
return;
|
||||
}
|
||||
if (selectedBotId && showCompactBotSheet) return;
|
||||
if (!selectedBotId) setShowCompactBotSheet(false);
|
||||
}, [compactMode, isManagementMode, selectedBotId, showCompactBotSheet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) {
|
||||
selectedBotExtrasRequestRef.current += 1;
|
||||
setSelectedBotDetail(null);
|
||||
setSelectedBotUsageSummary(null);
|
||||
setSelectedBotEnabledChannels([]);
|
||||
setSelectedBotSkillCount(0);
|
||||
setSelectedBotMcpCount(0);
|
||||
return;
|
||||
}
|
||||
if (!selectedBotId) {
|
||||
selectedBotExtrasRequestRef.current += 1;
|
||||
setSelectedBotDetail(null);
|
||||
setSelectedBotUsageSummary(null);
|
||||
setSelectedBotEnabledChannels([]);
|
||||
setSelectedBotSkillCount(0);
|
||||
setSelectedBotMcpCount(0);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await axios.get<BotState>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(selectedBotId)}`);
|
||||
if (alive) {
|
||||
setSelectedBotDetail(res.data);
|
||||
}
|
||||
} catch {
|
||||
if (alive) {
|
||||
setSelectedBotDetail(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
void loadSelectedBotUsageSummary(selectedBotId);
|
||||
void loadSelectedBotOverviewExtras(selectedBotId);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [isManagementMode, loadSelectedBotOverviewExtras, loadSelectedBotUsageSummary, selectedBotId]);
|
||||
|
||||
const resourceBot = useMemo(
|
||||
() => (resourceBotId ? botList.find((bot) => bot.id === resourceBotId) : undefined),
|
||||
[botList, resourceBotId],
|
||||
);
|
||||
|
||||
const selectedBotInfo = useMemo(() => {
|
||||
if (selectedBotDetail && selectedBotDetail.id === selectedBotId) {
|
||||
return {
|
||||
...selectedBot,
|
||||
...selectedBotDetail,
|
||||
logs: (selectedBotDetail.logs && selectedBotDetail.logs.length > 0)
|
||||
? selectedBotDetail.logs
|
||||
: (selectedBot?.logs || []),
|
||||
messages: (selectedBotDetail.messages && selectedBotDetail.messages.length > 0)
|
||||
? selectedBotDetail.messages
|
||||
: (selectedBot?.messages || []),
|
||||
events: (selectedBotDetail.events && selectedBotDetail.events.length > 0)
|
||||
? selectedBotDetail.events
|
||||
: (selectedBot?.events || []),
|
||||
} as BotState;
|
||||
}
|
||||
return selectedBot;
|
||||
}, [selectedBot, selectedBotDetail, selectedBotId]);
|
||||
|
||||
const lastActionPreview = useMemo(
|
||||
() => selectedBotInfo?.last_action?.trim() || '',
|
||||
[selectedBotInfo?.last_action],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) {
|
||||
setCompactSheetMounted(false);
|
||||
setCompactSheetClosing(false);
|
||||
if (compactSheetTimerRef.current) {
|
||||
window.clearTimeout(compactSheetTimerRef.current);
|
||||
compactSheetTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (compactMode && showCompactBotSheet && selectedBotInfo) {
|
||||
if (compactSheetTimerRef.current) {
|
||||
window.clearTimeout(compactSheetTimerRef.current);
|
||||
compactSheetTimerRef.current = null;
|
||||
}
|
||||
setCompactSheetMounted(true);
|
||||
setCompactSheetClosing(false);
|
||||
return;
|
||||
}
|
||||
if (!compactSheetMounted) return;
|
||||
setCompactSheetClosing(true);
|
||||
compactSheetTimerRef.current = window.setTimeout(() => {
|
||||
setCompactSheetMounted(false);
|
||||
setCompactSheetClosing(false);
|
||||
compactSheetTimerRef.current = null;
|
||||
}, 240);
|
||||
return () => {
|
||||
if (compactSheetTimerRef.current) {
|
||||
window.clearTimeout(compactSheetTimerRef.current);
|
||||
compactSheetTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [compactMode, compactSheetMounted, isManagementMode, selectedBotInfo, showCompactBotSheet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showResourceModal || !resourceBotId) return;
|
||||
let stopped = false;
|
||||
const tick = async () => {
|
||||
if (stopped) return;
|
||||
await loadResourceSnapshot(resourceBotId);
|
||||
};
|
||||
const timer = window.setInterval(() => {
|
||||
void tick();
|
||||
}, 2000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [loadResourceSnapshot, resourceBotId, showResourceModal]);
|
||||
|
||||
const handleSelectBot = useCallback((botId: string) => {
|
||||
setSelectedBotId(botId);
|
||||
if (compactMode) setShowCompactBotSheet(true);
|
||||
}, [compactMode]);
|
||||
|
||||
const closeCompactBotSheet = useCallback(() => setShowCompactBotSheet(false), []);
|
||||
const closeResourceModal = useCallback(() => setShowResourceModal(false), []);
|
||||
const refreshSelectedBot = useCallback(async (botId?: string) => {
|
||||
const targetBotId = String(botId || selectedBotId || '').trim();
|
||||
if (!isManagementMode || !targetBotId) return;
|
||||
await Promise.allSettled([
|
||||
loadSelectedBotDetail(targetBotId),
|
||||
loadSelectedBotUsageSummary(targetBotId),
|
||||
loadSelectedBotOverviewExtras(targetBotId),
|
||||
]);
|
||||
}, [
|
||||
isManagementMode,
|
||||
loadSelectedBotDetail,
|
||||
loadSelectedBotOverviewExtras,
|
||||
loadSelectedBotUsageSummary,
|
||||
selectedBotId,
|
||||
]);
|
||||
|
||||
return {
|
||||
botListPage,
|
||||
botListPageCount,
|
||||
closeCompactBotSheet,
|
||||
closeResourceModal,
|
||||
compactSheetClosing,
|
||||
compactSheetMounted,
|
||||
filteredBots,
|
||||
handleSelectBot,
|
||||
lastActionPreview,
|
||||
loadResourceSnapshot,
|
||||
pagedBots,
|
||||
refreshSelectedBot,
|
||||
resourceBot,
|
||||
resourceBotId,
|
||||
resourceError,
|
||||
resourceLoading,
|
||||
resourceSnapshot,
|
||||
search,
|
||||
selectedBotId,
|
||||
selectedBotInfo,
|
||||
selectedBotUsageSummary,
|
||||
selectedBotEnabledChannels,
|
||||
selectedBotSkillCount,
|
||||
selectedBotMcpCount,
|
||||
setBotListPage,
|
||||
setSearch,
|
||||
setSelectedBotId,
|
||||
setSelectedBotDetail,
|
||||
setShowBotLastActionModal,
|
||||
setShowResourceModal,
|
||||
showBotLastActionModal,
|
||||
showResourceModal,
|
||||
openResourceMonitor: (botId: string) => {
|
||||
setResourceBotId(botId);
|
||||
setShowResourceModal(true);
|
||||
void loadResourceSnapshot(botId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
import { APP_ENDPOINTS } from '../../../config/env';
|
||||
import {
|
||||
normalizePlatformPageSize,
|
||||
readCachedPlatformPageSize,
|
||||
writeCachedPlatformPageSize,
|
||||
} from '../../../utils/platformPageSize';
|
||||
import type {
|
||||
BotActivityStatsItem,
|
||||
PlatformOverviewResponse,
|
||||
PlatformUsageAnalyticsSeriesItem,
|
||||
PlatformUsageResponse,
|
||||
} from '../types';
|
||||
import {
|
||||
buildPlatformUsageAnalyticsSeries,
|
||||
buildPlatformUsageAnalyticsTicks,
|
||||
getPlatformChartCeiling,
|
||||
} from '../utils';
|
||||
|
||||
interface UsePlatformOverviewStateOptions {
|
||||
isAdminMode: boolean;
|
||||
isZh: boolean;
|
||||
notify: (message: string, options?: { tone?: 'error' | 'success' | 'warning' | 'info' }) => void;
|
||||
}
|
||||
|
||||
export function usePlatformOverviewState({
|
||||
isAdminMode,
|
||||
isZh,
|
||||
notify,
|
||||
}: UsePlatformOverviewStateOptions) {
|
||||
const [overview, setOverview] = useState<PlatformOverviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [usageData, setUsageData] = useState<PlatformUsageResponse | null>(null);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [activityStatsData, setActivityStatsData] = useState<BotActivityStatsItem[] | null>(null);
|
||||
const [activityLoading, setActivityLoading] = useState(false);
|
||||
const [platformPageSize, setPlatformPageSize] = useState(() => readCachedPlatformPageSize(10));
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get<PlatformOverviewResponse>(`${APP_ENDPOINTS.apiBase}/platform/overview`);
|
||||
setOverview(res.data);
|
||||
const normalizedPageSize = normalizePlatformPageSize(
|
||||
res.data?.settings?.page_size,
|
||||
readCachedPlatformPageSize(10),
|
||||
);
|
||||
writeCachedPlatformPageSize(normalizedPageSize);
|
||||
setPlatformPageSize(normalizedPageSize);
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '读取平台总览失败。' : 'Failed to load platform overview.'), { tone: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isZh, notify]);
|
||||
|
||||
const loadUsage = useCallback(async (page = 1) => {
|
||||
setUsageLoading(true);
|
||||
try {
|
||||
const res = await axios.get<PlatformUsageResponse>(`${APP_ENDPOINTS.apiBase}/platform/usage`, {
|
||||
params: {
|
||||
limit: platformPageSize,
|
||||
offset: Math.max(0, page - 1) * platformPageSize,
|
||||
},
|
||||
});
|
||||
setUsageData(res.data);
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '读取用量统计失败。' : 'Failed to load usage analytics.'), { tone: 'error' });
|
||||
} finally {
|
||||
setUsageLoading(false);
|
||||
}
|
||||
}, [isZh, notify, platformPageSize]);
|
||||
|
||||
const loadActivityStats = useCallback(async () => {
|
||||
setActivityLoading(true);
|
||||
try {
|
||||
const res = await axios.get<{ items: BotActivityStatsItem[] }>(`${APP_ENDPOINTS.apiBase}/platform/activity-stats`);
|
||||
setActivityStatsData(Array.isArray(res.data?.items) ? res.data.items : []);
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '读取 Bot 活跃度统计失败。' : 'Failed to load bot activity analytics.'), { tone: 'error' });
|
||||
} finally {
|
||||
setActivityLoading(false);
|
||||
}
|
||||
}, [isZh, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
}, [loadOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdminMode) {
|
||||
setUsageData(null);
|
||||
setUsageLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadUsage(1);
|
||||
}, [isAdminMode, loadUsage, platformPageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdminMode) {
|
||||
setActivityStatsData(null);
|
||||
setActivityLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadActivityStats();
|
||||
}, [isAdminMode, loadActivityStats]);
|
||||
|
||||
const overviewBots = overview?.summary.bots;
|
||||
const overviewImages = overview?.summary.images;
|
||||
const overviewResources = overview?.summary.resources;
|
||||
const activityStats = activityStatsData || overview?.activity_stats;
|
||||
const usageSummary = usageData?.summary || overview?.usage.summary;
|
||||
const usageAnalytics = usageData?.analytics || overview?.usage.analytics || null;
|
||||
|
||||
const usageAnalyticsSeries = useMemo<PlatformUsageAnalyticsSeriesItem[]>(
|
||||
() => buildPlatformUsageAnalyticsSeries(usageAnalytics, isZh),
|
||||
[isZh, usageAnalytics],
|
||||
);
|
||||
|
||||
const usageAnalyticsMax = useMemo(() => {
|
||||
const maxDailyRequests = usageAnalyticsSeries.reduce(
|
||||
(max, item) => Math.max(max, ...item.daily_counts.map((count) => Number(count || 0))),
|
||||
0,
|
||||
);
|
||||
return getPlatformChartCeiling(maxDailyRequests);
|
||||
}, [usageAnalyticsSeries]);
|
||||
|
||||
const usageAnalyticsTicks = useMemo(
|
||||
() => buildPlatformUsageAnalyticsTicks(usageAnalyticsMax),
|
||||
[usageAnalyticsMax],
|
||||
);
|
||||
|
||||
return {
|
||||
activityLoading,
|
||||
activityStats,
|
||||
loadActivityStats,
|
||||
loadOverview,
|
||||
loadUsage,
|
||||
loading,
|
||||
overview,
|
||||
overviewBots,
|
||||
overviewImages,
|
||||
overviewResources,
|
||||
platformPageSize,
|
||||
usageAnalytics,
|
||||
usageAnalyticsMax,
|
||||
usageAnalyticsSeries,
|
||||
usageAnalyticsTicks,
|
||||
usageLoading,
|
||||
usageSummary,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { MARKDOWN_SANITIZE_SCHEMA } from '../workspace/constants';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
components?: Components;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({
|
||||
content,
|
||||
components,
|
||||
}: MarkdownRendererProps) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +1,12 @@
|
|||
import axios from 'axios';
|
||||
|
||||
let initialized = false;
|
||||
const memoryMap = new Map<string, string>();
|
||||
const BOT_STORAGE_KEY_PREFIX = 'nanobot-bot-page-password:';
|
||||
export const BOT_AUTH_INVALID_EVENT = 'nanobot:bot-auth-invalid';
|
||||
|
||||
function normalizeBotId(raw: string): string {
|
||||
return String(raw || '').trim();
|
||||
}
|
||||
|
||||
function buildBotAccessStorageKey(botId: string): string {
|
||||
return `${BOT_STORAGE_KEY_PREFIX}${normalizeBotId(botId)}`;
|
||||
}
|
||||
|
||||
function resolveAbsoluteUrl(input: string): string {
|
||||
const url = String(input || '').trim();
|
||||
if (!url) return '';
|
||||
|
|
@ -40,49 +35,6 @@ export function extractBotIdFromApiPath(rawPath: string): string | null {
|
|||
}
|
||||
}
|
||||
|
||||
export function getBotAccessPassword(botId: string): string {
|
||||
const key = normalizeBotId(botId);
|
||||
if (!key) return '';
|
||||
const cached = memoryMap.get(key) || '';
|
||||
if (cached) return cached;
|
||||
if (typeof window === 'undefined') return '';
|
||||
const stored = window.sessionStorage.getItem(buildBotAccessStorageKey(key)) || '';
|
||||
if (stored) memoryMap.set(key, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function setBotAccessPassword(botId: string, password: string): void {
|
||||
const key = normalizeBotId(botId);
|
||||
const value = String(password || '').trim();
|
||||
if (!key) return;
|
||||
if (value) {
|
||||
memoryMap.set(key, value);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem(buildBotAccessStorageKey(key), value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
clearBotAccessPassword(key);
|
||||
}
|
||||
|
||||
export function clearBotAccessPassword(botId: string): void {
|
||||
const key = normalizeBotId(botId);
|
||||
if (!key) return;
|
||||
memoryMap.delete(key);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.removeItem(buildBotAccessStorageKey(key));
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllBotAccessPasswords(): void {
|
||||
if (memoryMap.size === 0) return;
|
||||
const keys = Array.from(memoryMap.keys());
|
||||
memoryMap.clear();
|
||||
if (typeof window !== 'undefined') {
|
||||
keys.forEach((botId) => window.sessionStorage.removeItem(buildBotAccessStorageKey(botId)));
|
||||
}
|
||||
}
|
||||
|
||||
function isBotAuthRoute(rawPath: string, botId: string): boolean {
|
||||
const path = resolveAbsoluteUrl(rawPath);
|
||||
return path === `/api/bots/${encodeURIComponent(botId)}/auth/login`
|
||||
|
|
@ -93,15 +45,25 @@ function isBotAuthRoute(rawPath: string, botId: string): boolean {
|
|||
export function notifyBotAuthInvalid(botId: string): void {
|
||||
const normalizedBotId = normalizeBotId(botId);
|
||||
if (!normalizedBotId) return;
|
||||
clearBotAccessPassword(normalizedBotId);
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent(BOT_AUTH_INVALID_EVENT, { detail: { botId: normalizedBotId } }));
|
||||
}
|
||||
|
||||
export function isBotUnauthorizedError(error: any, botId?: string): boolean {
|
||||
const normalizedBotId = normalizeBotId(botId || extractBotIdFromApiPath(String(error?.config?.url || '')) || '');
|
||||
interface UnauthorizedErrorShape {
|
||||
response?: {
|
||||
status?: number;
|
||||
};
|
||||
config?: {
|
||||
url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isBotUnauthorizedError(error: unknown, botId?: string): boolean {
|
||||
const resolvedError = (error && typeof error === 'object' ? error : {}) as UnauthorizedErrorShape;
|
||||
const normalizedBotId = normalizeBotId(botId || extractBotIdFromApiPath(String(resolvedError.config?.url || '')) || '');
|
||||
if (!normalizedBotId) return false;
|
||||
return Number(error?.response?.status || 0) === 401 && !isBotAuthRoute(String(error?.config?.url || ''), normalizedBotId);
|
||||
return Number(resolvedError.response?.status || 0) === 401
|
||||
&& !isBotAuthRoute(String(resolvedError.config?.url || ''), normalizedBotId);
|
||||
}
|
||||
|
||||
export function buildMonitorWsUrl(base: string, botId: string): string {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const PANEL_STORAGE_KEY = 'nanobot-panel-access-password';
|
||||
export const PANEL_AUTH_INVALID_EVENT = 'nanobot:panel-auth-invalid';
|
||||
|
||||
let initialized = false;
|
||||
let memoryPassword = '';
|
||||
|
||||
function resolveAbsoluteUrl(input: string): string {
|
||||
const url = String(input || '').trim();
|
||||
|
|
@ -27,40 +25,12 @@ function isApiRequest(url: string): boolean {
|
|||
return /^\/api(\/|$)/i.test(path);
|
||||
}
|
||||
|
||||
export function getPanelAccessPassword(): string {
|
||||
if (memoryPassword) return memoryPassword;
|
||||
if (typeof window === 'undefined') return '';
|
||||
const stored = window.localStorage.getItem(PANEL_STORAGE_KEY) || '';
|
||||
if (stored) {
|
||||
memoryPassword = stored;
|
||||
}
|
||||
return memoryPassword;
|
||||
}
|
||||
|
||||
export function setPanelAccessPassword(password: string): void {
|
||||
const value = String(password || '').trim();
|
||||
memoryPassword = value;
|
||||
if (typeof window === 'undefined') return;
|
||||
if (value) {
|
||||
window.localStorage.setItem(PANEL_STORAGE_KEY, value);
|
||||
} else {
|
||||
window.localStorage.removeItem(PANEL_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPanelAccessPassword(): void {
|
||||
memoryPassword = '';
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.removeItem(PANEL_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function isPanelAuthRoute(url: string): boolean {
|
||||
const path = resolveAbsoluteUrl(url);
|
||||
return /^\/api\/panel\/auth(?:\/|$)/i.test(path);
|
||||
}
|
||||
|
||||
export function notifyPanelAuthInvalid(): void {
|
||||
clearPanelAccessPassword();
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent(PANEL_AUTH_INVALID_EVENT));
|
||||
}
|
||||
|
|
@ -68,7 +38,6 @@ export function notifyPanelAuthInvalid(): void {
|
|||
export function setupPanelAccessAuth(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
getPanelAccessPassword();
|
||||
|
||||
axios.interceptors.response.use(undefined, (error) => {
|
||||
const status = Number(error?.response?.status || 0);
|
||||
|
|
|
|||
Loading…
Reference in New Issue