mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
feat(pi): expose first-class desktop workflows
This commit is contained in:
+59
-56
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
|
||||
@@ -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 四种模型字段 */}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -71,6 +71,7 @@ const APP_FILTER_ICON: Record<AppType, string> = {
|
||||
gemini: "gemini",
|
||||
grokbuild: "grok",
|
||||
opencode: "opencode",
|
||||
pi: "pi",
|
||||
};
|
||||
|
||||
// Select 的 "all" 哨兵和用户自定义名称同处一个值域——真有来源/模型叫 "all"
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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、OpenClaw 与 Hermes 会话记录",
|
||||
"subtitle": "管理 Claude Code、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes 与 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
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ConfigTransferResult {
|
||||
message: string;
|
||||
filePath?: string;
|
||||
backupId?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface WebDavTestResult {
|
||||
|
||||
+26
-1
@@ -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;
|
||||
}
|
||||
|
||||
/** 已安装的 Skill(v3.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");
|
||||
|
||||
@@ -7,4 +7,5 @@ export type AppId =
|
||||
| "grokbuild"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes";
|
||||
| "hermes"
|
||||
| "pi";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface ProxyTakeoverStatus {
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
hermes: boolean;
|
||||
pi: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderHealth {
|
||||
|
||||
+46
-1
@@ -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",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user