diff --git a/internal/cloud/provider-profile-management.test.ts b/internal/cloud/provider-profile-management.test.ts index b1410192..e7b0c57a 100644 --- a/internal/cloud/provider-profile-management.test.ts +++ b/internal/cloud/provider-profile-management.test.ts @@ -99,6 +99,49 @@ test("provider profile set-key delegates through HWLAB without echoing API key", } }); +test("provider profile config reads and writes config.toml through AgentRun", async () => { + const calls: Array<{ method?: string; path?: string; body?: any }> = []; + const agentRunServer = await startAgentRunServer(calls); + const { port: agentRunPort } = agentRunServer.address() as { port: number }; + const server = createCloudApiServer({ + env: testEnv(agentRunPort), + accessController: fakeAccessController({ actor: adminActor }) + }); + await listen(server); + const { port } = server.address() as { port: number }; + const configToml = 'model = "deepseek-chat"\nbase_url = "http://bridge/v1"\n'; + + try { + const read = await fetch(`http://127.0.0.1:${port}/v1/admin/provider-profiles/deepseek/config`, { headers: { cookie: "hwlab_session=admin" } }); + const readBody = await read.json(); + assert.equal(read.status, 200); + assert.equal(readBody.ok, true); + assert.equal(readBody.profile, "deepseek"); + assert.equal(readBody.data.configToml, configToml); + assert.equal(readBody.data.configTomlPrinted, true); + assert.equal(JSON.stringify(readBody).includes("sk-test"), false); + + const write = await fetch(`http://127.0.0.1:${port}/v1/admin/provider-profiles/deepseek/config`, { + method: "PUT", + headers: { "content-type": "application/json", cookie: "hwlab_session=admin" }, + body: JSON.stringify({ configToml: `${configToml}# edited\n` }) + }); + const writeBody = await write.json(); + assert.equal(write.status, 200); + assert.equal(writeBody.status, "updated"); + assert.equal(JSON.stringify(writeBody).includes("# edited"), false); + assert.equal(calls[0].method, "GET"); + assert.equal(calls[0].path, "/api/v1/provider-profiles/deepseek/config"); + assert.equal(calls[1].method, "PUT"); + assert.equal(calls[1].path, "/api/v1/provider-profiles/deepseek/config"); + assert.equal(calls[1].body.configToml, `${configToml}# edited\n`); + assert.equal(calls[1].body.delegatedBy.system, "hwlab-v02"); + } finally { + await close(server); + await close(agentRunServer); + } +}); + function testEnv(port: number) { return { HWLAB_CODE_AGENT_AGENTRUN_MGR_URL: `http://127.0.0.1:${port}`, @@ -140,6 +183,14 @@ async function startAgentRunServer(calls: Array<{ method?: string; path?: string response.end(`${JSON.stringify({ ok: true, traceId: "trc_set_key", data: { action: "provider-profile-credential-updated", mutation: true, profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "def67890", updatedAt: "2026-06-05T08:00:00.000Z", requiresExternalBridgeUpdate: true, valuesPrinted: false } })}\n`); return; } + if (request.method === "GET" && url.pathname === "/api/v1/provider-profiles/deepseek/config") { + response.end(`${JSON.stringify({ ok: true, traceId: "trc_config", data: { action: "provider-profile-config", profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "def67890", updatedAt: "2026-06-05T08:00:00.000Z", configToml: 'model = "deepseek-chat"\nbase_url = "http://bridge/v1"\n', configTomlPrinted: true, credentialValuesPrinted: false, valuesPrinted: false } })}\n`); + return; + } + if (request.method === "PUT" && url.pathname === "/api/v1/provider-profiles/deepseek/config") { + response.end(`${JSON.stringify({ ok: true, traceId: "trc_set_config", data: { action: "provider-profile-config-updated", mutation: true, profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "fedcba98", updatedAt: "2026-06-05T08:01:00.000Z", valuesPrinted: false } })}\n`); + return; + } response.end(`${JSON.stringify({ ok: false, error: { code: "unexpected", message: `${request.method} ${url.pathname}` } })}\n`); }); await listen(server); diff --git a/internal/cloud/provider-profile-management.ts b/internal/cloud/provider-profile-management.ts index 1ae8c870..23025b87 100644 --- a/internal/cloud/provider-profile-management.ts +++ b/internal/cloud/provider-profile-management.ts @@ -37,6 +37,23 @@ export async function handleProviderProfilesHttp(request, response, url, options return sendDelegated(response, delegated, auth, route, managerUrl, requestId, normalizeProfileData(delegated.data)); } + if (route.kind === "config") { + if (route.method === "GET") { + const delegated = await agentRunJson(fetchImpl, managerUrl, `/api/v1/provider-profiles/${encodeURIComponent(route.agentRunProfile)}/config`, { method: "GET", requestId, timeoutMs }); + return sendDelegated(response, delegated, auth, route, managerUrl, requestId, normalizeConfigData(delegated.data), { allowConfigToml: true }); + } + const body = await readJsonObject(request, options.bodyLimitBytes); + const configToml = typeof body.configToml === "string" ? body.configToml : ""; + if (!configToml.trim()) return sendError(response, 400, "config_toml_required", "configToml is required and must not be empty"); + const delegatedBody = { + configToml, + delegatedBy: delegatedBy(auth, requestId), + reason: text(body.reason) || "hwlab-provider-management" + }; + const delegated = await agentRunJson(fetchImpl, managerUrl, `/api/v1/provider-profiles/${encodeURIComponent(route.agentRunProfile)}/config`, { method: "PUT", body: delegatedBody, requestId, timeoutMs }); + return sendDelegated(response, delegated, auth, route, managerUrl, requestId, normalizeConfigData(delegated.data)); + } + if (route.kind === "validate") { const body = await readOptionalJsonObject(request, options.bodyLimitBytes); const delegatedBody = { @@ -75,7 +92,7 @@ function parseProviderProfileRoute(pathname, method) { if (method !== "GET") throw routeError(405, "method_not_allowed", "Provider profiles collection only supports GET"); return { kind: "list", publicProfile: null, agentRunProfile: null }; } - const match = pathname.match(/^\/v1\/admin\/provider-profiles\/([^/]+)(?:\/(credential|validate|validations\/([^/]+)))?$/u); + const match = pathname.match(/^\/v1\/admin\/provider-profiles\/([^/]+)(?:\/(credential|config|validate|validations\/([^/]+)))?$/u); if (!match) throw routeError(404, "not_found", "Provider profile route is not implemented"); const profile = normalizeProfile(decodeURIComponent(match[1])); const suffix = match[2] ?? ""; @@ -83,6 +100,10 @@ function parseProviderProfileRoute(pathname, method) { if (method !== "PUT") throw routeError(405, "method_not_allowed", "Provider profile credential only supports PUT"); return { kind: "credential", ...profile }; } + if (suffix === "config") { + if (method !== "GET" && method !== "PUT") throw routeError(405, "method_not_allowed", "Provider profile config only supports GET and PUT"); + return { kind: "config", method, ...profile }; + } if (suffix === "validate") { if (method !== "POST") throw routeError(405, "method_not_allowed", "Provider profile validation only supports POST"); return { kind: "validate", ...profile }; @@ -150,18 +171,19 @@ async function agentRunJson(fetchImpl, managerUrl, path, { method = "GET", body, } } -function sendDelegated(response, delegated, auth, route, managerUrl, requestId, data) { - const status = delegated.ok ? (route.kind === "credential" ? 200 : delegated.httpStatus || 200) : delegated.httpStatus || 502; +function sendDelegated(response, delegated, auth, route, managerUrl, requestId, data, options = {}) { + const mutation = route.kind === "credential" || (route.kind === "config" && route.method === "PUT"); + const status = delegated.ok ? (mutation ? 200 : delegated.httpStatus || 200) : delegated.httpStatus || 502; const base = { ok: delegated.ok, - status: delegated.ok ? (route.kind === "credential" ? "updated" : "ok") : "failed", + status: delegated.ok ? (mutation ? "updated" : "ok") : "failed", contractVersion: CONTRACT_VERSION, actor: actorSummary(auth.actor), profile: route.publicProfile ?? undefined, delegation: delegationSummary(managerUrl, delegated, requestId), valuesPrinted: false }; - if (delegated.ok) return sendJson(response, status, { ...base, ...listAliases(data), data }); + if (delegated.ok) return sendJson(response, status, { ...base, ...listAliases(data), data }, options); return sendJson(response, status, { ...base, error: delegatedError(delegated), @@ -187,6 +209,17 @@ function normalizeProfileData(item) { return copy; } +function normalizeConfigData(data) { + if (!data || typeof data !== "object") return data; + const configToml = typeof data.configToml === "string" ? data.configToml : typeof data["config.toml"] === "string" ? data["config.toml"] : undefined; + const copy = normalizeProfileData(data); + if (typeof configToml === "string") copy.configToml = configToml; + copy.configTomlPrinted = typeof configToml === "string"; + copy.credentialValuesPrinted = false; + copy.valuesPrinted = false; + return copy; +} + function normalizeValidationData(data, fallbackProfile) { if (!data || typeof data !== "object") return data; const copy = normalizeProfileData(data); @@ -385,9 +418,20 @@ function sendError(response, statusCode, code, message, details = {}) { return sendJson(response, statusCode, { ok: false, status: "failed", contractVersion: CONTRACT_VERSION, error: { code, message, ...redactObject(details), valuesPrinted: false }, valuesPrinted: false }); } -function sendJson(response, statusCode, payload) { +function sendJson(response, statusCode, payload, options = {}) { response.writeHead(statusCode, { "content-type": "application/json" }); - response.end(`${JSON.stringify(redactObject(payload))}\n`); + const body = options.allowConfigToml ? redactObjectAllowingConfigToml(payload) : redactObject(payload); + response.end(`${JSON.stringify(body)}\n`); +} + +function redactObjectAllowingConfigToml(value, key = "") { + if ((key === "configToml" || key === "config.toml") && typeof value === "string") return value; + if (Array.isArray(value)) return value.map((item) => redactObjectAllowingConfigToml(item, key)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, redactObjectAllowingConfigToml(entryValue, entryKey)])); + } + if (typeof value === "string" && (secretField(key) || looksSecret(value))) return "[redacted]"; + return value; } function text(value) { diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 9fb8faa9..358c20b3 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -87,6 +87,7 @@ function isProviderProfileManagementProxyRoute(method, pathname) { const parts = pathname.split("/").filter(Boolean); if (parts.length < 4 || parts[0] !== "v1" || parts[1] !== "admin" || parts[2] !== "provider-profiles") return false; if (method === "PUT" && parts.length === 5 && parts[4] === "credential") return true; + if (method === "PUT" && parts.length === 5 && parts[4] === "config") return true; if (method === "POST" && parts.length === 5 && parts[4] === "validate") return true; return false; } diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index b84d8f1d..5f854137 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -124,6 +124,12 @@ test("cloud web proxies authenticated provider profile management write routes", publicRoute: false, routeKey: "PUT /v1/admin/provider-profiles/deepseek/credential" }); + assert.deepEqual(cloudWebProxyRoutePolicy("PUT", "/v1/admin/provider-profiles/deepseek/config"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "PUT /v1/admin/provider-profiles/deepseek/config" + }); assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/admin/provider-profiles/deepseek/validate"), { proxy: true, authRequired: true, diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index fadce736..ec51d458 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -123,6 +123,36 @@ test("hwlab-cli client provider-profiles set-key reads stdin and never prints ke assert.equal(JSON.stringify(result.payload).includes(secret), false); }); +test("hwlab-cli client provider-profiles config reads and writes config.toml", async () => { + const calls: any[] = []; + const configToml = 'model = "deepseek-chat"\nbase_url = "http://bridge/v1"\n'; + const read = await runHwlabCli(["client", "provider-profiles", "config", "deepseek", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ ok: true, status: "ok", profile: "deepseek", data: { profile: "deepseek", configured: true, configToml, configHashSuffix: "def67890", configTomlPrinted: true, credentialValuesPrinted: false, valuesPrinted: false } }), { status: 200 }); + } + }); + assert.equal(read.exitCode, 0); + assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles/deepseek/config"); + assert.equal(calls[0].init.method, "GET"); + assert.equal(read.payload.body.configToml, configToml); + assert.equal(read.payload.body.configTomlPrinted, true); + + const write = await runHwlabCli(["client", "provider-profiles", "set-config", "deepseek", "--config-stdin", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { + stdinText: `${configToml}# edited\n`, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); + return new Response(JSON.stringify({ ok: true, status: "updated", profile: "deepseek", data: { profile: "deepseek", configured: true, configHashSuffix: "fedcba98", valuesPrinted: false } }), { status: 200 }); + } + }); + assert.equal(write.exitCode, 0); + assert.equal(calls[1].url, "http://web.test/v1/admin/provider-profiles/deepseek/config"); + assert.equal(calls[1].init.method, "PUT"); + assert.equal(calls[1].body.configToml, `${configToml}# edited\n`); + assert.equal(write.payload.body.profileStatus.profile, "deepseek"); + assert.equal(JSON.stringify(write.payload).includes("# edited"), false); +}); + test("hwlab-cli client provider-profiles validate can wait by polling validation result", async () => { const calls: any[] = []; const result = await runHwlabCli(["client", "provider-profiles", "validate", "deepseek", "--wait", "--timeout-ms", "5000", "--poll-interval-ms", "10", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index dad86cf8..d8687dce 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -123,6 +123,8 @@ function help() { "hwlab-cli client access tools grant USER_ID hwpod", "hwlab-cli client access tools revoke USER_ID hwpod", "hwlab-cli client provider-profiles list", + "hwlab-cli client provider-profiles config deepseek", + "hwlab-cli client provider-profiles set-config deepseek --config-stdin", "hwlab-cli client provider-profiles set-key deepseek --key-stdin", "hwlab-cli client provider-profiles validate deepseek --wait --timeout-ms 120000", "hwlab-cli client runtime routes", @@ -310,6 +312,20 @@ async function providerProfilesCommand(context: any) { const response = await requestJson({ ...context, method: "PUT", path: pathName, body: { apiKey }, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); return responsePayload("client.provider-profiles.set-key", response, context, { route: route("PUT", pathName), profile, body: providerProfileBodyForCli(response.body, context.parsed), valuesPrinted: false }); } + if (subcommand === "config") { + const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]); + const pathName = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + return responsePayload("client.provider-profiles.config", response, context, { route: route("GET", pathName), profile, body: providerProfileConfigBodyForCli(response.body, context.parsed) }); + } + if (subcommand === "set-config") { + const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]); + if (context.parsed.configStdin !== true) throw cliError("config_stdin_required", "set-config requires --config-stdin so config.toml is not passed in argv", { command: "provider-profiles set-config PROFILE --config-stdin" }); + const configToml = await providerProfileConfigFromStdin(context); + const pathName = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`; + const response = await requestJson({ ...context, method: "PUT", path: pathName, body: { configToml }, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); + return responsePayload("client.provider-profiles.set-config", response, context, { route: route("PUT", pathName), profile, body: providerProfileBodyForCli(response.body, context.parsed), valuesPrinted: false }); + } if (subcommand === "validate") { return providerProfilesValidate(context); } @@ -373,12 +389,16 @@ function providerProfilesHelp() { valuesPrinted: false, commands: [ "list", + "config PROFILE", + "set-config PROFILE --config-stdin", "set-key PROFILE --key-stdin", "validate PROFILE [--wait] [--timeout-ms N] [--poll-interval-ms N]", "validation PROFILE VALIDATION_ID" ], routes: { list: route("GET", "/v1/admin/provider-profiles"), + config: route("GET", "/v1/admin/provider-profiles/:profile/config"), + setConfig: route("PUT", "/v1/admin/provider-profiles/:profile/config"), setKey: route("PUT", "/v1/admin/provider-profiles/:profile/credential"), validate: route("POST", "/v1/admin/provider-profiles/:profile/validate"), validation: route("GET", "/v1/admin/provider-profiles/:profile/validations/:validationId") @@ -392,6 +412,12 @@ async function providerProfileApiKeyFromStdin(context: any) { return apiKey.trim(); } +async function providerProfileConfigFromStdin(context: any) { + const configToml = context.stdinText !== undefined ? String(context.stdinText) : await readRawStdin(); + if (!configToml.trim()) throw cliError("config_toml_empty", "provider profile config.toml from stdin must not be empty"); + return configToml; +} + function normalizeProviderProfile(value: unknown) { const profile = text(value).toLowerCase(); const normalized = profile === "codex" ? "codex-api" : profile; @@ -2244,6 +2270,12 @@ async function readStdin() { return Buffer.concat(chunks).toString("utf8").trimEnd(); } +async function readRawStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + function baseUrl(parsed: ParsedArgs, env: EnvLike, kind: "web" | "api" = "web") { return runtimeEndpoint(parsed, env, kind).baseUrl; } @@ -3006,6 +3038,28 @@ function providerProfileBodyForCli(body: any, parsed: ParsedArgs) { }); } +function providerProfileConfigBodyForCli(body: any, parsed: ParsedArgs) { + if (parsed.full === true) return redactSecretLike(body); + const source = body?.data && typeof body.data === "object" ? body.data : body; + const configToml = typeof source?.configToml === "string" ? source.configToml : undefined; + return pruneUndefined({ + ok: body?.ok, + status: text(body?.status) || text(source?.status) || undefined, + contractVersion: text(body?.contractVersion) || text(source?.contractVersion) || undefined, + actor: compactApiBody(body?.actor ?? source?.actor), + profile: text(body?.profile ?? source?.profile) || undefined, + delegation: providerProfileDelegationForCli(body?.delegation ?? source?.delegation), + profileStatus: source?.profile ? providerProfileStatusForCli(source) : undefined, + configToml, + configBytes: configToml !== undefined ? Buffer.byteLength(configToml, "utf8") : undefined, + configTomlPrinted: configToml !== undefined, + credentialValuesPrinted: false, + error: body?.error ? compactApiBody(body.error) : undefined, + valuesPrinted: false, + fullBodyAvailable: true + }); +} + function providerProfileStatusForCli(value: any) { return pruneUndefined({ profile: text(value?.profile), diff --git a/web/hwlab-cloud-web/src/components/management/ManagementView.tsx b/web/hwlab-cloud-web/src/components/management/ManagementView.tsx index 773a64ba..c1e1a9a5 100644 --- a/web/hwlab-cloud-web/src/components/management/ManagementView.tsx +++ b/web/hwlab-cloud-web/src/components/management/ManagementView.tsx @@ -2,14 +2,24 @@ import type { ReactElement } from "react"; import { useEffect, useMemo, useState } from "react"; import { api } from "../../services/api/client"; -import type { ProviderProfile, ProviderProfileStatusItem, ProviderProfileValidation, Tone } from "../../types/domain"; +import type { ProviderProfile, ProviderProfileConfig, ProviderProfileStatusItem, ProviderProfileValidation, Tone } from "../../types/domain"; import { formatTimestamp, shortToken, toneClass } from "../../utils"; import { StateTag } from "../shared/StateTag"; +import { WorkbenchDialog } from "../shared/WorkbenchDialog"; + +interface ConfigDialogState { + profile: ProviderProfile; + configToml: string; + loading: boolean; + saving: boolean; + error: string | null; +} interface ManagementState { loading: boolean; savingProfile: ProviderProfile | null; validatingProfile: ProviderProfile | null; + configDialog: ConfigDialogState | null; profiles: ProviderProfileStatusItem[]; inputs: Record; validations: Partial>; @@ -25,7 +35,7 @@ const profileLabels: Record = { }; const initialInputs: Record = { deepseek: "", "codex-api": "", "minimax-m3": "" }; -const initialState: ManagementState = { loading: false, savingProfile: null, validatingProfile: null, profiles: [], inputs: initialInputs, validations: {}, error: null, notice: null }; +const initialState: ManagementState = { loading: false, savingProfile: null, validatingProfile: null, configDialog: null, profiles: [], inputs: initialInputs, validations: {}, error: null, notice: null }; export function ManagementView(): ReactElement { const [state, setState] = useState(initialState); @@ -61,6 +71,47 @@ export function ManagementView(): ReactElement { if (!updated) void load(); } + async function openConfig(profile: ProviderProfile): Promise { + if (state.savingProfile || state.validatingProfile) return; + setState((current) => ({ ...current, configDialog: { profile, configToml: "", loading: true, saving: false, error: null }, error: null, notice: null })); + const response = await api.providerProfileConfig(profile); + if (!response.ok) { + setState((current) => ({ ...current, configDialog: current.configDialog?.profile === profile ? { ...current.configDialog, loading: false, error: response.error ?? `${profileLabels[profile]} config.toml 加载失败` } : current.configDialog })); + return; + } + const config = configPayload(response.data, profile); + setState((current) => ({ ...current, configDialog: current.configDialog?.profile === profile ? { ...current.configDialog, loading: false, configToml: config.configToml ?? "", error: null } : current.configDialog })); + } + + async function saveConfig(): Promise { + const dialog = state.configDialog; + if (!dialog || dialog.loading || dialog.saving || !dialog.configToml.trim()) return; + setState((current) => ({ ...current, configDialog: current.configDialog ? { ...current.configDialog, saving: true, error: null } : null, error: null, notice: null })); + const response = await api.setProviderProfileConfig(dialog.profile, dialog.configToml); + if (!response.ok) { + setState((current) => ({ ...current, configDialog: current.configDialog ? { ...current.configDialog, saving: false, error: response.error ?? `${profileLabels[dialog.profile]} config.toml 保存失败` } : null })); + return; + } + const updated = profileItem(response.data); + setState((current) => ({ + ...current, + configDialog: null, + profiles: updated ? mergeProfile(current.profiles, updated) : current.profiles, + notice: `${profileLabels[dialog.profile]} config.toml 已保存`, + error: null + })); + if (!updated) void load(); + } + + function closeConfig(): void { + if (state.configDialog?.saving) return; + setState((current) => ({ ...current, configDialog: null })); + } + + function setConfigToml(value: string): void { + setState((current) => ({ ...current, configDialog: current.configDialog ? { ...current.configDialog, configToml: value } : null })); + } + async function validateProfile(profile: ProviderProfile): Promise { if (state.savingProfile || state.validatingProfile) return; setState((current) => ({ ...current, validatingProfile: profile, error: null, notice: null })); @@ -113,14 +164,25 @@ export function ManagementView(): ReactElement { {state.error ?

{state.error}

: null} {state.notice ?

{state.notice}

: null}
- {visibleProfiles.map((profile) => void saveKey(item)} onValidate={(item) => void validateProfile(item)} />)} + {visibleProfiles.map((profile) => void saveKey(item)} onConfig={(item) => void openConfig(item)} onValidate={(item) => void validateProfile(item)} />)}
+ {state.configDialog ? +
+ {state.configDialog.error ?

{state.configDialog.error}

: null} + +