feat(apps): unify CLI apps and MCP (#3991)

* refactor(cli): load bundled apps from catalog

* feat(plugins): unify CLI and MCP settings

* feat(plugins): add settings category filter

* style(plugins): refine settings catalog

* refactor(cli): load nanobot apps from repo catalog

* feat(store): add capability store entry

* feat(apps): rename capability store

* fix(apps): verify clean app removal

* fix(apps): keep main sidebar on apps view

* feat(apps): add shared app manifest protocol

* fix(apps): dismiss app status message

* refactor(apps): move CLI adapter under apps

* refactor(apps): drop legacy cli apps package
This commit is contained in:
Xubin Ren
2026-05-25 20:07:02 +08:00
committed by GitHub
parent 179acfe104
commit 418cb23da2
32 changed files with 2026 additions and 903 deletions
+24 -5
View File
@@ -4,7 +4,7 @@ import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
import { Sidebar } from "@/components/Sidebar";
import { SessionSearchDialog } from "@/components/SessionSearchDialog";
import { SettingsView } from "@/components/settings/SettingsView";
import { SettingsView, type SettingsSectionKey } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
@@ -46,7 +46,7 @@ const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings";
type ShellView = "chat" | "settings" | "apps";
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
@@ -325,6 +325,7 @@ function Shell({
useSidebarState(sessions, !loading);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [view, setView] = useState<ShellView>("chat");
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionKey>("overview");
const [desktopSidebarOpen, setDesktopSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
@@ -588,12 +589,20 @@ function Shell({
[onSelectChat],
);
const onOpenSettings = useCallback(() => {
const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => {
setSessionSearchOpen(false);
setSettingsInitialSection(section);
setView("settings");
setMobileSidebarOpen(false);
}, []);
const onOpenApps = useCallback(() => {
setSessionSearchOpen(false);
setSettingsInitialSection("apps");
setView("apps");
setMobileSidebarOpen(false);
}, []);
const onBackToChat = useCallback(() => {
setView("chat");
setMobileSidebarOpen(false);
@@ -711,6 +720,12 @@ function Shell({
});
return;
}
if (view === "apps") {
document.title = t("app.documentTitle.chat", {
title: t("settings.nav.apps", { defaultValue: "Apps" }),
});
return;
}
document.title = activeSession
? t("app.documentTitle.chat", { title: headerTitle })
: t("app.documentTitle.base");
@@ -728,7 +743,9 @@ function Shell({
onRequestRename,
onToggleArchive,
onOpenSettings,
onOpenApps,
onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" ? "apps" as const : null,
onToggleArchived,
onUpdateView: onUpdateSidebarView,
pinnedKeys: sidebarState.pinned_keys,
@@ -805,7 +822,7 @@ function Shell({
<div
className={cn(
"absolute inset-0 flex flex-col",
view === "settings" && "invisible pointer-events-none",
view !== "chat" && "invisible pointer-events-none",
)}
>
<ThreadShell
@@ -820,10 +837,12 @@ function Shell({
hideSidebarToggleOnDesktop
/>
</div>
{view === "settings" && (
{view !== "chat" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
theme={theme}
initialSection={settingsInitialSection}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
+15 -1
View File
@@ -6,6 +6,7 @@ import {
Search,
Settings,
SquarePen,
Blocks,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -41,7 +42,9 @@ interface SidebarProps {
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onOpenSettings: () => void;
onOpenApps: () => void;
onOpenSearch: () => void;
activeUtility?: "apps" | null;
onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void;
@@ -129,6 +132,13 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenSearch}
icon={<Search className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.apps")}
onClick={props.onOpenApps}
active={props.activeUtility === "apps"}
icon={<Blocks className="h-4 w-4" />}
/>
<SidebarViewMenu
compact={collapsed}
view={props.viewState}
@@ -201,12 +211,14 @@ function SidebarActionButton({
label,
icon,
onClick,
active = false,
className,
}: {
collapsed: boolean;
label: string;
icon: ReactNode;
onClick: () => void;
active?: boolean;
className?: string;
}) {
return (
@@ -214,14 +226,16 @@ function SidebarActionButton({
type="button"
variant="ghost"
aria-label={label}
aria-current={active ? "page" : undefined}
title={collapsed ? label : undefined}
onClick={onClick}
onClick={() => onClick()}
className={cn(
"group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
collapsed
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]",
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
className,
)}
>
File diff suppressed because it is too large Load Diff
+26 -9
View File
@@ -63,7 +63,8 @@
"language": {
"label": "Language",
"ariaLabel": "Change language"
}
},
"apps": "Apps"
},
"settings": {
"backToChat": "Back to chat",
@@ -83,7 +84,8 @@
"cliApps": "CLI Apps",
"mcp": "MCP",
"runtime": "Runtime",
"advanced": "Advanced"
"advanced": "Advanced",
"apps": "Apps"
},
"sections": {
"interface": "Interface",
@@ -96,12 +98,13 @@
"imageDefaults": "Defaults",
"webSearch": "Web search",
"webBehavior": "Behavior",
"cliApps": "CLI Apps",
"mcp": "MCP",
"cliApps": "CLI apps",
"mcp": "MCP services",
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capabilities",
"integrations": "Integrations"
"integrations": "Integrations",
"apps": "Apps"
},
"models": {
"selectModel": "Select model",
@@ -380,6 +383,20 @@
"selectSize": "Select size",
"configureProvider": "Configure provider",
"missingCredential": "Configure this provider before enabling image generation."
},
"apps": {
"description": "Add app CLIs and MCP services nanobot can use from chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "All",
"filterCli": "CLI apps",
"filterMcp": "MCP services",
"enabledSummary": "{{count}} enabled",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Search Apps",
"featured": "Featured",
"loading": "Loading Apps...",
"empty": "No apps match this filter."
}
},
"chat": {
@@ -596,10 +613,10 @@
}
},
"mentions": {
"ariaLabel": "Apps and MCP",
"label": "Plugins",
"cliGroup": "CLI Apps",
"mcpGroup": "MCP servers",
"ariaLabel": "Apps",
"label": "Apps",
"cliGroup": "CLI apps",
"mcpGroup": "MCP services",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Use @{{name}} as a local CLI app",
+23 -6
View File
@@ -63,7 +63,8 @@
"language": {
"label": "Idioma",
"ariaLabel": "Cambiar idioma"
}
},
"apps": "Apps"
},
"settings": {
"backToChat": "Volver al chat",
@@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Apps CLI",
"mcp": "MCP"
"mcp": "MCP",
"apps": "Apps"
},
"sections": {
"interface": "Interfaz",
@@ -101,7 +103,8 @@
"capabilities": "Capacidades",
"integrations": "Integrations",
"cliApps": "Apps CLI",
"mcp": "MCP"
"mcp": "Servicios MCP",
"apps": "Apps"
},
"rows": {
"theme": "Tema",
@@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
},
"apps": {
"description": "Añade CLI de apps y servicios MCP que nanobot puede usar desde el chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Todo",
"filterCli": "Apps CLI",
"filterMcp": "Servicios MCP",
"enabledSummary": "{{count}} activados",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Buscar apps",
"featured": "Destacadas",
"loading": "Cargando apps...",
"empty": "Ninguna app coincide con este filtro."
}
},
"chat": {
@@ -607,10 +624,10 @@
"io": "No se pudo leer este archivo"
},
"mentions": {
"ariaLabel": "Apps y MCP",
"label": "Plugins",
"ariaLabel": "Apps",
"label": "Apps",
"cliGroup": "Apps CLI",
"mcpGroup": "Servidores MCP",
"mcpGroup": "Servicios MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Usar @{{name}} como app CLI local",
+23 -6
View File
@@ -63,7 +63,8 @@
"language": {
"label": "Langue",
"ariaLabel": "Changer de langue"
}
},
"apps": "Apps"
},
"settings": {
"backToChat": "Retour à la discussion",
@@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Apps CLI",
"mcp": "MCP"
"mcp": "MCP",
"apps": "Apps"
},
"sections": {
"interface": "Interface",
@@ -101,7 +103,8 @@
"capabilities": "Capacités",
"integrations": "Integrations",
"cliApps": "Apps CLI",
"mcp": "MCP"
"mcp": "Services MCP",
"apps": "Apps"
},
"rows": {
"theme": "Thème",
@@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
},
"apps": {
"description": "Ajoutez des CLI dapps et des services MCP que nanobot peut utiliser dans le chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Tout",
"filterCli": "Apps CLI",
"filterMcp": "Services MCP",
"enabledSummary": "{{count}} activés",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Rechercher des apps",
"featured": "En vedette",
"loading": "Chargement des apps...",
"empty": "Aucune app ne correspond à ce filtre."
}
},
"chat": {
@@ -607,10 +624,10 @@
"io": "Impossible de lire ce fichier"
},
"mentions": {
"ariaLabel": "Apps et MCP",
"label": "Plugins",
"ariaLabel": "Apps",
"label": "Apps",
"cliGroup": "Apps CLI",
"mcpGroup": "Serveurs MCP",
"mcpGroup": "Services MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Utiliser @{{name}} comme app CLI locale",
+25 -8
View File
@@ -63,7 +63,8 @@
"language": {
"label": "Bahasa",
"ariaLabel": "Ganti bahasa"
}
},
"apps": "Aplikasi"
},
"settings": {
"backToChat": "Kembali ke obrolan",
@@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Aplikasi CLI",
"mcp": "MCP"
"mcp": "MCP",
"apps": "Aplikasi"
},
"sections": {
"interface": "Antarmuka",
@@ -100,8 +102,9 @@
"safety": "Safety",
"capabilities": "Kapabilitas",
"integrations": "Integrations",
"cliApps": "Aplikasi CLI",
"mcp": "MCP"
"cliApps": "App CLI",
"mcp": "Layanan MCP",
"apps": "Aplikasi"
},
"rows": {
"theme": "Tema",
@@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
},
"apps": {
"description": "Tambahkan CLI app dan layanan MCP yang dapat digunakan nanobot dari chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Semua",
"filterCli": "App CLI",
"filterMcp": "Layanan MCP",
"enabledSummary": "{{count}} aktif",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Cari aplikasi",
"featured": "Unggulan",
"loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok dengan filter ini."
}
},
"chat": {
@@ -607,10 +624,10 @@
"io": "Tidak dapat membaca file ini"
},
"mentions": {
"ariaLabel": "Aplikasi dan MCP",
"label": "Plugin",
"cliGroup": "Aplikasi CLI",
"mcpGroup": "Server MCP",
"ariaLabel": "Aplikasi",
"label": "Aplikasi",
"cliGroup": "App CLI",
"mcpGroup": "Layanan MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
+23 -6
View File
@@ -63,7 +63,8 @@
"language": {
"label": "言語",
"ariaLabel": "言語を変更"
}
},
"apps": "アプリ"
},
"settings": {
"backToChat": "チャットに戻る",
@@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "CLI アプリ",
"mcp": "MCP"
"mcp": "MCP",
"apps": "アプリ"
},
"sections": {
"interface": "インターフェース",
@@ -101,7 +103,8 @@
"capabilities": "機能",
"integrations": "Integrations",
"cliApps": "CLI アプリ",
"mcp": "MCP"
"mcp": "MCP サービス",
"apps": "アプリ"
},
"rows": {
"theme": "テーマ",
@@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
},
"apps": {
"description": "チャットから nanobot が使えるアプリ CLI と MCP サービスを追加します。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "すべて",
"filterCli": "CLI アプリ",
"filterMcp": "MCP サービス",
"enabledSummary": "{{count}} 件有効",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "アプリを検索",
"featured": "おすすめ",
"loading": "アプリを読み込み中...",
"empty": "このフィルターに一致するアプリはありません。"
}
},
"chat": {
@@ -607,10 +624,10 @@
"io": "このファイルを読み込めません"
},
"mentions": {
"ariaLabel": "アプリと MCP",
"label": "プラグイン",
"ariaLabel": "アプリ",
"label": "アプリ",
"cliGroup": "CLI アプリ",
"mcpGroup": "MCP サーバー",
"mcpGroup": "MCP サービス",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
+23 -6
View File
@@ -63,7 +63,8 @@
"language": {
"label": "언어",
"ariaLabel": "언어 변경"
}
},
"apps": "앱"
},
"settings": {
"backToChat": "채팅으로 돌아가기",
@@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "CLI 앱",
"mcp": "MCP"
"mcp": "MCP",
"apps": "앱"
},
"sections": {
"interface": "인터페이스",
@@ -101,7 +103,8 @@
"capabilities": "기능",
"integrations": "Integrations",
"cliApps": "CLI 앱",
"mcp": "MCP"
"mcp": "MCP 서비스",
"apps": "앱"
},
"rows": {
"theme": "테마",
@@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
},
"apps": {
"description": "채팅에서 nanobot이 사용할 수 있는 앱 CLI와 MCP 서비스를 추가합니다.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "전체",
"filterCli": "CLI 앱",
"filterMcp": "MCP 서비스",
"enabledSummary": "{{count}}개 활성화됨",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "앱 검색",
"featured": "추천",
"loading": "앱 불러오는 중...",
"empty": "이 필터와 일치하는 앱이 없습니다."
}
},
"chat": {
@@ -607,10 +624,10 @@
"io": "이 파일을 읽을 수 없습니다"
},
"mentions": {
"ariaLabel": "앱 및 MCP",
"label": "플러그인",
"ariaLabel": "앱",
"label": "",
"cliGroup": "CLI 앱",
"mcpGroup": "MCP 서",
"mcpGroup": "MCP 서비스",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
+23 -6
View File
@@ -63,7 +63,8 @@
"language": {
"label": "Ngôn ngữ",
"ariaLabel": "Đổi ngôn ngữ"
}
},
"apps": "Ứng dụng"
},
"settings": {
"backToChat": "Quay lại trò chuyện",
@@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Ứng dụng CLI",
"mcp": "MCP"
"mcp": "MCP",
"apps": "Ứng dụng"
},
"sections": {
"interface": "Giao diện",
@@ -101,7 +103,8 @@
"capabilities": "Khả năng",
"integrations": "Integrations",
"cliApps": "Ứng dụng CLI",
"mcp": "MCP"
"mcp": "Dịch vụ MCP",
"apps": "Ứng dụng"
},
"rows": {
"theme": "Giao diện",
@@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận."
},
"apps": {
"description": "Thêm CLI ứng dụng và dịch vụ MCP để nanobot dùng trong trò chuyện.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Tất cả",
"filterCli": "Ứng dụng CLI",
"filterMcp": "Dịch vụ MCP",
"enabledSummary": "{{count}} đã bật",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Tìm ứng dụng",
"featured": "Nổi bật",
"loading": "Đang tải ứng dụng...",
"empty": "Không có ứng dụng nào khớp với bộ lọc này."
}
},
"chat": {
@@ -607,10 +624,10 @@
"io": "Không thể đọc tệp này"
},
"mentions": {
"ariaLabel": "Ứng dụng và MCP",
"label": "Plugin",
"ariaLabel": "Ứng dụng",
"label": "Ứng dụng",
"cliGroup": "Ứng dụng CLI",
"mcpGroup": "Máy chủ MCP",
"mcpGroup": "Dịch vụ MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
+23 -6
View File
@@ -63,7 +63,8 @@
"language": {
"label": "语言",
"ariaLabel": "切换语言"
}
},
"apps": "应用"
},
"settings": {
"backToChat": "返回对话",
@@ -83,7 +84,8 @@
"cliApps": "CLI 应用",
"mcp": "MCP",
"runtime": "运行时",
"advanced": "高级"
"advanced": "高级",
"apps": "应用"
},
"sections": {
"interface": "界面",
@@ -97,11 +99,12 @@
"webSearch": "网页搜索",
"webBehavior": "行为",
"cliApps": "CLI 应用",
"mcp": "MCP",
"mcp": "MCP 服务",
"identity": "身份",
"safety": "安全",
"capabilities": "能力",
"integrations": "集成"
"integrations": "集成",
"apps": "应用"
},
"models": {
"selectModel": "选择模型",
@@ -380,6 +383,20 @@
"selectSize": "选择尺寸",
"configureProvider": "配置服务商",
"missingCredential": "启用图片生成前,请先配置这个服务商。"
},
"apps": {
"description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "全部",
"filterCli": "CLI 应用",
"filterMcp": "MCP 服务",
"enabledSummary": "已启用 {{count}} 个",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "搜索应用",
"featured": "精选",
"loading": "正在加载应用...",
"empty": "没有符合筛选条件的应用。"
}
},
"chat": {
@@ -595,8 +612,8 @@
}
},
"mentions": {
"ariaLabel": "应用和 MCP",
"label": "插件",
"ariaLabel": "应用",
"label": "应用",
"cliGroup": "CLI 应用",
"mcpGroup": "MCP 服务",
"cliBadge": "CLI",
+22 -5
View File
@@ -63,7 +63,8 @@
"language": {
"label": "語言",
"ariaLabel": "切換語言"
}
},
"apps": "應用"
},
"settings": {
"backToChat": "返回對話",
@@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "CLI 應用",
"mcp": "MCP"
"mcp": "MCP",
"apps": "應用"
},
"sections": {
"interface": "介面",
@@ -101,7 +103,8 @@
"capabilities": "功能",
"integrations": "Integrations",
"cliApps": "CLI 應用",
"mcp": "MCP"
"mcp": "MCP 服務",
"apps": "應用"
},
"rows": {
"theme": "主題",
@@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
},
"apps": {
"description": "新增 nanobot 可在聊天中使用的 App CLI 與 MCP 服務。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "全部",
"filterCli": "CLI 應用",
"filterMcp": "MCP 服務",
"enabledSummary": "已啟用 {{count}} 個",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "搜尋應用",
"featured": "精選",
"loading": "正在載入應用...",
"empty": "沒有符合篩選條件的應用。"
}
},
"chat": {
@@ -607,8 +624,8 @@
"io": "無法讀取這個檔案"
},
"mentions": {
"ariaLabel": "應用和 MCP",
"label": "插件",
"ariaLabel": "應用",
"label": "應用",
"cliGroup": "CLI 應用",
"mcpGroup": "MCP 服務",
"cliBadge": "CLI",
+65
View File
@@ -280,6 +280,59 @@ export interface SettingsPayload {
restart_required_sections?: Array<"runtime" | "web" | "image">;
}
export interface AppPackageRef {
manager: string;
name?: string;
}
export interface AppCapability {
type: "cli" | "mcp" | "skill" | string;
entry_point?: string;
package?: AppPackageRef;
path?: string;
transport?: string;
command?: string;
args?: string[];
url?: string;
fields?: Array<{
name: string;
target?: string;
required?: boolean;
secret?: boolean;
env_var?: string | null;
}>;
}
export interface AppPlan {
supported: boolean;
strategy?: string;
managed_paths?: string[];
verification?: string[];
}
export interface AppTrust {
registry: string;
level: string;
review_status: string;
}
export interface AppManifest {
schema: "agent-app.v1" | string;
id: string;
display_name: string;
version?: string;
description: string;
category: string;
source: string;
logo_url?: string | null;
brand_color?: string | null;
docs_url?: string | null;
capabilities: AppCapability[];
install: AppPlan;
remove: AppPlan;
trust: AppTrust;
}
export interface CliAppInfo {
name: string;
display_name: string;
@@ -295,6 +348,7 @@ export interface CliAppInfo {
logo_url?: string | null;
brand_color?: string | null;
skill_installed: boolean;
manifest?: AppManifest;
}
export interface CliAppsPayload {
@@ -304,7 +358,12 @@ export interface CliAppsPayload {
last_action?: {
ok: boolean;
message: string;
installed?: boolean;
removed?: boolean;
output?: string | null;
still_available?: boolean;
verification?: string[];
verification_failed?: string[];
};
}
@@ -342,6 +401,7 @@ export interface McpPresetInfo {
error?: string | null;
enabled_tools?: string[];
source?: "preset" | "custom" | string;
manifest?: AppManifest;
}
export interface McpPresetsPayload {
@@ -364,6 +424,11 @@ export interface McpPresetsPayload {
last_action?: {
ok: boolean;
message: string;
installed?: boolean;
removed?: boolean;
managed_paths_removed?: string[];
verification?: string[];
verification_failed?: string[];
tool_count?: number;
tool_names?: string[];
checked_at?: string | null;
+134
View File
@@ -13,6 +13,100 @@ const attachSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
let mockSessions: ChatSummary[] = [];
function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as Response;
}
function baseSettingsPayload() {
return {
agent: {
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
model_preset: "default",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
timezone: "UTC",
bot_name: "nanobot",
bot_icon: "nb",
tool_hint_max_length: 40,
},
model_presets: [{
name: "default",
label: "Default",
active: true,
is_default: true,
model: "openai/gpt-4o",
provider: "auto",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
}],
providers: [],
web_search: {
provider: "duckduckgo",
api_key_hint: null,
base_url: null,
max_results: 5,
timeout: 30,
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
},
web: {
enable: true,
proxy: null,
user_agent: null,
search: { max_results: 5, timeout: 30 },
fetch: { use_jina_reader: true },
},
image_generation: {
enabled: false,
provider: "openrouter",
provider_configured: false,
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "1:1",
default_image_size: "1K",
max_images_per_turn: 4,
save_dir: "generated",
providers: [],
},
runtime: {
config_path: "/tmp/config.json",
workspace_path: "/tmp/workspace",
gateway_host: "127.0.0.1",
gateway_port: 18790,
heartbeat: {
enabled: true,
interval_s: 1800,
keep_recent_messages: 8,
},
dream: {
schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_append_set: false,
},
requires_restart: false,
};
}
vi.mock("@/hooks/useSessions", async (importOriginal) => {
const React = await import("react");
const actual = await importOriginal<typeof import("@/hooks/useSessions")>();
@@ -709,6 +803,9 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
@@ -731,6 +828,7 @@ describe("App layout", () => {
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Apps" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
@@ -822,6 +920,42 @@ describe("App layout", () => {
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
});
it("opens Apps from the main sidebar without replacing the sidebar", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const href = String(input);
if (href === "/api/settings") {
return jsonResponse(baseSettingsPayload());
}
if (href === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" });
}
if (href === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
fireEvent.click(appsButton);
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute(
"aria-current",
"page",
);
expect(document.title).toBe("Apps · nanobot");
});
it("returns from settings to the blank start page when no session was active", async () => {
mockSessions = [
{
+1
View File
@@ -28,6 +28,7 @@ const SETTINGS_NAV_KEYS = [
"models",
"image",
"web",
"apps",
"runtime",
"advanced",
];
+191
View File
@@ -0,0 +1,191 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
import { ClientProvider } from "@/providers/ClientProvider";
function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as Response;
}
function settingsPayload() {
return {
agent: {
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
model_preset: "default",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
timezone: "UTC",
bot_name: "nanobot",
bot_icon: "nb",
tool_hint_max_length: 40,
},
model_presets: [{
name: "default",
label: "Default",
active: true,
is_default: true,
model: "openai/gpt-4o",
provider: "auto",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
}],
providers: [],
web_search: {
provider: "duckduckgo",
api_key_hint: null,
base_url: null,
max_results: 5,
timeout: 30,
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
},
web: {
enable: true,
proxy: null,
user_agent: null,
search: { max_results: 5, timeout: 30 },
fetch: { use_jina_reader: true },
},
image_generation: {
enabled: false,
provider: "openrouter",
provider_configured: false,
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "1:1",
default_image_size: "1K",
max_images_per_turn: 4,
save_dir: "generated",
providers: [],
},
runtime: {
config_path: "/tmp/config.json",
workspace_path: "/tmp/workspace",
gateway_host: "127.0.0.1",
gateway_port: 18790,
heartbeat: {
enabled: true,
interval_s: 1800,
keep_recent_messages: 8,
},
dream: {
schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_append_set: false,
},
requires_restart: false,
};
}
const installedAnyGen = {
name: "anygen",
display_name: "AnyGen",
category: "generation",
description: "Generate docs, slides, websites and more via AnyGen cloud API",
requires: "ANYGEN_API_KEY",
source: "harness",
entry_point: "cli-anything-anygen",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://www.google.com/s2/favicons?domain=anygen.io&sz=64",
brand_color: "#111827",
skill_installed: true,
};
function renderSettingsView() {
render(
<ClientProvider client={{} as never} token="tok">
<SettingsView
theme="light"
initialSection="apps"
onToggleTheme={() => {}}
onBackToChat={() => {}}
onModelNameChange={() => {}}
/>
</ClientProvider>,
);
}
describe("SettingsView Apps catalog", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") {
return jsonResponse(settingsPayload());
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [installedAnyGen],
installed_count: 1,
catalog_updated_at: "2026-04-18",
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/cli-apps/uninstall?name=anygen") {
return jsonResponse({
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
installed_count: 0,
catalog_updated_at: "2026-04-18",
last_action: {
ok: true,
message: "Uninstalled CLI for AnyGen.",
still_available: false,
},
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView();
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
expect(await screen.findByText("AnyGen")).toBeInTheDocument();
const uninstall = screen.getByRole("button", { name: "Uninstall CLI" });
fireEvent.click(uninstall);
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/cli-apps/uninstall?name=anygen",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
expect(await screen.findByText("Uninstalled CLI for AnyGen.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
});
});
+2 -2
View File
@@ -346,7 +346,7 @@ describe("ThreadComposer", () => {
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
const palette = screen.getByRole("listbox", { name: "Apps and MCP" });
const palette = screen.getByRole("listbox", { name: "Apps" });
expect(palette).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute(
"aria-selected",
@@ -365,7 +365,7 @@ describe("ThreadComposer", () => {
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+2 -2
View File
@@ -1088,7 +1088,7 @@ describe("ThreadShell", () => {
));
const input = await screen.findByLabelText("Message input");
expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument();
const payload: CliAppsPayload = {
apps: [{
@@ -1116,7 +1116,7 @@ describe("ThreadShell", () => {
});
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
expect(screen.getByRole("listbox", { name: "Apps and MCP" })).toBeInTheDocument();
expect(screen.getByRole("listbox", { name: "Apps" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
});
});