Merge branch 'refs/heads/main' into feat/proxy-url-refactor-full-url-mode

# Conflicts:
#	src-tauri/src/provider.rs
#	src-tauri/src/proxy/forwarder.rs
#	src-tauri/src/proxy/usage/parser.rs
#	src/components/providers/forms/ClaudeFormFields.tsx
#	src/components/providers/forms/ProviderForm.tsx
#	src/components/providers/forms/hooks/useApiKeyState.ts
#	src/i18n/locales/en.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/zh.json
#	src/types.ts
#	src/utils/providerConfigUtils.ts
This commit is contained in:
YoVinchen
2026-03-09 23:16:12 +08:00
107 changed files with 9633 additions and 1161 deletions
@@ -0,0 +1,140 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import type { SessionMessage, SessionMeta } from "@/types";
import { setSessionFixtures } from "../msw/state";
const toastSuccessMock = vi.fn();
const toastErrorMock = vi.fn();
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: (...args: unknown[]) => toastErrorMock(...args),
},
}));
vi.mock("@/components/sessions/SessionToc", () => ({
SessionTocSidebar: () => null,
SessionTocDialog: () => null,
}));
vi.mock("@/components/ConfirmDialog", () => ({
ConfirmDialog: ({
isOpen,
title,
message,
confirmText,
cancelText,
onConfirm,
onCancel,
}: {
isOpen: boolean;
title: string;
message: string;
confirmText: string;
cancelText: string;
onConfirm: () => void;
onCancel: () => void;
}) =>
isOpen ? (
<div data-testid="confirm-dialog">
<div>{title}</div>
<div>{message}</div>
<button onClick={onConfirm}>{confirmText}</button>
<button onClick={onCancel}>{cancelText}</button>
</div>
) : null,
}));
const renderPage = () => {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={client}>
<SessionManagerPage appId="codex" />
</QueryClientProvider>,
);
};
describe("SessionManagerPage", () => {
beforeEach(() => {
toastSuccessMock.mockReset();
toastErrorMock.mockReset();
const sessions: SessionMeta[] = [
{
providerId: "codex",
sessionId: "codex-session-1",
title: "Alpha Session",
summary: "Alpha summary",
projectDir: "/mock/codex",
createdAt: 2,
lastActiveAt: 20,
sourcePath: "/mock/codex/session-1.jsonl",
resumeCommand: "codex resume codex-session-1",
},
{
providerId: "codex",
sessionId: "codex-session-2",
title: "Beta Session",
summary: "Beta summary",
projectDir: "/mock/codex",
createdAt: 1,
lastActiveAt: 10,
sourcePath: "/mock/codex/session-2.jsonl",
resumeCommand: "codex resume codex-session-2",
},
];
const messages: Record<string, SessionMessage[]> = {
"codex:/mock/codex/session-1.jsonl": [
{ role: "user", content: "alpha", ts: 20 },
],
"codex:/mock/codex/session-2.jsonl": [
{ role: "user", content: "beta", ts: 10 },
],
};
setSessionFixtures(sessions, messages);
});
it("deletes the selected session and selects the next visible session", async () => {
renderPage();
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Alpha Session" }),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: /删除会话/i }));
const dialog = screen.getByTestId("confirm-dialog");
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByText(/Alpha Session/)).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: /删除会话/i }));
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Beta Session" }),
).toBeInTheDocument(),
);
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
expect(toastErrorMock).not.toHaveBeenCalled();
expect(toastSuccessMock).toHaveBeenCalled();
});
});
@@ -228,6 +228,14 @@ describe("WebdavSyncSection", () => {
fireEvent.click(
screen.getByRole("switch", { name: "settings.webdavSync.autoSync" }),
);
fireEvent.click(
screen.getByRole("button", { name: "confirm.autoSync.confirm" }),
);
await waitFor(() => {
expect(
screen.getByRole("switch", { name: "settings.webdavSync.autoSync" }),
).toHaveAttribute("aria-checked", "true");
});
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
await waitFor(() => {
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
getOpenClawTimeoutInputValue,
getOpenClawToolsProfileSelectValue,
getOpenClawUnsupportedProfile,
OPENCLAW_UNSUPPORTED_PROFILE,
parseOpenClawEnvEditorValue,
} from "@/components/openclaw/utils";
describe("OpenClaw utils", () => {
it("parses nested env objects without stringifying them", () => {
const env = parseOpenClawEnvEditorValue(`{
"API_KEY": "secret",
"vars": { "HTTP_PROXY": "http://127.0.0.1:8080" },
"shellEnv": { "NODE_OPTIONS": "--max-old-space-size=4096" }
}`);
expect(env).toEqual({
API_KEY: "secret",
vars: { HTTP_PROXY: "http://127.0.0.1:8080" },
shellEnv: { NODE_OPTIONS: "--max-old-space-size=4096" },
});
});
it("rejects non-object env payloads", () => {
expect(() => parseOpenClawEnvEditorValue(`["not", "an object"]`)).toThrow(
"OPENCLAW_ENV_OBJECT_REQUIRED",
);
});
it("flags unsupported tools profiles without silently normalizing them", () => {
expect(getOpenClawToolsProfileSelectValue("default")).toBe(
OPENCLAW_UNSUPPORTED_PROFILE,
);
expect(getOpenClawUnsupportedProfile("default")).toBe("default");
expect(getOpenClawUnsupportedProfile("coding")).toBeNull();
});
it("prefers timeoutSeconds and falls back to legacy timeout", () => {
expect(
getOpenClawTimeoutInputValue({ timeoutSeconds: 120, timeout: 30 }),
).toBe("120");
expect(getOpenClawTimeoutInputValue({ timeout: 45 })).toBe("45");
expect(getOpenClawTimeoutInputValue({})).toBe("");
});
});
+43
View File
@@ -53,6 +53,9 @@ const providersApiUpdateMock = vi.fn();
const providersApiUpdateTrayMenuMock = vi.fn();
const settingsApiGetMock = vi.fn();
const settingsApiApplyMock = vi.fn();
const openclawApiGetModelCatalogMock = vi.fn();
const openclawApiGetDefaultModelMock = vi.fn();
const openclawApiSetDefaultModelMock = vi.fn();
vi.mock("@/lib/api", () => ({
providersApi: {
@@ -65,6 +68,14 @@ vi.mock("@/lib/api", () => ({
applyClaudePluginConfig: (...args: unknown[]) =>
settingsApiApplyMock(...args),
},
openclawApi: {
getModelCatalog: (...args: unknown[]) =>
openclawApiGetModelCatalogMock(...args),
getDefaultModel: (...args: unknown[]) =>
openclawApiGetDefaultModelMock(...args),
setDefaultModel: (...args: unknown[]) =>
openclawApiSetDefaultModelMock(...args),
},
}));
interface WrapperProps {
@@ -100,6 +111,9 @@ beforeEach(() => {
providersApiUpdateTrayMenuMock.mockReset();
settingsApiGetMock.mockReset();
settingsApiApplyMock.mockReset();
openclawApiGetModelCatalogMock.mockReset();
openclawApiGetDefaultModelMock.mockReset();
openclawApiSetDefaultModelMock.mockReset();
toastSuccessMock.mockReset();
toastErrorMock.mockReset();
@@ -450,6 +464,35 @@ describe("useProviderActions", () => {
expect(result.current.isLoading).toBe(true);
});
it("does not show backup details when setting OpenClaw default model", async () => {
openclawApiSetDefaultModelMock.mockResolvedValueOnce({
backupPath: "/tmp/openclaw-backup.json5",
warnings: [],
});
const { wrapper } = createWrapper();
const provider = createProvider({
settingsConfig: {
models: [{ id: "gpt-4.1" }, { id: "gpt-4.1-mini" }],
},
});
const { result } = renderHook(() => useProviderActions("openclaw"), {
wrapper,
});
await act(async () => {
await result.current.setAsDefaultModel(provider);
});
expect(openclawApiSetDefaultModelMock).toHaveBeenCalledWith({
primary: "provider-1/gpt-4.1",
fallbacks: ["provider-1/gpt-4.1-mini"],
});
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
expect(toastSuccessMock.mock.calls[0]?.[1]).toEqual({ closeButton: true });
});
});
it("clears loading flag when all mutations idle", () => {
addProviderMutation.isPending = false;
+34 -4
View File
@@ -4,9 +4,12 @@ import type { McpServer, Provider, Settings } from "@/types";
import {
addProvider,
deleteProvider,
deleteSession,
getCurrentProviderId,
getSessionMessages,
getProviders,
listProviders,
listSessions,
resetProviderState,
setCurrentProviderId,
updateProvider,
@@ -37,7 +40,9 @@ const success = <T>(payload: T) => HttpResponse.json(payload as any);
export const handlers = [
http.post(`${TAURI_ENDPOINT}/get_migration_result`, () => success(false)),
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => success(null)),
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () =>
success(null),
),
http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request);
return success(getProviders(app));
@@ -105,6 +110,25 @@ export const handlers = [
http.post(`${TAURI_ENDPOINT}/open_external`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/list_sessions`, () => success(listSessions())),
http.post(`${TAURI_ENDPOINT}/get_session_messages`, async ({ request }) => {
const { providerId, sourcePath } = await withJson<{
providerId: string;
sourcePath: string;
}>(request);
return success(getSessionMessages(providerId, sourcePath));
}),
http.post(`${TAURI_ENDPOINT}/delete_session`, async ({ request }) => {
const { providerId, sessionId, sourcePath } = await withJson<{
providerId: string;
sessionId: string;
sourcePath: string;
}>(request);
return success(deleteSession(providerId, sessionId, sourcePath));
}),
// MCP APIs
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request);
@@ -178,9 +202,13 @@ export const handlers = [
},
),
http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () =>
success(true),
),
http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () =>
success(true),
),
http.post(`${TAURI_ENDPOINT}/get_config_dir`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request);
@@ -280,7 +308,9 @@ export const handlers = [
success([]),
),
http.post(`${TAURI_ENDPOINT}/add_to_failover_queue`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () =>
success(true),
),
http.post(`${TAURI_ENDPOINT}/reorder_failover_queue`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/set_failover_item_enabled`, () => success(true)),
+125 -5
View File
@@ -1,5 +1,11 @@
import type { AppId } from "@/lib/api/types";
import type { McpServer, Provider, Settings } from "@/types";
import type {
McpServer,
Provider,
SessionMessage,
SessionMeta,
Settings,
} from "@/types";
type ProvidersByApp = Record<AppId, Record<string, Provider>>;
type CurrentProviderState = Record<AppId, string>;
@@ -80,13 +86,69 @@ let settingsState: Settings = {
language: "zh",
};
let appConfigDirOverride: string | null = null;
const sessionMessageKey = (providerId: string, sourcePath: string) =>
`${providerId}:${sourcePath}`;
const createDefaultSessions = (): SessionMeta[] => {
const now = Date.now();
return [
{
providerId: "codex",
sessionId: "codex-session-1",
title: "Codex Session One",
summary: "Codex summary",
projectDir: "/mock/codex",
createdAt: now - 2000,
lastActiveAt: now - 1000,
sourcePath: "/mock/codex/session-1.jsonl",
resumeCommand: "codex resume codex-session-1",
},
{
providerId: "claude",
sessionId: "claude-session-1",
title: "Claude Session One",
summary: "Claude summary",
projectDir: "/mock/claude",
createdAt: now - 4000,
lastActiveAt: now - 3000,
sourcePath: "/mock/claude/session-1.jsonl",
resumeCommand: "claude --resume claude-session-1",
},
];
};
const createDefaultSessionMessages = (): Record<string, SessionMessage[]> => ({
[sessionMessageKey("codex", "/mock/codex/session-1.jsonl")]: [
{
role: "user",
content: "First codex message",
ts: Date.now() - 1000,
},
],
[sessionMessageKey("claude", "/mock/claude/session-1.jsonl")]: [
{
role: "user",
content: "First claude message",
ts: Date.now() - 3000,
},
],
});
let sessionsState = createDefaultSessions();
let sessionMessagesState = createDefaultSessionMessages();
let mcpConfigs: McpConfigState = {
claude: {
sample: {
id: "sample",
name: "Sample Claude Server",
enabled: true,
apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false },
apps: {
claude: true,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
},
server: {
type: "stdio",
command: "claude-server",
@@ -98,7 +160,13 @@ let mcpConfigs: McpConfigState = {
id: "httpServer",
name: "HTTP Codex Server",
enabled: false,
apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false },
apps: {
claude: false,
codex: true,
gemini: false,
opencode: false,
openclaw: false,
},
server: {
type: "http",
url: "http://localhost:3000",
@@ -116,6 +184,8 @@ const cloneProviders = (value: ProvidersByApp) =>
export const resetProviderState = () => {
providers = createDefaultProviders();
current = createDefaultCurrent();
sessionsState = createDefaultSessions();
sessionMessagesState = createDefaultSessionMessages();
settingsState = {
showInTray: true,
minimizeToTrayOnClose: true,
@@ -131,7 +201,13 @@ export const resetProviderState = () => {
id: "sample",
name: "Sample Claude Server",
enabled: true,
apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false },
apps: {
claude: true,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
},
server: {
type: "stdio",
command: "claude-server",
@@ -143,7 +219,13 @@ export const resetProviderState = () => {
id: "httpServer",
name: "HTTP Codex Server",
enabled: false,
apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false },
apps: {
claude: false,
codex: true,
gemini: false,
opencode: false,
openclaw: false,
},
server: {
type: "http",
url: "http://localhost:3000",
@@ -285,3 +367,41 @@ export const deleteMcpServer = (appType: AppId, id: string) => {
if (!mcpConfigs[appType]) return;
delete mcpConfigs[appType][id];
};
export const listSessions = () =>
JSON.parse(JSON.stringify(sessionsState)) as SessionMeta[];
export const getSessionMessages = (providerId: string, sourcePath: string) =>
JSON.parse(
JSON.stringify(
sessionMessagesState[sessionMessageKey(providerId, sourcePath)] ?? [],
),
) as SessionMessage[];
export const deleteSession = (
providerId: string,
sessionId: string,
sourcePath: string,
) => {
sessionsState = sessionsState.filter(
(session) =>
!(
session.providerId === providerId &&
session.sessionId === sessionId &&
session.sourcePath === sourcePath
),
);
delete sessionMessagesState[sessionMessageKey(providerId, sourcePath)];
return true;
};
export const setSessionFixtures = (
sessions: SessionMeta[],
messages: Record<string, SessionMessage[]>,
) => {
sessionsState = JSON.parse(JSON.stringify(sessions)) as SessionMeta[];
sessionMessagesState = JSON.parse(JSON.stringify(messages)) as Record<
string,
SessionMessage[]
>;
};