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

View File

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

View File

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

View File

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

View File

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

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 { 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()}>

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', 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' },

View File

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

View File

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

View File

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

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 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': '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: { 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'}

View 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">

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/'; 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>
), ),

View File

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

View File

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

View File

@ -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': '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: { 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>

View File

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

View File

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

View File

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

View File

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