fix git bugs.

main
mula.liu 2026-04-13 20:07:07 +08:00
parent f904d97a3d
commit e743ae7db5
12 changed files with 425 additions and 121 deletions

View File

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

View File

@ -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、语音模型和运行数据都落在这里。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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