v0..1.4-p4
parent
f77851d496
commit
1ef72df0b1
|
|
@ -41,6 +41,7 @@ class BotConfigManager:
|
||||||
"xunfei": "openai",
|
"xunfei": "openai",
|
||||||
"iflytek": "openai",
|
"iflytek": "openai",
|
||||||
"xfyun": "openai",
|
"xfyun": "openai",
|
||||||
|
"vllm": "openai",
|
||||||
}
|
}
|
||||||
provider_name = provider_alias.get(provider_name, provider_name)
|
provider_name = provider_alias.get(provider_name, provider_name)
|
||||||
if provider_name == "openai" and raw_provider_name in {"xunfei", "iflytek", "xfyun"}:
|
if provider_name == "openai" and raw_provider_name in {"xunfei", "iflytek", "xfyun"}:
|
||||||
|
|
@ -50,6 +51,8 @@ class BotConfigManager:
|
||||||
provider_cfg: Dict[str, Any] = {
|
provider_cfg: Dict[str, Any] = {
|
||||||
"apiKey": api_key,
|
"apiKey": api_key,
|
||||||
}
|
}
|
||||||
|
if raw_provider_name in {"xunfei", "iflytek", "xfyun", "vllm"}:
|
||||||
|
provider_cfg["dashboardProviderAlias"] = raw_provider_name
|
||||||
if api_base:
|
if api_base:
|
||||||
provider_cfg["apiBase"] = api_base
|
provider_cfg["apiBase"] = api_base
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -652,6 +652,8 @@ def _provider_defaults(provider: str) -> tuple[str, str]:
|
||||||
return "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
return "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
if p in {"xunfei", "iflytek", "xfyun"}:
|
if p in {"xunfei", "iflytek", "xfyun"}:
|
||||||
return "openai", "https://spark-api-open.xf-yun.com/v1"
|
return "openai", "https://spark-api-open.xf-yun.com/v1"
|
||||||
|
if p in {"vllm"}:
|
||||||
|
return "openai", ""
|
||||||
if p in {"kimi", "moonshot"}:
|
if p in {"kimi", "moonshot"}:
|
||||||
return "kimi", "https://api.moonshot.cn/v1"
|
return "kimi", "https://api.moonshot.cn/v1"
|
||||||
if p in {"minimax"}:
|
if p in {"minimax"}:
|
||||||
|
|
@ -1360,7 +1362,10 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
|
||||||
api_key = str(provider_cfg.get("apiKey") or "").strip()
|
api_key = str(provider_cfg.get("apiKey") or "").strip()
|
||||||
api_base = str(provider_cfg.get("apiBase") or "").strip()
|
api_base = str(provider_cfg.get("apiBase") or "").strip()
|
||||||
api_base_lower = api_base.lower()
|
api_base_lower = api_base.lower()
|
||||||
if llm_provider == "openai" and ("spark-api-open.xf-yun.com" in api_base_lower or "xf-yun.com" in api_base_lower):
|
provider_alias = str(provider_cfg.get("dashboardProviderAlias") or "").strip().lower()
|
||||||
|
if llm_provider == "openai" and provider_alias in {"xunfei", "iflytek", "xfyun", "vllm"}:
|
||||||
|
llm_provider = "xunfei" if provider_alias in {"iflytek", "xfyun"} else provider_alias
|
||||||
|
elif llm_provider == "openai" and ("spark-api-open.xf-yun.com" in api_base_lower or "xf-yun.com" in api_base_lower):
|
||||||
llm_provider = "xunfei"
|
llm_provider = "xunfei"
|
||||||
|
|
||||||
soul_md = _read_workspace_md(bot.id, "SOUL.md", DEFAULT_SOUL_MD)
|
soul_md = _read_workspace_md(bot.id, "SOUL.md", DEFAULT_SOUL_MD)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,19 @@ class PlatformUsageSummary(BaseModel):
|
||||||
total_tokens: int
|
total_tokens: int
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformUsageAnalyticsSeries(BaseModel):
|
||||||
|
model: str
|
||||||
|
total_requests: int
|
||||||
|
daily_counts: List[int]
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformUsageAnalytics(BaseModel):
|
||||||
|
window_days: int
|
||||||
|
days: List[str]
|
||||||
|
total_requests: int
|
||||||
|
series: List[PlatformUsageAnalyticsSeries]
|
||||||
|
|
||||||
|
|
||||||
class PlatformUsageResponse(BaseModel):
|
class PlatformUsageResponse(BaseModel):
|
||||||
summary: PlatformUsageSummary
|
summary: PlatformUsageSummary
|
||||||
items: List[PlatformUsageItem]
|
items: List[PlatformUsageItem]
|
||||||
|
|
@ -62,6 +75,7 @@ class PlatformUsageResponse(BaseModel):
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
has_more: bool
|
has_more: bool
|
||||||
|
analytics: PlatformUsageAnalytics
|
||||||
|
|
||||||
|
|
||||||
class PlatformActivityItem(BaseModel):
|
class PlatformActivityItem(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import math
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
@ -32,6 +33,8 @@ from models.platform import BotActivityEvent, BotRequestUsage, PlatformSetting
|
||||||
from schemas.platform import (
|
from schemas.platform import (
|
||||||
LoadingPageSettings,
|
LoadingPageSettings,
|
||||||
PlatformActivityItem,
|
PlatformActivityItem,
|
||||||
|
PlatformUsageAnalytics,
|
||||||
|
PlatformUsageAnalyticsSeries,
|
||||||
PlatformSettingsPayload,
|
PlatformSettingsPayload,
|
||||||
PlatformUsageResponse,
|
PlatformUsageResponse,
|
||||||
PlatformUsageItem,
|
PlatformUsageItem,
|
||||||
|
|
@ -922,9 +925,57 @@ def list_usage(
|
||||||
limit=safe_limit,
|
limit=safe_limit,
|
||||||
offset=safe_offset,
|
offset=safe_offset,
|
||||||
has_more=safe_offset + len(items) < total,
|
has_more=safe_offset + len(items) < total,
|
||||||
|
analytics=_build_usage_analytics(session, bot_id=bot_id),
|
||||||
).model_dump()
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_usage_analytics(
|
||||||
|
session: Session,
|
||||||
|
bot_id: Optional[str] = None,
|
||||||
|
window_days: int = 7,
|
||||||
|
) -> PlatformUsageAnalytics:
|
||||||
|
safe_window_days = max(1, int(window_days or 0))
|
||||||
|
today = _utcnow().date()
|
||||||
|
days = [today - timedelta(days=offset) for offset in range(safe_window_days - 1, -1, -1)]
|
||||||
|
day_keys = [day.isoformat() for day in days]
|
||||||
|
day_labels = [day.strftime("%m-%d") for day in days]
|
||||||
|
first_day = days[0]
|
||||||
|
first_started_at = datetime.combine(first_day, datetime.min.time())
|
||||||
|
|
||||||
|
stmt = select(BotRequestUsage.model, BotRequestUsage.started_at).where(BotRequestUsage.started_at >= first_started_at)
|
||||||
|
if bot_id:
|
||||||
|
stmt = stmt.where(BotRequestUsage.bot_id == bot_id)
|
||||||
|
|
||||||
|
counts_by_model: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||||
|
total_requests = 0
|
||||||
|
for model_name, started_at in session.exec(stmt).all():
|
||||||
|
if not started_at:
|
||||||
|
continue
|
||||||
|
day_key = started_at.date().isoformat()
|
||||||
|
if day_key not in day_keys:
|
||||||
|
continue
|
||||||
|
normalized_model = str(model_name or "").strip() or "Unknown"
|
||||||
|
counts_by_model[normalized_model][day_key] += 1
|
||||||
|
total_requests += 1
|
||||||
|
|
||||||
|
series = [
|
||||||
|
PlatformUsageAnalyticsSeries(
|
||||||
|
model=model_name,
|
||||||
|
total_requests=sum(day_counts.values()),
|
||||||
|
daily_counts=[int(day_counts.get(day_key, 0)) for day_key in day_keys],
|
||||||
|
)
|
||||||
|
for model_name, day_counts in counts_by_model.items()
|
||||||
|
]
|
||||||
|
series.sort(key=lambda item: (-item.total_requests, item.model.lower()))
|
||||||
|
|
||||||
|
return PlatformUsageAnalytics(
|
||||||
|
window_days=safe_window_days,
|
||||||
|
days=day_labels,
|
||||||
|
total_requests=total_requests,
|
||||||
|
series=series,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_activity_events(
|
def list_activity_events(
|
||||||
session: Session,
|
session: Session,
|
||||||
bot_id: Optional[str] = None,
|
bot_id: Optional[str] = None,
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,15 @@ body {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header-bot-mobile {
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-top-bot-mobile {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header-collapsible {
|
.app-header-collapsible {
|
||||||
transition: padding 0.2s ease;
|
transition: padding 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +133,7 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-title-main {
|
.app-title-main {
|
||||||
|
|
@ -131,6 +141,7 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-title-icon {
|
.app-title-icon {
|
||||||
|
|
@ -151,6 +162,17 @@ body {
|
||||||
color: var(--title);
|
color: var(--title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header-top-bot-mobile .app-title-main {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-top-bot-mobile .app-title h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
.app-title p {
|
.app-title p {
|
||||||
margin: 2px 0 0;
|
margin: 2px 0 0;
|
||||||
color: var(--subtitle);
|
color: var(--subtitle);
|
||||||
|
|
@ -181,6 +203,10 @@ body {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-switches-compact-lite {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.switch-compact {
|
.switch-compact {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -208,6 +234,7 @@ body {
|
||||||
color: var(--icon-muted);
|
color: var(--icon-muted);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -232,6 +259,25 @@ body {
|
||||||
color: var(--icon);
|
color: var(--icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-btn-lite {
|
||||||
|
min-width: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-btn-lite:hover {
|
||||||
|
border-color: color-mix(in oklab, var(--brand) 40%, var(--line) 60%);
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-btn-lang-lite {
|
||||||
|
min-width: 42px;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.main-stage {
|
.main-stage {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -2501,11 +2547,97 @@ body {
|
||||||
|
|
||||||
.platform-usage-summary {
|
.platform-usage-summary {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-total {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
justify-items: end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-total strong {
|
||||||
|
font-size: clamp(28px, 4vw, 48px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-total span {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 82%, transparent);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-chip strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-chip span:last-child {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-chip-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-chart {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-chart svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 720px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-chart-grid {
|
||||||
|
stroke: color-mix(in oklab, var(--brand-soft) 34%, var(--line) 66%);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-chart-axis-label {
|
||||||
|
fill: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.platform-usage-table {
|
.platform-usage-table {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -2591,197 +2723,3 @@ body {
|
||||||
max-height: 84vh;
|
max-height: 84vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1400px) {
|
|
||||||
.grid-ops {
|
|
||||||
grid-template-columns: 280px 1fr 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-ops.grid-ops-forced {
|
|
||||||
grid-template-columns: minmax(0, 1fr) 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-ops.grid-ops-compact {
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-summary-grid {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-resource-card {
|
|
||||||
grid-column: span 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1160px) {
|
|
||||||
.grid-2,
|
|
||||||
.grid-ops,
|
|
||||||
.wizard-steps,
|
|
||||||
.wizard-steps-4,
|
|
||||||
.factory-kpi-grid,
|
|
||||||
.summary-grid,
|
|
||||||
.wizard-agent-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-grid,
|
|
||||||
.platform-main-grid,
|
|
||||||
.platform-monitor-grid,
|
|
||||||
.platform-entry-grid,
|
|
||||||
.platform-summary-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-resource-card {
|
|
||||||
grid-column: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-template-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skill-market-admin-layout,
|
|
||||||
.skill-market-card-grid,
|
|
||||||
.skill-market-browser-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skill-market-list-shell {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-template-tabs {
|
|
||||||
max-height: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-usage-head,
|
|
||||||
.platform-usage-row {
|
|
||||||
grid-template-columns: minmax(140px, 1.1fr) minmax(200px, 1.8fr) minmax(160px, 1fr) 70px 70px 70px 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-frame {
|
|
||||||
height: auto;
|
|
||||||
min-height: calc(100vh - 36px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell-compact .app-frame {
|
|
||||||
height: calc(100dvh - 24px);
|
|
||||||
min-height: calc(100dvh - 24px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header-top {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.global-switches {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-shell {
|
|
||||||
min-height: 640px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.grid-ops.grid-ops-compact {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell-compact .grid-ops.grid-ops-compact {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: minmax(0, 1fr);
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-bot-list-panel {
|
|
||||||
min-height: calc(100dvh - 170px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-bot-actions,
|
|
||||||
.platform-image-row,
|
|
||||||
.platform-activity-row {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-selected-bot-headline {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-selected-bot-head {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-selected-bot-actions {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-selected-bot-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-resource-meter {
|
|
||||||
grid-template-columns: 24px minmax(0, 1fr) 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-usage-head {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-usage-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-selected-bot-last-row,
|
|
||||||
.platform-settings-pager,
|
|
||||||
.platform-usage-pager,
|
|
||||||
.platform-template-header,
|
|
||||||
.skill-market-admin-toolbar,
|
|
||||||
.skill-market-browser-toolbar,
|
|
||||||
.skill-market-pager,
|
|
||||||
.skill-market-page-info-card,
|
|
||||||
.skill-market-page-info-main,
|
|
||||||
.skill-market-editor-head,
|
|
||||||
.skill-market-card-top,
|
|
||||||
.skill-market-card-footer,
|
|
||||||
.row-actions-inline {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-compact-sheet-card {
|
|
||||||
max-height: 90dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-compact-sheet-body {
|
|
||||||
max-height: calc(90dvh - 60px);
|
|
||||||
padding: 0 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skill-market-list-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skill-market-drawer {
|
|
||||||
position: fixed;
|
|
||||||
top: 84px;
|
|
||||||
right: 12px;
|
|
||||||
bottom: 12px;
|
|
||||||
width: min(460px, calc(100vw - 24px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-route-crumb {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,379 @@
|
||||||
|
.app-bot-panel-menu-btn {
|
||||||
|
min-width: 34px;
|
||||||
|
width: 34px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--icon);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-menu-btn:hover {
|
||||||
|
border-color: color-mix(in oklab, var(--brand) 40%, var(--line) 60%);
|
||||||
|
background: color-mix(in oklab, var(--brand-soft) 18%, var(--panel-soft) 82%);
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 95;
|
||||||
|
background: rgba(9, 15, 28, 0.36);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: min(380px, calc(100vw - 24px));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 28px 24px 22px;
|
||||||
|
min-height: 240px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 88% 15%, rgba(255, 255, 255, 0.16) 0, rgba(255, 255, 255, 0.16) 120px, transparent 121px),
|
||||||
|
linear-gradient(180deg, #3d63f6 0%, #2f58ef 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom: 1px solid color-mix(in oklab, var(--line) 24%, rgba(255, 255, 255, 0.18) 76%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-title {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: clamp(30px, 6vw, 38px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.14;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-subtitle {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #ffffff;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-avatar {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.2);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-avatar-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
align-content: start;
|
||||||
|
padding: 14px 0;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #344054;
|
||||||
|
padding: 18px 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-item.is-active {
|
||||||
|
background: rgba(61, 99, 246, 0.08);
|
||||||
|
color: #3d63f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-item svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.grid-ops {
|
||||||
|
grid-template-columns: 280px 1fr 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-ops.grid-ops-forced {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-ops.grid-ops-compact {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-summary-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-resource-card {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1160px) {
|
||||||
|
.grid-2,
|
||||||
|
.grid-ops,
|
||||||
|
.wizard-steps,
|
||||||
|
.wizard-steps-4,
|
||||||
|
.factory-kpi-grid,
|
||||||
|
.summary-grid,
|
||||||
|
.wizard-agent-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-grid,
|
||||||
|
.platform-main-grid,
|
||||||
|
.platform-monitor-grid,
|
||||||
|
.platform-entry-grid,
|
||||||
|
.platform-summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-resource-card {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-template-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-market-admin-layout,
|
||||||
|
.skill-market-card-grid,
|
||||||
|
.skill-market-browser-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-market-list-shell {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-template-tabs {
|
||||||
|
max-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-usage-head,
|
||||||
|
.platform-usage-row {
|
||||||
|
grid-template-columns: minmax(140px, 1.1fr) minmax(200px, 1.8fr) minmax(160px, 1fr) 70px 70px 70px 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-frame {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - 36px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-compact .app-frame {
|
||||||
|
height: calc(100dvh - 24px);
|
||||||
|
min-height: calc(100dvh - 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-top {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-top-bot-mobile {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-switches {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-switches-compact-lite {
|
||||||
|
width: auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-shell {
|
||||||
|
min-height: 640px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.grid-ops.grid-ops-compact {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-compact .grid-ops.grid-ops-compact {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-bot-list-panel {
|
||||||
|
min-height: calc(100dvh - 170px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-bot-actions,
|
||||||
|
.platform-image-row,
|
||||||
|
.platform-activity-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-selected-bot-headline {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-selected-bot-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-selected-bot-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-selected-bot-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-resource-meter {
|
||||||
|
grid-template-columns: 24px minmax(0, 1fr) 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-usage-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-model-analytics-total {
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-usage-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-selected-bot-last-row,
|
||||||
|
.platform-settings-pager,
|
||||||
|
.platform-usage-pager,
|
||||||
|
.platform-template-header,
|
||||||
|
.skill-market-admin-toolbar,
|
||||||
|
.skill-market-browser-toolbar,
|
||||||
|
.skill-market-pager,
|
||||||
|
.skill-market-page-info-card,
|
||||||
|
.skill-market-page-info-main,
|
||||||
|
.skill-market-editor-head,
|
||||||
|
.skill-market-card-top,
|
||||||
|
.skill-market-card-footer,
|
||||||
|
.row-actions-inline {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-compact-sheet-card {
|
||||||
|
max-height: 90dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-compact-sheet-body {
|
||||||
|
max-height: calc(90dvh - 60px);
|
||||||
|
padding: 0 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-market-list-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-market-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 84px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: min(460px, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-route-crumb {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer {
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: min(360px, calc(100vw - 20px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-hero {
|
||||||
|
min-height: 216px;
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-title {
|
||||||
|
font-size: clamp(26px, 8vw, 34px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bot-panel-drawer-item {
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState, type ReactElement } from 'react';
|
import { useEffect, useState, type ReactElement } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { ChevronDown, ChevronUp, MoonStar, SunMedium } from 'lucide-react';
|
import { Activity, ChevronDown, ChevronUp, Menu, MessageSquareText, MoonStar, SunMedium, X } from 'lucide-react';
|
||||||
import { useAppStore } from './store/appStore';
|
import { useAppStore } from './store/appStore';
|
||||||
import { useBotsSync } from './hooks/useBotsSync';
|
import { useBotsSync } from './hooks/useBotsSync';
|
||||||
import { APP_ENDPOINTS } from './config/env';
|
import { APP_ENDPOINTS } from './config/env';
|
||||||
|
|
@ -16,6 +16,7 @@ import { PlatformDashboardPage } from './modules/platform/PlatformDashboardPage'
|
||||||
import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal';
|
import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal';
|
||||||
import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute';
|
import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import './App.h5.css';
|
||||||
|
|
||||||
const defaultLoadingPage = {
|
const defaultLoadingPage = {
|
||||||
title: 'Dashboard Nanobot',
|
title: 'Dashboard Nanobot',
|
||||||
|
|
@ -23,6 +24,8 @@ const defaultLoadingPage = {
|
||||||
description: '请稍候,正在加载 Bot 平台数据。',
|
description: '请稍候,正在加载 Bot 平台数据。',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CompactBotPanelTab = 'chat' | 'runtime';
|
||||||
|
|
||||||
function AuthenticatedApp() {
|
function AuthenticatedApp() {
|
||||||
const route = useAppRoute();
|
const route = useAppRoute();
|
||||||
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
|
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
|
||||||
|
|
@ -32,6 +35,8 @@ function AuthenticatedApp() {
|
||||||
return window.matchMedia('(max-width: 980px)').matches;
|
return window.matchMedia('(max-width: 980px)').matches;
|
||||||
});
|
});
|
||||||
const [headerCollapsed, setHeaderCollapsed] = useState(false);
|
const [headerCollapsed, setHeaderCollapsed] = useState(false);
|
||||||
|
const [botPanelDrawerOpen, setBotPanelDrawerOpen] = useState(false);
|
||||||
|
const [botCompactPanelTab, setBotCompactPanelTab] = useState<CompactBotPanelTab>('chat');
|
||||||
const [singleBotPassword, setSingleBotPassword] = useState('');
|
const [singleBotPassword, setSingleBotPassword] = useState('');
|
||||||
const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
|
const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
|
||||||
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
|
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
|
||||||
|
|
@ -59,13 +64,22 @@ function AuthenticatedApp() {
|
||||||
const compactMode = readCompactModeFromUrl() || viewportCompact;
|
const compactMode = readCompactModeFromUrl() || viewportCompact;
|
||||||
const isCompactShell = compactMode;
|
const isCompactShell = compactMode;
|
||||||
const hideHeader = route.kind === 'dashboard' && compactMode;
|
const hideHeader = route.kind === 'dashboard' && compactMode;
|
||||||
|
const showBotPanelDrawerEntry = route.kind === 'bot' && compactMode;
|
||||||
|
const allowHeaderCollapse = isCompactShell && !showBotPanelDrawerEntry;
|
||||||
const forcedBot = forcedBotId ? activeBots[forcedBotId] : undefined;
|
const forcedBot = forcedBotId ? activeBots[forcedBotId] : undefined;
|
||||||
|
const forcedBotName = String(forcedBot?.name || '').trim();
|
||||||
|
const forcedBotIdLabel = String(forcedBotId || '').trim();
|
||||||
|
const botHeaderTitle = forcedBotName || defaultLoadingPage.title;
|
||||||
|
const botHeaderSubtitle = forcedBotIdLabel || defaultLoadingPage.title;
|
||||||
|
const botDocumentTitle = [forcedBotName, forcedBotIdLabel].filter(Boolean).join(' ') || defaultLoadingPage.title;
|
||||||
const shouldPromptSingleBotPassword = Boolean(
|
const shouldPromptSingleBotPassword = Boolean(
|
||||||
route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked,
|
route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked,
|
||||||
);
|
);
|
||||||
const headerTitle =
|
const headerTitle =
|
||||||
route.kind === 'bot'
|
showBotPanelDrawerEntry
|
||||||
? (forcedBot?.name || defaultLoadingPage.title)
|
? (botCompactPanelTab === 'runtime' ? t.botPanels.runtime : t.botPanels.chat)
|
||||||
|
: route.kind === 'bot'
|
||||||
|
? botHeaderTitle
|
||||||
: route.kind === 'dashboard-skills'
|
: route.kind === 'dashboard-skills'
|
||||||
? (locale === 'zh' ? '技能市场管理' : 'Skill Marketplace')
|
? (locale === 'zh' ? '技能市场管理' : 'Skill Marketplace')
|
||||||
: t.title;
|
: t.title;
|
||||||
|
|
@ -79,9 +93,8 @@ function AuthenticatedApp() {
|
||||||
document.title = `${t.title} - ${locale === 'zh' ? '技能市场' : 'Skill Marketplace'}`;
|
document.title = `${t.title} - ${locale === 'zh' ? '技能市场' : 'Skill Marketplace'}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const botName = String(forcedBot?.name || '').trim();
|
document.title = `${t.title} - ${botDocumentTitle}`;
|
||||||
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`;
|
}, [botDocumentTitle, locale, route.kind, t.title]);
|
||||||
}, [forcedBot?.name, forcedBotId, locale, route.kind, t.title]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSingleBotUnlocked(false);
|
setSingleBotUnlocked(false);
|
||||||
|
|
@ -89,6 +102,13 @@ function AuthenticatedApp() {
|
||||||
setSingleBotPasswordError('');
|
setSingleBotPasswordError('');
|
||||||
}, [forcedBotId]);
|
}, [forcedBotId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showBotPanelDrawerEntry) {
|
||||||
|
setBotPanelDrawerOpen(false);
|
||||||
|
setBotCompactPanelTab('chat');
|
||||||
|
}
|
||||||
|
}, [forcedBotId, showBotPanelDrawerEntry]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return;
|
if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return;
|
||||||
const stored = getBotAccessPassword(forcedBotId);
|
const stored = getBotAccessPassword(forcedBotId);
|
||||||
|
|
@ -141,33 +161,57 @@ function AuthenticatedApp() {
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const botPanelLabels = t.botPanels;
|
||||||
|
const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingPage.title;
|
||||||
|
const drawerBotId = String(forcedBotId || '').trim() || '-';
|
||||||
|
const nextTheme = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
const nextLocale = locale === 'zh' ? 'en' : 'zh';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
|
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
|
||||||
<div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}>
|
<div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}>
|
||||||
{!hideHeader ? (
|
{!hideHeader ? (
|
||||||
<header
|
<header
|
||||||
className={`app-header ${isCompactShell ? 'app-header-collapsible' : ''} ${isCompactShell && headerCollapsed ? 'is-collapsed' : ''}`}
|
className={`app-header ${allowHeaderCollapse ? 'app-header-collapsible' : ''} ${allowHeaderCollapse && headerCollapsed ? 'is-collapsed' : ''} ${showBotPanelDrawerEntry ? 'app-header-bot-mobile' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isCompactShell && headerCollapsed) setHeaderCollapsed(false);
|
if (allowHeaderCollapse && headerCollapsed) setHeaderCollapsed(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="row-between app-header-top">
|
<div className={`row-between app-header-top ${showBotPanelDrawerEntry ? 'app-header-top-bot-mobile' : ''}`}>
|
||||||
<div className="app-title">
|
<div className="app-title">
|
||||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
|
{showBotPanelDrawerEntry ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="app-bot-panel-menu-btn"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setBotPanelDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
aria-label={botPanelLabels.title}
|
||||||
|
title={botPanelLabels.title}
|
||||||
|
>
|
||||||
|
<Menu size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{!showBotPanelDrawerEntry ? (
|
||||||
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
|
||||||
|
) : null}
|
||||||
<div className="app-title-main">
|
<div className="app-title-main">
|
||||||
<h1>{headerTitle}</h1>
|
<h1>{headerTitle}</h1>
|
||||||
{route.kind === 'dashboard-skills' ? (
|
{!showBotPanelDrawerEntry && route.kind === 'dashboard-skills' ? (
|
||||||
<button type="button" className="app-route-subtitle app-route-crumb" onClick={navigateToDashboard}>
|
<button type="button" className="app-route-subtitle app-route-crumb" onClick={navigateToDashboard}>
|
||||||
{locale === 'zh' ? '平台总览' : 'Platform Overview'}
|
{locale === 'zh' ? '平台总览' : 'Platform Overview'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : !showBotPanelDrawerEntry ? (
|
||||||
<div className="app-route-subtitle">
|
<div className="app-route-subtitle">
|
||||||
{route.kind === 'dashboard'
|
{route.kind === 'dashboard'
|
||||||
? (locale === 'zh' ? '平台总览' : 'Platform overview')
|
? (locale === 'zh' ? '平台总览' : 'Platform overview')
|
||||||
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
|
: route.kind === 'bot'
|
||||||
|
? botHeaderSubtitle
|
||||||
|
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
{isCompactShell ? (
|
{allowHeaderCollapse ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="app-header-toggle-inline"
|
className="app-header-toggle-inline"
|
||||||
|
|
@ -185,7 +229,28 @@ function AuthenticatedApp() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="app-header-actions">
|
<div className="app-header-actions">
|
||||||
{!headerCollapsed ? (
|
{showBotPanelDrawerEntry ? (
|
||||||
|
<div className="global-switches global-switches-compact-lite">
|
||||||
|
<LucentTooltip content={nextTheme === 'light' ? t.light : t.dark}>
|
||||||
|
<button
|
||||||
|
className="switch-btn switch-btn-lite"
|
||||||
|
onClick={() => setTheme(nextTheme)}
|
||||||
|
aria-label={nextTheme === 'light' ? t.light : t.dark}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <MoonStar size={14} /> : <SunMedium size={14} />}
|
||||||
|
</button>
|
||||||
|
</LucentTooltip>
|
||||||
|
<LucentTooltip content={nextLocale === 'zh' ? t.zh : t.en}>
|
||||||
|
<button
|
||||||
|
className="switch-btn switch-btn-lite switch-btn-lang-lite"
|
||||||
|
onClick={() => setLocale(nextLocale)}
|
||||||
|
aria-label={nextLocale === 'zh' ? t.zh : t.en}
|
||||||
|
>
|
||||||
|
<span>{locale === 'zh' ? 'ZH' : 'EN'}</span>
|
||||||
|
</button>
|
||||||
|
</LucentTooltip>
|
||||||
|
</div>
|
||||||
|
) : !headerCollapsed ? (
|
||||||
<div className="global-switches">
|
<div className="global-switches">
|
||||||
<div className="switch-compact">
|
<div className="switch-compact">
|
||||||
<LucentTooltip content={t.dark}>
|
<LucentTooltip content={t.dark}>
|
||||||
|
|
@ -225,11 +290,71 @@ function AuthenticatedApp() {
|
||||||
) : route.kind === 'dashboard-skills' ? (
|
) : route.kind === 'dashboard-skills' ? (
|
||||||
<SkillMarketManagerPage isZh={locale === 'zh'} />
|
<SkillMarketManagerPage isZh={locale === 'zh'} />
|
||||||
) : (
|
) : (
|
||||||
<BotHomePage botId={forcedBotId} compactMode={compactMode} />
|
<BotHomePage
|
||||||
|
botId={forcedBotId}
|
||||||
|
compactMode={compactMode}
|
||||||
|
compactPanelTab={botCompactPanelTab}
|
||||||
|
onCompactPanelTabChange={setBotCompactPanelTab}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showBotPanelDrawerEntry && botPanelDrawerOpen ? (
|
||||||
|
<div className="app-bot-panel-drawer-mask" onClick={() => setBotPanelDrawerOpen(false)}>
|
||||||
|
<aside
|
||||||
|
className="app-bot-panel-drawer"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
aria-label={botPanelLabels.title}
|
||||||
|
>
|
||||||
|
<div className="app-bot-panel-drawer-hero">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="app-bot-panel-drawer-close"
|
||||||
|
onClick={() => setBotPanelDrawerOpen(false)}
|
||||||
|
aria-label={t.close}
|
||||||
|
title={t.close}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="app-bot-panel-drawer-avatar">
|
||||||
|
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-bot-panel-drawer-avatar-icon" />
|
||||||
|
</div>
|
||||||
|
<div className="app-bot-panel-drawer-title">{drawerBotName}</div>
|
||||||
|
<div className="app-bot-panel-drawer-subtitle">{drawerBotId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="app-bot-panel-drawer-list" role="tablist" aria-label={botPanelLabels.title}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`app-bot-panel-drawer-item ${botCompactPanelTab === 'chat' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setBotCompactPanelTab('chat');
|
||||||
|
setBotPanelDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={botCompactPanelTab === 'chat'}
|
||||||
|
>
|
||||||
|
<MessageSquareText size={16} />
|
||||||
|
<span>{botPanelLabels.chat}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`app-bot-panel-drawer-item ${botCompactPanelTab === 'runtime' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setBotCompactPanelTab('runtime');
|
||||||
|
setBotPanelDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={botCompactPanelTab === 'runtime'}
|
||||||
|
>
|
||||||
|
<Activity size={16} />
|
||||||
|
<span>{botPanelLabels.runtime}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{shouldPromptSingleBotPassword ? (
|
{shouldPromptSingleBotPassword ? (
|
||||||
<div className="modal-mask app-modal-mask">
|
<div className="modal-mask app-modal-mask">
|
||||||
<div className="app-login-card" onClick={(event) => event.stopPropagation()}>
|
<div className="app-login-card" onClick={(event) => event.stopPropagation()}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||||
|
import { Search, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProtectedSearchInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
clearTitle: string;
|
||||||
|
searchTitle: string;
|
||||||
|
className?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
debounceMs?: number;
|
||||||
|
onClear?: () => void;
|
||||||
|
onSearchAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinClassNames(...parts: Array<string | undefined | false>) {
|
||||||
|
return parts.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedSearchInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
ariaLabel,
|
||||||
|
clearTitle,
|
||||||
|
searchTitle,
|
||||||
|
className,
|
||||||
|
inputClassName,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
disabled = false,
|
||||||
|
autoFocus = false,
|
||||||
|
debounceMs = 0,
|
||||||
|
onClear,
|
||||||
|
onSearchAction,
|
||||||
|
}: ProtectedSearchInputProps) {
|
||||||
|
const [draftValue, setDraftValue] = useState(value);
|
||||||
|
const latestExternalValueRef = useRef(value);
|
||||||
|
const debounceTimerRef = useRef<number | null>(null);
|
||||||
|
const currentValue = debounceMs > 0 ? draftValue : value;
|
||||||
|
const hasValue = currentValue.trim().length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceMs <= 0) return;
|
||||||
|
if (value === latestExternalValueRef.current) return;
|
||||||
|
latestExternalValueRef.current = value;
|
||||||
|
setDraftValue(value);
|
||||||
|
}, [debounceMs, value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceMs > 0) return;
|
||||||
|
setDraftValue(value);
|
||||||
|
latestExternalValueRef.current = value;
|
||||||
|
}, [debounceMs, value]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flushValue = (nextValue: string) => {
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
latestExternalValueRef.current = nextValue;
|
||||||
|
onChange(nextValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (debounceMs > 0) {
|
||||||
|
flushValue(draftValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleButtonClick = () => {
|
||||||
|
if (hasValue) {
|
||||||
|
if (debounceMs > 0) {
|
||||||
|
setDraftValue('');
|
||||||
|
latestExternalValueRef.current = '';
|
||||||
|
}
|
||||||
|
if (onClear) {
|
||||||
|
onClear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flushValue('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSearchAction?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={joinClassNames('ops-searchbar', 'ops-searchbar-form', className)} autoComplete="off" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
className="ops-autofill-trap"
|
||||||
|
type="text"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="username"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="ops-autofill-trap"
|
||||||
|
type="password"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={joinClassNames('input', 'ops-search-input', 'ops-search-input-with-icon', inputClassName)}
|
||||||
|
type="search"
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
if (debounceMs > 0) {
|
||||||
|
setDraftValue(nextValue);
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
debounceTimerRef.current = window.setTimeout(() => {
|
||||||
|
latestExternalValueRef.current = nextValue;
|
||||||
|
onChange(nextValue);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}, debounceMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(nextValue);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
inputMode="search"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
|
name={name}
|
||||||
|
id={id}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-search-inline-btn"
|
||||||
|
onClick={handleButtonClick}
|
||||||
|
title={hasValue ? clearTitle : searchTitle}
|
||||||
|
aria-label={hasValue ? clearTitle : searchTitle}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{hasValue ? <X size={14} /> : <Search size={14} />}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,13 @@ export const appEn = {
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
expandHeader: 'Expand header',
|
expandHeader: 'Expand header',
|
||||||
collapseHeader: 'Collapse header',
|
collapseHeader: 'Collapse header',
|
||||||
|
botPanels: {
|
||||||
|
title: 'Panel Switcher',
|
||||||
|
subtitle: 'Switch the active bot panel',
|
||||||
|
trigger: 'Panels',
|
||||||
|
chat: 'Chat Panel',
|
||||||
|
runtime: 'Runtime Panel',
|
||||||
|
},
|
||||||
nav: {
|
nav: {
|
||||||
images: { title: 'Image Factory', subtitle: 'Manage registered images' },
|
images: { title: 'Image Factory', subtitle: 'Manage registered images' },
|
||||||
onboarding: { title: 'Creation Wizard', subtitle: 'Create bot step-by-step' },
|
onboarding: { title: 'Creation Wizard', subtitle: 'Create bot step-by-step' },
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ export const appZhCn = {
|
||||||
close: '关闭',
|
close: '关闭',
|
||||||
expandHeader: '展开头部',
|
expandHeader: '展开头部',
|
||||||
collapseHeader: '收起头部',
|
collapseHeader: '收起头部',
|
||||||
|
botPanels: {
|
||||||
|
title: '面板切换',
|
||||||
|
subtitle: '切换 Bot 页当前面板',
|
||||||
|
trigger: '面板',
|
||||||
|
chat: '对话面板',
|
||||||
|
runtime: '运行面板',
|
||||||
|
},
|
||||||
nav: {
|
nav: {
|
||||||
images: { title: '镜像工厂', subtitle: '管理已登记镜像' },
|
images: { title: '镜像工厂', subtitle: '管理已登记镜像' },
|
||||||
onboarding: { title: '创建向导', subtitle: '分步创建 Bot' },
|
onboarding: { title: '创建向导', subtitle: '分步创建 Bot' },
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,17 @@ import { BotDashboardModule } from '../dashboard/BotDashboardModule';
|
||||||
interface BotHomePageProps {
|
interface BotHomePageProps {
|
||||||
botId: string;
|
botId: string;
|
||||||
compactMode: boolean;
|
compactMode: boolean;
|
||||||
|
compactPanelTab?: 'chat' | 'runtime';
|
||||||
|
onCompactPanelTabChange?: (tab: 'chat' | 'runtime') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BotHomePage({ botId, compactMode }: BotHomePageProps) {
|
export function BotHomePage({ botId, compactMode, compactPanelTab, onCompactPanelTabChange }: BotHomePageProps) {
|
||||||
return <BotDashboardModule forcedBotId={botId} compactMode={compactMode} />;
|
return (
|
||||||
|
<BotDashboardModule
|
||||||
|
forcedBotId={botId}
|
||||||
|
compactMode={compactMode}
|
||||||
|
compactPanelTab={compactPanelTab}
|
||||||
|
onCompactPanelTabChange={onCompactPanelTabChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,104 +28,6 @@
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-compact-fab-stack {
|
|
||||||
position: fixed;
|
|
||||||
right: 14px;
|
|
||||||
bottom: 14px;
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
z-index: 85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops-compact-fab-switch {
|
|
||||||
position: relative;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid color-mix(in oklab, var(--brand) 58%, var(--line) 42%);
|
|
||||||
background: color-mix(in oklab, var(--panel) 70%, var(--brand-soft) 30%);
|
|
||||||
color: var(--icon);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow:
|
|
||||||
0 10px 24px rgba(9, 15, 28, 0.42),
|
|
||||||
0 0 0 2px color-mix(in oklab, var(--brand) 22%, transparent);
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: visible;
|
|
||||||
transform: translateY(0);
|
|
||||||
animation: ops-fab-float 2.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops-compact-fab-switch::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: -7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 2px solid color-mix(in oklab, var(--brand) 60%, transparent);
|
|
||||||
opacity: 0.45;
|
|
||||||
animation: ops-fab-pulse 1.8s ease-out infinite;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops-compact-fab-switch::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: -1px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 50% 50%, color-mix(in oklab, var(--brand) 20%, transparent) 0%, transparent 72%);
|
|
||||||
opacity: 0.9;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops-compact-fab-switch.is-chat {
|
|
||||||
box-shadow:
|
|
||||||
0 10px 24px rgba(9, 15, 28, 0.42),
|
|
||||||
0 0 18px color-mix(in oklab, #5c98ff 60%, transparent),
|
|
||||||
0 0 0 2px color-mix(in oklab, #5c98ff 35%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops-compact-fab-switch.is-runtime {
|
|
||||||
box-shadow:
|
|
||||||
0 10px 24px rgba(9, 15, 28, 0.42),
|
|
||||||
0 0 18px color-mix(in oklab, #40d6c3 62%, transparent),
|
|
||||||
0 0 0 2px color-mix(in oklab, #40d6c3 38%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops-compact-fab-switch.is-runtime::before {
|
|
||||||
border-color: color-mix(in oklab, #40d6c3 62%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops-compact-fab-switch:hover {
|
|
||||||
transform: translateY(-1px) scale(1.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ops-fab-pulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.92);
|
|
||||||
opacity: 0.62;
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
transform: scale(1.15);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1.15);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ops-fab-float {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops-list-actions {
|
.ops-list-actions {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -142,6 +44,22 @@
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ops-searchbar-form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-autofill-trap {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.ops-search-input {
|
.ops-search-input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type ImgHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Command, Copy, Download, EllipsisVertical, ExternalLink, Eye, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
|
import { ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Command, Copy, Download, EllipsisVertical, ExternalLink, Eye, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
|
|
@ -19,9 +19,19 @@ import { dashboardEn } from '../../i18n/dashboard.en';
|
||||||
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
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 { ProtectedSearchInput } from '../../components/ProtectedSearchInput';
|
||||||
import { MarkdownLiteEditor } from '../../components/markdown/MarkdownLiteEditor';
|
import { MarkdownLiteEditor } from '../../components/markdown/MarkdownLiteEditor';
|
||||||
import { PasswordInput } from '../../components/PasswordInput';
|
import { PasswordInput } from '../../components/PasswordInput';
|
||||||
import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel';
|
import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel';
|
||||||
|
import {
|
||||||
|
buildWorkspaceDownloadHref,
|
||||||
|
buildWorkspacePreviewHref,
|
||||||
|
buildWorkspaceRawHref,
|
||||||
|
createWorkspaceMarkdownComponents,
|
||||||
|
decorateWorkspacePathsForMarkdown,
|
||||||
|
normalizeDashboardAttachmentPath,
|
||||||
|
resolveWorkspaceDocumentPath,
|
||||||
|
} from './shared/workspaceMarkdown';
|
||||||
import type { BotSkillMarketItem } from '../platform/types';
|
import type { BotSkillMarketItem } from '../platform/types';
|
||||||
import { SkillMarketInstallModal } from './components/SkillMarketInstallModal';
|
import { SkillMarketInstallModal } from './components/SkillMarketInstallModal';
|
||||||
import {
|
import {
|
||||||
|
|
@ -36,6 +46,8 @@ interface BotDashboardModuleProps {
|
||||||
onOpenImageFactory?: () => void;
|
onOpenImageFactory?: () => void;
|
||||||
forcedBotId?: string;
|
forcedBotId?: string;
|
||||||
compactMode?: boolean;
|
compactMode?: boolean;
|
||||||
|
compactPanelTab?: CompactPanelTab;
|
||||||
|
onCompactPanelTabChange?: (tab: CompactPanelTab) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
|
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
|
||||||
|
|
@ -414,6 +426,13 @@ const providerPresets: Record<string, { model: string; apiBase?: string; note: {
|
||||||
en: 'OpenAI native endpoint',
|
en: 'OpenAI native endpoint',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
vllm: {
|
||||||
|
model: 'Qwen/Qwen2.5-7B-Instruct',
|
||||||
|
note: {
|
||||||
|
'zh-cn': 'vLLM(OpenAI 兼容)接口,请填写你的部署地址,例如 http://127.0.0.1:8000/v1',
|
||||||
|
en: 'vLLM (OpenAI-compatible) endpoint. Fill in your deployment URL, for example http://127.0.0.1:8000/v1',
|
||||||
|
},
|
||||||
|
},
|
||||||
deepseek: {
|
deepseek: {
|
||||||
model: 'deepseek-chat',
|
model: 'deepseek-chat',
|
||||||
note: {
|
note: {
|
||||||
|
|
@ -710,28 +729,6 @@ function workspaceFileAction(
|
||||||
return 'unsupported';
|
return 'unsupported';
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/';
|
|
||||||
const WORKSPACE_ABS_PATH_PATTERN =
|
|
||||||
/\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.[a-z0-9][a-z0-9._+-]{0,31}\b/gi;
|
|
||||||
const WORKSPACE_RELATIVE_PATH_PATTERN =
|
|
||||||
/(^|[\s(\[])(\/[^\n\r<>"'`)\]]+?\.[a-z0-9][a-z0-9._+-]{0,31})(?![A-Za-z0-9_./-])/gim;
|
|
||||||
|
|
||||||
function buildWorkspaceLink(path: string) {
|
|
||||||
return `${WORKSPACE_LINK_PREFIX}${encodeURIComponent(path)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWorkspaceLink(href: string): string | null {
|
|
||||||
const link = String(href || '').trim();
|
|
||||||
if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null;
|
|
||||||
const encoded = link.slice(WORKSPACE_LINK_PREFIX.length);
|
|
||||||
try {
|
|
||||||
const decoded = decodeURIComponent(encoded || '').trim();
|
|
||||||
return decoded || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWorkspacePathSegments(pathRaw: string, keyPrefix: string): ReactNode[] {
|
function renderWorkspacePathSegments(pathRaw: string, keyPrefix: string): ReactNode[] {
|
||||||
const path = String(pathRaw || '');
|
const path = String(pathRaw || '');
|
||||||
if (!path) return ['-'];
|
if (!path) return ['-'];
|
||||||
|
|
@ -765,96 +762,6 @@ const MARKDOWN_SANITIZE_SCHEMA = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveWorkspaceDocumentPath(targetRaw: string, baseFilePath?: string): string | null {
|
|
||||||
const target = String(targetRaw || '').trim();
|
|
||||||
if (!target || target.startsWith('#')) return null;
|
|
||||||
const linkedPath = parseWorkspaceLink(target);
|
|
||||||
if (linkedPath) return linkedPath;
|
|
||||||
if (target.startsWith('/root/.nanobot/workspace/')) {
|
|
||||||
return normalizeDashboardAttachmentPath(target);
|
|
||||||
}
|
|
||||||
const lower = target.toLowerCase();
|
|
||||||
if (
|
|
||||||
lower.startsWith('blob:') ||
|
|
||||||
lower.startsWith('data:') ||
|
|
||||||
lower.startsWith('http://') ||
|
|
||||||
lower.startsWith('https://') ||
|
|
||||||
lower.startsWith('javascript:') ||
|
|
||||||
lower.startsWith('mailto:') ||
|
|
||||||
lower.startsWith('tel:') ||
|
|
||||||
target.startsWith('//')
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedBase = normalizeDashboardAttachmentPath(baseFilePath || '');
|
|
||||||
if (!normalizedBase) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const baseUrl = new URL(`https://workspace.local/${normalizedBase}`);
|
|
||||||
const resolvedUrl = new URL(target, baseUrl);
|
|
||||||
if (resolvedUrl.origin !== 'https://workspace.local') return null;
|
|
||||||
try {
|
|
||||||
return normalizeDashboardAttachmentPath(decodeURIComponent(resolvedUrl.pathname));
|
|
||||||
} catch {
|
|
||||||
return normalizeDashboardAttachmentPath(resolvedUrl.pathname);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decorateWorkspacePathsInPlainChunk(source: string): string {
|
|
||||||
if (!source) return source;
|
|
||||||
const protectedLinks: string[] = [];
|
|
||||||
const withProtectedAbsoluteLinks = source.replace(WORKSPACE_ABS_PATH_PATTERN, (fullPath) => {
|
|
||||||
const normalized = normalizeDashboardAttachmentPath(fullPath);
|
|
||||||
if (!normalized) return fullPath;
|
|
||||||
const token = `@@WS_PATH_LINK_${protectedLinks.length}@@`;
|
|
||||||
protectedLinks.push(`[${fullPath}](${buildWorkspaceLink(normalized)})`);
|
|
||||||
return token;
|
|
||||||
});
|
|
||||||
const withRelativeLinks = withProtectedAbsoluteLinks.replace(
|
|
||||||
WORKSPACE_RELATIVE_PATH_PATTERN,
|
|
||||||
(full, prefix: string, rawPath: string) => {
|
|
||||||
const normalized = normalizeDashboardAttachmentPath(rawPath);
|
|
||||||
if (!normalized) return full;
|
|
||||||
return `${prefix}[${rawPath}](${buildWorkspaceLink(normalized)})`;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return withRelativeLinks.replace(/@@WS_PATH_LINK_(\d+)@@/g, (_full, idxRaw: string) => {
|
|
||||||
const idx = Number(idxRaw);
|
|
||||||
if (!Number.isFinite(idx) || idx < 0 || idx >= protectedLinks.length) return String(_full || '');
|
|
||||||
return protectedLinks[idx];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function decorateWorkspacePathsForMarkdown(text: string) {
|
|
||||||
const source = String(text || '');
|
|
||||||
if (!source) return source;
|
|
||||||
|
|
||||||
// Keep existing Markdown links unchanged; only decorate plain text segments.
|
|
||||||
const markdownLinkPattern = /\[[^\]]*?\]\((?:[^)(]|\([^)(]*\))*\)/g;
|
|
||||||
let result = '';
|
|
||||||
let last = 0;
|
|
||||||
let match = markdownLinkPattern.exec(source);
|
|
||||||
while (match) {
|
|
||||||
const idx = Number(match.index || 0);
|
|
||||||
if (idx > last) {
|
|
||||||
result += decorateWorkspacePathsInPlainChunk(source.slice(last, idx));
|
|
||||||
}
|
|
||||||
result += match[0];
|
|
||||||
last = idx + match[0].length;
|
|
||||||
match = markdownLinkPattern.exec(source);
|
|
||||||
}
|
|
||||||
if (last < source.length) {
|
|
||||||
result += decorateWorkspacePathsInPlainChunk(source.slice(last));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAttachmentPaths(raw: unknown): string[] {
|
function normalizeAttachmentPaths(raw: unknown): string[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw
|
return raw
|
||||||
|
|
@ -862,18 +769,6 @@ function normalizeAttachmentPaths(raw: unknown): string[] {
|
||||||
.filter((v) => v.length > 0);
|
.filter((v) => v.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDashboardAttachmentPath(path: string): string {
|
|
||||||
const v = String(path || '')
|
|
||||||
.trim()
|
|
||||||
.replace(/\\/g, '/')
|
|
||||||
.replace(/^['"`([<{]+/, '')
|
|
||||||
.replace(/['"`)\]>}.,,。!?;:]+$/, '');
|
|
||||||
if (!v) return '';
|
|
||||||
const prefix = '/root/.nanobot/workspace/';
|
|
||||||
if (v.startsWith(prefix)) return v.slice(prefix.length);
|
|
||||||
return v.replace(/^\/+/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMPOSER_DRAFT_STORAGE_PREFIX = 'nanobot-dashboard-composer-draft:v1:';
|
const COMPOSER_DRAFT_STORAGE_PREFIX = 'nanobot-dashboard-composer-draft:v1:';
|
||||||
|
|
||||||
interface ComposerDraftStorage {
|
interface ComposerDraftStorage {
|
||||||
|
|
@ -931,10 +826,6 @@ function persistComposerDraft(botId: string, commandRaw: string, attachmentsRaw:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExternalHttpLink(href: string): boolean {
|
|
||||||
return /^https?:\/\//i.test(String(href || '').trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseQuotedReplyBlock(input: string): { quoted: string; body: string } {
|
function parseQuotedReplyBlock(input: string): { quoted: string; body: string } {
|
||||||
const source = String(input || '');
|
const source = String(input || '');
|
||||||
const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i);
|
const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i);
|
||||||
|
|
@ -1061,6 +952,8 @@ export function BotDashboardModule({
|
||||||
onOpenImageFactory,
|
onOpenImageFactory,
|
||||||
forcedBotId,
|
forcedBotId,
|
||||||
compactMode = false,
|
compactMode = false,
|
||||||
|
compactPanelTab: compactPanelTabProp,
|
||||||
|
onCompactPanelTabChange,
|
||||||
}: BotDashboardModuleProps) {
|
}: BotDashboardModuleProps) {
|
||||||
const {
|
const {
|
||||||
activeBots,
|
activeBots,
|
||||||
|
|
@ -1234,7 +1127,7 @@ export function BotDashboardModule({
|
||||||
const [topicFeedDeleteSavingById, setTopicFeedDeleteSavingById] = useState<Record<number, boolean>>({});
|
const [topicFeedDeleteSavingById, setTopicFeedDeleteSavingById] = useState<Record<number, boolean>>({});
|
||||||
const [topicFeedUnreadCount, setTopicFeedUnreadCount] = useState(0);
|
const [topicFeedUnreadCount, setTopicFeedUnreadCount] = useState(0);
|
||||||
const [topicDetailOpen, setTopicDetailOpen] = useState(false);
|
const [topicDetailOpen, setTopicDetailOpen] = useState(false);
|
||||||
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat');
|
const [compactPanelTabState, setCompactPanelTabState] = useState<CompactPanelTab>('chat');
|
||||||
const [isCompactMobile, setIsCompactMobile] = useState(false);
|
const [isCompactMobile, setIsCompactMobile] = useState(false);
|
||||||
const [botListQuery, setBotListQuery] = useState('');
|
const [botListQuery, setBotListQuery] = useState('');
|
||||||
const [botListPage, setBotListPage] = useState(1);
|
const [botListPage, setBotListPage] = useState(1);
|
||||||
|
|
@ -1299,28 +1192,14 @@ export function BotDashboardModule({
|
||||||
storage_gb: String(clampStorageGb(bot.storage_gb ?? 10)),
|
storage_gb: String(clampStorageGb(bot.storage_gb ?? 10)),
|
||||||
});
|
});
|
||||||
}, [defaultSystemTimezone]);
|
}, [defaultSystemTimezone]);
|
||||||
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => {
|
const getWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) =>
|
||||||
const query = [`path=${encodeURIComponent(filePath)}`];
|
buildWorkspaceDownloadHref(selectedBotId, filePath, forceDownload);
|
||||||
if (forceDownload) query.push('download=1');
|
const getWorkspaceRawHref = (filePath: string, forceDownload: boolean = false) =>
|
||||||
return `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/download?${query.join('&')}`;
|
buildWorkspaceRawHref(selectedBotId, filePath, forceDownload);
|
||||||
};
|
const getWorkspacePreviewHref = (filePath: string) => {
|
||||||
const buildWorkspaceRawHref = (filePath: string, forceDownload: boolean = false) => {
|
|
||||||
const normalized = String(filePath || '')
|
|
||||||
.trim()
|
|
||||||
.split('/')
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((part) => encodeURIComponent(part))
|
|
||||||
.join('/');
|
|
||||||
if (!normalized) return '';
|
|
||||||
const base = `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/raw/${normalized}`;
|
|
||||||
return forceDownload ? `${base}?download=1` : base;
|
|
||||||
};
|
|
||||||
const buildWorkspacePreviewHref = (filePath: string) => {
|
|
||||||
const normalized = String(filePath || '').trim();
|
const normalized = String(filePath || '').trim();
|
||||||
if (!normalized) return '';
|
if (!normalized) return '';
|
||||||
return isHtmlPath(normalized)
|
return buildWorkspacePreviewHref(selectedBotId, normalized, { preferRaw: isHtmlPath(normalized) });
|
||||||
? buildWorkspaceRawHref(normalized, false)
|
|
||||||
: buildWorkspaceDownloadHref(normalized, false);
|
|
||||||
};
|
};
|
||||||
const closeWorkspacePreview = () => {
|
const closeWorkspacePreview = () => {
|
||||||
setWorkspacePreview(null);
|
setWorkspacePreview(null);
|
||||||
|
|
@ -1347,7 +1226,7 @@ export function BotDashboardModule({
|
||||||
if (!normalized) return;
|
if (!normalized) return;
|
||||||
const filename = normalized.split('/').pop() || 'workspace-file';
|
const filename = normalized.split('/').pop() || 'workspace-file';
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = buildWorkspaceDownloadHref(normalized, true);
|
link.href = getWorkspaceDownloadHref(normalized, true);
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
link.rel = 'noopener noreferrer';
|
link.rel = 'noopener noreferrer';
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
|
|
@ -1357,7 +1236,7 @@ export function BotDashboardModule({
|
||||||
const copyWorkspacePreviewUrl = async (filePath: string) => {
|
const copyWorkspacePreviewUrl = async (filePath: string) => {
|
||||||
const normalized = String(filePath || '').trim();
|
const normalized = String(filePath || '').trim();
|
||||||
if (!selectedBotId || !normalized) return;
|
if (!selectedBotId || !normalized) return;
|
||||||
const hrefRaw = buildWorkspacePreviewHref(normalized);
|
const hrefRaw = getWorkspacePreviewHref(normalized);
|
||||||
const href = (() => {
|
const href = (() => {
|
||||||
try {
|
try {
|
||||||
return new URL(hrefRaw, window.location.origin).href;
|
return new URL(hrefRaw, window.location.origin).href;
|
||||||
|
|
@ -1439,7 +1318,7 @@ export function BotDashboardModule({
|
||||||
if (!src || !selectedBotId) return src;
|
if (!src || !selectedBotId) return src;
|
||||||
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath);
|
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath);
|
||||||
if (resolvedWorkspacePath) {
|
if (resolvedWorkspacePath) {
|
||||||
return buildWorkspacePreviewHref(resolvedWorkspacePath);
|
return getWorkspacePreviewHref(resolvedWorkspacePath);
|
||||||
}
|
}
|
||||||
const lower = src.toLowerCase();
|
const lower = src.toLowerCase();
|
||||||
if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) {
|
if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) {
|
||||||
|
|
@ -1447,129 +1326,26 @@ export function BotDashboardModule({
|
||||||
}
|
}
|
||||||
return src;
|
return src;
|
||||||
}, [selectedBotId]);
|
}, [selectedBotId]);
|
||||||
const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => {
|
|
||||||
const source = String(text || '');
|
|
||||||
if (!source) return [source];
|
|
||||||
const pattern =
|
|
||||||
/\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.[a-z0-9][a-z0-9._+-]{0,31}\b|https:\/\/workspace\.local\/open\/[^)\r\n]+/gi;
|
|
||||||
const nodes: ReactNode[] = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
let matchIndex = 0;
|
|
||||||
let match = pattern.exec(source);
|
|
||||||
while (match) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
nodes.push(source.slice(lastIndex, match.index));
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = match[0];
|
|
||||||
const markdownPath = match[1] ? String(match[1]) : '';
|
|
||||||
const markdownHref = match[2] ? String(match[2]) : '';
|
|
||||||
let normalizedPath = '';
|
|
||||||
let displayText = raw;
|
|
||||||
|
|
||||||
if (markdownPath && markdownHref) {
|
|
||||||
normalizedPath = normalizeDashboardAttachmentPath(markdownPath);
|
|
||||||
displayText = markdownPath;
|
|
||||||
} else if (raw.startsWith(WORKSPACE_LINK_PREFIX)) {
|
|
||||||
normalizedPath = String(parseWorkspaceLink(raw) || '').trim();
|
|
||||||
displayText = normalizedPath ? `/root/.nanobot/workspace/${normalizedPath}` : raw;
|
|
||||||
} else if (raw.startsWith('/root/.nanobot/workspace/')) {
|
|
||||||
normalizedPath = normalizeDashboardAttachmentPath(raw);
|
|
||||||
displayText = raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedPath) {
|
|
||||||
nodes.push(
|
|
||||||
<a
|
|
||||||
key={`${keyPrefix}-ws-${matchIndex}`}
|
|
||||||
href="#"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
void openWorkspacePathFromChat(normalizedPath);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayText}
|
|
||||||
</a>,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
nodes.push(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIndex = match.index + raw.length;
|
|
||||||
matchIndex += 1;
|
|
||||||
match = pattern.exec(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < source.length) {
|
|
||||||
nodes.push(source.slice(lastIndex));
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
};
|
|
||||||
const renderWorkspaceAwareChildren = (children: ReactNode, keyPrefix: string): ReactNode => {
|
|
||||||
const list = Array.isArray(children) ? children : [children];
|
|
||||||
const mapped = list.flatMap((child, idx) => {
|
|
||||||
if (typeof child === 'string') {
|
|
||||||
return renderWorkspaceAwareText(child, `${keyPrefix}-${idx}`);
|
|
||||||
}
|
|
||||||
return [child];
|
|
||||||
});
|
|
||||||
return mapped;
|
|
||||||
};
|
|
||||||
const markdownComponents = useMemo(
|
const markdownComponents = useMemo(
|
||||||
() => ({
|
() => createWorkspaceMarkdownComponents(
|
||||||
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
(path) => {
|
||||||
const link = String(href || '').trim();
|
void openWorkspacePathFromChat(path);
|
||||||
const workspacePath = parseWorkspaceLink(link);
|
|
||||||
if (workspacePath) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void openWorkspacePathFromChat(workspacePath);
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isExternalHttpLink(link)) {
|
|
||||||
return (
|
|
||||||
<a href={link} target="_blank" rel="noopener noreferrer" {...props}>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a href={link || '#'} {...props}>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
img: ({ src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>) => {
|
{ resolveMediaSrc: resolveWorkspaceMediaSrc },
|
||||||
const resolvedSrc = resolveWorkspaceMediaSrc(String(src || ''));
|
),
|
||||||
return (
|
[resolveWorkspaceMediaSrc],
|
||||||
<img
|
);
|
||||||
src={resolvedSrc}
|
const workspacePreviewMarkdownComponents = useMemo(
|
||||||
alt={String(alt || '')}
|
() => createWorkspaceMarkdownComponents(
|
||||||
loading="lazy"
|
(path) => {
|
||||||
{...props}
|
void openWorkspacePathFromChat(path);
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
p: ({ children, ...props }: { children?: ReactNode }) => (
|
{
|
||||||
<p {...props}>{renderWorkspaceAwareChildren(children, 'md-p')}</p>
|
baseFilePath: workspacePreview?.path,
|
||||||
),
|
resolveMediaSrc: resolveWorkspaceMediaSrc,
|
||||||
li: ({ children, ...props }: { children?: ReactNode }) => (
|
},
|
||||||
<li {...props}>{renderWorkspaceAwareChildren(children, 'md-li')}</li>
|
),
|
||||||
),
|
[resolveWorkspaceMediaSrc, workspacePreview?.path],
|
||||||
code: ({ children, ...props }: { children?: ReactNode }) => (
|
|
||||||
<code {...props}>{renderWorkspaceAwareChildren(children, 'md-code')}</code>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
[fileNotPreviewableLabel, notify, resolveWorkspaceMediaSrc, selectedBotId],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
|
|
@ -1638,6 +1414,14 @@ export function BotDashboardModule({
|
||||||
const compactListFirstMode = compactMode && !hasForcedBot;
|
const compactListFirstMode = compactMode && !hasForcedBot;
|
||||||
const isCompactListPage = compactListFirstMode && !selectedBotId;
|
const isCompactListPage = compactListFirstMode && !selectedBotId;
|
||||||
const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId);
|
const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId);
|
||||||
|
const compactPanelTab = compactPanelTabProp ?? compactPanelTabState;
|
||||||
|
const setCompactPanelTab = useCallback((next: CompactPanelTab | ((prev: CompactPanelTab) => CompactPanelTab)) => {
|
||||||
|
const resolved = typeof next === 'function' ? next(compactPanelTab) : next;
|
||||||
|
if (compactPanelTabProp === undefined) {
|
||||||
|
setCompactPanelTabState(resolved);
|
||||||
|
}
|
||||||
|
onCompactPanelTabChange?.(resolved);
|
||||||
|
}, [compactPanelTab, compactPanelTabProp, onCompactPanelTabChange]);
|
||||||
const showBotListPanel = !hasForcedBot && (!compactMode || isCompactListPage);
|
const showBotListPanel = !hasForcedBot && (!compactMode || isCompactListPage);
|
||||||
const normalizedBotListQuery = botListQuery.trim().toLowerCase();
|
const normalizedBotListQuery = botListQuery.trim().toLowerCase();
|
||||||
const filteredBots = useMemo(() => {
|
const filteredBots = useMemo(() => {
|
||||||
|
|
@ -5306,43 +5090,22 @@ export function BotDashboardModule({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ops-bot-list-toolbar">
|
<div className="ops-bot-list-toolbar">
|
||||||
<div className="ops-searchbar">
|
<ProtectedSearchInput
|
||||||
<input
|
value={botListQuery}
|
||||||
className="input ops-search-input ops-search-input-with-icon"
|
onChange={setBotListQuery}
|
||||||
type="search"
|
onClear={() => {
|
||||||
value={botListQuery}
|
setBotListQuery('');
|
||||||
onChange={(e) => setBotListQuery(e.target.value)}
|
setBotListPage(1);
|
||||||
placeholder={t.botSearchPlaceholder}
|
}}
|
||||||
aria-label={t.botSearchPlaceholder}
|
onSearchAction={() => setBotListPage(1)}
|
||||||
autoComplete="new-password"
|
debounceMs={120}
|
||||||
autoCorrect="off"
|
placeholder={t.botSearchPlaceholder}
|
||||||
autoCapitalize="none"
|
ariaLabel={t.botSearchPlaceholder}
|
||||||
spellCheck={false}
|
clearTitle={t.clearSearch}
|
||||||
inputMode="search"
|
searchTitle={t.searchAction}
|
||||||
name={botSearchInputName}
|
name={botSearchInputName}
|
||||||
id={botSearchInputName}
|
id={botSearchInputName}
|
||||||
data-form-type="other"
|
/>
|
||||||
data-lpignore="true"
|
|
||||||
data-1p-ignore="true"
|
|
||||||
data-bwignore="true"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-search-inline-btn"
|
|
||||||
onClick={() => {
|
|
||||||
if (botListQuery.trim()) {
|
|
||||||
setBotListQuery('');
|
|
||||||
setBotListPage(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBotListPage(1);
|
|
||||||
}}
|
|
||||||
title={botListQuery.trim() ? t.clearSearch : t.searchAction}
|
|
||||||
aria-label={botListQuery.trim() ? t.clearSearch : t.searchAction}
|
|
||||||
>
|
|
||||||
{botListQuery.trim() ? <X size={14} /> : <Search size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-scroll">
|
<div className="list-scroll">
|
||||||
|
|
@ -5552,6 +5315,7 @@ export function BotDashboardModule({
|
||||||
onDeleteItem={(item) => void deleteTopicFeedItem(item)}
|
onDeleteItem={(item) => void deleteTopicFeedItem(item)}
|
||||||
onLoadMore={() => void loadTopicFeed({ append: true, cursor: topicFeedNextCursor, topicKey: topicFeedTopicKey })}
|
onLoadMore={() => void loadTopicFeed({ append: true, cursor: topicFeedNextCursor, topicKey: topicFeedTopicKey })}
|
||||||
onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)}
|
onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)}
|
||||||
|
resolveWorkspaceMediaSrc={resolveWorkspaceMediaSrc}
|
||||||
onOpenTopicSettings={() => {
|
onOpenTopicSettings={() => {
|
||||||
if (selectedBot) openTopicModal(selectedBot.id);
|
if (selectedBot) openTopicModal(selectedBot.id);
|
||||||
}}
|
}}
|
||||||
|
|
@ -6125,42 +5889,19 @@ export function BotDashboardModule({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-search-toolbar">
|
<div className="workspace-search-toolbar">
|
||||||
<div className="ops-searchbar">
|
<ProtectedSearchInput
|
||||||
<input
|
value={workspaceQuery}
|
||||||
className="input ops-search-input ops-search-input-with-icon"
|
onChange={setWorkspaceQuery}
|
||||||
type="search"
|
onClear={() => setWorkspaceQuery('')}
|
||||||
value={workspaceQuery}
|
onSearchAction={() => setWorkspaceQuery((value) => value.trim())}
|
||||||
onChange={(e) => setWorkspaceQuery(e.target.value)}
|
debounceMs={200}
|
||||||
placeholder={t.workspaceSearchPlaceholder}
|
placeholder={t.workspaceSearchPlaceholder}
|
||||||
aria-label={t.workspaceSearchPlaceholder}
|
ariaLabel={t.workspaceSearchPlaceholder}
|
||||||
autoComplete="new-password"
|
clearTitle={t.clearSearch}
|
||||||
autoCorrect="off"
|
searchTitle={t.searchAction}
|
||||||
autoCapitalize="none"
|
name={workspaceSearchInputName}
|
||||||
spellCheck={false}
|
id={workspaceSearchInputName}
|
||||||
inputMode="search"
|
/>
|
||||||
name={workspaceSearchInputName}
|
|
||||||
id={workspaceSearchInputName}
|
|
||||||
data-form-type="other"
|
|
||||||
data-lpignore="true"
|
|
||||||
data-1p-ignore="true"
|
|
||||||
data-bwignore="true"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-search-inline-btn"
|
|
||||||
onClick={() => {
|
|
||||||
if (workspaceQuery.trim()) {
|
|
||||||
setWorkspaceQuery('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setWorkspaceQuery((v) => v.trim());
|
|
||||||
}}
|
|
||||||
title={workspaceQuery.trim() ? t.clearSearch : t.searchAction}
|
|
||||||
aria-label={workspaceQuery.trim() ? t.clearSearch : t.searchAction}
|
|
||||||
>
|
|
||||||
{workspaceQuery.trim() ? <X size={14} /> : <Search size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-panel">
|
<div className="workspace-panel">
|
||||||
<div className="workspace-list">
|
<div className="workspace-list">
|
||||||
|
|
@ -6207,19 +5948,6 @@ export function BotDashboardModule({
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</LucentIconButton>
|
</LucentIconButton>
|
||||||
) : null}
|
) : null}
|
||||||
{compactMode && !isCompactListPage ? (
|
|
||||||
<div className="ops-compact-fab-stack">
|
|
||||||
<LucentIconButton
|
|
||||||
className={`ops-compact-fab-switch ${compactPanelTab === 'chat' ? 'is-chat' : 'is-runtime'}`}
|
|
||||||
onClick={() => setCompactPanelTab((v) => (v === 'runtime' ? 'chat' : 'runtime'))}
|
|
||||||
tooltip={compactPanelTab === 'runtime' ? (isZh ? '切换到对话面板' : 'Switch to chat') : (isZh ? '切换到运行面板' : 'Switch to runtime')}
|
|
||||||
aria-label={compactPanelTab === 'runtime' ? (isZh ? '切换到对话面板' : 'Switch to chat') : (isZh ? '切换到运行面板' : 'Switch to runtime')}
|
|
||||||
>
|
|
||||||
{compactPanelTab === 'runtime' ? <MessageSquareText size={18} /> : <Activity size={18} />}
|
|
||||||
</LucentIconButton>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showResourceModal && (
|
{showResourceModal && (
|
||||||
<div className="modal-mask" onClick={() => setShowResourceModal(false)}>
|
<div className="modal-mask" onClick={() => setShowResourceModal(false)}>
|
||||||
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
@ -6417,6 +6145,7 @@ export function BotDashboardModule({
|
||||||
<option value="openrouter">openrouter</option>
|
<option value="openrouter">openrouter</option>
|
||||||
<option value="dashscope">dashscope (aliyun qwen)</option>
|
<option value="dashscope">dashscope (aliyun qwen)</option>
|
||||||
<option value="openai">openai</option>
|
<option value="openai">openai</option>
|
||||||
|
<option value="vllm">vllm (openai-compatible)</option>
|
||||||
<option value="deepseek">deepseek</option>
|
<option value="deepseek">deepseek</option>
|
||||||
<option value="kimi">kimi (moonshot)</option>
|
<option value="kimi">kimi (moonshot)</option>
|
||||||
<option value="minimax">minimax</option>
|
<option value="minimax">minimax</option>
|
||||||
|
|
@ -7843,27 +7572,27 @@ export function BotDashboardModule({
|
||||||
{workspacePreview.isImage ? (
|
{workspacePreview.isImage ? (
|
||||||
<img
|
<img
|
||||||
className="workspace-preview-image"
|
className="workspace-preview-image"
|
||||||
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
|
src={getWorkspaceDownloadHref(workspacePreview.path, false)}
|
||||||
alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
|
alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
|
||||||
/>
|
/>
|
||||||
) : workspacePreview.isVideo ? (
|
) : workspacePreview.isVideo ? (
|
||||||
<video
|
<video
|
||||||
className="workspace-preview-media"
|
className="workspace-preview-media"
|
||||||
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
|
src={getWorkspaceDownloadHref(workspacePreview.path, false)}
|
||||||
controls
|
controls
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
) : workspacePreview.isAudio ? (
|
) : workspacePreview.isAudio ? (
|
||||||
<audio
|
<audio
|
||||||
className="workspace-preview-audio"
|
className="workspace-preview-audio"
|
||||||
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
|
src={getWorkspaceDownloadHref(workspacePreview.path, false)}
|
||||||
controls
|
controls
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
) : workspacePreview.isHtml ? (
|
) : workspacePreview.isHtml ? (
|
||||||
<iframe
|
<iframe
|
||||||
className="workspace-preview-embed"
|
className="workspace-preview-embed"
|
||||||
src={buildWorkspaceRawHref(workspacePreview.path, false)}
|
src={getWorkspaceRawHref(workspacePreview.path, false)}
|
||||||
title={workspacePreview.path}
|
title={workspacePreview.path}
|
||||||
/>
|
/>
|
||||||
) : workspacePreviewEditorEnabled ? (
|
) : workspacePreviewEditorEnabled ? (
|
||||||
|
|
@ -7883,7 +7612,7 @@ export function BotDashboardModule({
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
|
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
|
||||||
components={markdownComponents}
|
components={workspacePreviewMarkdownComponents}
|
||||||
>
|
>
|
||||||
{decorateWorkspacePathsForMarkdown(workspacePreview.content || '')}
|
{decorateWorkspacePathsForMarkdown(workspacePreview.content || '')}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
@ -7937,7 +7666,7 @@ export function BotDashboardModule({
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
href={buildWorkspaceDownloadHref(workspacePreview.path, true)}
|
href={getWorkspaceDownloadHref(workspacePreview.path, true)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
download={workspacePreview.path.split('/').pop() || 'workspace-file'}
|
download={workspacePreview.path.split('/').pop() || 'workspace-file'}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { ChevronLeft, ChevronRight, Hammer, RefreshCw, Search, X } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Hammer, RefreshCw, X } from 'lucide-react';
|
||||||
import { APP_ENDPOINTS } from '../../../config/env';
|
import { APP_ENDPOINTS } from '../../../config/env';
|
||||||
import type { BotSkillMarketItem } from '../../platform/types';
|
import type { BotSkillMarketItem } from '../../platform/types';
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
|
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
||||||
import {
|
import {
|
||||||
normalizePlatformPageSize,
|
normalizePlatformPageSize,
|
||||||
readCachedPlatformPageSize,
|
readCachedPlatformPageSize,
|
||||||
|
|
@ -100,25 +101,18 @@ export function SkillMarketInstallModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="skill-market-browser-toolbar">
|
<div className="skill-market-browser-toolbar">
|
||||||
<div className="ops-searchbar platform-searchbar skill-market-search">
|
<ProtectedSearchInput
|
||||||
<input
|
className="platform-searchbar skill-market-search"
|
||||||
className="input ops-search-input ops-search-input-with-icon"
|
value={search}
|
||||||
type="search"
|
onChange={setSearch}
|
||||||
value={search}
|
onClear={() => setSearch('')}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
autoFocus
|
||||||
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}
|
debounceMs={120}
|
||||||
aria-label={isZh ? '搜索技能市场' : 'Search skill marketplace'}
|
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}
|
||||||
/>
|
ariaLabel={isZh ? '搜索技能市场' : 'Search skill marketplace'}
|
||||||
<button
|
clearTitle={isZh ? '清空搜索' : 'Clear search'}
|
||||||
type="button"
|
searchTitle={isZh ? '搜索' : 'Search'}
|
||||||
className="ops-search-inline-btn"
|
/>
|
||||||
onClick={() => setSearch('')}
|
|
||||||
title={search.trim() ? (isZh ? '清空搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
|
|
||||||
aria-label={search.trim() ? (isZh ? '清空搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
|
|
||||||
>
|
|
||||||
{search.trim() ? <X size={14} /> : <Search size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="skill-market-browser-grid">
|
<div className="skill-market-browser-grid">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { AnchorHTMLAttributes, ReactNode } from 'react';
|
import type { AnchorHTMLAttributes, ImgHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
export const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/';
|
export const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/';
|
||||||
const WORKSPACE_ABS_PATH_PATTERN =
|
const WORKSPACE_ABS_PATH_PATTERN =
|
||||||
|
|
@ -9,7 +9,11 @@ const WORKSPACE_RENDER_PATTERN =
|
||||||
/\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)|\/root\/\.nanobot\/workspace\/[^\s<>"'`)\],,。!?;:]+|https:\/\/workspace\.local\/open\/[^)\r\n]+/gi;
|
/\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)|\/root\/\.nanobot\/workspace\/[^\s<>"'`)\],,。!?;:]+|https:\/\/workspace\.local\/open\/[^)\r\n]+/gi;
|
||||||
|
|
||||||
export function normalizeDashboardAttachmentPath(path: string): string {
|
export function normalizeDashboardAttachmentPath(path: string): string {
|
||||||
const v = String(path || '').trim().replace(/\\/g, '/');
|
const v = String(path || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/^['"`([<{]+/, '')
|
||||||
|
.replace(/['"`)\]>}.,,。!?;:]+$/, '');
|
||||||
if (!v) return '';
|
if (!v) return '';
|
||||||
const prefix = '/root/.nanobot/workspace/';
|
const prefix = '/root/.nanobot/workspace/';
|
||||||
if (v.startsWith(prefix)) return v.slice(prefix.length);
|
if (v.startsWith(prefix)) return v.slice(prefix.length);
|
||||||
|
|
@ -20,6 +24,39 @@ export function buildWorkspaceLink(path: string) {
|
||||||
return `${WORKSPACE_LINK_PREFIX}${encodeURIComponent(path)}`;
|
return `${WORKSPACE_LINK_PREFIX}${encodeURIComponent(path)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceDownloadHref(botIdRaw: string, filePath: string, forceDownload: boolean = true) {
|
||||||
|
const botId = String(botIdRaw || '').trim();
|
||||||
|
const normalizedPath = String(filePath || '').trim();
|
||||||
|
const query = [`path=${encodeURIComponent(normalizedPath)}`];
|
||||||
|
if (forceDownload) query.push('download=1');
|
||||||
|
return `/public/bots/${encodeURIComponent(botId)}/workspace/download?${query.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceRawHref(botIdRaw: string, filePath: string, forceDownload: boolean = false) {
|
||||||
|
const botId = String(botIdRaw || '').trim();
|
||||||
|
const normalized = String(filePath || '')
|
||||||
|
.trim()
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => encodeURIComponent(part))
|
||||||
|
.join('/');
|
||||||
|
if (!normalized) return '';
|
||||||
|
const base = `/public/bots/${encodeURIComponent(botId)}/workspace/raw/${normalized}`;
|
||||||
|
return forceDownload ? `${base}?download=1` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspacePreviewHref(
|
||||||
|
botIdRaw: string,
|
||||||
|
filePath: string,
|
||||||
|
options: { preferRaw?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const normalized = String(filePath || '').trim();
|
||||||
|
if (!normalized) return '';
|
||||||
|
return options.preferRaw
|
||||||
|
? buildWorkspaceRawHref(botIdRaw, normalized, false)
|
||||||
|
: buildWorkspaceDownloadHref(botIdRaw, normalized, false);
|
||||||
|
}
|
||||||
|
|
||||||
export function parseWorkspaceLink(href: string): string | null {
|
export function parseWorkspaceLink(href: string): string | null {
|
||||||
const link = String(href || '').trim();
|
const link = String(href || '').trim();
|
||||||
if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null;
|
if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null;
|
||||||
|
|
@ -36,6 +73,45 @@ function isExternalHttpLink(href: string): boolean {
|
||||||
return /^https?:\/\//i.test(String(href || '').trim());
|
return /^https?:\/\//i.test(String(href || '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveWorkspaceDocumentPath(targetRaw: string, baseFilePath?: string): string | null {
|
||||||
|
const target = String(targetRaw || '').trim();
|
||||||
|
if (!target || target.startsWith('#')) return null;
|
||||||
|
const linkedPath = parseWorkspaceLink(target);
|
||||||
|
if (linkedPath) return linkedPath;
|
||||||
|
if (target.startsWith('/root/.nanobot/workspace/')) {
|
||||||
|
return normalizeDashboardAttachmentPath(target);
|
||||||
|
}
|
||||||
|
const lower = target.toLowerCase();
|
||||||
|
if (
|
||||||
|
lower.startsWith('blob:') ||
|
||||||
|
lower.startsWith('data:') ||
|
||||||
|
lower.startsWith('http://') ||
|
||||||
|
lower.startsWith('https://') ||
|
||||||
|
lower.startsWith('javascript:') ||
|
||||||
|
lower.startsWith('mailto:') ||
|
||||||
|
lower.startsWith('tel:') ||
|
||||||
|
target.startsWith('//')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedBase = normalizeDashboardAttachmentPath(baseFilePath || '');
|
||||||
|
if (!normalizedBase) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = new URL(`https://workspace.local/${normalizedBase}`);
|
||||||
|
const resolvedUrl = new URL(target, baseUrl);
|
||||||
|
if (resolvedUrl.origin !== 'https://workspace.local') return null;
|
||||||
|
try {
|
||||||
|
return normalizeDashboardAttachmentPath(decodeURIComponent(resolvedUrl.pathname));
|
||||||
|
} catch {
|
||||||
|
return normalizeDashboardAttachmentPath(resolvedUrl.pathname);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function decorateWorkspacePathsInPlainChunk(source: string): string {
|
function decorateWorkspacePathsInPlainChunk(source: string): string {
|
||||||
if (!source) return source;
|
if (!source) return source;
|
||||||
const protectedLinks: string[] = [];
|
const protectedLinks: string[] = [];
|
||||||
|
|
@ -160,11 +236,20 @@ function renderWorkspaceAwareChildren(
|
||||||
return mapped;
|
return mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWorkspaceMarkdownComponents(openWorkspacePath: (path: string) => void) {
|
interface WorkspaceMarkdownOptions {
|
||||||
|
baseFilePath?: string;
|
||||||
|
resolveMediaSrc?: (src: string, baseFilePath?: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkspaceMarkdownComponents(
|
||||||
|
openWorkspacePath: (path: string) => void,
|
||||||
|
options: WorkspaceMarkdownOptions = {},
|
||||||
|
) {
|
||||||
|
const { baseFilePath, resolveMediaSrc } = options;
|
||||||
return {
|
return {
|
||||||
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
const link = String(href || '').trim();
|
const link = String(href || '').trim();
|
||||||
const workspacePath = parseWorkspaceLink(link);
|
const workspacePath = parseWorkspaceLink(link) || resolveWorkspaceDocumentPath(link, baseFilePath);
|
||||||
if (workspacePath) {
|
if (workspacePath) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
|
@ -192,6 +277,18 @@ export function createWorkspaceMarkdownComponents(openWorkspacePath: (path: stri
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
img: ({ src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>) => {
|
||||||
|
const link = String(src || '').trim();
|
||||||
|
const resolvedSrc = resolveMediaSrc ? resolveMediaSrc(link, baseFilePath) : link;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={resolvedSrc}
|
||||||
|
alt={String(alt || '')}
|
||||||
|
loading="lazy"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
p: ({ children, ...props }: { children?: ReactNode }) => (
|
p: ({ children, ...props }: { children?: ReactNode }) => (
|
||||||
<p {...props}>{renderWorkspaceAwareChildren(children, 'md-p', openWorkspacePath)}</p>
|
<p {...props}>{renderWorkspaceAwareChildren(children, 'md-p', openWorkspacePath)}</p>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ import rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
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 { createWorkspaceMarkdownComponents, decorateWorkspacePathsForMarkdown } from '../shared/workspaceMarkdown';
|
import {
|
||||||
|
createWorkspaceMarkdownComponents,
|
||||||
|
decorateWorkspacePathsForMarkdown,
|
||||||
|
resolveWorkspaceDocumentPath,
|
||||||
|
} from '../shared/workspaceMarkdown';
|
||||||
|
|
||||||
export interface TopicFeedItem {
|
export interface TopicFeedItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -47,6 +51,7 @@ interface TopicFeedPanelProps {
|
||||||
onDeleteItem: (item: TopicFeedItem) => void;
|
onDeleteItem: (item: TopicFeedItem) => void;
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
onOpenWorkspacePath: (path: string) => void;
|
onOpenWorkspacePath: (path: string) => void;
|
||||||
|
resolveWorkspaceMediaSrc?: (src: string, baseFilePath?: string) => string;
|
||||||
onOpenTopicSettings?: () => void;
|
onOpenTopicSettings?: () => void;
|
||||||
onDetailOpenChange?: (open: boolean) => void;
|
onDetailOpenChange?: (open: boolean) => void;
|
||||||
layout?: 'compact' | 'panel';
|
layout?: 'compact' | 'panel';
|
||||||
|
|
@ -148,14 +153,11 @@ export function TopicFeedPanel({
|
||||||
onDeleteItem,
|
onDeleteItem,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
onOpenWorkspacePath,
|
onOpenWorkspacePath,
|
||||||
|
resolveWorkspaceMediaSrc,
|
||||||
onOpenTopicSettings,
|
onOpenTopicSettings,
|
||||||
onDetailOpenChange,
|
onDetailOpenChange,
|
||||||
layout = 'compact',
|
layout = 'compact',
|
||||||
}: TopicFeedPanelProps) {
|
}: TopicFeedPanelProps) {
|
||||||
const markdownComponents = useMemo(
|
|
||||||
() => createWorkspaceMarkdownComponents((path) => onOpenWorkspacePath(path)),
|
|
||||||
[onOpenWorkspacePath],
|
|
||||||
);
|
|
||||||
const [detailState, setDetailState] = useState<TopicDetailState | null>(null);
|
const [detailState, setDetailState] = useState<TopicDetailState | null>(null);
|
||||||
const closeDetail = useCallback(() => setDetailState(null), []);
|
const closeDetail = useCallback(() => setDetailState(null), []);
|
||||||
const detailItem = useMemo(
|
const detailItem = useMemo(
|
||||||
|
|
@ -168,6 +170,30 @@ export function TopicFeedPanel({
|
||||||
const detailContent = detailItem
|
const detailContent = detailItem
|
||||||
? String(detailItem.content || detailState?.fallbackContent || '').trim()
|
? String(detailItem.content || detailState?.fallbackContent || '').trim()
|
||||||
: String(detailState?.fallbackContent || '').trim();
|
: String(detailState?.fallbackContent || '').trim();
|
||||||
|
const detailBaseFilePath = useMemo(() => {
|
||||||
|
if (!detailItem) return '';
|
||||||
|
const view = detailItem.view && typeof detailItem.view === 'object' ? detailItem.view as Record<string, unknown> : {};
|
||||||
|
const candidates = [
|
||||||
|
view.path,
|
||||||
|
view.file_path,
|
||||||
|
view.source_path,
|
||||||
|
view.workspace_path,
|
||||||
|
detailItem.source,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const raw = String(candidate || '').trim();
|
||||||
|
const resolved = resolveWorkspaceDocumentPath(raw);
|
||||||
|
if (resolved) return resolved;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [detailItem]);
|
||||||
|
const markdownComponents = useMemo(
|
||||||
|
() => createWorkspaceMarkdownComponents((path) => onOpenWorkspacePath(path), {
|
||||||
|
baseFilePath: detailBaseFilePath || undefined,
|
||||||
|
resolveMediaSrc: resolveWorkspaceMediaSrc,
|
||||||
|
}),
|
||||||
|
[detailBaseFilePath, onOpenWorkspacePath, resolveWorkspaceMediaSrc],
|
||||||
|
);
|
||||||
const portalTarget = useMemo(() => {
|
const portalTarget = useMemo(() => {
|
||||||
if (typeof document === 'undefined') return null;
|
if (typeof document === 'undefined') return null;
|
||||||
return document.querySelector('.app-shell[data-theme]') || document.body;
|
return document.querySelector('.app-shell[data-theme]') || document.body;
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
|
||||||
onChange={(e) => setFormData({ ...formData, llm_provider: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, llm_provider: e.target.value })}
|
||||||
>
|
>
|
||||||
<option value="openai">OpenAI</option>
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="vllm">vLLM (OpenAI-compatible)</option>
|
||||||
<option value="deepseek">DeepSeek</option>
|
<option value="deepseek">DeepSeek</option>
|
||||||
<option value="kimi">Kimi (Moonshot)</option>
|
<option value="kimi">Kimi (Moonshot)</option>
|
||||||
<option value="minimax">MiniMax</option>
|
<option value="minimax">MiniMax</option>
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,13 @@ const providerPresets: Record<string, { model: string; note: { 'zh-cn': string;
|
||||||
en: 'OpenAI native models.',
|
en: 'OpenAI native models.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
vllm: {
|
||||||
|
model: 'Qwen/Qwen2.5-7B-Instruct',
|
||||||
|
note: {
|
||||||
|
'zh-cn': 'vLLM(OpenAI 兼容)接口,请填写你的部署地址,例如 http://127.0.0.1:8000/v1 。',
|
||||||
|
en: 'vLLM (OpenAI-compatible) endpoint. Fill in your deployment URL, for example http://127.0.0.1:8000/v1 .',
|
||||||
|
},
|
||||||
|
},
|
||||||
deepseek: {
|
deepseek: {
|
||||||
model: 'deepseek-chat',
|
model: 'deepseek-chat',
|
||||||
note: {
|
note: {
|
||||||
|
|
@ -752,6 +759,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
||||||
<option value="openrouter">openrouter</option>
|
<option value="openrouter">openrouter</option>
|
||||||
<option value="dashscope">dashscope (aliyun qwen)</option>
|
<option value="dashscope">dashscope (aliyun qwen)</option>
|
||||||
<option value="openai">openai</option>
|
<option value="openai">openai</option>
|
||||||
|
<option value="vllm">vllm (openai-compatible)</option>
|
||||||
<option value="deepseek">deepseek</option>
|
<option value="deepseek">deepseek</option>
|
||||||
<option value="kimi">kimi (moonshot)</option>
|
<option value="kimi">kimi (moonshot)</option>
|
||||||
<option value="minimax">minimax</option>
|
<option value="minimax">minimax</option>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
Lock,
|
Lock,
|
||||||
Power,
|
Power,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
|
||||||
Settings2,
|
Settings2,
|
||||||
Square,
|
Square,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
|
@ -25,6 +24,7 @@ import {
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { APP_ENDPOINTS } from '../../config/env';
|
import { APP_ENDPOINTS } from '../../config/env';
|
||||||
|
import { ProtectedSearchInput } from '../../components/ProtectedSearchInput';
|
||||||
import { ImageFactoryModule } from '../images/ImageFactoryModule';
|
import { ImageFactoryModule } from '../images/ImageFactoryModule';
|
||||||
import { BotWizardModule } from '../onboarding/BotWizardModule';
|
import { BotWizardModule } from '../onboarding/BotWizardModule';
|
||||||
import { useAppStore } from '../../store/appStore';
|
import { useAppStore } from '../../store/appStore';
|
||||||
|
|
@ -58,23 +58,28 @@ function clampPercent(value: number) {
|
||||||
return Math.max(0, Math.min(100, Number(value || 0)));
|
return Math.max(0, Math.min(100, Number(value || 0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string | null | undefined, locale: string) {
|
|
||||||
if (!value) return '-';
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
|
||||||
return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', {
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBotPanelHref(botId: string) {
|
function buildBotPanelHref(botId: string) {
|
||||||
const encodedId = encodeURIComponent(String(botId || '').trim());
|
const encodedId = encodeURIComponent(String(botId || '').trim());
|
||||||
return `/bot/${encodedId}`;
|
return `/bot/${encodedId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MODEL_ANALYTICS_COLORS = ['#5B8FF9', '#61DDAA', '#65789B', '#F6BD16', '#7262FD', '#78D3F8'];
|
||||||
|
|
||||||
|
function getChartCeiling(value: number) {
|
||||||
|
const safeValue = Math.max(0, Number(value || 0));
|
||||||
|
if (safeValue <= 4) return 4;
|
||||||
|
const roughStep = safeValue / 4;
|
||||||
|
const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep)));
|
||||||
|
const normalized = roughStep / magnitude;
|
||||||
|
const niceStep = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||||
|
return niceStep * magnitude * 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinePath(points: Array<{ x: number; y: number }>) {
|
||||||
|
if (!points.length) return '';
|
||||||
|
return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
interface PlatformDashboardPageProps {
|
interface PlatformDashboardPageProps {
|
||||||
compactMode: boolean;
|
compactMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -422,9 +427,6 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
|
||||||
const overviewImages = overview?.summary.images;
|
const overviewImages = overview?.summary.images;
|
||||||
const overviewResources = overview?.summary.resources;
|
const overviewResources = overview?.summary.resources;
|
||||||
const usageSummary = usageData?.summary || overview?.usage.summary;
|
const usageSummary = usageData?.summary || overview?.usage.summary;
|
||||||
const usageItems = pageSizeReady ? usageData?.items || [] : [];
|
|
||||||
const usageTotal = pageSizeReady ? usageData?.total || 0 : 0;
|
|
||||||
const usagePageCount = Math.max(1, Math.ceil(usageTotal / usagePageSize));
|
|
||||||
const selectedBotInfo = selectedBotDetail && selectedBotDetail.id === selectedBotId ? { ...selectedBot, ...selectedBotDetail } : selectedBot;
|
const selectedBotInfo = selectedBotDetail && selectedBotDetail.id === selectedBotId ? { ...selectedBot, ...selectedBotDetail } : selectedBot;
|
||||||
const lastActionPreview = selectedBotInfo?.last_action?.trim() || '';
|
const lastActionPreview = selectedBotInfo?.last_action?.trim() || '';
|
||||||
const memoryPercent =
|
const memoryPercent =
|
||||||
|
|
@ -436,6 +438,39 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
|
||||||
? clampPercent((overviewResources.workspace_used_bytes / overviewResources.workspace_limit_bytes) * 100)
|
? clampPercent((overviewResources.workspace_used_bytes / overviewResources.workspace_limit_bytes) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
const resourceBot = resourceBotId ? botList.find((bot) => bot.id === resourceBotId) : undefined;
|
const resourceBot = resourceBotId ? botList.find((bot) => bot.id === resourceBotId) : undefined;
|
||||||
|
const usageAnalytics = usageData?.analytics || overview?.usage.analytics || null;
|
||||||
|
const usageAnalyticsSeries = useMemo(() => {
|
||||||
|
const source = usageAnalytics?.series || [];
|
||||||
|
if (!source.length) return [];
|
||||||
|
const sorted = [...source].sort((left, right) => right.total_requests - left.total_requests || left.model.localeCompare(right.model));
|
||||||
|
const visibleItems = sorted.length > 6
|
||||||
|
? [
|
||||||
|
...sorted.slice(0, 5),
|
||||||
|
{
|
||||||
|
model: isZh ? '其他模型' : 'Others',
|
||||||
|
total_requests: sorted.slice(5).reduce((sum, item) => sum + item.total_requests, 0),
|
||||||
|
daily_counts: (usageAnalytics?.days || []).map((_, index) =>
|
||||||
|
sorted.slice(5).reduce((sum, item) => sum + (item.daily_counts[index] || 0), 0),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: sorted;
|
||||||
|
return visibleItems.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
color: MODEL_ANALYTICS_COLORS[index % MODEL_ANALYTICS_COLORS.length],
|
||||||
|
}));
|
||||||
|
}, [isZh, usageAnalytics]);
|
||||||
|
const usageAnalyticsMax = useMemo(() => {
|
||||||
|
const maxDailyRequests = usageAnalyticsSeries.reduce(
|
||||||
|
(max, item) => Math.max(max, ...item.daily_counts.map((count) => Number(count || 0))),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return getChartCeiling(maxDailyRequests);
|
||||||
|
}, [usageAnalyticsSeries]);
|
||||||
|
const usageAnalyticsTicks = useMemo(
|
||||||
|
() => Array.from({ length: 5 }, (_, index) => Math.round(usageAnalyticsMax - (usageAnalyticsMax / 4) * index)),
|
||||||
|
[usageAnalyticsMax],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (compactMode && showCompactBotSheet && selectedBotInfo) {
|
if (compactMode && showCompactBotSheet && selectedBotInfo) {
|
||||||
|
|
@ -643,82 +678,118 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
|
||||||
|
|
||||||
const renderUsageSection = () => (
|
const renderUsageSection = () => (
|
||||||
<section className="panel stack">
|
<section className="panel stack">
|
||||||
<div className="row-between">
|
<div className="platform-model-analytics-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>{isZh ? 'Token 调用明细' : 'Token Usage Details'}</h2>
|
<h2>{isZh ? '模型数据分析' : 'Model Analytics'}</h2>
|
||||||
|
<div className="platform-model-analytics-subtitle">
|
||||||
|
{isZh
|
||||||
|
? `最近 ${usageAnalytics?.window_days || 7} 天 · 调用次数趋势`
|
||||||
|
: `Last ${usageAnalytics?.window_days || 7} days · Request trend`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="platform-usage-summary">
|
<div className="platform-model-analytics-total">
|
||||||
<span>{isZh ? `请求 ${usageSummary?.request_count || 0}` : `Requests ${usageSummary?.request_count || 0}`}</span>
|
<strong>{usageAnalytics?.total_requests || 0}</strong>
|
||||||
<span>{isZh ? `总 Tokens ${usageSummary?.total_tokens || 0}` : `Total Tokens ${usageSummary?.total_tokens || 0}`}</span>
|
<span>{isZh ? '总调用次数' : 'Total Requests'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="platform-usage-table">
|
{usageLoading && !usageAnalytics ? (
|
||||||
<div className="platform-usage-head">
|
<div className="ops-empty-inline">{isZh ? '正在加载模型调用统计...' : 'Loading model analytics...'}</div>
|
||||||
<span>{isZh ? 'Bot / 请求' : 'Bot / Request'}</span>
|
|
||||||
<span>{isZh ? '内容' : 'Content'}</span>
|
|
||||||
<span>{isZh ? '模型' : 'Model'}</span>
|
|
||||||
<span>{isZh ? '输入' : 'Input'}</span>
|
|
||||||
<span>{isZh ? '输出' : 'Output'}</span>
|
|
||||||
<span>{isZh ? '总计' : 'Total'}</span>
|
|
||||||
<span>{isZh ? '时间 / 来源' : 'Time / Source'}</span>
|
|
||||||
</div>
|
|
||||||
{!pageSizeReady ? (
|
|
||||||
<div className="ops-empty-inline">{isZh ? '正在同步分页设置...' : 'Syncing page size...'}</div>
|
|
||||||
) : null}
|
|
||||||
{usageItems.map((item) => (
|
|
||||||
<div key={item.id} className="platform-usage-row">
|
|
||||||
<div>
|
|
||||||
<div><strong>{item.bot_id}</strong></div>
|
|
||||||
<div className="platform-usage-meta">message_id: {item.message_id ?? '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="platform-usage-content-cell">
|
|
||||||
<div className="platform-usage-preview">{item.content || '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="platform-usage-model">{item.model || '-'}</div>
|
|
||||||
<div className="platform-usage-meta">{item.provider || item.channel}</div>
|
|
||||||
</div>
|
|
||||||
<span>{item.input_tokens}</span>
|
|
||||||
<span>{item.output_tokens}</span>
|
|
||||||
<span>{item.total_tokens}</span>
|
|
||||||
<div>
|
|
||||||
<div className="platform-usage-meta">{formatDateTime(item.completed_at || item.started_at, locale)}</div>
|
|
||||||
<div className="platform-usage-meta">{item.token_source}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{pageSizeReady && !usageLoading && usageItems.length === 0 ? (
|
|
||||||
<div className="ops-empty-inline">{isZh ? '暂无请求用量数据。' : 'No usage records yet.'}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{pageSizeReady ? (
|
|
||||||
<div className="platform-usage-pager">
|
|
||||||
<span className="pager-status">{isZh ? `第 ${usagePage} / ${usagePageCount} 页,共 ${usageTotal} 条` : `Page ${usagePage} / ${usagePageCount}, ${usageTotal} rows`}</span>
|
|
||||||
<div className="platform-usage-pager-actions">
|
|
||||||
<LucentIconButton
|
|
||||||
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
|
|
||||||
type="button"
|
|
||||||
disabled={usageLoading || usagePage <= 1}
|
|
||||||
onClick={() => void loadUsage(usagePage - 1)}
|
|
||||||
tooltip={isZh ? '上一页' : 'Previous'}
|
|
||||||
aria-label={isZh ? '上一页' : 'Previous'}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={16} />
|
|
||||||
</LucentIconButton>
|
|
||||||
<LucentIconButton
|
|
||||||
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
|
|
||||||
type="button"
|
|
||||||
disabled={usageLoading || usagePage >= usagePageCount}
|
|
||||||
onClick={() => void loadUsage(usagePage + 1)}
|
|
||||||
tooltip={isZh ? '下一页' : 'Next'}
|
|
||||||
aria-label={isZh ? '下一页' : 'Next'}
|
|
||||||
>
|
|
||||||
<ChevronRight size={16} />
|
|
||||||
</LucentIconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!usageLoading && (!usageAnalytics || usageAnalytics.total_requests <= 0 || usageAnalyticsSeries.length === 0) ? (
|
||||||
|
<div className="ops-empty-inline">{isZh ? '最近 7 天暂无模型调用数据。' : 'No model usage data in the last 7 days.'}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{usageAnalytics && usageAnalytics.total_requests > 0 && usageAnalyticsSeries.length > 0 ? (() => {
|
||||||
|
const chartWidth = 1120;
|
||||||
|
const chartHeight = 360;
|
||||||
|
const paddingTop = 24;
|
||||||
|
const paddingRight = 20;
|
||||||
|
const paddingBottom = 40;
|
||||||
|
const paddingLeft = 52;
|
||||||
|
const innerWidth = chartWidth - paddingLeft - paddingRight;
|
||||||
|
const innerHeight = chartHeight - paddingTop - paddingBottom;
|
||||||
|
const labels = usageAnalytics.days || [];
|
||||||
|
const pointsForSeries = usageAnalyticsSeries.map((series) => {
|
||||||
|
const divisor = Math.max(1, labels.length - 1);
|
||||||
|
const points = series.daily_counts.map((count, index) => ({
|
||||||
|
x: labels.length === 1
|
||||||
|
? paddingLeft + innerWidth / 2
|
||||||
|
: paddingLeft + (innerWidth / divisor) * index,
|
||||||
|
y: paddingTop + innerHeight - (Number(count || 0) / usageAnalyticsMax) * innerHeight,
|
||||||
|
value: Number(count || 0),
|
||||||
|
}));
|
||||||
|
return { ...series, points };
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="platform-model-analytics-legend">
|
||||||
|
{usageAnalyticsSeries.map((series) => (
|
||||||
|
<div key={series.model} className="platform-model-analytics-chip">
|
||||||
|
<span
|
||||||
|
className="platform-model-analytics-chip-dot"
|
||||||
|
style={{ backgroundColor: series.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<strong>{series.model}</strong>
|
||||||
|
<span>{series.total_requests}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="platform-model-chart">
|
||||||
|
<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} role="img" aria-label={isZh ? '最近 7 天模型调用趋势图' : 'Model request trend in the last 7 days'}>
|
||||||
|
{usageAnalyticsTicks.map((tick) => {
|
||||||
|
const y = paddingTop + innerHeight - (tick / usageAnalyticsMax) * innerHeight;
|
||||||
|
return (
|
||||||
|
<g key={tick}>
|
||||||
|
<line x1={paddingLeft} y1={y} x2={chartWidth - paddingRight} y2={y} className="platform-model-chart-grid" />
|
||||||
|
<text x={paddingLeft - 10} y={y + 4} textAnchor="end" className="platform-model-chart-axis-label">
|
||||||
|
{tick}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{labels.map((label, index) => {
|
||||||
|
const divisor = Math.max(1, labels.length - 1);
|
||||||
|
const x = labels.length === 1
|
||||||
|
? paddingLeft + innerWidth / 2
|
||||||
|
: paddingLeft + (innerWidth / divisor) * index;
|
||||||
|
return (
|
||||||
|
<text key={label} x={x} y={chartHeight - 10} textAnchor="middle" className="platform-model-chart-axis-label">
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{pointsForSeries.map((series) => (
|
||||||
|
<g key={series.model}>
|
||||||
|
<path d={buildLinePath(series.points)} fill="none" stroke={series.color} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
{series.points.map((point, index) => (
|
||||||
|
<circle
|
||||||
|
key={`${series.model}-${labels[index] || index}`}
|
||||||
|
cx={point.x}
|
||||||
|
cy={point.y}
|
||||||
|
r="5.5"
|
||||||
|
fill={series.color}
|
||||||
|
stroke="rgba(255,255,255,0.95)"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="platform-usage-summary">
|
||||||
|
<span>{isZh ? `请求 ${usageSummary?.request_count || 0}` : `Requests ${usageSummary?.request_count || 0}`}</span>
|
||||||
|
<span>{isZh ? `总 Tokens ${usageSummary?.total_tokens || 0}` : `Total Tokens ${usageSummary?.total_tokens || 0}`}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -740,30 +811,17 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ops-searchbar platform-searchbar">
|
<ProtectedSearchInput
|
||||||
<input
|
className="platform-searchbar"
|
||||||
className="input ops-search-input ops-search-input-with-icon"
|
value={search}
|
||||||
type="search"
|
onChange={setSearch}
|
||||||
value={search}
|
onClear={() => setSearch('')}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
debounceMs={120}
|
||||||
placeholder={isZh ? '搜索 Bot 名称或 ID' : 'Search bot name or id'}
|
placeholder={isZh ? '搜索 Bot 名称或 ID' : 'Search bot name or id'}
|
||||||
aria-label={isZh ? '搜索 Bot 名称或 ID' : 'Search bot name or id'}
|
ariaLabel={isZh ? '搜索 Bot 名称或 ID' : 'Search bot name or id'}
|
||||||
autoComplete="new-password"
|
clearTitle={isZh ? '清除搜索' : 'Clear search'}
|
||||||
autoCorrect="off"
|
searchTitle={isZh ? '搜索' : 'Search'}
|
||||||
autoCapitalize="none"
|
/>
|
||||||
spellCheck={false}
|
|
||||||
inputMode="search"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-search-inline-btn"
|
|
||||||
onClick={() => setSearch('')}
|
|
||||||
title={search.trim() ? (isZh ? '清除搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
|
|
||||||
aria-label={search.trim() ? (isZh ? '清除搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
|
|
||||||
>
|
|
||||||
{search.trim() ? <X size={14} /> : <Search size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="list-scroll platform-bot-list-scroll">
|
<div className="list-scroll platform-bot-list-scroll">
|
||||||
{!pageSizeReady ? (
|
{!pageSizeReady ? (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2, X } from 'lucide-react';
|
||||||
import { APP_ENDPOINTS } from '../../../config/env';
|
import { APP_ENDPOINTS } from '../../../config/env';
|
||||||
|
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
||||||
import type { PlatformSettings, SystemSettingItem } from '../types';
|
import type { PlatformSettings, SystemSettingItem } from '../types';
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||||
|
|
@ -170,30 +171,18 @@ export function PlatformSettingsModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="platform-settings-toolbar">
|
<div className="platform-settings-toolbar">
|
||||||
<div className="ops-searchbar platform-searchbar platform-settings-search">
|
<ProtectedSearchInput
|
||||||
<input
|
className="platform-searchbar platform-settings-search"
|
||||||
className="input ops-search-input ops-search-input-with-icon"
|
value={search}
|
||||||
type="search"
|
onChange={setSearch}
|
||||||
value={search}
|
onClear={() => setSearch('')}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
autoFocus
|
||||||
placeholder={isZh ? '搜索参数...' : 'Search settings...'}
|
debounceMs={120}
|
||||||
aria-label={isZh ? '搜索参数' : 'Search settings'}
|
placeholder={isZh ? '搜索参数...' : 'Search settings...'}
|
||||||
autoComplete="new-password"
|
ariaLabel={isZh ? '搜索参数' : 'Search settings'}
|
||||||
autoCorrect="off"
|
clearTitle={isZh ? '清除搜索' : 'Clear search'}
|
||||||
autoCapitalize="none"
|
searchTitle={isZh ? '搜索' : 'Search'}
|
||||||
spellCheck={false}
|
/>
|
||||||
inputMode="search"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-search-inline-btn"
|
|
||||||
onClick={() => setSearch('')}
|
|
||||||
title={search.trim() ? (isZh ? '清除搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
|
|
||||||
aria-label={search.trim() ? (isZh ? '清除搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
|
|
||||||
>
|
|
||||||
{search.trim() ? <X size={14} /> : <Search size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { ChevronLeft, ChevronRight, FileArchive, Hammer, Pencil, Plus, RefreshCw, Search, Trash2, Upload, X } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, FileArchive, Hammer, Pencil, Plus, RefreshCw, Trash2, Upload, X } from 'lucide-react';
|
||||||
import { APP_ENDPOINTS } from '../../../config/env';
|
import { APP_ENDPOINTS } from '../../../config/env';
|
||||||
|
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
||||||
import type { SkillMarketItem } from '../types';
|
import type { SkillMarketItem } from '../types';
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||||
|
|
@ -290,25 +291,18 @@ function SkillMarketManagerView({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="skill-market-admin-toolbar">
|
<div className="skill-market-admin-toolbar">
|
||||||
<div className="ops-searchbar platform-searchbar skill-market-search">
|
<ProtectedSearchInput
|
||||||
<input
|
className="platform-searchbar skill-market-search"
|
||||||
className="input ops-search-input ops-search-input-with-icon"
|
value={search}
|
||||||
type="search"
|
onChange={setSearch}
|
||||||
value={search}
|
onClear={() => setSearch('')}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
autoFocus
|
||||||
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}
|
debounceMs={120}
|
||||||
aria-label={isZh ? '搜索技能市场' : 'Search skill marketplace'}
|
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}
|
||||||
/>
|
ariaLabel={isZh ? '搜索技能市场' : 'Search skill marketplace'}
|
||||||
<button
|
clearTitle={isZh ? '清空搜索' : 'Clear search'}
|
||||||
type="button"
|
searchTitle={isZh ? '搜索' : 'Search'}
|
||||||
className="ops-search-inline-btn"
|
/>
|
||||||
onClick={() => setSearch('')}
|
|
||||||
title={search.trim() ? (isZh ? '清空搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
|
|
||||||
aria-label={search.trim() ? (isZh ? '清空搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
|
|
||||||
>
|
|
||||||
{search.trim() ? <X size={14} /> : <Search size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="skill-market-admin-actions">
|
<div className="skill-market-admin-actions">
|
||||||
<button className="btn btn-secondary btn-sm" type="button" disabled={loading} onClick={() => void loadRows()}>
|
<button className="btn btn-secondary btn-sm" type="button" disabled={loading} onClick={() => void loadRows()}>
|
||||||
{loading ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
{loading ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,16 @@ export interface PlatformUsageResponse {
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
};
|
};
|
||||||
|
analytics: {
|
||||||
|
window_days: number;
|
||||||
|
days: string[];
|
||||||
|
total_requests: number;
|
||||||
|
series: Array<{
|
||||||
|
model: string;
|
||||||
|
total_requests: number;
|
||||||
|
daily_counts: number[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
items: PlatformUsageItem[];
|
items: PlatformUsageItem[];
|
||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
@ -140,6 +150,16 @@ export interface PlatformOverviewResponse {
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
};
|
};
|
||||||
|
analytics: {
|
||||||
|
window_days: number;
|
||||||
|
days: string[];
|
||||||
|
total_requests: number;
|
||||||
|
series: Array<{
|
||||||
|
model: string;
|
||||||
|
total_requests: number;
|
||||||
|
daily_counts: number[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
items: PlatformUsageItem[];
|
items: PlatformUsageItem[];
|
||||||
total?: number;
|
total?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue