From e743ae7db5fdea55482bdc0ca2b263b5ecd82a58 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Mon, 13 Apr 2026 20:07:07 +0800 Subject: [PATCH] fix git bugs. --- .env.prod.example | 2 + README.md | 2 + backend/api/health_router.py | 9 +- backend/bootstrap/app_runtime.py | 5 +- backend/core/cache.py | 30 +++- backend/services/bot_management_service.py | 7 + backend/services/platform_auth_service.py | 158 ++++++++++++++++-- .../markdown/MarkdownLiteEditor.tsx | 78 ++++++--- .../hooks/useDashboardChatCommandDispatch.ts | 51 ++++-- .../hooks/useDashboardDerivedState.ts | 72 ++++++-- .../hooks/usePlatformBotDockerLogs.ts | 112 +++++++++---- frontend/src/shared/text/messageText.ts | 20 ++- 12 files changed, 425 insertions(+), 121 deletions(-) diff --git a/.env.prod.example b/.env.prod.example index de7e60c..8d02d20 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -34,6 +34,8 @@ DATABASE_POOL_TIMEOUT=30 DATABASE_POOL_RECYCLE=1800 # 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_URL=redis://127.0.0.1:6379/8 REDIS_PREFIX=nanobot diff --git a/README.md b/README.md index 4d1f2c1..58b7934 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ graph TD - `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。 - `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`。 - 必须挂载 `/var/run/docker.sock`,否则后端无法操作 Bot 镜像与容器。 - `data/` 始终绑定到宿主机项目根目录下的 `./data`,其中模板、默认 skills、语音模型和运行数据都落在这里。 diff --git a/backend/api/health_router.py b/backend/api/health_router.py index be3622c..138dee2 100644 --- a/backend/api/health_router.py +++ b/backend/api/health_router.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, HTTPException from sqlmodel import Session, select -from core.cache import cache +from core.cache import auth_cache, cache from core.database import engine from core.settings import DATABASE_ENGINE, REDIS_ENABLED, REDIS_PREFIX, REDIS_URL from models.bot import BotInstance @@ -35,5 +35,12 @@ def get_cache_health(): "enabled": client_enabled, "reachable": reachable, "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 ""), }, } diff --git a/backend/bootstrap/app_runtime.py b/backend/bootstrap/app_runtime.py index bd7c4b1..9dba740 100644 --- a/backend/bootstrap/app_runtime.py +++ b/backend/bootstrap/app_runtime.py @@ -24,8 +24,11 @@ def reload_platform_runtime(app: FastAPI) -> None: def register_app_runtime(app: FastAPI) -> None: @app.on_event("startup") async def _on_startup() -> None: + redis_state = "Disabled" + if REDIS_ENABLED: + redis_state = "Connected" if cache.enabled else f"Unavailable ({cache.status})" 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() app.state.main_loop = current_loop diff --git a/backend/core/cache.py b/backend/core/cache.py index d36f075..6c4bcfa 100644 --- a/backend/core/cache.py +++ b/backend/core/cache.py @@ -13,17 +13,31 @@ except Exception: # pragma: no cover class RedisCache: 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.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 - if self.enabled: - try: - self._client = Redis.from_url(REDIS_URL, decode_responses=True) - self._client.ping() - except Exception: - self.enabled = False - self._client = None + 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: + self._client = Redis.from_url(REDIS_URL, decode_responses=True) + self._client.ping() + self.enabled = True + self.status = "connected" + except Exception as exc: + self.enabled = False + self._client = None + self.status = "connection_failed" + self.status_detail = str(exc or "").strip()[:200] def _full_key(self, key: str) -> str: return f"{self.prefix}:{key}" diff --git a/backend/services/bot_management_service.py b/backend/services/bot_management_service.py index 8273025..9d40c01 100644 --- a/backend/services/bot_management_service.py +++ b/backend/services/bot_management_service.py @@ -23,6 +23,7 @@ from services.bot_storage_service import ( normalize_bot_env_params, normalize_bot_resource_limits, 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.platform_service import record_activity_event @@ -114,6 +115,12 @@ def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[st session.add(bot) session.flush() 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( session, normalized_bot_id, diff --git a/backend/services/platform_auth_service.py b/backend/services/platform_auth_service.py index 90699f5..e9e32ee 100644 --- a/backend/services/platform_auth_service.py +++ b/backend/services/platform_auth_service.py @@ -176,10 +176,100 @@ def _principal_from_payload(payload: dict[str, Any]) -> tuple[str, str, Optional return auth_type, subject_id, bot_id -def _mark_audit_revoked(session: Session, token_hash: str, *, reason: str) -> None: - row = session.exec( - select(AuthLoginLog).where(AuthLoginLog.token_hash == token_hash).limit(1) +def _find_audit_row_by_token_hash(session: Session, token_hash: str) -> Optional[AuthLoginLog]: + normalized_hash = str(token_hash or "").strip() + if not normalized_hash: + return None + return session.exec( + select(AuthLoginLog).where(AuthLoginLog.token_hash == normalized_hash).limit(1) ).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: return now = _utcnow() @@ -228,9 +318,7 @@ def _cleanup_principal_set(session: Session, principal_key: str) -> list[tuple[i def _ensure_auth_store_available() -> None: - if auth_cache.enabled: - return - raise RuntimeError("Redis authentication store is unavailable") + return def _persist_token_payload( @@ -240,6 +328,8 @@ def _persist_token_payload( raw_token: str, ttl_seconds: int, ) -> None: + if not auth_cache.enabled: + return token_hash = _hash_session_token(raw_token) payload = { "auth_type": row.auth_type, @@ -271,8 +361,15 @@ def _enforce_token_limit( bot_id: Optional[str], max_active: int, ) -> None: - principal_key = _principal_tokens_key(auth_type, subject_id, bot_id) - rows = _cleanup_principal_set(session, principal_key) + rows = [ + (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) if overflow <= 0: return @@ -417,21 +514,28 @@ def _resolve_token_auth( normalized_bot_id = str(bot_id or "").strip() or None if not token: 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: - 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 row is None: + 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) - if auth_type != expected_type: - return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing") - if expected_type == "bot" and payload_bot_id != normalized_bot_id: - return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing") + if auth_type != expected_type or (expected_type == "bot" and payload_bot_id != normalized_bot_id): + 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") + _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() if expires_at_raw: try: expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", "")) 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") except Exception: pass @@ -439,8 +543,28 @@ def _resolve_token_auth( row_id = int(payload.get("audit_id") or 0) or None if row_id is not None: row = session.get(AuthLoginLog, row_id) - if row is not None: - _touch_session(session, row) + 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) return AuthPrincipal(expected_type, subject_id, payload_bot_id, True, f"{expected_type}_token", row_id) diff --git a/frontend/src/components/markdown/MarkdownLiteEditor.tsx b/frontend/src/components/markdown/MarkdownLiteEditor.tsx index 81f30d3..f9d5500 100644 --- a/frontend/src/components/markdown/MarkdownLiteEditor.tsx +++ b/frontend/src/components/markdown/MarkdownLiteEditor.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react'; interface MarkdownLiteEditorProps { value: string; @@ -14,6 +14,18 @@ interface MarkdownLiteEditorProps { } 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) { return values.filter(Boolean).join(' '); @@ -115,26 +127,43 @@ export function MarkdownLiteEditor({ const tableHeaderStart = 2; const tableHeaderEnd = '| Column 1 | Column 2 | Column 3 |'.length - 2; - const actions = useMemo( - () => [ - { key: 'h1', label: 'H1', title: 'Heading 1', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `# ${line}`) }, - { 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') }, - { key: 'italic', label: 'I', title: 'Italic', run: () => replaceSelection(textareaRef.current!, onChange, '*', '*', 'italic text') }, - { key: 'ul', label: '- List', title: 'Bullet List', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `- ${line}`) }, - { key: 'ol', label: '1. List', title: 'Ordered List', run: () => replaceLineBlock(textareaRef.current!, onChange, (line, index) => `${index + 1}. ${line}`) }, - { key: 'quote', label: '> Quote', title: 'Quote', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `> ${line}`) }, - { key: 'code', label: '', title: 'Code Block', run: () => replaceSelection(textareaRef.current!, onChange, '```\n', '\n```', 'code') }, - { key: 'link', label: 'Link', title: 'Link', run: () => replaceSelection(textareaRef.current!, onChange, '[', '](https://)', 'link text') }, - { - key: 'table', - label: 'Table', - title: 'Insert Table', - run: () => insertBlockSnippet(textareaRef.current!, onChange, tableSnippet, tableHeaderStart, tableHeaderEnd), - }, - ], - [onChange, tableHeaderEnd, tableHeaderStart, tableSnippet], - ); + const runToolbarAction = (actionKey: (typeof TOOLBAR_ACTIONS)[number]['key']) => { + const textarea = textareaRef.current; + if (!textarea || disabled) return; + + switch (actionKey) { + case 'h1': + replaceLineBlock(textarea, onChange, (line) => `# ${line}`); + break; + case 'h2': + replaceLineBlock(textarea, onChange, (line) => `## ${line}`); + break; + case 'bold': + replaceSelection(textarea, onChange, '**', '**', 'bold text'); + break; + case 'italic': + replaceSelection(textarea, onChange, '*', '*', 'italic text'); + break; + case 'ul': + 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) => { const textarea = textareaRef.current; @@ -155,17 +184,14 @@ export function MarkdownLiteEditor({ return (
- {actions.map((action) => ( + {TOOLBAR_ACTIONS.map((action) => ( diff --git a/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts b/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts index a159c57..48d20c7 100644 --- a/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts +++ b/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts @@ -7,6 +7,37 @@ import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../. import type { QuotedReply, StagedSubmissionDraft } from '../types'; 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(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 { selectedBot?: { id: string } | null; canChat: boolean; @@ -24,7 +55,7 @@ interface UseDashboardChatCommandDispatchOptions { scrollConversationToBottom: (behavior?: ScrollBehavior) => void; completeLeadingStagedSubmission: (stagedSubmissionId: string) => void; notify: (message: string, options?: DashboardChatNotifyOptions) => void; - t: any; + t: ChatCommandDispatchLabels; } export function useDashboardChatCommandDispatch({ @@ -110,7 +141,7 @@ export function useDashboardChatCommandDispatch({ ...prev, [selectedBot.id]: Date.now() + (commandAutoUnlockSeconds * 1000), })); - const res = await axios.post( + const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, { command: payloadText, attachments }, { timeout: 12000 }, @@ -135,8 +166,8 @@ export function useDashboardChatCommandDispatch({ completeLeadingStagedSubmission(clearStagedSubmissionId); } return true; - } catch (error: any) { - const msg = error?.response?.data?.detail || error?.message || t.sendFail; + } catch (error: unknown) { + const msg = resolveApiErrorMessage(error, t.sendFail); setCommandAutoUnlockDeadlineByBot((prev) => { const next = { ...prev }; delete next[selectedBot.id]; @@ -200,7 +231,7 @@ export function useDashboardChatCommandDispatch({ if (!selectedBot || !canChat || activeControlCommand) return; try { setControlCommandByBot((prev) => ({ ...prev, [selectedBot.id]: slashCommand })); - const res = await axios.post( + const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, { command: slashCommand }, { timeout: 12000 }, @@ -216,8 +247,8 @@ export function useDashboardChatCommandDispatch({ setChatDatePickerOpen(false); setControlCommandPanelOpen(false); notify(t.controlCommandSent(slashCommand), { tone: 'success' }); - } catch (error: any) { - const msg = error?.response?.data?.detail || error?.message || t.sendFail; + } catch (error: unknown) { + const msg = resolveApiErrorMessage(error, t.sendFail); notify(msg, { tone: 'error' }); } finally { setControlCommandByBot((prev) => { @@ -243,7 +274,7 @@ export function useDashboardChatCommandDispatch({ if (!selectedBot || !canChat || isInterrupting) return; try { setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true })); - const res = await axios.post( + const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, { command: '/stop' }, { timeout: 12000 }, @@ -254,8 +285,8 @@ export function useDashboardChatCommandDispatch({ setChatDatePickerOpen(false); setControlCommandPanelOpen(false); notify(t.interruptSent, { tone: 'success' }); - } catch (error: any) { - const msg = error?.response?.data?.detail || error?.message || t.sendFail; + } catch (error: unknown) { + const msg = resolveApiErrorMessage(error, t.sendFail); notify(msg, { tone: 'error' }); } finally { setInterruptingByBot((prev) => { diff --git a/frontend/src/modules/dashboard/hooks/useDashboardDerivedState.ts b/frontend/src/modules/dashboard/hooks/useDashboardDerivedState.ts index 54f9944..bd15ecf 100644 --- a/frontend/src/modules/dashboard/hooks/useDashboardDerivedState.ts +++ b/frontend/src/modules/dashboard/hooks/useDashboardDerivedState.ts @@ -1,26 +1,31 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { parseBotTimestamp } from '../../../shared/bot/sortBots'; +import type { BotEvent, BotState, ChatMessage } from '../../../types/bot'; import { getSystemTimezoneOptions } from '../../../utils/systemTimezones'; import { mergeConversation } from '../chat/chatUtils'; import { RUNTIME_STALE_MS } from '../constants'; 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 { 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 { availableImages: NanobotImage[]; controlStateByBot: Record; defaultSystemTimezone: string; editFormImageTag: string; editFormSystemTimezone: string; - events: any[]; + events: BotEvent[]; isZh: boolean; - messages: any[]; - selectedBot?: any; + messages: ChatMessage[]; + selectedBot?: BotState; topicFeedUnreadCount: number; - topics: any[]; + topics: BotTopic[]; } interface UseDashboardInteractionStateOptions { @@ -28,7 +33,7 @@ interface UseDashboardInteractionStateOptions { isSendingBlocked?: boolean; isVoiceRecording?: boolean; isVoiceTranscribing?: boolean; - selectedBot?: any; + selectedBot?: BotState; } export function useDashboardBaseState({ @@ -100,7 +105,8 @@ export function useDashboardBaseState({ selectedBot.docker_status === 'RUNNING' && !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( () => getSystemTimezoneOptions(editFormSystemTimezone || defaultSystemTimezone), [defaultSystemTimezone, editFormSystemTimezone], @@ -124,9 +130,41 @@ export function useDashboardBaseState({ const latestEventTs = latestEvent?.ts || 0; return Math.max(latestEventTs, botUpdatedAtTs, lastUserTs); }, [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( - () => latestRuntimeSignalTs > 0 && Date.now() - latestRuntimeSignalTs < RUNTIME_STALE_MS, - [latestRuntimeSignalTs], + () => latestRuntimeSignalTs > 0 && nowTs - latestRuntimeSignalTs < RUNTIME_STALE_MS, + [latestRuntimeSignalTs, nowTs], ); const isThinking = useMemo(() => { if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false; @@ -147,21 +185,21 @@ export function useDashboardBaseState({ } if (isThinking) { - if (latestEvent?.state === 'TOOL_CALL') return 'TOOL_CALL'; + if (latestEventState === 'TOOL_CALL') return 'TOOL_CALL'; return 'THINKING'; } if ( - latestEvent && - ['THINKING', 'TOOL_CALL', 'ERROR'].includes(latestEvent.state) && - Date.now() - latestEvent.ts < 15000 + latestEvent?.ts && + TRANSIENT_RUNTIME_STATES.has(latestEventState) && + 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'; - }, [hasFreshRuntimeSignal, isThinking, latestEvent, selectedBot]); + }, [hasFreshRuntimeSignal, isThinking, latestEvent, latestEventState, nowTs, selectedBot]); const runtimeAction = useMemo(() => { const action = normalizeAssistantMessageText(selectedBot?.last_action || '').trim(); if (action) return action; diff --git a/frontend/src/modules/platform/hooks/usePlatformBotDockerLogs.ts b/frontend/src/modules/platform/hooks/usePlatformBotDockerLogs.ts index 34f1f35..03bf55b 100644 --- a/frontend/src/modules/platform/hooks/usePlatformBotDockerLogs.ts +++ b/frontend/src/modules/platform/hooks/usePlatformBotDockerLogs.ts @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import axios from 'axios'; import { APP_ENDPOINTS } from '../../../config/env'; +import { cleanBotLogLine } from '../../../shared/text/messageText'; import type { BotState } from '../../../types/bot'; -const ANSI_ESCAPE_RE = /(?:\u001b\[|\[)[0-9;]{1,12}m/g; const EMPTY_DOCKER_LOG_ENTRY = { key: '', index: '', @@ -13,12 +13,8 @@ const EMPTY_DOCKER_LOG_ENTRY = { tone: 'plain', } as const; -function stripAnsi(textRaw: string) { - return String(textRaw || '').replace(ANSI_ESCAPE_RE, '').trim(); -} - 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 levelRaw = String(levelMatch?.[1] || '').toUpperCase(); const level = levelRaw === 'WARNING' ? 'WARN' : (levelRaw || '-'); @@ -44,16 +40,34 @@ interface UsePlatformBotDockerLogsOptions { 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({ effectivePageSize, isZh, selectedBotInfo, }: UsePlatformBotDockerLogsOptions) { + const selectedBotId = String(selectedBotInfo?.id || '').trim(); const [dockerLogs, setDockerLogs] = useState([]); const [dockerLogsLoading, setDockerLogsLoading] = useState(false); const [dockerLogsError, setDockerLogsError] = useState(''); const [dockerLogsPage, setDockerLogsPage] = useState(1); const [dockerLogsHasMore, setDockerLogsHasMore] = useState(false); + const requestSeqRef = useRef(0); + const activeBotIdRef = useRef(selectedBotId); + const logsScopeKeyRef = useRef(`${selectedBotId}:${effectivePageSize}`); const recentLogEntries = useMemo(() => { const logs = (dockerLogs || []) @@ -88,67 +102,95 @@ export function usePlatformBotDockerLogs({ [dockerLogsPage, effectivePageSize, recentLogEntries], ); - const fetchDockerLogsPage = useCallback(async (page: number, silent: boolean = false) => { - if (!selectedBotInfo?.id) { - setDockerLogs([]); - setDockerLogsHasMore(false); - setDockerLogsError(''); - setDockerLogsLoading(false); + const resetDockerLogsState = useCallback(() => { + requestSeqRef.current += 1; + setDockerLogs([]); + setDockerLogsHasMore(false); + setDockerLogsError(''); + 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; } + const safePage = Math.max(1, page); + const requestSeq = ++requestSeqRef.current; if (!silent) setDockerLogsLoading(true); setDockerLogsError(''); + try { - const res = await axios.get<{ - bot_id: string; - logs?: string[]; - total?: number | null; - offset?: number; - limit?: number; - has_more?: boolean; - reverse?: boolean; - }>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotInfo.id}/logs`, { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/logs`, { params: { offset: (safePage - 1) * effectivePageSize, limit: effectivePageSize, reverse: true, }, }); + + if (requestSeq !== requestSeqRef.current || activeBotIdRef.current !== botId) { + return; + } + const lines = Array.isArray(res.data?.logs) ? res.data.logs.map((line) => String(line || '').trim()).filter(Boolean) : []; setDockerLogs(lines); setDockerLogsHasMore(Boolean(res.data?.has_more)); setDockerLogsPage(safePage); - } catch (error: any) { + } catch (error: unknown) { + if (requestSeq !== requestSeqRef.current || activeBotIdRef.current !== botId) { + return; + } + + const detail = axios.isAxiosError(error) + ? String(error.response?.data?.detail || '').trim() + : ''; setDockerLogs([]); setDockerLogsHasMore(false); - setDockerLogsError(error?.response?.data?.detail || (isZh ? '读取 Docker 日志失败。' : 'Failed to load Docker logs.')); + setDockerLogsError(detail || (isZh ? '读取 Docker 日志失败。' : 'Failed to load Docker logs.')); } finally { - if (!silent) { + if (!silent && requestSeq === requestSeqRef.current && activeBotIdRef.current === botId) { setDockerLogsLoading(false); } } - }, [effectivePageSize, isZh, selectedBotInfo?.id]); + }, [effectivePageSize, isZh, resetDockerLogsState, selectedBotId]); useEffect(() => { - setDockerLogsPage(1); - }, [selectedBotInfo?.id, effectivePageSize]); + const nextLogsScopeKey = `${selectedBotId}:${effectivePageSize}`; + if (!selectedBotId) { + logsScopeKeyRef.current = nextLogsScopeKey; + resetDockerLogsState(); + setDockerLogsPage(1); + return; + } - useEffect(() => { - if (!selectedBotInfo?.id) { + if (logsScopeKeyRef.current !== nextLogsScopeKey) { + logsScopeKeyRef.current = nextLogsScopeKey; + requestSeqRef.current += 1; setDockerLogs([]); setDockerLogsHasMore(false); setDockerLogsError(''); - setDockerLogsLoading(false); + setDockerLogsPage(1); return; } 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 () => { stopped = true; }; @@ -156,7 +198,7 @@ export function usePlatformBotDockerLogs({ const timer = window.setInterval(() => { if (!stopped) { - void fetchDockerLogsPage(1, true); + void fetchDockerLogsPage(1, true, selectedBotId); } }, 3000); @@ -164,7 +206,7 @@ export function usePlatformBotDockerLogs({ stopped = true; window.clearInterval(timer); }; - }, [dockerLogsPage, fetchDockerLogsPage, selectedBotInfo?.docker_status, selectedBotInfo?.id]); + }, [dockerLogsPage, effectivePageSize, fetchDockerLogsPage, resetDockerLogsState, selectedBotId, selectedBotInfo?.docker_status]); return { dockerLogsError, diff --git a/frontend/src/shared/text/messageText.ts b/frontend/src/shared/text/messageText.ts index 5b18114..2cf64ac 100644 --- a/frontend/src/shared/text/messageText.ts +++ b/frontend/src/shared/text/messageText.ts @@ -1,9 +1,17 @@ -const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g; -const OSC_RE = /\x1b\][^\u0007]*(\u0007|\x1b\\)/g; -const NON_TEXT_RE = /[^\u0009\u0020-\u007E\u4E00-\u9FFF。,!?:;、“”‘’()《》【】—…·\-_./:\\,%+*='"`|<>]/g; -const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g; +const ESC = String.fromCharCode(0x1b); +const BEL = String.fromCharCode(0x07); +const CONTROL_RE_SOURCE = [ + `[${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 QUOTED_REPLY_BLOCK_RE = /\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]/gi; +const SUMMARY_MARKDOWN_CHARS_RE = /[[\]`*_>#|()]/g; export function normalizeUserMessageText(input: string) { let text = (input || '').replace(/\r\n/g, '\n').trim(); @@ -76,7 +84,7 @@ export function summarizeProgressText(input: string, isZh: boolean) { .map((value) => value.trim()) .find((value) => value.length > 0); const line = (firstLine || raw) - .replace(/[`*_>#|\[\]\(\)]/g, ' ') + .replace(SUMMARY_MARKDOWN_CHARS_RE, ' ') .replace(/\s+/g, ' ') .trim(); if (!line) return isZh ? '处理中...' : 'Processing...'; @@ -89,7 +97,7 @@ export function cleanBotLogLine(line: string) { .replace(ANSI_RE, '') .replace(/\[(\?|\d|;)+[A-Za-z]/g, '') .replace(/\[(\d+)?K/g, '') - .replace(NON_TEXT_RE, ' ') + .replace(CONTROL_RE, ' ') .replace(/\s+/g, ' ') .trim(); }