v0.1.4-p5

main
mula.liu 2026-04-05 00:29:37 +08:00
parent ca1f941e4c
commit ae34bfc6a0
60 changed files with 3109 additions and 2473 deletions

View File

@ -16,9 +16,9 @@ from services.bot_management_service import (
create_bot_record, create_bot_record,
get_bot_detail_cached, get_bot_detail_cached,
list_bots_with_cache, list_bots_with_cache,
test_provider_connection,
update_bot_record, update_bot_record,
) )
from services.provider_service import test_provider_connection
router = APIRouter() router = APIRouter()

View File

@ -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,
},
}

View File

@ -1,121 +1,31 @@
from typing import Any, Dict, List from typing import Dict
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from sqlmodel import Session, select from sqlmodel import Session
from core.cache import cache
from core.database import get_session from core.database import get_session
from core.docker_instance import docker_manager from services.image_service import (
from models.bot import BotInstance, NanobotImage delete_registered_image,
from services.cache_service import _cache_key_images, _invalidate_images_cache list_docker_images_by_repository,
list_registered_images,
register_image as register_image_record,
)
router = APIRouter() 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") @router.get("/api/images")
def list_images(session: Session = Depends(get_session)): def list_images(session: Session = Depends(get_session)):
cached = cache.get_json(_cache_key_images()) return list_registered_images(session)
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
@router.delete("/api/images/{tag:path}") @router.delete("/api/images/{tag:path}")
def delete_image(tag: str, session: Session = Depends(get_session)): def delete_image(tag: str, session: Session = Depends(get_session)):
image = session.get(NanobotImage, tag) return delete_registered_image(session, tag=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"}
@router.get("/api/docker-images") @router.get("/api/docker-images")
def list_docker_images(repository: str = "nanobot-base"): def list_docker_images(repository: str = "nanobot-base"):
rows = docker_manager.list_images_by_repo(repository) return list_docker_images_by_repository(repository)
return rows
@router.post("/api/images/register") @router.post("/api/images/register")
def register_image(payload: dict, session: Session = Depends(get_session)): def register_image(payload: dict, session: Session = Depends(get_session)):
tag = (payload.get("tag") or "").strip() return register_image_record(session, payload)
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)

View File

@ -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}

View File

@ -3,6 +3,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session from sqlmodel import Session
from bootstrap.app_runtime import reload_platform_runtime
from core.cache import cache from core.cache import cache
from core.database import get_session from core.database import get_session
from schemas.platform import PlatformSettingsPayload, SystemSettingPayload from schemas.platform import PlatformSettingsPayload, SystemSettingPayload
@ -22,13 +23,6 @@ from services.platform_service import (
router = APIRouter() 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") @router.get("/api/platform/overview")
def get_platform_overview(request: Request, session: Session = Depends(get_session)): def get_platform_overview(request: Request, session: Session = Depends(get_session)):
docker_manager = getattr(request.app.state, "docker_manager", None) 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") @router.put("/api/platform/settings")
def update_platform_settings_api(payload: PlatformSettingsPayload, request: Request, session: Session = Depends(get_session)): def update_platform_settings_api(payload: PlatformSettingsPayload, request: Request, session: Session = Depends(get_session)):
result = save_platform_settings(session, payload).model_dump() result = save_platform_settings(session, payload).model_dump()
_apply_platform_runtime_changes(request) reload_platform_runtime(request.app)
return result return result
@ -54,8 +48,8 @@ def clear_platform_cache():
@router.post("/api/platform/reload") @router.post("/api/platform/reload")
def reload_platform_runtime(request: Request): def reload_platform_runtime_api(request: Request):
_apply_platform_runtime_changes(request) reload_platform_runtime(request.app)
return {"status": "reloaded"} 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)): def create_system_setting(payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)):
try: try:
result = create_or_update_system_setting(session, payload) result = create_or_update_system_setting(session, payload)
_apply_platform_runtime_changes(request) reload_platform_runtime(request.app)
return result return result
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from 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)): def update_system_setting(key: str, payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)):
try: try:
result = create_or_update_system_setting(session, payload.model_copy(update={"key": key})) 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 return result
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from 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)): def remove_system_setting(key: str, request: Request, session: Session = Depends(get_session)):
try: try:
delete_system_setting(session, key) delete_system_setting(session, key)
_apply_platform_runtime_changes(request) reload_platform_runtime(request.app)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"status": "deleted", "key": key} return {"status": "deleted", "key": key}

View File

@ -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 core.utils import _get_default_system_timezone
from models.bot import BotInstance from schemas.system import SystemTemplatesUpdateRequest
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 services.platform_service import get_platform_settings_snapshot, get_speech_runtime_settings from services.platform_service import get_platform_settings_snapshot, get_speech_runtime_settings
from services.template_service import ( from services.template_service import (
get_agent_md_templates, get_agent_md_templates,
@ -26,40 +12,6 @@ from services.template_service import (
router = APIRouter() 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") @router.get("/api/system/defaults")
def get_system_defaults(): def get_system_defaults():
md_templates = get_agent_md_templates() md_templates = get_agent_md_templates()
@ -115,31 +67,3 @@ def update_system_templates(payload: SystemTemplatesUpdateRequest):
"agent_md_templates": get_agent_md_templates(), "agent_md_templates": get_agent_md_templates(),
"topic_presets": get_topic_presets(), "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,
},
}

View File

@ -1,42 +1,24 @@
import json from typing import Any, Dict, Optional
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func from sqlmodel import Session
from sqlmodel import Session, select
from core.database import get_session from core.database import get_session
from models.bot import BotInstance
from models.topic import TopicItem, TopicTopic
from services.topic_service import ( from services.topic_service import (
_TOPIC_KEY_RE, create_topic,
_list_topics, delete_topic,
_normalize_topic_key, delete_topic_item,
_topic_item_to_dict, get_topic_item_stats,
_topic_to_dict, list_topic_items,
list_topics,
mark_topic_item_read,
update_topic,
) )
router = APIRouter() 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): class TopicCreateRequest(BaseModel):
topic_key: str topic_key: str
name: Optional[str] = None name: Optional[str] = None
@ -56,112 +38,31 @@ class TopicUpdateRequest(BaseModel):
@router.get("/api/bots/{bot_id}/topics") @router.get("/api/bots/{bot_id}/topics")
def list_bot_topics(bot_id: str, session: Session = Depends(get_session)): def list_bot_topics(bot_id: str, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) return list_topics(session, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return _list_topics(session, bot_id)
@router.post("/api/bots/{bot_id}/topics") @router.post("/api/bots/{bot_id}/topics")
def create_bot_topic(bot_id: str, payload: TopicCreateRequest, session: Session = Depends(get_session)): def create_bot_topic(bot_id: str, payload: TopicCreateRequest, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) return create_topic(
if not bot: session,
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(
bot_id=bot_id, bot_id=bot_id,
topic_key=topic_key, topic_key=payload.topic_key,
name=str(payload.name or topic_key).strip() or topic_key, name=payload.name,
description=str(payload.description or "").strip(), description=payload.description,
is_active=bool(payload.is_active), is_active=payload.is_active,
is_default_fallback=False, routing=payload.routing,
routing_json=json.dumps(payload.routing or {}, ensure_ascii=False), view_schema=payload.view_schema,
view_schema_json=json.dumps(payload.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)
@router.put("/api/bots/{bot_id}/topics/{topic_key}") @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)): def update_bot_topic(bot_id: str, topic_key: str, payload: TopicUpdateRequest, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) return update_topic(session, bot_id=bot_id, topic_key=topic_key, updates=payload.model_dump(exclude_unset=True))
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)
@router.delete("/api/bots/{bot_id}/topics/{topic_key}") @router.delete("/api/bots/{bot_id}/topics/{topic_key}")
def delete_bot_topic(bot_id: str, topic_key: str, session: Session = Depends(get_session)): def delete_bot_topic(bot_id: str, topic_key: str, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) return delete_topic(session, bot_id=bot_id, topic_key=topic_key)
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}
@router.get("/api/bots/{bot_id}/topic-items") @router.get("/api/bots/{bot_id}/topic-items")
@ -172,97 +73,19 @@ def list_bot_topic_items(
limit: int = 50, limit: int = 50,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
bot = session.get(BotInstance, bot_id) return list_topic_items(session, bot_id=bot_id, topic_key=topic_key, cursor=cursor, limit=limit)
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),
}
@router.get("/api/bots/{bot_id}/topic-items/stats") @router.get("/api/bots/{bot_id}/topic-items/stats")
def get_bot_topic_item_stats(bot_id: str, session: Session = Depends(get_session)): def get_bot_topic_item_stats(bot_id: str, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) return get_topic_item_stats(session, bot_id=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,
}
@router.post("/api/bots/{bot_id}/topic-items/{item_id}/read") @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)): def mark_bot_topic_item_read(bot_id: str, item_id: int, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) return mark_topic_item_read(session, bot_id=bot_id, item_id=item_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),
}
@router.delete("/api/bots/{bot_id}/topic-items/{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)): def delete_bot_topic_item(bot_id: str, item_id: int, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) return delete_topic_item(session, bot_id=bot_id, item_id=item_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,
}

View File

@ -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.bot_speech_router import router as bot_speech_router
from api.chat_history_router import router as chat_history_router from api.chat_history_router import router as chat_history_router
from api.chat_router import router as chat_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.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.platform_router import router as platform_router
from api.skill_router import router as skill_router from api.skill_router import router as skill_router
from api.system_router import router as system_router from api.system_router import router as system_router
from api.topic_router import router as topic_router from api.topic_router import router as topic_router
from api.workspace_router import router as workspace_router from api.workspace_router import router as workspace_router
from bootstrap.app_runtime import register_app_runtime 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.docker_instance import docker_manager
from core.settings import BOTS_WORKSPACE_ROOT, CORS_ALLOWED_ORIGINS, DATA_ROOT from core.settings import BOTS_WORKSPACE_ROOT, CORS_ALLOWED_ORIGINS, DATA_ROOT
from core.speech_service import WhisperSpeechService from core.speech_service import WhisperSpeechService
@ -30,7 +32,7 @@ def create_app() -> FastAPI:
app.state.docker_manager = docker_manager app.state.docker_manager = docker_manager
app.state.speech_service = speech_service app.state.speech_service = speech_service
app.add_middleware(PasswordProtectionMiddleware) app.add_middleware(AuthAccessMiddleware)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=list(CORS_ALLOWED_ORIGINS), allow_origins=list(CORS_ALLOWED_ORIGINS),
@ -39,6 +41,8 @@ def create_app() -> FastAPI:
allow_credentials=True, allow_credentials=True,
) )
app.include_router(panel_auth_router)
app.include_router(health_router)
app.include_router(platform_router) app.include_router(platform_router)
app.include_router(topic_router) app.include_router(topic_router)
app.include_router(system_router) app.include_router(system_router)

View File

@ -3,6 +3,7 @@ import asyncio
from fastapi import FastAPI from fastapi import FastAPI
from sqlmodel import Session, select from sqlmodel import Session, select
from core.cache import cache
from core.database import engine, init_database from core.database import engine, init_database
from core.docker_instance import docker_manager from core.docker_instance import docker_manager
from core.settings import DATABASE_URL_DISPLAY, REDIS_ENABLED 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 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: def register_app_runtime(app: FastAPI) -> None:
@app.on_event("startup") @app.on_event("startup")
async def _on_startup() -> None: async def _on_startup() -> None:

View File

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import re
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
@ -12,46 +11,84 @@ class RouteAccessMode(str, Enum):
PUBLIC_BOT_OR_PANEL = "public_bot_or_panel" PUBLIC_BOT_OR_PANEL = "public_bot_or_panel"
_BOT_ID_API_RE = re.compile(r"^/api/bots/([^/]+)(?:/.*)?$") _PUBLIC_EXACT_PATHS = {
_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",
"/api/health", "/api/health",
"/api/health/cache", "/api/health/cache",
"/api/system/defaults", "/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]: def extract_bot_id(path: str) -> Optional[str]:
raw = str(path or "").strip() segments = _path_segments(path)
match = _BOT_ID_API_RE.match(raw) or _BOT_ID_PUBLIC_RE.match(raw) if len(segments) < 3:
if not match or not match.group(1):
return None 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: def resolve_route_access_mode(path: str, method: str) -> RouteAccessMode:
raw_path = str(path or "").strip() raw_path = str(path or "").strip()
verb = str(method or "GET").strip().upper() 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 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 return RouteAccessMode.PUBLIC_BOT_OR_PANEL
if _BOT_ID_API_RE.fullmatch(raw_path): if _is_panel_only_bot_action(segments, verb):
if any(pattern.fullmatch(raw_path) and verb in methods for pattern, methods in _BOT_PANEL_ONLY_ROUTE_METHODS):
return RouteAccessMode.PANEL_ONLY return RouteAccessMode.PANEL_ONLY
if _is_bot_scoped_api_route(segments):
return RouteAccessMode.BOT_OR_PANEL return RouteAccessMode.BOT_OR_PANEL
if raw_path.startswith("/api/"): if raw_path.startswith("/api/"):

View File

@ -17,7 +17,7 @@ def _unauthorized(detail: str) -> JSONResponse:
return JSONResponse(status_code=401, content={"detail": detail}) return JSONResponse(status_code=401, content={"detail": detail})
class PasswordProtectionMiddleware(BaseHTTPMiddleware): class AuthAccessMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
if request.method.upper() == "OPTIONS": if request.method.upper() == "OPTIONS":
return await call_next(request) return await call_next(request)

View File

@ -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"))

View File

@ -1,11 +1,12 @@
import os
from datetime import datetime from datetime import datetime
from typing import Any, Dict from typing import Any, Dict, Optional
from fastapi import HTTPException from fastapi import HTTPException
from sqlmodel import Session from sqlmodel import Session
from core.docker_instance import docker_manager 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 models.bot import BotInstance
from schemas.bot import ( from schemas.bot import (
BotEnvParamsUpdateRequest, BotEnvParamsUpdateRequest,
@ -13,28 +14,30 @@ from schemas.bot import (
ChannelConfigRequest, ChannelConfigRequest,
ChannelConfigUpdateRequest, ChannelConfigUpdateRequest,
) )
from services.bot_channel_service import ( from services.bot_service import (
_channel_api_to_cfg, channel_api_to_config,
_get_bot_channels_from_config, list_bot_channels_from_config,
_normalize_channel_extra, normalize_channel_extra,
_read_global_delivery_flags, read_global_delivery_flags,
sync_bot_workspace_channels,
) )
from services.bot_service import _sync_workspace_channels
from services.bot_mcp_service import ( from services.bot_mcp_service import (
_merge_mcp_servers_preserving_extras, _merge_mcp_servers_preserving_extras,
_normalize_mcp_servers, _normalize_mcp_servers,
) )
from services.bot_storage_service import ( from services.bot_storage_service import (
_normalize_env_params, get_bot_resource_limits,
_read_bot_config, get_bot_workspace_snapshot,
_read_bot_resources, normalize_bot_env_params,
_read_env_store, read_bot_config_data,
_workspace_root, read_bot_env_params,
_write_bot_config, write_bot_config_data,
_write_env_store, write_bot_env_params,
) )
from services.cache_service import _invalidate_bot_detail_cache 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: def _get_bot_or_404(session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id) bot = session.get(BotInstance, bot_id)
@ -43,14 +46,103 @@ def _get_bot_or_404(session: Session, bot_id: str) -> BotInstance:
return bot 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]: def get_bot_resources_snapshot(session: Session, *, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id) 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) runtime = docker_manager.get_bot_resource_snapshot(bot_id)
workspace_root = _workspace_root(bot_id) workspace = get_bot_workspace_snapshot(bot_id)
workspace_bytes = _calc_dir_size_bytes(workspace_root) workspace_root = str(workspace.get("path") or "")
configured_storage_bytes = int(configured.get("storage_gb", 0) or 0) * 1024 * 1024 * 1024 workspace_bytes = int(workspace.get("usage_bytes") or 0)
configured_storage_bytes = int(workspace.get("configured_limit_bytes") or 0)
workspace_percent = 0.0 workspace_percent = 0.0
if configured_storage_bytes > 0: if configured_storage_bytes > 0:
workspace_percent = (workspace_bytes / configured_storage_bytes) * 100.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): def list_bot_channels_config(session: Session, *, bot_id: str):
bot = _get_bot_or_404(session, bot_id) 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]: 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]: def get_bot_mcp_config_state(session: Session, *, bot_id: str) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id) _get_bot_or_404(session, bot_id)
config_data = _read_bot_config(bot_id) _config_data, tools_cfg = _read_bot_tools_cfg(bot_id)
tools_cfg = config_data.get("tools") if isinstance(config_data, dict) else {}
if not isinstance(tools_cfg, dict):
tools_cfg = {}
mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers")) mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
return { return {
"bot_id": bot_id, "bot_id": bot_id,
@ -134,20 +223,13 @@ def update_bot_mcp_config_state(
payload: BotMcpConfigUpdateRequest, payload: BotMcpConfigUpdateRequest,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id) _get_bot_or_404(session, bot_id)
config_data = _read_bot_config(bot_id) config_data, tools_cfg = _read_bot_tools_cfg(bot_id)
if not isinstance(config_data, dict):
config_data = {}
tools_cfg = config_data.get("tools")
if not isinstance(tools_cfg, dict):
tools_cfg = {}
normalized_mcp_servers = _normalize_mcp_servers(payload.mcp_servers or {}) normalized_mcp_servers = _normalize_mcp_servers(payload.mcp_servers or {})
current_mcp_servers = tools_cfg.get("mcpServers") current_mcp_servers = tools_cfg.get("mcpServers")
merged_mcp_servers = _merge_mcp_servers_preserving_extras(current_mcp_servers, normalized_mcp_servers) merged_mcp_servers = _merge_mcp_servers_preserving_extras(current_mcp_servers, normalized_mcp_servers)
tools_cfg["mcpServers"] = merged_mcp_servers tools_cfg["mcpServers"] = merged_mcp_servers
config_data["tools"] = tools_cfg
sanitized_after_save = _normalize_mcp_servers(tools_cfg.get("mcpServers")) sanitized_after_save = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
_write_bot_config(bot_id, config_data) _write_bot_config_state(session, bot_id=bot_id, config_data=config_data)
_invalidate_bot_detail_cache(bot_id)
return { return {
"status": "updated", "status": "updated",
"bot_id": bot_id, "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) _get_bot_or_404(session, bot_id)
return { return {
"bot_id": bot_id, "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, payload: BotEnvParamsUpdateRequest,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id) _get_bot_or_404(session, bot_id)
normalized = _normalize_env_params(payload.env_params) normalized = normalize_bot_env_params(payload.env_params)
_write_env_store(bot_id, normalized) write_bot_env_params(bot_id, normalized)
_invalidate_bot_detail_cache(bot_id) _invalidate_bot_detail_cache(bot_id)
return { return {
"status": "updated", "status": "updated",
@ -196,7 +278,7 @@ def create_bot_channel_config(
raise HTTPException(status_code=400, detail="channel_type is required") raise HTTPException(status_code=400, detail="channel_type is required")
if ctype == "dashboard": if ctype == "dashboard":
raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be created manually") 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): 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}") 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(), "app_secret": (payload.app_secret or "").strip(),
"internal_port": max(1, min(int(payload.internal_port or 8080), 65535)), "internal_port": max(1, min(int(payload.internal_port or 8080), 65535)),
"is_active": bool(payload.is_active), "is_active": bool(payload.is_active),
"extra_config": _normalize_channel_extra(payload.extra_config), "extra_config": normalize_channel_extra(payload.extra_config),
"locked": False, "locked": False,
} }
config_data = _read_bot_config(bot_id) config_data, channels_cfg = _read_bot_channels_cfg(bot_id)
channels_cfg = config_data.get("channels") channels_cfg[ctype] = channel_api_to_config(new_row)
if not isinstance(channels_cfg, dict): _write_bot_config_state(session, bot_id=bot_id, config_data=config_data, sync_workspace=True)
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)
return new_row return new_row
@ -233,11 +309,8 @@ def update_bot_channel_config(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id) bot = _get_bot_or_404(session, bot_id)
channel_key = str(channel_id or "").strip().lower() rows = list_bot_channels_from_config(bot)
rows = _get_bot_channels_from_config(bot) row = _find_channel_row(rows, channel_id)
row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
if not row:
raise HTTPException(status_code=404, detail="Channel not found")
if str(row.get("channel_type") or "").strip().lower() == "dashboard" or bool(row.get("locked")): 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") 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") raise HTTPException(status_code=400, detail="dashboard channel must remain enabled")
row["is_active"] = next_active row["is_active"] = next_active
if "extra_config" in update_data: 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["channel_type"] = new_type
row["id"] = new_type row["id"] = new_type
row["locked"] = new_type == "dashboard" row["locked"] = new_type == "dashboard"
config_data = _read_bot_config(bot_id) config_data, channels_cfg = _read_bot_channels_cfg(bot_id)
channels_cfg = config_data.get("channels") current_send_progress, current_send_tool_hints = read_global_delivery_flags(channels_cfg)
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)
if new_type == "dashboard": 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["sendProgress"] = bool(extra.get("sendProgress", current_send_progress))
channels_cfg["sendToolHints"] = bool(extra.get("sendToolHints", current_send_tool_hints)) channels_cfg["sendToolHints"] = bool(extra.get("sendToolHints", current_send_tool_hints))
else: 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: if existing_type != "dashboard" and existing_type in channels_cfg and existing_type != new_type:
channels_cfg.pop(existing_type, None) channels_cfg.pop(existing_type, None)
if new_type != "dashboard": if new_type != "dashboard":
channels_cfg[new_type] = _channel_api_to_cfg(row) channels_cfg[new_type] = channel_api_to_config(row)
_write_bot_config(bot_id, config_data) _write_bot_config_state(session, bot_id=bot_id, config_data=config_data, sync_workspace=True)
session.commit()
_sync_workspace_channels(session, bot_id)
_invalidate_bot_detail_cache(bot_id)
return row return row
@ -303,22 +369,12 @@ def delete_bot_channel_config(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id) bot = _get_bot_or_404(session, bot_id)
channel_key = str(channel_id or "").strip().lower() rows = list_bot_channels_from_config(bot)
rows = _get_bot_channels_from_config(bot) row = _find_channel_row(rows, channel_id)
row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
if not row:
raise HTTPException(status_code=404, detail="Channel not found")
if str(row.get("channel_type") or "").lower() == "dashboard": if str(row.get("channel_type") or "").lower() == "dashboard":
raise HTTPException(status_code=400, detail="dashboard channel cannot be deleted") raise HTTPException(status_code=400, detail="dashboard channel cannot be deleted")
config_data = _read_bot_config(bot_id) config_data, channels_cfg = _read_bot_channels_cfg(bot_id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
config_data["channels"] = channels_cfg
channels_cfg.pop(str(row.get("channel_type") or "").lower(), None) channels_cfg.pop(str(row.get("channel_type") or "").lower(), None)
_write_bot_config(bot_id, config_data) _write_bot_config_state(session, bot_id=bot_id, config_data=config_data, sync_workspace=True)
session.commit()
_sync_workspace_channels(session, bot_id)
_invalidate_bot_detail_cache(bot_id)
return {"status": "deleted"} return {"status": "deleted"}

View File

@ -12,16 +12,16 @@ from models.platform import BotActivityEvent, BotRequestUsage
from models.skill import BotSkillInstall from models.skill import BotSkillInstall
from models.topic import TopicItem, TopicTopic from models.topic import TopicItem, TopicTopic
from services.bot_service import ( from services.bot_service import (
_read_bot_runtime_snapshot,
_resolve_bot_env_params,
_safe_float, _safe_float,
_safe_int, _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.cache_service import _invalidate_bot_detail_cache, _invalidate_bot_messages_cache
from services.platform_service import record_activity_event 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: 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)): if not bool(getattr(bot, "enabled", True)):
raise PermissionError("Bot is disabled. Enable it first.") raise PermissionError("Bot is disabled. Enable it first.")
_sync_workspace_channels(session, bot_id) sync_bot_workspace_channels(session, bot_id)
runtime_snapshot = _read_bot_runtime_snapshot(bot) runtime_snapshot = read_bot_runtime_snapshot(bot)
env_params = _resolve_bot_env_params(bot_id) env_params = resolve_bot_runtime_env_params(bot_id)
_write_env_store(bot_id, env_params) write_bot_env_params(bot_id, env_params)
success = docker_manager.start_bot( success = docker_manager.start_bot(
bot_id, bot_id,
image_tag=bot.image_tag, 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) _invalidate_bot_detail_cache(bot_id)
raise RuntimeError("Bot container failed shortly after startup. Check bot logs/config.") 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) session.add(bot)
record_activity_event(session, bot_id, "bot_started", channel="system", detail=f"Container started for {bot_id}") record_activity_event(session, bot_id, "bot_started", channel="system", detail=f"Container started for {bot_id}")
session.commit() session.commit()

View File

@ -3,7 +3,6 @@ import re
import shutil import shutil
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import httpx
from fastapi import HTTPException from fastapi import HTTPException
from sqlmodel import Session, select from sqlmodel import Session, select
@ -13,19 +12,21 @@ from core.settings import BOTS_WORKSPACE_ROOT
from models.bot import BotInstance, NanobotImage from models.bot import BotInstance, NanobotImage
from schemas.bot import BotCreateRequest, BotUpdateRequest from schemas.bot import BotCreateRequest, BotUpdateRequest
from services.bot_service import ( from services.bot_service import (
_normalize_env_params, normalize_initial_bot_channels,
_normalize_initial_channels, normalize_bot_system_timezone,
_normalize_resource_limits, resolve_bot_runtime_env_params,
_normalize_system_timezone, serialize_bot_detail,
_provider_defaults, serialize_bot_list_entry,
_resolve_bot_env_params, sync_bot_workspace_channels,
_serialize_bot, )
_serialize_bot_list_item, from services.bot_storage_service import (
_sync_workspace_channels, 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.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.platform_service import record_activity_event
from services.provider_service import get_provider_defaults
from services.template_service import get_agent_md_templates from services.template_service import get_agent_md_templates
BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$") 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) 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]: def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[str, Any]:
normalized_bot_id = str(payload.id or "").strip() normalized_bot_id = str(payload.id or "").strip()
if not normalized_bot_id: 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): if not docker_manager.has_image(payload.image_tag):
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {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: 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: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from 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), workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, normalized_bot_id),
) )
template_defaults = get_agent_md_templates() 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: try:
session.add(bot) session.add(bot)
session.flush() session.flush()
_write_env_store(normalized_bot_id, normalized_env_params) write_bot_env_params(normalized_bot_id, normalized_env_params)
_sync_workspace_channels( sync_bot_workspace_channels(
session, session,
normalized_bot_id, 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={ global_delivery_override={
"sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False, "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, "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) _cleanup_bot_workspace_root(normalized_bot_id)
raise raise
_invalidate_bot_detail_cache(normalized_bot_id) _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]]: 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() session.commit()
for bot in bots: for bot in bots:
session.refresh(bot) 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) cache.set_json(_cache_key_bots_list(), rows, ttl=30)
return rows 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) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:
raise HTTPException(status_code=404, detail="Bot not found") 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) cache.set_json(_cache_key_bot_detail(bot_id), row, ttl=30)
return row return row
@ -290,7 +237,7 @@ def update_bot_record(session: Session, *, bot_id: str, payload: BotUpdateReques
normalized_system_timezone: Optional[str] = None normalized_system_timezone: Optional[str] = None
if system_timezone is not None: if system_timezone is not None:
try: try:
normalized_system_timezone = _normalize_system_timezone(system_timezone) normalized_system_timezone = normalize_bot_system_timezone(system_timezone)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from 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"] runtime_overrides["system_prompt"] = runtime_overrides["soul_md"]
if {"cpu_cores", "memory_mb", "storage_gb"} & set(runtime_overrides.keys()): if {"cpu_cores", "memory_mb", "storage_gb"} & set(runtime_overrides.keys()):
runtime_overrides.update( runtime_overrides.update(
_normalize_resource_limits( normalize_bot_resource_limits(
runtime_overrides.get("cpu_cores"), runtime_overrides.get("cpu_cores"),
runtime_overrides.get("memory_mb"), runtime_overrides.get("memory_mb"),
runtime_overrides.get("storage_gb"), runtime_overrides.get("storage_gb"),
@ -350,12 +297,12 @@ def update_bot_record(session: Session, *, bot_id: str, payload: BotUpdateReques
session.flush() session.flush()
if env_params is not None or normalized_system_timezone is not None: 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: 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: if normalized_system_timezone is not None:
next_env_params["TZ"] = normalized_system_timezone 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 global_delivery_override: Optional[Dict[str, Any]] = None
if "send_progress" in runtime_overrides or "send_tool_hints" in runtime_overrides: 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: if "send_tool_hints" in runtime_overrides:
global_delivery_override["sendToolHints"] = bool(runtime_overrides.get("send_tool_hints")) global_delivery_override["sendToolHints"] = bool(runtime_overrides.get("send_tool_hints"))
_sync_workspace_channels( sync_bot_workspace_channels(
session, session,
bot_id, bot_id,
runtime_overrides=runtime_overrides if runtime_overrides else None, 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 bot = refreshed_bot
raise raise
_invalidate_bot_detail_cache(bot_id) _invalidate_bot_detail_cache(bot_id)
return _serialize_bot(bot) return serialize_bot_detail(bot)

View File

@ -12,9 +12,14 @@ from sqlmodel import Session
from core.docker_instance import docker_manager from core.docker_instance import docker_manager
from core.settings import BOTS_WORKSPACE_ROOT from core.settings import BOTS_WORKSPACE_ROOT
from models.bot import BotInstance 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_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 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( weixin_channel = next(
( (
row 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" if str(row.get("channel_type") or "").strip().lower() == "weixin"
), ),
None, None,
@ -107,12 +112,12 @@ async def relogin_weixin(session: Session, *, bot_id: str) -> Dict[str, Any]:
except Exception as exc: except Exception as exc:
raise RuntimeError(f"Failed to remove weixin state: {exc}") from 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 {} 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 weixin_cfg = channels_cfg.get("weixin") if isinstance(channels_cfg, dict) else None
if isinstance(weixin_cfg, dict) and "token" in weixin_cfg: if isinstance(weixin_cfg, dict) and "token" in weixin_cfg:
weixin_cfg.pop("token", None) weixin_cfg.pop("token", None)
_write_bot_config(bot_id, config_data) write_bot_config_data(bot_id, config_data)
restarted = False restarted = False
if str(bot.docker_status or "").upper() == "RUNNING": 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]: def list_cron_jobs(session: Session, *, bot_id: str, include_disabled: bool = True) -> Dict[str, Any]:
_get_bot_or_raise(session, bot_id) _get_bot_or_raise(session, bot_id)
store = _read_cron_store(bot_id) store = read_bot_cron_jobs_store(bot_id)
rows = [] rows = []
for row in store.get("jobs", []): for row in store.get("jobs", []):
if not isinstance(row, dict): 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]: def stop_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]:
_get_bot_or_raise(session, bot_id) _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", []) jobs = store.get("jobs", [])
if not isinstance(jobs, list): if not isinstance(jobs, list):
jobs = [] jobs = []
@ -159,13 +164,13 @@ def stop_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, An
state = {} state = {}
found["state"] = state found["state"] = state
state["nextRunAtMs"] = None 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} return {"status": "stopped", "job_id": job_id}
def start_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]: def start_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]:
_get_bot_or_raise(session, bot_id) _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", []) jobs = store.get("jobs", [])
if not isinstance(jobs, list): if not isinstance(jobs, list):
jobs = [] jobs = []
@ -180,20 +185,20 @@ def start_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, A
found["state"] = state found["state"] = state
schedule = found.get("schedule") schedule = found.get("schedule")
state["nextRunAtMs"] = _compute_cron_next_run(schedule if isinstance(schedule, dict) else {}) 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} return {"status": "started", "job_id": job_id}
def delete_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]: def delete_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]:
_get_bot_or_raise(session, bot_id) _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", []) jobs = store.get("jobs", [])
if not isinstance(jobs, list): if not isinstance(jobs, list):
jobs = [] jobs = []
kept = [row for row in jobs if not (isinstance(row, dict) and str(row.get("id")) == job_id)] kept = [row for row in jobs if not (isinstance(row, dict) and str(row.get("id")) == job_id)]
if len(kept) == len(jobs): if len(kept) == len(jobs):
raise LookupError("Cron job not found") 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} return {"status": "deleted", "job_id": job_id}

View File

@ -1,81 +1,32 @@
import os import os
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from datetime import datetime, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from sqlmodel import Session 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 models.bot import BotInstance
from schemas.bot import ChannelConfigRequest
from services.bot_storage_service import ( 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_env_params,
_normalize_resource_limits,
_read_bot_config, _read_bot_config,
_read_bot_resources, _read_bot_resources,
_read_cron_store,
_read_env_store, _read_env_store,
_safe_float, _safe_float,
_safe_int, _safe_int,
_workspace_root, _workspace_root,
_write_bot_config, normalize_bot_resource_limits,
_write_bot_resources, write_bot_resource_limits,
_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,
) )
from services.template_service import get_agent_md_templates from services.template_service import get_agent_md_templates
__all__ = [ config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT)
"_bot_data_root",
"_channel_api_to_cfg",
"_clear_bot_dashboard_direct_session", def get_default_bot_system_timezone() -> str:
"_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:
value = str(DEFAULT_BOT_SYSTEM_TIMEZONE or "").strip() or "Asia/Shanghai" value = str(DEFAULT_BOT_SYSTEM_TIMEZONE or "").strip() or "Asia/Shanghai"
try: try:
ZoneInfo(value) ZoneInfo(value)
@ -84,10 +35,10 @@ def _get_default_system_timezone() -> str:
return "Asia/Shanghai" return "Asia/Shanghai"
def _normalize_system_timezone(raw: Any) -> str: def normalize_bot_system_timezone(raw: Any) -> str:
value = str(raw or "").strip() value = str(raw or "").strip()
if not value: if not value:
return _get_default_system_timezone() return get_default_bot_system_timezone()
try: try:
ZoneInfo(value) ZoneInfo(value)
except Exception as exc: except Exception as exc:
@ -95,47 +46,316 @@ def _normalize_system_timezone(raw: Any) -> str:
return value 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)) env_params = _normalize_env_params(raw if isinstance(raw, dict) else _read_env_store(bot_id))
try: try:
env_params["TZ"] = _normalize_system_timezone(env_params.get("TZ")) env_params["TZ"] = normalize_bot_system_timezone(env_params.get("TZ"))
except ValueError: except ValueError:
env_params["TZ"] = _get_default_system_timezone() env_params["TZ"] = get_default_bot_system_timezone()
return env_params return env_params
def _provider_defaults(provider: str) -> tuple[str, str]: def normalize_channel_extra(raw: Any) -> Dict[str, Any]:
normalized = provider.lower().strip() if not isinstance(raw, dict):
if normalized in {"openai"}: return {}
return "openai", "https://api.openai.com/v1" return raw
if normalized in {"openrouter"}:
return "openrouter", "https://openrouter.ai/api/v1"
if normalized in {"dashscope", "aliyun", "qwen", "aliyun-qwen"}: def _normalize_allow_from(raw: Any) -> List[str]:
return "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1" rows: List[str] = []
if normalized in {"deepseek"}: if isinstance(raw, list):
return "deepseek", "https://api.deepseek.com/v1" for item in raw:
if normalized in {"xunfei", "iflytek", "xfyun"}: text = str(item or "").strip()
return "openai", "https://spark-api-open.xf-yun.com/v1" if text and text not in rows:
if normalized in {"vllm"}: rows.append(text)
return "openai", "" return rows or ["*"]
if normalized in {"kimi", "moonshot"}:
return "kimi", "https://api.moonshot.cn/v1"
if normalized in {"minimax"}: def read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]:
return "minimax", "https://api.minimax.chat/v1" if not isinstance(channels_cfg, dict):
return normalized, "" 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: def _read_workspace_md(bot_id: str, filename: str, default_value: str) -> str:
path = os.path.join(_workspace_root(bot_id), filename) path = os.path.join(_workspace_root(bot_id), filename)
if not os.path.isfile(path): if not os.path.isfile(path):
return default_value return default_value
try: try:
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as file:
return f.read().strip() return file.read().strip()
except Exception: except Exception:
return default_value 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) 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() template_defaults = get_agent_md_templates()
provider_name = "" provider_name = ""
@ -156,7 +376,7 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
agents_defaults = defaults agents_defaults = defaults
channels_cfg = config_data.get("channels") 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_provider = provider_name or ""
llm_model = str(agents_defaults.get("model") 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"], "cpu_cores": resources["cpu_cores"],
"memory_mb": resources["memory_mb"], "memory_mb": resources["memory_mb"],
"storage_gb": resources["storage_gb"], "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_progress": send_progress,
"send_tool_hints": send_tool_hints, "send_tool_hints": send_tool_hints,
"soul_md": soul_md, "soul_md": soul_md,
@ -193,8 +413,9 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
"system_prompt": soul_md, "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 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 updated_at = bot.updated_at.isoformat() + "Z" if bot.updated_at else None
return { return {
@ -216,7 +437,7 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]:
"cpu_cores": _safe_float(runtime.get("cpu_cores"), 1.0), "cpu_cores": _safe_float(runtime.get("cpu_cores"), 1.0),
"memory_mb": _safe_int(runtime.get("memory_mb"), 1024), "memory_mb": _safe_int(runtime.get("memory_mb"), 1024),
"storage_gb": _safe_int(runtime.get("storage_gb"), 10), "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_progress": bool(runtime.get("send_progress")),
"send_tool_hints": bool(runtime.get("send_tool_hints")), "send_tool_hints": bool(runtime.get("send_tool_hints")),
"soul_md": runtime.get("soul_md") or "", "soul_md": runtime.get("soul_md") or "",
@ -232,7 +453,8 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]:
"updated_at": updated_at, "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 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 updated_at = bot.updated_at.isoformat() + "Z" if bot.updated_at else None
return { return {
@ -248,7 +470,8 @@ def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]:
"updated_at": updated_at, "updated_at": updated_at,
} }
def _sync_workspace_channels(
def sync_bot_workspace_channels(
session: Session, session: Session,
bot_id: str, bot_id: str,
channels_override: Optional[List[Dict[str, Any]]] = None, channels_override: Optional[List[Dict[str, Any]]] = None,
@ -258,12 +481,75 @@ def _sync_workspace_channels(
bot = session.get(BotInstance, bot_id) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:
return return
snapshot = _read_bot_runtime_snapshot(bot)
_sync_workspace_channels_impl( snapshot = read_bot_runtime_snapshot(bot)
session, template_defaults = get_agent_md_templates()
bot_id, bot_data: Dict[str, Any] = {
snapshot, "name": bot.name,
channels_override=channels_override, "system_prompt": snapshot.get("system_prompt") or template_defaults.get("soul_md", ""),
global_delivery_override=global_delivery_override, "soul_md": snapshot.get("soul_md") or template_defaults.get("soul_md", ""),
runtime_overrides=runtime_overrides, "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"))

View File

@ -5,11 +5,27 @@ import os
import re import re
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from core.utils import _calc_dir_size_bytes
from core.settings import BOTS_WORKSPACE_ROOT from core.settings import BOTS_WORKSPACE_ROOT
_ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$") _ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$")
_BYTES_PER_GB = 1024 * 1024 * 1024
__all__ = [ __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", "_bot_data_root",
"_clear_bot_dashboard_direct_session", "_clear_bot_dashboard_direct_session",
"_clear_bot_sessions", "_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: def _workspace_root(bot_id: str) -> str:
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace")) 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: def _bot_data_root(bot_id: str) -> str:
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot")) 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]: def _normalize_env_params(raw: Any) -> Dict[str, str]:
if not isinstance(raw, dict): if not isinstance(raw, dict):
return {} return {}
@ -84,6 +112,10 @@ def _normalize_env_params(raw: Any) -> Dict[str, str]:
return rows 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]: def _read_json_object(path: str) -> Dict[str, Any]:
if not os.path.isfile(path): if not os.path.isfile(path):
return {} return {}
@ -121,10 +153,18 @@ def _read_bot_config(bot_id: str) -> Dict[str, Any]:
return _read_json_object(_config_json_path(bot_id)) 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: def _write_bot_config(bot_id: str, config_data: Dict[str, Any]) -> None:
_write_json_atomic(_config_json_path(bot_id), config_data) _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: def _resources_json_path(bot_id: str) -> str:
return os.path.join(_bot_data_root(bot_id), "resources.json") 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]: def _read_bot_resources(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
cpu_raw: Any = None cpu_raw: Any = None
memory_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) 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: def _migrate_bot_resources_store(bot_id: str) -> None:
config_data = _read_bot_config(bot_id) config_data = _read_bot_config(bot_id)
runtime_cfg = config_data.get("runtime") 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))) 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: 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)) _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: def _cron_store_path(bot_id: str) -> str:
return os.path.join(_workspace_root(bot_id), "cron", "jobs.json") 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))) 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: def _write_cron_store(bot_id: str, store: Dict[str, Any]) -> None:
normalized = _normalize_cron_store_payload(store) normalized = _normalize_cron_store_payload(store)
_write_json_atomic(_cron_store_path(bot_id), normalized) _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: def _sessions_root(bot_id: str) -> str:
return os.path.join(_workspace_root(bot_id), "sessions") return os.path.join(_workspace_root(bot_id), "sessions")

View File

@ -6,16 +6,16 @@ from fastapi import HTTPException
from sqlmodel import Session from sqlmodel import Session
from core.docker_instance import docker_manager 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 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 ( from services.platform_service import (
create_usage_request, create_usage_request,
fail_latest_usage, fail_latest_usage,
record_activity_event, record_activity_event,
) )
from services.runtime_service import _persist_runtime_packet, _queue_runtime_broadcast from services.runtime_service import broadcast_runtime_packet, persist_runtime_packet
from services.workspace_service import _resolve_workspace_path from services.workspace_service import resolve_workspace_path
from core.utils import _is_video_attachment_path, _is_visual_attachment_path
logger = logging.getLogger("dashboard.backend") 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) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:
raise HTTPException(status_code=404, detail="Bot not found") 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) normalized_attachments = _normalize_message_media_list(attachments)
text_command = str(command or "").strip() 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] = [] checked_attachments: List[str] = []
for rel_path in normalized_attachments: 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): if not os.path.isfile(target):
raise HTTPException(status_code=400, detail=f"attachment not found: {rel_path}") raise HTTPException(status_code=400, detail=f"attachment not found: {rel_path}")
checked_attachments.append(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, "media": checked_attachments,
"request_id": request_id, "request_id": request_id,
} }
_persist_runtime_packet(bot_id, outbound_user_packet) persist_runtime_packet(bot_id, outbound_user_packet)
if 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) success = docker_manager.send_command(bot_id, delivery_command, media=delivery_media)
if success: 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], detail=(detail or "command delivery failed")[:400],
) )
session.commit() session.commit()
_queue_runtime_broadcast( broadcast_runtime_packet(
bot_id, bot_id,
{ {
"type": "AGENT_STATE", "type": "AGENT_STATE",

View File

@ -10,7 +10,11 @@ from core.cache import cache
from core.docker_instance import docker_manager from core.docker_instance import docker_manager
from core.utils import _resolve_local_day_range from core.utils import _resolve_local_day_range
from models.bot import BotInstance, BotMessage 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 ( from services.cache_service import (
_cache_key_bot_messages, _cache_key_bot_messages,
_cache_key_bot_messages_page, _cache_key_bot_messages_page,
@ -33,7 +37,7 @@ def _normalize_message_media_item(bot_id: str, value: Any) -> str:
return "" return ""
if raw.startswith("/root/.nanobot/workspace/"): if raw.startswith("/root/.nanobot/workspace/"):
return raw[len("/root/.nanobot/workspace/") :].lstrip("/") return raw[len("/root/.nanobot/workspace/") :].lstrip("/")
root = _workspace_root(bot_id) root = get_bot_workspace_root(bot_id)
if os.path.isabs(raw): if os.path.isabs(raw):
try: try:
if os.path.commonpath([root, raw]) == root: if os.path.commonpath([root, raw]) == root:

View File

@ -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)

View File

@ -2,9 +2,8 @@ from typing import Any, Dict
from sqlmodel import Session, select from sqlmodel import Session, select
from core.utils import _calc_dir_size_bytes
from models.bot import BotInstance, NanobotImage 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 ( from services.platform_activity_service import (
get_bot_activity_stats, get_bot_activity_stats,
list_activity_events, list_activity_events,
@ -39,15 +38,15 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str,
for bot in bots: for bot in bots:
enabled = bool(getattr(bot, "enabled", True)) 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") 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 = ( runtime = (
docker_manager.get_bot_resource_snapshot(bot.id) docker_manager.get_bot_resource_snapshot(bot.id)
if docker_manager if docker_manager
else {"usage": {}, "limits": {}, "docker_status": runtime_status} else {"usage": {}, "limits": {}, "docker_status": runtime_status}
) )
workspace_root = _workspace_root(bot.id) workspace = get_bot_workspace_snapshot(bot.id, config_data=None)
workspace_used = _calc_dir_size_bytes(workspace_root) workspace_used = int(workspace.get("usage_bytes") or 0)
workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024 workspace_limit = int(workspace.get("configured_limit_bytes") or 0)
configured_cpu_total += float(resources["cpu_cores"] or 0) configured_cpu_total += float(resources["cpu_cores"] or 0)
configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024 configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024

View File

@ -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,
}

View File

@ -1,23 +1,30 @@
from services.platform_runtime_settings_service import ( from typing import Any, Dict, List
get_auth_token_max_active,
get_auth_token_ttl_hours, from sqlmodel import Session, select
default_platform_settings,
get_allowed_attachment_extensions, from core.database import engine
get_chat_pull_page_size, from core.settings import (
get_page_size, DEFAULT_STT_AUDIO_FILTER,
get_platform_settings, DEFAULT_STT_AUDIO_PREPROCESS,
get_platform_settings_snapshot, DEFAULT_STT_DEFAULT_LANGUAGE,
get_speech_runtime_settings, DEFAULT_STT_FORCE_SIMPLIFIED,
get_upload_max_mb, DEFAULT_STT_INITIAL_PROMPT,
get_workspace_download_extensions, DEFAULT_STT_MAX_AUDIO_SECONDS,
save_platform_settings, STT_DEVICE,
STT_MODEL,
) )
from models.platform import PlatformSetting
from schemas.platform import PlatformSettingsPayload
from services.platform_settings_core import ( from services.platform_settings_core import (
ACTIVITY_EVENT_RETENTION_SETTING_KEY, ACTIVITY_EVENT_RETENTION_SETTING_KEY,
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS, DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS,
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS, DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS,
SETTING_KEYS, SETTING_KEYS,
SYSTEM_SETTING_DEFINITIONS, SYSTEM_SETTING_DEFINITIONS,
_bootstrap_platform_setting_values,
_normalize_extension_list,
_read_setting_value,
_upsert_setting_row,
) )
from services.platform_system_settings_service import ( from services.platform_system_settings_service import (
create_or_update_system_setting, create_or_update_system_setting,
@ -26,3 +33,128 @@ from services.platform_system_settings_service import (
get_activity_event_retention_days, get_activity_event_retention_days,
list_system_settings, 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,
}

View File

@ -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),
}

View File

@ -13,7 +13,7 @@ from core.database import engine
from core.docker_instance import docker_manager from core.docker_instance import docker_manager
from core.websocket_manager import manager from core.websocket_manager import manager
from models.bot import BotInstance, BotMessage 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.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.platform_service import bind_usage_message, finalize_usage_from_packet, record_activity_event
from services.topic_runtime import publish_runtime_topic_packet 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) 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: def _normalize_packet_channel(packet: Dict[str, Any]) -> str:
raw = str(packet.get("channel") or packet.get("source") or "").strip().lower() raw = str(packet.get("channel") or packet.get("source") or "").strip().lower()
if raw in {"dashboard", "dashboard_channel", "dashboard-channel"}: if raw in {"dashboard", "dashboard_channel", "dashboard-channel"}:
@ -54,7 +58,7 @@ def _normalize_media_item(bot_id: str, value: Any) -> str:
return "" return ""
if raw.startswith("/root/.nanobot/workspace/"): if raw.startswith("/root/.nanobot/workspace/"):
return raw[len("/root/.nanobot/workspace/") :].lstrip("/") return raw[len("/root/.nanobot/workspace/") :].lstrip("/")
root = _workspace_root(bot_id) root = get_bot_workspace_root(bot_id)
if os.path.isabs(raw): if os.path.isabs(raw):
try: try:
if os.path.commonpath([root, raw]) == root: 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 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: def docker_callback(bot_id: str, packet: Dict[str, Any]) -> None:
packet_type = str(packet.get("type", "")).upper() packet_type = str(packet.get("type", "")).upper()
if packet_type == "RAW_LOG": if packet_type == "RAW_LOG":
@ -272,3 +280,15 @@ async def _record_agent_loop_ready_warning(
_invalidate_bot_detail_cache(bot_id) _invalidate_bot_detail_cache(bot_id)
except Exception: except Exception:
logger.exception("Failed to record agent loop readiness warning for bot_id=%s", bot_id) 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,
)

View File

@ -19,7 +19,7 @@ from core.utils import (
) )
from models.skill import BotSkillInstall, SkillMarketItem from models.skill import BotSkillInstall, SkillMarketItem
from services.platform_service import get_platform_settings_snapshot 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: def _skill_market_root() -> str:
@ -341,7 +341,7 @@ def list_bot_skill_market_items(session: Session, *, bot_id: str) -> List[Dict[s
else ( else (
install_lookup[int(item.id or 0)].status == "INSTALLED" install_lookup[int(item.id or 0)].status == "INSTALLED"
and all( 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) 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() ).first()
try: 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() now = datetime.utcnow()
if not install_row: if not install_row:
install_row = BotSkillInstall( install_row = BotSkillInstall(

View File

@ -11,12 +11,16 @@ from core.utils import (
_is_ignored_skill_zip_top_level, _is_ignored_skill_zip_top_level,
_is_valid_top_level_skill_name, _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 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: 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: def _read_skill_description(entry_path: str) -> str:
candidates: List[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]]: def list_bot_skills(bot_id: str) -> List[Dict[str, Any]]:
return _list_workspace_skills(bot_id) return _list_workspace_skills(bot_id)

View File

@ -3,7 +3,7 @@ from typing import Any, Dict, Optional
from sqlmodel import Session 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 from .publisher import build_topic_publish_payload
@ -30,6 +30,6 @@ def publish_runtime_topic_packet(
try: try:
with Session(engine) as session: with Session(engine) as session:
_topic_publish_internal(session, bot_id, topic_payload) publish_topic_item(session, bot_id, topic_payload)
except Exception: except Exception:
logger.exception("topic auto publish failed for bot %s packet %s", bot_id, packet_type) logger.exception("topic auto publish failed for bot %s packet %s", bot_id, packet_type)

View File

@ -3,13 +3,16 @@ import re
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from sqlmodel import Session, select from sqlmodel import Session, select
from models.bot import BotInstance
from models.topic import TopicItem, TopicTopic from models.topic import TopicItem, TopicTopic
TOPIC_DEDUPE_WINDOW_SECONDS = 10 * 60 TOPIC_DEDUPE_WINDOW_SECONDS = 10 * 60
TOPIC_LEVEL_SET = {"info", "warn", "error", "success"} TOPIC_LEVEL_SET = {"info", "warn", "error", "success"}
_TOPIC_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,63}$") _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: 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() ).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]: def _normalize_topic_keywords(raw: Any) -> List[str]:
rows: List[str] = [] rows: List[str] = []
if isinstance(raw, list): 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), "item": _topic_item_to_dict(item),
"route": route_result, "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)

View File

@ -9,7 +9,7 @@ from fastapi import HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, RedirectResponse, Response, StreamingResponse from fastapi.responses import FileResponse, RedirectResponse, Response, StreamingResponse
from core.utils import _workspace_stat_ctime_iso 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 from services.platform_service import get_platform_settings_snapshot
TEXT_PREVIEW_EXTENSIONS = { TEXT_PREVIEW_EXTENSIONS = {
@ -32,7 +32,7 @@ TEXT_PREVIEW_EXTENSIONS = {
MARKDOWN_EXTENSIONS = {".md", ".markdown"} MARKDOWN_EXTENSIONS = {".md", ".markdown"}
def _resolve_workspace_path(bot_id: str, rel_path: Optional[str] = None) -> tuple[str, str]: 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("\\", "/") rel = (rel_path or "").strip().replace("\\", "/")
target = os.path.abspath(os.path.join(root, rel)) target = os.path.abspath(os.path.join(root, rel))
if os.path.commonpath([root, target]) != root: 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 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: def _write_text_atomic(target: str, content: str) -> None:
os.makedirs(os.path.dirname(target), exist_ok=True) os.makedirs(os.path.dirname(target), exist_ok=True)
tmp = f"{target}.tmp" tmp = f"{target}.tmp"
@ -249,7 +253,7 @@ def get_workspace_tree_data(
path: Optional[str] = None, path: Optional[str] = None,
recursive: bool = False, recursive: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
root = _workspace_root(bot_id) root = get_bot_workspace_root(bot_id)
if not os.path.isdir(root): if not os.path.isdir(root):
return {"bot_id": bot_id, "root": root, "cwd": "", "parent": None, "entries": []} return {"bot_id": bot_id, "root": root, "cwd": "", "parent": None, "entries": []}

View File

@ -161,7 +161,7 @@
### 2.6 前端禁止事项 ### 2.6 前端禁止事项
- 禁止再次把页面做成“一个文件管状态、接口、弹层、列表、详情、搜索、分页” - 禁止把页面做成“一个文件管状态、接口、弹层、列表、详情、搜索、分页”
- 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件 - 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件
- 禁止创建无明确职责的超通用组件 - 禁止创建无明确职责的超通用组件
- 禁止为减少行数而做不可读的过度抽象 - 禁止为减少行数而做不可读的过度抽象
@ -226,12 +226,6 @@ Router 不允许承担:
- 数据库表间拼装 - 数据库表间拼装
- 本地文件系统读写细节 - 本地文件系统读写细节
Router 文件体量规则:
- 目标:`< 300`
- 可接受上限:`400` 行
- 超过 `400` 行必须拆成子 router并由装配层统一 `include_router`
### 3.4 Service 规范 ### 3.4 Service 规范
Service 必须按业务域内聚组织,而不是为了压缩行数而机械切碎。 Service 必须按业务域内聚组织,而不是为了压缩行数而机械切碎。

View File

@ -68,26 +68,18 @@ function AppShell() {
const showNavRail = route.kind !== 'bot' && !compactMode; const showNavRail = route.kind !== 'bot' && !compactMode;
const showAppNavDrawerEntry = route.kind !== 'bot' && compactMode; const showAppNavDrawerEntry = route.kind !== 'bot' && compactMode;
const showBotPanelDrawerEntry = 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 useCompactSimpleHeader = showBotPanelDrawerEntry || showAppNavDrawerEntry;
const headerTitle = showBotPanelDrawerEntry const headerTitle = showBotPanelDrawerEntry
? (botCompactPanelTab === 'runtime' ? t.botPanels.runtime : t.botPanels.chat) ? (activeCompactPanelTab === 'runtime' ? t.botPanels.runtime : t.botPanels.chat)
: routeMeta.title; : routeMeta.title;
useEffect(() => { useEffect(() => {
document.title = `${t.title} - ${route.kind === 'bot' ? botDocumentTitle : routeMeta.title}`; document.title = `${t.title} - ${route.kind === 'bot' ? botDocumentTitle : routeMeta.title}`;
}, [botDocumentTitle, route.kind, routeMeta.title, t.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 botPanelLabels = t.botPanels;
const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingTitle; const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingTitle;
const drawerBotId = String(forcedBotId || '').trim() || '-'; const drawerBotId = String(forcedBotId || '').trim() || '-';
@ -152,7 +144,7 @@ function AppShell() {
<LazyBotHomePage <LazyBotHomePage
botId={forcedBotId} botId={forcedBotId}
compactMode={compactMode} compactMode={compactMode}
compactPanelTab={botCompactPanelTab} compactPanelTab={activeCompactPanelTab}
onCompactPanelTabChange={setBotCompactPanelTab} onCompactPanelTabChange={setBotCompactPanelTab}
/> />
</BotRouteAccessGate> </BotRouteAccessGate>
@ -299,7 +291,7 @@ function AppShell() {
</div> </div>
</div> </div>
{showAppNavDrawerEntry && appNavDrawerOpen ? ( {appNavDrawerVisible ? (
<div className="app-bot-panel-drawer-mask" onClick={() => setAppNavDrawerOpen(false)}> <div className="app-bot-panel-drawer-mask" onClick={() => setAppNavDrawerOpen(false)}>
<aside <aside
className="app-bot-panel-drawer app-nav-drawer" className="app-bot-panel-drawer app-nav-drawer"
@ -354,7 +346,7 @@ function AppShell() {
</div> </div>
) : null} ) : null}
{showBotPanelDrawerEntry && botPanelDrawerOpen ? ( {botPanelDrawerVisible ? (
<div className="app-bot-panel-drawer-mask" onClick={() => setBotPanelDrawerOpen(false)}> <div className="app-bot-panel-drawer-mask" onClick={() => setBotPanelDrawerOpen(false)}>
<aside <aside
className="app-bot-panel-drawer" className="app-bot-panel-drawer"
@ -380,26 +372,26 @@ function AppShell() {
<div className="app-bot-panel-drawer-list" role="tablist" aria-label={botPanelLabels.title}> <div className="app-bot-panel-drawer-list" role="tablist" aria-label={botPanelLabels.title}>
<button <button
type="button" type="button"
className={`app-bot-panel-drawer-item ${botCompactPanelTab === 'chat' ? 'is-active' : ''}`} className={`app-bot-panel-drawer-item ${activeCompactPanelTab === 'chat' ? 'is-active' : ''}`}
onClick={() => { onClick={() => {
setBotCompactPanelTab('chat'); setBotCompactPanelTab('chat');
setBotPanelDrawerOpen(false); setBotPanelDrawerOpen(false);
}} }}
role="tab" role="tab"
aria-selected={botCompactPanelTab === 'chat'} aria-selected={activeCompactPanelTab === 'chat'}
> >
<MessageSquareText size={16} /> <MessageSquareText size={16} />
<span>{botPanelLabels.chat}</span> <span>{botPanelLabels.chat}</span>
</button> </button>
<button <button
type="button" type="button"
className={`app-bot-panel-drawer-item ${botCompactPanelTab === 'runtime' ? 'is-active' : ''}`} className={`app-bot-panel-drawer-item ${activeCompactPanelTab === 'runtime' ? 'is-active' : ''}`}
onClick={() => { onClick={() => {
setBotCompactPanelTab('runtime'); setBotCompactPanelTab('runtime');
setBotPanelDrawerOpen(false); setBotPanelDrawerOpen(false);
}} }}
role="tab" role="tab"
aria-selected={botCompactPanelTab === 'runtime'} aria-selected={activeCompactPanelTab === 'runtime'}
> >
<Activity size={16} /> <Activity size={16} />
<span>{botPanelLabels.runtime}</span> <span>{botPanelLabels.runtime}</span>

View File

@ -7,9 +7,6 @@ import { useAppStore } from '../store/appStore';
import type { BotState } from '../types/bot'; import type { BotState } from '../types/bot';
import { import {
BOT_AUTH_INVALID_EVENT, BOT_AUTH_INVALID_EVENT,
clearBotAccessPassword,
getBotAccessPassword,
setBotAccessPassword,
} from '../utils/botAccess'; } from '../utils/botAccess';
interface BotRouteAccessGateProps { interface BotRouteAccessGateProps {
@ -133,32 +130,6 @@ export function BotRouteAccessGate({
return () => window.removeEventListener(BOT_AUTH_INVALID_EVENT, handleBotAuthInvalid as EventListener); return () => window.removeEventListener(BOT_AUTH_INVALID_EVENT, handleBotAuthInvalid as EventListener);
}, [copy.errorExpired, normalizedBotId, passwordEnabled]); }, [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 unlockBot = async () => {
const entered = String(password || '').trim(); const entered = String(password || '').trim();
if (!entered || !normalizedBotId) { if (!entered || !normalizedBotId) {
@ -168,13 +139,11 @@ export function BotRouteAccessGate({
setSubmitting(true); setSubmitting(true);
try { try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/login`, { password: entered }); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/login`, { password: entered });
setBotAccessPassword(normalizedBotId, entered);
setPasswordError(''); setPasswordError('');
setUnlocked(true); setUnlocked(true);
setPassword(''); setPassword('');
await refreshBotDetail(); await refreshBotDetail();
} catch { } catch {
clearBotAccessPassword(normalizedBotId);
setPasswordError(copy.errorInvalid); setPasswordError(copy.errorInvalid);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@ -184,10 +153,13 @@ export function BotRouteAccessGate({
const shouldPromptPassword = Boolean( const shouldPromptPassword = Boolean(
normalizedBotId && passwordEnabled && !authChecking && !unlocked, normalizedBotId && passwordEnabled && !authChecking && !unlocked,
); );
const canRenderChildren = normalizedBotId
? (!authChecking && (unlocked || !passwordEnabled))
: true;
return ( return (
<> <>
{children} {canRenderChildren ? children : null}
{shouldPromptPassword ? ( {shouldPromptPassword ? (
<div className="modal-mask app-modal-mask"> <div className="modal-mask app-modal-mask">
<div className="app-login-card" onClick={(event) => event.stopPropagation()}> <div className="app-login-card" onClick={(event) => event.stopPropagation()}>

View File

@ -9,9 +9,6 @@ import { pickLocale } from '../i18n';
import { useAppStore } from '../store/appStore'; import { useAppStore } from '../store/appStore';
import { import {
PANEL_AUTH_INVALID_EVENT, PANEL_AUTH_INVALID_EVENT,
clearPanelAccessPassword,
getPanelAccessPassword,
setPanelAccessPassword,
} from '../utils/panelAccess'; } from '../utils/panelAccess';
interface PanelLoginGateProps { interface PanelLoginGateProps {
@ -46,9 +43,10 @@ export function PanelLoginGate({
let alive = true; let alive = true;
const boot = async () => { const boot = async () => {
try { 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; if (!alive) return;
const enabled = Boolean(status.data?.enabled); const enabled = Boolean(status.data?.enabled);
const authenticated = Boolean(status.data?.authenticated);
if (!enabled) { if (!enabled) {
setRequired(false); setRequired(false);
setAuthenticated(true); setAuthenticated(true);
@ -56,25 +54,15 @@ export function PanelLoginGate({
return; return;
} }
setRequired(true); setRequired(true);
const stored = getPanelAccessPassword(); if (!authenticated) {
if (!stored) { setAuthenticated(false);
setChecking(false); setChecking(false);
return; return;
} }
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: stored });
if (!alive) return;
setAuthenticated(true); setAuthenticated(true);
} catch { setChecking(false);
clearPanelAccessPassword();
if (!alive) return;
setError(isZh ? '面板访问密码错误,请重新输入。' : 'Invalid panel access password. Please try again.');
} finally {
if (alive) setChecking(false);
}
} catch { } catch {
if (!alive) return; if (!alive) return;
clearPanelAccessPassword();
setRequired(true); setRequired(true);
setAuthenticated(false); setAuthenticated(false);
setError( setError(
@ -118,12 +106,13 @@ export function PanelLoginGate({
setError(''); setError('');
try { try {
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: next }); await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: next });
setPanelAccessPassword(next);
setAuthenticated(true); setAuthenticated(true);
} catch (error: any) { } catch (error: unknown) {
clearPanelAccessPassword(); const resolvedError = (error && typeof error === 'object'
? error
: {}) as { response?: { data?: { detail?: string } } };
setError( setError(
error?.response?.data?.detail resolvedError.response?.data?.detail
|| (isZh ? '面板访问密码错误。' : 'Invalid panel access password.'), || (isZh ? '面板访问密码错误。' : 'Invalid panel access password.'),
); );
} finally { } finally {

View File

@ -40,10 +40,11 @@ export function DrawerShell({
bodyClassName, bodyClassName,
}: DrawerShellProps) { }: DrawerShellProps) {
const [mounted, setMounted] = useState(open); const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(open); const visible = open;
useEffect(() => { useEffect(() => {
if (!open) return undefined; if (!open) return undefined;
if (!mounted) return undefined;
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@ -55,27 +56,27 @@ export function DrawerShell({
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}; };
}, [onClose, open]); }, [mounted, onClose, open]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setMounted(true); if (mounted) return undefined;
const frameId = window.requestAnimationFrame(() => { const frameId = window.requestAnimationFrame(() => {
setVisible(true); setMounted(true);
}); });
return () => { return () => {
window.cancelAnimationFrame(frameId); window.cancelAnimationFrame(frameId);
}; };
} }
setVisible(false); if (!mounted) return undefined;
const timerId = window.setTimeout(() => { const timerId = window.setTimeout(() => {
setMounted(false); setMounted(false);
}, DRAWER_ANIMATION_MS); }, DRAWER_ANIMATION_MS);
return () => { return () => {
window.clearTimeout(timerId); window.clearTimeout(timerId);
}; };
}, [open]); }, [mounted, open]);
if (!mounted) return null; if (!mounted) return null;

View File

@ -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%);
}

View File

@ -1,6 +1,8 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'; import { useEffect, useRef, useState, type FormEvent } from 'react';
import { Search, X } from 'lucide-react'; import { Search, X } from 'lucide-react';
import './ProtectedSearchInput.css';
interface ProtectedSearchInputProps { interface ProtectedSearchInputProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
@ -47,16 +49,18 @@ export function ProtectedSearchInput({
const hasValue = currentValue.trim().length > 0; const hasValue = currentValue.trim().length > 0;
useEffect(() => { useEffect(() => {
if (debounceMs <= 0) return; if (debounceMs <= 0) {
if (value === latestExternalValueRef.current) return;
latestExternalValueRef.current = value; latestExternalValueRef.current = value;
setDraftValue(value); return undefined;
}, [debounceMs, value]); }
if (value === latestExternalValueRef.current) return undefined;
useEffect(() => {
if (debounceMs > 0) return;
setDraftValue(value);
latestExternalValueRef.current = value; latestExternalValueRef.current = value;
const frameId = window.requestAnimationFrame(() => {
setDraftValue(value);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [debounceMs, value]); }, [debounceMs, value]);
useEffect(() => () => { useEffect(() => () => {

View File

@ -9,6 +9,52 @@ import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn';
import { botsSyncEn } from '../i18n/bots-sync.en'; import { botsSyncEn } from '../i18n/bots-sync.en';
import { buildMonitorWsUrl, notifyBotAuthInvalid } from '../utils/botAccess'; 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' { function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' {
const s = (v || '').toUpperCase(); const s = (v || '').toUpperCase();
if (s === 'THINKING' || s === 'TOOL_CALL' || s === 'SUCCESS' || s === 'ERROR') return s; 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(); const target = String(botId || '').trim();
if (!target) return; if (!target) return;
try { 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 rows = Array.isArray(res.data?.items) ? res.data.items : [];
const latestPage: ChatMessage[] = rows const latestPage: ChatMessage[] = rows
.map((row) => { .map((row) => {
@ -152,8 +198,9 @@ export function useBotsSync(forcedBotId?: string) {
} }
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`); const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
setBots(res.data); setBots(res.data);
} catch (error: any) { } catch (error: unknown) {
const status = Number(error?.response?.status || 0); const resolvedError = (error && typeof error === 'object' ? error : {}) as RequestErrorShape;
const status = Number(resolvedError.response?.status || 0);
if (forced && status === 401) { if (forced && status === 401) {
setBots([]); setBots([]);
return; return;
@ -240,9 +287,9 @@ export function useBotsSync(forcedBotId?: string) {
void syncBotMessages(bot.id); void syncBotMessages(bot.id);
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
let data: any; let data: MonitorWsMessage;
try { try {
data = JSON.parse(event.data); data = JSON.parse(event.data) as MonitorWsMessage;
} catch { } catch {
return; return;
} }
@ -374,7 +421,7 @@ export function useBotsSync(forcedBotId?: string) {
return () => { return () => {
// no-op: clean in unmount effect below // 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(() => { useEffect(() => {
return () => { return () => {

View File

@ -1,10 +1,7 @@
import { BotDashboardView } from './components/BotDashboardView'; import { BotDashboardView } from './components/BotDashboardView';
import { useBotDashboardModule } from './hooks/useBotDashboardModule'; import { useBotDashboardModule } from './hooks/useBotDashboardModule';
import type { BotDashboardModuleProps } from './types'; import type { BotDashboardModuleProps } from './types';
import { import { useBotDashboardViewProps } from './useBotDashboardViewProps';
formatBytes,
formatWorkspaceTime,
} from './utils';
import './BotDashboardModule.css'; import './BotDashboardModule.css';
import './components/DashboardShared.css'; import './components/DashboardShared.css';
@ -21,487 +18,11 @@ export function BotDashboardModule({
compactPanelTab: compactPanelTabProp, compactPanelTab: compactPanelTabProp,
onCompactPanelTabChange, onCompactPanelTabChange,
}); });
const runtimeMoreLabel = dashboard.isZh ? '更多' : 'More';
const botListPanelProps = { const viewProps = useBotDashboardViewProps({
bots: dashboard.bots, dashboard,
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, 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 = { return <BotDashboardView {...viewProps} />;
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}
/>
);
} }

View File

@ -3,10 +3,14 @@ import { MessageCircle, MessageSquareText, X } from 'lucide-react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { CompactPanelTab, RuntimeViewMode } from '../types'; import type { CompactPanelTab, RuntimeViewMode } from '../types';
import { BotListPanel } from './BotListPanel';
import { DashboardChatPanel } from './DashboardChatPanel'; 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(() => const LazyCreateBotWizardModal = lazy(() =>
import('../../onboarding/CreateBotWizardModal').then((module) => ({ default: module.CreateBotWizardModal })), import('../../onboarding/CreateBotWizardModal').then((module) => ({ default: module.CreateBotWizardModal })),
); );
@ -25,7 +29,7 @@ export interface BotDashboardViewProps {
compactMode: boolean; compactMode: boolean;
hasForcedBot: boolean; hasForcedBot: boolean;
showBotListPanel: boolean; showBotListPanel: boolean;
botListPanelProps: ComponentProps<typeof BotListPanel>; botListPanelProps: Parameters<typeof import('./BotListPanel').BotListPanel>[0];
hasSelectedBot: boolean; hasSelectedBot: boolean;
isCompactListPage: boolean; isCompactListPage: boolean;
compactPanelTab: CompactPanelTab; compactPanelTab: CompactPanelTab;
@ -38,7 +42,7 @@ export interface BotDashboardViewProps {
onRuntimeViewModeChange: (mode: RuntimeViewMode) => void; onRuntimeViewModeChange: (mode: RuntimeViewMode) => void;
topicFeedPanelProps: TopicFeedPanelProps; topicFeedPanelProps: TopicFeedPanelProps;
dashboardChatPanelProps: ComponentProps<typeof DashboardChatPanel>; dashboardChatPanelProps: ComponentProps<typeof DashboardChatPanel>;
runtimePanelProps: ComponentProps<typeof RuntimePanel>; runtimePanelProps: Parameters<typeof import('./RuntimePanel').RuntimePanel>[0];
onCompactClose: () => void; onCompactClose: () => void;
dashboardModalStackProps: DashboardModalStackProps; dashboardModalStackProps: DashboardModalStackProps;
createBotModalProps: CreateBotWizardModalProps; createBotModalProps: CreateBotWizardModalProps;
@ -86,7 +90,11 @@ export function BotDashboardView({
return ( return (
<> <>
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}> <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' : ''}`}> <section className={`panel ops-chat-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'chat') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
{hasSelectedBot ? ( {hasSelectedBot ? (
@ -137,7 +145,9 @@ export function BotDashboardView({
)} )}
</section> </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> </div>
{showCompactBotPageClose ? ( {showCompactBotPageClose ? (

View File

@ -1,5 +1,5 @@
import { Boxes, ChevronLeft, ChevronRight, EllipsisVertical, ExternalLink, FileText, Gauge, Lock, Plus, Power, Square, Trash2 } from 'lucide-react'; 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 { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
@ -70,7 +70,7 @@ interface BotListPanelProps {
onRemoveBot: (botId: string) => Promise<void> | void; onRemoveBot: (botId: string) => Promise<void> | void;
} }
export function BotListPanel({ export const BotListPanel = memo(function BotListPanel({
bots, bots,
filteredBots, filteredBots,
pagedBots, pagedBots,
@ -360,4 +360,4 @@ export function BotListPanel({
) : null} ) : null}
</section> </section>
); );
} });

View File

@ -8,6 +8,8 @@ import { workspaceFileAction } from '../../../shared/workspace/utils';
import { formatDateInputValue } from '../chat/chatUtils'; import { formatDateInputValue } from '../chat/chatUtils';
import type { DashboardChatPanelLabels } from './DashboardChatPanel.types'; import type { DashboardChatPanelLabels } from './DashboardChatPanel.types';
const TODAY_DATE_INPUT_MAX = formatDateInputValue(Date.now());
interface DashboardChatComposerProps { interface DashboardChatComposerProps {
isZh: boolean; isZh: boolean;
labels: DashboardChatPanelLabels; labels: DashboardChatPanelLabels;
@ -276,7 +278,7 @@ export function DashboardChatComposer({
className="input ops-control-date-input" className="input ops-control-date-input"
type="date" type="date"
value={chatDateValue} value={chatDateValue}
max={formatDateInputValue(Date.now())} max={TODAY_DATE_INPUT_MAX}
onChange={(event) => onChatDateValueChange(event.target.value)} onChange={(event) => onChatDateValueChange(event.target.value)}
/> />
</label> </label>

View File

@ -1,15 +1,18 @@
import type { Components } from 'react-markdown'; 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 nanobotLogo from '../../../assets/nanobot-logo.png';
import type { ChatMessage } from '../../../types/bot'; import type { ChatMessage } from '../../../types/bot';
import type { StagedSubmissionDraft } from '../types'; import type { StagedSubmissionDraft } from '../types';
import { DashboardChatComposer } from './DashboardChatComposer'; import { DashboardChatComposer } from './DashboardChatComposer';
import type { DashboardChatPanelLabels } from './DashboardChatPanel.types'; import type { DashboardChatPanelLabels } from './DashboardChatPanel.types';
import { DashboardConversationMessages } from './DashboardConversationMessages';
import { DashboardStagedSubmissionQueue } from './DashboardStagedSubmissionQueue'; import { DashboardStagedSubmissionQueue } from './DashboardStagedSubmissionQueue';
import './DashboardChatPanel.css'; import './DashboardChatPanel.css';
const LazyDashboardConversationMessages = lazy(() =>
import('./DashboardConversationMessages').then((module) => ({ default: module.DashboardConversationMessages })),
);
interface DashboardChatPanelProps { interface DashboardChatPanelProps {
conversation: ChatMessage[]; conversation: ChatMessage[];
isZh: boolean; isZh: boolean;
@ -84,7 +87,7 @@ interface DashboardChatPanelProps {
interface DashboardChatTranscriptProps { interface DashboardChatTranscriptProps {
conversation: ChatMessage[]; conversation: ChatMessage[];
isZh: boolean; isZh: boolean;
labels: DashboardChatPanelLabels; labels: DashboardConversationTranscriptLabels;
chatScrollRef: RefObject<HTMLDivElement | null>; chatScrollRef: RefObject<HTMLDivElement | null>;
onChatScroll: () => void; onChatScroll: () => void;
expandedProgressByKey: Record<string, boolean>; expandedProgressByKey: Record<string, boolean>;
@ -105,6 +108,24 @@ interface DashboardChatTranscriptProps {
isThinking: boolean; 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({ const MemoizedChatTranscript = memo(function MemoizedChatTranscript({
conversation, conversation,
isZh, isZh,
@ -135,24 +156,11 @@ const MemoizedChatTranscript = memo(function MemoizedChatTranscript({
{labels.noConversation} {labels.noConversation}
</div> </div>
) : ( ) : (
<DashboardConversationMessages <Suspense fallback={<div className="ops-empty-inline">{isZh ? '读取对话内容中...' : 'Loading conversation...'}</div>}>
<LazyDashboardConversationMessages
conversation={conversation} conversation={conversation}
isZh={isZh} isZh={isZh}
labels={{ labels={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} expandedProgressByKey={expandedProgressByKey}
expandedUserByKey={expandedUserByKey} expandedUserByKey={expandedUserByKey}
deletingMessageIdMap={deletingMessageIdMap} deletingMessageIdMap={deletingMessageIdMap}
@ -169,6 +177,7 @@ const MemoizedChatTranscript = memo(function MemoizedChatTranscript({
onQuoteAssistantReply={onQuoteAssistantReply} onQuoteAssistantReply={onQuoteAssistantReply}
onCopyAssistantReply={onCopyAssistantReply} onCopyAssistantReply={onCopyAssistantReply}
/> />
</Suspense>
)} )}
{isThinking ? ( {isThinking ? (
@ -192,33 +201,7 @@ const MemoizedChatTranscript = memo(function MemoizedChatTranscript({
<div /> <div />
</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({ export function DashboardChatPanel({
conversation, conversation,
@ -290,12 +273,60 @@ export function DashboardChatPanel({
submitActionMode, submitActionMode,
onSubmitAction, onSubmitAction,
}: DashboardChatPanelProps) { }: 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 ( return (
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}> <div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
<MemoizedChatTranscript <MemoizedChatTranscript
conversation={conversation} conversation={conversation}
isZh={isZh} isZh={isZh}
labels={labels} labels={transcriptLabels}
chatScrollRef={chatScrollRef} chatScrollRef={chatScrollRef}
onChatScroll={onChatScroll} onChatScroll={onChatScroll}
expandedProgressByKey={expandedProgressByKey} expandedProgressByKey={expandedProgressByKey}
@ -318,7 +349,7 @@ export function DashboardChatPanel({
<div className="ops-chat-dock"> <div className="ops-chat-dock">
<DashboardStagedSubmissionQueue <DashboardStagedSubmissionQueue
labels={labels} labels={stagedSubmissionLabels}
stagedSubmissions={stagedSubmissions} stagedSubmissions={stagedSubmissions}
onRestoreStagedSubmission={onRestoreStagedSubmission} onRestoreStagedSubmission={onRestoreStagedSubmission}
onRemoveStagedSubmission={onRemoveStagedSubmission} onRemoveStagedSubmission={onRemoveStagedSubmission}

View File

@ -1,20 +1,20 @@
import { ChevronDown, ChevronUp, Copy, Download, Eye, FileText, Pencil, Reply, ThumbsDown, ThumbsUp, Trash2, UserRound } from 'lucide-react'; import { ChevronDown, ChevronUp, Copy, Download, Eye, FileText, Pencil, Reply, ThumbsDown, ThumbsUp, Trash2, UserRound } from 'lucide-react';
import { memo } from 'react'; import { Suspense, lazy, memo } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown'; import type { Components } from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import remarkGfm from 'remark-gfm';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import nanobotLogo from '../../../assets/nanobot-logo.png'; import nanobotLogo from '../../../assets/nanobot-logo.png';
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../../../shared/text/messageText'; import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../../../shared/text/messageText';
import { MARKDOWN_SANITIZE_SCHEMA } from '../../../shared/workspace/constants';
import { decorateWorkspacePathsForMarkdown, normalizeDashboardAttachmentPath } from '../../../shared/workspace/workspaceMarkdown'; import { decorateWorkspacePathsForMarkdown, normalizeDashboardAttachmentPath } from '../../../shared/workspace/workspaceMarkdown';
import { workspaceFileAction } from '../../../shared/workspace/utils'; import { workspaceFileAction } from '../../../shared/workspace/utils';
import type { ChatMessage } from '../../../types/bot'; import type { ChatMessage } from '../../../types/bot';
import { formatClock, formatConversationDate } from '../chat/chatUtils'; import { formatClock, formatConversationDate } from '../chat/chatUtils';
import './DashboardConversationMessages.css'; import './DashboardConversationMessages.css';
const LazyMarkdownRenderer = lazy(() =>
import('../../../shared/markdown/MarkdownRenderer').then((module) => ({ default: module.MarkdownRenderer })),
);
interface DashboardConversationLabels { interface DashboardConversationLabels {
badReply: string; badReply: string;
copyPrompt: string; copyPrompt: string;
@ -215,13 +215,14 @@ const DashboardConversationMessageRow = memo(function DashboardConversationMessa
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div> <div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
</> </>
) : ( ) : (
<ReactMarkdown <Suspense
remarkPlugins={[remarkGfm]} fallback={<div className="whitespace-pre-wrap">{normalizeAssistantMessageText(displayText)}</div>}
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents}
> >
{decorateWorkspacePathsForMarkdown(displayText)} <LazyMarkdownRenderer
</ReactMarkdown> components={markdownComponents}
content={decorateWorkspacePathsForMarkdown(displayText)}
/>
</Suspense>
) )
) : null} ) : null}
{(item.attachments || []).length > 0 ? ( {(item.attachments || []).length > 0 ? (

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useMemo, useState } from 'react';
import { Plus, Save, Trash2, X } from 'lucide-react'; import { Plus, Save, Trash2, X } from 'lucide-react';
import { DrawerShell } from '../../../components/DrawerShell'; import { DrawerShell } from '../../../components/DrawerShell';
@ -57,26 +57,28 @@ export function EnvParamsModal({
const [createPanelOpen, setCreatePanelOpen] = useState(false); const [createPanelOpen, setCreatePanelOpen] = useState(false);
const [envEditDrafts, setEnvEditDrafts] = useState<Record<string, { key: string; value: string }>>({}); const [envEditDrafts, setEnvEditDrafts] = useState<Record<string, { key: string; value: string }>>({});
useEffect(() => { const resetLocalState = () => {
if (open) return;
setCreatePanelOpen(false); setCreatePanelOpen(false);
}, [open]); setEnvEditDrafts({});
};
useEffect(() => { const mergedEnvDrafts = useMemo(() => {
if (!open) return;
const nextDrafts: Record<string, { key: string; value: string }> = {}; const nextDrafts: Record<string, { key: string; value: string }> = {};
envEntries.forEach(([key, value]) => { envEntries.forEach(([key, value]) => {
nextDrafts[key] = { key, value }; nextDrafts[key] = envEditDrafts[key] || { key, value };
}); });
setEnvEditDrafts(nextDrafts); return nextDrafts;
}, [envEntries, open]); }, [envEditDrafts, envEntries]);
if (!open) return null; if (!open) return null;
return ( return (
<DrawerShell <DrawerShell
open={open} open={open}
onClose={onClose} onClose={() => {
resetLocalState();
onClose();
}}
title={labels.envParams} title={labels.envParams}
size="standard" size="standard"
bodyClassName="ops-config-drawer-body" bodyClassName="ops-config-drawer-body"
@ -99,7 +101,7 @@ export function EnvParamsModal({
<div className="ops-empty-inline">{labels.noEnvParams}</div> <div className="ops-empty-inline">{labels.noEnvParams}</div>
) : ( ) : (
envEntries.map(([key, value]) => { envEntries.map(([key, value]) => {
const draft = envEditDrafts[key] || { key, value }; const draft = mergedEnvDrafts[key] || { key, value };
return ( return (
<div key={key} className="card wizard-channel-card wizard-channel-compact"> <div key={key} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header"> <div className="ops-config-card-header">
@ -110,7 +112,16 @@ export function EnvParamsModal({
<div className="ops-config-card-actions"> <div className="ops-config-card-actions">
<LucentIconButton <LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn" 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} tooltip={labels.removeEnvParam}
aria-label={labels.removeEnvParam} aria-label={labels.removeEnvParam}
> >
@ -165,7 +176,19 @@ export function EnvParamsModal({
</div> </div>
<div className="row-between ops-config-footer"> <div className="row-between ops-config-footer">
<span className="field-label">{labels.envParamsHint}</span> <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} /> <Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.save}</span> <span style={{ marginLeft: 6 }}>{labels.save}</span>
</button> </button>
@ -186,7 +209,7 @@ export function EnvParamsModal({
<LucentIconButton <LucentIconButton
className="ops-plain-icon-btn" className="ops-plain-icon-btn"
onClick={() => { onClick={() => {
setCreatePanelOpen(false); resetLocalState();
onEnvDraftKeyChange(''); onEnvDraftKeyChange('');
onEnvDraftValueChange(''); onEnvDraftValueChange('');
}} }}

View File

@ -1,17 +1,25 @@
import { Pencil, Trash2 } from 'lucide-react'; import { Pencil, Trash2 } from 'lucide-react';
import { memo } from 'react';
import { normalizeUserMessageText } from '../../../shared/text/messageText'; import { normalizeUserMessageText } from '../../../shared/text/messageText';
import type { StagedSubmissionDraft } from '../types'; 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 { interface DashboardStagedSubmissionQueueProps {
labels: DashboardChatPanelLabels; labels: DashboardStagedSubmissionQueueLabels;
stagedSubmissions: StagedSubmissionDraft[]; stagedSubmissions: StagedSubmissionDraft[];
onRestoreStagedSubmission: (stagedSubmissionId: string) => void; onRestoreStagedSubmission: (stagedSubmissionId: string) => void;
onRemoveStagedSubmission: (stagedSubmissionId: string) => void; onRemoveStagedSubmission: (stagedSubmissionId: string) => void;
} }
export function DashboardStagedSubmissionQueue({ export const DashboardStagedSubmissionQueue = memo(function DashboardStagedSubmissionQueue({
labels, labels,
stagedSubmissions, stagedSubmissions,
onRestoreStagedSubmission, onRestoreStagedSubmission,
@ -67,4 +75,4 @@ export function DashboardStagedSubmissionQueue({
))} ))}
</div> </div>
); );
} });

View File

@ -1,5 +1,5 @@
import { Boxes, Check, Clock3, EllipsisVertical, FileText, Hammer, MessageSquareText, RefreshCw, RotateCcw, Save, Settings2, SlidersHorizontal, TriangleAlert, Trash2, Waypoints } from 'lucide-react'; 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 { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
@ -92,7 +92,7 @@ interface RuntimePanelProps {
onHideWorkspaceHoverCard: () => void; onHideWorkspaceHoverCard: () => void;
} }
export function RuntimePanel({ export const RuntimePanel = memo(function RuntimePanel({
selectedBot, selectedBot,
selectedBotEnabled, selectedBotEnabled,
operatingBotId, operatingBotId,
@ -336,4 +336,4 @@ export function RuntimePanel({
)} )}
</section> </section>
); );
} });

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useId, useState } from 'react';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { pickLocale } from '../../../i18n'; import { pickLocale } from '../../../i18n';
@ -44,14 +44,8 @@ export function useBotDashboardModule({
const [botListPageSize, setBotListPageSize] = useState(10); const [botListPageSize, setBotListPageSize] = useState(10);
const [chatPullPageSize, setChatPullPageSize] = useState(60); const [chatPullPageSize, setChatPullPageSize] = useState(60);
const [commandAutoUnlockSeconds, setCommandAutoUnlockSeconds] = useState(10); const [commandAutoUnlockSeconds, setCommandAutoUnlockSeconds] = useState(10);
const botSearchInputName = useMemo( const botSearchInputName = `nbot-search-${useId().replace(/:/g, '-')}`;
() => `nbot-search-${Math.random().toString(36).slice(2, 10)}`, const workspaceSearchInputName = `nbot-workspace-search-${useId().replace(/:/g, '-')}`;
[],
);
const workspaceSearchInputName = useMemo(
() => `nbot-workspace-search-${Math.random().toString(36).slice(2, 10)}`,
[],
);
const { const {
allowedAttachmentExtensions, allowedAttachmentExtensions,
botListPageSizeReady, botListPageSizeReady,

View File

@ -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,
};
}

View File

@ -31,7 +31,10 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
isZh={dashboard.isZh} isZh={dashboard.isZh}
lastActionPreview={dashboard.lastActionPreview} lastActionPreview={dashboard.lastActionPreview}
operatingBotId={dashboard.operatingBotId} operatingBotId={dashboard.operatingBotId}
selectedBotEnabledChannels={dashboard.selectedBotEnabledChannels}
selectedBotInfo={dashboard.selectedBotInfo} selectedBotInfo={dashboard.selectedBotInfo}
selectedBotMcpCount={dashboard.selectedBotMcpCount}
selectedBotSkillCount={dashboard.selectedBotSkillCount}
selectedBotUsageSummary={dashboard.selectedBotUsageSummary} selectedBotUsageSummary={dashboard.selectedBotUsageSummary}
onClearDashboardDirectSession={dashboard.clearDashboardDirectSession} onClearDashboardDirectSession={dashboard.clearDashboardDirectSession}
onOpenBotPanel={dashboard.openBotPanel} onOpenBotPanel={dashboard.openBotPanel}

View File

@ -624,8 +624,9 @@
} }
.platform-selected-bot-name { .platform-selected-bot-name {
font-size: 26px; font-size: 20px;
font-weight: 800; line-height: 1.3;
font-weight: 700;
color: var(--title); color: var(--title);
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@ -643,14 +644,14 @@
} }
.platform-selected-bot-name-id { .platform-selected-bot-name-id {
font-size: 0.78em; font-size: 0.74em;
color: var(--muted); color: var(--muted);
font-weight: 700; font-weight: 700;
} }
.platform-selected-bot-grid { .platform-selected-bot-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 14px; gap: 12px 14px;
margin-top: 18px; margin-top: 18px;
} }
@ -668,15 +669,19 @@
.platform-selected-bot-info-label { .platform-selected-bot-info-label {
font-size: 12px; font-size: 12px;
font-weight: 600;
line-height: 1.4;
color: var(--muted); color: var(--muted);
} }
.platform-selected-bot-info-value { .platform-selected-bot-info-value {
font-size: 14px; font-size: 13px;
line-height: 1.5;
font-weight: 600;
color: var(--title); color: var(--title);
overflow: hidden; white-space: normal;
text-overflow: ellipsis; overflow-wrap: anywhere;
white-space: nowrap; word-break: break-word;
} }
.platform-selected-bot-last-row { .platform-selected-bot-last-row {
@ -702,6 +707,8 @@
.platform-selected-bot-last-preview { .platform-selected-bot-last-preview {
display: block; display: block;
font-size: 13px;
line-height: 1.5;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -1264,6 +1271,22 @@
max-width: 460px; 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 { .platform-settings-table-wrap {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0; border-radius: 0;
@ -1308,6 +1331,19 @@
color: var(--muted); 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 { .platform-template-shell {
max-width: min(1400px, 96vw); max-width: min(1400px, 96vw);
} }

View File

@ -116,9 +116,9 @@ export function PlatformLoginLogPage({ isZh }: PlatformLoginLogPageProps) {
</div> </div>
</div> </div>
<div className="platform-settings-toolbar"> <div className="platform-settings-toolbar platform-login-log-toolbar">
<ProtectedSearchInput <ProtectedSearchInput
className="platform-searchbar platform-settings-search" className="platform-searchbar platform-settings-search platform-login-log-search"
value={search} value={search}
onChange={setSearch} onChange={setSearch}
onClear={() => setSearch('')} onClear={() => setSearch('')}
@ -128,12 +128,20 @@ export function PlatformLoginLogPage({ isZh }: PlatformLoginLogPageProps) {
clearTitle={isZh ? '清除搜索' : 'Clear search'} clearTitle={isZh ? '清除搜索' : 'Clear search'}
searchTitle={isZh ? '搜索' : '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="all">{isZh ? '全部类型' : 'All Types'}</option>
<option value="panel">{isZh ? 'Panel' : 'Panel'}</option> <option value="panel">{isZh ? 'Panel' : 'Panel'}</option>
<option value="bot">{isZh ? 'Bot' : 'Bot'}</option> <option value="bot">{isZh ? 'Bot' : 'Bot'}</option>
</LucentSelect> </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="all">{isZh ? '全部状态' : 'All Statuses'}</option>
<option value="active">{isZh ? '活跃中' : 'Active'}</option> <option value="active">{isZh ? '活跃中' : 'Active'}</option>
<option value="revoked">{isZh ? '已失效' : 'Revoked'}</option> <option value="revoked">{isZh ? '已失效' : 'Revoked'}</option>

View File

@ -9,7 +9,10 @@ interface PlatformBotOverviewSectionProps {
isZh: boolean; isZh: boolean;
lastActionPreview: string; lastActionPreview: string;
operatingBotId: string; operatingBotId: string;
selectedBotEnabledChannels: string[];
selectedBotInfo?: BotState; selectedBotInfo?: BotState;
selectedBotMcpCount: number;
selectedBotSkillCount: number;
selectedBotUsageSummary: PlatformUsageResponse['summary'] | null; selectedBotUsageSummary: PlatformUsageResponse['summary'] | null;
onClearDashboardDirectSession: (bot: BotState) => Promise<void> | void; onClearDashboardDirectSession: (bot: BotState) => Promise<void> | void;
onOpenBotPanel: (botId: string) => void; onOpenBotPanel: (botId: string) => void;
@ -18,12 +21,21 @@ interface PlatformBotOverviewSectionProps {
onRemoveBot: (bot: BotState) => Promise<void> | void; 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({ export function PlatformBotOverviewSection({
compactSheet = false, compactSheet = false,
isZh, isZh,
lastActionPreview, lastActionPreview,
operatingBotId, operatingBotId,
selectedBotEnabledChannels,
selectedBotInfo, selectedBotInfo,
selectedBotMcpCount,
selectedBotSkillCount,
selectedBotUsageSummary, selectedBotUsageSummary,
onClearDashboardDirectSession, onClearDashboardDirectSession,
onOpenBotPanel, onOpenBotPanel,
@ -31,6 +43,14 @@ export function PlatformBotOverviewSection({
onOpenResourceMonitor, onOpenResourceMonitor,
onRemoveBot, onRemoveBot,
}: PlatformBotOverviewSectionProps) { }: 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 ( return (
<section className={`${compactSheet ? 'platform-compact-overview' : 'panel stack'}`}> <section className={`${compactSheet ? 'platform-compact-overview' : 'panel stack'}`}>
<div className={compactSheet ? 'platform-compact-overview-head' : undefined}> <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-grid">
<div className="platform-selected-bot-info"> <div className="platform-selected-bot-info">
<span className="platform-selected-bot-info-label">{isZh ? '镜像' : 'Image'}</span> <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>
<div className="platform-selected-bot-info"> <div className="platform-selected-bot-info">
<span className="platform-selected-bot-info-label">{isZh ? 'Provider / 模型' : 'Provider / Model'}</span> <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 || '-'} {selectedBotInfo.llm_provider || '-'} / {selectedBotInfo.llm_model || '-'}
</span> </span>
</div> </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-label">{isZh ? 'Bot 状态' : 'Bot State'}</span>
<span className="platform-selected-bot-info-value">{selectedBotInfo.current_state || 'IDLE'}</span> <span className="platform-selected-bot-info-value">{selectedBotInfo.current_state || 'IDLE'}</span>
</div> </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"> <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-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 {isZh
? `请求 ${selectedBotUsageSummary?.request_count || 0} / 输入 ${selectedBotUsageSummary?.input_tokens || 0} / 输出 ${selectedBotUsageSummary?.output_tokens || 0} / 总计 ${selectedBotUsageSummary?.total_tokens || 0}` ? `请求 ${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}`} : `Req ${selectedBotUsageSummary?.request_count || 0} / In ${selectedBotUsageSummary?.input_tokens || 0} / Out ${selectedBotUsageSummary?.output_tokens || 0} / Total ${selectedBotUsageSummary?.total_tokens || 0}`}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
@ -6,317 +6,75 @@ import { APP_ENDPOINTS } from '../../../config/env';
import { sortBotsByCreatedAtDesc } from '../../../shared/bot/sortBots'; import { sortBotsByCreatedAtDesc } from '../../../shared/bot/sortBots';
import { useAppStore } from '../../../store/appStore'; import { useAppStore } from '../../../store/appStore';
import type { BotState } from '../../../types/bot'; import type { BotState } from '../../../types/bot';
import { import { buildBotPanelHref } from '../utils';
normalizePlatformPageSize, import { usePlatformManagementState } from './usePlatformManagementState';
readCachedPlatformPageSize, import { usePlatformOverviewState } from './usePlatformOverviewState';
writeCachedPlatformPageSize,
} from '../../../utils/platformPageSize';
import type {
BotActivityStatsItem,
PlatformBotResourceSnapshot,
PlatformOverviewResponse,
PlatformUsageAnalyticsSeriesItem,
PlatformUsageResponse,
} from '../types';
import {
buildBotPanelHref,
buildPlatformUsageAnalyticsSeries,
buildPlatformUsageAnalyticsTicks,
getPlatformChartCeiling,
} from '../utils';
interface UsePlatformDashboardOptions { interface UsePlatformDashboardOptions {
compactMode: boolean; compactMode: boolean;
mode?: 'admin' | 'management'; 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) { export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePlatformDashboardOptions) {
const { activeBots, setBots, updateBotStatus, locale } = useAppStore(); const { activeBots, setBots, updateBotStatus, locale } = useAppStore();
const { notify, confirm } = useLucentPrompt(); const { notify, confirm } = useLucentPrompt();
const isZh = locale === 'zh'; const isZh = locale === 'zh';
const isAdminMode = mode === 'admin'; const isAdminMode = mode === 'admin';
const isManagementMode = mode === 'management'; 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 [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(() => { const overviewState = usePlatformOverviewState({
return sortBotsByCreatedAtDesc(Object.values(activeBots)) as BotState[]; isAdminMode,
}, [activeBots]); isZh,
notify,
});
const filteredBots = useMemo(() => { const botList = useMemo(
const keyword = search.trim().toLowerCase(); () => sortBotsByCreatedAtDesc(Object.values(activeBots)) as BotState[],
if (!keyword) return botList; [activeBots],
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 pagedBots = useMemo(() => { const management = usePlatformManagementState({
const page = Math.min(Math.max(1, botListPage), botListPageCount); botList,
const start = (page - 1) * botListPageSize; compactMode,
return filteredBots.slice(start, start + botListPageSize); isManagementMode,
}, [filteredBots, botListPage, botListPageCount, botListPageSize]); isZh,
platformPageSize: overviewState.platformPageSize,
const selectedBot = useMemo( });
() => (selectedBotId ? botList.find((bot) => bot.id === selectedBotId) : undefined),
[botList, selectedBotId],
);
const loadBots = useCallback(async () => { const loadBots = useCallback(async () => {
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`); const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
setBots(res.data); setBots(res.data);
}, [setBots]); }, [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 refreshAll = useCallback(async () => {
const jobs: Promise<unknown>[] = [loadOverview(), loadBots()]; const jobs: Promise<unknown>[] = [overviewState.loadOverview(), loadBots()];
if (isAdminMode) { 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); await Promise.allSettled(jobs);
}, [isAdminMode, loadActivityStats, loadBots, loadOverview, loadSelectedBotUsageSummary, loadUsage, selectedBotId]); }, [
isAdminMode,
loadBots,
management,
overviewState,
]);
const toggleBot = useCallback(async (bot: BotState) => { const toggleBot = useCallback(async (bot: BotState) => {
setOperatingBotId(bot.id); setOperatingBotId(bot.id);
@ -329,8 +87,8 @@ export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePl
updateBotStatus(bot.id, 'RUNNING'); updateBotStatus(bot.id, 'RUNNING');
} }
await refreshAll(); await refreshAll();
} catch (error: any) { } catch (error: unknown) {
notify(error?.response?.data?.detail || (isZh ? 'Bot 操作失败。' : 'Bot action failed.'), { tone: 'error' }); notify(getRequestErrorDetail(error) || (isZh ? 'Bot 操作失败。' : 'Bot action failed.'), { tone: 'error' });
} finally { } finally {
setOperatingBotId(''); setOperatingBotId('');
} }
@ -341,8 +99,8 @@ export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePl
try { try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${bot.id}/${enabled ? 'enable' : 'disable'}`); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${bot.id}/${enabled ? 'enable' : 'disable'}`);
await refreshAll(); await refreshAll();
} catch (error: any) { } catch (error: unknown) {
notify(error?.response?.data?.detail || (isZh ? '更新 Bot 状态失败。' : 'Failed to update bot status.'), { tone: 'error' }); notify(getRequestErrorDetail(error) || (isZh ? '更新 Bot 状态失败。' : 'Failed to update bot status.'), { tone: 'error' });
} finally { } finally {
setOperatingBotId(''); setOperatingBotId('');
} }
@ -363,19 +121,19 @@ export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePl
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}`, { await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}`, {
params: { delete_workspace: true }, params: { delete_workspace: true },
}); });
if (selectedBotId === targetId) { if (management.selectedBotId === targetId) {
setSelectedBotId(''); management.setSelectedBotId('');
setSelectedBotDetail(null); management.setSelectedBotDetail(null);
setShowBotLastActionModal(false); management.setShowBotLastActionModal(false);
} }
await refreshAll(); await refreshAll();
notify(isZh ? 'Bot 已删除。' : 'Bot deleted.', { tone: 'success' }); notify(isZh ? 'Bot 已删除。' : 'Bot deleted.', { tone: 'success' });
} catch (error: any) { } catch (error: unknown) {
notify(error?.response?.data?.detail || (isZh ? '删除 Bot 失败。' : 'Failed to delete bot.'), { tone: 'error' }); notify(getRequestErrorDetail(error) || (isZh ? '删除 Bot 失败。' : 'Failed to delete bot.'), { tone: 'error' });
} finally { } finally {
setOperatingBotId(''); setOperatingBotId('');
} }
}, [confirm, isZh, notify, refreshAll, selectedBotId]); }, [confirm, isZh, management, notify, refreshAll]);
const clearDashboardDirectSession = useCallback(async (bot: BotState) => { const clearDashboardDirectSession = useCallback(async (bot: BotState) => {
const targetId = String(bot.id || '').trim(); 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`); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}/sessions/dashboard-direct/clear`);
notify(isZh ? '面板 Session 已清空。' : 'Dashboard session cleared.', { tone: 'success' }); notify(isZh ? '面板 Session 已清空。' : 'Dashboard session cleared.', { tone: 'success' });
await refreshAll(); await refreshAll();
} catch (error: any) { } catch (error: unknown) {
notify(error?.response?.data?.detail || (isZh ? '清空面板 Session 失败。' : 'Failed to clear dashboard session.'), { tone: 'error' }); notify(getRequestErrorDetail(error) || (isZh ? '清空面板 Session 失败。' : 'Failed to clear dashboard session.'), { tone: 'error' });
} finally { } finally {
setOperatingBotId(''); setOperatingBotId('');
} }
}, [confirm, isZh, notify, refreshAll]); }, [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) => { const openBotPanel = useCallback((botId: string) => {
if (!botId || typeof window === 'undefined') return; if (!botId || typeof window === 'undefined') return;
window.open(buildBotPanelHref(botId), '_blank', 'noopener,noreferrer'); window.open(buildBotPanelHref(botId), '_blank', 'noopener,noreferrer');
}, []); }, []);
const closeResourceModal = useCallback(() => setShowResourceModal(false), []);
return { return {
botListPage, botListPage: management.botListPage,
botListPageCount, botListPageCount: management.botListPageCount,
botListPageSize, botListPageSize: overviewState.platformPageSize,
closeCompactBotSheet, closeCompactBotSheet: management.closeCompactBotSheet,
closeResourceModal, closeResourceModal: management.closeResourceModal,
clearDashboardDirectSession, clearDashboardDirectSession,
compactSheetClosing, compactSheetClosing: management.compactSheetClosing,
compactSheetMounted, compactSheetMounted: management.compactSheetMounted,
filteredBots, filteredBots: management.filteredBots,
handleSelectBot, handleSelectBot: management.handleSelectBot,
isZh, isZh,
lastActionPreview, lastActionPreview: management.lastActionPreview,
loadResourceSnapshot, loadResourceSnapshot: management.loadResourceSnapshot,
loading, loading: overviewState.loading,
openBotPanel, openBotPanel,
openResourceMonitor, openResourceMonitor: management.openResourceMonitor,
operatingBotId, operatingBotId,
overview, overview: overviewState.overview,
overviewBots, overviewBots: overviewState.overviewBots,
overviewImages, overviewImages: overviewState.overviewImages,
overviewResources, overviewResources: overviewState.overviewResources,
pagedBots, pagedBots: management.pagedBots,
refreshAll, refreshAll,
removeBot, removeBot,
resourceBot, resourceBot: management.resourceBot,
resourceBotId, resourceBotId: management.resourceBotId,
resourceError, resourceError: management.resourceError,
resourceLoading, resourceLoading: management.resourceLoading,
resourceSnapshot, resourceSnapshot: management.resourceSnapshot,
search, search: management.search,
selectedBotId, selectedBotId: management.selectedBotId,
selectedBotInfo, selectedBotEnabledChannels: management.selectedBotEnabledChannels,
selectedBotUsageSummary, selectedBotInfo: management.selectedBotInfo,
selectedBotMcpCount: management.selectedBotMcpCount,
selectedBotSkillCount: management.selectedBotSkillCount,
selectedBotUsageSummary: management.selectedBotUsageSummary,
setBotEnabled, setBotEnabled,
setBotListPage, setBotListPage: management.setBotListPage,
setSearch, setSearch: management.setSearch,
setShowBotLastActionModal, setShowBotLastActionModal: management.setShowBotLastActionModal,
showBotLastActionModal, showBotLastActionModal: management.showBotLastActionModal,
showResourceModal, showResourceModal: management.showResourceModal,
toggleBot, toggleBot,
usageAnalytics, usageAnalytics: overviewState.usageAnalytics,
activityStats, activityStats: overviewState.activityStats,
activityLoading, activityLoading: overviewState.activityLoading,
usageAnalyticsMax, usageAnalyticsMax: overviewState.usageAnalyticsMax,
usageAnalyticsSeries, usageAnalyticsSeries: overviewState.usageAnalyticsSeries,
usageAnalyticsTicks, usageAnalyticsTicks: overviewState.usageAnalyticsTicks,
usageLoading, usageLoading: overviewState.usageLoading,
usageSummary, usageSummary: overviewState.usageSummary,
}; };
} }

View File

@ -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);
},
};
}

View File

@ -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,
};
}

View File

@ -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>
);
}

View File

@ -1,17 +1,12 @@
import axios from 'axios'; import axios from 'axios';
let initialized = false; 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'; export const BOT_AUTH_INVALID_EVENT = 'nanobot:bot-auth-invalid';
function normalizeBotId(raw: string): string { function normalizeBotId(raw: string): string {
return String(raw || '').trim(); return String(raw || '').trim();
} }
function buildBotAccessStorageKey(botId: string): string {
return `${BOT_STORAGE_KEY_PREFIX}${normalizeBotId(botId)}`;
}
function resolveAbsoluteUrl(input: string): string { function resolveAbsoluteUrl(input: string): string {
const url = String(input || '').trim(); const url = String(input || '').trim();
if (!url) return ''; 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 { function isBotAuthRoute(rawPath: string, botId: string): boolean {
const path = resolveAbsoluteUrl(rawPath); const path = resolveAbsoluteUrl(rawPath);
return path === `/api/bots/${encodeURIComponent(botId)}/auth/login` 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 { export function notifyBotAuthInvalid(botId: string): void {
const normalizedBotId = normalizeBotId(botId); const normalizedBotId = normalizeBotId(botId);
if (!normalizedBotId) return; if (!normalizedBotId) return;
clearBotAccessPassword(normalizedBotId);
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(BOT_AUTH_INVALID_EVENT, { detail: { botId: normalizedBotId } })); window.dispatchEvent(new CustomEvent(BOT_AUTH_INVALID_EVENT, { detail: { botId: normalizedBotId } }));
} }
export function isBotUnauthorizedError(error: any, botId?: string): boolean { interface UnauthorizedErrorShape {
const normalizedBotId = normalizeBotId(botId || extractBotIdFromApiPath(String(error?.config?.url || '')) || ''); 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; 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 { export function buildMonitorWsUrl(base: string, botId: string): string {

View File

@ -1,10 +1,8 @@
import axios from 'axios'; import axios from 'axios';
const PANEL_STORAGE_KEY = 'nanobot-panel-access-password';
export const PANEL_AUTH_INVALID_EVENT = 'nanobot:panel-auth-invalid'; export const PANEL_AUTH_INVALID_EVENT = 'nanobot:panel-auth-invalid';
let initialized = false; let initialized = false;
let memoryPassword = '';
function resolveAbsoluteUrl(input: string): string { function resolveAbsoluteUrl(input: string): string {
const url = String(input || '').trim(); const url = String(input || '').trim();
@ -27,40 +25,12 @@ function isApiRequest(url: string): boolean {
return /^\/api(\/|$)/i.test(path); 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 { function isPanelAuthRoute(url: string): boolean {
const path = resolveAbsoluteUrl(url); const path = resolveAbsoluteUrl(url);
return /^\/api\/panel\/auth(?:\/|$)/i.test(path); return /^\/api\/panel\/auth(?:\/|$)/i.test(path);
} }
export function notifyPanelAuthInvalid(): void { export function notifyPanelAuthInvalid(): void {
clearPanelAccessPassword();
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(PANEL_AUTH_INVALID_EVENT)); window.dispatchEvent(new CustomEvent(PANEL_AUTH_INVALID_EVENT));
} }
@ -68,7 +38,6 @@ export function notifyPanelAuthInvalid(): void {
export function setupPanelAccessAuth(): void { export function setupPanelAccessAuth(): void {
if (initialized) return; if (initialized) return;
initialized = true; initialized = true;
getPanelAccessPassword();
axios.interceptors.response.use(undefined, (error) => { axios.interceptors.response.use(undefined, (error) => {
const status = Number(error?.response?.status || 0); const status = Number(error?.response?.status || 0);