From a88a58b5163e77f5f1ce66da89bce2066ef68321 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Sun, 2 Aug 2026 22:52:35 +0000 Subject: [PATCH] feat(pi): expose first-class desktop workflows --- src/App.tsx | 115 ++-- src/components/AppSwitcher.tsx | 15 +- src/components/DeepLinkImportDialog.tsx | 10 + src/components/UsageScriptModal.tsx | 10 + src/components/common/AppToggleGroup.tsx | 32 +- .../prompts/PiNativePromptResources.tsx | 411 ++++++++++++ src/components/prompts/PromptFormPanel.tsx | 1 + src/components/prompts/PromptPanel.tsx | 12 + .../providers/AddProviderDialog.tsx | 9 +- .../providers/EditProviderDialog.tsx | 10 +- .../providers/PiNativeCatalogPanel.tsx | 335 ++++++++++ src/components/providers/ProviderCard.tsx | 22 +- src/components/providers/ProviderList.tsx | 2 +- .../providers/forms/EndpointSpeedTest.tsx | 1 + .../providers/forms/PiProviderForm.tsx | 589 ++++++++++++++++++ .../providers/forms/ProviderForm.tsx | 4 + src/components/proxy/FailoverToggle.tsx | 10 +- src/components/proxy/ProxyPanel.tsx | 136 +++- src/components/proxy/ProxyToggle.tsx | 12 +- .../sessions/SessionManagerPage.tsx | 55 +- src/components/settings/AboutSection.tsx | 11 +- .../settings/AppVisibilitySettings.tsx | 13 +- src/components/settings/DirectorySettings.tsx | 13 + .../settings/ImportExportSection.tsx | 5 +- src/components/settings/ProxyTabContent.tsx | 13 +- src/components/settings/SettingsPage.tsx | 1 + src/components/skills/UnifiedSkillsPanel.tsx | 82 ++- src/components/usage/PricingConfigPanel.tsx | 6 +- src/components/usage/UsageDashboard.tsx | 1 + src/components/usage/UsageHero.tsx | 26 +- src/config/appConfig.tsx | 55 +- src/hooks/useDirectorySettings.ts | 15 +- src/hooks/useImportExport.ts | 5 + src/hooks/useProxyStatus.ts | 12 +- src/hooks/useSettings.ts | 19 +- src/hooks/useSettingsForm.ts | 4 + src/hooks/useSkills.ts | 30 + src/i18n/locales/en.json | 144 ++++- src/i18n/locales/zh.json | 144 ++++- src/icons/extracted/index.ts | 1 + src/lib/api/deeplink.ts | 4 +- src/lib/api/index.ts | 6 + src/lib/api/pi.ts | 84 +++ src/lib/api/prompts.ts | 68 ++ src/lib/api/settings.ts | 1 + src/lib/api/skills.ts | 27 +- src/lib/api/types.ts | 3 +- src/lib/query/failover.ts | 10 +- src/lib/query/mutations.ts | 32 +- src/types.ts | 23 + src/types/proxy.ts | 1 + src/types/usage.ts | 47 +- tests/components/ImportExportSection.test.tsx | 17 + .../PiNativePromptResources.test.tsx | 144 +++++ tests/components/PiProviderForm.test.tsx | 162 +++++ tests/components/ProxyTabContent.apps.test.ts | 3 +- tests/components/SessionManagerPage.test.tsx | 18 + tests/components/UnifiedSkillsPanel.test.tsx | 74 ++- tests/hooks/useDirectorySettings.test.tsx | 2 + tests/hooks/useImportSkillsFromApps.test.tsx | 1 + tests/hooks/useSettings.test.tsx | 29 +- tests/hooks/useSettingsForm.test.tsx | 4 + tests/msw/state.ts | 4 + tests/types/usage.test.ts | 17 + 64 files changed, 2954 insertions(+), 218 deletions(-) create mode 100644 src/components/prompts/PiNativePromptResources.tsx create mode 100644 src/components/providers/PiNativeCatalogPanel.tsx create mode 100644 src/components/providers/forms/PiProviderForm.tsx create mode 100644 src/lib/api/pi.ts create mode 100644 tests/components/PiNativePromptResources.test.tsx create mode 100644 tests/components/PiProviderForm.test.tsx create mode 100644 tests/types/usage.test.ts diff --git a/src/App.tsx b/src/App.tsx index e794ab131..8bd26ce78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import type { Provider, VisibleApps } from "@/types"; import type { EnvConflict } from "@/types/env"; import { proxyKeys, useProvidersQuery, useSettingsQuery } from "@/lib/query"; import { + piApi, providersApi, settingsApi, type AppId, @@ -59,6 +60,7 @@ import { import { AppSwitcher } from "@/components/AppSwitcher"; import { ProfileSwitcher } from "@/components/profiles/ProfileSwitcher"; import { ProviderList } from "@/components/providers/ProviderList"; +import { PiNativeCatalogPanel } from "@/components/providers/PiNativeCatalogPanel"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; import { EditProviderDialog } from "@/components/providers/EditProviderDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -94,6 +96,7 @@ import ToolsPanel from "@/components/openclaw/ToolsPanel"; import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel"; import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner"; import HermesMemoryPanel from "@/components/hermes/HermesMemoryPanel"; +import { APP_IDS, DEFAULT_VISIBLE_APPS } from "@/config/appConfig"; type View = | "providers" @@ -121,20 +124,9 @@ const DEFAULT_DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px const HEADER_HEIGHT = 64; // px const STORAGE_KEY = "cc-switch-last-app"; -const VALID_APPS: AppId[] = [ - "claude", - "claude-desktop", - "codex", - "gemini", - "grokbuild", - "opencode", - "openclaw", - "hermes", -]; - const getInitialApp = (): AppId => { const saved = localStorage.getItem(STORAGE_KEY) as AppId | null; - if (saved && VALID_APPS.includes(saved)) { + if (saved && APP_IDS.includes(saved)) { return saved; } return "claude"; @@ -189,27 +181,16 @@ function App() { isLinux() && (settingsData?.useAppWindowControls ?? false); const dragBarHeight = useAppWindowControls ? 32 : DEFAULT_DRAG_BAR_HEIGHT; const contentTopOffset = dragBarHeight + HEADER_HEIGHT; - const visibleApps: VisibleApps = settingsData?.visibleApps ?? { - claude: true, - "claude-desktop": true, - codex: true, - gemini: true, - grokbuild: true, - opencode: true, - openclaw: true, - hermes: true, - }; + const visibleApps = useMemo( + () => ({ + ...DEFAULT_VISIBLE_APPS, + ...settingsData?.visibleApps, + }), + [settingsData?.visibleApps], + ); const getFirstVisibleApp = (): AppId => { - if (visibleApps.claude) return "claude"; - if (visibleApps["claude-desktop"]) return "claude-desktop"; - if (visibleApps.codex) return "codex"; - if (visibleApps.gemini) return "gemini"; - if (visibleApps.grokbuild) return "grokbuild"; - if (visibleApps.opencode) return "opencode"; - if (visibleApps.openclaw) return "openclaw"; - if (visibleApps.hermes) return "hermes"; - return "claude"; // fallback + return APP_IDS.find((app) => visibleApps[app]) ?? "claude"; }; useEffect(() => { @@ -220,6 +201,10 @@ function App() { // Fallback from sessions view when switching to an app without session support useEffect(() => { + if (currentView === "mcp" && sharedFeatureApp === "pi") { + setCurrentView("providers"); + return; + } if ( currentView === "sessions" && sharedFeatureApp !== "claude" && @@ -228,7 +213,8 @@ function App() { sharedFeatureApp !== "opencode" && sharedFeatureApp !== "openclaw" && sharedFeatureApp !== "gemini" && - sharedFeatureApp !== "hermes" + sharedFeatureApp !== "hermes" && + sharedFeatureApp !== "pi" ) { setCurrentView("providers"); } @@ -295,7 +281,9 @@ function App() { sharedFeatureApp === "opencode" || sharedFeatureApp === "openclaw" || sharedFeatureApp === "gemini" || - sharedFeatureApp === "hermes"; + sharedFeatureApp === "hermes" || + sharedFeatureApp === "pi"; + const hasMcpSupport = sharedFeatureApp !== "pi"; const { addProvider, @@ -726,7 +714,8 @@ function App() { if ( activeApp === "opencode" || activeApp === "openclaw" || - activeApp === "hermes" + activeApp === "hermes" || + activeApp === "pi" ) { let liveProviderIds: string[] = []; try { @@ -741,10 +730,17 @@ function App() { queryKey: openclawKeys.liveProviderIds, queryFn: () => providersApi.getOpenClawLiveProviderIds(), }) - : await queryClient.ensureQueryData({ - queryKey: hermesKeys.liveProviderIds, - queryFn: () => providersApi.getHermesLiveProviderIds(), - }); + : activeApp === "hermes" + ? await queryClient.ensureQueryData({ + queryKey: hermesKeys.liveProviderIds, + queryFn: () => providersApi.getHermesLiveProviderIds(), + }) + : ( + await queryClient.ensureQueryData({ + queryKey: ["pi", "nativeCatalog"], + queryFn: () => piApi.getNativeCatalog(), + }) + ).map((entry) => entry.providerKey); } catch (error) { console.error( "[App] Failed to load live provider IDs for duplication", @@ -978,6 +974,9 @@ function App() { transition={{ duration: 0.15 }} className="space-y-4" > + {activeApp === "pi" && ( + + )} - + {hasMcpSupport && ( + + )} ) : activeApp === "openclaw" ? ( <> @@ -1542,15 +1543,17 @@ function App() { > - + {hasMcpSupport && ( + + )} )} diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index bff3a2054..13d3163b6 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -3,6 +3,7 @@ import type { VisibleApps } from "@/types"; import { ProviderIcon } from "@/components/ProviderIcon"; import { cn } from "@/lib/utils"; import { Monitor, Terminal } from "lucide-react"; +import { APP_IDS } from "@/config/appConfig"; const APP_BADGE_ICON: Partial< Record @@ -17,16 +18,6 @@ interface AppSwitcherProps { visibleApps?: VisibleApps; } -const ALL_APPS: AppId[] = [ - "claude", - "claude-desktop", - "codex", - "gemini", - "grokbuild", - "opencode", - "openclaw", - "hermes", -]; const STORAGE_KEY = "cc-switch-last-app"; export function AppSwitcher({ @@ -49,6 +40,7 @@ export function AppSwitcher({ opencode: "opencode", openclaw: "openclaw", hermes: "hermes", + pi: "pi", }; const appDisplayName: Record = { claude: "Claude Code", @@ -59,10 +51,11 @@ export function AppSwitcher({ opencode: "OpenCode", openclaw: "OpenClaw", hermes: "Hermes", + pi: "Pi", }; // Filter apps based on visibility settings (default all visible) - const appsToShow = ALL_APPS.filter((app) => { + const appsToShow = APP_IDS.filter((app) => { if (!visibleApps) return true; return visibleApps[app]; }); diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index f2d4b669c..a967a76e0 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -405,6 +405,16 @@ export function DeepLinkImportDialog() { {/* Model Fields - 根据应用类型显示不同的模型字段 */} + {request.app === "pi" && request.api && ( +
+
+ {t("deeplink.api")} +
+
+ {request.api} +
+
+ )} {request.app === "claude" ? ( <> {/* Claude 四种模型字段 */} diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index a2960d7af..442f15f03 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -284,6 +284,16 @@ const UsageScriptModal: React.FC = ({ apiKey: (config as any).api_key, baseUrl: (config as any).base_url, }; + } else if (appId === "pi") { + // Pi: provider values are camelCase; a model may override baseUrl. + const root = config as any; + const firstModel = Array.isArray(root.models) + ? root.models[0] + : undefined; + return { + apiKey: root.apiKey, + baseUrl: firstModel?.baseUrl || root.baseUrl, + }; } else if (appId === "openclaw") { // OpenClaw: settingsConfig 顶层扁平(camelCase,对应 openclaw.json) return { diff --git a/src/components/common/AppToggleGroup.tsx b/src/components/common/AppToggleGroup.tsx index c41eb1a12..e082a43dd 100644 --- a/src/components/common/AppToggleGroup.tsx +++ b/src/components/common/AppToggleGroup.tsx @@ -11,27 +11,44 @@ interface AppToggleGroupProps { apps: Partial>; onToggle: (app: AppId, enabled: boolean) => void; appIds?: AppId[]; + stateByApp?: Partial>; +} + +export interface AppToggleVisualState { + /** 应用实际是否发现/启用了资源。 */ + active: boolean; + /** 用户期望状态;点击切换时以此取反。 */ + desired: boolean; + statusLabel?: string; + warning?: boolean; } export const AppToggleGroup: React.FC = ({ apps, onToggle, appIds = APP_IDS, + stateByApp, }) => { return (
{appIds.map((app) => { const { label, icon, activeClass } = APP_ICON_MAP[app]; - const enabled = apps[app]; + const visualState = stateByApp?.[app]; + const desired = visualState?.desired ?? Boolean(apps[app]); + const active = visualState?.active ?? desired; + const warning = + visualState?.warning ?? (visualState ? active !== desired : false); return ( @@ -39,8 +56,13 @@ export const AppToggleGroup: React.FC = ({

{label} - {enabled ? " ✓" : ""} + {active ? " ✓" : ""}

+ {visualState?.statusLabel && ( +

+ {visualState.statusLabel} +

+ )}
); diff --git a/src/components/prompts/PiNativePromptResources.tsx b/src/components/prompts/PiNativePromptResources.tsx new file mode 100644 index 000000000..bdaadd40d --- /dev/null +++ b/src/components/prompts/PiNativePromptResources.tsx @@ -0,0 +1,411 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { FilePlus2, Loader2, RefreshCw, Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + promptsApi, + type PiPromptFileKind, + type PiPromptFileSnapshot, + type PiPromptTemplate, +} from "@/lib/api/prompts"; +import { extractErrorMessage } from "@/utils/errorUtils"; + +const EDITABLE_FILES: Array<{ + kind: Exclude; + filename: string; + titleKey: string; + descriptionKey: string; +}> = [ + { + kind: "system_override", + filename: "SYSTEM.md", + titleKey: "pi.prompts.systemOverride", + descriptionKey: "pi.prompts.systemOverrideDescription", + }, + { + kind: "system_append", + filename: "APPEND_SYSTEM.md", + titleKey: "pi.prompts.systemAppend", + descriptionKey: "pi.prompts.systemAppendDescription", + }, +]; + +function mutationError(error: unknown, fallback: string) { + toast.error(extractErrorMessage(error) || fallback); +} + +function PiInstructionFileEditor({ + kind, + filename, + titleKey, + descriptionKey, +}: (typeof EDITABLE_FILES)[number]) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [draft, setDraft] = useState(""); + const [confirmCreate, setConfirmCreate] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const queryKey = ["pi", "promptFile", kind] as const; + const query = useQuery({ + queryKey, + queryFn: () => promptsApi.getPiPromptFile(kind), + }); + + useEffect(() => { + if (query.data) setDraft(query.data.content); + }, [query.data?.revision]); + + const save = useMutation({ + mutationFn: () => { + const snapshot = query.data; + if (!snapshot) throw new Error(t("pi.prompts.loadFirst")); + return promptsApi.replacePiPromptFile(kind, snapshot.revision, draft); + }, + onSuccess: (snapshot) => { + queryClient.setQueryData(queryKey, snapshot); + setConfirmCreate(false); + toast.success(t("pi.prompts.fileSaved", { filename })); + }, + onError: (error) => { + mutationError(error, t("pi.prompts.saveFailed")); + void query.refetch(); + }, + }); + const remove = useMutation({ + mutationFn: async () => { + const snapshot = query.data; + if (!snapshot) throw new Error(t("pi.prompts.loadFirst")); + await promptsApi.deletePiPromptFile(kind, snapshot.revision); + return promptsApi.getPiPromptFile(kind); + }, + onSuccess: (snapshot) => { + queryClient.setQueryData(queryKey, snapshot); + setDraft(""); + setConfirmDelete(false); + toast.success(t("pi.prompts.fileDeactivated", { filename })); + }, + onError: (error) => { + mutationError(error, t("pi.prompts.deleteFailed")); + void query.refetch(); + }, + }); + + const busy = save.isPending || remove.isPending; + const changed = Boolean(query.data && draft !== query.data.content); + const blank = !draft.trim(); + const requestSave = () => { + if (kind === "system_override" && query.data && !query.data.exists) { + setConfirmCreate(true); + return; + } + save.mutate(); + }; + + return ( +
+
+
+
+

{t(titleKey)}

+ + {query.data?.exists + ? t("pi.prompts.active") + : t("pi.prompts.inactive")} + +
+

+ {t(descriptionKey)} +

+ {query.data?.path && ( + + {query.data.path} + + )} +
+ +
+ + {query.isLoading ? ( +
+ + {t("common.loading")} +
+ ) : ( + <> +