From 1ef72df0b1cb0218994058eae00716a054db3a30 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Fri, 27 Mar 2026 02:09:25 +0800 Subject: [PATCH] v0..1.4-p4 --- backend/core/config_manager.py | 3 + backend/main.py | 7 +- backend/schemas/platform.py | 14 + backend/services/platform_service.py | 51 ++ frontend/src/App.css | 326 +++++-------- frontend/src/App.h5.css | 379 ++++++++++++++ frontend/src/App.tsx | 159 +++++- .../src/components/ProtectedSearchInput.tsx | 164 +++++++ frontend/src/i18n/app.en.ts | 7 + frontend/src/i18n/app.zh-cn.ts | 7 + frontend/src/modules/bot-home/BotHomePage.tsx | 13 +- .../modules/dashboard/BotDashboardModule.css | 114 +---- .../modules/dashboard/BotDashboardModule.tsx | 461 ++++-------------- .../components/SkillMarketInstallModal.tsx | 34 +- .../dashboard/shared/workspaceMarkdown.tsx | 105 +++- .../dashboard/topic/TopicFeedPanel.tsx | 36 +- .../management/components/CreateBotModal.tsx | 1 + .../modules/onboarding/BotWizardModule.tsx | 8 + .../platform/PlatformDashboardPage.tsx | 278 ++++++----- .../components/PlatformSettingsModal.tsx | 39 +- .../components/SkillMarketManagerModal.tsx | 34 +- frontend/src/modules/platform/types.ts | 20 + 22 files changed, 1398 insertions(+), 862 deletions(-) create mode 100644 frontend/src/App.h5.css create mode 100644 frontend/src/components/ProtectedSearchInput.tsx diff --git a/backend/core/config_manager.py b/backend/core/config_manager.py index c9e9c1a..b42fd27 100644 --- a/backend/core/config_manager.py +++ b/backend/core/config_manager.py @@ -41,6 +41,7 @@ class BotConfigManager: "xunfei": "openai", "iflytek": "openai", "xfyun": "openai", + "vllm": "openai", } provider_name = provider_alias.get(provider_name, provider_name) if provider_name == "openai" and raw_provider_name in {"xunfei", "iflytek", "xfyun"}: @@ -50,6 +51,8 @@ class BotConfigManager: provider_cfg: Dict[str, Any] = { "apiKey": api_key, } + if raw_provider_name in {"xunfei", "iflytek", "xfyun", "vllm"}: + provider_cfg["dashboardProviderAlias"] = raw_provider_name if api_base: provider_cfg["apiBase"] = api_base diff --git a/backend/main.py b/backend/main.py index 5418003..4c47a02 100644 --- a/backend/main.py +++ b/backend/main.py @@ -652,6 +652,8 @@ def _provider_defaults(provider: str) -> tuple[str, str]: return "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1" if p in {"xunfei", "iflytek", "xfyun"}: return "openai", "https://spark-api-open.xf-yun.com/v1" + if p in {"vllm"}: + return "openai", "" if p in {"kimi", "moonshot"}: return "kimi", "https://api.moonshot.cn/v1" if p in {"minimax"}: @@ -1360,7 +1362,10 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: api_key = str(provider_cfg.get("apiKey") or "").strip() api_base = str(provider_cfg.get("apiBase") or "").strip() api_base_lower = api_base.lower() - if llm_provider == "openai" and ("spark-api-open.xf-yun.com" in api_base_lower or "xf-yun.com" in api_base_lower): + provider_alias = str(provider_cfg.get("dashboardProviderAlias") or "").strip().lower() + if llm_provider == "openai" and provider_alias in {"xunfei", "iflytek", "xfyun", "vllm"}: + llm_provider = "xunfei" if provider_alias in {"iflytek", "xfyun"} else provider_alias + elif llm_provider == "openai" and ("spark-api-open.xf-yun.com" in api_base_lower or "xf-yun.com" in api_base_lower): llm_provider = "xunfei" soul_md = _read_workspace_md(bot.id, "SOUL.md", DEFAULT_SOUL_MD) diff --git a/backend/schemas/platform.py b/backend/schemas/platform.py index 4fddb04..ed79fe2 100644 --- a/backend/schemas/platform.py +++ b/backend/schemas/platform.py @@ -55,6 +55,19 @@ class PlatformUsageSummary(BaseModel): total_tokens: int +class PlatformUsageAnalyticsSeries(BaseModel): + model: str + total_requests: int + daily_counts: List[int] + + +class PlatformUsageAnalytics(BaseModel): + window_days: int + days: List[str] + total_requests: int + series: List[PlatformUsageAnalyticsSeries] + + class PlatformUsageResponse(BaseModel): summary: PlatformUsageSummary items: List[PlatformUsageItem] @@ -62,6 +75,7 @@ class PlatformUsageResponse(BaseModel): limit: int offset: int has_more: bool + analytics: PlatformUsageAnalytics class PlatformActivityItem(BaseModel): diff --git a/backend/services/platform_service.py b/backend/services/platform_service.py index 1b28806..3882dfb 100644 --- a/backend/services/platform_service.py +++ b/backend/services/platform_service.py @@ -3,6 +3,7 @@ import math import os import re import uuid +from collections import defaultdict from datetime import datetime, timedelta from typing import Any, Dict, List, Optional @@ -32,6 +33,8 @@ from models.platform import BotActivityEvent, BotRequestUsage, PlatformSetting from schemas.platform import ( LoadingPageSettings, PlatformActivityItem, + PlatformUsageAnalytics, + PlatformUsageAnalyticsSeries, PlatformSettingsPayload, PlatformUsageResponse, PlatformUsageItem, @@ -922,9 +925,57 @@ def list_usage( limit=safe_limit, offset=safe_offset, has_more=safe_offset + len(items) < total, + analytics=_build_usage_analytics(session, bot_id=bot_id), ).model_dump() +def _build_usage_analytics( + session: Session, + bot_id: Optional[str] = None, + window_days: int = 7, +) -> PlatformUsageAnalytics: + safe_window_days = max(1, int(window_days or 0)) + today = _utcnow().date() + days = [today - timedelta(days=offset) for offset in range(safe_window_days - 1, -1, -1)] + day_keys = [day.isoformat() for day in days] + day_labels = [day.strftime("%m-%d") for day in days] + first_day = days[0] + first_started_at = datetime.combine(first_day, datetime.min.time()) + + stmt = select(BotRequestUsage.model, BotRequestUsage.started_at).where(BotRequestUsage.started_at >= first_started_at) + if bot_id: + stmt = stmt.where(BotRequestUsage.bot_id == bot_id) + + counts_by_model: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int)) + total_requests = 0 + for model_name, started_at in session.exec(stmt).all(): + if not started_at: + continue + day_key = started_at.date().isoformat() + if day_key not in day_keys: + continue + normalized_model = str(model_name or "").strip() or "Unknown" + counts_by_model[normalized_model][day_key] += 1 + total_requests += 1 + + series = [ + PlatformUsageAnalyticsSeries( + model=model_name, + total_requests=sum(day_counts.values()), + daily_counts=[int(day_counts.get(day_key, 0)) for day_key in day_keys], + ) + for model_name, day_counts in counts_by_model.items() + ] + series.sort(key=lambda item: (-item.total_requests, item.model.lower())) + + return PlatformUsageAnalytics( + window_days=safe_window_days, + days=day_labels, + total_requests=total_requests, + series=series, + ) + + def list_activity_events( session: Session, bot_id: Optional[str] = None, diff --git a/frontend/src/App.css b/frontend/src/App.css index bf97105..a0def59 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -102,6 +102,15 @@ body { gap: 8px; } +.app-header-bot-mobile { + padding-top: 12px; + padding-bottom: 12px; +} + +.app-header-top-bot-mobile { + align-items: center; +} + .app-header-collapsible { transition: padding 0.2s ease; } @@ -124,6 +133,7 @@ body { display: flex; align-items: center; gap: 10px; + min-width: 0; } .app-title-main { @@ -131,6 +141,7 @@ body { align-items: center; gap: 8px; flex-wrap: wrap; + min-width: 0; } .app-title-icon { @@ -151,6 +162,17 @@ body { color: var(--title); } +.app-header-top-bot-mobile .app-title-main { + flex-direction: column; + align-items: flex-start; + gap: 0; +} + +.app-header-top-bot-mobile .app-title h1 { + font-size: 18px; + line-height: 1.15; +} + .app-title p { margin: 2px 0 0; color: var(--subtitle); @@ -181,6 +203,10 @@ body { justify-content: flex-end; } +.global-switches-compact-lite { + flex-wrap: nowrap; +} + .switch-compact { display: inline-flex; align-items: center; @@ -208,6 +234,7 @@ body { color: var(--icon-muted); display: inline-flex; align-items: center; + justify-content: center; gap: 4px; cursor: pointer; font-size: 11px; @@ -232,6 +259,25 @@ body { color: var(--icon); } +.switch-btn-lite { + min-width: 34px; + padding: 0 10px; + border-color: var(--line); + background: var(--panel-soft); + color: var(--icon); +} + +.switch-btn-lite:hover { + border-color: color-mix(in oklab, var(--brand) 40%, var(--line) 60%); + color: var(--brand); +} + +.switch-btn-lang-lite { + min-width: 42px; + font-size: 12px; + gap: 0; +} + .main-stage { min-height: 0; height: 100%; @@ -2501,11 +2547,97 @@ body { .platform-usage-summary { display: inline-flex; + flex-wrap: wrap; gap: 16px; font-size: 12px; color: var(--muted); } +.platform-model-analytics-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.platform-model-analytics-subtitle { + margin-top: 6px; + font-size: 13px; + color: var(--muted); +} + +.platform-model-analytics-total { + flex: 0 0 auto; + display: grid; + gap: 6px; + justify-items: end; + text-align: right; +} + +.platform-model-analytics-total strong { + font-size: clamp(28px, 4vw, 48px); + line-height: 1; +} + +.platform-model-analytics-total span { + font-size: 13px; + color: var(--muted); +} + +.platform-model-analytics-legend { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.platform-model-analytics-chip { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 999px; + background: color-mix(in oklab, var(--panel-soft) 82%, transparent); + border: 1px solid color-mix(in oklab, var(--line) 76%, transparent); + font-size: 13px; +} + +.platform-model-analytics-chip strong { + font-size: 13px; +} + +.platform-model-analytics-chip span:last-child { + color: var(--muted); +} + +.platform-model-analytics-chip-dot { + width: 12px; + height: 12px; + border-radius: 999px; + flex: 0 0 auto; +} + +.platform-model-chart { + width: 100%; + overflow-x: auto; +} + +.platform-model-chart svg { + display: block; + width: 100%; + min-width: 720px; + height: auto; +} + +.platform-model-chart-grid { + stroke: color-mix(in oklab, var(--brand-soft) 34%, var(--line) 66%); + stroke-width: 1; +} + +.platform-model-chart-axis-label { + fill: var(--muted); + font-size: 12px; +} + .platform-usage-table { display: flex; flex-direction: column; @@ -2591,197 +2723,3 @@ body { max-height: 84vh; overflow-y: auto; } - -@media (max-width: 1400px) { - .grid-ops { - grid-template-columns: 280px 1fr 320px; - } - - .grid-ops.grid-ops-forced { - grid-template-columns: minmax(0, 1fr) 320px; - } - - .grid-ops.grid-ops-compact { - grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); - } - - .platform-summary-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .platform-resource-card { - grid-column: span 3; - } -} - -@media (max-width: 1160px) { - .grid-2, - .grid-ops, - .wizard-steps, - .wizard-steps-4, - .factory-kpi-grid, - .summary-grid, - .wizard-agent-layout { - grid-template-columns: 1fr; - } - - .platform-grid, - .platform-main-grid, - .platform-monitor-grid, - .platform-entry-grid, - .platform-summary-grid { - grid-template-columns: 1fr; - } - - .platform-resource-card { - grid-column: auto; - } - - .platform-template-layout { - grid-template-columns: 1fr; - } - - .skill-market-admin-layout, - .skill-market-card-grid, - .skill-market-browser-grid { - grid-template-columns: 1fr; - } - - .skill-market-list-shell { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .platform-template-tabs { - max-height: 220px; - } - - .platform-usage-head, - .platform-usage-row { - grid-template-columns: minmax(140px, 1.1fr) minmax(200px, 1.8fr) minmax(160px, 1fr) 70px 70px 70px 100px; - } - - .app-frame { - height: auto; - min-height: calc(100vh - 36px); - } - - .app-shell-compact .app-frame { - height: calc(100dvh - 24px); - min-height: calc(100dvh - 24px); - } - - .app-shell { - padding: 12px; - } - - .app-header-top { - flex-direction: column; - align-items: flex-start; - } - - .global-switches { - width: 100%; - justify-content: flex-start; - } - - .wizard-shell { - min-height: 640px; - } -} - -@media (max-width: 980px) { - .grid-ops.grid-ops-compact { - grid-template-columns: 1fr; - grid-template-rows: minmax(0, 1fr); - } - - .app-shell-compact .grid-ops.grid-ops-compact { - grid-template-columns: 1fr; - grid-template-rows: minmax(0, 1fr); - height: 100%; - min-height: 0; - } - - .platform-bot-list-panel { - min-height: calc(100dvh - 170px); - } - - .platform-bot-actions, - .platform-image-row, - .platform-activity-row { - flex-direction: column; - align-items: flex-start; - } - - .platform-selected-bot-headline { - gap: 8px; - } - - .platform-selected-bot-head { - flex-direction: column; - align-items: stretch; - } - - .platform-selected-bot-actions { - justify-content: flex-start; - } - - .platform-selected-bot-grid { - grid-template-columns: 1fr; - } - - .platform-resource-meter { - grid-template-columns: 24px minmax(0, 1fr) 64px; - } - - .platform-usage-head { - display: none; - } - - .platform-usage-row { - grid-template-columns: 1fr; - } - - .platform-selected-bot-last-row, - .platform-settings-pager, - .platform-usage-pager, - .platform-template-header, - .skill-market-admin-toolbar, - .skill-market-browser-toolbar, - .skill-market-pager, - .skill-market-page-info-card, - .skill-market-page-info-main, - .skill-market-editor-head, - .skill-market-card-top, - .skill-market-card-footer, - .row-actions-inline { - flex-direction: column; - align-items: stretch; - } - - .platform-compact-sheet-card { - max-height: 90dvh; - } - - .platform-compact-sheet-body { - max-height: calc(90dvh - 60px); - padding: 0 10px 12px; - } - - .skill-market-list-shell { - grid-template-columns: 1fr; - } - - .skill-market-drawer { - position: fixed; - top: 84px; - right: 12px; - bottom: 12px; - width: min(460px, calc(100vw - 24px)); - } - - .app-route-crumb { - width: 100%; - text-align: left; - } -} diff --git a/frontend/src/App.h5.css b/frontend/src/App.h5.css new file mode 100644 index 0000000..d6e5a09 --- /dev/null +++ b/frontend/src/App.h5.css @@ -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; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8504eb..e59e0b9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, type ReactElement } from 'react'; import axios from 'axios'; -import { ChevronDown, ChevronUp, MoonStar, SunMedium } from 'lucide-react'; +import { Activity, ChevronDown, ChevronUp, Menu, MessageSquareText, MoonStar, SunMedium, X } from 'lucide-react'; import { useAppStore } from './store/appStore'; import { useBotsSync } from './hooks/useBotsSync'; import { APP_ENDPOINTS } from './config/env'; @@ -16,6 +16,7 @@ import { PlatformDashboardPage } from './modules/platform/PlatformDashboardPage' import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal'; import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute'; import './App.css'; +import './App.h5.css'; const defaultLoadingPage = { title: 'Dashboard Nanobot', @@ -23,6 +24,8 @@ const defaultLoadingPage = { description: '请稍候,正在加载 Bot 平台数据。', }; +type CompactBotPanelTab = 'chat' | 'runtime'; + function AuthenticatedApp() { const route = useAppRoute(); const { theme, setTheme, locale, setLocale, activeBots } = useAppStore(); @@ -32,6 +35,8 @@ function AuthenticatedApp() { return window.matchMedia('(max-width: 980px)').matches; }); const [headerCollapsed, setHeaderCollapsed] = useState(false); + const [botPanelDrawerOpen, setBotPanelDrawerOpen] = useState(false); + const [botCompactPanelTab, setBotCompactPanelTab] = useState('chat'); const [singleBotPassword, setSingleBotPassword] = useState(''); const [singleBotPasswordError, setSingleBotPasswordError] = useState(''); const [singleBotUnlocked, setSingleBotUnlocked] = useState(false); @@ -59,13 +64,22 @@ function AuthenticatedApp() { const compactMode = readCompactModeFromUrl() || viewportCompact; const isCompactShell = compactMode; const hideHeader = route.kind === 'dashboard' && compactMode; + const showBotPanelDrawerEntry = route.kind === 'bot' && compactMode; + const allowHeaderCollapse = isCompactShell && !showBotPanelDrawerEntry; const forcedBot = forcedBotId ? activeBots[forcedBotId] : undefined; + const forcedBotName = String(forcedBot?.name || '').trim(); + const forcedBotIdLabel = String(forcedBotId || '').trim(); + const botHeaderTitle = forcedBotName || defaultLoadingPage.title; + const botHeaderSubtitle = forcedBotIdLabel || defaultLoadingPage.title; + const botDocumentTitle = [forcedBotName, forcedBotIdLabel].filter(Boolean).join(' ') || defaultLoadingPage.title; const shouldPromptSingleBotPassword = Boolean( route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked, ); const headerTitle = - route.kind === 'bot' - ? (forcedBot?.name || defaultLoadingPage.title) + showBotPanelDrawerEntry + ? (botCompactPanelTab === 'runtime' ? t.botPanels.runtime : t.botPanels.chat) + : route.kind === 'bot' + ? botHeaderTitle : route.kind === 'dashboard-skills' ? (locale === 'zh' ? '技能市场管理' : 'Skill Marketplace') : t.title; @@ -79,9 +93,8 @@ function AuthenticatedApp() { document.title = `${t.title} - ${locale === 'zh' ? '技能市场' : 'Skill Marketplace'}`; return; } - const botName = String(forcedBot?.name || '').trim(); - document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`; - }, [forcedBot?.name, forcedBotId, locale, route.kind, t.title]); + document.title = `${t.title} - ${botDocumentTitle}`; + }, [botDocumentTitle, locale, route.kind, t.title]); useEffect(() => { setSingleBotUnlocked(false); @@ -89,6 +102,13 @@ function AuthenticatedApp() { setSingleBotPasswordError(''); }, [forcedBotId]); + useEffect(() => { + if (!showBotPanelDrawerEntry) { + setBotPanelDrawerOpen(false); + setBotCompactPanelTab('chat'); + } + }, [forcedBotId, showBotPanelDrawerEntry]); + useEffect(() => { if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return; const stored = getBotAccessPassword(forcedBotId); @@ -141,33 +161,57 @@ function AuthenticatedApp() { window.dispatchEvent(new PopStateEvent('popstate')); }; + const botPanelLabels = t.botPanels; + const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingPage.title; + const drawerBotId = String(forcedBotId || '').trim() || '-'; + const nextTheme = theme === 'dark' ? 'light' : 'dark'; + const nextLocale = locale === 'zh' ? 'en' : 'zh'; + return (
{!hideHeader ? (
{ - if (isCompactShell && headerCollapsed) setHeaderCollapsed(false); + if (allowHeaderCollapse && headerCollapsed) setHeaderCollapsed(false); }} > -
+
- Nanobot + {showBotPanelDrawerEntry ? ( + + ) : null} + {!showBotPanelDrawerEntry ? ( + Nanobot + ) : null}

{headerTitle}

- {route.kind === 'dashboard-skills' ? ( + {!showBotPanelDrawerEntry && route.kind === 'dashboard-skills' ? ( - ) : ( + ) : !showBotPanelDrawerEntry ? (
{route.kind === 'dashboard' ? (locale === 'zh' ? '平台总览' : 'Platform overview') - : (locale === 'zh' ? 'Bot 首页' : 'Bot Home')} + : route.kind === 'bot' + ? botHeaderSubtitle + : (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
- )} - {isCompactShell ? ( + ) : null} + {allowHeaderCollapse ? ( + + + + +
+ ) : !headerCollapsed ? (
@@ -225,11 +290,71 @@ function AuthenticatedApp() { ) : route.kind === 'dashboard-skills' ? ( ) : ( - + )}
+ {showBotPanelDrawerEntry && botPanelDrawerOpen ? ( +
setBotPanelDrawerOpen(false)}> + +
+ ) : null} + {shouldPromptSingleBotPassword ? (
event.stopPropagation()}> diff --git a/frontend/src/components/ProtectedSearchInput.tsx b/frontend/src/components/ProtectedSearchInput.tsx new file mode 100644 index 0000000..3b1592e --- /dev/null +++ b/frontend/src/components/ProtectedSearchInput.tsx @@ -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) { + 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(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) => { + 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 ( +
+ + + { + 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} + /> + +
+ ); +} diff --git a/frontend/src/i18n/app.en.ts b/frontend/src/i18n/app.en.ts index f8a1686..febc7f7 100644 --- a/frontend/src/i18n/app.en.ts +++ b/frontend/src/i18n/app.en.ts @@ -9,6 +9,13 @@ export const appEn = { close: 'Close', expandHeader: 'Expand header', collapseHeader: 'Collapse header', + botPanels: { + title: 'Panel Switcher', + subtitle: 'Switch the active bot panel', + trigger: 'Panels', + chat: 'Chat Panel', + runtime: 'Runtime Panel', + }, nav: { images: { title: 'Image Factory', subtitle: 'Manage registered images' }, onboarding: { title: 'Creation Wizard', subtitle: 'Create bot step-by-step' }, diff --git a/frontend/src/i18n/app.zh-cn.ts b/frontend/src/i18n/app.zh-cn.ts index 0f98c3c..ad106d9 100644 --- a/frontend/src/i18n/app.zh-cn.ts +++ b/frontend/src/i18n/app.zh-cn.ts @@ -9,6 +9,13 @@ export const appZhCn = { close: '关闭', expandHeader: '展开头部', collapseHeader: '收起头部', + botPanels: { + title: '面板切换', + subtitle: '切换 Bot 页当前面板', + trigger: '面板', + chat: '对话面板', + runtime: '运行面板', + }, nav: { images: { title: '镜像工厂', subtitle: '管理已登记镜像' }, onboarding: { title: '创建向导', subtitle: '分步创建 Bot' }, diff --git a/frontend/src/modules/bot-home/BotHomePage.tsx b/frontend/src/modules/bot-home/BotHomePage.tsx index 487218c..b36348e 100644 --- a/frontend/src/modules/bot-home/BotHomePage.tsx +++ b/frontend/src/modules/bot-home/BotHomePage.tsx @@ -3,8 +3,17 @@ import { BotDashboardModule } from '../dashboard/BotDashboardModule'; interface BotHomePageProps { botId: string; compactMode: boolean; + compactPanelTab?: 'chat' | 'runtime'; + onCompactPanelTabChange?: (tab: 'chat' | 'runtime') => void; } -export function BotHomePage({ botId, compactMode }: BotHomePageProps) { - return ; +export function BotHomePage({ botId, compactMode, compactPanelTab, onCompactPanelTabChange }: BotHomePageProps) { + return ( + + ); } diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 15c307e..e6ce701 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -28,104 +28,6 @@ display: none !important; } -.ops-compact-fab-stack { - position: fixed; - right: 14px; - bottom: 14px; - display: grid; - gap: 10px; - z-index: 85; -} - -.ops-compact-fab-switch { - position: relative; - width: 48px; - height: 48px; - border-radius: 999px; - border: 1px solid color-mix(in oklab, var(--brand) 58%, var(--line) 42%); - background: color-mix(in oklab, var(--panel) 70%, var(--brand-soft) 30%); - color: var(--icon); - display: inline-flex; - align-items: center; - justify-content: center; - box-shadow: - 0 10px 24px rgba(9, 15, 28, 0.42), - 0 0 0 2px color-mix(in oklab, var(--brand) 22%, transparent); - cursor: pointer; - overflow: visible; - transform: translateY(0); - animation: ops-fab-float 2.2s ease-in-out infinite; -} - -.ops-compact-fab-switch::before { - content: ''; - position: absolute; - inset: -7px; - border-radius: 999px; - border: 2px solid color-mix(in oklab, var(--brand) 60%, transparent); - opacity: 0.45; - animation: ops-fab-pulse 1.8s ease-out infinite; - pointer-events: none; -} - -.ops-compact-fab-switch::after { - content: ''; - position: absolute; - inset: -1px; - border-radius: 999px; - background: - radial-gradient(circle at 50% 50%, color-mix(in oklab, var(--brand) 20%, transparent) 0%, transparent 72%); - opacity: 0.9; - pointer-events: none; -} - -.ops-compact-fab-switch.is-chat { - box-shadow: - 0 10px 24px rgba(9, 15, 28, 0.42), - 0 0 18px color-mix(in oklab, #5c98ff 60%, transparent), - 0 0 0 2px color-mix(in oklab, #5c98ff 35%, transparent); -} - -.ops-compact-fab-switch.is-runtime { - box-shadow: - 0 10px 24px rgba(9, 15, 28, 0.42), - 0 0 18px color-mix(in oklab, #40d6c3 62%, transparent), - 0 0 0 2px color-mix(in oklab, #40d6c3 38%, transparent); -} - -.ops-compact-fab-switch.is-runtime::before { - border-color: color-mix(in oklab, #40d6c3 62%, transparent); -} - -.ops-compact-fab-switch:hover { - transform: translateY(-1px) scale(1.03); -} - -@keyframes ops-fab-pulse { - 0% { - transform: scale(0.92); - opacity: 0.62; - } - 70% { - transform: scale(1.15); - opacity: 0; - } - 100% { - transform: scale(1.15); - opacity: 0; - } -} - -@keyframes ops-fab-float { - 0%, - 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-2px); - } -} - .ops-list-actions { position: relative; display: inline-flex; @@ -142,6 +44,22 @@ display: block; } +.ops-searchbar-form { + margin: 0; +} + +.ops-autofill-trap { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + padding: 0; + border: 0; + opacity: 0; + pointer-events: none; +} + .ops-search-input { min-width: 0; } diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index 226c1ac..d9c9a7f 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type ImgHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; import axios from 'axios'; -import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Command, Copy, Download, EllipsisVertical, ExternalLink, Eye, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; +import { ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Command, Copy, Download, EllipsisVertical, ExternalLink, Eye, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; @@ -19,9 +19,19 @@ import { dashboardEn } from '../../i18n/dashboard.en'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { LucentIconButton } from '../../components/lucent/LucentIconButton'; import { LucentSelect } from '../../components/lucent/LucentSelect'; +import { ProtectedSearchInput } from '../../components/ProtectedSearchInput'; import { MarkdownLiteEditor } from '../../components/markdown/MarkdownLiteEditor'; import { PasswordInput } from '../../components/PasswordInput'; import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel'; +import { + buildWorkspaceDownloadHref, + buildWorkspacePreviewHref, + buildWorkspaceRawHref, + createWorkspaceMarkdownComponents, + decorateWorkspacePathsForMarkdown, + normalizeDashboardAttachmentPath, + resolveWorkspaceDocumentPath, +} from './shared/workspaceMarkdown'; import type { BotSkillMarketItem } from '../platform/types'; import { SkillMarketInstallModal } from './components/SkillMarketInstallModal'; import { @@ -36,6 +46,8 @@ interface BotDashboardModuleProps { onOpenImageFactory?: () => void; forcedBotId?: string; compactMode?: boolean; + compactPanelTab?: CompactPanelTab; + onCompactPanelTabChange?: (tab: CompactPanelTab) => void; } type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; @@ -414,6 +426,13 @@ const providerPresets: Record"'`]+?\.[a-z0-9][a-z0-9._+-]{0,31}\b/gi; -const WORKSPACE_RELATIVE_PATH_PATTERN = - /(^|[\s(\[])(\/[^\n\r<>"'`)\]]+?\.[a-z0-9][a-z0-9._+-]{0,31})(?![A-Za-z0-9_./-])/gim; - -function buildWorkspaceLink(path: string) { - return `${WORKSPACE_LINK_PREFIX}${encodeURIComponent(path)}`; -} - -function parseWorkspaceLink(href: string): string | null { - const link = String(href || '').trim(); - if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null; - const encoded = link.slice(WORKSPACE_LINK_PREFIX.length); - try { - const decoded = decodeURIComponent(encoded || '').trim(); - return decoded || null; - } catch { - return null; - } -} - function renderWorkspacePathSegments(pathRaw: string, keyPrefix: string): ReactNode[] { const path = String(pathRaw || ''); if (!path) return ['-']; @@ -765,96 +762,6 @@ const MARKDOWN_SANITIZE_SCHEMA = { }, }; -function resolveWorkspaceDocumentPath(targetRaw: string, baseFilePath?: string): string | null { - const target = String(targetRaw || '').trim(); - if (!target || target.startsWith('#')) return null; - const linkedPath = parseWorkspaceLink(target); - if (linkedPath) return linkedPath; - if (target.startsWith('/root/.nanobot/workspace/')) { - return normalizeDashboardAttachmentPath(target); - } - const lower = target.toLowerCase(); - if ( - lower.startsWith('blob:') || - lower.startsWith('data:') || - lower.startsWith('http://') || - lower.startsWith('https://') || - lower.startsWith('javascript:') || - lower.startsWith('mailto:') || - lower.startsWith('tel:') || - target.startsWith('//') - ) { - return null; - } - - const normalizedBase = normalizeDashboardAttachmentPath(baseFilePath || ''); - if (!normalizedBase) { - return null; - } - - try { - const baseUrl = new URL(`https://workspace.local/${normalizedBase}`); - const resolvedUrl = new URL(target, baseUrl); - if (resolvedUrl.origin !== 'https://workspace.local') return null; - try { - return normalizeDashboardAttachmentPath(decodeURIComponent(resolvedUrl.pathname)); - } catch { - return normalizeDashboardAttachmentPath(resolvedUrl.pathname); - } - } catch { - return null; - } -} - -function decorateWorkspacePathsInPlainChunk(source: string): string { - if (!source) return source; - const protectedLinks: string[] = []; - const withProtectedAbsoluteLinks = source.replace(WORKSPACE_ABS_PATH_PATTERN, (fullPath) => { - const normalized = normalizeDashboardAttachmentPath(fullPath); - if (!normalized) return fullPath; - const token = `@@WS_PATH_LINK_${protectedLinks.length}@@`; - protectedLinks.push(`[${fullPath}](${buildWorkspaceLink(normalized)})`); - return token; - }); - const withRelativeLinks = withProtectedAbsoluteLinks.replace( - WORKSPACE_RELATIVE_PATH_PATTERN, - (full, prefix: string, rawPath: string) => { - const normalized = normalizeDashboardAttachmentPath(rawPath); - if (!normalized) return full; - return `${prefix}[${rawPath}](${buildWorkspaceLink(normalized)})`; - }, - ); - return withRelativeLinks.replace(/@@WS_PATH_LINK_(\d+)@@/g, (_full, idxRaw: string) => { - const idx = Number(idxRaw); - if (!Number.isFinite(idx) || idx < 0 || idx >= protectedLinks.length) return String(_full || ''); - return protectedLinks[idx]; - }); -} - -function decorateWorkspacePathsForMarkdown(text: string) { - const source = String(text || ''); - if (!source) return source; - - // Keep existing Markdown links unchanged; only decorate plain text segments. - const markdownLinkPattern = /\[[^\]]*?\]\((?:[^)(]|\([^)(]*\))*\)/g; - let result = ''; - let last = 0; - let match = markdownLinkPattern.exec(source); - while (match) { - const idx = Number(match.index || 0); - if (idx > last) { - result += decorateWorkspacePathsInPlainChunk(source.slice(last, idx)); - } - result += match[0]; - last = idx + match[0].length; - match = markdownLinkPattern.exec(source); - } - if (last < source.length) { - result += decorateWorkspacePathsInPlainChunk(source.slice(last)); - } - return result; -} - function normalizeAttachmentPaths(raw: unknown): string[] { if (!Array.isArray(raw)) return []; return raw @@ -862,18 +769,6 @@ function normalizeAttachmentPaths(raw: unknown): string[] { .filter((v) => v.length > 0); } -function normalizeDashboardAttachmentPath(path: string): string { - const v = String(path || '') - .trim() - .replace(/\\/g, '/') - .replace(/^['"`([<{]+/, '') - .replace(/['"`)\]>}.,,。!?;:]+$/, ''); - if (!v) return ''; - const prefix = '/root/.nanobot/workspace/'; - if (v.startsWith(prefix)) return v.slice(prefix.length); - return v.replace(/^\/+/, ''); -} - const COMPOSER_DRAFT_STORAGE_PREFIX = 'nanobot-dashboard-composer-draft:v1:'; interface ComposerDraftStorage { @@ -931,10 +826,6 @@ function persistComposerDraft(botId: string, commandRaw: string, attachmentsRaw: } } -function isExternalHttpLink(href: string): boolean { - return /^https?:\/\//i.test(String(href || '').trim()); -} - function parseQuotedReplyBlock(input: string): { quoted: string; body: string } { const source = String(input || ''); const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i); @@ -1061,6 +952,8 @@ export function BotDashboardModule({ onOpenImageFactory, forcedBotId, compactMode = false, + compactPanelTab: compactPanelTabProp, + onCompactPanelTabChange, }: BotDashboardModuleProps) { const { activeBots, @@ -1234,7 +1127,7 @@ export function BotDashboardModule({ const [topicFeedDeleteSavingById, setTopicFeedDeleteSavingById] = useState>({}); const [topicFeedUnreadCount, setTopicFeedUnreadCount] = useState(0); const [topicDetailOpen, setTopicDetailOpen] = useState(false); - const [compactPanelTab, setCompactPanelTab] = useState('chat'); + const [compactPanelTabState, setCompactPanelTabState] = useState('chat'); const [isCompactMobile, setIsCompactMobile] = useState(false); const [botListQuery, setBotListQuery] = useState(''); const [botListPage, setBotListPage] = useState(1); @@ -1299,28 +1192,14 @@ export function BotDashboardModule({ storage_gb: String(clampStorageGb(bot.storage_gb ?? 10)), }); }, [defaultSystemTimezone]); - const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => { - const query = [`path=${encodeURIComponent(filePath)}`]; - if (forceDownload) query.push('download=1'); - return `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/download?${query.join('&')}`; - }; - const buildWorkspaceRawHref = (filePath: string, forceDownload: boolean = false) => { - const normalized = String(filePath || '') - .trim() - .split('/') - .filter(Boolean) - .map((part) => encodeURIComponent(part)) - .join('/'); - if (!normalized) return ''; - const base = `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/raw/${normalized}`; - return forceDownload ? `${base}?download=1` : base; - }; - const buildWorkspacePreviewHref = (filePath: string) => { + const getWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => + buildWorkspaceDownloadHref(selectedBotId, filePath, forceDownload); + const getWorkspaceRawHref = (filePath: string, forceDownload: boolean = false) => + buildWorkspaceRawHref(selectedBotId, filePath, forceDownload); + const getWorkspacePreviewHref = (filePath: string) => { const normalized = String(filePath || '').trim(); if (!normalized) return ''; - return isHtmlPath(normalized) - ? buildWorkspaceRawHref(normalized, false) - : buildWorkspaceDownloadHref(normalized, false); + return buildWorkspacePreviewHref(selectedBotId, normalized, { preferRaw: isHtmlPath(normalized) }); }; const closeWorkspacePreview = () => { setWorkspacePreview(null); @@ -1347,7 +1226,7 @@ export function BotDashboardModule({ if (!normalized) return; const filename = normalized.split('/').pop() || 'workspace-file'; const link = document.createElement('a'); - link.href = buildWorkspaceDownloadHref(normalized, true); + link.href = getWorkspaceDownloadHref(normalized, true); link.download = filename; link.rel = 'noopener noreferrer'; document.body.appendChild(link); @@ -1357,7 +1236,7 @@ export function BotDashboardModule({ const copyWorkspacePreviewUrl = async (filePath: string) => { const normalized = String(filePath || '').trim(); if (!selectedBotId || !normalized) return; - const hrefRaw = buildWorkspacePreviewHref(normalized); + const hrefRaw = getWorkspacePreviewHref(normalized); const href = (() => { try { return new URL(hrefRaw, window.location.origin).href; @@ -1439,7 +1318,7 @@ export function BotDashboardModule({ if (!src || !selectedBotId) return src; const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath); if (resolvedWorkspacePath) { - return buildWorkspacePreviewHref(resolvedWorkspacePath); + return getWorkspacePreviewHref(resolvedWorkspacePath); } const lower = src.toLowerCase(); if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) { @@ -1447,129 +1326,26 @@ export function BotDashboardModule({ } return src; }, [selectedBotId]); - const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => { - const source = String(text || ''); - if (!source) return [source]; - const pattern = - /\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.[a-z0-9][a-z0-9._+-]{0,31}\b|https:\/\/workspace\.local\/open\/[^)\r\n]+/gi; - const nodes: ReactNode[] = []; - let lastIndex = 0; - let matchIndex = 0; - let match = pattern.exec(source); - while (match) { - if (match.index > lastIndex) { - nodes.push(source.slice(lastIndex, match.index)); - } - - const raw = match[0]; - const markdownPath = match[1] ? String(match[1]) : ''; - const markdownHref = match[2] ? String(match[2]) : ''; - let normalizedPath = ''; - let displayText = raw; - - if (markdownPath && markdownHref) { - normalizedPath = normalizeDashboardAttachmentPath(markdownPath); - displayText = markdownPath; - } else if (raw.startsWith(WORKSPACE_LINK_PREFIX)) { - normalizedPath = String(parseWorkspaceLink(raw) || '').trim(); - displayText = normalizedPath ? `/root/.nanobot/workspace/${normalizedPath}` : raw; - } else if (raw.startsWith('/root/.nanobot/workspace/')) { - normalizedPath = normalizeDashboardAttachmentPath(raw); - displayText = raw; - } - - if (normalizedPath) { - nodes.push( - { - event.preventDefault(); - event.stopPropagation(); - void openWorkspacePathFromChat(normalizedPath); - }} - > - {displayText} - , - ); - } else { - nodes.push(raw); - } - - lastIndex = match.index + raw.length; - matchIndex += 1; - match = pattern.exec(source); - } - - if (lastIndex < source.length) { - nodes.push(source.slice(lastIndex)); - } - return nodes; - }; - const renderWorkspaceAwareChildren = (children: ReactNode, keyPrefix: string): ReactNode => { - const list = Array.isArray(children) ? children : [children]; - const mapped = list.flatMap((child, idx) => { - if (typeof child === 'string') { - return renderWorkspaceAwareText(child, `${keyPrefix}-${idx}`); - } - return [child]; - }); - return mapped; - }; const markdownComponents = useMemo( - () => ({ - a: ({ href, children, ...props }: AnchorHTMLAttributes) => { - const link = String(href || '').trim(); - const workspacePath = parseWorkspaceLink(link); - if (workspacePath) { - return ( - { - event.preventDefault(); - void openWorkspacePathFromChat(workspacePath); - }} - {...props} - > - {children} - - ); - } - if (isExternalHttpLink(link)) { - return ( - - {children} - - ); - } - return ( - - {children} - - ); + () => createWorkspaceMarkdownComponents( + (path) => { + void openWorkspacePathFromChat(path); }, - img: ({ src, alt, ...props }: ImgHTMLAttributes) => { - const resolvedSrc = resolveWorkspaceMediaSrc(String(src || '')); - return ( - {String(alt - ); + { resolveMediaSrc: resolveWorkspaceMediaSrc }, + ), + [resolveWorkspaceMediaSrc], + ); + const workspacePreviewMarkdownComponents = useMemo( + () => createWorkspaceMarkdownComponents( + (path) => { + void openWorkspacePathFromChat(path); }, - p: ({ children, ...props }: { children?: ReactNode }) => ( -

{renderWorkspaceAwareChildren(children, 'md-p')}

- ), - li: ({ children, ...props }: { children?: ReactNode }) => ( -
  • {renderWorkspaceAwareChildren(children, 'md-li')}
  • - ), - code: ({ children, ...props }: { children?: ReactNode }) => ( - {renderWorkspaceAwareChildren(children, 'md-code')} - ), - }), - [fileNotPreviewableLabel, notify, resolveWorkspaceMediaSrc, selectedBotId], + { + baseFilePath: workspacePreview?.path, + resolveMediaSrc: resolveWorkspaceMediaSrc, + }, + ), + [resolveWorkspaceMediaSrc, workspacePreview?.path], ); const [editForm, setEditForm] = useState({ @@ -1638,6 +1414,14 @@ export function BotDashboardModule({ const compactListFirstMode = compactMode && !hasForcedBot; const isCompactListPage = compactListFirstMode && !selectedBotId; const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId); + const compactPanelTab = compactPanelTabProp ?? compactPanelTabState; + const setCompactPanelTab = useCallback((next: CompactPanelTab | ((prev: CompactPanelTab) => CompactPanelTab)) => { + const resolved = typeof next === 'function' ? next(compactPanelTab) : next; + if (compactPanelTabProp === undefined) { + setCompactPanelTabState(resolved); + } + onCompactPanelTabChange?.(resolved); + }, [compactPanelTab, compactPanelTabProp, onCompactPanelTabChange]); const showBotListPanel = !hasForcedBot && (!compactMode || isCompactListPage); const normalizedBotListQuery = botListQuery.trim().toLowerCase(); const filteredBots = useMemo(() => { @@ -5306,43 +5090,22 @@ export function BotDashboardModule({
    -
    - setBotListQuery(e.target.value)} - placeholder={t.botSearchPlaceholder} - aria-label={t.botSearchPlaceholder} - autoComplete="new-password" - autoCorrect="off" - autoCapitalize="none" - spellCheck={false} - inputMode="search" - name={botSearchInputName} - id={botSearchInputName} - data-form-type="other" - data-lpignore="true" - data-1p-ignore="true" - data-bwignore="true" - /> - -
    + { + setBotListQuery(''); + setBotListPage(1); + }} + onSearchAction={() => setBotListPage(1)} + debounceMs={120} + placeholder={t.botSearchPlaceholder} + ariaLabel={t.botSearchPlaceholder} + clearTitle={t.clearSearch} + searchTitle={t.searchAction} + name={botSearchInputName} + id={botSearchInputName} + />
    @@ -5552,6 +5315,7 @@ export function BotDashboardModule({ onDeleteItem={(item) => void deleteTopicFeedItem(item)} onLoadMore={() => void loadTopicFeed({ append: true, cursor: topicFeedNextCursor, topicKey: topicFeedTopicKey })} onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)} + resolveWorkspaceMediaSrc={resolveWorkspaceMediaSrc} onOpenTopicSettings={() => { if (selectedBot) openTopicModal(selectedBot.id); }} @@ -6125,42 +5889,19 @@ export function BotDashboardModule({
    -
    - setWorkspaceQuery(e.target.value)} - placeholder={t.workspaceSearchPlaceholder} - aria-label={t.workspaceSearchPlaceholder} - autoComplete="new-password" - autoCorrect="off" - autoCapitalize="none" - spellCheck={false} - inputMode="search" - name={workspaceSearchInputName} - id={workspaceSearchInputName} - data-form-type="other" - data-lpignore="true" - data-1p-ignore="true" - data-bwignore="true" - /> - -
    + setWorkspaceQuery('')} + onSearchAction={() => setWorkspaceQuery((value) => value.trim())} + debounceMs={200} + placeholder={t.workspaceSearchPlaceholder} + ariaLabel={t.workspaceSearchPlaceholder} + clearTitle={t.clearSearch} + searchTitle={t.searchAction} + name={workspaceSearchInputName} + id={workspaceSearchInputName} + />
    @@ -6207,19 +5948,6 @@ export function BotDashboardModule({ ) : null} - {compactMode && !isCompactListPage ? ( -
    - 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' ? : } - -
    - ) : null} - {showResourceModal && (
    setShowResourceModal(false)}>
    e.stopPropagation()}> @@ -6417,6 +6145,7 @@ export function BotDashboardModule({ + @@ -7843,27 +7572,27 @@ export function BotDashboardModule({ {workspacePreview.isImage ? ( {workspacePreview.path.split('/').pop() ) : workspacePreview.isVideo ? (