fix git bugs.
parent
f904d97a3d
commit
e743ae7db5
|
|
@ -34,6 +34,8 @@ DATABASE_POOL_TIMEOUT=30
|
||||||
DATABASE_POOL_RECYCLE=1800
|
DATABASE_POOL_RECYCLE=1800
|
||||||
|
|
||||||
# Redis cache (optional)
|
# Redis cache (optional)
|
||||||
|
# REDIS_URL must be reachable from the backend container.
|
||||||
|
# In docker-compose.prod.yml, 127.0.0.1 points to the backend container itself, not the host machine.
|
||||||
REDIS_ENABLED=true
|
REDIS_ENABLED=true
|
||||||
REDIS_URL=redis://127.0.0.1:6379/8
|
REDIS_URL=redis://127.0.0.1:6379/8
|
||||||
REDIS_PREFIX=nanobot
|
REDIS_PREFIX=nanobot
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,8 @@ graph TD
|
||||||
|
|
||||||
- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。
|
- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。
|
||||||
- `deploy-prod.sh` 仅负责前后端容器部署,不会初始化外部数据库;外部 PostgreSQL 需要事先建表并导入初始化数据。
|
- `deploy-prod.sh` 仅负责前后端容器部署,不会初始化外部数据库;外部 PostgreSQL 需要事先建表并导入初始化数据。
|
||||||
|
- 如果启用 Redis,`REDIS_URL` 必须从 `backend` 容器内部可达;在 `docker-compose.prod.yml` 里使用 `127.0.0.1` 只会指向后端容器自己,不是宿主机。
|
||||||
|
- Redis 不可达时,通用缓存健康检查会显示 `degraded`;面板登录认证会自动回退到数据库登录态,不再因为缓存不可达直接报错。
|
||||||
- `UPLOAD_MAX_MB` 仅用于 Nginx 入口限制;后端业务校验值来自 `sys_setting.upload_max_mb`。
|
- `UPLOAD_MAX_MB` 仅用于 Nginx 入口限制;后端业务校验值来自 `sys_setting.upload_max_mb`。
|
||||||
- 必须挂载 `/var/run/docker.sock`,否则后端无法操作 Bot 镜像与容器。
|
- 必须挂载 `/var/run/docker.sock`,否则后端无法操作 Bot 镜像与容器。
|
||||||
- `data/` 始终绑定到宿主机项目根目录下的 `./data`,其中模板、默认 skills、语音模型和运行数据都落在这里。
|
- `data/` 始终绑定到宿主机项目根目录下的 `./data`,其中模板、默认 skills、语音模型和运行数据都落在这里。
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from core.cache import cache
|
from core.cache import auth_cache, cache
|
||||||
from core.database import engine
|
from core.database import engine
|
||||||
from core.settings import DATABASE_ENGINE, REDIS_ENABLED, REDIS_PREFIX, REDIS_URL
|
from core.settings import DATABASE_ENGINE, REDIS_ENABLED, REDIS_PREFIX, REDIS_URL
|
||||||
from models.bot import BotInstance
|
from models.bot import BotInstance
|
||||||
|
|
@ -35,5 +35,12 @@ def get_cache_health():
|
||||||
"enabled": client_enabled,
|
"enabled": client_enabled,
|
||||||
"reachable": reachable,
|
"reachable": reachable,
|
||||||
"prefix": REDIS_PREFIX,
|
"prefix": REDIS_PREFIX,
|
||||||
|
"status": str(getattr(cache, "status", "") or ""),
|
||||||
|
"detail": str(getattr(cache, "status_detail", "") or ""),
|
||||||
|
},
|
||||||
|
"auth_store": {
|
||||||
|
"enabled": bool(getattr(auth_cache, "enabled", False)),
|
||||||
|
"status": str(getattr(auth_cache, "status", "") or ""),
|
||||||
|
"detail": str(getattr(auth_cache, "status_detail", "") or ""),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,11 @@ def reload_platform_runtime(app: FastAPI) -> None:
|
||||||
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:
|
||||||
|
redis_state = "Disabled"
|
||||||
|
if REDIS_ENABLED:
|
||||||
|
redis_state = "Connected" if cache.enabled else f"Unavailable ({cache.status})"
|
||||||
print(
|
print(
|
||||||
f"🚀 Dashboard Backend 启动中... (DB: {DATABASE_URL_DISPLAY}, REDIS: {'Enabled' if REDIS_ENABLED else 'Disabled'})"
|
f"🚀 Dashboard Backend 启动中... (DB: {DATABASE_URL_DISPLAY}, REDIS: {redis_state})"
|
||||||
)
|
)
|
||||||
current_loop = asyncio.get_running_loop()
|
current_loop = asyncio.get_running_loop()
|
||||||
app.state.main_loop = current_loop
|
app.state.main_loop = current_loop
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,31 @@ except Exception: # pragma: no cover
|
||||||
|
|
||||||
class RedisCache:
|
class RedisCache:
|
||||||
def __init__(self, *, prefix_override: Optional[str] = None, default_ttl_override: Optional[int] = None):
|
def __init__(self, *, prefix_override: Optional[str] = None, default_ttl_override: Optional[int] = None):
|
||||||
self.enabled = bool(REDIS_ENABLED and REDIS_URL and Redis is not None)
|
|
||||||
self.prefix = str(prefix_override or REDIS_PREFIX).strip() or REDIS_PREFIX
|
self.prefix = str(prefix_override or REDIS_PREFIX).strip() or REDIS_PREFIX
|
||||||
self.default_ttl = int(default_ttl_override if default_ttl_override is not None else REDIS_DEFAULT_TTL)
|
self.default_ttl = int(default_ttl_override if default_ttl_override is not None else REDIS_DEFAULT_TTL)
|
||||||
|
self.enabled = False
|
||||||
|
self.status = "disabled"
|
||||||
|
self.status_detail = ""
|
||||||
self._client: Optional["Redis"] = None
|
self._client: Optional["Redis"] = None
|
||||||
if self.enabled:
|
if not REDIS_ENABLED:
|
||||||
|
return
|
||||||
|
if not REDIS_URL:
|
||||||
|
self.status = "missing_url"
|
||||||
|
return
|
||||||
|
if Redis is None:
|
||||||
|
self.status = "client_unavailable"
|
||||||
|
self.status_detail = "redis python package is not installed"
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self._client = Redis.from_url(REDIS_URL, decode_responses=True)
|
self._client = Redis.from_url(REDIS_URL, decode_responses=True)
|
||||||
self._client.ping()
|
self._client.ping()
|
||||||
except Exception:
|
self.enabled = True
|
||||||
|
self.status = "connected"
|
||||||
|
except Exception as exc:
|
||||||
self.enabled = False
|
self.enabled = False
|
||||||
self._client = None
|
self._client = None
|
||||||
|
self.status = "connection_failed"
|
||||||
|
self.status_detail = str(exc or "").strip()[:200]
|
||||||
|
|
||||||
def _full_key(self, key: str) -> str:
|
def _full_key(self, key: str) -> str:
|
||||||
return f"{self.prefix}:{key}"
|
return f"{self.prefix}:{key}"
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from services.bot_storage_service import (
|
||||||
normalize_bot_env_params,
|
normalize_bot_env_params,
|
||||||
normalize_bot_resource_limits,
|
normalize_bot_resource_limits,
|
||||||
write_bot_env_params,
|
write_bot_env_params,
|
||||||
|
write_bot_resource_limits,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
@ -114,6 +115,12 @@ def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[st
|
||||||
session.add(bot)
|
session.add(bot)
|
||||||
session.flush()
|
session.flush()
|
||||||
write_bot_env_params(normalized_bot_id, normalized_env_params)
|
write_bot_env_params(normalized_bot_id, normalized_env_params)
|
||||||
|
write_bot_resource_limits(
|
||||||
|
normalized_bot_id,
|
||||||
|
resource_limits["cpu_cores"],
|
||||||
|
resource_limits["memory_mb"],
|
||||||
|
resource_limits["storage_gb"],
|
||||||
|
)
|
||||||
sync_bot_workspace_channels(
|
sync_bot_workspace_channels(
|
||||||
session,
|
session,
|
||||||
normalized_bot_id,
|
normalized_bot_id,
|
||||||
|
|
|
||||||
|
|
@ -176,10 +176,100 @@ def _principal_from_payload(payload: dict[str, Any]) -> tuple[str, str, Optional
|
||||||
return auth_type, subject_id, bot_id
|
return auth_type, subject_id, bot_id
|
||||||
|
|
||||||
|
|
||||||
def _mark_audit_revoked(session: Session, token_hash: str, *, reason: str) -> None:
|
def _find_audit_row_by_token_hash(session: Session, token_hash: str) -> Optional[AuthLoginLog]:
|
||||||
row = session.exec(
|
normalized_hash = str(token_hash or "").strip()
|
||||||
select(AuthLoginLog).where(AuthLoginLog.token_hash == token_hash).limit(1)
|
if not normalized_hash:
|
||||||
|
return None
|
||||||
|
return session.exec(
|
||||||
|
select(AuthLoginLog).where(AuthLoginLog.token_hash == normalized_hash).limit(1)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_cached_token(*, token_hash: str, auth_type: str, subject_id: str, bot_id: Optional[str]) -> None:
|
||||||
|
if not auth_cache.enabled:
|
||||||
|
return
|
||||||
|
auth_cache.delete(_token_key(token_hash))
|
||||||
|
auth_cache.srem(_principal_tokens_key(auth_type, subject_id, bot_id), token_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_token_row(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token_hash: str,
|
||||||
|
expected_type: str,
|
||||||
|
bot_id: Optional[str] = None,
|
||||||
|
) -> Optional[AuthLoginLog]:
|
||||||
|
row = _find_audit_row_by_token_hash(session, token_hash)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
normalized_bot_id = str(bot_id or "").strip() or None
|
||||||
|
if row.auth_type != expected_type:
|
||||||
|
return None
|
||||||
|
if expected_type == "bot" and (str(row.bot_id or "").strip() or None) != normalized_bot_id:
|
||||||
|
return None
|
||||||
|
if row.revoked_at is not None:
|
||||||
|
return None
|
||||||
|
if row.expires_at <= _utcnow():
|
||||||
|
now = _utcnow()
|
||||||
|
row.last_seen_at = now
|
||||||
|
row.revoked_at = now
|
||||||
|
row.revoke_reason = "expired"
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
_purge_cached_token(
|
||||||
|
token_hash=token_hash,
|
||||||
|
auth_type=row.auth_type,
|
||||||
|
subject_id=row.subject_id,
|
||||||
|
bot_id=row.bot_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _list_active_token_rows(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
auth_type: str,
|
||||||
|
subject_id: str,
|
||||||
|
bot_id: Optional[str],
|
||||||
|
) -> list[AuthLoginLog]:
|
||||||
|
statement = select(AuthLoginLog).where(
|
||||||
|
AuthLoginLog.auth_type == auth_type,
|
||||||
|
AuthLoginLog.subject_id == subject_id,
|
||||||
|
AuthLoginLog.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
normalized_bot_id = str(bot_id or "").strip() or None
|
||||||
|
if normalized_bot_id is None:
|
||||||
|
statement = statement.where(AuthLoginLog.bot_id.is_(None))
|
||||||
|
else:
|
||||||
|
statement = statement.where(AuthLoginLog.bot_id == normalized_bot_id)
|
||||||
|
rows = list(session.exec(statement.order_by(AuthLoginLog.created_at.asc(), AuthLoginLog.id.asc())).all())
|
||||||
|
now = _utcnow()
|
||||||
|
expired_rows: list[AuthLoginLog] = []
|
||||||
|
active_rows: list[AuthLoginLog] = []
|
||||||
|
for row in rows:
|
||||||
|
if row.expires_at <= now:
|
||||||
|
row.last_seen_at = now
|
||||||
|
row.revoked_at = now
|
||||||
|
row.revoke_reason = "expired"
|
||||||
|
session.add(row)
|
||||||
|
expired_rows.append(row)
|
||||||
|
continue
|
||||||
|
active_rows.append(row)
|
||||||
|
if expired_rows:
|
||||||
|
session.commit()
|
||||||
|
for row in expired_rows:
|
||||||
|
_purge_cached_token(
|
||||||
|
token_hash=row.token_hash,
|
||||||
|
auth_type=row.auth_type,
|
||||||
|
subject_id=row.subject_id,
|
||||||
|
bot_id=row.bot_id,
|
||||||
|
)
|
||||||
|
return active_rows
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_audit_revoked(session: Session, token_hash: str, *, reason: str) -> None:
|
||||||
|
row = _find_audit_row_by_token_hash(session, token_hash)
|
||||||
if not row:
|
if not row:
|
||||||
return
|
return
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
|
|
@ -228,9 +318,7 @@ def _cleanup_principal_set(session: Session, principal_key: str) -> list[tuple[i
|
||||||
|
|
||||||
|
|
||||||
def _ensure_auth_store_available() -> None:
|
def _ensure_auth_store_available() -> None:
|
||||||
if auth_cache.enabled:
|
|
||||||
return
|
return
|
||||||
raise RuntimeError("Redis authentication store is unavailable")
|
|
||||||
|
|
||||||
|
|
||||||
def _persist_token_payload(
|
def _persist_token_payload(
|
||||||
|
|
@ -240,6 +328,8 @@ def _persist_token_payload(
|
||||||
raw_token: str,
|
raw_token: str,
|
||||||
ttl_seconds: int,
|
ttl_seconds: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if not auth_cache.enabled:
|
||||||
|
return
|
||||||
token_hash = _hash_session_token(raw_token)
|
token_hash = _hash_session_token(raw_token)
|
||||||
payload = {
|
payload = {
|
||||||
"auth_type": row.auth_type,
|
"auth_type": row.auth_type,
|
||||||
|
|
@ -271,8 +361,15 @@ def _enforce_token_limit(
|
||||||
bot_id: Optional[str],
|
bot_id: Optional[str],
|
||||||
max_active: int,
|
max_active: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
principal_key = _principal_tokens_key(auth_type, subject_id, bot_id)
|
rows = [
|
||||||
rows = _cleanup_principal_set(session, principal_key)
|
(int(row.created_at.timestamp()), row.token_hash)
|
||||||
|
for row in _list_active_token_rows(
|
||||||
|
session,
|
||||||
|
auth_type=auth_type,
|
||||||
|
subject_id=subject_id,
|
||||||
|
bot_id=bot_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
overflow = max(0, len(rows) - max_active + 1)
|
overflow = max(0, len(rows) - max_active + 1)
|
||||||
if overflow <= 0:
|
if overflow <= 0:
|
||||||
return
|
return
|
||||||
|
|
@ -417,21 +514,28 @@ def _resolve_token_auth(
|
||||||
normalized_bot_id = str(bot_id or "").strip() or None
|
normalized_bot_id = str(bot_id or "").strip() or None
|
||||||
if not token:
|
if not token:
|
||||||
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
|
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
|
||||||
payload = _active_token_payload(_hash_session_token(token))
|
token_hash = _hash_session_token(token)
|
||||||
|
payload = _active_token_payload(token_hash) if auth_cache.enabled else None
|
||||||
if not payload:
|
if not payload:
|
||||||
|
row = _active_token_row(session, token_hash=token_hash, expected_type=expected_type, bot_id=normalized_bot_id)
|
||||||
|
if row is None:
|
||||||
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
|
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
|
||||||
|
_touch_session(session, row)
|
||||||
|
return AuthPrincipal(expected_type, row.subject_id, row.bot_id, True, f"{expected_type}_token", row.id)
|
||||||
auth_type, subject_id, payload_bot_id = _principal_from_payload(payload)
|
auth_type, subject_id, payload_bot_id = _principal_from_payload(payload)
|
||||||
if auth_type != expected_type:
|
if auth_type != expected_type or (expected_type == "bot" and payload_bot_id != normalized_bot_id):
|
||||||
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
|
row = _active_token_row(session, token_hash=token_hash, expected_type=expected_type, bot_id=normalized_bot_id)
|
||||||
if expected_type == "bot" and payload_bot_id != normalized_bot_id:
|
if row is None:
|
||||||
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
|
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
|
||||||
|
_touch_session(session, row)
|
||||||
|
return AuthPrincipal(expected_type, row.subject_id, row.bot_id, True, f"{expected_type}_token", row.id)
|
||||||
|
|
||||||
expires_at_raw = str(payload.get("expires_at") or "").strip()
|
expires_at_raw = str(payload.get("expires_at") or "").strip()
|
||||||
if expires_at_raw:
|
if expires_at_raw:
|
||||||
try:
|
try:
|
||||||
expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", ""))
|
expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", ""))
|
||||||
if expires_at <= _utcnow():
|
if expires_at <= _utcnow():
|
||||||
_revoke_token_hash(session, _hash_session_token(token), reason="expired")
|
_revoke_token_hash(session, token_hash, reason="expired")
|
||||||
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
|
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -439,7 +543,27 @@ def _resolve_token_auth(
|
||||||
row_id = int(payload.get("audit_id") or 0) or None
|
row_id = int(payload.get("audit_id") or 0) or None
|
||||||
if row_id is not None:
|
if row_id is not None:
|
||||||
row = session.get(AuthLoginLog, row_id)
|
row = session.get(AuthLoginLog, row_id)
|
||||||
if row is not None:
|
if row is None or row.revoked_at is not None:
|
||||||
|
fallback_row = _active_token_row(
|
||||||
|
session,
|
||||||
|
token_hash=token_hash,
|
||||||
|
expected_type=expected_type,
|
||||||
|
bot_id=normalized_bot_id,
|
||||||
|
)
|
||||||
|
if fallback_row is None:
|
||||||
|
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
|
||||||
|
_touch_session(session, fallback_row)
|
||||||
|
return AuthPrincipal(
|
||||||
|
expected_type,
|
||||||
|
fallback_row.subject_id,
|
||||||
|
fallback_row.bot_id,
|
||||||
|
True,
|
||||||
|
f"{expected_type}_token",
|
||||||
|
fallback_row.id,
|
||||||
|
)
|
||||||
|
if row.expires_at <= _utcnow():
|
||||||
|
_revoke_token_hash(session, token_hash, reason="expired")
|
||||||
|
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
|
||||||
_touch_session(session, row)
|
_touch_session(session, row)
|
||||||
return AuthPrincipal(expected_type, subject_id, payload_bot_id, True, f"{expected_type}_token", row_id)
|
return AuthPrincipal(expected_type, subject_id, payload_bot_id, True, f"{expected_type}_token", row_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
import { useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||||
|
|
||||||
interface MarkdownLiteEditorProps {
|
interface MarkdownLiteEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -14,6 +14,18 @@ interface MarkdownLiteEditorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const INDENT = ' ';
|
const INDENT = ' ';
|
||||||
|
const TOOLBAR_ACTIONS = [
|
||||||
|
{ key: 'h1', label: 'H1', title: 'Heading 1' },
|
||||||
|
{ key: 'h2', label: 'H2', title: 'Heading 2' },
|
||||||
|
{ key: 'bold', label: 'B', title: 'Bold' },
|
||||||
|
{ key: 'italic', label: 'I', title: 'Italic' },
|
||||||
|
{ key: 'ul', label: '- List', title: 'Bullet List' },
|
||||||
|
{ key: 'ol', label: '1. List', title: 'Ordered List' },
|
||||||
|
{ key: 'quote', label: '> Quote', title: 'Quote' },
|
||||||
|
{ key: 'code', label: '</>', title: 'Code Block' },
|
||||||
|
{ key: 'link', label: 'Link', title: 'Link' },
|
||||||
|
{ key: 'table', label: 'Table', title: 'Insert Table' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
function joinClassNames(...values: Array<string | false | null | undefined>) {
|
function joinClassNames(...values: Array<string | false | null | undefined>) {
|
||||||
return values.filter(Boolean).join(' ');
|
return values.filter(Boolean).join(' ');
|
||||||
|
|
@ -115,26 +127,43 @@ export function MarkdownLiteEditor({
|
||||||
const tableHeaderStart = 2;
|
const tableHeaderStart = 2;
|
||||||
const tableHeaderEnd = '| Column 1 | Column 2 | Column 3 |'.length - 2;
|
const tableHeaderEnd = '| Column 1 | Column 2 | Column 3 |'.length - 2;
|
||||||
|
|
||||||
const actions = useMemo(
|
const runToolbarAction = (actionKey: (typeof TOOLBAR_ACTIONS)[number]['key']) => {
|
||||||
() => [
|
const textarea = textareaRef.current;
|
||||||
{ key: 'h1', label: 'H1', title: 'Heading 1', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `# ${line}`) },
|
if (!textarea || disabled) return;
|
||||||
{ key: 'h2', label: 'H2', title: 'Heading 2', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `## ${line}`) },
|
|
||||||
{ key: 'bold', label: 'B', title: 'Bold', run: () => replaceSelection(textareaRef.current!, onChange, '**', '**', 'bold text') },
|
switch (actionKey) {
|
||||||
{ key: 'italic', label: 'I', title: 'Italic', run: () => replaceSelection(textareaRef.current!, onChange, '*', '*', 'italic text') },
|
case 'h1':
|
||||||
{ key: 'ul', label: '- List', title: 'Bullet List', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `- ${line}`) },
|
replaceLineBlock(textarea, onChange, (line) => `# ${line}`);
|
||||||
{ key: 'ol', label: '1. List', title: 'Ordered List', run: () => replaceLineBlock(textareaRef.current!, onChange, (line, index) => `${index + 1}. ${line}`) },
|
break;
|
||||||
{ key: 'quote', label: '> Quote', title: 'Quote', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `> ${line}`) },
|
case 'h2':
|
||||||
{ key: 'code', label: '</>', title: 'Code Block', run: () => replaceSelection(textareaRef.current!, onChange, '```\n', '\n```', 'code') },
|
replaceLineBlock(textarea, onChange, (line) => `## ${line}`);
|
||||||
{ key: 'link', label: 'Link', title: 'Link', run: () => replaceSelection(textareaRef.current!, onChange, '[', '](https://)', 'link text') },
|
break;
|
||||||
{
|
case 'bold':
|
||||||
key: 'table',
|
replaceSelection(textarea, onChange, '**', '**', 'bold text');
|
||||||
label: 'Table',
|
break;
|
||||||
title: 'Insert Table',
|
case 'italic':
|
||||||
run: () => insertBlockSnippet(textareaRef.current!, onChange, tableSnippet, tableHeaderStart, tableHeaderEnd),
|
replaceSelection(textarea, onChange, '*', '*', 'italic text');
|
||||||
},
|
break;
|
||||||
],
|
case 'ul':
|
||||||
[onChange, tableHeaderEnd, tableHeaderStart, tableSnippet],
|
replaceLineBlock(textarea, onChange, (line) => `- ${line}`);
|
||||||
);
|
break;
|
||||||
|
case 'ol':
|
||||||
|
replaceLineBlock(textarea, onChange, (line, index) => `${index + 1}. ${line}`);
|
||||||
|
break;
|
||||||
|
case 'quote':
|
||||||
|
replaceLineBlock(textarea, onChange, (line) => `> ${line}`);
|
||||||
|
break;
|
||||||
|
case 'code':
|
||||||
|
replaceSelection(textarea, onChange, '```\n', '\n```', 'code');
|
||||||
|
break;
|
||||||
|
case 'link':
|
||||||
|
replaceSelection(textarea, onChange, '[', '](https://)', 'link text');
|
||||||
|
break;
|
||||||
|
case 'table':
|
||||||
|
insertBlockSnippet(textarea, onChange, tableSnippet, tableHeaderStart, tableHeaderEnd);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
|
|
@ -155,17 +184,14 @@ export function MarkdownLiteEditor({
|
||||||
return (
|
return (
|
||||||
<div className={joinClassNames('md-lite-editor', fullHeight && 'is-full-height', className)}>
|
<div className={joinClassNames('md-lite-editor', fullHeight && 'is-full-height', className)}>
|
||||||
<div className="md-lite-toolbar" role="toolbar" aria-label="Markdown editor toolbar">
|
<div className="md-lite-toolbar" role="toolbar" aria-label="Markdown editor toolbar">
|
||||||
{actions.map((action) => (
|
{TOOLBAR_ACTIONS.map((action) => (
|
||||||
<button
|
<button
|
||||||
key={action.key}
|
key={action.key}
|
||||||
className="md-lite-toolbtn"
|
className="md-lite-toolbtn"
|
||||||
type="button"
|
type="button"
|
||||||
title={action.title}
|
title={action.title}
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => runToolbarAction(action.key)}
|
||||||
if (disabled || !textareaRef.current) return;
|
|
||||||
action.run();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,37 @@ import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../.
|
||||||
import type { QuotedReply, StagedSubmissionDraft } from '../types';
|
import type { QuotedReply, StagedSubmissionDraft } from '../types';
|
||||||
import type { DashboardChatNotifyOptions } from './dashboardChatShared';
|
import type { DashboardChatNotifyOptions } from './dashboardChatShared';
|
||||||
|
|
||||||
|
interface ChatCommandDispatchLabels {
|
||||||
|
attachmentMessage: string;
|
||||||
|
quoteOnlyMessage: string;
|
||||||
|
backendDeliverFail: string;
|
||||||
|
sendFail: string;
|
||||||
|
sendFailMsg: (message: string) => string;
|
||||||
|
controlCommandSent: (command: '/new' | '/restart') => string;
|
||||||
|
interruptSent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandResponse {
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiErrorDetail {
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (axios.isAxiosError<ApiErrorDetail>(error)) {
|
||||||
|
const detail = String(error.response?.data?.detail || '').trim();
|
||||||
|
if (detail) return detail;
|
||||||
|
const message = String(error.message || '').trim();
|
||||||
|
if (message) return message;
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
const message = String(error.message || '').trim();
|
||||||
|
if (message) return message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseDashboardChatCommandDispatchOptions {
|
interface UseDashboardChatCommandDispatchOptions {
|
||||||
selectedBot?: { id: string } | null;
|
selectedBot?: { id: string } | null;
|
||||||
canChat: boolean;
|
canChat: boolean;
|
||||||
|
|
@ -24,7 +55,7 @@ interface UseDashboardChatCommandDispatchOptions {
|
||||||
scrollConversationToBottom: (behavior?: ScrollBehavior) => void;
|
scrollConversationToBottom: (behavior?: ScrollBehavior) => void;
|
||||||
completeLeadingStagedSubmission: (stagedSubmissionId: string) => void;
|
completeLeadingStagedSubmission: (stagedSubmissionId: string) => void;
|
||||||
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
|
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
|
||||||
t: any;
|
t: ChatCommandDispatchLabels;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDashboardChatCommandDispatch({
|
export function useDashboardChatCommandDispatch({
|
||||||
|
|
@ -110,7 +141,7 @@ export function useDashboardChatCommandDispatch({
|
||||||
...prev,
|
...prev,
|
||||||
[selectedBot.id]: Date.now() + (commandAutoUnlockSeconds * 1000),
|
[selectedBot.id]: Date.now() + (commandAutoUnlockSeconds * 1000),
|
||||||
}));
|
}));
|
||||||
const res = await axios.post(
|
const res = await axios.post<CommandResponse>(
|
||||||
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
|
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
|
||||||
{ command: payloadText, attachments },
|
{ command: payloadText, attachments },
|
||||||
{ timeout: 12000 },
|
{ timeout: 12000 },
|
||||||
|
|
@ -135,8 +166,8 @@ export function useDashboardChatCommandDispatch({
|
||||||
completeLeadingStagedSubmission(clearStagedSubmissionId);
|
completeLeadingStagedSubmission(clearStagedSubmissionId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
|
const msg = resolveApiErrorMessage(error, t.sendFail);
|
||||||
setCommandAutoUnlockDeadlineByBot((prev) => {
|
setCommandAutoUnlockDeadlineByBot((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[selectedBot.id];
|
delete next[selectedBot.id];
|
||||||
|
|
@ -200,7 +231,7 @@ export function useDashboardChatCommandDispatch({
|
||||||
if (!selectedBot || !canChat || activeControlCommand) return;
|
if (!selectedBot || !canChat || activeControlCommand) return;
|
||||||
try {
|
try {
|
||||||
setControlCommandByBot((prev) => ({ ...prev, [selectedBot.id]: slashCommand }));
|
setControlCommandByBot((prev) => ({ ...prev, [selectedBot.id]: slashCommand }));
|
||||||
const res = await axios.post(
|
const res = await axios.post<CommandResponse>(
|
||||||
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
|
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
|
||||||
{ command: slashCommand },
|
{ command: slashCommand },
|
||||||
{ timeout: 12000 },
|
{ timeout: 12000 },
|
||||||
|
|
@ -216,8 +247,8 @@ export function useDashboardChatCommandDispatch({
|
||||||
setChatDatePickerOpen(false);
|
setChatDatePickerOpen(false);
|
||||||
setControlCommandPanelOpen(false);
|
setControlCommandPanelOpen(false);
|
||||||
notify(t.controlCommandSent(slashCommand), { tone: 'success' });
|
notify(t.controlCommandSent(slashCommand), { tone: 'success' });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
|
const msg = resolveApiErrorMessage(error, t.sendFail);
|
||||||
notify(msg, { tone: 'error' });
|
notify(msg, { tone: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setControlCommandByBot((prev) => {
|
setControlCommandByBot((prev) => {
|
||||||
|
|
@ -243,7 +274,7 @@ export function useDashboardChatCommandDispatch({
|
||||||
if (!selectedBot || !canChat || isInterrupting) return;
|
if (!selectedBot || !canChat || isInterrupting) return;
|
||||||
try {
|
try {
|
||||||
setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
|
setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
|
||||||
const res = await axios.post(
|
const res = await axios.post<CommandResponse>(
|
||||||
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
|
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
|
||||||
{ command: '/stop' },
|
{ command: '/stop' },
|
||||||
{ timeout: 12000 },
|
{ timeout: 12000 },
|
||||||
|
|
@ -254,8 +285,8 @@ export function useDashboardChatCommandDispatch({
|
||||||
setChatDatePickerOpen(false);
|
setChatDatePickerOpen(false);
|
||||||
setControlCommandPanelOpen(false);
|
setControlCommandPanelOpen(false);
|
||||||
notify(t.interruptSent, { tone: 'success' });
|
notify(t.interruptSent, { tone: 'success' });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
|
const msg = resolveApiErrorMessage(error, t.sendFail);
|
||||||
notify(msg, { tone: 'error' });
|
notify(msg, { tone: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setInterruptingByBot((prev) => {
|
setInterruptingByBot((prev) => {
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,31 @@
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { parseBotTimestamp } from '../../../shared/bot/sortBots';
|
import { parseBotTimestamp } from '../../../shared/bot/sortBots';
|
||||||
|
import type { BotEvent, BotState, ChatMessage } from '../../../types/bot';
|
||||||
import { getSystemTimezoneOptions } from '../../../utils/systemTimezones';
|
import { getSystemTimezoneOptions } from '../../../utils/systemTimezones';
|
||||||
import { mergeConversation } from '../chat/chatUtils';
|
import { mergeConversation } from '../chat/chatUtils';
|
||||||
import { RUNTIME_STALE_MS } from '../constants';
|
import { RUNTIME_STALE_MS } from '../constants';
|
||||||
import { normalizeAssistantMessageText } from '../../../shared/text/messageText';
|
import { normalizeAssistantMessageText } from '../../../shared/text/messageText';
|
||||||
import type { BaseImageOption, NanobotImage } from '../types';
|
import type { BaseImageOption, BotTopic, NanobotImage } from '../types';
|
||||||
import type { TopicFeedOption } from '../topic/TopicFeedPanel';
|
import type { TopicFeedOption } from '../topic/TopicFeedPanel';
|
||||||
import { normalizeRuntimeState } from '../utils';
|
import { normalizeRuntimeState } from '../utils';
|
||||||
|
|
||||||
|
const RECENT_RUNTIME_EVENT_MS = 15000;
|
||||||
|
const RUNTIME_STATUS_TICK_MS = 1000;
|
||||||
|
const TRANSIENT_RUNTIME_STATES = new Set(['THINKING', 'TOOL_CALL', 'ERROR']);
|
||||||
|
|
||||||
interface UseDashboardBaseStateOptions {
|
interface UseDashboardBaseStateOptions {
|
||||||
availableImages: NanobotImage[];
|
availableImages: NanobotImage[];
|
||||||
controlStateByBot: Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>;
|
controlStateByBot: Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>;
|
||||||
defaultSystemTimezone: string;
|
defaultSystemTimezone: string;
|
||||||
editFormImageTag: string;
|
editFormImageTag: string;
|
||||||
editFormSystemTimezone: string;
|
editFormSystemTimezone: string;
|
||||||
events: any[];
|
events: BotEvent[];
|
||||||
isZh: boolean;
|
isZh: boolean;
|
||||||
messages: any[];
|
messages: ChatMessage[];
|
||||||
selectedBot?: any;
|
selectedBot?: BotState;
|
||||||
topicFeedUnreadCount: number;
|
topicFeedUnreadCount: number;
|
||||||
topics: any[];
|
topics: BotTopic[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseDashboardInteractionStateOptions {
|
interface UseDashboardInteractionStateOptions {
|
||||||
|
|
@ -28,7 +33,7 @@ interface UseDashboardInteractionStateOptions {
|
||||||
isSendingBlocked?: boolean;
|
isSendingBlocked?: boolean;
|
||||||
isVoiceRecording?: boolean;
|
isVoiceRecording?: boolean;
|
||||||
isVoiceTranscribing?: boolean;
|
isVoiceTranscribing?: boolean;
|
||||||
selectedBot?: any;
|
selectedBot?: BotState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDashboardBaseState({
|
export function useDashboardBaseState({
|
||||||
|
|
@ -100,7 +105,8 @@ export function useDashboardBaseState({
|
||||||
selectedBot.docker_status === 'RUNNING' &&
|
selectedBot.docker_status === 'RUNNING' &&
|
||||||
!selectedBotControlState,
|
!selectedBotControlState,
|
||||||
);
|
);
|
||||||
const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
|
const [nowTs, setNowTs] = useState(() => Date.now());
|
||||||
|
const latestEvent = useMemo(() => events[events.length - 1], [events]);
|
||||||
const systemTimezoneOptions = useMemo(
|
const systemTimezoneOptions = useMemo(
|
||||||
() => getSystemTimezoneOptions(editFormSystemTimezone || defaultSystemTimezone),
|
() => getSystemTimezoneOptions(editFormSystemTimezone || defaultSystemTimezone),
|
||||||
[defaultSystemTimezone, editFormSystemTimezone],
|
[defaultSystemTimezone, editFormSystemTimezone],
|
||||||
|
|
@ -124,9 +130,41 @@ export function useDashboardBaseState({
|
||||||
const latestEventTs = latestEvent?.ts || 0;
|
const latestEventTs = latestEvent?.ts || 0;
|
||||||
return Math.max(latestEventTs, botUpdatedAtTs, lastUserTs);
|
return Math.max(latestEventTs, botUpdatedAtTs, lastUserTs);
|
||||||
}, [botUpdatedAtTs, lastUserTs, latestEvent?.ts]);
|
}, [botUpdatedAtTs, lastUserTs, latestEvent?.ts]);
|
||||||
|
const latestEventState = useMemo(
|
||||||
|
() => normalizeRuntimeState(latestEvent?.state),
|
||||||
|
[latestEvent?.state],
|
||||||
|
);
|
||||||
|
const shouldTrackRuntimeStatus = Boolean(
|
||||||
|
selectedBot &&
|
||||||
|
selectedBot.docker_status === 'RUNNING' &&
|
||||||
|
(
|
||||||
|
latestRuntimeSignalTs > 0 ||
|
||||||
|
(latestEvent?.ts && TRANSIENT_RUNTIME_STATES.has(latestEventState))
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldTrackRuntimeStatus || typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const refreshNow = () => setNowTs(Date.now());
|
||||||
|
const frameId = window.requestAnimationFrame(refreshNow);
|
||||||
|
const timerId = window.setInterval(refreshNow, RUNTIME_STATUS_TICK_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
window.clearInterval(timerId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
latestEvent?.ts,
|
||||||
|
latestEventState,
|
||||||
|
latestRuntimeSignalTs,
|
||||||
|
selectedBot?.id,
|
||||||
|
shouldTrackRuntimeStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
const hasFreshRuntimeSignal = useMemo(
|
const hasFreshRuntimeSignal = useMemo(
|
||||||
() => latestRuntimeSignalTs > 0 && Date.now() - latestRuntimeSignalTs < RUNTIME_STALE_MS,
|
() => latestRuntimeSignalTs > 0 && nowTs - latestRuntimeSignalTs < RUNTIME_STALE_MS,
|
||||||
[latestRuntimeSignalTs],
|
[latestRuntimeSignalTs, nowTs],
|
||||||
);
|
);
|
||||||
const isThinking = useMemo(() => {
|
const isThinking = useMemo(() => {
|
||||||
if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false;
|
if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false;
|
||||||
|
|
@ -147,21 +185,21 @@ export function useDashboardBaseState({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isThinking) {
|
if (isThinking) {
|
||||||
if (latestEvent?.state === 'TOOL_CALL') return 'TOOL_CALL';
|
if (latestEventState === 'TOOL_CALL') return 'TOOL_CALL';
|
||||||
return 'THINKING';
|
return 'THINKING';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
latestEvent &&
|
latestEvent?.ts &&
|
||||||
['THINKING', 'TOOL_CALL', 'ERROR'].includes(latestEvent.state) &&
|
TRANSIENT_RUNTIME_STATES.has(latestEventState) &&
|
||||||
Date.now() - latestEvent.ts < 15000
|
nowTs - latestEvent.ts < RECENT_RUNTIME_EVENT_MS
|
||||||
) {
|
) {
|
||||||
return latestEvent.state;
|
return latestEventState;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (latestEvent?.state === 'ERROR') return 'ERROR';
|
if (latestEventState === 'ERROR') return 'ERROR';
|
||||||
return 'IDLE';
|
return 'IDLE';
|
||||||
}, [hasFreshRuntimeSignal, isThinking, latestEvent, selectedBot]);
|
}, [hasFreshRuntimeSignal, isThinking, latestEvent, latestEventState, nowTs, selectedBot]);
|
||||||
const runtimeAction = useMemo(() => {
|
const runtimeAction = useMemo(() => {
|
||||||
const action = normalizeAssistantMessageText(selectedBot?.last_action || '').trim();
|
const action = normalizeAssistantMessageText(selectedBot?.last_action || '').trim();
|
||||||
if (action) return action;
|
if (action) return action;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
import { APP_ENDPOINTS } from '../../../config/env';
|
import { APP_ENDPOINTS } from '../../../config/env';
|
||||||
|
import { cleanBotLogLine } from '../../../shared/text/messageText';
|
||||||
import type { BotState } from '../../../types/bot';
|
import type { BotState } from '../../../types/bot';
|
||||||
|
|
||||||
const ANSI_ESCAPE_RE = /(?:\u001b\[|\[)[0-9;]{1,12}m/g;
|
|
||||||
const EMPTY_DOCKER_LOG_ENTRY = {
|
const EMPTY_DOCKER_LOG_ENTRY = {
|
||||||
key: '',
|
key: '',
|
||||||
index: '',
|
index: '',
|
||||||
|
|
@ -13,12 +13,8 @@ const EMPTY_DOCKER_LOG_ENTRY = {
|
||||||
tone: 'plain',
|
tone: 'plain',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function stripAnsi(textRaw: string) {
|
|
||||||
return String(textRaw || '').replace(ANSI_ESCAPE_RE, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDockerLogEntry(textRaw: string) {
|
function parseDockerLogEntry(textRaw: string) {
|
||||||
const text = stripAnsi(textRaw);
|
const text = cleanBotLogLine(textRaw);
|
||||||
const levelMatch = text.match(/\b(INFO|ERROR|WARN|WARNING|DEBUG|TRACE|CRITICAL|FATAL)\b/i);
|
const levelMatch = text.match(/\b(INFO|ERROR|WARN|WARNING|DEBUG|TRACE|CRITICAL|FATAL)\b/i);
|
||||||
const levelRaw = String(levelMatch?.[1] || '').toUpperCase();
|
const levelRaw = String(levelMatch?.[1] || '').toUpperCase();
|
||||||
const level = levelRaw === 'WARNING' ? 'WARN' : (levelRaw || '-');
|
const level = levelRaw === 'WARNING' ? 'WARN' : (levelRaw || '-');
|
||||||
|
|
@ -44,16 +40,34 @@ interface UsePlatformBotDockerLogsOptions {
|
||||||
selectedBotInfo?: BotState;
|
selectedBotInfo?: BotState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DockerLogsResponse {
|
||||||
|
bot_id: string;
|
||||||
|
logs?: string[];
|
||||||
|
total?: number | null;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
has_more?: boolean;
|
||||||
|
reverse?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiErrorDetail {
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function usePlatformBotDockerLogs({
|
export function usePlatformBotDockerLogs({
|
||||||
effectivePageSize,
|
effectivePageSize,
|
||||||
isZh,
|
isZh,
|
||||||
selectedBotInfo,
|
selectedBotInfo,
|
||||||
}: UsePlatformBotDockerLogsOptions) {
|
}: UsePlatformBotDockerLogsOptions) {
|
||||||
|
const selectedBotId = String(selectedBotInfo?.id || '').trim();
|
||||||
const [dockerLogs, setDockerLogs] = useState<string[]>([]);
|
const [dockerLogs, setDockerLogs] = useState<string[]>([]);
|
||||||
const [dockerLogsLoading, setDockerLogsLoading] = useState(false);
|
const [dockerLogsLoading, setDockerLogsLoading] = useState(false);
|
||||||
const [dockerLogsError, setDockerLogsError] = useState('');
|
const [dockerLogsError, setDockerLogsError] = useState('');
|
||||||
const [dockerLogsPage, setDockerLogsPage] = useState(1);
|
const [dockerLogsPage, setDockerLogsPage] = useState(1);
|
||||||
const [dockerLogsHasMore, setDockerLogsHasMore] = useState(false);
|
const [dockerLogsHasMore, setDockerLogsHasMore] = useState(false);
|
||||||
|
const requestSeqRef = useRef(0);
|
||||||
|
const activeBotIdRef = useRef(selectedBotId);
|
||||||
|
const logsScopeKeyRef = useRef(`${selectedBotId}:${effectivePageSize}`);
|
||||||
|
|
||||||
const recentLogEntries = useMemo(() => {
|
const recentLogEntries = useMemo(() => {
|
||||||
const logs = (dockerLogs || [])
|
const logs = (dockerLogs || [])
|
||||||
|
|
@ -88,67 +102,95 @@ export function usePlatformBotDockerLogs({
|
||||||
[dockerLogsPage, effectivePageSize, recentLogEntries],
|
[dockerLogsPage, effectivePageSize, recentLogEntries],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchDockerLogsPage = useCallback(async (page: number, silent: boolean = false) => {
|
const resetDockerLogsState = useCallback(() => {
|
||||||
if (!selectedBotInfo?.id) {
|
requestSeqRef.current += 1;
|
||||||
setDockerLogs([]);
|
setDockerLogs([]);
|
||||||
setDockerLogsHasMore(false);
|
setDockerLogsHasMore(false);
|
||||||
setDockerLogsError('');
|
setDockerLogsError('');
|
||||||
setDockerLogsLoading(false);
|
setDockerLogsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeBotIdRef.current = selectedBotId;
|
||||||
|
}, [selectedBotId]);
|
||||||
|
|
||||||
|
const fetchDockerLogsPage = useCallback(async (
|
||||||
|
page: number,
|
||||||
|
silent: boolean = false,
|
||||||
|
botIdOverride?: string,
|
||||||
|
) => {
|
||||||
|
const botId = String(botIdOverride || selectedBotId).trim();
|
||||||
|
if (!botId) {
|
||||||
|
resetDockerLogsState();
|
||||||
|
setDockerLogsPage(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const safePage = Math.max(1, page);
|
const safePage = Math.max(1, page);
|
||||||
|
const requestSeq = ++requestSeqRef.current;
|
||||||
if (!silent) setDockerLogsLoading(true);
|
if (!silent) setDockerLogsLoading(true);
|
||||||
setDockerLogsError('');
|
setDockerLogsError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<{
|
const res = await axios.get<DockerLogsResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/logs`, {
|
||||||
bot_id: string;
|
|
||||||
logs?: string[];
|
|
||||||
total?: number | null;
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
has_more?: boolean;
|
|
||||||
reverse?: boolean;
|
|
||||||
}>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotInfo.id}/logs`, {
|
|
||||||
params: {
|
params: {
|
||||||
offset: (safePage - 1) * effectivePageSize,
|
offset: (safePage - 1) * effectivePageSize,
|
||||||
limit: effectivePageSize,
|
limit: effectivePageSize,
|
||||||
reverse: true,
|
reverse: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (requestSeq !== requestSeqRef.current || activeBotIdRef.current !== botId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const lines = Array.isArray(res.data?.logs)
|
const lines = Array.isArray(res.data?.logs)
|
||||||
? res.data.logs.map((line) => String(line || '').trim()).filter(Boolean)
|
? res.data.logs.map((line) => String(line || '').trim()).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
setDockerLogs(lines);
|
setDockerLogs(lines);
|
||||||
setDockerLogsHasMore(Boolean(res.data?.has_more));
|
setDockerLogsHasMore(Boolean(res.data?.has_more));
|
||||||
setDockerLogsPage(safePage);
|
setDockerLogsPage(safePage);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
if (requestSeq !== requestSeqRef.current || activeBotIdRef.current !== botId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = axios.isAxiosError<ApiErrorDetail>(error)
|
||||||
|
? String(error.response?.data?.detail || '').trim()
|
||||||
|
: '';
|
||||||
setDockerLogs([]);
|
setDockerLogs([]);
|
||||||
setDockerLogsHasMore(false);
|
setDockerLogsHasMore(false);
|
||||||
setDockerLogsError(error?.response?.data?.detail || (isZh ? '读取 Docker 日志失败。' : 'Failed to load Docker logs.'));
|
setDockerLogsError(detail || (isZh ? '读取 Docker 日志失败。' : 'Failed to load Docker logs.'));
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) {
|
if (!silent && requestSeq === requestSeqRef.current && activeBotIdRef.current === botId) {
|
||||||
setDockerLogsLoading(false);
|
setDockerLogsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [effectivePageSize, isZh, selectedBotInfo?.id]);
|
}, [effectivePageSize, isZh, resetDockerLogsState, selectedBotId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const nextLogsScopeKey = `${selectedBotId}:${effectivePageSize}`;
|
||||||
|
if (!selectedBotId) {
|
||||||
|
logsScopeKeyRef.current = nextLogsScopeKey;
|
||||||
|
resetDockerLogsState();
|
||||||
setDockerLogsPage(1);
|
setDockerLogsPage(1);
|
||||||
}, [selectedBotInfo?.id, effectivePageSize]);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
if (logsScopeKeyRef.current !== nextLogsScopeKey) {
|
||||||
if (!selectedBotInfo?.id) {
|
logsScopeKeyRef.current = nextLogsScopeKey;
|
||||||
|
requestSeqRef.current += 1;
|
||||||
setDockerLogs([]);
|
setDockerLogs([]);
|
||||||
setDockerLogsHasMore(false);
|
setDockerLogsHasMore(false);
|
||||||
setDockerLogsError('');
|
setDockerLogsError('');
|
||||||
setDockerLogsLoading(false);
|
setDockerLogsPage(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
void fetchDockerLogsPage(dockerLogsPage, false);
|
void fetchDockerLogsPage(dockerLogsPage, false, selectedBotId);
|
||||||
|
|
||||||
if (dockerLogsPage !== 1 || String(selectedBotInfo.docker_status || '').toUpperCase() !== 'RUNNING') {
|
if (dockerLogsPage !== 1 || String(selectedBotInfo?.docker_status || '').toUpperCase() !== 'RUNNING') {
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
};
|
};
|
||||||
|
|
@ -156,7 +198,7 @@ export function usePlatformBotDockerLogs({
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
if (!stopped) {
|
if (!stopped) {
|
||||||
void fetchDockerLogsPage(1, true);
|
void fetchDockerLogsPage(1, true, selectedBotId);
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
|
|
@ -164,7 +206,7 @@ export function usePlatformBotDockerLogs({
|
||||||
stopped = true;
|
stopped = true;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [dockerLogsPage, fetchDockerLogsPage, selectedBotInfo?.docker_status, selectedBotInfo?.id]);
|
}, [dockerLogsPage, effectivePageSize, fetchDockerLogsPage, resetDockerLogsState, selectedBotId, selectedBotInfo?.docker_status]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dockerLogsError,
|
dockerLogsError,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
const ESC = String.fromCharCode(0x1b);
|
||||||
const OSC_RE = /\x1b\][^\u0007]*(\u0007|\x1b\\)/g;
|
const BEL = String.fromCharCode(0x07);
|
||||||
const NON_TEXT_RE = /[^\u0009\u0020-\u007E\u4E00-\u9FFF。,!?:;、“”‘’()《》【】—…·\-_./:\\,%+*='"`|<>]/g;
|
const CONTROL_RE_SOURCE = [
|
||||||
const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
`[${String.fromCharCode(0x00)}-${String.fromCharCode(0x08)}`,
|
||||||
|
`${String.fromCharCode(0x0b)}${String.fromCharCode(0x0c)}`,
|
||||||
|
`${String.fromCharCode(0x0e)}-${String.fromCharCode(0x1f)}`,
|
||||||
|
`${String.fromCharCode(0x7f)}-${String.fromCharCode(0x9f)}]`,
|
||||||
|
].join('');
|
||||||
|
const ANSI_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
|
||||||
|
const OSC_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g');
|
||||||
|
const CONTROL_RE = new RegExp(CONTROL_RE_SOURCE, 'g');
|
||||||
const ATTACHMENT_BLOCK_RE = /\[Attached Files\][\s\S]*?\[\/Attached Files\]/gi;
|
const ATTACHMENT_BLOCK_RE = /\[Attached Files\][\s\S]*?\[\/Attached Files\]/gi;
|
||||||
const QUOTED_REPLY_BLOCK_RE = /\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]/gi;
|
const QUOTED_REPLY_BLOCK_RE = /\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]/gi;
|
||||||
|
const SUMMARY_MARKDOWN_CHARS_RE = /[[\]`*_>#|()]/g;
|
||||||
|
|
||||||
export function normalizeUserMessageText(input: string) {
|
export function normalizeUserMessageText(input: string) {
|
||||||
let text = (input || '').replace(/\r\n/g, '\n').trim();
|
let text = (input || '').replace(/\r\n/g, '\n').trim();
|
||||||
|
|
@ -76,7 +84,7 @@ export function summarizeProgressText(input: string, isZh: boolean) {
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.find((value) => value.length > 0);
|
.find((value) => value.length > 0);
|
||||||
const line = (firstLine || raw)
|
const line = (firstLine || raw)
|
||||||
.replace(/[`*_>#|\[\]\(\)]/g, ' ')
|
.replace(SUMMARY_MARKDOWN_CHARS_RE, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
if (!line) return isZh ? '处理中...' : 'Processing...';
|
if (!line) return isZh ? '处理中...' : 'Processing...';
|
||||||
|
|
@ -89,7 +97,7 @@ export function cleanBotLogLine(line: string) {
|
||||||
.replace(ANSI_RE, '')
|
.replace(ANSI_RE, '')
|
||||||
.replace(/\[(\?|\d|;)+[A-Za-z]/g, '')
|
.replace(/\[(\?|\d|;)+[A-Za-z]/g, '')
|
||||||
.replace(/\[(\d+)?K/g, '')
|
.replace(/\[(\d+)?K/g, '')
|
||||||
.replace(NON_TEXT_RE, ' ')
|
.replace(CONTROL_RE, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue