Merge pull request #949 from pikasTech/fix/issue939-management-config-toml
fix: expose provider config editing in management
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"], {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<ProviderProfile, string>;
|
||||
validations: Partial<Record<ProviderProfile, ProviderProfileValidation>>;
|
||||
@@ -25,7 +35,7 @@ const profileLabels: Record<ProviderProfile, string> = {
|
||||
};
|
||||
|
||||
const initialInputs: Record<ProviderProfile, string> = { 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<ManagementState>(initialState);
|
||||
@@ -61,6 +71,47 @@ export function ManagementView(): ReactElement {
|
||||
if (!updated) void load();
|
||||
}
|
||||
|
||||
async function openConfig(profile: ProviderProfile): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 ? <p className="management-error" role="alert">{state.error}</p> : null}
|
||||
{state.notice ? <p className="management-notice" role="status">{state.notice}</p> : null}
|
||||
<div className="management-grid" aria-live="polite">
|
||||
{visibleProfiles.map((profile) => <ProfilePanel key={profile.profile} profile={profile} inputValue={state.inputs[profile.profile] ?? ""} validation={state.validations[profile.profile] ?? profile.lastValidation ?? null} saving={state.savingProfile === profile.profile} validating={state.validatingProfile === profile.profile} busy={Boolean(state.savingProfile || state.validatingProfile)} onInput={setInput} onSave={(item) => void saveKey(item)} onValidate={(item) => void validateProfile(item)} />)}
|
||||
{visibleProfiles.map((profile) => <ProfilePanel key={profile.profile} profile={profile} inputValue={state.inputs[profile.profile] ?? ""} validation={state.validations[profile.profile] ?? profile.lastValidation ?? null} saving={state.savingProfile === profile.profile} validating={state.validatingProfile === profile.profile} configuring={state.configDialog?.profile === profile.profile && (state.configDialog.loading || state.configDialog.saving)} busy={Boolean(state.savingProfile || state.validatingProfile || state.configDialog?.loading || state.configDialog?.saving)} onInput={setInput} onSave={(item) => void saveKey(item)} onConfig={(item) => void openConfig(item)} onValidate={(item) => void validateProfile(item)} />)}
|
||||
</div>
|
||||
{state.configDialog ? <WorkbenchDialog id="management-config-dialog" title={`${profileLabels[state.configDialog.profile]} config.toml`} onClose={closeConfig}>
|
||||
<div className="management-config-editor">
|
||||
{state.configDialog.error ? <p className="management-config-error" role="alert">{state.configDialog.error}</p> : null}
|
||||
<label className="management-config-label" htmlFor="management-config-toml">config.toml</label>
|
||||
<textarea id="management-config-toml" className="mono" value={state.configDialog.configToml} disabled={state.configDialog.loading || state.configDialog.saving} aria-label={`${profileLabels[state.configDialog.profile]} config.toml`} spellCheck={false} onChange={(event) => setConfigToml(event.currentTarget.value)} />
|
||||
<div className="management-config-actions">
|
||||
<button className="command-button secondary" type="button" disabled={state.configDialog.saving} onClick={closeConfig}>关闭</button>
|
||||
<button className="command-button" type="button" disabled={state.configDialog.loading || state.configDialog.saving || !state.configDialog.configToml.trim()} onClick={() => void saveConfig()}>{state.configDialog.saving ? "保存中" : state.configDialog.loading ? "加载中" : "保存"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</WorkbenchDialog> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfilePanel({ profile, inputValue, validation, saving, validating, busy, onInput, onSave, onValidate }: { profile: ProviderProfileStatusItem; inputValue: string; validation: ProviderProfileValidation | null; saving: boolean; validating: boolean; busy: boolean; onInput(profile: ProviderProfile, value: string): void; onSave(profile: ProviderProfile): void; onValidate(profile: ProviderProfile): void }): ReactElement {
|
||||
function ProfilePanel({ profile, inputValue, validation, saving, validating, configuring, busy, onInput, onSave, onConfig, onValidate }: { profile: ProviderProfileStatusItem; inputValue: string; validation: ProviderProfileValidation | null; saving: boolean; validating: boolean; configuring: boolean; busy: boolean; onInput(profile: ProviderProfile, value: string): void; onSave(profile: ProviderProfile): void; onConfig(profile: ProviderProfile): void; onValidate(profile: ProviderProfile): void }): ReactElement {
|
||||
const tone = profile.configured ? "ok" : "blocked";
|
||||
const validationTone = validationToneFor(validation);
|
||||
const bridge = profile.profile === "deepseek" ? profile.bridge ?? { route: "HWLAB Moon Bridge -> DeepSeek official upstream", serviceUrl: "http://hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local:4000/v1", responsesPath: "/v1/responses" } : null;
|
||||
@@ -143,6 +205,7 @@ function ProfilePanel({ profile, inputValue, validation, saving, validating, bus
|
||||
{bridge ? <div className="management-bridge" aria-label="DeepSeek bridge"><strong>{bridge.route}</strong><span className="mono wrap">{bridge.serviceUrl}{bridge.responsesPath ?? ""}</span></div> : null}
|
||||
<div className="management-key-row">
|
||||
<label><span>API Key</span><input type="password" value={inputValue} autoComplete="new-password" placeholder="sk-..." disabled={disabled || saving || validating} onChange={(event) => onInput(profile.profile, event.currentTarget.value)} /></label>
|
||||
<button className="command-button secondary" type="button" disabled={disabled || saving || validating || configuring} onClick={() => onConfig(profile.profile)}>{configuring ? "加载中" : "config.toml"}</button>
|
||||
<button className="command-button" type="button" disabled={disabled || saving || validating || inputValue.trim().length < 8} onClick={() => onSave(profile.profile)}>{saving ? "保存中" : "保存"}</button>
|
||||
<button className="command-button secondary" type="button" disabled={disabled || saving || validating || profile.configured !== true} onClick={() => onValidate(profile.profile)}>{validating ? "验证中" : "验证"}</button>
|
||||
</div>
|
||||
@@ -179,6 +242,12 @@ function validationPayload(payload: unknown, profile: ProviderProfile): Provider
|
||||
return { ...validation, profile: validation.profile ?? profile };
|
||||
}
|
||||
|
||||
function configPayload(payload: unknown, profile: ProviderProfile): ProviderProfileConfig {
|
||||
const record = payload && typeof payload === "object" ? payload as { data?: ProviderProfileConfig; configToml?: string; profile?: ProviderProfile } : {};
|
||||
const config = record.data && typeof record.data === "object" ? record.data : record as ProviderProfileConfig;
|
||||
return { ...config, profile: config.profile ?? profile, configToml: typeof config.configToml === "string" ? config.configToml : "" };
|
||||
}
|
||||
|
||||
function orderedProfiles(items: ProviderProfileStatusItem[]): ProviderProfileStatusItem[] {
|
||||
const byProfile = new Map(items.map((item) => [item.profile, item]));
|
||||
return profileOrder.map((profile) => byProfile.get(profile) ?? { profile, configured: false, valuesPrinted: false });
|
||||
|
||||
@@ -238,6 +238,8 @@ export const api = {
|
||||
setAdminAccessTool: (userId: string, toolId: AccessToolId, allowed: boolean): Promise<ApiResult<AdminAccessMutationResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}/tools/${encodeURIComponent(toolId)}/can-use`, { method: allowed ? "PUT" : "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "admin access tool" }),
|
||||
providerProfiles: (): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson("/v1/admin/provider-profiles", { timeoutMs: 12000, timeoutName: "provider profiles" }),
|
||||
setProviderProfileKey: (profile: ProviderProfile, apiKey: string): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/credential`, { method: "PUT", body: JSON.stringify({ apiKey }), timeoutMs: 30000, timeoutName: "provider profile key update" }),
|
||||
providerProfileConfig: (profile: ProviderProfile): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`, { timeoutMs: 12000, timeoutName: "provider profile config" }),
|
||||
setProviderProfileConfig: (profile: ProviderProfile, configToml: string): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`, { method: "PUT", body: JSON.stringify({ configToml }), timeoutMs: 30000, timeoutName: "provider profile config update" }),
|
||||
validateProviderProfile: (profile: ProviderProfile): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validate`, { method: "POST", body: JSON.stringify({}), timeoutMs: 30000, timeoutName: "provider profile validation submit" }),
|
||||
providerProfileValidation: (profile: ProviderProfile, validationId: string): Promise<ApiResult<ProviderProfilesResponse & ProviderProfileValidation>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`, { timeoutMs: 12000, timeoutName: "provider profile validation" }),
|
||||
workspace: (projectId: string): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }),
|
||||
|
||||
@@ -378,12 +378,18 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.management-bridge { min-width: 0; display: grid; gap: 4px; border: 1px solid #a8cfc1; border-radius: 8px; background: #eff8f4; padding: 9px 10px; }
|
||||
.management-bridge strong { font-size: 13px; }
|
||||
.management-bridge span { color: var(--muted); font-size: 12px; }
|
||||
.management-key-row { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; align-items: end; }
|
||||
.management-key-row { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 8px; align-items: end; }
|
||||
.management-key-row label { min-width: 0; display: grid; gap: 4px; }
|
||||
.management-key-row input { min-width: 0; width: 100%; border: 1px solid var(--line); border-radius: 6px; background: #fff; color: var(--ink); padding: 9px 10px; font: inherit; }
|
||||
.management-key-row input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.management-validation { min-width: 0; display: grid; gap: 8px; border-top: 1px solid var(--line); padding-top: 10px; }
|
||||
.management-validation-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.management-config-editor { min-width: 0; display: grid; gap: 10px; }
|
||||
.management-config-label { color: var(--muted); font-size: 11px; font-weight: 900; text-transform: uppercase; }
|
||||
.management-config-editor textarea { min-width: 0; width: 100%; min-height: min(52vh, 520px); resize: vertical; border: 1px solid var(--line); border-radius: 6px; background: #fff; color: var(--ink); padding: 10px; font-size: 12px; line-height: 1.45; }
|
||||
.management-config-editor textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.management-config-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.management-config-error { margin: 0; border: 1px solid #e7a1a1; border-radius: 6px; background: #fff3f3; color: var(--bad); padding: 8px 10px; font-size: 13px; font-weight: 800; }
|
||||
.performance-view { display: grid; gap: 12px; align-content: start; background: #eef3ef; }
|
||||
.performance-header { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.performance-header h2, .performance-panel h3 { margin: 0; }
|
||||
|
||||
@@ -320,12 +320,18 @@ export interface ProviderProfilesResponse {
|
||||
profile?: ProviderProfile;
|
||||
items?: ProviderProfileStatusItem[];
|
||||
count?: number;
|
||||
data?: ProviderProfileStatusItem | ProviderProfilesData | ProviderProfileValidation | null;
|
||||
data?: ProviderProfileStatusItem | ProviderProfilesData | ProviderProfileValidation | ProviderProfileConfig | null;
|
||||
delegation?: ProviderProfileDelegationSummary;
|
||||
error?: { code?: string; message?: string; failureKind?: string; [key: string]: unknown };
|
||||
valuesPrinted?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderProfileConfig extends ProviderProfileStatusItem {
|
||||
configToml?: string;
|
||||
configTomlPrinted?: boolean;
|
||||
credentialValuesPrinted?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderProfilesData {
|
||||
items?: ProviderProfileStatusItem[];
|
||||
count?: number;
|
||||
|
||||
Reference in New Issue
Block a user