v0..1.4-p4

main
mula.liu 2026-03-27 02:09:25 +08:00
parent f77851d496
commit 1ef72df0b1
22 changed files with 1398 additions and 862 deletions

View File

@ -41,6 +41,7 @@ class BotConfigManager:
"xunfei": "openai",
"iflytek": "openai",
"xfyun": "openai",
"vllm": "openai",
}
provider_name = provider_alias.get(provider_name, provider_name)
if provider_name == "openai" and raw_provider_name in {"xunfei", "iflytek", "xfyun"}:
@ -50,6 +51,8 @@ class BotConfigManager:
provider_cfg: Dict[str, Any] = {
"apiKey": api_key,
}
if raw_provider_name in {"xunfei", "iflytek", "xfyun", "vllm"}:
provider_cfg["dashboardProviderAlias"] = raw_provider_name
if api_base:
provider_cfg["apiBase"] = api_base

View File

@ -652,6 +652,8 @@ def _provider_defaults(provider: str) -> tuple[str, str]:
return "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"
if p in {"xunfei", "iflytek", "xfyun"}:
return "openai", "https://spark-api-open.xf-yun.com/v1"
if p in {"vllm"}:
return "openai", ""
if p in {"kimi", "moonshot"}:
return "kimi", "https://api.moonshot.cn/v1"
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_base = str(provider_cfg.get("apiBase") or "").strip()
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"
soul_md = _read_workspace_md(bot.id, "SOUL.md", DEFAULT_SOUL_MD)

View File

@ -55,6 +55,19 @@ class PlatformUsageSummary(BaseModel):
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):
summary: PlatformUsageSummary
items: List[PlatformUsageItem]
@ -62,6 +75,7 @@ class PlatformUsageResponse(BaseModel):
limit: int
offset: int
has_more: bool
analytics: PlatformUsageAnalytics
class PlatformActivityItem(BaseModel):

View File

@ -3,6 +3,7 @@ import math
import os
import re
import uuid
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
@ -32,6 +33,8 @@ from models.platform import BotActivityEvent, BotRequestUsage, PlatformSetting
from schemas.platform import (
LoadingPageSettings,
PlatformActivityItem,
PlatformUsageAnalytics,
PlatformUsageAnalyticsSeries,
PlatformSettingsPayload,
PlatformUsageResponse,
PlatformUsageItem,
@ -922,9 +925,57 @@ def list_usage(
limit=safe_limit,
offset=safe_offset,
has_more=safe_offset + len(items) < total,
analytics=_build_usage_analytics(session, bot_id=bot_id),
).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(
session: Session,
bot_id: Optional[str] = None,

View File

@ -102,6 +102,15 @@ body {
gap: 8px;
}
.app-header-bot-mobile {
padding-top: 12px;
padding-bottom: 12px;
}
.app-header-top-bot-mobile {
align-items: center;
}
.app-header-collapsible {
transition: padding 0.2s ease;
}
@ -124,6 +133,7 @@ body {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.app-title-main {
@ -131,6 +141,7 @@ body {
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.app-title-icon {
@ -151,6 +162,17 @@ body {
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 {
margin: 2px 0 0;
color: var(--subtitle);
@ -181,6 +203,10 @@ body {
justify-content: flex-end;
}
.global-switches-compact-lite {
flex-wrap: nowrap;
}
.switch-compact {
display: inline-flex;
align-items: center;
@ -208,6 +234,7 @@ body {
color: var(--icon-muted);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
font-size: 11px;
@ -232,6 +259,25 @@ body {
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 {
min-height: 0;
height: 100%;
@ -2501,11 +2547,97 @@ body {
.platform-usage-summary {
display: inline-flex;
flex-wrap: wrap;
gap: 16px;
font-size: 12px;
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 {
display: flex;
flex-direction: column;
@ -2591,197 +2723,3 @@ body {
max-height: 84vh;
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;
}
}

View File

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

View File

@ -1,6 +1,6 @@
import { useEffect, useState, type ReactElement } from 'react';
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 { useBotsSync } from './hooks/useBotsSync';
import { APP_ENDPOINTS } from './config/env';
@ -16,6 +16,7 @@ import { PlatformDashboardPage } from './modules/platform/PlatformDashboardPage'
import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal';
import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute';
import './App.css';
import './App.h5.css';
const defaultLoadingPage = {
title: 'Dashboard Nanobot',
@ -23,6 +24,8 @@ const defaultLoadingPage = {
description: '请稍候,正在加载 Bot 平台数据。',
};
type CompactBotPanelTab = 'chat' | 'runtime';
function AuthenticatedApp() {
const route = useAppRoute();
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
@ -32,6 +35,8 @@ function AuthenticatedApp() {
return window.matchMedia('(max-width: 980px)').matches;
});
const [headerCollapsed, setHeaderCollapsed] = useState(false);
const [botPanelDrawerOpen, setBotPanelDrawerOpen] = useState(false);
const [botCompactPanelTab, setBotCompactPanelTab] = useState<CompactBotPanelTab>('chat');
const [singleBotPassword, setSingleBotPassword] = useState('');
const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
@ -59,13 +64,22 @@ function AuthenticatedApp() {
const compactMode = readCompactModeFromUrl() || viewportCompact;
const isCompactShell = compactMode;
const hideHeader = route.kind === 'dashboard' && compactMode;
const showBotPanelDrawerEntry = route.kind === 'bot' && compactMode;
const allowHeaderCollapse = isCompactShell && !showBotPanelDrawerEntry;
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(
route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked,
);
const headerTitle =
route.kind === 'bot'
? (forcedBot?.name || defaultLoadingPage.title)
showBotPanelDrawerEntry
? (botCompactPanelTab === 'runtime' ? t.botPanels.runtime : t.botPanels.chat)
: route.kind === 'bot'
? botHeaderTitle
: route.kind === 'dashboard-skills'
? (locale === 'zh' ? '技能市场管理' : 'Skill Marketplace')
: t.title;
@ -79,9 +93,8 @@ function AuthenticatedApp() {
document.title = `${t.title} - ${locale === 'zh' ? '技能市场' : 'Skill Marketplace'}`;
return;
}
const botName = String(forcedBot?.name || '').trim();
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`;
}, [forcedBot?.name, forcedBotId, locale, route.kind, t.title]);
document.title = `${t.title} - ${botDocumentTitle}`;
}, [botDocumentTitle, locale, route.kind, t.title]);
useEffect(() => {
setSingleBotUnlocked(false);
@ -89,6 +102,13 @@ function AuthenticatedApp() {
setSingleBotPasswordError('');
}, [forcedBotId]);
useEffect(() => {
if (!showBotPanelDrawerEntry) {
setBotPanelDrawerOpen(false);
setBotCompactPanelTab('chat');
}
}, [forcedBotId, showBotPanelDrawerEntry]);
useEffect(() => {
if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return;
const stored = getBotAccessPassword(forcedBotId);
@ -141,33 +161,57 @@ function AuthenticatedApp() {
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 (
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
<div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}>
{!hideHeader ? (
<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={() => {
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">
<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">
<h1>{headerTitle}</h1>
{route.kind === 'dashboard-skills' ? (
{!showBotPanelDrawerEntry && route.kind === 'dashboard-skills' ? (
<button type="button" className="app-route-subtitle app-route-crumb" onClick={navigateToDashboard}>
{locale === 'zh' ? '平台总览' : 'Platform Overview'}
</button>
) : (
) : !showBotPanelDrawerEntry ? (
<div className="app-route-subtitle">
{route.kind === 'dashboard'
? (locale === 'zh' ? '平台总览' : 'Platform overview')
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
: route.kind === 'bot'
? botHeaderSubtitle
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
</div>
)}
{isCompactShell ? (
) : null}
{allowHeaderCollapse ? (
<button
type="button"
className="app-header-toggle-inline"
@ -185,7 +229,28 @@ function AuthenticatedApp() {
</div>
<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="switch-compact">
<LucentTooltip content={t.dark}>
@ -225,11 +290,71 @@ function AuthenticatedApp() {
) : route.kind === 'dashboard-skills' ? (
<SkillMarketManagerPage isZh={locale === 'zh'} />
) : (
<BotHomePage botId={forcedBotId} compactMode={compactMode} />
<BotHomePage
botId={forcedBotId}
compactMode={compactMode}
compactPanelTab={botCompactPanelTab}
onCompactPanelTabChange={setBotCompactPanelTab}
/>
)}
</main>
</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 ? (
<div className="modal-mask app-modal-mask">
<div className="app-login-card" onClick={(event) => event.stopPropagation()}>

View File

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

View File

@ -9,6 +9,13 @@ export const appEn = {
close: 'Close',
expandHeader: 'Expand header',
collapseHeader: 'Collapse header',
botPanels: {
title: 'Panel Switcher',
subtitle: 'Switch the active bot panel',
trigger: 'Panels',
chat: 'Chat Panel',
runtime: 'Runtime Panel',
},
nav: {
images: { title: 'Image Factory', subtitle: 'Manage registered images' },
onboarding: { title: 'Creation Wizard', subtitle: 'Create bot step-by-step' },

View File

@ -9,6 +9,13 @@ export const appZhCn = {
close: '关闭',
expandHeader: '展开头部',
collapseHeader: '收起头部',
botPanels: {
title: '面板切换',
subtitle: '切换 Bot 页当前面板',
trigger: '面板',
chat: '对话面板',
runtime: '运行面板',
},
nav: {
images: { title: '镜像工厂', subtitle: '管理已登记镜像' },
onboarding: { title: '创建向导', subtitle: '分步创建 Bot' },

View File

@ -3,8 +3,17 @@ import { BotDashboardModule } from '../dashboard/BotDashboardModule';
interface BotHomePageProps {
botId: string;
compactMode: boolean;
compactPanelTab?: 'chat' | 'runtime';
onCompactPanelTabChange?: (tab: 'chat' | 'runtime') => void;
}
export function BotHomePage({ botId, compactMode }: BotHomePageProps) {
return <BotDashboardModule forcedBotId={botId} compactMode={compactMode} />;
export function BotHomePage({ botId, compactMode, compactPanelTab, onCompactPanelTabChange }: BotHomePageProps) {
return (
<BotDashboardModule
forcedBotId={botId}
compactMode={compactMode}
compactPanelTab={compactPanelTab}
onCompactPanelTabChange={onCompactPanelTabChange}
/>
);
}

View File

@ -28,104 +28,6 @@
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 {
position: relative;
display: inline-flex;
@ -142,6 +44,22 @@
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 {
min-width: 0;
}

View File

@ -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 { 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 remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
@ -19,9 +19,19 @@ import { dashboardEn } from '../../i18n/dashboard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../components/lucent/LucentSelect';
import { ProtectedSearchInput } from '../../components/ProtectedSearchInput';
import { MarkdownLiteEditor } from '../../components/markdown/MarkdownLiteEditor';
import { PasswordInput } from '../../components/PasswordInput';
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 { SkillMarketInstallModal } from './components/SkillMarketInstallModal';
import {
@ -36,6 +46,8 @@ interface BotDashboardModuleProps {
onOpenImageFactory?: () => void;
forcedBotId?: string;
compactMode?: boolean;
compactPanelTab?: CompactPanelTab;
onCompactPanelTabChange?: (tab: CompactPanelTab) => void;
}
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
@ -414,6 +426,13 @@ const providerPresets: Record<string, { model: string; apiBase?: string; note: {
en: 'OpenAI native endpoint',
},
},
vllm: {
model: 'Qwen/Qwen2.5-7B-Instruct',
note: {
'zh-cn': 'vLLMOpenAI 兼容)接口,请填写你的部署地址,例如 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: {
model: 'deepseek-chat',
note: {
@ -710,28 +729,6 @@ function workspaceFileAction(
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[] {
const path = String(pathRaw || '');
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[] {
if (!Array.isArray(raw)) return [];
return raw
@ -862,18 +769,6 @@ function normalizeAttachmentPaths(raw: unknown): string[] {
.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:';
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 } {
const source = String(input || '');
const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i);
@ -1061,6 +952,8 @@ export function BotDashboardModule({
onOpenImageFactory,
forcedBotId,
compactMode = false,
compactPanelTab: compactPanelTabProp,
onCompactPanelTabChange,
}: BotDashboardModuleProps) {
const {
activeBots,
@ -1234,7 +1127,7 @@ export function BotDashboardModule({
const [topicFeedDeleteSavingById, setTopicFeedDeleteSavingById] = useState<Record<number, boolean>>({});
const [topicFeedUnreadCount, setTopicFeedUnreadCount] = useState(0);
const [topicDetailOpen, setTopicDetailOpen] = useState(false);
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat');
const [compactPanelTabState, setCompactPanelTabState] = useState<CompactPanelTab>('chat');
const [isCompactMobile, setIsCompactMobile] = useState(false);
const [botListQuery, setBotListQuery] = useState('');
const [botListPage, setBotListPage] = useState(1);
@ -1299,28 +1192,14 @@ export function BotDashboardModule({
storage_gb: String(clampStorageGb(bot.storage_gb ?? 10)),
});
}, [defaultSystemTimezone]);
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => {
const query = [`path=${encodeURIComponent(filePath)}`];
if (forceDownload) query.push('download=1');
return `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/download?${query.join('&')}`;
};
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 getWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) =>
buildWorkspaceDownloadHref(selectedBotId, filePath, forceDownload);
const getWorkspaceRawHref = (filePath: string, forceDownload: boolean = false) =>
buildWorkspaceRawHref(selectedBotId, filePath, forceDownload);
const getWorkspacePreviewHref = (filePath: string) => {
const normalized = String(filePath || '').trim();
if (!normalized) return '';
return isHtmlPath(normalized)
? buildWorkspaceRawHref(normalized, false)
: buildWorkspaceDownloadHref(normalized, false);
return buildWorkspacePreviewHref(selectedBotId, normalized, { preferRaw: isHtmlPath(normalized) });
};
const closeWorkspacePreview = () => {
setWorkspacePreview(null);
@ -1347,7 +1226,7 @@ export function BotDashboardModule({
if (!normalized) return;
const filename = normalized.split('/').pop() || 'workspace-file';
const link = document.createElement('a');
link.href = buildWorkspaceDownloadHref(normalized, true);
link.href = getWorkspaceDownloadHref(normalized, true);
link.download = filename;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
@ -1357,7 +1236,7 @@ export function BotDashboardModule({
const copyWorkspacePreviewUrl = async (filePath: string) => {
const normalized = String(filePath || '').trim();
if (!selectedBotId || !normalized) return;
const hrefRaw = buildWorkspacePreviewHref(normalized);
const hrefRaw = getWorkspacePreviewHref(normalized);
const href = (() => {
try {
return new URL(hrefRaw, window.location.origin).href;
@ -1439,7 +1318,7 @@ export function BotDashboardModule({
if (!src || !selectedBotId) return src;
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath);
if (resolvedWorkspacePath) {
return buildWorkspacePreviewHref(resolvedWorkspacePath);
return getWorkspacePreviewHref(resolvedWorkspacePath);
}
const lower = src.toLowerCase();
if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) {
@ -1447,129 +1326,26 @@ export function BotDashboardModule({
}
return src;
}, [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(
() => ({
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
const link = String(href || '').trim();
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>
);
() => createWorkspaceMarkdownComponents(
(path) => {
void openWorkspacePathFromChat(path);
},
img: ({ src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>) => {
const resolvedSrc = resolveWorkspaceMediaSrc(String(src || ''));
return (
<img
src={resolvedSrc}
alt={String(alt || '')}
loading="lazy"
{...props}
/>
);
{ resolveMediaSrc: resolveWorkspaceMediaSrc },
),
[resolveWorkspaceMediaSrc],
);
const workspacePreviewMarkdownComponents = useMemo(
() => createWorkspaceMarkdownComponents(
(path) => {
void openWorkspacePathFromChat(path);
},
p: ({ children, ...props }: { children?: ReactNode }) => (
<p {...props}>{renderWorkspaceAwareChildren(children, 'md-p')}</p>
),
li: ({ children, ...props }: { children?: ReactNode }) => (
<li {...props}>{renderWorkspaceAwareChildren(children, 'md-li')}</li>
),
code: ({ children, ...props }: { children?: ReactNode }) => (
<code {...props}>{renderWorkspaceAwareChildren(children, 'md-code')}</code>
),
}),
[fileNotPreviewableLabel, notify, resolveWorkspaceMediaSrc, selectedBotId],
{
baseFilePath: workspacePreview?.path,
resolveMediaSrc: resolveWorkspaceMediaSrc,
},
),
[resolveWorkspaceMediaSrc, workspacePreview?.path],
);
const [editForm, setEditForm] = useState({
@ -1638,6 +1414,14 @@ export function BotDashboardModule({
const compactListFirstMode = compactMode && !hasForcedBot;
const isCompactListPage = compactListFirstMode && !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 normalizedBotListQuery = botListQuery.trim().toLowerCase();
const filteredBots = useMemo(() => {
@ -5306,43 +5090,22 @@ export function BotDashboardModule({
</div>
<div className="ops-bot-list-toolbar">
<div className="ops-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={botListQuery}
onChange={(e) => setBotListQuery(e.target.value)}
placeholder={t.botSearchPlaceholder}
aria-label={t.botSearchPlaceholder}
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
inputMode="search"
name={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>
<ProtectedSearchInput
value={botListQuery}
onChange={setBotListQuery}
onClear={() => {
setBotListQuery('');
setBotListPage(1);
}}
onSearchAction={() => setBotListPage(1)}
debounceMs={120}
placeholder={t.botSearchPlaceholder}
ariaLabel={t.botSearchPlaceholder}
clearTitle={t.clearSearch}
searchTitle={t.searchAction}
name={botSearchInputName}
id={botSearchInputName}
/>
</div>
<div className="list-scroll">
@ -5552,6 +5315,7 @@ export function BotDashboardModule({
onDeleteItem={(item) => void deleteTopicFeedItem(item)}
onLoadMore={() => void loadTopicFeed({ append: true, cursor: topicFeedNextCursor, topicKey: topicFeedTopicKey })}
onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)}
resolveWorkspaceMediaSrc={resolveWorkspaceMediaSrc}
onOpenTopicSettings={() => {
if (selectedBot) openTopicModal(selectedBot.id);
}}
@ -6125,42 +5889,19 @@ export function BotDashboardModule({
</div>
</div>
<div className="workspace-search-toolbar">
<div className="ops-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={workspaceQuery}
onChange={(e) => setWorkspaceQuery(e.target.value)}
placeholder={t.workspaceSearchPlaceholder}
aria-label={t.workspaceSearchPlaceholder}
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
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>
<ProtectedSearchInput
value={workspaceQuery}
onChange={setWorkspaceQuery}
onClear={() => setWorkspaceQuery('')}
onSearchAction={() => setWorkspaceQuery((value) => value.trim())}
debounceMs={200}
placeholder={t.workspaceSearchPlaceholder}
ariaLabel={t.workspaceSearchPlaceholder}
clearTitle={t.clearSearch}
searchTitle={t.searchAction}
name={workspaceSearchInputName}
id={workspaceSearchInputName}
/>
</div>
<div className="workspace-panel">
<div className="workspace-list">
@ -6207,19 +5948,6 @@ export function BotDashboardModule({
<X size={16} />
</LucentIconButton>
) : 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 && (
<div className="modal-mask" onClick={() => setShowResourceModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
@ -6417,6 +6145,7 @@ export function BotDashboardModule({
<option value="openrouter">openrouter</option>
<option value="dashscope">dashscope (aliyun qwen)</option>
<option value="openai">openai</option>
<option value="vllm">vllm (openai-compatible)</option>
<option value="deepseek">deepseek</option>
<option value="kimi">kimi (moonshot)</option>
<option value="minimax">minimax</option>
@ -7843,27 +7572,27 @@ export function BotDashboardModule({
{workspacePreview.isImage ? (
<img
className="workspace-preview-image"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
src={getWorkspaceDownloadHref(workspacePreview.path, false)}
alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
/>
) : workspacePreview.isVideo ? (
<video
className="workspace-preview-media"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
src={getWorkspaceDownloadHref(workspacePreview.path, false)}
controls
preload="metadata"
/>
) : workspacePreview.isAudio ? (
<audio
className="workspace-preview-audio"
src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
src={getWorkspaceDownloadHref(workspacePreview.path, false)}
controls
preload="metadata"
/>
) : workspacePreview.isHtml ? (
<iframe
className="workspace-preview-embed"
src={buildWorkspaceRawHref(workspacePreview.path, false)}
src={getWorkspaceRawHref(workspacePreview.path, false)}
title={workspacePreview.path}
/>
) : workspacePreviewEditorEnabled ? (
@ -7883,7 +7612,7 @@ export function BotDashboardModule({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents}
components={workspacePreviewMarkdownComponents}
>
{decorateWorkspacePathsForMarkdown(workspacePreview.content || '')}
</ReactMarkdown>
@ -7937,7 +7666,7 @@ export function BotDashboardModule({
) : (
<a
className="btn btn-secondary"
href={buildWorkspaceDownloadHref(workspacePreview.path, true)}
href={getWorkspaceDownloadHref(workspacePreview.path, true)}
target="_blank"
rel="noopener noreferrer"
download={workspacePreview.path.split('/').pop() || 'workspace-file'}

View File

@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
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 type { BotSkillMarketItem } from '../../platform/types';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import {
normalizePlatformPageSize,
readCachedPlatformPageSize,
@ -100,25 +101,18 @@ export function SkillMarketInstallModal({
</div>
<div className="skill-market-browser-toolbar">
<div className="ops-searchbar platform-searchbar skill-market-search">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}
aria-label={isZh ? '搜索技能市场' : 'Search skill marketplace'}
/>
<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>
<ProtectedSearchInput
className="platform-searchbar skill-market-search"
value={search}
onChange={setSearch}
onClear={() => setSearch('')}
autoFocus
debounceMs={120}
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}
ariaLabel={isZh ? '搜索技能市场' : 'Search skill marketplace'}
clearTitle={isZh ? '清空搜索' : 'Clear search'}
searchTitle={isZh ? '搜索' : 'Search'}
/>
</div>
<div className="skill-market-browser-grid">

View File

@ -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/';
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;
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 '';
const prefix = '/root/.nanobot/workspace/';
if (v.startsWith(prefix)) return v.slice(prefix.length);
@ -20,6 +24,39 @@ export function buildWorkspaceLink(path: string) {
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 {
const link = String(href || '').trim();
if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null;
@ -36,6 +73,45 @@ function isExternalHttpLink(href: string): boolean {
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 {
if (!source) return source;
const protectedLinks: string[] = [];
@ -160,11 +236,20 @@ function renderWorkspaceAwareChildren(
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 {
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
const link = String(href || '').trim();
const workspacePath = parseWorkspaceLink(link);
const workspacePath = parseWorkspaceLink(link) || resolveWorkspaceDocumentPath(link, baseFilePath);
if (workspacePath) {
return (
<a
@ -192,6 +277,18 @@ export function createWorkspaceMarkdownComponents(openWorkspacePath: (path: stri
</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 {...props}>{renderWorkspaceAwareChildren(children, 'md-p', openWorkspacePath)}</p>
),

View File

@ -7,7 +7,11 @@ import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../../components/lucent/LucentSelect';
import { createWorkspaceMarkdownComponents, decorateWorkspacePathsForMarkdown } from '../shared/workspaceMarkdown';
import {
createWorkspaceMarkdownComponents,
decorateWorkspacePathsForMarkdown,
resolveWorkspaceDocumentPath,
} from '../shared/workspaceMarkdown';
export interface TopicFeedItem {
id: number;
@ -47,6 +51,7 @@ interface TopicFeedPanelProps {
onDeleteItem: (item: TopicFeedItem) => void;
onLoadMore: () => void;
onOpenWorkspacePath: (path: string) => void;
resolveWorkspaceMediaSrc?: (src: string, baseFilePath?: string) => string;
onOpenTopicSettings?: () => void;
onDetailOpenChange?: (open: boolean) => void;
layout?: 'compact' | 'panel';
@ -148,14 +153,11 @@ export function TopicFeedPanel({
onDeleteItem,
onLoadMore,
onOpenWorkspacePath,
resolveWorkspaceMediaSrc,
onOpenTopicSettings,
onDetailOpenChange,
layout = 'compact',
}: TopicFeedPanelProps) {
const markdownComponents = useMemo(
() => createWorkspaceMarkdownComponents((path) => onOpenWorkspacePath(path)),
[onOpenWorkspacePath],
);
const [detailState, setDetailState] = useState<TopicDetailState | null>(null);
const closeDetail = useCallback(() => setDetailState(null), []);
const detailItem = useMemo(
@ -168,6 +170,30 @@ export function TopicFeedPanel({
const detailContent = detailItem
? String(detailItem.content || 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(() => {
if (typeof document === 'undefined') return null;
return document.querySelector('.app-shell[data-theme]') || document.body;

View File

@ -128,6 +128,7 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
onChange={(e) => setFormData({ ...formData, llm_provider: e.target.value })}
>
<option value="openai">OpenAI</option>
<option value="vllm">vLLM (OpenAI-compatible)</option>
<option value="deepseek">DeepSeek</option>
<option value="kimi">Kimi (Moonshot)</option>
<option value="minimax">MiniMax</option>

View File

@ -70,6 +70,13 @@ const providerPresets: Record<string, { model: string; note: { 'zh-cn': string;
en: 'OpenAI native models.',
},
},
vllm: {
model: 'Qwen/Qwen2.5-7B-Instruct',
note: {
'zh-cn': 'vLLMOpenAI 兼容)接口,请填写你的部署地址,例如 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: {
model: 'deepseek-chat',
note: {
@ -752,6 +759,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
<option value="openrouter">openrouter</option>
<option value="dashscope">dashscope (aliyun qwen)</option>
<option value="openai">openai</option>
<option value="vllm">vllm (openai-compatible)</option>
<option value="deepseek">deepseek</option>
<option value="kimi">kimi (moonshot)</option>
<option value="minimax">minimax</option>

View File

@ -17,7 +17,6 @@ import {
Lock,
Power,
RefreshCw,
Search,
Settings2,
Square,
Sparkles,
@ -25,6 +24,7 @@ import {
X,
} from 'lucide-react';
import { APP_ENDPOINTS } from '../../config/env';
import { ProtectedSearchInput } from '../../components/ProtectedSearchInput';
import { ImageFactoryModule } from '../images/ImageFactoryModule';
import { BotWizardModule } from '../onboarding/BotWizardModule';
import { useAppStore } from '../../store/appStore';
@ -58,23 +58,28 @@ function clampPercent(value: number) {
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) {
const encodedId = encodeURIComponent(String(botId || '').trim());
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 {
compactMode: boolean;
}
@ -422,9 +427,6 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
const overviewImages = overview?.summary.images;
const overviewResources = overview?.summary.resources;
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 lastActionPreview = selectedBotInfo?.last_action?.trim() || '';
const memoryPercent =
@ -436,6 +438,39 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
? clampPercent((overviewResources.workspace_used_bytes / overviewResources.workspace_limit_bytes) * 100)
: 0;
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(() => {
if (compactMode && showCompactBotSheet && selectedBotInfo) {
@ -643,82 +678,118 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
const renderUsageSection = () => (
<section className="panel stack">
<div className="row-between">
<div className="platform-model-analytics-head">
<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 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 className="platform-model-analytics-total">
<strong>{usageAnalytics?.total_requests || 0}</strong>
<span>{isZh ? '总调用次数' : 'Total Requests'}</span>
</div>
</div>
<div className="platform-usage-table">
<div className="platform-usage-head">
<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>
{usageLoading && !usageAnalytics ? (
<div className="ops-empty-inline">{isZh ? '正在加载模型调用统计...' : 'Loading model analytics...'}</div>
) : 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>
);
@ -740,30 +811,17 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
</div>
</div>
<div className="ops-searchbar platform-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={isZh ? '搜索 Bot 名称或 ID' : 'Search bot name or id'}
aria-label={isZh ? '搜索 Bot 名称或 ID' : 'Search bot name or id'}
autoComplete="new-password"
autoCorrect="off"
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>
<ProtectedSearchInput
className="platform-searchbar"
value={search}
onChange={setSearch}
onClear={() => setSearch('')}
debounceMs={120}
placeholder={isZh ? '搜索 Bot 名称或 ID' : 'Search bot name or id'}
ariaLabel={isZh ? '搜索 Bot 名称或 ID' : 'Search bot name or id'}
clearTitle={isZh ? '清除搜索' : 'Clear search'}
searchTitle={isZh ? '搜索' : 'Search'}
/>
<div className="list-scroll platform-bot-list-scroll">
{!pageSizeReady ? (

View File

@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
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 { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import type { PlatformSettings, SystemSettingItem } from '../types';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
@ -170,30 +171,18 @@ export function PlatformSettingsModal({
</div>
<div className="platform-settings-toolbar">
<div className="ops-searchbar platform-searchbar platform-settings-search">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={isZh ? '搜索参数...' : 'Search settings...'}
aria-label={isZh ? '搜索参数' : 'Search settings'}
autoComplete="new-password"
autoCorrect="off"
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>
<ProtectedSearchInput
className="platform-searchbar platform-settings-search"
value={search}
onChange={setSearch}
onClear={() => setSearch('')}
autoFocus
debounceMs={120}
placeholder={isZh ? '搜索参数...' : 'Search settings...'}
ariaLabel={isZh ? '搜索参数' : 'Search settings'}
clearTitle={isZh ? '清除搜索' : 'Clear search'}
searchTitle={isZh ? '搜索' : 'Search'}
/>
<button
className="btn btn-primary"
type="button"

View File

@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
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 { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import type { SkillMarketItem } from '../types';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
@ -290,25 +291,18 @@ function SkillMarketManagerView({
</div>
<div className="skill-market-admin-toolbar">
<div className="ops-searchbar platform-searchbar skill-market-search">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}
aria-label={isZh ? '搜索技能市场' : 'Search skill marketplace'}
/>
<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>
<ProtectedSearchInput
className="platform-searchbar skill-market-search"
value={search}
onChange={setSearch}
onClear={() => setSearch('')}
autoFocus
debounceMs={120}
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}
ariaLabel={isZh ? '搜索技能市场' : 'Search skill marketplace'}
clearTitle={isZh ? '清空搜索' : 'Clear search'}
searchTitle={isZh ? '搜索' : 'Search'}
/>
<div className="skill-market-admin-actions">
<button className="btn btn-secondary btn-sm" type="button" disabled={loading} onClick={() => void loadRows()}>
{loading ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}

View File

@ -53,6 +53,16 @@ export interface PlatformUsageResponse {
output_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[];
total: number;
limit: number;
@ -140,6 +150,16 @@ export interface PlatformOverviewResponse {
output_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[];
total?: number;
limit?: number;