Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"start": "vite",
"dev": "vite",
"typecheck": "tsc --noEmit",
"test:plugins": "node --experimental-strip-types --test tests/pluginAccounts.test.ts",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build",
"build": "vite build",
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/demo/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createPluginFixture } from "./pluginFixture";
import type {
CallDetail,
CursorHarnessStatus,
Expand Down Expand Up @@ -99,6 +100,8 @@ let tabSettings: TabSettings = { mode: "public", address: "" };
let storage: StatisticsStorage = { call_count: calls.length, trace_count: calls.length };

export function installDemoApi() {
const fixture = createPluginFixture(Date.now(), Number(new URLSearchParams(window.location.search).get("fixtureDelay") ?? 350));
(window as typeof window & { pluginFixture: typeof fixture }).pluginFixture = fixture;
const nativeFetch = window.fetch.bind(window);

window.fetch = async (input, init) => {
Expand All @@ -109,7 +112,10 @@ export function installDemoApi() {
const path = url.pathname.slice(API_ROOT.length) || "/";
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
const body = await readBody(input, init);
const pluginResponse = await fixture.handle(path, method, body);
if (pluginResponse) return pluginResponse;

if (path === "/settings/pricing") return json({ input_per_million: 5, output_per_million: 25, cache_read_per_million: 0.5, cache_write_per_million: 6.25 });
if (path === "/promotions") return json({ slots: [] });
if (path === "/models" && method === "GET") return json(models);
if (path === "/models" && method === "POST") return json(models);
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/demo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { installDemoApi } from "./api";
installDemoApi();

const params = new URLSearchParams(window.location.search);
const locale = params.get("locale") === "en-US" ? "en-US" : "zh-CN";
const requestedLocale = params.get("locale");
const locale = requestedLocale === "en-US" || requestedLocale === "pt-BR" ? requestedLocale : "zh-CN";
const theme = params.get("theme") === "default-light" ? "default-light" : "default-dark";

document.documentElement.dataset.platform = "macos";
Expand Down
98 changes: 98 additions & 0 deletions apps/desktop/src/demo/pluginFixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { PluginDescriptor, PluginResourceSelection } from "../shared/api.ts";

/** In-memory browser/test fixture, installed only by the demo entry point. */
export function createPluginFixture(now = Date.now(), delayMs = 350) {
const calls: { method: string; path: string }[] = [];
const plugins: PluginDescriptor[] = ["codex", "grok", "antigravity"].map((name, index) => ({
id: `fixture-${name}`,
name: `${name[0].toUpperCase()}${name.slice(1)} fixture`,
version: "1.0.0",
author: "Local test data",
icon: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23666'/%3E%3Cpath d='M8 16h16M16 8v16' stroke='white' stroke-width='2'/%3E%3C/svg%3E",
providers: [{
id: name, pluginId: `fixture-${name}`, displayName: name, description: null,
providerType: "openai", resourceType: "accounts", hasModels: true, configured: false, models: [],
}],
resources: [{
type: "accounts", displayName: { "en-US": "Accounts", "zh-CN": "账号", "pt-BR": "Contas" },
selection: { activeResourceId: index === 0 ? "account-12" : "account-1", automaticSwitching: index !== 1, revision: 1 },
canRefresh: true, canRemove: true,
import: { displayName: "Import fixture", description: null, accept: [".json"], multiple: true },
add: [{ id: "fixture-oauth", type: "oauth2.0", displayName: "Demo OAuth", description: "Local fixture only; no external authorization." }],
actions: [],
resources: Array.from({ length: index === 0 ? 12 : 2 }, (_, account) => ({
id: `account-${account + 1}`, displayName: `${name}-${account + 1}@example.test`,
description: account === 2 ? "Fixture: refresh and selection fail for this account." : null,
createdAtMs: now - account * 3600000,
state: account === 1 ? { status: "cooling" as const, retryAtMs: now + 15000 }
: account === 2 ? { status: "invalid" as const, message: "Fixture authorization expired" }
: { status: "ready" as const },
metrics: [
{ id: "quota", label: { "en-US": "Weekly quota", "zh-CN": "每周额度", "pt-BR": "Cota semanal" }, unit: "percent" as const, value: account === 1 ? 0 : 78 - account, resetAtMs: account === 3 ? null : now + 10000 + account * 3000 },
...(index === 0 ? [{ id: "cards", label: { "en-US": "Reset cards", "zh-CN": "重置卡", "pt-BR": "Cartões de redefinição" }, unit: "count" as const, value: 2, expiresAtMs: account === 3 ? null : now + 60000 }] : []),
],
})),
}],
}));
const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
const empty = () => new Response(null, { status: 204 });
const wait = () => new Promise<void>((resolve) => setTimeout(resolve, delayMs));
let failSnapshot = false;
let failSelection = false;
let oauthPlugin: PluginDescriptor | undefined;

const addAccount = (pluginId = plugins[0].id) => {
const resource = plugins.find((plugin) => plugin.id === pluginId)!.resources[0];
const id = `added-${resource.resources.length + 1}`;
resource.resources.push({ id, displayName: `${id}@example.test`, description: null, createdAtMs: Date.now(), state: { status: "ready" }, metrics: [] });
return id;
};

return {
calls, plugins, addAccount,
setSnapshotFailure(value: boolean) { failSnapshot = value; },
setSelectionFailure(value: boolean) { failSelection = value; },
async handle(path: string, method: string, body: unknown): Promise<Response | null> {
if (!path.startsWith("/plugins")) return null;
calls.push({ method, path });
if (path === "/plugins/runtime") return json({ state: "ready", version: "fixture", target: null, phase: null, downloaded_bytes: 0, total_bytes: null, error: null });
if (path === "/plugins") return failSnapshot ? json({ message: "Fixture local snapshot failure" }, 503) : json(plugins);
if (path.startsWith("/plugins/oauth/")) {
await wait();
if (oauthPlugin) addAccount(oauthPlugin.id);
oauthPlugin = undefined;
return json({ status: "completed", added: 1, updated: 0, modelSyncError: "Fixture model sync failure" });
}
const parts = path.split("/").filter(Boolean).map(decodeURIComponent);
const plugin = plugins.find((item) => item.id === parts[1]);
if (!plugin) return json({ message: "Fixture plugin not found" }, 404);
const resource = plugin.resources[0];
await wait();
if (parts.at(-1) === "selection" && method === "PUT") {
const next = body as Omit<PluginResourceSelection, "revision">;
if (failSelection || next.activeResourceId === "account-3") return json({ message: "Fixture selection rejected" }, 409);
resource.selection = { ...next, revision: resource.selection.revision + 1 };
return json(resource.selection);
}
if (parts.at(-1) === "refresh") {
if (parts[4] === "account-3") return json({ message: "Fixture quota refresh failed; previous data retained" }, 503);
return empty();
}
if (parts.at(-1) === "sync") return json({ message: "Fixture model sync failed; accounts remain manageable" }, 503);
if (parts.at(-1) === "begin") {
oauthPlugin = plugin;
return json({ sessionId: "fixture-session", userCode: "DEMO-CODE", verificationUrl: "https://example.test/fixture", verificationUrlComplete: null, expiresAtMs: now + 60000, pollIntervalMs: 1000 });
}
if (parts.at(-1) === "import") {
addAccount(plugin.id);
return json({ added: 1, updated: 0, warnings: [], modelSyncError: "Fixture model sync failure" });
}
if (method === "DELETE" && parts.length === 5) {
resource.resources = resource.resources.filter((item) => item.id !== parts[4]);
if (resource.selection.activeResourceId === parts[4]) resource.selection = { ...resource.selection, activeResourceId: null, revision: resource.selection.revision + 1 };
return empty();
}
return json({ message: `Unhandled plugin fixture: ${method} ${path}` }, 404);
},
};
}
10 changes: 8 additions & 2 deletions apps/desktop/src/features/plugins/PluginManagementPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useKeepAliveContext } from "keepalive-for-react";
import { useEffect, useRef, useState } from "react";
import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
import { useI18n } from "../../i18n/store";
Expand All @@ -13,13 +14,18 @@ import { PluginAddPanel, PluginSettingsPanel } from "./PluginResourcePanels";
import styles from "./PluginManagementPage.module.scss";

export function PluginManagementPage() {
const { active } = useKeepAliveContext();
const { pluginRuntime, plugins } = useAppStore();
const [progressOpen, setProgressOpen] = useState(false);
const [starting, setStarting] = useState(false);
const [selected, setSelected] = useState<{ pluginId: string; mode: "add" | "settings" } | null>(null);
const cancelRequested = useRef(false);
const selectedPlugin = selected ? plugins.find((plugin) => plugin.id === selected.pluginId) ?? null : null;

useEffect(() => {
if (!active) setSelected(null);
}, [active]);

useEffect(() => {
if (!pluginRuntime) void appStore.refreshPluginRuntime();
}, [pluginRuntime]);
Expand Down Expand Up @@ -73,7 +79,7 @@ export function PluginManagementPage() {
/>
<Modal
fullHeight
open={selectedPlugin !== null}
open={active && selectedPlugin !== null}
title={selected?.mode === "settings"
? t("{name} 账号管理", { name: selectedPlugin?.name ?? "" })
: t("添加 {name} 账号", { name: selectedPlugin?.name ?? "" })}
Expand Down Expand Up @@ -234,7 +240,7 @@ function PluginCard({ plugin, onOpen }: {
label={t("添加账号")}
onClick={() => onOpen(plugin.id, "add")}
/>
{configured && (
{(configured || accountCount > 0) && (
<TruncatedButton
size="small"
label={t("账号管理")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,47 @@
}
}

.selectionSummary {
display: flex;
flex-direction: column;
gap: 8px;
overflow-wrap: anywhere;
}

.metric {
display: flex;
flex-direction: column;
gap: 3px;
}

.resourceRow {
flex-wrap: wrap;
align-items: flex-start;

.accountDetails {
flex: 1 1 260px;
overflow-wrap: anywhere;
gap: 8px;
}

.actions {
flex-wrap: wrap;
}
}

.activeResource {
border-color: var(--vscode-focusBorder);
}

.resourceRow .activeLabel {
color: var(--vscode-textLink-foreground);
margin-inline-start: 8px;
}

.resourceRow .deleteButton {
color: var(--vscode-errorForeground);
}

.actionBody {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -231,7 +272,9 @@
}
}

.error {
.error,
.providerRow .error,
.resourceRow .error {
color: var(--vscode-errorForeground, #f48771);
font-size: type.$font-size-xs;
}
Loading