feat(pi): expose first-class desktop workflows

This commit is contained in:
SaladDay
2026-08-02 22:52:35 +00:00
parent 7609ff799e
commit a88a58b516
64 changed files with 2954 additions and 218 deletions
+59 -56
View File
@@ -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<VisibleApps>(
() => ({
...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" && (
<PiNativeCatalogPanel providers={providers} />
)}
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
@@ -1443,15 +1442,17 @@ function App() {
>
<LayoutDashboard className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
{hasMcpSupport && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
)}
</>
) : activeApp === "openclaw" ? (
<>
@@ -1542,15 +1543,17 @@ function App() {
>
<History className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
{hasMcpSupport && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
)}
</>
)}
</motion.div>
+4 -11
View File
@@ -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<AppId, { icon: typeof Terminal; offsetY?: number }>
@@ -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<AppId, string> = {
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];
});
+10
View File
@@ -405,6 +405,16 @@ export function DeepLinkImportDialog() {
</div>
{/* Model Fields - 根据应用类型显示不同的模型字段 */}
{request.app === "pi" && request.api && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.api")}
</div>
<div className="col-span-2 text-sm font-mono">
{request.api}
</div>
</div>
)}
{request.app === "claude" ? (
<>
{/* Claude 四种模型字段 */}
+10
View File
@@ -284,6 +284,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
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 {
+27 -5
View File
@@ -11,27 +11,44 @@ interface AppToggleGroupProps {
apps: Partial<Record<AppId, boolean>>;
onToggle: (app: AppId, enabled: boolean) => void;
appIds?: AppId[];
stateByApp?: Partial<Record<AppId, AppToggleVisualState>>;
}
export interface AppToggleVisualState {
/** 应用实际是否发现/启用了资源。 */
active: boolean;
/** 用户期望状态;点击切换时以此取反。 */
desired: boolean;
statusLabel?: string;
warning?: boolean;
}
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
apps,
onToggle,
appIds = APP_IDS,
stateByApp,
}) => {
return (
<div className="flex items-center gap-1.5 flex-shrink-0">
{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 (
<Tooltip key={app}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onToggle(app, !enabled)}
aria-label={label}
aria-pressed={active}
onClick={() => onToggle(app, !desired)}
className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all ${
enabled ? activeClass : "opacity-35 hover:opacity-70"
}`}
active ? activeClass : "opacity-35 hover:opacity-70"
} ${warning ? "ring-2 ring-amber-500 ring-offset-1 ring-offset-background" : ""}`}
>
{icon}
</button>
@@ -39,8 +56,13 @@ export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
<TooltipContent side="bottom">
<p>
{label}
{enabled ? " ✓" : ""}
{active ? " ✓" : ""}
</p>
{visualState?.statusLabel && (
<p className="max-w-64 text-xs text-muted-foreground">
{visualState.statusLabel}
</p>
)}
</TooltipContent>
</Tooltip>
);
@@ -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<PiPromptFileKind, "global_context">;
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<PiPromptFileSnapshot>(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<PiPromptFileSnapshot>(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 (
<div className="rounded-lg border border-border bg-background/60 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">{t(titleKey)}</h4>
<Badge variant={query.data?.exists ? "default" : "outline"}>
{query.data?.exists
? t("pi.prompts.active")
: t("pi.prompts.inactive")}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{t(descriptionKey)}
</p>
{query.data?.path && (
<code className="mt-1 block break-all text-[10px] text-muted-foreground">
{query.data.path}
</code>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => void query.refetch()}
disabled={query.isFetching || busy}
title={t("common.refresh")}
>
<RefreshCw
className={`h-4 w-4 ${query.isFetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
{query.isLoading ? (
<div className="flex items-center gap-2 py-6 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common.loading")}
</div>
) : (
<>
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="mt-3 min-h-28 font-mono text-xs"
placeholder={t("pi.prompts.instructionPlaceholder")}
spellCheck={false}
/>
{blank && (
<p className="mt-1 text-xs text-destructive">
{t("pi.prompts.blankInstruction")}
</p>
)}
<div className="mt-3 flex justify-end gap-2">
{query.data?.exists && (
<Button
type="button"
variant="outline"
onClick={() => setConfirmDelete(true)}
disabled={busy}
>
{t("pi.prompts.deactivate")}
</Button>
)}
<Button
type="button"
onClick={requestSave}
disabled={!query.data || !changed || blank || busy}
>
{save.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
</>
)}
<ConfirmDialog
isOpen={confirmCreate}
title={t("pi.prompts.activateOverrideTitle", { filename })}
message={t("pi.prompts.activateOverrideMessage", { filename })}
confirmText={t("pi.prompts.activateOverride")}
onConfirm={() => save.mutate()}
onCancel={() => setConfirmCreate(false)}
/>
<ConfirmDialog
isOpen={confirmDelete}
title={t("pi.prompts.deactivateTitle", { filename })}
message={t("pi.prompts.deactivateMessage", { filename })}
confirmText={t("pi.prompts.deactivate")}
onConfirm={() => remove.mutate()}
onCancel={() => setConfirmDelete(false)}
/>
</div>
);
}
function PiTemplateEditor({
template,
onChanged,
}: {
template: PiPromptTemplate;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState(template.content);
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(
() => setDraft(template.content),
[template.content, template.revision],
);
const save = useMutation({
mutationFn: () =>
promptsApi.upsertPiPromptTemplate(
template.slug,
template.revision,
draft,
),
onSuccess: () => {
toast.success(t("pi.prompts.templateSaved", { slug: template.slug }));
onChanged();
},
onError: (error) =>
mutationError(error, t("pi.prompts.templateSaveFailed")),
});
const remove = useMutation({
mutationFn: () =>
promptsApi.deletePiPromptTemplate(template.slug, template.revision),
onSuccess: () => {
setConfirmDelete(false);
toast.success(t("pi.prompts.templateDeleted", { slug: template.slug }));
onChanged();
},
onError: (error) =>
mutationError(error, t("pi.prompts.templateDeleteFailed")),
});
const busy = save.isPending || remove.isPending;
return (
<div className="rounded-lg border border-border bg-background/60 p-3">
<div className="flex items-center justify-between gap-2">
<code className="text-xs font-medium">/{template.slug}</code>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setConfirmDelete(true)}
disabled={busy}
title={t("common.delete")}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="mt-2 min-h-24 font-mono text-xs"
spellCheck={false}
/>
<div className="mt-2 flex justify-end">
<Button
type="button"
size="sm"
onClick={() => save.mutate()}
disabled={draft === template.content || busy}
>
{save.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
<ConfirmDialog
isOpen={confirmDelete}
title={t("pi.prompts.deleteTemplateTitle", {
slug: template.slug,
})}
message={t("pi.prompts.deleteTemplateMessage", {
slug: template.slug,
})}
confirmText={t("common.delete")}
onConfirm={() => remove.mutate()}
onCancel={() => setConfirmDelete(false)}
/>
</div>
);
}
export function PiNativePromptResources() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [slug, setSlug] = useState("");
const [content, setContent] = useState("");
const templates = useQuery({
queryKey: ["pi", "promptTemplates"],
queryFn: () => promptsApi.listPiPromptTemplates(),
});
const createTemplate = useMutation({
mutationFn: () =>
promptsApi.upsertPiPromptTemplate(slug.trim(), "missing", content),
onSuccess: async () => {
setSlug("");
setContent("");
await queryClient.invalidateQueries({
queryKey: ["pi", "promptTemplates"],
});
toast.success(t("pi.prompts.templateCreated"));
},
onError: (error) =>
mutationError(error, t("pi.prompts.templateSaveFailed")),
});
const refreshTemplates = () =>
queryClient.invalidateQueries({ queryKey: ["pi", "promptTemplates"] });
return (
<section className="mb-5 space-y-4 rounded-xl border border-border bg-muted/20 p-4">
<div>
<h3 className="text-sm font-semibold">{t("pi.prompts.nativeTitle")}</h3>
<p className="mt-1 text-xs text-muted-foreground">
{t("pi.prompts.nativeDescription")}
</p>
</div>
<div className="grid gap-3 lg:grid-cols-2">
{EDITABLE_FILES.map((file) => (
<PiInstructionFileEditor key={file.kind} {...file} />
))}
</div>
<div className="rounded-lg border border-border bg-background/40 p-4">
<div className="flex items-start justify-between gap-2">
<div>
<h4 className="text-sm font-medium">{t("pi.prompts.templates")}</h4>
<p className="mt-1 text-xs text-muted-foreground">
{t("pi.prompts.templatesDescription")}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => void templates.refetch()}
disabled={templates.isFetching}
title={t("common.refresh")}
>
<RefreshCw
className={`h-4 w-4 ${
templates.isFetching ? "animate-spin" : ""
}`}
/>
</Button>
</div>
<div className="mt-3 grid gap-3 lg:grid-cols-2">
{(templates.data ?? []).map((template) => (
<PiTemplateEditor
key={template.slug}
template={template}
onChanged={() => void refreshTemplates()}
/>
))}
</div>
<div className="mt-4 rounded-lg border border-dashed p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium">
<FilePlus2 className="h-4 w-4" />
{t("pi.prompts.newTemplate")}
</div>
<Input
value={slug}
onChange={(event) => setSlug(event.target.value)}
placeholder={t("pi.prompts.templateSlug")}
/>
<Textarea
value={content}
onChange={(event) => setContent(event.target.value)}
className="mt-2 min-h-24 font-mono text-xs"
placeholder={t("pi.prompts.templateContent")}
spellCheck={false}
/>
<div className="mt-2 flex justify-end">
<Button
type="button"
size="sm"
onClick={() => createTemplate.mutate()}
disabled={!slug.trim() || createTemplate.isPending}
>
{createTemplate.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
{t("pi.prompts.createTemplate")}
</Button>
</div>
</div>
</div>
</section>
);
}
@@ -33,6 +33,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
opencode: "AGENTS.md",
openclaw: "AGENTS.md",
hermes: "AGENTS.md",
pi: "AGENTS.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
+12
View File
@@ -6,6 +6,7 @@ import { usePromptActions } from "@/hooks/usePromptActions";
import { useTauriEvent } from "@/hooks/useTauriEvent";
import PromptListItem from "./PromptListItem";
import PromptFormPanel from "./PromptFormPanel";
import { PiNativePromptResources } from "./PiNativePromptResources";
import { ConfirmDialog } from "../ConfirmDialog";
interface PromptPanelProps {
@@ -111,6 +112,17 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
</div>
<div className="flex-1 overflow-y-auto pb-16">
{appId === "pi" && <PiNativePromptResources />}
{appId === "pi" && (
<div className="mb-3">
<h3 className="text-sm font-semibold">
{t("pi.prompts.agentsLibrary")}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
{t("pi.prompts.agentsLibraryDescription")}
</p>
</div>
)}
{loading ? (
<div className="text-center py-12 text-muted-foreground">
{t("prompts.loading")}
@@ -51,6 +51,7 @@ export function AddProviderDialog({
appId !== "opencode" &&
appId !== "openclaw" &&
appId !== "hermes" &&
appId !== "pi" &&
appId !== "grokbuild" &&
appId !== "claude-desktop";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
@@ -158,9 +159,13 @@ export function AddProviderDialog({
values.presetId === GROKBUILD_OFFICIAL_PROVIDER_ID;
}
// OpenCode/OpenClaw: pass providerKey for ID generation
// Apps whose native catalog has a stable provider key use it as the
// managed provider identity.
if (
(appId === "opencode" || appId === "openclaw" || appId === "hermes") &&
(appId === "opencode" ||
appId === "openclaw" ||
appId === "hermes" ||
appId === "pi") &&
values.providerKey
) {
providerData.providerKey = values.providerKey;
@@ -66,10 +66,10 @@ export function EditProviderDialog({
return;
}
// OpenCode uses additive mode - each provider's config is stored independently in DB
// Reading live config would return the full opencode.json (with $schema, provider, mcp etc.)
// instead of just the provider fragment, causing incorrect nested structure on save
if (appId === "opencode") {
// OpenCode uses additive mode, while Pi's shared models.json is owned by
// the catalog coordinator. Neither has a per-provider generic live
// snapshot that may replace the DB aggregate in this form.
if (appId === "opencode" || appId === "pi") {
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
@@ -189,7 +189,7 @@ export function EditProviderDialog({
unknown
>;
const nextProviderId =
(appId === "opencode" || appId === "openclaw") &&
(appId === "opencode" || appId === "openclaw" || appId === "pi") &&
values.providerKey?.trim()
? values.providerKey.trim()
: provider.id;
@@ -0,0 +1,335 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
Download,
Loader2,
RefreshCw,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { piApi, type PiNativeDiagnostic } from "@/lib/api";
import type { Provider } from "@/types";
import { extractErrorMessage } from "@/utils/errorUtils";
interface PiNativeCatalogPanelProps {
providers: Record<string, Provider>;
}
interface PiModelChoice {
id: string;
name: string;
}
function providerModels(provider: Provider | undefined): PiModelChoice[] {
const models = provider?.settingsConfig?.models;
if (!Array.isArray(models)) return [];
return models.flatMap((value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const model = value as Record<string, unknown>;
if (typeof model.id !== "string" || !model.id) return [];
return [
{
id: model.id,
name:
typeof model.name === "string" && model.name ? model.name : model.id,
},
];
});
}
function diagnosticTone(entry: PiNativeDiagnostic): string {
if (entry.rawValidity === "invalid") {
return "border-red-500/30 bg-red-500/5";
}
if (
entry.rawValidity === "unknown" ||
entry.gatewayStatus === "unknown" ||
entry.managementStatus.status === "unsupported"
) {
return "border-amber-500/30 bg-amber-500/5";
}
return "border-border bg-background/50";
}
export function PiNativeCatalogPanel({ providers }: PiNativeCatalogPanelProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const catalog = useQuery({
queryKey: ["pi", "nativeCatalog"],
queryFn: () => piApi.getNativeCatalog(),
});
const defaults = useQuery({
queryKey: ["pi", "nativeDefaults"],
queryFn: () => piApi.getNativeDefaults(),
});
const providerIds = useMemo(() => Object.keys(providers), [providers]);
const managedNativeKeys = useMemo(() => {
const keys = new Map<string, string>();
for (const entry of catalog.data ?? []) {
if (entry.managementStatus.status === "managed") {
keys.set(entry.managementStatus.providerId, entry.providerKey);
}
}
return keys;
}, [catalog.data]);
const nativeDefaultProviderId = useMemo(() => {
const providerKey = defaults.data?.defaultProvider;
if (!providerKey) return undefined;
for (const [providerId, managedKey] of managedNativeKeys) {
if (managedKey === providerKey && providers[providerId]) {
return providerId;
}
}
return undefined;
}, [defaults.data?.defaultProvider, managedNativeKeys, providers]);
const [selectedProvider, setSelectedProvider] = useState("");
const [selectedModel, setSelectedModel] = useState("");
useEffect(() => {
const next = nativeDefaultProviderId ?? providerIds[0] ?? "";
setSelectedProvider((current) =>
current && providers[current] ? current : next,
);
}, [nativeDefaultProviderId, providerIds, providers]);
const models = useMemo(
() => providerModels(providers[selectedProvider]),
[providers, selectedProvider],
);
useEffect(() => {
const nativeModel =
nativeDefaultProviderId === selectedProvider
? defaults.data?.defaultModel
: undefined;
const next =
(nativeModel && models.some((model) => model.id === nativeModel)
? nativeModel
: undefined) ??
models[0]?.id ??
"";
setSelectedModel((current) =>
current && models.some((model) => model.id === current) ? current : next,
);
}, [
defaults.data?.defaultModel,
nativeDefaultProviderId,
models,
selectedProvider,
]);
const importMutation = useMutation({
mutationFn: (entry: PiNativeDiagnostic) =>
piApi.importNativeProvider(entry.providerKey, entry.fingerprint),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["pi", "nativeCatalog"] }),
queryClient.invalidateQueries({ queryKey: ["providers", "pi"] }),
]);
toast.success(t("pi.native.imported"));
},
onError: (error) => {
toast.error(extractErrorMessage(error) || t("pi.native.importFailed"));
void catalog.refetch();
},
});
const defaultMutation = useMutation({
mutationFn: () => piApi.setDefaultModel(selectedProvider, selectedModel),
onSuccess: async () => {
await Promise.all([
defaults.refetch(),
queryClient.invalidateQueries({ queryKey: ["providers", "pi"] }),
]);
toast.success(t("pi.native.defaultSaved"));
},
onError: (error) =>
toast.error(
extractErrorMessage(error) || t("pi.native.defaultSaveFailed"),
),
});
const entries = catalog.data ?? [];
const loading = catalog.isLoading || defaults.isLoading;
const defaultIsCurrent =
managedNativeKeys.get(selectedProvider) ===
defaults.data?.defaultProvider &&
defaults.data?.defaultModel === selectedModel;
return (
<section className="rounded-xl border border-border bg-muted/20 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">{t("pi.native.title")}</h2>
<p className="mt-1 text-xs text-muted-foreground">
{t("pi.native.description")}
</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
void Promise.all([catalog.refetch(), defaults.refetch()])
}
disabled={catalog.isFetching || defaults.isFetching}
>
<RefreshCw
className={`mr-1.5 h-3.5 w-3.5 ${
catalog.isFetching || defaults.isFetching ? "animate-spin" : ""
}`}
/>
{t("common.refresh")}
</Button>
</div>
{providerIds.length > 0 && (
<div className="mt-4 rounded-lg border bg-background/70 p-3">
<div className="mb-2 text-xs font-medium">
{t("pi.native.defaultModel")}
</div>
<div className="grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
<Select
value={selectedProvider}
onValueChange={setSelectedProvider}
>
<SelectTrigger aria-label={t("pi.native.defaultProvider")}>
<SelectValue placeholder={t("pi.native.defaultProvider")} />
</SelectTrigger>
<SelectContent>
{providerIds.map((providerId) => (
<SelectItem key={providerId} value={providerId}>
{providers[providerId].name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger aria-label={t("pi.native.defaultModel")}>
<SelectValue placeholder={t("pi.native.defaultModel")} />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
onClick={() => defaultMutation.mutate()}
disabled={
!selectedProvider ||
!selectedModel ||
defaultIsCurrent ||
defaultMutation.isPending
}
>
{defaultMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
{defaultIsCurrent
? t("pi.native.currentDefault")
: t("pi.native.setDefault")}
</Button>
</div>
{defaults.data?.defaultProvider && !nativeDefaultProviderId && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
{t("pi.native.unmanagedDefault", {
provider: defaults.data.defaultProvider,
model: defaults.data.defaultModel ?? t("common.notSet"),
})}
</p>
)}
</div>
)}
<div className="mt-4 space-y-2">
{loading ? (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common.loading")}
</div>
) : catalog.isError ? (
<div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/5 p-3 text-xs text-red-700 dark:text-red-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
{extractErrorMessage(catalog.error)}
</div>
) : entries.length === 0 ? (
<p className="py-2 text-xs text-muted-foreground">
{t("pi.native.empty")}
</p>
) : (
entries.map((entry) => (
<div
key={entry.providerKey}
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 ${diagnosticTone(
entry,
)}`}
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">
{entry.displayName || entry.providerKey}
</span>
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px]">
{entry.providerKey}
</code>
<span className="text-[10px] text-muted-foreground">
{t(`pi.native.management.${entry.managementStatus.status}`)}
{" · "}
{t(`pi.native.gateway.${entry.gatewayStatus}`)}
</span>
</div>
{entry.reasons.length > 0 && (
<p className="mt-1 break-all text-[10px] text-muted-foreground">
{entry.reasons
.map(
(reason) =>
`${reason.layer}:${reason.code}${
reason.jsonPointer ? `@${reason.jsonPointer}` : ""
}`,
)
.join(", ")}
</p>
)}
</div>
{entry.managementStatus.status === "importable" ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => importMutation.mutate(entry)}
disabled={importMutation.isPending}
>
{importMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Download className="mr-1.5 h-4 w-4" />
)}
{t("common.import")}
</Button>
) : entry.managementStatus.status === "managed" ? (
<span className="flex items-center gap-1 text-xs text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-4 w-4" />
{t("pi.native.managed")}
</span>
) : null}
</div>
))
)}
</div>
</section>
);
}
+19 -3
View File
@@ -116,14 +116,30 @@ const extractApiUrl = (provider: Provider, fallbackText: string) => {
const config = provider.settingsConfig;
if (config && typeof config === "object") {
const object = config as Record<string, any>;
const envBase =
(config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL ||
(config as Record<string, any>)?.env?.GOOGLE_GEMINI_BASE_URL;
object?.env?.ANTHROPIC_BASE_URL || object?.env?.GOOGLE_GEMINI_BASE_URL;
if (typeof envBase === "string" && envBase.trim()) {
return envBase;
}
const baseUrl = (config as Record<string, any>)?.config;
const directBaseUrl =
object.baseUrl ||
object.base_url ||
object.options?.baseURL ||
(Array.isArray(object.models)
? object.models.find(
(model: unknown) =>
model &&
typeof model === "object" &&
typeof (model as Record<string, unknown>).baseUrl === "string",
)?.baseUrl
: undefined);
if (typeof directBaseUrl === "string" && directBaseUrl.trim()) {
return directBaseUrl;
}
const baseUrl = object.config;
if (typeof baseUrl === "string" && baseUrl.includes("base_url")) {
const extractedBaseUrl = extractCodexBaseUrl(baseUrl);
+1 -1
View File
@@ -369,7 +369,7 @@ export function ProviderList({
<ProviderEmptyState
appId={appId}
onCreate={onCreate}
onImport={() => importMutation.mutate()}
onImport={appId === "pi" ? undefined : () => importMutation.mutate()}
/>
);
}
@@ -18,6 +18,7 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
opencode: 8,
openclaw: 8,
hermes: 8,
pi: 12,
};
interface TestResult {
@@ -0,0 +1,589 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { ProviderFormProps, ProviderFormValues } from "./ProviderForm";
import EndpointSpeedTest from "./EndpointSpeedTest";
import type { CustomEndpoint, EndpointCandidate, ProviderMeta } from "@/types";
const PI_API_EXAMPLES = [
"anthropic-messages",
"openai-responses",
"openai-completions",
"google-generative-ai",
] as const;
const ROOT_CONTROLLED_KEYS = new Set([
"name",
"baseUrl",
"api",
"apiKey",
"headers",
"authHeader",
"models",
]);
const MODEL_CONTROLLED_KEYS = new Set(["id", "name", "baseUrl", "api"]);
interface PiModelDraft {
key: string;
id: string;
name: string;
api: string;
baseUrl: string;
additionalJson: string;
}
function objectWithout(
value: Record<string, unknown>,
denied: Set<string>,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(value).filter(([key]) => !denied.has(key)),
);
}
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function optionalText(value: unknown): string {
return typeof value === "string" ? value : "";
}
function containsNonFiniteNumber(value: unknown): boolean {
if (typeof value === "number") return !Number.isFinite(value);
if (Array.isArray(value)) return value.some(containsNonFiniteNumber);
if (value && typeof value === "object") {
return Object.values(value).some(containsNonFiniteNumber);
}
return false;
}
function parseObject(
text: string,
objectError: string,
numberError: string,
): Record<string, unknown> {
let parsed: unknown;
try {
parsed = JSON.parse(text || "{}") as unknown;
} catch {
throw new Error(objectError);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(objectError);
}
if (containsNonFiniteNumber(parsed)) {
throw new Error(numberError);
}
return parsed as Record<string, unknown>;
}
function validateAbsoluteHttpUrl(value: string, errorMessage: string): void {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(errorMessage);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(errorMessage);
}
}
function modelDraft(value: unknown): PiModelDraft {
const model = asObject(value);
return {
key: crypto.randomUUID(),
id: optionalText(model.id),
name: optionalText(model.name),
api: optionalText(model.api),
baseUrl: optionalText(model.baseUrl),
additionalJson: JSON.stringify(
objectWithout(model, MODEL_CONTROLLED_KEYS),
null,
2,
),
};
}
function newModel(): PiModelDraft {
return {
key: crypto.randomUUID(),
id: "",
name: "",
api: "",
baseUrl: "",
// Pinned Pi accepts an id-only model override and supplies its own
// composition defaults. Do not invent model capability or pricing values.
additionalJson: "{}",
};
}
export function PiProviderForm({
providerId,
submitLabel,
onSubmit,
onCancel,
onSubmittingChange,
initialData,
showButtons = true,
}: ProviderFormProps) {
const { t } = useTranslation();
const initialConfig = useMemo(
() => asObject(initialData?.settingsConfig),
[initialData?.settingsConfig],
);
const isEdit = Boolean(initialData);
const [providerKey, setProviderKey] = useState(providerId ?? "");
const [name, setName] = useState(
initialData?.name ?? optionalText(initialConfig.name),
);
const [websiteUrl, setWebsiteUrl] = useState(initialData?.websiteUrl ?? "");
const [notes, setNotes] = useState(initialData?.notes ?? "");
const [baseUrl, setBaseUrl] = useState(optionalText(initialConfig.baseUrl));
const [api, setApi] = useState(optionalText(initialConfig.api));
const [apiKey, setApiKey] = useState(optionalText(initialConfig.apiKey));
const [authHeader, setAuthHeader] = useState(
typeof initialConfig.authHeader === "boolean"
? initialConfig.authHeader
: false,
);
const [authHeaderExplicit, setAuthHeaderExplicit] = useState(
typeof initialConfig.authHeader === "boolean",
);
const [headersJson, setHeadersJson] = useState(
JSON.stringify(asObject(initialConfig.headers), null, 2),
);
const [additionalJson, setAdditionalJson] = useState(
JSON.stringify(objectWithout(initialConfig, ROOT_CONTROLLED_KEYS), null, 2),
);
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [endpointAutoSelect, setEndpointAutoSelect] = useState(
initialData?.meta?.endpointAutoSelect ?? true,
);
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
() => Object.keys(initialData?.meta?.custom_endpoints ?? {}),
);
const [models, setModels] = useState<PiModelDraft[]>(() => {
const configured = Array.isArray(initialConfig.models)
? initialConfig.models
: [];
return configured.length > 0 ? configured.map(modelDraft) : [newModel()];
});
const updateModel = (
key: string,
update: Partial<Omit<PiModelDraft, "key">>,
) => {
setModels((current) =>
current.map((model) =>
model.key === key ? { ...model, ...update } : model,
),
);
};
const submit = async (event: React.FormEvent) => {
event.preventDefault();
onSubmittingChange?.(true);
try {
const trimmedName = name.trim();
const trimmedKey = providerKey.trim();
if (!trimmedName) throw new Error(t("pi.form.nameRequired"));
if (!isEdit && !trimmedKey) {
throw new Error(t("pi.form.providerKeyRequired"));
}
if (models.length === 0) throw new Error(t("pi.form.modelRequired"));
const headersLabel = t("pi.form.headers");
const headers = parseObject(
headersJson,
t("pi.form.jsonObjectRequired", { label: headersLabel }),
t("pi.form.nonFiniteNumber", { label: headersLabel }),
);
if (Object.values(headers).some((value) => typeof value !== "string")) {
throw new Error(t("pi.form.headersStringValues"));
}
const providerAdditionalLabel = t("pi.form.additionalConfig");
const rootAdditional = parseObject(
additionalJson,
t("pi.form.jsonObjectRequired", {
label: providerAdditionalLabel,
}),
t("pi.form.nonFiniteNumber", { label: providerAdditionalLabel }),
);
const seen = new Set<string>();
const normalizedModels = models.map((model, index) => {
const id = model.id.trim();
const modelApi = model.api.trim();
const modelBaseUrl = model.baseUrl.trim();
if (!id) {
throw new Error(t("pi.form.modelIdRequired", { index: index + 1 }));
}
if (seen.has(id)) {
throw new Error(t("pi.form.duplicateModel", { id }));
}
seen.add(id);
if (!modelApi && !api.trim()) {
throw new Error(t("pi.form.effectiveApiRequired", { id }));
}
const effectiveUrl = modelBaseUrl || baseUrl.trim();
if (!effectiveUrl) {
throw new Error(t("pi.form.effectiveBaseUrlRequired", { id }));
}
validateAbsoluteHttpUrl(
effectiveUrl,
t("pi.form.absoluteHttpUrlRequired", {
label: t("pi.form.modelBaseUrlFor", { id }),
}),
);
const modelAdditionalLabel = t("pi.form.modelAdditionalConfig", {
id,
});
const additional = parseObject(
model.additionalJson,
t("pi.form.jsonObjectRequired", { label: modelAdditionalLabel }),
t("pi.form.nonFiniteNumber", { label: modelAdditionalLabel }),
);
return {
...additional,
id,
...(model.name.trim() ? { name: model.name.trim() } : {}),
...(modelApi ? { api: modelApi } : {}),
...(modelBaseUrl ? { baseUrl: modelBaseUrl } : {}),
};
});
if (baseUrl.trim()) {
validateAbsoluteHttpUrl(
baseUrl.trim(),
t("pi.form.absoluteHttpUrlRequired", {
label: t("pi.form.providerBaseUrl"),
}),
);
}
const settingsConfig: Record<string, unknown> = {
...rootAdditional,
name: trimmedName,
...(baseUrl.trim() ? { baseUrl: baseUrl.trim() } : {}),
...(api.trim() ? { api: api.trim() } : {}),
...(apiKey ? { apiKey } : {}),
...(Object.keys(headers).length > 0 ? { headers } : {}),
...(authHeaderExplicit ? { authHeader } : {}),
models: normalizedModels,
};
const meta: ProviderMeta = {
...(initialData?.meta ?? {}),
endpointAutoSelect,
};
// Existing-provider endpoint membership is owned by the dedicated
// add/remove commands in EndpointSpeedTest. Provider update DTOs reject
// hydrated endpoint snapshots by design.
delete meta.custom_endpoints;
if (!isEdit && draftCustomEndpoints.length > 0) {
const now = Date.now();
meta.custom_endpoints = Object.fromEntries(
draftCustomEndpoints.map((url) => [
url,
{
url,
addedAt: now,
lastUsed: undefined,
} satisfies CustomEndpoint,
]),
);
}
const values: ProviderFormValues = {
name: trimmedName,
websiteUrl: websiteUrl.trim(),
notes: notes.trim(),
settingsConfig: JSON.stringify(settingsConfig),
icon: initialData?.icon ?? "pi",
iconColor: initialData?.iconColor ?? "",
providerKey: isEdit ? providerId : trimmedKey,
presetCategory: initialData?.category ?? "custom",
meta,
};
await onSubmit(values);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
onSubmittingChange?.(false);
}
};
const endpointCandidates = useMemo<EndpointCandidate[]>(() => {
const candidates: EndpointCandidate[] = [];
if (baseUrl.trim()) {
candidates.push({ url: baseUrl.trim(), isCustom: false });
}
for (const url of draftCustomEndpoints) {
if (url !== baseUrl.trim()) {
candidates.push({ url, isCustom: true });
}
}
return candidates;
}, [baseUrl, draftCustomEndpoints]);
return (
<form id="provider-form" onSubmit={submit} className="space-y-6">
<section className="grid gap-4 sm:grid-cols-2">
<Field label={t("pi.form.providerKey")}>
<Input
value={providerKey}
onChange={(event) => setProviderKey(event.target.value)}
disabled={isEdit}
placeholder="my-provider"
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
{t("pi.form.providerKeyHint")}
</p>
</Field>
<Field label={t("pi.form.displayName")}>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="My Pi provider"
/>
</Field>
<Field label={t("pi.form.providerApi")}>
<Input
value={api}
onChange={(event) => setApi(event.target.value)}
list="pi-api-examples"
placeholder="openai-responses"
/>
<datalist id="pi-api-examples">
{PI_API_EXAMPLES.map((value) => (
<option key={value} value={value} />
))}
</datalist>
<p className="text-xs text-muted-foreground">
{t("pi.form.inheritanceHint")}
</p>
</Field>
<Field label={t("pi.form.providerBaseUrl")}>
<Input
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="https://api.example.com/v1"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsEndpointModalOpen(true)}
>
{t("pi.form.manageEndpoints")}
</Button>
</Field>
<Field label={t("pi.form.credential")}>
<Input
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
type="password"
autoComplete="new-password"
placeholder="literal, $ENV, or !command"
/>
<p className="text-xs text-muted-foreground">
{t("pi.form.credentialHint")}
</p>
</Field>
<Field label={t("pi.form.website")}>
<Input
value={websiteUrl}
onChange={(event) => setWebsiteUrl(event.target.value)}
placeholder="https://example.com"
/>
</Field>
</section>
<label className="flex items-start gap-2 rounded-md border p-3 text-sm">
<input
type="checkbox"
checked={authHeader}
onChange={(event) => {
setAuthHeader(event.target.checked);
setAuthHeaderExplicit(true);
}}
className="mt-0.5"
/>
<span>
<span className="font-medium">{t("pi.form.authHeader")}</span>
<span className="block text-xs text-muted-foreground">
{t("pi.form.authHeaderHint")}
</span>
</span>
</label>
<Field label={t("pi.form.headers")}>
<Textarea
value={headersJson}
onChange={(event) => setHeadersJson(event.target.value)}
className="min-h-24 font-mono text-xs"
spellCheck={false}
/>
</Field>
<section className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">{t("pi.form.models")}</h3>
<p className="text-xs text-muted-foreground">
{t("pi.form.modelsHint")}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setModels((current) => [...current, newModel()])}
>
<Plus className="mr-1 h-4 w-4" />
{t("pi.form.addModel")}
</Button>
</div>
{models.map((model, index) => (
<div
key={model.key}
className="space-y-3 rounded-lg border bg-muted/20 p-4"
>
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">
{t("pi.form.modelNumber", { index: index + 1 })}
</h4>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() =>
setModels((current) =>
current.filter((item) => item.key !== model.key),
)
}
aria-label={t("pi.form.removeModel")}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={t("pi.form.modelId")}>
<Input
value={model.id}
onChange={(event) =>
updateModel(model.key, { id: event.target.value })
}
placeholder="model-id"
/>
</Field>
<Field label={t("pi.form.modelName")}>
<Input
value={model.name}
onChange={(event) =>
updateModel(model.key, { name: event.target.value })
}
placeholder="Display name"
/>
</Field>
<Field label={t("pi.form.modelApi")}>
<Input
value={model.api}
onChange={(event) =>
updateModel(model.key, { api: event.target.value })
}
list="pi-api-examples"
placeholder={t("pi.form.inherit")}
/>
</Field>
<Field label={t("pi.form.modelBaseUrl")}>
<Input
value={model.baseUrl}
onChange={(event) =>
updateModel(model.key, { baseUrl: event.target.value })
}
placeholder={t("pi.form.inherit")}
/>
</Field>
</div>
<Field label={t("pi.form.modelAdditionalConfig", { id: model.id })}>
<Textarea
value={model.additionalJson}
onChange={(event) =>
updateModel(model.key, {
additionalJson: event.target.value,
})
}
className="min-h-32 font-mono text-xs"
spellCheck={false}
/>
</Field>
</div>
))}
</section>
<Field label={t("pi.form.additionalConfig")}>
<Textarea
value={additionalJson}
onChange={(event) => setAdditionalJson(event.target.value)}
className="min-h-28 font-mono text-xs"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
{t("pi.form.additionalConfigHint")}
</p>
</Field>
<Field label={t("provider.notes")}>
<Textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
className="min-h-20"
/>
</Field>
{showButtons && (
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit">{submitLabel}</Button>
</div>
)}
{isEndpointModalOpen && (
<EndpointSpeedTest
appId="pi"
providerId={providerId}
value={baseUrl}
onChange={setBaseUrl}
initialEndpoints={endpointCandidates}
onClose={() => setIsEndpointModalOpen(false)}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
onCustomEndpointsChange={isEdit ? undefined : setDraftCustomEndpoints}
/>
)}
</form>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label>{label}</Label>
{children}
</div>
);
}
@@ -80,6 +80,7 @@ import { ClaudeDesktopProviderForm } from "./ClaudeDesktopProviderForm";
import { GrokBuildProviderForm } from "./GrokBuildProviderForm";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
import { PiProviderForm } from "./PiProviderForm";
import { OmoFormFields } from "./OmoFormFields";
import { parseOmoOtherFieldsObject } from "@/types/omo";
import {
@@ -242,6 +243,9 @@ export interface ProviderFormProps {
}
export function ProviderForm(props: ProviderFormProps) {
if (props.appId === "pi") {
return <PiProviderForm {...props} />;
}
if (props.appId === "claude-desktop") {
return <ClaudeDesktopProviderForm {...props} />;
}
+2 -8
View File
@@ -14,6 +14,7 @@ import { useProxyStatus } from "@/hooks/useProxyStatus";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import type { AppId } from "@/lib/api";
import { getAppLabel } from "@/config/appConfig";
interface FailoverToggleProps {
className?: string;
@@ -33,14 +34,7 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
setEnabled.mutate({ appType: activeApp, enabled: checked });
};
const appLabel =
activeApp === "claude"
? "Claude"
: activeApp === "codex"
? "Codex"
: activeApp === "grokbuild"
? "Grok Build"
: "Gemini";
const appLabel = getAppLabel(activeApp);
const tooltipText = !takeoverEnabled
? t("failover.tooltip.takeoverRequired", {
+107 -29
View File
@@ -9,6 +9,7 @@ import {
Loader2,
Zap,
Power,
KeyRound,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
@@ -30,6 +31,13 @@ import type { ProxyStatus } from "@/types/proxy";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion";
import { extractErrorMessage } from "@/utils/errorUtils";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { piApi } from "@/lib/api";
import {
getAppLabel,
PROXY_APP_IDS,
type ProxyAppId,
} from "@/config/appConfig";
interface ProxyPanelProps {
enableLocalProxy: boolean;
@@ -74,6 +82,9 @@ export function ProxyPanel({
const { data: codexQueue = [] } = useFailoverQueue("codex");
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
const { data: grokQueue = [] } = useFailoverQueue("grokbuild");
const { data: piQueue = [] } = useFailoverQueue("pi");
const [showPiCredentialConfirm, setShowPiCredentialConfirm] = useState(false);
const [isRotatingPiCredential, setIsRotatingPiCredential] = useState(false);
const handleTakeoverChange = async (appType: string, enabled: boolean) => {
try {
@@ -123,6 +134,24 @@ export function ProxyPanel({
}
};
const handleRotatePiCredential = async () => {
setShowPiCredentialConfirm(false);
setIsRotatingPiCredential(true);
try {
await piApi.resetGatewayCredential();
toast.success(t("proxy.piGateway.rotateSuccess"), {
description: t("proxy.piGateway.restartNotice"),
closeButton: true,
});
} catch (error) {
toast.error(t("proxy.piGateway.rotateFailed"), {
description: extractErrorMessage(error) || String(error),
});
} finally {
setIsRotatingPiCredential(false);
}
};
const handleSaveBasicConfig = async () => {
if (!globalConfig) return;
@@ -274,32 +303,30 @@ export function ProxyPanel({
defaultValue: "应用接管",
})}
</p>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{(["claude", "codex", "gemini", "grokbuild"] as const).map(
(appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType === "grokbuild" ? "Grok Build" : appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
},
)}
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
{PROXY_APP_IDS.map((appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium">
{getAppLabel(appType)}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{t("proxy.takeover.hint", {
@@ -312,6 +339,35 @@ export function ProxyPanel({
)}
</AnimatePresence>
<div className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card/50 p-4">
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-background ring-1 ring-border">
<KeyRound className="h-4 w-4 text-fuchsia-500" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
{t("proxy.piGateway.title")}
</p>
<p className="text-xs text-muted-foreground">
{t("proxy.piGateway.description")}
</p>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={isRotatingPiCredential}
onClick={() => setShowPiCredentialConfirm(true)}
>
{isRotatingPiCredential && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("proxy.piGateway.rotate")}
</Button>
</div>
{/* Running state: service info + stats */}
{isRunning && status ? (
<div className="space-y-6">
@@ -420,7 +476,8 @@ export function ProxyPanel({
{(claudeQueue.length > 0 ||
codexQueue.length > 0 ||
geminiQueue.length > 0 ||
grokQueue.length > 0) && (
grokQueue.length > 0 ||
piQueue.length > 0) && (
<div className="pt-3 border-t border-border space-y-3">
<div className="flex items-center gap-2">
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
@@ -476,6 +533,18 @@ export function ProxyPanel({
status={status}
/>
)}
{piQueue.length > 0 && (
<ProviderQueueGroup
appType="pi"
appLabel="Pi"
targets={piQueue.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
</div>
)}
</div>
@@ -622,6 +691,15 @@ export function ProxyPanel({
</div>
)}
</section>
<ConfirmDialog
isOpen={showPiCredentialConfirm}
variant="destructive"
title={t("proxy.piGateway.confirmTitle")}
message={t("proxy.piGateway.confirmMessage")}
confirmText={t("proxy.piGateway.rotate")}
onConfirm={() => void handleRotatePiCredential()}
onCancel={() => setShowPiCredentialConfirm(false)}
/>
</>
);
}
@@ -654,7 +732,7 @@ function StatCard({ icon, label, value, variant = "default" }: StatCardProps) {
}
interface ProviderQueueGroupProps {
appType: string;
appType: ProxyAppId;
appLabel: string;
targets: Array<{
id: string;
@@ -706,7 +784,7 @@ interface ProviderQueueItemProps {
name: string;
};
priority: number;
appType: string;
appType: ProxyAppId;
isCurrent: boolean;
}
+2 -10
View File
@@ -11,6 +11,7 @@ import { useProxyStatus } from "@/hooks/useProxyStatus";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import type { AppId } from "@/lib/api";
import { getAppLabel } from "@/config/appConfig";
interface ProxyToggleProps {
className?: string;
@@ -32,16 +33,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
const takeoverEnabled = takeoverStatus?.[activeApp] || false;
const appLabel =
activeApp === "claude"
? "Claude"
: activeApp === "codex"
? "Codex"
: activeApp === "gemini"
? "Gemini"
: activeApp === "grokbuild"
? "Grok Build"
: "OpenCode";
const appLabel = getAppLabel(activeApp);
const tooltipText = takeoverEnabled
? isRunning
+52 -3
View File
@@ -3,8 +3,9 @@ import { useSessionSearch } from "@/hooks/useSessionSearch";
import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Copy,
RefreshCw,
Search,
@@ -27,7 +28,7 @@ import {
useSessionMessagesQuery,
useSessionsQuery,
} from "@/lib/query";
import { sessionsApi } from "@/lib/api";
import { piApi, sessionsApi } from "@/lib/api";
import type { SessionMeta } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -88,7 +89,8 @@ type ProviderFilter =
| "opencode"
| "openclaw"
| "gemini"
| "hermes";
| "hermes"
| "pi";
type SessionListViewMode = "flat" | "grouped";
@@ -191,6 +193,12 @@ export function SessionManagerPage({ appId }: { appId: string }) {
const queryClient = useQueryClient();
const { data, isLoading, refetch } = useSessionsQuery();
const sessions = data ?? [];
const piSessionDiscovery = useQuery({
queryKey: ["pi", "sessionDiscovery"],
queryFn: () => piApi.getSessionDiscovery(),
enabled: appId === "pi",
staleTime: 30 * 1000,
});
const detailRef = useRef<HTMLDivElement | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>(
@@ -226,6 +234,10 @@ export function SessionManagerPage({ appId }: { appId: string }) {
Set<string>
>(() => initialGroupExpansionState.expandedDirectoryKeys);
useEffect(() => {
setProviderFilter(appId as ProviderFilter);
}, [appId]);
// 使用 FlexSearch 全文搜索
const { search: searchSessions } = useSessionSearch({
sessions,
@@ -794,6 +806,37 @@ export function SessionManagerPage({ appId }: { appId: string }) {
onWheel={(e) => e.stopPropagation()}
>
<div className="flex-1 overflow-hidden flex flex-col gap-4">
{appId === "pi" &&
piSessionDiscovery.data?.status === "requires_project_context" && (
<div
role="status"
className="flex shrink-0 items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
{t("sessionManager.piRelativeSessionDir")}{" "}
<code>{piSessionDiscovery.data.configuredPath}</code>
</span>
</div>
)}
{appId === "pi" &&
(piSessionDiscovery.data?.status === "unavailable" ||
piSessionDiscovery.isError) && (
<div
role="alert"
className="flex shrink-0 items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-800 dark:text-red-200"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
{t("sessionManager.piDiscoveryUnavailable", {
error:
piSessionDiscovery.data?.status === "unavailable"
? piSessionDiscovery.data.reason
: extractErrorMessage(piSessionDiscovery.error),
})}
</span>
</div>
)}
{/* 主内容区域 - 左右分栏 */}
<div className="flex-1 overflow-hidden grid gap-4 md:grid-cols-[320px_1fr]">
{/* 左侧会话列表 */}
@@ -1128,6 +1171,12 @@ export function SessionManagerPage({ appId }: { appId: string }) {
<span>Gemini CLI</span>
</div>
</SelectItem>
<SelectItem value="pi">
<div className="flex items-center gap-2">
<ProviderIcon icon="pi" name="pi" size={14} />
<span>Pi</span>
</div>
</SelectItem>
</SelectContent>
</Select>
+9 -2
View File
@@ -67,6 +67,7 @@ const TOOL_NAMES = [
"opencode",
"openclaw",
"hermes",
"pi",
] as const;
type ToolName = (typeof TOOL_NAMES)[number];
type ToolLifecycleAction = "install" | "update";
@@ -138,7 +139,9 @@ ${posixScriptInstallCommand("https://opencode.ai/install")} || npm i -g opencode
# OpenClaw
npm i -g openclaw@latest
# Hermes
${posixScriptInstallCommand("https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh")}`;
${posixScriptInstallCommand("https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh")}
# Pi
npm i -g @earendil-works/pi-coding-agent@latest`;
const WINDOWS_ONE_CLICK_INSTALL_COMMANDS = `# Claude Code
npm i -g @anthropic-ai/claude-code@latest
@@ -153,7 +156,9 @@ npm i -g opencode-ai@latest
# OpenClaw
npm i -g openclaw@latest
# Hermes
${HERMES_WINDOWS_INSTALL_COMMAND}`;
${HERMES_WINDOWS_INSTALL_COMMAND}
# Pi
npm i -g @earendil-works/pi-coding-agent@latest`;
const ONE_CLICK_INSTALL_COMMANDS = isWindows()
? WINDOWS_ONE_CLICK_INSTALL_COMMANDS
@@ -167,6 +172,7 @@ const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
opencode: "OpenCode",
openclaw: "OpenClaw",
hermes: "Hermes",
pi: "Pi",
};
// 后端返回的 tool 是 string;这里收敛唯一的 ToolName 断言与兜底,供升级确认
@@ -183,6 +189,7 @@ const TOOL_APP_IDS: Record<ToolName, AppId> = {
opencode: "opencode",
openclaw: "openclaw",
hermes: "hermes",
pi: "pi",
};
// 工具版本探测代价高:每个工具一次 `--version` 子进程 + 一次 npm/github/pypi 网络请求。
@@ -7,6 +7,7 @@ import { ProviderIcon } from "@/components/ProviderIcon";
import type { SettingsFormState } from "@/hooks/useSettings";
import type { VisibleApps } from "@/types";
import type { AppId } from "@/lib/api";
import { DEFAULT_VISIBLE_APPS } from "@/config/appConfig";
interface AppVisibilitySettingsProps {
settings: SettingsFormState;
@@ -30,6 +31,7 @@ const APP_CONFIG: Array<{
{ id: "opencode", icon: "opencode", nameKey: "apps.opencode" },
{ id: "openclaw", icon: "openclaw", nameKey: "apps.openclaw" },
{ id: "hermes", icon: "hermes", nameKey: "apps.hermes" },
{ id: "pi", icon: "pi", nameKey: "apps.pi" },
];
export function AppVisibilitySettings({
@@ -38,16 +40,7 @@ export function AppVisibilitySettings({
}: AppVisibilitySettingsProps) {
const { t } = useTranslation();
const visibleApps: VisibleApps = settings.visibleApps ?? {
claude: true,
"claude-desktop": true,
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
openclaw: true,
hermes: true,
};
const visibleApps: VisibleApps = settings.visibleApps ?? DEFAULT_VISIBLE_APPS;
// Count how many apps are currently visible
const visibleCount = Object.values(visibleApps).filter(Boolean).length;
@@ -21,6 +21,7 @@ interface DirectorySettingsProps {
opencodeDir?: string;
openclawDir?: string;
hermesDir?: string;
piDir?: string;
onDirectoryChange: (app: DirectoryAppId, value?: string) => void;
onBrowseDirectory: (app: DirectoryAppId) => Promise<void>;
onResetDirectory: (app: DirectoryAppId) => Promise<void>;
@@ -39,6 +40,7 @@ export function DirectorySettings({
opencodeDir,
openclawDir,
hermesDir,
piDir,
onDirectoryChange,
onBrowseDirectory,
onResetDirectory,
@@ -171,6 +173,17 @@ export function DirectorySettings({
onBrowse={() => onBrowseDirectory("hermes")}
onReset={() => onResetDirectory("hermes")}
/>
<DirectoryInput
label={t("settings.piConfigDir")}
description={undefined}
value={piDir}
resolvedValue={resolvedDirs.pi}
placeholder={t("settings.browsePlaceholderPi")}
onChange={(val) => onDirectoryChange("pi", val)}
onBrowse={() => onBrowseDirectory("pi")}
onReset={() => onResetDirectory("pi")}
/>
</section>
</div>
);
@@ -51,6 +51,9 @@ export function ImportExportSection({
<p className="text-sm text-muted-foreground">
{t("settings.importExportHint")}
</p>
<p className="text-xs text-muted-foreground">
{t("settings.piImportExportBoundary")}
</p>
</header>
<div className="space-y-4 rounded-lg border border-border bg-muted/40 p-6">
@@ -189,7 +192,7 @@ function ImportStatusMessage({
<div className="space-y-1.5">
<p className="font-semibold">{t("settings.importPartialSuccess")}</p>
<p className="text-yellow-600/80 dark:text-yellow-400/80">
{t("settings.importPartialHint")}
{errorMessage || t("settings.importPartialHint")}
</p>
</div>
</div>
+6 -7
View File
@@ -19,18 +19,17 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
import { ToggleRow } from "@/components/ui/toggle-row";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import type { SettingsFormState } from "@/hooks/useSettings";
import { getAppLabel, PROXY_APP_IDS } from "@/config/appConfig";
interface ProxyTabContentProps {
settings: SettingsFormState;
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<boolean | void>;
}
export const FAILOVER_APPS = [
{ id: "claude", label: "Claude" },
{ id: "codex", label: "Codex" },
{ id: "gemini", label: "Gemini" },
{ id: "grokbuild", label: "Grok Build" },
] as const;
export const FAILOVER_APPS = PROXY_APP_IDS.map((id) => ({
id,
label: getAppLabel(id),
}));
export function ProxyTabContent({
settings,
@@ -179,7 +178,7 @@ export function ProxyTabContent({
)}
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsList className="grid w-full grid-cols-5">
{FAILOVER_APPS.map(({ id, label }) => (
<TabsTrigger key={id} value={id}>
{label}
+1
View File
@@ -356,6 +356,7 @@ export function SettingsPage({
opencodeDir={settings.opencodeConfigDir}
openclawDir={settings.openclawConfigDir}
hermesDir={settings.hermesConfigDir}
piDir={settings.piConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
+80 -2
View File
@@ -15,6 +15,7 @@ import {
type SkillBackupEntry,
useDeleteSkillBackup,
useInstalledSkills,
usePiSkillStatuses,
useSkillBackups,
useRestoreSkillBackup,
useToggleSkillApp,
@@ -25,6 +26,7 @@ import {
useCheckSkillUpdates,
useUpdateSkill,
type InstalledSkill,
type PiSkillStatus,
type SkillUpdateInfo,
} from "@/hooks/useSkills";
import type { AppId } from "@/lib/api/types";
@@ -81,6 +83,11 @@ const UnifiedSkillsPanel = React.forwardRef<
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
const { data: skills, isLoading } = useInstalledSkills();
const {
data: piSkillStatuses,
isLoading: isLoadingPiSkillStatuses,
isError: isPiSkillStatusError,
} = usePiSkillStatuses();
const {
data: skillBackups = [],
refetch: refetchSkillBackups,
@@ -123,15 +130,22 @@ const UnifiedSkillsPanel = React.forwardRef<
opencode: 0,
openclaw: 0,
hermes: 0,
pi: 0,
};
if (!skills) return counts;
skills.forEach((skill) => {
for (const app of SKILLS_APP_IDS) {
if (skill.apps[app]) counts[app]++;
if (app === "pi") {
if (piSkillStatuses?.[skill.id]?.effectivelyDiscovered) {
counts.pi++;
}
} else if (skill.apps[app]) {
counts[app]++;
}
}
});
return counts;
}, [skills]);
}, [piSkillStatuses, skills]);
const handleToggleApp = async (id: string, app: AppId, enabled: boolean) => {
try {
@@ -433,6 +447,11 @@ const UnifiedSkillsPanel = React.forwardRef<
onToggleApp={handleToggleApp}
onUninstall={() => handleUninstall(skill)}
onUpdate={() => handleUpdateSkill(skill)}
piStatus={piSkillStatuses?.[skill.id]}
piStatusUnavailable={
!isLoadingPiSkillStatuses &&
(isPiSkillStatusError || !piSkillStatuses?.[skill.id])
}
isLast={index === skills.length - 1}
/>
))}
@@ -486,6 +505,8 @@ interface InstalledSkillListItemProps {
onToggleApp: (id: string, app: AppId, enabled: boolean) => void;
onUninstall: () => void;
onUpdate?: () => void;
piStatus?: PiSkillStatus;
piStatusUnavailable?: boolean;
isLast?: boolean;
}
@@ -496,6 +517,8 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
onToggleApp,
onUninstall,
onUpdate,
piStatus,
piStatusUnavailable,
isLast,
}) => {
const { t } = useTranslation();
@@ -516,6 +539,39 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
return t("skills.local");
}, [skill.repoOwner, skill.repoName, t]);
const piVisualState = useMemo(() => {
if (!piStatus) {
return {
active: false,
desired: skill.apps.pi,
warning: true,
label: piStatusUnavailable
? t("skills.piStatus.inspectionUnavailable")
: t("skills.piStatus.inspecting"),
};
}
let key = "inactive";
if (piStatus.effectivelyDiscovered) {
key = piStatus.desiredEnabled ? "active" : "unmanagedActive";
} else if (piStatus.desiredEnabled) {
if (piStatus.ownership === "foreign") key = "foreignConflict";
else if (piStatus.ownership === "stale") key = "staleDeployment";
else if (piStatus.discovery === "shadowed") key = "shadowed";
else if (piStatus.discovery === "invalid") key = "invalid";
else key = "desiredButMissing";
}
return {
active: piStatus.effectivelyDiscovered,
desired: piStatus.desiredEnabled,
warning:
piStatus.effectivelyDiscovered !== piStatus.desiredEnabled ||
Boolean(piStatus.issue),
label: t(`skills.piStatus.${key}`),
};
}, [piStatus, piStatusUnavailable, skill.apps.pi, t]);
return (
<ListItemRow isLast={isLast}>
<div className="flex-1 min-w-0">
@@ -543,6 +599,18 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
{t("skills.updateAvailable")}
</Badge>
)}
<Badge
variant="outline"
className={`shrink-0 text-[10px] px-1.5 py-0 h-4 ${
piVisualState.active && !piVisualState.warning
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: piVisualState.warning
? "border-amber-500 text-amber-600 dark:text-amber-400"
: "text-muted-foreground"
}`}
>
Pi: {piVisualState.label}
</Badge>
</div>
{skill.description && (
<p
@@ -556,6 +624,14 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
<AppToggleGroup
apps={skill.apps}
stateByApp={{
pi: {
active: piVisualState.active,
desired: piVisualState.desired,
warning: piVisualState.warning,
statusLabel: piVisualState.label,
},
}}
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
appIds={SKILLS_APP_IDS}
/>
@@ -751,6 +827,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
opencode: skill.foundIn.includes("opencode"),
openclaw: false,
hermes: skill.foundIn.includes("hermes"),
pi: skill.foundIn.includes("pi"),
},
]),
),
@@ -778,6 +855,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
opencode: false,
openclaw: false,
hermes: false,
pi: false,
},
})),
);
+4 -2
View File
@@ -34,7 +34,7 @@ import { toast } from "sonner";
import { proxyApi } from "@/lib/api/proxy";
import { ModelsDevAutoSyncPanel } from "./ModelsDevAutoSyncPanel";
const PRICING_APPS = ["claude", "codex", "gemini", "grokbuild"] as const;
const PRICING_APPS = ["claude", "codex", "gemini", "grokbuild", "pi"] as const;
type PricingApp = (typeof PRICING_APPS)[number];
type PricingModelSource = "request" | "response";
@@ -53,12 +53,13 @@ export function PricingConfigPanel() {
const [isAddingNew, setIsAddingNew] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
// 三个应用的配置状态
// All applications with a first-class usage pipeline.
const [appConfigs, setAppConfigs] = useState<AppConfigState>({
claude: { multiplier: "1", source: "response" },
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
grokbuild: { multiplier: "1", source: "response" },
pi: { multiplier: "1", source: "response" },
});
const [originalConfigs, setOriginalConfigs] = useState<AppConfigState | null>(
null,
@@ -105,6 +106,7 @@ export function PricingConfigPanel() {
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
grokbuild: { multiplier: "1", source: "response" },
pi: { multiplier: "1", source: "response" },
};
for (const result of results) {
newState[result.app] = {
+1
View File
@@ -71,6 +71,7 @@ const APP_FILTER_ICON: Record<AppType, string> = {
gemini: "gemini",
grokbuild: "grok",
opencode: "opencode",
pi: "pi",
};
// Select 的 "all" 哨兵和用户自定义名称同处一个值域——真有来源/模型叫 "all"
+6 -20
View File
@@ -23,7 +23,7 @@ import {
parseFiniteNumber,
} from "./format";
import {
CACHE_INCLUSIVE_APP_TYPES,
getCacheWriteAvailability,
type AppType,
type UsageRangeSelection,
type UsageSummary,
@@ -69,6 +69,10 @@ const TITLE_THEMES: Record<AppType | "all", TitleTheme> = {
accent: "text-purple-600 dark:text-purple-400",
iconBg: "bg-purple-500/10",
},
pi: {
accent: "text-teal-600 dark:text-teal-400",
iconBg: "bg-teal-500/10",
},
};
/**
@@ -123,24 +127,6 @@ function pickSummary(
return aggregateSummaries(apps.map((a) => a.summary));
}
type CacheWriteState = "ok" | "partial" | "na";
/**
* Anthropic-style protocols report cache creation; OpenAI-style protocols
* (Codex/Gemini) do not — so a mix shows the number with a caveat, all-OpenAI
* shows N/A. `appTypes` is the set actually contributing to the displayed
* summary (a single app, or every app that participated in "all").
*/
function deriveCacheWriteState(appTypes: string[]): CacheWriteState {
if (appTypes.length === 0) return "ok";
const inclusive = appTypes.filter((t) =>
CACHE_INCLUSIVE_APP_TYPES.has(t),
).length;
if (inclusive === appTypes.length) return "na";
if (inclusive === 0) return "ok";
return "partial";
}
/**
* Hero 标题图标:选中具体应用时显示该应用的品牌图标,"全部"时回退到通用闪电。
* 复用 APP_ICON_MAP(与侧边栏 / 应用切换器同一套图标),用 cloneElement 放大到
@@ -193,7 +179,7 @@ export function UsageHero({
const appLabel =
appType && appType in TITLE_THEMES ? t(`usage.appFilter.${appType}`) : null;
const cacheWriteState = deriveCacheWriteState(
const cacheWriteState = getCacheWriteAvailability(
appType ? [appType] : allApps.map((a) => a.appType),
);
+52 -3
View File
@@ -1,5 +1,6 @@
import React from "react";
import type { AppId } from "@/lib/api/types";
import type { VisibleApps } from "@/types";
import {
ClaudeIcon,
CodexIcon,
@@ -24,9 +25,22 @@ export const APP_IDS: AppId[] = [
"opencode",
"openclaw",
"hermes",
"pi",
];
/** App IDs shown in Skills panels (excludes OpenClaw — it doesn't support Skills) */
export const DEFAULT_VISIBLE_APPS: VisibleApps = {
claude: true,
"claude-desktop": true,
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
openclaw: true,
hermes: true,
pi: true,
};
/** App IDs shown in Skills panels. */
export const SKILLS_APP_IDS: AppId[] = [
"claude",
"codex",
@@ -34,10 +48,33 @@ export const SKILLS_APP_IDS: AppId[] = [
"grokbuild",
"opencode",
"hermes",
"pi",
];
/** App IDs shown in MCP panels (excludes OpenClaw) */
export const MCP_APP_IDS: AppId[] = [...SKILLS_APP_IDS];
export type ProxyAppId = Extract<
AppId,
"claude" | "codex" | "gemini" | "grokbuild" | "pi"
>;
/** Apps with a complete local gateway + failover data plane. */
export const PROXY_APP_IDS: ProxyAppId[] = [
"claude",
"codex",
"gemini",
"grokbuild",
"pi",
];
/** Pi has no native MCP registry; do not manufacture a disabled mirror. */
export type McpAppId = Exclude<AppId, "claude-desktop" | "openclaw" | "pi">;
export const MCP_APP_IDS: McpAppId[] = [
"claude",
"codex",
"gemini",
"grokbuild",
"opencode",
"hermes",
];
export const APP_ICON_MAP: Record<AppId, AppConfig> = {
claude: {
@@ -125,4 +162,16 @@ export const APP_ICON_MAP: Record<AppId, AppConfig> = {
badgeClass:
"bg-violet-500/10 text-violet-700 dark:text-violet-300 hover:bg-violet-500/20 border-0 gap-1.5",
},
pi: {
label: "Pi",
icon: <ProviderIcon icon="pi" name="Pi" size={14} showFallback={false} />,
activeClass:
"bg-fuchsia-500/10 ring-1 ring-fuchsia-500/20 hover:bg-fuchsia-500/20 text-fuchsia-600 dark:text-fuchsia-400",
badgeClass:
"bg-fuchsia-500/10 text-fuchsia-700 dark:text-fuchsia-300 hover:bg-fuchsia-500/20 border-0 gap-1.5",
},
};
export function getAppLabel(appId: string): string {
return APP_ICON_MAP[appId as AppId]?.label ?? appId;
}
+14 -1
View File
@@ -13,7 +13,8 @@ type AppDirectoryKey =
| "grokbuild"
| "opencode"
| "openclaw"
| "hermes";
| "hermes"
| "pi";
type DirectoryKey = "appConfig" | AppDirectoryKey;
export interface ResolvedDirectories {
@@ -25,6 +26,7 @@ export interface ResolvedDirectories {
opencode: string;
openclaw: string;
hermes: string;
pi: string;
}
// Single source of truth for per-app directory metadata.
@@ -39,6 +41,7 @@ const APP_DIRECTORY_META: Record<
opencode: { key: "opencode", defaultFolder: ".config/opencode" },
openclaw: { key: "openclaw", defaultFolder: ".openclaw" },
hermes: { key: "hermes", defaultFolder: ".hermes" },
pi: { key: "pi", defaultFolder: ".pi/agent" },
};
const DIRECTORY_KEY_TO_SETTINGS_FIELD: Record<
@@ -52,6 +55,7 @@ const DIRECTORY_KEY_TO_SETTINGS_FIELD: Record<
opencode: "opencodeConfigDir",
openclaw: "openclawConfigDir",
hermes: "hermesConfigDir",
pi: "piConfigDir",
};
const sanitizeDir = (value?: string | null): string | undefined => {
@@ -138,6 +142,7 @@ export function useDirectorySettings({
opencode: "",
openclaw: "",
hermes: "",
pi: "",
});
const [isLoading, setIsLoading] = useState(true);
@@ -150,6 +155,7 @@ export function useDirectorySettings({
opencode: "",
openclaw: "",
hermes: "",
pi: "",
});
const initialAppConfigDirRef = useRef<string | undefined>(undefined);
@@ -169,6 +175,7 @@ export function useDirectorySettings({
opencodeDir,
openclawDir,
hermesDir,
piDir,
defaultAppConfig,
defaultClaudeDir,
defaultCodexDir,
@@ -177,6 +184,7 @@ export function useDirectorySettings({
defaultOpencodeDir,
defaultOpenclawDir,
defaultHermesDir,
defaultPiDir,
] = await Promise.all([
settingsApi.getAppConfigDirOverride(),
settingsApi.getConfigDir("claude"),
@@ -186,6 +194,7 @@ export function useDirectorySettings({
settingsApi.getConfigDir("opencode"),
settingsApi.getConfigDir("openclaw"),
settingsApi.getConfigDir("hermes"),
settingsApi.getConfigDir("pi"),
computeDefaultAppConfigDir(),
computeDefaultConfigDir("claude"),
computeDefaultConfigDir("codex"),
@@ -194,6 +203,7 @@ export function useDirectorySettings({
computeDefaultConfigDir("opencode"),
computeDefaultConfigDir("openclaw"),
computeDefaultConfigDir("hermes"),
computeDefaultConfigDir("pi"),
]);
if (!active) return;
@@ -209,6 +219,7 @@ export function useDirectorySettings({
opencode: defaultOpencodeDir ?? "",
openclaw: defaultOpenclawDir ?? "",
hermes: defaultHermesDir ?? "",
pi: defaultPiDir ?? "",
};
setAppConfigDir(normalizedOverride);
@@ -223,6 +234,7 @@ export function useDirectorySettings({
opencode: opencodeDir || defaultsRef.current.opencode,
openclaw: openclawDir || defaultsRef.current.openclaw,
hermes: hermesDir || defaultsRef.current.hermes,
pi: piDir || defaultsRef.current.pi,
});
} catch (error) {
console.error(
@@ -365,6 +377,7 @@ export function useDirectorySettings({
opencode: overrides?.opencode ?? defaultsRef.current.opencode,
openclaw: overrides?.openclaw ?? defaultsRef.current.openclaw,
hermes: overrides?.hermes ?? defaultsRef.current.hermes,
pi: overrides?.pi ?? defaultsRef.current.pi,
});
},
[],
+5
View File
@@ -115,6 +115,11 @@ export function useImportExport(
"[useImportExport] Failed to sync live config",
syncResult.error,
);
setErrorMessage(
syncResult.error instanceof Error
? syncResult.error.message
: String(syncResult.error ?? ""),
);
setStatus("partial-success");
toast.warning(
t("settings.importPartialSuccess", {
+2 -10
View File
@@ -12,6 +12,7 @@ import {
useProxyTakeoverStatus,
} from "@/lib/query/proxy";
import { extractErrorMessage } from "@/utils/errorUtils";
import { getAppLabel } from "@/config/appConfig";
/**
* 代理服务状态管理
@@ -114,16 +115,7 @@ export function useProxyStatus() {
mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>
proxyApi.setProxyTakeoverForApp(appType, enabled),
onSuccess: (_data, variables) => {
const appLabel =
variables.appType === "claude"
? "Claude"
: variables.appType === "codex"
? "Codex"
: variables.appType === "gemini"
? "Gemini"
: variables.appType === "grokbuild"
? "Grok Build"
: "OpenCode";
const appLabel = getAppLabel(variables.appType);
toast.success(
variables.enabled
+17 -2
View File
@@ -114,6 +114,7 @@ export function useSettings(): UseSettingsResult {
opencode: sanitizeDir(data?.opencodeConfigDir),
openclaw: sanitizeDir(data?.openclawConfigDir),
hermes: sanitizeDir(data?.hermesConfigDir),
pi: sanitizeDir(data?.piConfigDir),
});
setRequiresRestart(false);
}, [
@@ -195,6 +196,8 @@ export function useSettings(): UseSettingsResult {
const sanitizedOpenclawDir = sanitizeDir(
mergedSettings.openclawConfigDir,
);
const sanitizedHermesDir = sanitizeDir(mergedSettings.hermesConfigDir);
const sanitizedPiDir = sanitizeDir(mergedSettings.piConfigDir);
const {
webdavSync: _ignoredWebdavSync,
s3Sync: _ignoredS3Sync,
@@ -209,6 +212,8 @@ export function useSettings(): UseSettingsResult {
grokConfigDir: sanitizedGrokDir,
opencodeConfigDir: sanitizedOpencodeDir,
openclawConfigDir: sanitizedOpenclawDir,
hermesConfigDir: sanitizedHermesDir,
piConfigDir: sanitizedPiDir,
language: mergedSettings.language,
};
@@ -328,6 +333,8 @@ export function useSettings(): UseSettingsResult {
const sanitizedOpenclawDir = sanitizeDir(
mergedSettings.openclawConfigDir,
);
const sanitizedHermesDir = sanitizeDir(mergedSettings.hermesConfigDir);
const sanitizedPiDir = sanitizeDir(mergedSettings.piConfigDir);
const previousAppDir = initialAppConfigDir;
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
@@ -335,6 +342,8 @@ export function useSettings(): UseSettingsResult {
const previousGrokDir = sanitizeDir(data?.grokConfigDir);
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
const previousOpenclawDir = sanitizeDir(data?.openclawConfigDir);
const previousHermesDir = sanitizeDir(data?.hermesConfigDir);
const previousPiDir = sanitizeDir(data?.piConfigDir);
const {
webdavSync: _ignoredWebdavSync,
s3Sync: _ignoredS3Sync,
@@ -349,6 +358,8 @@ export function useSettings(): UseSettingsResult {
grokConfigDir: sanitizedGrokDir,
opencodeConfigDir: sanitizedOpencodeDir,
openclawConfigDir: sanitizedOpenclawDir,
hermesConfigDir: sanitizedHermesDir,
piConfigDir: sanitizedPiDir,
language: mergedSettings.language,
};
@@ -428,7 +439,7 @@ export function useSettings(): UseSettingsResult {
console.warn("[useSettings] Failed to refresh tray menu", error);
}
// 如果 Claude/Codex/Gemini/OpenCode/OpenClaw 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置
// 任一 app 的目录覆盖发生变化后,立即把当前状态投影到新的 live 目录。
// 如果插件同步已经执行过 syncCurrentProvidersLiveSafe,则跳过避免重复
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
@@ -436,6 +447,8 @@ export function useSettings(): UseSettingsResult {
const grokDirChanged = sanitizedGrokDir !== previousGrokDir;
const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir;
const openclawDirChanged = sanitizedOpenclawDir !== previousOpenclawDir;
const hermesDirChanged = sanitizedHermesDir !== previousHermesDir;
const piDirChanged = sanitizedPiDir !== previousPiDir;
if (
!pluginSynced &&
(claudeDirChanged ||
@@ -443,7 +456,9 @@ export function useSettings(): UseSettingsResult {
geminiDirChanged ||
grokDirChanged ||
opencodeDirChanged ||
openclawDirChanged)
openclawDirChanged ||
hermesDirChanged ||
piDirChanged)
) {
const syncResult = await syncCurrentProvidersLiveSafe();
if (!syncResult.ok) {
+4
View File
@@ -125,6 +125,8 @@ export function useSettingsForm(): UseSettingsFormResult {
grokConfigDir: sanitizeDir(data.grokConfigDir),
opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),
openclawConfigDir: sanitizeDir(data.openclawConfigDir),
hermesConfigDir: sanitizeDir(data.hermesConfigDir),
piConfigDir: sanitizeDir(data.piConfigDir),
language: normalizedLanguage,
};
@@ -192,6 +194,8 @@ export function useSettingsForm(): UseSettingsFormResult {
grokConfigDir: sanitizeDir(serverData.grokConfigDir),
opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),
openclawConfigDir: sanitizeDir(serverData.openclawConfigDir),
hermesConfigDir: sanitizeDir(serverData.hermesConfigDir),
piConfigDir: sanitizeDir(serverData.piConfigDir),
language: normalizedLanguage,
};
+30
View File
@@ -10,12 +10,23 @@ import {
type DiscoverableSkill,
type ImportSkillSelection,
type InstalledSkill,
type PiSkillStatus,
type SkillUpdateInfo,
type SkillsShSearchResult,
} from "@/lib/api/skills";
import type { AppId } from "@/lib/api/types";
import { mergeImportedSkills } from "@/hooks/useSkills.helpers";
const PI_SKILL_STATUSES_QUERY_KEY = ["skills", "pi-statuses"] as const;
function invalidatePiSkillStatuses(
queryClient: ReturnType<typeof useQueryClient>,
) {
void queryClient.invalidateQueries({
queryKey: PI_SKILL_STATUSES_QUERY_KEY,
});
}
/**
* 查询所有已安装的 Skills
* 使用 staleTime: Infinity 和 placeholderData: keepPreviousData
@@ -30,6 +41,17 @@ export function useInstalledSkills() {
});
}
/**
* Pi 的实际 discovery 状态来自文件系统 inspection,不从 apps.pi 推导。
*/
export function usePiSkillStatuses() {
return useQuery({
queryKey: PI_SKILL_STATUSES_QUERY_KEY,
queryFn: () => skillsApi.getPiStatuses(),
staleTime: 10 * 1000,
});
}
export function useSkillBackups() {
return useQuery({
queryKey: ["skills", "backups"],
@@ -105,6 +127,7 @@ export function useInstallSkill() {
});
},
);
invalidatePiSkillStatuses(queryClient);
},
});
}
@@ -143,6 +166,7 @@ export function useUninstallSkill() {
});
},
);
invalidatePiSkillStatuses(queryClient);
},
});
}
@@ -160,6 +184,7 @@ export function useRestoreSkillBackup() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "backups"] });
invalidatePiSkillStatuses(queryClient);
},
});
}
@@ -181,6 +206,7 @@ export function useToggleSkillApp() {
}) => skillsApi.toggleApp(id, app, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
invalidatePiSkillStatuses(queryClient);
},
});
}
@@ -220,6 +246,7 @@ export function useImportSkillsFromApps() {
);
// 刷新 unmanaged 列表(已被导入的应该移除)
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
invalidatePiSkillStatuses(queryClient);
},
});
}
@@ -286,6 +313,7 @@ export function useInstallSkillsFromZip() {
return [...oldData, ...installedSkills];
},
);
invalidatePiSkillStatuses(queryClient);
},
});
}
@@ -328,6 +356,7 @@ export function useUpdateSkill() {
return oldData.filter((u) => u.id !== updatedSkill.id);
},
);
invalidatePiSkillStatuses(queryClient);
},
});
}
@@ -356,6 +385,7 @@ export function useSearchSkillsSh(
export type {
InstalledSkill,
PiSkillStatus,
DiscoverableSkill,
ImportSkillSelection,
SkillBackupEntry,
+140 -4
View File
@@ -431,6 +431,7 @@
"themeSystem": "System",
"importExport": "SQL Import/Export",
"importExportHint": "Import or export database SQL backups for migration or restore (import supports only backups exported by CC Switch).",
"piImportExportBoundary": "Pi providers and managed settings are portable. Native models.json ownership is rebuilt only for an empty matching key; native instruction files, prompt files, skill deployments, and sessions remain device-local and are never overwritten by import.",
"exportConfig": "Export SQL Backup",
"selectConfigFile": "Select SQL File",
"noFileSelected": "No configuration file selected.",
@@ -767,6 +768,8 @@
"openclawConfigDirDescription": "Override OpenClaw configuration directory (openclaw.json).",
"hermesConfigDir": "Hermes Configuration Directory",
"hermesConfigDirDescription": "Override Hermes configuration directory (config.yaml).",
"piConfigDir": "Pi Configuration Directory",
"piConfigDirDescription": "Override the Pi configuration directory (models.json, instruction files, prompts, skills, and sessions).",
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
"browsePlaceholderGemini": "e.g., /home/<your-username>/.gemini",
@@ -774,6 +777,7 @@
"browsePlaceholderOpencode": "e.g., /home/<your-username>/.config/opencode",
"browsePlaceholderOpenclaw": "e.g., /home/<your-username>/.openclaw",
"browsePlaceholderHermes": "e.g., /home/<your-username>/.hermes",
"browsePlaceholderPi": "e.g., /home/<your-username>/.pi/agent",
"browseDirectory": "Browse Directory",
"resetDefault": "Reset to default directory (takes effect after saving)",
"checkForUpdates": "Check for Updates",
@@ -794,7 +798,7 @@
"officialWebsite": "Official Website",
"github": "GitHub",
"manualInstallCommands": "Manual Install Commands",
"oneClickInstallHint": "Install or upgrade Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes",
"oneClickInstallHint": "Install or upgrade Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes / Pi",
"localEnvCheck": "Local environment check",
"updateAllTools": "Update All ({{count}})",
"currentVersion": "Current Version",
@@ -884,7 +888,113 @@
"grokbuild": "Grok Build",
"opencode": "OpenCode",
"openclaw": "OpenClaw",
"hermes": "Hermes"
"hermes": "Hermes",
"pi": "Pi"
},
"pi": {
"form": {
"providerKey": "Provider key",
"providerKeyHint": "Stable key used by Pi in models.json. It cannot be changed after creation.",
"providerKeyRequired": "Provider key is required",
"displayName": "Display name",
"nameRequired": "Display name is required",
"providerApi": "Provider API",
"providerBaseUrl": "Provider base URL",
"manageEndpoints": "Manage failover endpoints",
"credential": "Credential",
"credentialHint": "Pi accepts a literal, $ENV reference, or !command expression. Deferred values are validated only after Pi resolves them.",
"website": "Website",
"authHeader": "Send credential as Authorization",
"authHeaderHint": "Use Pi's authHeader behavior for API families that support it. Anthropic OAuth credentials are handled by the gateway's verified OAuth transport.",
"headers": "Custom headers (JSON)",
"headersStringValues": "Every custom header value must be a string",
"jsonObjectRequired": "{{label}} must be a JSON object",
"nonFiniteNumber": "{{label}} contains a non-finite number",
"absoluteHttpUrlRequired": "{{label}} must be an absolute HTTP or HTTPS URL",
"models": "Models",
"modelsHint": "Each model may inherit API and base URL from the provider. The full model object is preserved in models.json.",
"addModel": "Add model",
"modelNumber": "Model {{index}}",
"removeModel": "Remove model",
"modelId": "Model ID",
"modelIdRequired": "Model {{index}} needs an ID",
"modelName": "Display name",
"modelApi": "Model API",
"modelBaseUrl": "Model base URL",
"modelBaseUrlFor": "Model {{id}} base URL",
"inherit": "Inherit from provider",
"inheritanceHint": "Models without an override inherit this provider value.",
"effectiveApiRequired": "Model {{id}} needs an API, either directly or from the provider",
"effectiveBaseUrlRequired": "Model {{id}} needs a base URL, either directly or from the provider",
"duplicateModel": "Model ID {{id}} is duplicated",
"modelRequired": "Add at least one model",
"modelAdditionalConfig": "Additional model configuration {{id}} (JSON)",
"additionalConfig": "Additional provider configuration (JSON)",
"additionalConfigHint": "Unknown Pi fields are preserved. Controlled fields shown above take precedence."
},
"native": {
"title": "Pi native model catalog",
"description": "Inspect the real Pi models.json through the certified classifier. Import only explicitly, and keep Pi's native file authoritative.",
"empty": "Pi models.json does not contain any providers.",
"defaultProvider": "Default provider",
"defaultModel": "Default model",
"setDefault": "Set default",
"currentDefault": "Current default",
"defaultSaved": "Pi default model updated",
"defaultSaveFailed": "Failed to update the Pi default model",
"unmanagedDefault": "Pi currently selects unmanaged provider {{provider}} / {{model}}. Import it or choose a managed default.",
"imported": "Pi provider imported",
"importFailed": "Failed to import the Pi provider",
"managed": "Managed",
"management": {
"importable": "Importable",
"managed": "Managed",
"unsupported": "Unsupported"
},
"gateway": {
"proxyable": "Gateway ready",
"direct_only": "Direct only",
"unknown": "Unknown"
}
},
"prompts": {
"nativeTitle": "Pi native prompt resources",
"nativeDescription": "File presence is activation. SYSTEM.md replaces Pi's system prompt, APPEND_SYSTEM.md appends to it, and /template-name expands files from the prompts directory.",
"agentsLibrary": "AGENTS.md library",
"agentsLibraryDescription": "The enabled library entry is projected to AGENTS.md. External edits are detected and must be imported before switching.",
"systemOverride": "System override",
"systemOverrideDescription": "SYSTEM.md completely replaces Pi's built-in system prompt while the file exists.",
"systemAppend": "System append",
"systemAppendDescription": "APPEND_SYSTEM.md is appended to Pi's system prompt while the file exists.",
"active": "Active",
"inactive": "Inactive",
"activateOverride": "Activate override",
"activateOverrideTitle": "Activate {{filename}}?",
"activateOverrideMessage": "Creating {{filename}} completely replaces Pi's built-in system prompt. Confirm that this is intentional.",
"deactivate": "Deactivate",
"deactivateTitle": "Deactivate {{filename}}?",
"deactivateMessage": "This deletes {{filename}}. Pi will stop applying it immediately.",
"instructionPlaceholder": "Markdown content used by Pi",
"blankInstruction": "Enter non-blank content, or deactivate the file to remove it.",
"loadFirst": "Load the current Pi file before changing it",
"fileSaved": "{{filename}} saved",
"fileDeactivated": "{{filename}} deactivated",
"saveFailed": "Failed to save the Pi instruction file",
"deleteFailed": "Failed to deactivate the Pi instruction file",
"templates": "Prompt templates",
"templatesDescription": "Each prompts/<slug>.md file is invoked by Pi as /<slug>.",
"newTemplate": "New prompt template",
"templateSlug": "Template slug (for example: review)",
"templateContent": "Template Markdown",
"createTemplate": "Create template",
"templateCreated": "Pi prompt template created",
"templateSaved": "/{{slug}} saved",
"templateDeleted": "/{{slug}} deleted",
"templateSaveFailed": "Failed to save the Pi prompt template",
"templateDeleteFailed": "Failed to delete the Pi prompt template",
"deleteTemplateTitle": "Delete /{{slug}}?",
"deleteTemplateMessage": "This permanently deletes prompts/{{slug}}.md."
}
},
"grokBuild": {
"apiBackend": "API backend",
@@ -896,7 +1006,7 @@
},
"sessionManager": {
"title": "Session Manager",
"subtitle": "Manage Claude Code, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw and Hermes sessions",
"subtitle": "Manage Claude Code, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes, and Pi sessions",
"searchPlaceholder": "Search by content, directory, or ID",
"searchSessions": "Search sessions",
"providerFilterAll": "All",
@@ -923,6 +1033,8 @@
"batchDeleting": "Deleting...",
"loadingSessions": "Loading sessions...",
"noSessions": "No sessions found",
"piRelativeSessionDir": "Pi sessionDir is relative to the directory where Pi is launched, so a global browser cannot enumerate it safely. Set an absolute sessionDir to browse all Pi sessions here:",
"piDiscoveryUnavailable": "Pi sessions could not be inspected: {{error}}",
"selectSession": "Select a session to view details",
"noSummary": "No summary available",
"lastActive": "Last active",
@@ -1503,7 +1615,8 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
"grokbuild": "Grok Build"
"grokbuild": "Grok Build",
"pi": "Pi"
},
"rawInputLabel": "Raw",
"rebuildCodex": {
@@ -2377,6 +2490,18 @@
"importSelected": "Import Selected ({{count}})",
"noUnmanagedFound": "No skills to import found. All skills are already managed by CC Switch.",
"unmanagedAvailable": "Skills available to import",
"piStatus": {
"active": "Active",
"inactive": "Inactive",
"unmanagedActive": "Active, not managed",
"desiredButMissing": "Enabled, not discovered",
"foreignConflict": "Blocked by an unmanaged destination",
"staleDeployment": "Managed deployment changed externally",
"shadowed": "Shadowed by another Skill",
"invalid": "Invalid Skill manifest",
"inspecting": "Inspecting...",
"inspectionUnavailable": "Inspection unavailable"
},
"foundIn": "Found in",
"local": "Local",
"uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\"? This will remove the skill from all apps and create a local backup first.",
@@ -2440,6 +2565,7 @@
"homepage": "Homepage",
"endpoint": "API Endpoint",
"apiKey": "API Key",
"api": "Native API protocol",
"icon": "Icon",
"model": "Model",
"haikuModel": "Haiku Model",
@@ -2553,6 +2679,16 @@
}
},
"proxy": {
"piGateway": {
"title": "Pi gateway credential",
"description": "Rotate the device-local credential used only between Pi and this local gateway.",
"rotate": "Rotate credential",
"confirmTitle": "Rotate Pi gateway credential?",
"confirmMessage": "The current local credential will stop working immediately. Running Pi processes keep their projected credential in memory and must be restarted.",
"rotateSuccess": "Pi gateway credential rotated",
"rotateFailed": "Failed to rotate Pi gateway credential",
"restartNotice": "Restart every running Pi process before sending another request."
},
"panel": {
"serviceAddress": "Service Address",
"addressCopied": "Address copied",
+140 -4
View File
@@ -431,6 +431,7 @@
"themeSystem": "跟随系统",
"importExport": "SQL 导入导出",
"importExportHint": "导入/导出数据库 SQL 备份(仅支持导入由 CC Switch 导出的备份),便于备份或迁移。",
"piImportExportBoundary": "Pi 供应商与托管设置可迁移;仅当同名原生 key 为空时才重建 models.json 所有权。原生指令文件、prompt 文件、Skill 部署和会话均保留在本机,导入不会覆盖。",
"exportConfig": "导出 SQL 备份",
"selectConfigFile": "选择 SQL 文件",
"noFileSelected": "尚未选择配置文件。",
@@ -767,6 +768,8 @@
"openclawConfigDirDescription": "覆盖 OpenClaw 配置目录 (openclaw.json)。",
"hermesConfigDir": "Hermes 配置目录",
"hermesConfigDirDescription": "覆盖 Hermes 配置目录 (config.yaml)。",
"piConfigDir": "Pi 配置目录",
"piConfigDirDescription": "覆盖 Pi 配置目录(models.json、指令文件、提示词、Skills 与会话)。",
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
"browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini",
@@ -774,6 +777,7 @@
"browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode",
"browsePlaceholderOpenclaw": "例如:/home/<你的用户名>/.openclaw",
"browsePlaceholderHermes": "例如:/home/<你的用户名>/.hermes",
"browsePlaceholderPi": "例如:/home/<你的用户名>/.pi/agent",
"browseDirectory": "浏览目录",
"resetDefault": "恢复默认目录(需保存后生效)",
"checkForUpdates": "检查更新",
@@ -794,7 +798,7 @@
"officialWebsite": "官方网站",
"github": "GitHub",
"manualInstallCommands": "手动安装命令",
"oneClickInstallHint": "安装或升级 Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes",
"oneClickInstallHint": "安装或升级 Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes / Pi",
"localEnvCheck": "本地环境检查",
"updateAllTools": "全部升级({{count}}",
"currentVersion": "当前版本",
@@ -884,7 +888,113 @@
"grokbuild": "Grok Build",
"opencode": "OpenCode",
"openclaw": "OpenClaw",
"hermes": "Hermes"
"hermes": "Hermes",
"pi": "Pi"
},
"pi": {
"form": {
"providerKey": "供应商标识",
"providerKeyHint": "Pi 在 models.json 中使用的稳定标识,创建后不可修改。",
"providerKeyRequired": "供应商标识不能为空",
"displayName": "显示名称",
"nameRequired": "显示名称不能为空",
"providerApi": "供应商 API",
"providerBaseUrl": "供应商基础地址",
"manageEndpoints": "管理故障转移端点",
"credential": "凭证",
"credentialHint": "Pi 支持字面量、$ENV 引用或 !command 表达式;延迟值仅在 Pi 解析后校验。",
"website": "网站",
"authHeader": "通过 Authorization 发送凭证",
"authHeaderHint": "对支持的 API 族使用 Pi 的 authHeader 行为。Anthropic OAuth 凭证由网关按实测规则传输。",
"headers": "自定义请求头(JSON",
"headersStringValues": "所有自定义请求头的值必须是字符串",
"jsonObjectRequired": "{{label}} 必须是 JSON 对象",
"nonFiniteNumber": "{{label}} 包含非有限数值",
"absoluteHttpUrlRequired": "{{label}} 必须是绝对 HTTP 或 HTTPS 地址",
"models": "模型",
"modelsHint": "每个模型可继承供应商的 API 与基础地址;完整模型对象会保留在 models.json 中。",
"addModel": "添加模型",
"modelNumber": "模型 {{index}}",
"removeModel": "移除模型",
"modelId": "模型 ID",
"modelIdRequired": "第 {{index}} 个模型缺少 ID",
"modelName": "显示名称",
"modelApi": "模型 API",
"modelBaseUrl": "模型基础地址",
"modelBaseUrlFor": "模型 {{id}} 的基础地址",
"inherit": "继承供应商配置",
"inheritanceHint": "未单独覆盖的模型将继承此供应商配置。",
"effectiveApiRequired": "模型 {{id}} 必须直接配置或继承一个 API",
"effectiveBaseUrlRequired": "模型 {{id}} 必须直接配置或继承一个基础地址",
"duplicateModel": "模型 ID {{id}} 重复",
"modelRequired": "请至少添加一个模型",
"modelAdditionalConfig": "模型 {{id}} 的附加配置(JSON",
"additionalConfig": "供应商附加配置(JSON",
"additionalConfigHint": "未识别的 Pi 字段会原样保留;上方受控字段优先。"
},
"native": {
"title": "Pi 原生模型目录",
"description": "通过已认证的分类器检查真实 Pi models.json。仅在明确操作时导入,并始终以 Pi 原生文件为准。",
"empty": "Pi models.json 中没有供应商。",
"defaultProvider": "默认供应商",
"defaultModel": "默认模型",
"setDefault": "设为默认",
"currentDefault": "当前默认",
"defaultSaved": "Pi 默认模型已更新",
"defaultSaveFailed": "更新 Pi 默认模型失败",
"unmanagedDefault": "Pi 当前选择的是未托管供应商 {{provider}} / {{model}}。请导入它或选择一个已托管默认项。",
"imported": "Pi 供应商已导入",
"importFailed": "导入 Pi 供应商失败",
"managed": "已托管",
"management": {
"importable": "可导入",
"managed": "已托管",
"unsupported": "不支持"
},
"gateway": {
"proxyable": "网关就绪",
"direct_only": "仅直连",
"unknown": "未知"
}
},
"prompts": {
"nativeTitle": "Pi 原生提示资源",
"nativeDescription": "文件存在即启用。SYSTEM.md 替换 Pi 系统提示,APPEND_SYSTEM.md 追加系统提示,/模板名 会展开 prompts 目录中的文件。",
"agentsLibrary": "AGENTS.md 提示库",
"agentsLibraryDescription": "已启用的提示库条目会投影到 AGENTS.md;检测到外部修改时,必须先导入才能切换。",
"systemOverride": "系统提示替换",
"systemOverrideDescription": "SYSTEM.md 存在期间会完整替换 Pi 内置系统提示。",
"systemAppend": "系统提示追加",
"systemAppendDescription": "APPEND_SYSTEM.md 存在期间会追加到 Pi 系统提示。",
"active": "已启用",
"inactive": "未启用",
"activateOverride": "启用替换",
"activateOverrideTitle": "启用 {{filename}}",
"activateOverrideMessage": "创建 {{filename}} 会完整替换 Pi 内置系统提示。请确认这是你的明确意图。",
"deactivate": "停用",
"deactivateTitle": "停用 {{filename}}",
"deactivateMessage": "此操作会删除 {{filename}}Pi 会立即停止应用它。",
"instructionPlaceholder": "供 Pi 使用的 Markdown 内容",
"blankInstruction": "请输入非空白内容;如需停用,请删除该文件。",
"loadFirst": "请先读取当前 Pi 文件再修改",
"fileSaved": "{{filename}} 已保存",
"fileDeactivated": "{{filename}} 已停用",
"saveFailed": "保存 Pi 指令文件失败",
"deleteFailed": "停用 Pi 指令文件失败",
"templates": "提示词模板",
"templatesDescription": "每个 prompts/<标识>.md 文件都可在 Pi 中通过 /<标识> 调用。",
"newTemplate": "新建提示词模板",
"templateSlug": "模板标识(例如 review",
"templateContent": "模板 Markdown 内容",
"createTemplate": "创建模板",
"templateCreated": "Pi 提示词模板已创建",
"templateSaved": "/{{slug}} 已保存",
"templateDeleted": "/{{slug}} 已删除",
"templateSaveFailed": "保存 Pi 提示词模板失败",
"templateDeleteFailed": "删除 Pi 提示词模板失败",
"deleteTemplateTitle": "删除 /{{slug}}",
"deleteTemplateMessage": "此操作会永久删除 prompts/{{slug}}.md。"
}
},
"grokBuild": {
"apiBackend": "API Backend",
@@ -896,7 +1006,7 @@
},
"sessionManager": {
"title": "会话管理",
"subtitle": "管理 Claude Code、Codex、Gemini CLI、Grok Build、OpenCode、OpenClawHermes 会话记录",
"subtitle": "管理 Claude Code、Codex、Gemini CLI、Grok Build、OpenCode、OpenClawHermes 与 Pi 会话记录",
"searchPlaceholder": "搜索会话内容、目录或 ID",
"searchSessions": "搜索会话",
"providerFilterAll": "全部",
@@ -923,6 +1033,8 @@
"batchDeleting": "删除中...",
"loadingSessions": "加载会话中...",
"noSessions": "未发现会话",
"piRelativeSessionDir": "Pi 的 sessionDir 相对于启动 Pi 时的目录,全局浏览器无法安全枚举。请改用绝对 sessionDir,之后即可在此浏览全部 Pi 会话:",
"piDiscoveryUnavailable": "无法检查 Pi 会话:{{error}}",
"selectSession": "请选择会话查看详情",
"noSummary": "暂无摘要",
"lastActive": "最近活跃",
@@ -1503,7 +1615,8 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
"grokbuild": "Grok Build"
"grokbuild": "Grok Build",
"pi": "Pi"
},
"rawInputLabel": "原始",
"rebuildCodex": {
@@ -2377,6 +2490,18 @@
"importSelected": "导入已选 ({{count}})",
"noUnmanagedFound": "未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。",
"unmanagedAvailable": "发现可导入的技能",
"piStatus": {
"active": "已生效",
"inactive": "未启用",
"unmanagedActive": "已生效但未受管",
"desiredButMissing": "已启用但 Pi 未发现",
"foreignConflict": "被未受管目录占用",
"staleDeployment": "受管部署已被外部修改",
"shadowed": "被同名 Skill 遮蔽",
"invalid": "Skill 清单无效",
"inspecting": "检查中...",
"inspectionUnavailable": "无法检查实际状态"
},
"foundIn": "发现于",
"local": "本地",
"uninstallConfirm": "确定要卸载技能 \"{{name}}\" 吗?这将从所有应用中移除该技能,并在删除前自动创建本地备份。",
@@ -2440,6 +2565,7 @@
"homepage": "官网地址",
"endpoint": "API 端点",
"apiKey": "API 密钥",
"api": "原生 API 协议",
"icon": "图标",
"model": "模型",
"haikuModel": "Haiku 模型",
@@ -2553,6 +2679,16 @@
}
},
"proxy": {
"piGateway": {
"title": "Pi 网关凭证",
"description": "轮换仅用于 Pi 与本机网关之间通信的设备级凭证。",
"rotate": "轮换凭证",
"confirmTitle": "轮换 Pi 网关凭证?",
"confirmMessage": "当前本机凭证会立即失效。正在运行的 Pi 进程仍在内存中保留旧投影凭证,必须重启。",
"rotateSuccess": "Pi 网关凭证已轮换",
"rotateFailed": "Pi 网关凭证轮换失败",
"restartNotice": "再次发起请求前,请重启所有正在运行的 Pi 进程。"
},
"panel": {
"serviceAddress": "服务地址",
"addressCopied": "地址已复制",
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -14,7 +14,8 @@ export interface DeepLinkImportRequest {
| "grokbuild"
| "opencode"
| "openclaw"
| "hermes";
| "hermes"
| "pi";
name?: string;
enabled?: boolean;
@@ -24,6 +25,7 @@ export interface DeepLinkImportRequest {
apiKey?: string;
icon?: string;
model?: string;
api?: string;
notes?: string;
haikuModel?: string;
sonnetModel?: string;
+6
View File
@@ -1,4 +1,10 @@
export type { AppId } from "./types";
export { piApi } from "./pi";
export type {
PiNativeDiagnostic,
PiNativeDefaults,
PiManagementStatus,
} from "./pi";
export { providersApi, universalProvidersApi } from "./providers";
export { settingsApi } from "./settings";
export { backupsApi } from "./settings";
+84
View File
@@ -0,0 +1,84 @@
import { invoke } from "@tauri-apps/api/core";
export type PiRawValidity = "valid" | "invalid" | "unknown";
export type PiManagedAssessment = "manageable" | "unsupported";
export type PiCompositionStatus = "composed" | "failed" | "unknown";
export type PiGatewayStatus = "proxyable" | "direct_only" | "unknown";
export type PiManagementStatus =
| { status: "importable" }
| { status: "managed"; providerId: string }
| { status: "unsupported" };
export interface PiDiagnosticReason {
layer: "raw_schema" | "managed" | "composition" | "gateway";
code: string;
jsonPointer?: string;
}
export interface PiNativeDiagnostic {
providerKey: string;
displayName?: string;
fingerprint: string;
kind: string;
rawValidity: PiRawValidity;
managedAssessment: PiManagedAssessment;
compositionStatus: PiCompositionStatus;
managementStatus: PiManagementStatus;
gatewayStatus: PiGatewayStatus;
reasons: PiDiagnosticReason[];
}
export interface PiNativeDefaults {
defaultProvider?: string;
defaultModel?: string;
sessionDir?: string;
}
export type PiSessionDiscovery =
| {
status: "available";
root: string;
source: "environment" | "settings" | "default";
}
| {
status: "requires_project_context";
configuredPath: string;
source: "environment" | "settings";
}
| {
status: "unavailable";
reason: string;
};
export const piApi = {
async getNativeCatalog(): Promise<PiNativeDiagnostic[]> {
return await invoke("get_pi_native_catalog");
},
async importNativeProvider(
providerKey: string,
expectedFingerprint: string,
): Promise<string> {
return await invoke("import_pi_native_provider", {
providerKey,
expectedFingerprint,
});
},
async getNativeDefaults(): Promise<PiNativeDefaults> {
return await invoke("get_pi_native_defaults");
},
async getSessionDiscovery(): Promise<PiSessionDiscovery> {
return await invoke("get_pi_session_discovery");
},
async setDefaultModel(providerId: string, modelId: string): Promise<boolean> {
return await invoke("set_pi_default_model", { providerId, modelId });
},
async resetGatewayCredential(): Promise<boolean> {
return await invoke("reset_pi_gateway_credential");
},
};
+68
View File
@@ -11,6 +11,25 @@ export interface Prompt {
updatedAt?: number;
}
export type PiPromptFileKind =
| "global_context"
| "system_override"
| "system_append";
export interface PiPromptFileSnapshot {
kind: PiPromptFileKind;
path: string;
exists: boolean;
revision: string;
content: string;
}
export interface PiPromptTemplate {
slug: string;
content: string;
revision: string;
}
export const promptsApi = {
async getPrompts(app: AppId): Promise<Record<string, Prompt>> {
return await invoke("get_prompts", { app });
@@ -35,4 +54,53 @@ export const promptsApi = {
async getCurrentFileContent(app: AppId): Promise<string | null> {
return await invoke("get_current_prompt_file_content", { app });
},
async getPiPromptFile(kind: PiPromptFileKind): Promise<PiPromptFileSnapshot> {
return await invoke("get_pi_prompt_file", { kind });
},
async replacePiPromptFile(
kind: PiPromptFileKind,
expectedRevision: string,
content: string,
): Promise<PiPromptFileSnapshot> {
return await invoke("replace_pi_prompt_file", {
kind,
expectedRevision,
content,
});
},
async deletePiPromptFile(
kind: PiPromptFileKind,
expectedRevision: string,
): Promise<boolean> {
return await invoke("delete_pi_prompt_file", { kind, expectedRevision });
},
async listPiPromptTemplates(): Promise<PiPromptTemplate[]> {
return await invoke("list_pi_prompt_templates");
},
async upsertPiPromptTemplate(
slug: string,
expectedRevision: string,
content: string,
): Promise<PiPromptTemplate> {
return await invoke("upsert_pi_prompt_template", {
slug,
expectedRevision,
content,
});
},
async deletePiPromptTemplate(
slug: string,
expectedRevision: string,
): Promise<boolean> {
return await invoke("delete_pi_prompt_template", {
slug,
expectedRevision,
});
},
};
+1
View File
@@ -12,6 +12,7 @@ export interface ConfigTransferResult {
message: string;
filePath?: string;
backupId?: string;
warning?: string;
}
export interface WebDavTestResult {
+26 -1
View File
@@ -10,7 +10,8 @@ export type AppType =
| "grokbuild"
| "opencode"
| "openclaw"
| "hermes";
| "hermes"
| "pi";
/** Skill 应用启用状态 */
export interface SkillApps {
@@ -22,6 +23,25 @@ export interface SkillApps {
opencode: boolean;
openclaw: boolean;
hermes: boolean;
pi: boolean;
}
export type PiSkillOwnership = "absent" | "owned" | "foreign" | "stale";
export type PiSkillDiscovery = "absent" | "active" | "shadowed" | "invalid";
/**
* Pi pinned discovery
*
* `effectivelyDiscovered` active `desiredEnabled`
* Pi
*/
export interface PiSkillStatus {
desiredEnabled: boolean;
ownedDeployment: boolean;
effectivelyDiscovered: boolean;
ownership: PiSkillOwnership;
discovery: PiSkillDiscovery;
issue?: string;
}
/** 已安装的 Skillv3.10.0+ 统一结构) */
@@ -143,6 +163,11 @@ export const skillsApi = {
return await invoke("get_installed_skills");
},
/** 获取 Pi Skill 的期望、所有权与实际 discovery 状态 */
async getPiStatuses(): Promise<Record<string, PiSkillStatus>> {
return await invoke("get_pi_skill_statuses");
},
/** 获取可恢复的 Skill 备份列表 */
async getBackups(): Promise<SkillBackupEntry[]> {
return await invoke("get_skill_backups");
+2 -1
View File
@@ -7,4 +7,5 @@ export type AppId =
| "grokbuild"
| "opencode"
| "openclaw"
| "hermes";
| "hermes"
| "pi";
+2 -8
View File
@@ -4,6 +4,7 @@ import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { extractErrorMessage } from "@/utils/errorUtils";
import { proxyKeys } from "@/lib/query/proxy";
import { getAppLabel } from "@/config/appConfig";
// ========== 熔断器 Hooks ==========
@@ -223,14 +224,7 @@ export function useSetAutoFailoverEnabled() {
},
onSuccess: (_data, variables) => {
const appLabel =
variables.appType === "claude"
? "Claude"
: variables.appType === "codex"
? "Codex"
: variables.appType === "grokbuild"
? "Grok Build"
: "Gemini";
const appLabel = getAppLabel(variables.appType);
toast.success(
variables.enabled
+30 -2
View File
@@ -1,4 +1,8 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
useMutation,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { providersApi, sessionsApi, settingsApi, type AppId } from "@/lib/api";
@@ -16,6 +20,13 @@ import {
GROKBUILD_OFFICIAL_PROVIDER_ID,
} from "@/utils/providerCapabilities";
const invalidatePiNativeCaches = async (queryClient: QueryClient) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["pi", "nativeCatalog"] }),
queryClient.invalidateQueries({ queryKey: ["pi", "nativeDefaults"] }),
]);
};
export const useAddProviderMutation = (appId: AppId) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
@@ -71,7 +82,12 @@ export const useAddProviderMutation = (appId: AppId) => {
let id: string;
if (appId === "opencode" || appId === "openclaw" || appId === "hermes") {
if (
appId === "opencode" ||
appId === "openclaw" ||
appId === "hermes" ||
appId === "pi"
) {
if (
providerInput.category === "omo" ||
providerInput.category === "omo-slim"
@@ -125,6 +141,9 @@ export const useAddProviderMutation = (appId: AppId) => {
if (appId === "hermes") {
await invalidateHermesProviderCaches(queryClient);
}
if (appId === "pi") {
await invalidatePiNativeCaches(queryClient);
}
try {
await providersApi.updateTrayMenu();
@@ -193,6 +212,9 @@ export const useUpdateProviderMutation = (appId: AppId) => {
if (appId === "hermes") {
await invalidateHermesProviderCaches(queryClient);
}
if (appId === "pi") {
await invalidatePiNativeCaches(queryClient);
}
toast.success(
t("notifications.updateSuccess", {
defaultValue: "供应商更新成功",
@@ -249,6 +271,9 @@ export const useDeleteProviderMutation = (appId: AppId) => {
if (appId === "hermes") {
await invalidateHermesProviderCaches(queryClient);
}
if (appId === "pi") {
await invalidatePiNativeCaches(queryClient);
}
try {
await providersApi.updateTrayMenu();
@@ -323,6 +348,9 @@ export const useSwitchProviderMutation = (appId: AppId) => {
if (appId === "hermes") {
await invalidateHermesProviderCaches(queryClient);
}
if (appId === "pi") {
await invalidatePiNativeCaches(queryClient);
}
try {
await providersApi.updateTrayMenu();
+23
View File
@@ -284,6 +284,7 @@ export interface VisibleApps {
opencode: boolean;
openclaw: boolean;
hermes: boolean;
pi: boolean;
}
// WebDAV 同步状态
@@ -403,6 +404,8 @@ export interface Settings {
openclawConfigDir?: string;
// 覆盖 Hermes 配置目录(可选)
hermesConfigDir?: string;
// 覆盖 Pi agent 配置目录(可选)
piConfigDir?: string;
// ===== 当前供应商 ID(设备级)=====
// 当前 Claude 供应商 ID(优先于数据库 is_current
@@ -413,6 +416,26 @@ export interface Settings {
currentProviderCodex?: string;
// 当前 Gemini 供应商 ID(优先于数据库 is_current
currentProviderGemini?: string;
currentProviderGrokbuild?: string;
currentProviderOpencode?: string;
currentProviderOpenclaw?: string;
currentProviderHermes?: string;
currentProviderPi?: string;
// Pi gateway projection is device-local and is reconciled on startup.
piTakeoverEnabled?: boolean;
piProxy?: {
autoFailoverEnabled: boolean;
maxRetries: number;
streamingFirstByteTimeout: number;
streamingIdleTimeout: number;
nonStreamingTimeout: number;
circuitFailureThreshold: number;
circuitSuccessThreshold: number;
circuitTimeoutSeconds: number;
circuitErrorRateThreshold: number;
circuitMinRequests: number;
};
// ===== Skill 同步设置 =====
// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
+1
View File
@@ -50,6 +50,7 @@ export interface ProxyTakeoverStatus {
opencode: boolean;
openclaw: boolean;
hermes: boolean;
pi: boolean;
}
export interface ProviderHealth {
+46 -1
View File
@@ -16,6 +16,8 @@ export interface RequestLog {
requestModel?: string;
/** 写入时实际用于计价的模型名;路由接管 + request 计价模式下可能与 model 不同 */
pricingModel?: string;
/** 0=legacy, 1=input includes cache buckets, 2=input is already fresh. */
inputTokenSemantics: 0 | 1 | 2;
costMultiplier: string;
inputTokens: number;
outputTokens: number;
@@ -188,7 +190,13 @@ export interface UsageRangeSelection {
* `opencode` / `openclaw` / `hermes` have no proxy handler at all they
* appear only as managed apps elsewhere.
*/
export type AppType = "claude" | "codex" | "gemini" | "grokbuild" | "opencode";
export type AppType =
| "claude"
| "codex"
| "gemini"
| "grokbuild"
| "opencode"
| "pi";
export type AppTypeFilter = "all" | AppType;
@@ -198,6 +206,7 @@ export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
"gemini",
"grokbuild",
"opencode",
"pi",
];
/**
@@ -218,11 +227,40 @@ export const CACHE_INCLUSIVE_APP_TYPES: ReadonlySet<string> = new Set([
"grokbuild",
]);
/**
* Apps whose wire protocol is selected per request rather than fixed by the
* app. A summary row cannot prove that every request reported cache creation,
* especially after detail rows have been rolled up, so the UI must present
* cache-write totals as partial rather than as an authoritative zero.
*/
export const CACHE_PROTOCOL_MIXED_APP_TYPES: ReadonlySet<string> = new Set([
"pi",
]);
export type CacheWriteAvailability = "ok" | "partial" | "na";
export function getCacheWriteAvailability(
appTypes: readonly string[],
): CacheWriteAvailability {
if (appTypes.length === 0) return "ok";
if (appTypes.some((appType) => CACHE_PROTOCOL_MIXED_APP_TYPES.has(appType))) {
return "partial";
}
const unavailable = appTypes.filter((appType) =>
CACHE_INCLUSIVE_APP_TYPES.has(appType),
).length;
if (unavailable === appTypes.length) return "na";
return unavailable === 0 ? "ok" : "partial";
}
/** Subset of request-log fields needed to derive cache-normalized input. */
export interface CacheNormalizableLog {
appType: string;
inputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
inputTokenSemantics: 0 | 1 | 2;
}
/**
@@ -231,6 +269,13 @@ export interface CacheNormalizableLog {
* cache, so they pass through unchanged.
*/
export function getFreshInputTokens(log: CacheNormalizableLog): number {
if (log.inputTokenSemantics === 2) return log.inputTokens;
if (log.inputTokenSemantics === 1) {
return Math.max(
0,
log.inputTokens - log.cacheReadTokens - log.cacheCreationTokens,
);
}
if (
CACHE_INCLUSIVE_APP_TYPES.has(log.appType) &&
log.inputTokens >= log.cacheReadTokens
@@ -112,4 +112,21 @@ describe("ImportExportSection Component", () => {
expect(screen.getByText("settings.importFailed")).toBeInTheDocument();
expect(screen.getByText("Parse failed")).toBeInTheDocument();
});
it("shows the Pi portability boundary and the concrete partial-sync failure", () => {
render(
<ImportExportSection
{...baseProps}
status="partial-success"
errorMessage="unclaimed native key 'occupied' already exists"
/>,
);
expect(
screen.getByText("settings.piImportExportBoundary"),
).toBeInTheDocument();
expect(
screen.getByText("unclaimed native key 'occupied' already exists"),
).toBeInTheDocument();
});
});
@@ -0,0 +1,144 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PiNativePromptResources } from "@/components/prompts/PiNativePromptResources";
import { promptsApi, type PiPromptFileKind } from "@/lib/api/prompts";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
const renderResources = () => {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={client}>
<PiNativePromptResources />
</QueryClientProvider>,
);
};
describe("PiNativePromptResources", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(promptsApi, "getPiPromptFile").mockImplementation(
async (kind: PiPromptFileKind) => ({
kind,
path:
kind === "system_override"
? "/agent/SYSTEM.md"
: "/agent/APPEND_SYSTEM.md",
exists: kind === "system_append",
revision: kind === "system_append" ? "append-revision" : "missing",
content: kind === "system_append" ? "append" : "",
}),
);
vi.spyOn(promptsApi, "listPiPromptTemplates").mockResolvedValue([
{
slug: "empty",
content: "",
revision: "empty-revision",
},
]);
vi.spyOn(promptsApi, "upsertPiPromptTemplate").mockResolvedValue({
slug: "new-empty",
content: "",
revision: "created-revision",
});
vi.spyOn(promptsApi, "replacePiPromptFile").mockImplementation(
async (kind, _revision, content) => ({
kind,
path: "/agent/SYSTEM.md",
exists: true,
revision: "saved-empty",
content,
}),
);
});
it("uses file presence as state, rejects blank direct saves, and permits empty templates", async () => {
renderResources();
await waitFor(() => expect(screen.getByText("/empty")).toBeInTheDocument());
expect(screen.getByText("pi.prompts.active")).toBeInTheDocument();
expect(screen.getByText("pi.prompts.inactive")).toBeInTheDocument();
const instructionEditors = screen.getAllByPlaceholderText(
"pi.prompts.instructionPlaceholder",
);
fireEvent.change(instructionEditors[1], { target: { value: "" } });
const saveButtons = screen.getAllByRole("button", { name: "common.save" });
expect(saveButtons[1]).toBeDisabled();
fireEvent.change(instructionEditors[1], {
target: { value: "new append" },
});
expect(saveButtons[1]).toBeEnabled();
fireEvent.click(saveButtons[1]);
await waitFor(() =>
expect(promptsApi.replacePiPromptFile).toHaveBeenCalledWith(
"system_append",
"append-revision",
"new append",
),
);
fireEvent.change(screen.getByPlaceholderText("pi.prompts.templateSlug"), {
target: { value: "new-empty" },
});
const create = screen.getByRole("button", {
name: "pi.prompts.createTemplate",
});
expect(create).toBeEnabled();
fireEvent.click(create);
await waitFor(() =>
expect(promptsApi.upsertPiPromptTemplate).toHaveBeenCalledWith(
"new-empty",
"missing",
"",
),
);
});
it("requires confirmation before creating the dangerous SYSTEM override", async () => {
renderResources();
const instructionEditors = await screen.findAllByPlaceholderText(
"pi.prompts.instructionPlaceholder",
);
fireEvent.change(instructionEditors[0], {
target: { value: "replace the system prompt" },
});
fireEvent.click(
screen.getAllByRole("button", { name: "common.save" })[0],
);
expect(promptsApi.replacePiPromptFile).not.toHaveBeenCalled();
expect(
screen.getByText("pi.prompts.activateOverrideTitle"),
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "pi.prompts.activateOverride" }),
);
await waitFor(() =>
expect(promptsApi.replacePiPromptFile).toHaveBeenCalledWith(
"system_override",
"missing",
"replace the system prompt",
),
);
});
});
+162
View File
@@ -0,0 +1,162 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PiProviderForm } from "@/components/providers/forms/PiProviderForm";
import composerOracle from "../fixtures/pi/native-oracle/composer-oracle-v1.json";
describe("PiProviderForm", () => {
it("submits only explicit model fields and leaves pinned defaults to Pi", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<PiProviderForm
appId="pi"
submitLabel="Save Pi provider"
onSubmit={onSubmit}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByPlaceholderText("my-provider"), {
target: { value: "verified-provider" },
});
fireEvent.change(screen.getByPlaceholderText("My Pi provider"), {
target: { value: "Verified provider" },
});
fireEvent.change(screen.getByPlaceholderText("openai-responses"), {
target: { value: "openai-responses" },
});
fireEvent.change(
screen.getByPlaceholderText("https://api.example.com/v1"),
{
target: { value: "https://api.example.com/v1" },
},
);
fireEvent.change(screen.getByPlaceholderText("model-id"), {
target: { value: "opaque-model" },
});
fireEvent.click(screen.getByRole("button", { name: "Save Pi provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const submitted = onSubmit.mock.calls[0][0];
expect(submitted.providerKey).toBe("verified-provider");
expect(JSON.parse(submitted.settingsConfig)).toEqual({
name: "Verified provider",
api: "openai-responses",
baseUrl: "https://api.example.com/v1",
models: [{ id: "opaque-model" }],
});
});
it("round-trips every field in the pinned all-fields composer vector", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const vector = composerOracle.cases.find(
(candidate) => candidate.id === "combined-all-fields-precedence",
);
expect(vector).toBeDefined();
const input = JSON.parse(JSON.stringify(vector?.input)) as Record<
string,
unknown
>;
render(
<PiProviderForm
appId="pi"
providerId="all-fields"
submitLabel="Save all fields"
onSubmit={onSubmit}
onCancel={() => {}}
initialData={{
name: String(input.name),
settingsConfig: input,
}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Save all fields" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(JSON.parse(onSubmit.mock.calls[0][0].settingsConfig)).toEqual(input);
});
it("preserves an explicitly false authHeader instead of erasing it", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const input = {
name: "Explicit false",
api: "openai-responses",
baseUrl: "https://api.example.com/v1",
authHeader: false,
models: [{ id: "model" }],
};
render(
<PiProviderForm
appId="pi"
providerId="explicit-false"
submitLabel="Save explicit false"
onSubmit={onSubmit}
onCancel={() => {}}
initialData={{ name: input.name, settingsConfig: input }}
/>,
);
fireEvent.click(
screen.getByRole("button", { name: "Save explicit false" }),
);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(JSON.parse(onSubmit.mock.calls[0][0].settingsConfig)).toEqual(input);
});
it("creates and submits typed failover endpoints from the real form entry", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<PiProviderForm
appId="pi"
submitLabel="Save endpoint provider"
onSubmit={onSubmit}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByPlaceholderText("my-provider"), {
target: { value: "endpoint-provider" },
});
fireEvent.change(screen.getByPlaceholderText("My Pi provider"), {
target: { value: "Endpoint provider" },
});
fireEvent.change(screen.getByPlaceholderText("openai-responses"), {
target: { value: "openai-responses" },
});
fireEvent.change(screen.getByPlaceholderText("model-id"), {
target: { value: "endpoint-model" },
});
fireEvent.click(
screen.getByRole("button", { name: "pi.form.manageEndpoints" }),
);
const endpointInput = await screen.findByPlaceholderText(
"endpointTest.addEndpointPlaceholder",
);
fireEvent.change(endpointInput, {
target: { value: "https://failover.example/v1" },
});
fireEvent.keyDown(endpointInput, { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
fireEvent.click(
screen.getByRole("button", { name: "Save endpoint provider" }),
);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(JSON.parse(onSubmit.mock.calls[0][0].settingsConfig)).toMatchObject({
baseUrl: "https://failover.example/v1",
models: [{ id: "endpoint-model" }],
});
expect(onSubmit.mock.calls[0][0].meta.custom_endpoints).toEqual({
"https://failover.example/v1": expect.objectContaining({
url: "https://failover.example/v1",
}),
});
});
});
@@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest";
import { FAILOVER_APPS } from "@/components/settings/ProxyTabContent";
describe("ProxyTabContent failover apps", () => {
it("exposes Grok Build alongside the existing failover applications", () => {
it("exposes Pi alongside the existing failover applications", () => {
expect(FAILOVER_APPS.map(({ id }) => id)).toEqual([
"claude",
"codex",
"gemini",
"grokbuild",
"pi",
]);
});
});
@@ -10,6 +10,7 @@ import {
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import { piApi } from "@/lib/api/pi";
import { sessionsApi } from "@/lib/api/sessions";
import type { SessionMessage, SessionMeta } from "@/types";
import { setSessionFixtures } from "../msw/state";
@@ -221,6 +222,23 @@ describe("SessionManagerPage", () => {
setSessionFixtures(sessions, messages);
});
it("surfaces a relative Pi sessionDir instead of presenting an empty scan as authoritative", async () => {
const discovery = vi
.spyOn(piApi, "getSessionDiscovery")
.mockResolvedValue({
status: "requires_project_context",
configuredPath: ".pi/sessions",
source: "settings",
});
renderPage("pi");
const notice = await screen.findByRole("status");
expect(notice).toHaveTextContent(".pi/sessions");
expect(discovery).toHaveBeenCalledTimes(1);
discovery.mockRestore();
});
it("deletes the selected session and selects the next visible session", async () => {
renderPage();
+73 -1
View File
@@ -13,6 +13,12 @@ const importSkillsMock = vi.fn();
const installFromZipMock = vi.fn();
const deleteSkillBackupMock = vi.fn();
const restoreSkillBackupMock = vi.fn();
const skillsHookState = vi.hoisted(() => ({
installed: [] as unknown[],
piStatuses: {} as Record<string, unknown>,
piStatusesLoading: false,
piStatusesError: false,
}));
vi.mock("sonner", () => ({
toast: {
@@ -24,9 +30,14 @@ vi.mock("sonner", () => ({
vi.mock("@/hooks/useSkills", () => ({
useInstalledSkills: () => ({
data: [],
data: skillsHookState.installed,
isLoading: false,
}),
usePiSkillStatuses: () => ({
data: skillsHookState.piStatuses,
isLoading: skillsHookState.piStatusesLoading,
isError: skillsHookState.piStatusesError,
}),
useSkillBackups: () => ({
data: [],
refetch: vi.fn(),
@@ -94,6 +105,10 @@ describe("UnifiedSkillsPanel", () => {
installFromZipMock.mockReset();
deleteSkillBackupMock.mockReset();
restoreSkillBackupMock.mockReset();
skillsHookState.installed = [];
skillsHookState.piStatuses = {};
skillsHookState.piStatusesLoading = false;
skillsHookState.piStatusesError = false;
});
it("opens the import dialog without crashing when app toggles render", async () => {
@@ -130,4 +145,61 @@ describe("UnifiedSkillsPanel", () => {
]);
});
});
it("renders Pi active state from inspection and toggles the desired state", async () => {
skillsHookState.installed = [
{
id: "skill-1",
name: "Pi Skill",
directory: "pi-skill",
apps: {
claude: false,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
hermes: false,
pi: true,
},
installedAt: 1,
updatedAt: 1,
},
];
skillsHookState.piStatuses = {
"skill-1": {
desiredEnabled: true,
ownedDeployment: false,
effectivelyDiscovered: false,
ownership: "foreign",
discovery: "absent",
issue: "collision",
},
};
toggleSkillAppMock.mockResolvedValue(true);
render(
<UnifiedSkillsPanel
onOpenDiscovery={() => {}}
currentApp="pi"
/>,
);
expect(
screen.getByText("Pi: skills.piStatus.foreignConflict"),
).toBeInTheDocument();
const piToggle = screen.getByRole("button", { name: "Pi" });
expect(piToggle).toHaveAttribute("aria-pressed", "false");
await act(async () => {
piToggle.click();
});
await waitFor(() => {
expect(toggleSkillAppMock).toHaveBeenCalledWith({
id: "skill-1",
app: "pi",
enabled: false,
});
});
});
});
@@ -72,6 +72,7 @@ describe("useDirectorySettings", () => {
if (app === "grokbuild") return "/remote/grok";
if (app === "opencode") return "/remote/opencode";
if (app === "openclaw") return "/remote/openclaw";
if (app === "pi") return "/remote/pi";
return "/remote/hermes";
});
selectConfigDirectoryMock.mockReset();
@@ -96,6 +97,7 @@ describe("useDirectorySettings", () => {
opencode: "/remote/opencode",
openclaw: "/remote/openclaw",
hermes: "/remote/hermes",
pi: "/remote/pi",
});
});
@@ -14,6 +14,7 @@ function makeSkill(overrides: Partial<InstalledSkill> = {}): InstalledSkill {
opencode: false,
openclaw: false,
hermes: false,
pi: false,
},
installedAt: 0,
updatedAt: 0,
+28 -1
View File
@@ -92,6 +92,8 @@ const createSettingsFormMock = (overrides: Record<string, unknown> = {}) => ({
geminiConfigDir: "/gemini",
opencodeConfigDir: "/opencode",
openclawConfigDir: "/openclaw",
hermesConfigDir: "/hermes",
piConfigDir: "/pi",
language: "zh",
},
isLoading: false,
@@ -113,6 +115,8 @@ const createDirectorySettingsMock = (
gemini: "/default/gemini",
opencode: "/default/opencode",
openclaw: "/default/openclaw",
hermes: "/default/hermes",
pi: "/default/pi",
},
isLoading: false,
initialAppConfigDir: undefined,
@@ -161,6 +165,8 @@ describe("useSettings hook", () => {
geminiConfigDir: "/server/gemini",
opencodeConfigDir: "/server/opencode",
openclawConfigDir: "/server/openclaw",
hermesConfigDir: "/server/hermes",
piConfigDir: "/server/pi",
language: "zh",
};
@@ -348,6 +354,25 @@ describe("useSettings hook", () => {
expect(syncCurrentProvidersLiveMock).not.toHaveBeenCalled();
});
it("sanitizes and republishes when the Pi native directory changes", async () => {
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
piConfigDir: " /custom/pi ",
},
});
const { result } = renderHook(() => useSettings());
await act(async () => {
await result.current.saveSettings(undefined, { silent: true });
});
const payload = mutateAsyncMock.mock.calls[0][0] as Settings;
expect(payload.piConfigDir).toBe("/custom/pi");
expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1);
});
it("shows toast when Claude plugin sync fails but continues flow", async () => {
// 设置服务器状态为 false,本地状态为 true,触发状态变化
serverSettings = {
@@ -459,9 +484,11 @@ describe("useSettings hook", () => {
claude: "/server/claude",
codex: undefined,
gemini: "/server/gemini",
grokbuild: undefined,
opencode: "/server/opencode",
openclaw: "/server/openclaw",
hermes: undefined,
hermes: "/server/hermes",
pi: "/server/pi",
});
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
});
+4
View File
@@ -170,6 +170,8 @@ describe("useSettingsForm Hook", () => {
enableClaudePluginIntegration: true,
claudeConfigDir: " /reset ",
codexConfigDir: " ",
hermesConfigDir: " /hermes-reset ",
piConfigDir: " /pi-reset ",
language: "zh",
});
});
@@ -180,6 +182,8 @@ describe("useSettingsForm Hook", () => {
expect(settings.enableClaudePluginIntegration).toBe(true);
expect(settings.claudeConfigDir).toBe("/reset");
expect(settings.codexConfigDir).toBeUndefined();
expect(settings.hermesConfigDir).toBe("/hermes-reset");
expect(settings.piConfigDir).toBe("/pi-reset");
expect(settings.language).toBe("zh");
expect(result.current.initialLanguage).toBe("en");
expect(changeLanguageSpy).toHaveBeenCalledWith("en");
+4
View File
@@ -73,6 +73,7 @@ const createDefaultProviders = (): ProvidersByApp => ({
opencode: {},
openclaw: {},
hermes: {},
pi: {},
});
const createDefaultCurrent = (): CurrentProviderState => ({
@@ -84,6 +85,7 @@ const createDefaultCurrent = (): CurrentProviderState => ({
opencode: "",
openclaw: "",
hermes: "",
pi: "",
});
let providers = createDefaultProviders();
@@ -197,6 +199,7 @@ let mcpConfigs: McpConfigState = {
opencode: {},
openclaw: {},
hermes: {},
pi: {},
};
const cloneProviders = (value: ProvidersByApp) =>
@@ -266,6 +269,7 @@ export const resetProviderState = () => {
opencode: {},
openclaw: {},
hermes: {},
pi: {},
};
};
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { getCacheWriteAvailability } from "@/types/usage";
describe("getCacheWriteAvailability", () => {
it("does not present Pi mixed-protocol cache creation as an authoritative zero", () => {
expect(getCacheWriteAvailability(["pi"])).toBe("partial");
expect(getCacheWriteAvailability(["pi", "codex"])).toBe("partial");
expect(getCacheWriteAvailability(["pi", "claude"])).toBe("partial");
});
it("preserves fixed-protocol and cross-app availability states", () => {
expect(getCacheWriteAvailability(["claude"])).toBe("ok");
expect(getCacheWriteAvailability(["codex", "gemini"])).toBe("na");
expect(getCacheWriteAvailability(["claude", "codex"])).toBe("partial");
expect(getCacheWriteAvailability([])).toBe("ok");
});
});