v0.1.4-p3
parent
20c70d2e69
commit
de74c5dfe1
|
|
@ -3,6 +3,7 @@ ENV PYTHONUNBUFFERED=1
|
||||||
ENV LANG=C.UTF-8
|
ENV LANG=C.UTF-8
|
||||||
ENV LC_ALL=C.UTF-8
|
ENV LC_ALL=C.UTF-8
|
||||||
ENV PYTHONIOENCODING=utf-8
|
ENV PYTHONIOENCODING=utf-8
|
||||||
|
ENV PYTHONPATH=/opt/dashboard-patches${PYTHONPATH:+:${PYTHONPATH}}
|
||||||
|
|
||||||
# 1. 替换 Debian 源为国内镜像
|
# 1. 替换 Debian 源为国内镜像
|
||||||
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
|
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
|
||||||
|
|
@ -19,6 +20,155 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
RUN python -m pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --upgrade \
|
RUN python -m pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --upgrade \
|
||||||
pip setuptools wheel aiohttp
|
pip setuptools wheel aiohttp
|
||||||
|
|
||||||
|
# 3.1 LiteLLM compatibility patch for DashScope coder/code models.
|
||||||
|
# DashScope coder models require `tool_calls[*].function.arguments` to be a JSON string.
|
||||||
|
# Some upstream stacks may replay historical tool calls as dicts / Python-literal strings.
|
||||||
|
# We patch LiteLLM entrypoints at runtime so old history can still be forwarded safely.
|
||||||
|
RUN mkdir -p /opt/dashboard-patches && cat > /opt/dashboard-patches/sitecustomize.py <<'PY'
|
||||||
|
import ast
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _log(message: str) -> None:
|
||||||
|
if str(os.getenv("DASHBOARD_LITELLM_PATCH_VERBOSE") or "").strip().lower() not in {"1", "true", "yes", "on"}:
|
||||||
|
return
|
||||||
|
print(f"[dashboard-litellm-patch] {message}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_json_arguments(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "{}"
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return "{}"
|
||||||
|
try:
|
||||||
|
json.loads(text)
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
parsed = ast.literal_eval(text)
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return json.dumps(parsed, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return json.dumps({"raw": text}, ensure_ascii=False)
|
||||||
|
try:
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"raw": str(value)}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_openai_messages(messages: Any) -> tuple[Any, int]:
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
return messages, 0
|
||||||
|
try:
|
||||||
|
cloned = copy.deepcopy(messages)
|
||||||
|
except Exception:
|
||||||
|
cloned = list(messages)
|
||||||
|
|
||||||
|
changed = 0
|
||||||
|
for message in cloned:
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tool_calls = message.get("tool_calls")
|
||||||
|
if isinstance(tool_calls, list):
|
||||||
|
for tool_call in tool_calls:
|
||||||
|
if not isinstance(tool_call, dict):
|
||||||
|
continue
|
||||||
|
function = tool_call.get("function")
|
||||||
|
if not isinstance(function, dict):
|
||||||
|
continue
|
||||||
|
arguments = function.get("arguments")
|
||||||
|
normalized = _coerce_json_arguments(arguments)
|
||||||
|
if arguments != normalized:
|
||||||
|
function["arguments"] = normalized
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
function_call = message.get("function_call")
|
||||||
|
if isinstance(function_call, dict):
|
||||||
|
arguments = function_call.get("arguments")
|
||||||
|
normalized = _coerce_json_arguments(arguments)
|
||||||
|
if arguments != normalized:
|
||||||
|
function_call["arguments"] = normalized
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
return cloned, changed
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_litellm() -> None:
|
||||||
|
try:
|
||||||
|
import litellm # type: ignore
|
||||||
|
except Exception as exc:
|
||||||
|
_log(f"litellm import skipped: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
def _wrap_sync(fn):
|
||||||
|
if not callable(fn) or getattr(fn, "_dashboard_litellm_patch", False):
|
||||||
|
return fn
|
||||||
|
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
messages = kwargs.get("messages")
|
||||||
|
normalized_messages, changed = _sanitize_openai_messages(messages)
|
||||||
|
if changed:
|
||||||
|
kwargs["messages"] = normalized_messages
|
||||||
|
_log(f"sanitized {changed} tool/function argument payload(s) before sync completion")
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
setattr(wrapper, "_dashboard_litellm_patch", True)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def _wrap_async(fn):
|
||||||
|
if not callable(fn) or getattr(fn, "_dashboard_litellm_patch", False):
|
||||||
|
return fn
|
||||||
|
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
messages = kwargs.get("messages")
|
||||||
|
normalized_messages, changed = _sanitize_openai_messages(messages)
|
||||||
|
if changed:
|
||||||
|
kwargs["messages"] = normalized_messages
|
||||||
|
_log(f"sanitized {changed} tool/function argument payload(s) before async completion")
|
||||||
|
return await fn(*args, **kwargs)
|
||||||
|
|
||||||
|
setattr(wrapper, "_dashboard_litellm_patch", True)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
for attr in ("completion", "completion_with_retries"):
|
||||||
|
if hasattr(litellm, attr):
|
||||||
|
setattr(litellm, attr, _wrap_sync(getattr(litellm, attr)))
|
||||||
|
|
||||||
|
for attr in ("acompletion",):
|
||||||
|
if hasattr(litellm, attr):
|
||||||
|
setattr(litellm, attr, _wrap_async(getattr(litellm, attr)))
|
||||||
|
|
||||||
|
try:
|
||||||
|
import litellm.main as litellm_main # type: ignore
|
||||||
|
except Exception:
|
||||||
|
litellm_main = None
|
||||||
|
|
||||||
|
if litellm_main is not None:
|
||||||
|
for attr in ("completion", "completion_with_retries"):
|
||||||
|
if hasattr(litellm_main, attr):
|
||||||
|
setattr(litellm_main, attr, _wrap_sync(getattr(litellm_main, attr)))
|
||||||
|
for attr in ("acompletion",):
|
||||||
|
if hasattr(litellm_main, attr):
|
||||||
|
setattr(litellm_main, attr, _wrap_async(getattr(litellm_main, attr)))
|
||||||
|
|
||||||
|
_log("LiteLLM monkey patch installed")
|
||||||
|
|
||||||
|
|
||||||
|
_patch_litellm()
|
||||||
|
PY
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# 这一步会把您修改好的 nanobot/channels/dashboard.py 一起拷进去
|
# 这一步会把您修改好的 nanobot/channels/dashboard.py 一起拷进去
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ export const dashboardEn = {
|
||||||
workspaceSearchNoResult: 'No matching files or folders.',
|
workspaceSearchNoResult: 'No matching files or folders.',
|
||||||
searchAction: 'Search',
|
searchAction: 'Search',
|
||||||
clearSearch: 'Clear search',
|
clearSearch: 'Clear search',
|
||||||
|
syncingPageSize: 'Syncing page size...',
|
||||||
paginationPrev: 'Prev',
|
paginationPrev: 'Prev',
|
||||||
paginationNext: 'Next',
|
paginationNext: 'Next',
|
||||||
paginationPage: (current: number, total: number) => `${current} / ${total}`,
|
paginationPage: (current: number, total: number) => `${current} / ${total}`,
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ export const dashboardZhCn = {
|
||||||
workspaceSearchNoResult: '没有匹配的文件或目录。',
|
workspaceSearchNoResult: '没有匹配的文件或目录。',
|
||||||
searchAction: '搜索',
|
searchAction: '搜索',
|
||||||
clearSearch: '清除搜索',
|
clearSearch: '清除搜索',
|
||||||
|
syncingPageSize: '正在同步分页设置...',
|
||||||
paginationPrev: '上一页',
|
paginationPrev: '上一页',
|
||||||
paginationNext: '下一页',
|
paginationNext: '下一页',
|
||||||
paginationPage: (current: number, total: number) => `${current} / ${total}`,
|
paginationPage: (current: number, total: number) => `${current} / ${total}`,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||||
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
||||||
import { LucentSelect } from '../../components/lucent/LucentSelect';
|
import { LucentSelect } from '../../components/lucent/LucentSelect';
|
||||||
import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel';
|
import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel';
|
||||||
|
import {
|
||||||
|
normalizePlatformPageSize,
|
||||||
|
readCachedPlatformPageSize,
|
||||||
|
writeCachedPlatformPageSize,
|
||||||
|
} from '../../utils/platformPageSize';
|
||||||
|
|
||||||
interface BotDashboardModuleProps {
|
interface BotDashboardModuleProps {
|
||||||
onOpenCreateWizard?: () => void;
|
onOpenCreateWizard?: () => void;
|
||||||
|
|
@ -1138,7 +1143,10 @@ export function BotDashboardModule({
|
||||||
});
|
});
|
||||||
const [uploadMaxMb, setUploadMaxMb] = useState(100);
|
const [uploadMaxMb, setUploadMaxMb] = useState(100);
|
||||||
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState<string[]>([]);
|
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState<string[]>([]);
|
||||||
const [botListPageSize, setBotListPageSize] = useState(10);
|
const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10));
|
||||||
|
const [botListPageSizeReady, setBotListPageSizeReady] = useState(
|
||||||
|
() => readCachedPlatformPageSize(0) > 0,
|
||||||
|
);
|
||||||
const [chatPullPageSize, setChatPullPageSize] = useState(60);
|
const [chatPullPageSize, setChatPullPageSize] = useState(60);
|
||||||
const [chatHasMore, setChatHasMore] = useState(false);
|
const [chatHasMore, setChatHasMore] = useState(false);
|
||||||
const [chatLoadingMore, setChatLoadingMore] = useState(false);
|
const [chatLoadingMore, setChatLoadingMore] = useState(false);
|
||||||
|
|
@ -2153,10 +2161,12 @@ export function BotDashboardModule({
|
||||||
if (Number.isFinite(configured) && configured > 0) {
|
if (Number.isFinite(configured) && configured > 0) {
|
||||||
setUploadMaxMb(Math.max(1, Math.floor(configured)));
|
setUploadMaxMb(Math.max(1, Math.floor(configured)));
|
||||||
}
|
}
|
||||||
const configuredPageSize = Number(res.data?.chat?.page_size);
|
const configuredPageSize = normalizePlatformPageSize(
|
||||||
if (Number.isFinite(configuredPageSize) && configuredPageSize > 0) {
|
res.data?.chat?.page_size,
|
||||||
setBotListPageSize(Math.max(1, Math.min(100, Math.floor(configuredPageSize))));
|
readCachedPlatformPageSize(10),
|
||||||
}
|
);
|
||||||
|
writeCachedPlatformPageSize(configuredPageSize);
|
||||||
|
setBotListPageSize(configuredPageSize);
|
||||||
setAllowedAttachmentExtensions(
|
setAllowedAttachmentExtensions(
|
||||||
parseAllowedAttachmentExtensions(res.data?.workspace?.allowed_attachment_extensions),
|
parseAllowedAttachmentExtensions(res.data?.workspace?.allowed_attachment_extensions),
|
||||||
);
|
);
|
||||||
|
|
@ -2183,6 +2193,10 @@ export function BotDashboardModule({
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// keep default limit
|
// keep default limit
|
||||||
|
} finally {
|
||||||
|
if (alive) {
|
||||||
|
setBotListPageSizeReady(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void loadSystemDefaults();
|
void loadSystemDefaults();
|
||||||
|
|
@ -4953,150 +4967,157 @@ export function BotDashboardModule({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-scroll">
|
<div className="list-scroll">
|
||||||
{pagedBots.map((bot) => {
|
{!botListPageSizeReady ? (
|
||||||
const selected = selectedBotId === bot.id;
|
<div className="ops-bot-list-empty">{t.syncingPageSize}</div>
|
||||||
const controlState = controlStateByBot[bot.id];
|
) : null}
|
||||||
const isOperating = operatingBotId === bot.id;
|
{botListPageSizeReady
|
||||||
const isEnabled = bot.enabled !== false;
|
? pagedBots.map((bot) => {
|
||||||
const isStarting = controlState === 'starting';
|
const selected = selectedBotId === bot.id;
|
||||||
const isStopping = controlState === 'stopping';
|
const controlState = controlStateByBot[bot.id];
|
||||||
const isEnabling = controlState === 'enabling';
|
const isOperating = operatingBotId === bot.id;
|
||||||
const isDisabling = controlState === 'disabling';
|
const isEnabled = bot.enabled !== false;
|
||||||
const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING';
|
const isStarting = controlState === 'starting';
|
||||||
return (
|
const isStopping = controlState === 'stopping';
|
||||||
<div
|
const isEnabling = controlState === 'enabling';
|
||||||
key={bot.id}
|
const isDisabling = controlState === 'disabling';
|
||||||
className={`ops-bot-card ${selected ? 'is-active' : ''} ${isEnabled ? (isRunning ? 'state-running' : 'state-stopped') : 'state-disabled'}`}
|
const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING';
|
||||||
onClick={() => {
|
return (
|
||||||
setSelectedBotId(bot.id);
|
<div
|
||||||
if (compactMode) setCompactPanelTab('chat');
|
key={bot.id}
|
||||||
}}
|
className={`ops-bot-card ${selected ? 'is-active' : ''} ${isEnabled ? (isRunning ? 'state-running' : 'state-stopped') : 'state-disabled'}`}
|
||||||
>
|
onClick={() => {
|
||||||
<span className={`ops-bot-strip ${isEnabled ? (isRunning ? 'is-running' : 'is-stopped') : 'is-disabled'}`} aria-hidden="true" />
|
setSelectedBotId(bot.id);
|
||||||
<div className="row-between ops-bot-top">
|
if (compactMode) setCompactPanelTab('chat');
|
||||||
<div className="ops-bot-name-wrap">
|
}}
|
||||||
<div className="ops-bot-name-row">
|
|
||||||
{bot.has_access_password ? (
|
|
||||||
<span className="ops-bot-lock" title={isZh ? '已设置访问密码' : 'Access password enabled'} aria-label={isZh ? '已设置访问密码' : 'Access password enabled'}>
|
|
||||||
<Lock size={12} />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<div className="ops-bot-name">{bot.name}</div>
|
|
||||||
<LucentIconButton
|
|
||||||
className="ops-bot-open-inline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`;
|
|
||||||
window.open(target, '_blank', 'noopener,noreferrer');
|
|
||||||
}}
|
|
||||||
tooltip={isZh ? '新页面打开' : 'Open in new page'}
|
|
||||||
aria-label={isZh ? '新页面打开' : 'Open in new page'}
|
|
||||||
>
|
|
||||||
<ExternalLink size={11} />
|
|
||||||
</LucentIconButton>
|
|
||||||
</div>
|
|
||||||
<div className="mono ops-bot-id">{bot.id}</div>
|
|
||||||
</div>
|
|
||||||
<div className="ops-bot-top-actions">
|
|
||||||
{!isEnabled ? (
|
|
||||||
<span className="badge badge-err">{t.disabled}</span>
|
|
||||||
) : null}
|
|
||||||
<span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ops-bot-meta">{t.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
|
|
||||||
<div className="ops-bot-actions">
|
|
||||||
<label
|
|
||||||
className="ops-bot-enable-switch"
|
|
||||||
title={isEnabled ? t.disable : t.enable}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<input
|
<span className={`ops-bot-strip ${isEnabled ? (isRunning ? 'is-running' : 'is-stopped') : 'is-disabled'}`} aria-hidden="true" />
|
||||||
type="checkbox"
|
<div className="row-between ops-bot-top">
|
||||||
checked={isEnabled}
|
<div className="ops-bot-name-wrap">
|
||||||
disabled={isOperating || isEnabling || isDisabling}
|
<div className="ops-bot-name-row">
|
||||||
onChange={(e) => {
|
{bot.has_access_password ? (
|
||||||
void setBotEnabled(bot.id, e.target.checked);
|
<span className="ops-bot-lock" title={isZh ? '已设置访问密码' : 'Access password enabled'} aria-label={isZh ? '已设置访问密码' : 'Access password enabled'}>
|
||||||
}}
|
<Lock size={12} />
|
||||||
aria-label={isZh ? '启用/停用' : 'Enable/Disable'}
|
</span>
|
||||||
/>
|
) : null}
|
||||||
<span className="ops-bot-enable-switch-track" />
|
<div className="ops-bot-name">{bot.name}</div>
|
||||||
</label>
|
<LucentIconButton
|
||||||
<div className="ops-bot-actions-main">
|
className="ops-bot-open-inline"
|
||||||
<LucentIconButton
|
onClick={(e) => {
|
||||||
className={`btn btn-sm ops-bot-icon-btn ${isRunning ? 'ops-bot-action-stop' : 'ops-bot-action-start'}`}
|
e.stopPropagation();
|
||||||
disabled={isOperating || !isEnabled}
|
const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`;
|
||||||
onClick={(e) => {
|
window.open(target, '_blank', 'noopener,noreferrer');
|
||||||
e.stopPropagation();
|
}}
|
||||||
void (isRunning ? stopBot(bot.id, bot.docker_status) : startBot(bot.id, bot.docker_status));
|
tooltip={isZh ? '新页面打开' : 'Open in new page'}
|
||||||
}}
|
aria-label={isZh ? '新页面打开' : 'Open in new page'}
|
||||||
tooltip={isRunning ? t.stop : t.start}
|
>
|
||||||
aria-label={isRunning ? t.stop : t.start}
|
<ExternalLink size={11} />
|
||||||
>
|
</LucentIconButton>
|
||||||
{isStarting || isStopping ? (
|
</div>
|
||||||
<span className="ops-control-pending">
|
<div className="mono ops-bot-id">{bot.id}</div>
|
||||||
<span className="ops-control-dots" aria-hidden="true">
|
</div>
|
||||||
<i />
|
<div className="ops-bot-top-actions">
|
||||||
<i />
|
{!isEnabled ? (
|
||||||
<i />
|
<span className="badge badge-err">{t.disabled}</span>
|
||||||
</span>
|
) : null}
|
||||||
</span>
|
<span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
|
||||||
) : isRunning ? <Square size={14} /> : <Power size={14} />}
|
</div>
|
||||||
</LucentIconButton>
|
</div>
|
||||||
<LucentIconButton
|
<div className="ops-bot-meta">{t.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
|
||||||
className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
|
<div className="ops-bot-actions">
|
||||||
disabled={isOperating || !isEnabled}
|
<label
|
||||||
onClick={(e) => {
|
className="ops-bot-enable-switch"
|
||||||
e.stopPropagation();
|
title={isEnabled ? t.disable : t.enable}
|
||||||
openResourceMonitor(bot.id);
|
onClick={(e) => e.stopPropagation()}
|
||||||
}}
|
>
|
||||||
tooltip={isZh ? '资源监测' : 'Resource Monitor'}
|
<input
|
||||||
aria-label={isZh ? '资源监测' : 'Resource Monitor'}
|
type="checkbox"
|
||||||
>
|
checked={isEnabled}
|
||||||
<Gauge size={14} />
|
disabled={isOperating || isEnabling || isDisabling}
|
||||||
</LucentIconButton>
|
onChange={(e) => {
|
||||||
<LucentIconButton
|
void setBotEnabled(bot.id, e.target.checked);
|
||||||
className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
|
}}
|
||||||
disabled={isOperating || !isEnabled}
|
aria-label={isZh ? '启用/停用' : 'Enable/Disable'}
|
||||||
onClick={(e) => {
|
/>
|
||||||
e.stopPropagation();
|
<span className="ops-bot-enable-switch-track" />
|
||||||
void removeBot(bot.id);
|
</label>
|
||||||
}}
|
<div className="ops-bot-actions-main">
|
||||||
tooltip={t.delete}
|
<LucentIconButton
|
||||||
aria-label={t.delete}
|
className={`btn btn-sm ops-bot-icon-btn ${isRunning ? 'ops-bot-action-stop' : 'ops-bot-action-start'}`}
|
||||||
>
|
disabled={isOperating || !isEnabled}
|
||||||
<Trash2 size={14} />
|
onClick={(e) => {
|
||||||
</LucentIconButton>
|
e.stopPropagation();
|
||||||
|
void (isRunning ? stopBot(bot.id, bot.docker_status) : startBot(bot.id, bot.docker_status));
|
||||||
|
}}
|
||||||
|
tooltip={isRunning ? t.stop : t.start}
|
||||||
|
aria-label={isRunning ? t.stop : t.start}
|
||||||
|
>
|
||||||
|
{isStarting || isStopping ? (
|
||||||
|
<span className="ops-control-pending">
|
||||||
|
<span className="ops-control-dots" aria-hidden="true">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : isRunning ? <Square size={14} /> : <Power size={14} />}
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
|
||||||
|
disabled={isOperating || !isEnabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openResourceMonitor(bot.id);
|
||||||
|
}}
|
||||||
|
tooltip={isZh ? '资源监测' : 'Resource Monitor'}
|
||||||
|
aria-label={isZh ? '资源监测' : 'Resource Monitor'}
|
||||||
|
>
|
||||||
|
<Gauge size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
|
||||||
|
disabled={isOperating || !isEnabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void removeBot(bot.id);
|
||||||
|
}}
|
||||||
|
tooltip={t.delete}
|
||||||
|
aria-label={t.delete}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})
|
||||||
);
|
: null}
|
||||||
})}
|
{botListPageSizeReady && filteredBots.length === 0 ? (
|
||||||
{filteredBots.length === 0 ? (
|
|
||||||
<div className="ops-bot-list-empty">{t.botSearchNoResult}</div>
|
<div className="ops-bot-list-empty">{t.botSearchNoResult}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="ops-bot-list-pagination">
|
{botListPageSizeReady ? (
|
||||||
<LucentIconButton
|
<div className="ops-bot-list-pagination">
|
||||||
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
|
<LucentIconButton
|
||||||
onClick={() => setBotListPage((p) => Math.max(1, p - 1))}
|
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
|
||||||
disabled={botListPage <= 1}
|
onClick={() => setBotListPage((p) => Math.max(1, p - 1))}
|
||||||
tooltip={t.paginationPrev}
|
disabled={botListPage <= 1}
|
||||||
aria-label={t.paginationPrev}
|
tooltip={t.paginationPrev}
|
||||||
>
|
aria-label={t.paginationPrev}
|
||||||
<ChevronLeft size={14} />
|
>
|
||||||
</LucentIconButton>
|
<ChevronLeft size={14} />
|
||||||
<div className="ops-bot-list-page-indicator pager-status">{t.paginationPage(botListPage, botListTotalPages)}</div>
|
</LucentIconButton>
|
||||||
<LucentIconButton
|
<div className="ops-bot-list-page-indicator pager-status">{t.paginationPage(botListPage, botListTotalPages)}</div>
|
||||||
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
|
<LucentIconButton
|
||||||
onClick={() => setBotListPage((p) => Math.min(botListTotalPages, p + 1))}
|
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
|
||||||
disabled={botListPage >= botListTotalPages}
|
onClick={() => setBotListPage((p) => Math.min(botListTotalPages, p + 1))}
|
||||||
tooltip={t.paginationNext}
|
disabled={botListPage >= botListTotalPages}
|
||||||
aria-label={t.paginationNext}
|
tooltip={t.paginationNext}
|
||||||
>
|
aria-label={t.paginationNext}
|
||||||
<ChevronRight size={14} />
|
>
|
||||||
</LucentIconButton>
|
<ChevronRight size={14} />
|
||||||
</div>
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
||||||
import { PlatformSettingsModal } from './components/PlatformSettingsModal';
|
import { PlatformSettingsModal } from './components/PlatformSettingsModal';
|
||||||
import { TemplateManagerModal } from './components/TemplateManagerModal';
|
import { TemplateManagerModal } from './components/TemplateManagerModal';
|
||||||
import { appendPanelAccessPassword } from '../../utils/panelAccess';
|
import { appendPanelAccessPassword } from '../../utils/panelAccess';
|
||||||
|
import {
|
||||||
|
normalizePlatformPageSize,
|
||||||
|
readCachedPlatformPageSize,
|
||||||
|
writeCachedPlatformPageSize,
|
||||||
|
} from '../../utils/platformPageSize';
|
||||||
import '../dashboard/BotDashboardModule.css';
|
import '../dashboard/BotDashboardModule.css';
|
||||||
|
|
||||||
function formatBytes(bytes: number) {
|
function formatBytes(bytes: number) {
|
||||||
|
|
@ -65,12 +70,6 @@ function formatDateTime(value: string | null | undefined, locale: string) {
|
||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePageSize(value: unknown, fallback = 10) {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
||||||
return Math.max(1, Math.min(100, Math.floor(parsed)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBotPanelHref(botId: string) {
|
function buildBotPanelHref(botId: string) {
|
||||||
const encodedId = encodeURIComponent(String(botId || '').trim());
|
const encodedId = encodeURIComponent(String(botId || '').trim());
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
@ -152,9 +151,9 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
|
||||||
const [usageData, setUsageData] = useState<PlatformUsageResponse | null>(null);
|
const [usageData, setUsageData] = useState<PlatformUsageResponse | null>(null);
|
||||||
const [usageLoading, setUsageLoading] = useState(false);
|
const [usageLoading, setUsageLoading] = useState(false);
|
||||||
const [usagePage, setUsagePage] = useState(1);
|
const [usagePage, setUsagePage] = useState(1);
|
||||||
const [usagePageSize, setUsagePageSize] = useState(10);
|
const [usagePageSize, setUsagePageSize] = useState(() => readCachedPlatformPageSize(10));
|
||||||
const [botListPage, setBotListPage] = useState(1);
|
const [botListPage, setBotListPage] = useState(1);
|
||||||
const [botListPageSize, setBotListPageSize] = useState(10);
|
const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10));
|
||||||
const [showCompactBotSheet, setShowCompactBotSheet] = useState(false);
|
const [showCompactBotSheet, setShowCompactBotSheet] = useState(false);
|
||||||
const [compactSheetClosing, setCompactSheetClosing] = useState(false);
|
const [compactSheetClosing, setCompactSheetClosing] = useState(false);
|
||||||
const [compactSheetMounted, setCompactSheetMounted] = useState(false);
|
const [compactSheetMounted, setCompactSheetMounted] = useState(false);
|
||||||
|
|
@ -194,8 +193,13 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<PlatformOverviewResponse>(`${APP_ENDPOINTS.apiBase}/platform/overview`);
|
const res = await axios.get<PlatformOverviewResponse>(`${APP_ENDPOINTS.apiBase}/platform/overview`);
|
||||||
setOverview(res.data);
|
setOverview(res.data);
|
||||||
setUsagePageSize((prev) => normalizePageSize(res.data?.settings?.page_size, prev));
|
const normalizedPageSize = normalizePlatformPageSize(
|
||||||
setBotListPageSize((prev) => normalizePageSize(res.data?.settings?.page_size, prev));
|
res.data?.settings?.page_size,
|
||||||
|
readCachedPlatformPageSize(10),
|
||||||
|
);
|
||||||
|
writeCachedPlatformPageSize(normalizedPageSize);
|
||||||
|
setUsagePageSize(normalizedPageSize);
|
||||||
|
setBotListPageSize(normalizedPageSize);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify(error?.response?.data?.detail || (isZh ? '读取平台总览失败。' : 'Failed to load platform overview.'), { tone: 'error' });
|
notify(error?.response?.data?.detail || (isZh ? '读取平台总览失败。' : 'Failed to load platform overview.'), { tone: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -956,8 +960,10 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
|
||||||
onClose={() => setShowPlatformSettings(false)}
|
onClose={() => setShowPlatformSettings(false)}
|
||||||
onSaved={(settings) => {
|
onSaved={(settings) => {
|
||||||
setOverview((prev) => (prev ? { ...prev, settings } : prev));
|
setOverview((prev) => (prev ? { ...prev, settings } : prev));
|
||||||
setUsagePageSize(normalizePageSize(settings.page_size, 10));
|
const normalizedPageSize = normalizePlatformPageSize(settings.page_size, 10);
|
||||||
setBotListPageSize(normalizePageSize(settings.page_size, 10));
|
writeCachedPlatformPageSize(normalizedPageSize);
|
||||||
|
setUsagePageSize(normalizedPageSize);
|
||||||
|
setBotListPageSize(normalizedPageSize);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{showBotLastActionModal && selectedBotInfo ? (
|
{showBotLastActionModal && selectedBotInfo ? (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
const PLATFORM_PAGE_SIZE_STORAGE_KEY = 'nanobot-platform-page-size';
|
||||||
|
|
||||||
|
export function normalizePlatformPageSize(value: unknown, fallback = 10) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||||
|
return Math.max(1, Math.min(100, Math.floor(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCachedPlatformPageSize(fallback = 10) {
|
||||||
|
if (typeof window === 'undefined') return fallback;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(PLATFORM_PAGE_SIZE_STORAGE_KEY);
|
||||||
|
return normalizePlatformPageSize(raw, fallback);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCachedPlatformPageSize(value: unknown) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
PLATFORM_PAGE_SIZE_STORAGE_KEY,
|
||||||
|
String(normalizePlatformPageSize(value, 10)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage write failures
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue