fix git bugs.
parent
f904d97a3d
commit
e743ae7db5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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、语音模型和运行数据都落在这里。
|
||||
|
|
|
|||
|
|
@ -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 ""),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | false | null | undefined>) {
|
||||
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<HTMLTextAreaElement>) => {
|
||||
const textarea = textareaRef.current;
|
||||
|
|
@ -155,17 +184,14 @@ export function MarkdownLiteEditor({
|
|||
return (
|
||||
<div className={joinClassNames('md-lite-editor', fullHeight && 'is-full-height', className)}>
|
||||
<div className="md-lite-toolbar" role="toolbar" aria-label="Markdown editor toolbar">
|
||||
{actions.map((action) => (
|
||||
{TOOLBAR_ACTIONS.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
className="md-lite-toolbtn"
|
||||
type="button"
|
||||
title={action.title}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
if (disabled || !textareaRef.current) return;
|
||||
action.run();
|
||||
}}
|
||||
onClick={() => runToolbarAction(action.key)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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<CommandResponse>(
|
||||
`${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<CommandResponse>(
|
||||
`${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<CommandResponse>(
|
||||
`${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) => {
|
||||
|
|
|
|||
|
|
@ -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<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<string[]>([]);
|
||||
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<DockerLogsResponse>(`${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<ApiErrorDetail>(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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue