feat: remove static provider profile gating
This commit is contained in:
@@ -34,7 +34,8 @@ view: provider API Key 管理
|
||||
|
||||
页面目标是直接可用的运维界面,不是 landing page。首版至少包含:
|
||||
|
||||
- provider profile 列表:`deepseek`、`codex-api`、`minimax-m3`。
|
||||
- provider profile 列表来自 `GET /v1/admin/provider-profiles` 的实时返回,不是前端固定枚举;内建友好名至少覆盖 `deepseek`、`dsflash-go`、`codex-api`、`minimax-m3`,其余动态 slug 直接回显并可立即使用。
|
||||
- 新增 provider profile slug 属于 AgentRun provider 配置数据变更;当 AgentRun runtime 已支持动态 slug 时,管理员只通过管理页或 `hwlab-cli client provider-profiles set-config/set-key/validate` 即可创建、配置和验证新 slug,HWLAB 不再为每个新 slug 增加 cloud-api/web 静态枚举,也不为此触发专门服务代码改动或单独 CI/CD。
|
||||
- 每个 profile 的配置状态:`configured`、SecretRef、key hash 后缀、resourceVersion、最近更新时间、最近验证结果。
|
||||
- API Key 写入表单:保存后立即清空输入框,不回显旧值。
|
||||
- DeepSeek 链路摘要:必须显示 `HWLAB Moon Bridge -> DeepSeek 官方 upstream`,并明确不是 hyue 通道。
|
||||
@@ -47,12 +48,16 @@ view: provider API Key 管理
|
||||
面向前端的同源 API 固定归属 HWLAB:
|
||||
|
||||
```http
|
||||
GET /v1/provider-profiles
|
||||
GET /v1/admin/provider-profiles
|
||||
PUT /v1/admin/provider-profiles/:profile/credential
|
||||
POST /v1/admin/provider-profiles/:profile/validate
|
||||
GET /v1/admin/provider-profiles/:profile/validations/:validationId
|
||||
```
|
||||
|
||||
- `/v1/provider-profiles` 是已认证用户可读的公开 catalog,只返回工作台下拉选择所需的 `profile/configured/backendKind/bridge` 等非敏感字段,用于工作台和设置页展示动态 slug。
|
||||
- `/v1/admin/provider-profiles*` 继续是管理员管理入口,负责查看 SecretRef/resourceVersion/hash 后缀、写 key/config 和触发 validate。
|
||||
|
||||
所有接口必须先通过 [spec-v02-auth.md](spec-v02-auth.md) 恢复 `AuthPrincipal`,再按 [spec-user-access.md](spec-user-access.md) 和 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 判定是否允许访问管理能力。首版允许只对 `admin` 开放;如果后续引入 `provider_manager` 或等价 relation,必须先写入用户权限规格。
|
||||
|
||||
响应必须是 JSON,且不得包含完整 API Key、Kubernetes Secret data、base64 Secret data、Codex `auth.json` 明文或 `config.toml` 明文。允许返回的字段包括:
|
||||
|
||||
@@ -30,7 +30,42 @@ test("provider profile management requires HWLAB admin auth before AgentRun dele
|
||||
}
|
||||
});
|
||||
|
||||
test("provider profiles collection delegates to AgentRun and rewrites codex profile for HWLAB UI", async () => {
|
||||
test("provider profile catalog is readable to authenticated non-admin actors and redacts management-only fields", async () => {
|
||||
const calls: Array<{ method?: string; path?: string; body?: unknown }> = [];
|
||||
const agentRunServer = await startAgentRunServer(calls);
|
||||
const { port: agentRunPort } = agentRunServer.address() as { port: number };
|
||||
const server = createCloudApiServer({
|
||||
env: testEnv(agentRunPort),
|
||||
accessController: fakeAccessController({ actor: userActor })
|
||||
});
|
||||
await listen(server);
|
||||
const { port } = server.address() as { port: number };
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/provider-profiles`, { headers: { cookie: "hwlab_session=user" } });
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.contractVersion, "provider-profile-management-v1");
|
||||
assert.equal(body.count, 4);
|
||||
const profiles = body.items.map((item: any) => item.profile);
|
||||
assert.equal(profiles.includes("deepseek"), true);
|
||||
assert.equal(profiles.includes("codex-api"), true);
|
||||
assert.equal(profiles.includes("minimax-m3"), true);
|
||||
assert.equal(profiles.includes("dsflash-go-cli-example"), true);
|
||||
assert.equal(body.items[0].secretRef, undefined);
|
||||
assert.equal(body.items[0].credentialHashSuffix, undefined);
|
||||
assert.equal(body.items[0].bridge.route, "HWLAB Moon Bridge -> DeepSeek official upstream");
|
||||
assert.equal(JSON.stringify(body).includes("auth.json"), false);
|
||||
assert.equal(calls[0].method, "GET");
|
||||
assert.equal(calls[0].path, "/api/v1/provider-profiles");
|
||||
} finally {
|
||||
await close(server);
|
||||
await close(agentRunServer);
|
||||
}
|
||||
});
|
||||
|
||||
test("provider profiles collection delegates to AgentRun, rewrites codex profile, and preserves dynamic slugs", async () => {
|
||||
const calls: Array<{ method?: string; path?: string; body?: unknown }> = [];
|
||||
const agentRunServer = await startAgentRunServer(calls);
|
||||
const { port: agentRunPort } = agentRunServer.address() as { port: number };
|
||||
@@ -48,7 +83,12 @@ test("provider profiles collection delegates to AgentRun and rewrites codex prof
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.contractVersion, "provider-profile-management-v1");
|
||||
assert.equal(body.delegation.agentRunHost, "127.0.0.1");
|
||||
assert.deepEqual(body.items.map((item: any) => item.profile), ["deepseek", "codex-api", "minimax-m3"]);
|
||||
const profiles = body.items.map((item: any) => item.profile);
|
||||
assert.equal(body.count, 4);
|
||||
assert.equal(profiles.includes("deepseek"), true);
|
||||
assert.equal(profiles.includes("codex-api"), true);
|
||||
assert.equal(profiles.includes("minimax-m3"), true);
|
||||
assert.equal(profiles.includes("dsflash-go-cli-example"), true);
|
||||
assert.equal(body.items[0].bridge.route, "HWLAB Moon Bridge -> DeepSeek official upstream");
|
||||
assert.equal(JSON.stringify(body).includes("auth.json"), true);
|
||||
assert.equal(JSON.stringify(body).includes("sk-test"), false);
|
||||
@@ -176,7 +216,7 @@ async function startAgentRunServer(calls: Array<{ method?: string; path?: string
|
||||
calls.push({ method: request.method, path: url.pathname, body });
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/provider-profiles") {
|
||||
response.end(`${JSON.stringify({ ok: true, traceId: "trc_profiles", data: { items: [profile("deepseek"), profile("codex"), profile("minimax-m3")], count: 3, valuesPrinted: false } })}\n`);
|
||||
response.end(`${JSON.stringify({ ok: true, traceId: "trc_profiles", data: { items: [profile("deepseek"), profile("codex"), profile("minimax-m3"), profile("dsflash-go-cli-example")], count: 4, valuesPrinted: false } })}\n`);
|
||||
return;
|
||||
}
|
||||
if (request.method === "PUT" && url.pathname === "/api/v1/provider-profiles/deepseek/credential") {
|
||||
|
||||
@@ -9,6 +9,38 @@ const PROVIDER_PROFILE_ALIASES = Object.freeze({ codex: "codex-api" });
|
||||
const PROVIDER_PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/u;
|
||||
const RESERVED_PROVIDER_PROFILES = new Set(["runtime-default"]);
|
||||
|
||||
export async function handleProviderProfileCatalogHttp(request, response, options = {}) {
|
||||
try {
|
||||
const auth = await authenticateActor(request, options);
|
||||
if (!auth.ok) return sendJson(response, numericStatus(auth.status) ?? numericStatus(auth.statusCode) ?? 401, auth);
|
||||
|
||||
const requestId = requestIdFor(request);
|
||||
const managerUrl = resolveAgentRunManagerUrl(options.env ?? process.env);
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const timeoutMs = positiveInteger((options.env ?? process.env).HWLAB_PROVIDER_PROFILE_AGENTRUN_HTTP_TIMEOUT_MS, positiveInteger((options.env ?? process.env).HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, DEFAULT_TIMEOUT_MS));
|
||||
const delegated = await agentRunJson(fetchImpl, managerUrl, "/api/v1/provider-profiles", { method: "GET", requestId, timeoutMs });
|
||||
const data = normalizeCatalogData(delegated.data);
|
||||
const status = delegated.ok ? delegated.httpStatus || 200 : delegated.httpStatus || 502;
|
||||
const base = {
|
||||
ok: delegated.ok,
|
||||
status: delegated.ok ? "ok" : "failed",
|
||||
contractVersion: CONTRACT_VERSION,
|
||||
actor: actorSummary(auth.actor),
|
||||
valuesPrinted: false
|
||||
};
|
||||
if (delegated.ok) {
|
||||
return sendJson(response, status, { ...base, items: data.items, count: data.count, data });
|
||||
}
|
||||
return sendJson(response, status, {
|
||||
...base,
|
||||
error: delegatedError(delegated),
|
||||
data: null
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(response, error.httpStatus ?? 500, error.code ?? "provider_profile_catalog_failed", error.message ?? "Provider profile catalog failed", error.details);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleProviderProfilesHttp(request, response, url, options = {}) {
|
||||
try {
|
||||
const auth = await authenticateAdmin(request, options);
|
||||
@@ -79,13 +111,19 @@ export async function handleProviderProfilesHttp(request, response, url, options
|
||||
}
|
||||
|
||||
async function authenticateAdmin(request, options) {
|
||||
const auth = await authenticateActor(request, options);
|
||||
if (!auth.ok) return auth;
|
||||
if (auth.actor?.role !== "admin") return errorPayload("admin_required", "Only admin users can manage provider profiles", 403);
|
||||
return auth;
|
||||
}
|
||||
|
||||
async function authenticateActor(request, options) {
|
||||
const controller = options.accessController;
|
||||
if (!controller || typeof controller.authenticate !== "function") {
|
||||
return errorPayload("access_controller_unavailable", "HWLAB access controller is not available", 503);
|
||||
}
|
||||
const auth = await controller.authenticate(request, { required: true });
|
||||
if (!auth.ok) return auth;
|
||||
if (auth.actor?.role !== "admin") return errorPayload("admin_required", "Only admin users can manage provider profiles", 403);
|
||||
return auth;
|
||||
}
|
||||
|
||||
@@ -205,6 +243,12 @@ function normalizeListData(data) {
|
||||
return { ...redactObject(data), items, count: items.length, valuesPrinted: false };
|
||||
}
|
||||
|
||||
function normalizeCatalogData(data) {
|
||||
const sourceItems = Array.isArray(data?.items) ? data.items : [];
|
||||
const items = sourceItems.map(normalizeCatalogProfileData);
|
||||
return { items, count: items.length, valuesPrinted: false };
|
||||
}
|
||||
|
||||
function normalizeProfileData(item) {
|
||||
if (!item || typeof item !== "object") return item;
|
||||
const copy = redactObject(item);
|
||||
@@ -218,6 +262,20 @@ function normalizeProfileData(item) {
|
||||
return copy;
|
||||
}
|
||||
|
||||
function normalizeCatalogProfileData(item) {
|
||||
const normalized = normalizeProfileData(item);
|
||||
if (!normalized || typeof normalized !== "object") return normalized;
|
||||
return {
|
||||
profile: normalized.profile,
|
||||
backendProfile: normalized.backendProfile,
|
||||
backendKind: normalized.backendKind,
|
||||
configured: normalized.configured,
|
||||
failureKind: normalized.failureKind ?? null,
|
||||
bridge: normalized.bridge,
|
||||
valuesPrinted: false
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
} from "./server-code-agent-http.ts";
|
||||
import { handleM3IoControlHttp } from "./server-m3-http.ts";
|
||||
import { handleSkillsHttp } from "./server-skills-http.ts";
|
||||
import { handleProviderProfilesHttp } from "./provider-profile-management.ts";
|
||||
import { handleProviderProfileCatalogHttp, handleProviderProfilesHttp } from "./provider-profile-management.ts";
|
||||
import {
|
||||
configureSkillRuntime,
|
||||
ensureCodexSkillsAggregationSync
|
||||
@@ -433,6 +433,11 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1/provider-profiles" && request.method === "GET") {
|
||||
await handleProviderProfileCatalogHttp(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/v1/admin/")) {
|
||||
await options.accessController.handleAdminRoute(request, response, url);
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactElement, ReactNode } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useAuth } from "./hooks/useAuth";
|
||||
import { providerProfileLabel } from "./hooks/useProviderProfileOptions";
|
||||
import { useSidebarResize } from "./hooks/useSidebarResize";
|
||||
import { useWorkbenchStore } from "./state/workbench";
|
||||
import type { RouteId } from "./types/domain";
|
||||
@@ -127,13 +128,7 @@ export function App(): ReactElement {
|
||||
void fetchText("/help.md", { timeoutMs: 8000 }).then((response) => setHelp(response.ok ? response.data ?? "" : `帮助内容加载失败:${response.error ?? "unknown"}`));
|
||||
}, [route]);
|
||||
|
||||
const modelLabel = useMemo(() => {
|
||||
if (store.state.providerProfile === "codex-api") return "Codex API";
|
||||
if (store.state.providerProfile === "minimax-m3") return "MiniMax-M3";
|
||||
if (store.state.providerProfile === "dsflash-go") return "DeepSeek V4 Flash";
|
||||
if (store.state.providerProfile === "deepseek") return "DeepSeek";
|
||||
return store.state.providerProfile;
|
||||
}, [store.state.providerProfile]);
|
||||
const modelLabel = useMemo(() => providerProfileLabel(store.state.providerProfile), [store.state.providerProfile]);
|
||||
const sessionSidebarVisible = workspaceRoute && !leftCollapsed;
|
||||
const hwpodSidebarVisible = workspaceRoute && !rightCollapsed;
|
||||
const shellStyle = useMemo(() => ({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useProviderProfileOptions } from "../../hooks/useProviderProfileOptions";
|
||||
import type { ProviderProfile } from "../../types/domain";
|
||||
import { DraftsList } from "./DraftsList";
|
||||
|
||||
@@ -33,6 +34,7 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason,
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [draftsOpen, setDraftsOpen] = useState(false);
|
||||
const inputRows = useMemo(() => commandInputRows(value), [value]);
|
||||
const { options: providerOptions } = useProviderProfileOptions(props.providerProfile);
|
||||
|
||||
useEffect(() => {
|
||||
if (pickedDraft) {
|
||||
@@ -63,10 +65,7 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason,
|
||||
<label className="agent-timeout-control" htmlFor="code-agent-provider-profile">
|
||||
<span>模型通道</span>
|
||||
<select id="code-agent-provider-profile" aria-label="Code Agent 模型通道" value={props.providerProfile} disabled={interactionDisabled} onChange={(event) => props.onProviderProfile(event.currentTarget.value as ProviderProfile)}>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="dsflash-go">DeepSeek V4 Flash</option>
|
||||
<option value="codex-api">Codex API</option>
|
||||
<option value="minimax-m3">MiniMax-M3</option>
|
||||
{providerOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="input-shell">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { builtinProviderProfileOrder, providerProfileLabel as profileLabel } from "../../hooks/useProviderProfileOptions";
|
||||
import { api } from "../../services/api/client";
|
||||
import type { ProviderProfile, ProviderProfileConfig, ProviderProfileStatusItem, ProviderProfileValidation, Tone } from "../../types/domain";
|
||||
import { formatTimestamp, shortToken, toneClass } from "../../utils";
|
||||
@@ -27,21 +28,9 @@ interface ManagementState {
|
||||
notice: string | null;
|
||||
}
|
||||
|
||||
const profileOrder: ProviderProfile[] = ["deepseek", "dsflash-go", "codex-api", "minimax-m3"];
|
||||
const profileLabels: Record<string, string> = {
|
||||
deepseek: "DeepSeek",
|
||||
"dsflash-go": "DeepSeek V4 Flash",
|
||||
"codex-api": "Codex API",
|
||||
"minimax-m3": "MiniMax-M3"
|
||||
};
|
||||
|
||||
const initialInputs: Record<string, string> = { deepseek: "", "dsflash-go": "", "codex-api": "", "minimax-m3": "" };
|
||||
const initialInputs: Record<string, string> = {};
|
||||
const initialState: ManagementState = { loading: false, savingProfile: null, validatingProfile: null, configDialog: null, profiles: [], inputs: initialInputs, validations: {}, error: null, notice: null };
|
||||
|
||||
function profileLabel(profile: ProviderProfile): string {
|
||||
return profileLabels[profile] ?? profile;
|
||||
}
|
||||
|
||||
export function ManagementView(): ReactElement {
|
||||
const [state, setState] = useState<ManagementState>(initialState);
|
||||
|
||||
@@ -56,7 +45,7 @@ export function ManagementView(): ReactElement {
|
||||
}
|
||||
|
||||
async function saveKey(profile: ProviderProfile): Promise<void> {
|
||||
const apiKey = state.inputs[profile].trim();
|
||||
const apiKey = (state.inputs[profile] ?? "").trim();
|
||||
if (apiKey.length < 8 || state.savingProfile || state.validatingProfile) return;
|
||||
setState((current) => ({ ...current, savingProfile: profile, error: null, notice: null }));
|
||||
const response = await api.setProviderProfileKey(profile, apiKey);
|
||||
@@ -255,8 +244,8 @@ function configPayload(payload: unknown, profile: ProviderProfile): ProviderProf
|
||||
|
||||
function orderedProfiles(items: ProviderProfileStatusItem[]): ProviderProfileStatusItem[] {
|
||||
const byProfile = new Map(items.map((item) => [item.profile, item]));
|
||||
const ordered = profileOrder.map((profile) => byProfile.get(profile) ?? { profile, configured: false, valuesPrinted: false });
|
||||
const extras = items.filter((item) => !profileOrder.includes(item.profile)).sort((left, right) => left.profile.localeCompare(right.profile));
|
||||
const ordered = builtinProviderProfileOrder.map((profile) => byProfile.get(profile) ?? { profile, configured: false, valuesPrinted: false });
|
||||
const extras = items.filter((item) => !builtinProviderProfileOrder.includes(item.profile)).sort((left, right) => left.profile.localeCompare(right.profile));
|
||||
return [...ordered, ...extras];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { useProviderProfileOptions } from "../../hooks/useProviderProfileOptions";
|
||||
import type { ProviderProfile } from "../../types/domain";
|
||||
|
||||
interface SettingsViewProps {
|
||||
@@ -13,6 +14,7 @@ interface SettingsViewProps {
|
||||
}
|
||||
|
||||
export function SettingsView({ providerProfile, codeAgentTimeoutMs, gatewayShellTimeoutMs, onProviderProfile, onCodeAgentTimeout, onGatewayShellTimeout, onLogout }: SettingsViewProps): ReactElement {
|
||||
const { options: providerOptions } = useProviderProfileOptions(providerProfile);
|
||||
return (
|
||||
<section className="view settings-view" id="settings" data-view="settings" aria-labelledby="settings-title">
|
||||
<div className="workspace-panel settings-panel">
|
||||
@@ -22,10 +24,7 @@ export function SettingsView({ providerProfile, codeAgentTimeoutMs, gatewayShell
|
||||
<label className="agent-timeout-control" htmlFor="settings-code-agent-provider-profile">
|
||||
<span>模型通道</span>
|
||||
<select id="settings-code-agent-provider-profile" aria-label="Code Agent 模型通道" value={providerProfile} onChange={(event) => onProviderProfile(event.currentTarget.value as ProviderProfile)}>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="dsflash-go">DeepSeek V4 Flash</option>
|
||||
<option value="codex-api">Codex API</option>
|
||||
<option value="minimax-m3">MiniMax-M3</option>
|
||||
{providerOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="agent-timeout-control" htmlFor="code-agent-timeout">
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "bun:test";
|
||||
|
||||
const sourcePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "useProviderProfileOptions.ts");
|
||||
|
||||
test("provider profile options use the public catalog instead of admin management routes", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
assert.match(source, /api\.providerProfileCatalog\(\)/u);
|
||||
assert.doesNotMatch(source, /api\.providerProfiles\(\)/u);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "../services/api/client";
|
||||
import type { ProviderProfile, ProviderProfileStatusItem } from "../types/domain";
|
||||
|
||||
export interface ProviderProfileOption {
|
||||
value: ProviderProfile;
|
||||
label: string;
|
||||
configured?: boolean;
|
||||
}
|
||||
|
||||
export const builtinProviderProfileOrder: readonly ProviderProfile[] = Object.freeze(["deepseek", "dsflash-go", "codex-api", "minimax-m3"]);
|
||||
|
||||
const builtinProviderProfileLabels: Readonly<Record<string, string>> = Object.freeze({
|
||||
deepseek: "DeepSeek",
|
||||
"dsflash-go": "DeepSeek V4 Flash",
|
||||
"codex-api": "Codex API",
|
||||
"minimax-m3": "MiniMax-M3"
|
||||
});
|
||||
|
||||
export function providerProfileLabel(profile: ProviderProfile): string {
|
||||
return builtinProviderProfileLabels[profile] ?? profile;
|
||||
}
|
||||
|
||||
export function useProviderProfileOptions(selectedProfile: ProviderProfile): { options: ProviderProfileOption[] } {
|
||||
const [options, setOptions] = useState<ProviderProfileOption[]>(() => defaultProviderProfileOptions(selectedProfile));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const response = await api.providerProfileCatalog();
|
||||
if (cancelled) return;
|
||||
if (!response.ok) {
|
||||
setOptions(defaultProviderProfileOptions(selectedProfile));
|
||||
return;
|
||||
}
|
||||
const items = profileItems(response.data);
|
||||
setOptions(items.length > 0 ? optionsFromItems(items, selectedProfile) : defaultProviderProfileOptions(selectedProfile));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProfile]);
|
||||
|
||||
const normalized = useMemo(() => ensureSelectedProfile(options, selectedProfile), [options, selectedProfile]);
|
||||
return { options: normalized };
|
||||
}
|
||||
|
||||
function profileItems(payload: unknown): ProviderProfileStatusItem[] {
|
||||
const record = payload && typeof payload === "object" ? payload as { items?: ProviderProfileStatusItem[]; data?: { items?: ProviderProfileStatusItem[] } } : {};
|
||||
return Array.isArray(record.items) ? record.items : Array.isArray(record.data?.items) ? record.data.items : [];
|
||||
}
|
||||
|
||||
function optionsFromItems(items: ProviderProfileStatusItem[], selectedProfile: ProviderProfile): ProviderProfileOption[] {
|
||||
const ordered = [...items]
|
||||
.filter((item) => typeof item.profile === "string" && item.profile.trim().length > 0)
|
||||
.sort((left, right) => compareProviderProfiles(left.profile, right.profile))
|
||||
.map((item) => ({ value: item.profile, label: providerProfileLabel(item.profile), configured: item.configured } satisfies ProviderProfileOption));
|
||||
return ensureSelectedProfile(uniqueOptions(ordered), selectedProfile);
|
||||
}
|
||||
|
||||
function defaultProviderProfileOptions(selectedProfile: ProviderProfile): ProviderProfileOption[] {
|
||||
const base = builtinProviderProfileOrder.map((profile) => ({ value: profile, label: providerProfileLabel(profile) } satisfies ProviderProfileOption));
|
||||
return ensureSelectedProfile(base, selectedProfile);
|
||||
}
|
||||
|
||||
function ensureSelectedProfile(options: ProviderProfileOption[], selectedProfile: ProviderProfile): ProviderProfileOption[] {
|
||||
const normalized = String(selectedProfile ?? "").trim();
|
||||
if (!normalized) return uniqueOptions(options);
|
||||
if (options.some((option) => option.value === normalized)) return uniqueOptions(options);
|
||||
return uniqueOptions([{ value: normalized, label: providerProfileLabel(normalized) }, ...options]);
|
||||
}
|
||||
|
||||
function uniqueOptions(options: ProviderProfileOption[]): ProviderProfileOption[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ProviderProfileOption[] = [];
|
||||
for (const option of options) {
|
||||
const value = String(option.value ?? "").trim();
|
||||
if (!value || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
result.push({ ...option, value });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function compareProviderProfiles(left: ProviderProfile, right: ProviderProfile): number {
|
||||
const leftIndex = builtinProviderProfileOrder.indexOf(left);
|
||||
const rightIndex = builtinProviderProfileOrder.indexOf(right);
|
||||
if (leftIndex >= 0 || rightIndex >= 0) {
|
||||
if (leftIndex < 0) return 1;
|
||||
if (rightIndex < 0) return -1;
|
||||
if (leftIndex !== rightIndex) return leftIndex - rightIndex;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
}
|
||||
@@ -233,6 +233,7 @@ export const api = {
|
||||
updateAdminAccessUser: (userId: string, payload: { role?: AccessUserRole; status?: AccessUserStatus }): Promise<ApiResult<AdminAccessMutationResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 12000, timeoutName: "admin access user update" }),
|
||||
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" }),
|
||||
providerProfileCatalog: (): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson("/v1/provider-profiles", { timeoutMs: 12000, timeoutName: "provider profile catalog" }),
|
||||
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" }),
|
||||
|
||||
@@ -395,8 +395,8 @@ test("subscribeToTrace starts result and trace polls in the same refresh tick (#
|
||||
const originalClearTimeout = globalThis.window?.clearTimeout;
|
||||
const fetches: string[] = [];
|
||||
let traceStartedBeforeResultResolved = false;
|
||||
let resolveResult: (() => void) | null = null;
|
||||
const resultGate = new Promise<void>((resolve) => { resolveResult = resolve; });
|
||||
let releaseResultGate: () => void = () => undefined;
|
||||
const resultGate = new Promise<void>((resolve) => { releaseResultGate = () => resolve(); });
|
||||
globalThis.window = {
|
||||
...(globalThis.window ?? {}),
|
||||
setTimeout: ((callback: () => void) => { callback(); return 1; }) as typeof window.setTimeout,
|
||||
@@ -411,7 +411,7 @@ test("subscribeToTrace starts result and trace polls in the same refresh tick (#
|
||||
}
|
||||
if (url.includes("/trace/")) {
|
||||
traceStartedBeforeResultResolved = true;
|
||||
resolveResult?.();
|
||||
releaseResultGate();
|
||||
return jsonResponse({
|
||||
status: "running",
|
||||
traceId: "trc_issue1000_parallel",
|
||||
@@ -438,14 +438,16 @@ test("subscribeToTrace starts result and trace polls in the same refresh tick (#
|
||||
});
|
||||
assert.equal(traceStartedBeforeResultResolved, true);
|
||||
assert.equal(snapshots.length, 1);
|
||||
assert.equal(snapshots[0].eventCount, 1);
|
||||
const firstSnapshot = snapshots[0];
|
||||
assert.ok(firstSnapshot);
|
||||
assert.equal(firstSnapshot.eventCount, 1);
|
||||
assert.equal(completed.assistantText, "最终回复");
|
||||
assert.deepEqual(fetches, [
|
||||
"/v1/agent/chat/result/trc_issue1000_parallel",
|
||||
"/v1/agent/chat/trace/trc_issue1000_parallel?projectId=prj_hwpod_workbench"
|
||||
]);
|
||||
} finally {
|
||||
resolveResult?.();
|
||||
releaseResultGate();
|
||||
globalThis.fetch = originalFetch;
|
||||
if (globalThis.window) {
|
||||
globalThis.window.setTimeout = originalSetTimeout ?? globalThis.window.setTimeout;
|
||||
|
||||
@@ -145,5 +145,5 @@ export function readStoredNumber(key: string, fallback: number, min: number, max
|
||||
|
||||
export function readProviderProfile(): string {
|
||||
const value = readStoredString("hwlab.workbench.codeAgentProviderProfile.v1");
|
||||
return /^[a-z0-9][a-z0-9-]{0,63}$/u.test(value) && value !== "runtime-default" ? value : "deepseek";
|
||||
return value && /^[a-z0-9][a-z0-9-]{0,63}$/u.test(value) && value !== "runtime-default" ? value : "deepseek";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user