feat(runtime-provider-management): add provider directory bridge
This commit is contained in:
parent
825cfc00d1
commit
7fb5c8cf85
15 changed files with 865 additions and 18 deletions
|
|
@ -1,6 +1,8 @@
|
|||
import type {
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
|
|
@ -15,6 +17,9 @@ export interface RuntimeProviderManagementApi {
|
|||
loadView(
|
||||
input: RuntimeProviderManagementLoadViewInput
|
||||
): Promise<RuntimeProviderManagementViewResponse>;
|
||||
loadProviderDirectory(
|
||||
input: RuntimeProviderManagementLoadDirectoryInput
|
||||
): Promise<RuntimeProviderManagementDirectoryResponse>;
|
||||
connectWithApiKey(
|
||||
input: RuntimeProviderManagementConnectApiKeyInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse>;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const RUNTIME_PROVIDER_MANAGEMENT_VIEW = 'runtimeProviderManagement:view';
|
||||
export const RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY = 'runtimeProviderManagement:directory';
|
||||
export const RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY =
|
||||
'runtimeProviderManagement:connectApiKey';
|
||||
export const RUNTIME_PROVIDER_MANAGEMENT_FORGET = 'runtimeProviderManagement:forget';
|
||||
|
|
|
|||
|
|
@ -58,6 +58,64 @@ export interface RuntimeProviderConnectionDto {
|
|||
detail: string | null;
|
||||
}
|
||||
|
||||
export type RuntimeProviderDirectoryFilterDto =
|
||||
| 'all'
|
||||
| 'connected'
|
||||
| 'configured'
|
||||
| 'connectable'
|
||||
| 'manual'
|
||||
| 'has-models';
|
||||
|
||||
export type RuntimeProviderSetupKindDto =
|
||||
| 'connected'
|
||||
| 'connect-api-key'
|
||||
| 'configure-manually'
|
||||
| 'requires-environment'
|
||||
| 'available-readonly'
|
||||
| 'unsupported';
|
||||
|
||||
export type RuntimeProviderDirectorySourceDto =
|
||||
| 'opencode-provider'
|
||||
| 'config-provider'
|
||||
| 'inventory'
|
||||
| 'seed';
|
||||
|
||||
export interface RuntimeProviderDirectoryEntryDto {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
state: RuntimeProviderConnectionStateDto;
|
||||
setupKind: RuntimeProviderSetupKindDto;
|
||||
ownership: readonly RuntimeProviderOwnershipDto[];
|
||||
recommended: boolean;
|
||||
modelCount: number | null;
|
||||
authMethods: readonly RuntimeProviderAuthMethodDto[];
|
||||
defaultModelId: string | null;
|
||||
sources: readonly RuntimeProviderDirectorySourceDto[];
|
||||
sourceLabel: string | null;
|
||||
providerSource: string | null;
|
||||
detail: string | null;
|
||||
actions: readonly RuntimeProviderActionDescriptorDto[];
|
||||
metadata: {
|
||||
hasKnownModels: boolean;
|
||||
requiresManualConfig: boolean;
|
||||
supportedInlineAuth: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuntimeProviderDirectoryDto {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
totalCount: number;
|
||||
returnedCount: number;
|
||||
query: string | null;
|
||||
filter: RuntimeProviderDirectoryFilterDto;
|
||||
limit: number;
|
||||
cursor: string | null;
|
||||
nextCursor: string | null;
|
||||
entries: readonly RuntimeProviderDirectoryEntryDto[];
|
||||
diagnostics: readonly string[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementViewDto {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
title: string;
|
||||
|
|
@ -93,6 +151,13 @@ export interface RuntimeProviderManagementViewResponse {
|
|||
error?: RuntimeProviderManagementErrorDto;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementDirectoryResponse {
|
||||
schemaVersion: 1;
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
directory?: RuntimeProviderDirectoryDto;
|
||||
error?: RuntimeProviderManagementErrorDto;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementProviderResponse {
|
||||
schemaVersion: 1;
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
|
|
@ -153,6 +218,16 @@ export interface RuntimeProviderManagementLoadViewInput {
|
|||
projectPath?: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementLoadDirectoryInput {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
projectPath?: string | null;
|
||||
query?: string | null;
|
||||
filter?: RuntimeProviderDirectoryFilterDto | null;
|
||||
limit?: number | null;
|
||||
cursor?: string | null;
|
||||
refresh?: boolean | null;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementConnectApiKeyInput {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
providerId: string;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { RuntimeProviderManagementPort } from './RuntimeProviderManagementPort';
|
||||
import type {
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
|
|
@ -19,6 +21,13 @@ export function loadRuntimeProviderManagementView(
|
|||
return port.loadView(input);
|
||||
}
|
||||
|
||||
export function loadRuntimeProviderDirectory(
|
||||
port: RuntimeProviderManagementPort,
|
||||
input: RuntimeProviderManagementLoadDirectoryInput
|
||||
): Promise<RuntimeProviderManagementDirectoryResponse> {
|
||||
return port.loadProviderDirectory(input);
|
||||
}
|
||||
|
||||
export function connectRuntimeProviderWithApiKey(
|
||||
port: RuntimeProviderManagementPort,
|
||||
input: RuntimeProviderManagementConnectApiKeyInput
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_FORGET,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL,
|
||||
|
|
@ -11,7 +12,9 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import type { RuntimeProviderManagementFeatureFacade } from '../../composition/createRuntimeProviderManagementFeature';
|
||||
import type {
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
|
|
@ -52,6 +55,29 @@ export function registerRuntimeProviderManagementIpc(
|
|||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY,
|
||||
async (
|
||||
_event,
|
||||
input: RuntimeProviderManagementLoadDirectoryInput
|
||||
): Promise<RuntimeProviderManagementDirectoryResponse> => {
|
||||
try {
|
||||
return await feature.loadProviderDirectory(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load runtime provider directory', error);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'runtime-unhealthy',
|
||||
message: error instanceof Error ? error.message : 'Failed to load provider directory',
|
||||
recoverable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
|
||||
async (
|
||||
|
|
@ -173,6 +199,7 @@ export function registerRuntimeProviderManagementIpc(
|
|||
|
||||
export function removeRuntimeProviderManagementIpc(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_VIEW);
|
||||
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY);
|
||||
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY);
|
||||
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_FORGET);
|
||||
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_MODELS);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import type { RuntimeProviderManagementPort } from '../../core/application';
|
|||
import type {
|
||||
RuntimeProviderManagementApi,
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
|
|
@ -28,6 +30,9 @@ export function createRuntimeProviderManagementFeature(
|
|||
loadView: (
|
||||
input: RuntimeProviderManagementLoadViewInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> => port.loadView(input),
|
||||
loadProviderDirectory: (
|
||||
input: RuntimeProviderManagementLoadDirectoryInput
|
||||
): Promise<RuntimeProviderManagementDirectoryResponse> => port.loadProviderDirectory(input),
|
||||
connectWithApiKey: (
|
||||
input: RuntimeProviderManagementConnectApiKeyInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> => port.connectWithApiKey(input),
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
|||
import type {
|
||||
RuntimeProviderManagementApi,
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementErrorDto,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
|
|
@ -26,6 +28,7 @@ const COMMAND_ERROR_DETAIL_LIMIT = 1_600;
|
|||
|
||||
type RuntimeProviderManagementErrorResponse =
|
||||
| RuntimeProviderManagementViewResponse
|
||||
| RuntimeProviderManagementDirectoryResponse
|
||||
| RuntimeProviderManagementProviderResponse
|
||||
| RuntimeProviderManagementModelsResponse
|
||||
| RuntimeProviderManagementModelTestResponse;
|
||||
|
|
@ -116,6 +119,13 @@ function appendProjectPathArgs(args: string[], projectPath: string | null): stri
|
|||
return projectPath ? [...args, '--project-path', projectPath] : args;
|
||||
}
|
||||
|
||||
function appendOptionalArg(args: string[], name: string, value: string | null | undefined): void {
|
||||
const normalized = value?.trim();
|
||||
if (normalized) {
|
||||
args.push(name, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeProviderCommandOptions<T extends { env: NodeJS.ProcessEnv }>(
|
||||
options: T,
|
||||
projectPath: string | null
|
||||
|
|
@ -233,6 +243,51 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
|||
}
|
||||
}
|
||||
|
||||
async loadProviderDirectory(
|
||||
input: RuntimeProviderManagementLoadDirectoryInput
|
||||
): Promise<RuntimeProviderManagementDirectoryResponse> {
|
||||
const { binaryPath, env } = await resolveCliEnv();
|
||||
if (!binaryPath) {
|
||||
return errorResponse<RuntimeProviderManagementDirectoryResponse>(
|
||||
input.runtimeId,
|
||||
'Multimodel runtime binary was not found.',
|
||||
'runtime-missing'
|
||||
);
|
||||
}
|
||||
|
||||
const projectPath = normalizeProjectPath(input.projectPath);
|
||||
const args = ['runtime', 'providers', 'directory', '--runtime', input.runtimeId, '--json'];
|
||||
appendOptionalArg(args, '--project-path', projectPath);
|
||||
appendOptionalArg(args, '--query', input.query ?? null);
|
||||
appendOptionalArg(args, '--filter', input.filter ?? null);
|
||||
if (typeof input.limit === 'number' && Number.isFinite(input.limit) && input.limit > 0) {
|
||||
args.push('--limit', String(Math.floor(input.limit)));
|
||||
}
|
||||
appendOptionalArg(args, '--cursor', input.cursor ?? null);
|
||||
if (input.refresh) {
|
||||
args.push('--refresh');
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
args,
|
||||
runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath)
|
||||
);
|
||||
return extractJsonObject<RuntimeProviderManagementDirectoryResponse>(stdout);
|
||||
} catch (error) {
|
||||
const response =
|
||||
extractJsonObjectFromError<RuntimeProviderManagementDirectoryResponse>(error);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return errorResponse<RuntimeProviderManagementDirectoryResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async connectWithApiKey(
|
||||
input: RuntimeProviderManagementConnectApiKeyInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> {
|
||||
|
|
@ -329,6 +384,10 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
|||
);
|
||||
return extractJsonObject<RuntimeProviderManagementProviderResponse>(stdout);
|
||||
} catch (error) {
|
||||
const response = extractJsonObjectFromError<RuntimeProviderManagementProviderResponse>(error);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return errorResponse<RuntimeProviderManagementProviderResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_FORGET,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL,
|
||||
|
|
@ -10,7 +11,9 @@ import {
|
|||
|
||||
import type {
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
|
|
@ -30,6 +33,10 @@ export function createRuntimeProviderManagementBridge(
|
|||
input: RuntimeProviderManagementLoadViewInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> =>
|
||||
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_VIEW, input),
|
||||
loadProviderDirectory: (
|
||||
input: RuntimeProviderManagementLoadDirectoryInput
|
||||
): Promise<RuntimeProviderManagementDirectoryResponse> =>
|
||||
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, input),
|
||||
connectWithApiKey: (
|
||||
input: RuntimeProviderManagementConnectApiKeyInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
|
|
@ -10,6 +10,8 @@ import {
|
|||
|
||||
import type {
|
||||
RuntimeProviderConnectionDto,
|
||||
RuntimeProviderDirectoryEntryDto,
|
||||
RuntimeProviderDirectoryFilterDto,
|
||||
RuntimeProviderManagementRuntimeId,
|
||||
RuntimeProviderManagementViewDto,
|
||||
RuntimeProviderModelDto,
|
||||
|
|
@ -30,6 +32,18 @@ export interface RuntimeProviderManagementState {
|
|||
providers: readonly RuntimeProviderConnectionDto[];
|
||||
selectedProviderId: string | null;
|
||||
providerQuery: string;
|
||||
directoryOpen: boolean;
|
||||
directoryLoading: boolean;
|
||||
directoryRefreshing: boolean;
|
||||
directoryError: string | null;
|
||||
directoryEntries: readonly RuntimeProviderDirectoryEntryDto[];
|
||||
directoryTotalCount: number | null;
|
||||
directoryNextCursor: string | null;
|
||||
directoryQuery: string;
|
||||
directoryFilter: RuntimeProviderDirectoryFilterDto;
|
||||
directoryLoaded: boolean;
|
||||
directorySelectedProviderId: string | null;
|
||||
directorySupported: boolean;
|
||||
activeFormProviderId: string | null;
|
||||
apiKeyValue: string;
|
||||
modelPickerProviderId: string | null;
|
||||
|
|
@ -52,6 +66,14 @@ export interface RuntimeProviderManagementActions {
|
|||
refresh: () => Promise<void>;
|
||||
selectProvider: (providerId: string) => void;
|
||||
setProviderQuery: (value: string) => void;
|
||||
openDirectory: () => void;
|
||||
closeDirectory: () => void;
|
||||
setDirectoryQuery: (value: string) => void;
|
||||
setDirectoryFilter: (value: RuntimeProviderDirectoryFilterDto) => void;
|
||||
loadMoreDirectory: () => Promise<void>;
|
||||
refreshDirectory: () => Promise<void>;
|
||||
selectDirectoryProvider: (providerId: string) => void;
|
||||
searchAllProviders: (query: string) => void;
|
||||
startConnect: (providerId: string) => void;
|
||||
cancelConnect: () => void;
|
||||
setApiKeyValue: (value: string) => void;
|
||||
|
|
@ -146,6 +168,23 @@ export function useRuntimeProviderManagement(
|
|||
const [view, setView] = useState<RuntimeProviderManagementViewDto | null>(null);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
|
||||
const [providerQuery, setProviderQuery] = useState('');
|
||||
const [directoryOpen, setDirectoryOpen] = useState(false);
|
||||
const [directoryLoading, setDirectoryLoading] = useState(false);
|
||||
const [directoryRefreshing, setDirectoryRefreshing] = useState(false);
|
||||
const [directoryError, setDirectoryError] = useState<string | null>(null);
|
||||
const [directoryEntries, setDirectoryEntries] = useState<
|
||||
readonly RuntimeProviderDirectoryEntryDto[]
|
||||
>([]);
|
||||
const [directoryTotalCount, setDirectoryTotalCount] = useState<number | null>(null);
|
||||
const [directoryNextCursor, setDirectoryNextCursor] = useState<string | null>(null);
|
||||
const [directoryQuery, setDirectoryQuery] = useState('');
|
||||
const [directoryFilter, setDirectoryFilterState] =
|
||||
useState<RuntimeProviderDirectoryFilterDto>('all');
|
||||
const [directoryLoaded, setDirectoryLoaded] = useState(false);
|
||||
const [directorySelectedProviderId, setDirectorySelectedProviderId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [directorySupported, setDirectorySupported] = useState(true);
|
||||
const [activeFormProviderId, setActiveFormProviderId] = useState<string | null>(null);
|
||||
const [apiKeyValue, setApiKeyValue] = useState('');
|
||||
const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null);
|
||||
|
|
@ -166,6 +205,7 @@ export function useRuntimeProviderManagement(
|
|||
const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const directoryRequestSeq = useRef(0);
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
if (!options.enabled) {
|
||||
|
|
@ -199,9 +239,109 @@ export function useRuntimeProviderManagement(
|
|||
}
|
||||
}, [options.enabled, options.projectPath, options.runtimeId]);
|
||||
|
||||
const loadDirectoryPage = useCallback(
|
||||
async (
|
||||
input: {
|
||||
append?: boolean;
|
||||
refresh?: boolean;
|
||||
query?: string;
|
||||
filter?: RuntimeProviderDirectoryFilterDto;
|
||||
cursor?: string | null;
|
||||
} = {}
|
||||
): Promise<void> => {
|
||||
if (!options.enabled || !directorySupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
const append = input.append === true;
|
||||
const refreshDirectoryData = input.refresh === true;
|
||||
const query = input.query ?? directoryQuery;
|
||||
const filter = input.filter ?? directoryFilter;
|
||||
const cursor = input.cursor ?? (append ? directoryNextCursor : null);
|
||||
const requestSeq = directoryRequestSeq.current + 1;
|
||||
directoryRequestSeq.current = requestSeq;
|
||||
|
||||
if (append) {
|
||||
setDirectoryRefreshing(true);
|
||||
} else if (refreshDirectoryData) {
|
||||
setDirectoryRefreshing(true);
|
||||
} else {
|
||||
setDirectoryLoading(true);
|
||||
}
|
||||
setDirectoryError(null);
|
||||
|
||||
try {
|
||||
const response = await api.runtimeProviderManagement.loadProviderDirectory({
|
||||
runtimeId: options.runtimeId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
query: query.trim() || null,
|
||||
filter,
|
||||
limit: 50,
|
||||
cursor,
|
||||
refresh: refreshDirectoryData,
|
||||
});
|
||||
if (directoryRequestSeq.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
setDirectoryError(response.error.message);
|
||||
if (
|
||||
response.error.code === 'unsupported-action' ||
|
||||
response.error.message.toLowerCase().includes('unknown command')
|
||||
) {
|
||||
setDirectorySupported(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const directory = response.directory;
|
||||
if (!directory) {
|
||||
setDirectoryError('Provider directory response was empty');
|
||||
return;
|
||||
}
|
||||
setDirectoryLoaded(true);
|
||||
setDirectoryTotalCount(directory.totalCount);
|
||||
setDirectoryNextCursor(directory.nextCursor);
|
||||
setDirectoryEntries((current) =>
|
||||
append ? [...current, ...directory.entries] : directory.entries
|
||||
);
|
||||
} catch (loadError) {
|
||||
if (directoryRequestSeq.current === requestSeq) {
|
||||
setDirectoryError(
|
||||
loadError instanceof Error ? loadError.message : 'Failed to load provider directory'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (directoryRequestSeq.current === requestSeq) {
|
||||
setDirectoryLoading(false);
|
||||
setDirectoryRefreshing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
directoryFilter,
|
||||
directoryNextCursor,
|
||||
directoryQuery,
|
||||
directorySupported,
|
||||
options.enabled,
|
||||
options.projectPath,
|
||||
options.runtimeId,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
setProviderQuery('');
|
||||
setDirectoryOpen(false);
|
||||
setDirectoryLoading(false);
|
||||
setDirectoryRefreshing(false);
|
||||
setDirectoryError(null);
|
||||
setDirectoryEntries([]);
|
||||
setDirectoryTotalCount(null);
|
||||
setDirectoryNextCursor(null);
|
||||
setDirectoryQuery('');
|
||||
setDirectoryFilterState('all');
|
||||
setDirectoryLoaded(false);
|
||||
setDirectorySelectedProviderId(null);
|
||||
setApiKeyValue('');
|
||||
setActiveFormProviderId(null);
|
||||
const reset = resetModelState();
|
||||
|
|
@ -216,6 +356,34 @@ export function useRuntimeProviderManagement(
|
|||
void refresh();
|
||||
}, [options.enabled, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled || !directoryOpen || !directorySupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(
|
||||
() => {
|
||||
void loadDirectoryPage({
|
||||
append: false,
|
||||
query: directoryQuery,
|
||||
filter: directoryFilter,
|
||||
cursor: null,
|
||||
});
|
||||
},
|
||||
directoryLoaded ? 250 : 0
|
||||
);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [
|
||||
directoryFilter,
|
||||
directoryLoaded,
|
||||
directoryOpen,
|
||||
directoryQuery,
|
||||
directorySupported,
|
||||
loadDirectoryPage,
|
||||
options.enabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled || !modelPickerProviderId) {
|
||||
return;
|
||||
|
|
@ -281,13 +449,17 @@ export function useRuntimeProviderManagement(
|
|||
const selectedProvider = view?.providers.find(
|
||||
(provider) => provider.providerId === selectedProviderId
|
||||
);
|
||||
const selectedDirectoryProvider = directoryEntries.find(
|
||||
(provider) => provider.providerId === selectedProviderId
|
||||
);
|
||||
if (
|
||||
selectedProvider &&
|
||||
selectedProvider.state === 'connected' &&
|
||||
selectedProvider.modelCount > 0
|
||||
(selectedProvider?.state === 'connected' && selectedProvider.modelCount > 0) ||
|
||||
(selectedDirectoryProvider?.state === 'connected' &&
|
||||
selectedDirectoryProvider.modelCount !== 0)
|
||||
) {
|
||||
if (modelPickerProviderId !== selectedProvider.providerId) {
|
||||
setModelPickerProviderId(selectedProvider.providerId);
|
||||
const providerId = selectedProvider?.providerId ?? selectedDirectoryProvider!.providerId;
|
||||
if (modelPickerProviderId !== providerId) {
|
||||
setModelPickerProviderId(providerId);
|
||||
setModelPickerMode('use');
|
||||
setModelQuery('');
|
||||
setModels([]);
|
||||
|
|
@ -306,7 +478,85 @@ export function useRuntimeProviderManagement(
|
|||
setSelectedModelId(null);
|
||||
setModelResults({});
|
||||
}
|
||||
}, [activeFormProviderId, modelPickerProviderId, options.enabled, selectedProviderId, view]);
|
||||
}, [
|
||||
activeFormProviderId,
|
||||
directoryEntries,
|
||||
modelPickerProviderId,
|
||||
options.enabled,
|
||||
selectedProviderId,
|
||||
view,
|
||||
]);
|
||||
|
||||
const openDirectory = useCallback((): void => {
|
||||
if (!directorySupported) {
|
||||
return;
|
||||
}
|
||||
setDirectoryOpen(true);
|
||||
setDirectoryError(null);
|
||||
}, [directorySupported]);
|
||||
|
||||
const closeDirectory = useCallback((): void => {
|
||||
setDirectoryOpen(false);
|
||||
setDirectorySelectedProviderId(null);
|
||||
}, []);
|
||||
|
||||
const setDirectoryFilter = useCallback((value: RuntimeProviderDirectoryFilterDto): void => {
|
||||
setDirectoryFilterState(value);
|
||||
setDirectoryNextCursor(null);
|
||||
}, []);
|
||||
|
||||
const loadMoreDirectory = useCallback(async (): Promise<void> => {
|
||||
if (!directoryNextCursor || directoryLoading || directoryRefreshing) {
|
||||
return;
|
||||
}
|
||||
await loadDirectoryPage({
|
||||
append: true,
|
||||
cursor: directoryNextCursor,
|
||||
});
|
||||
}, [directoryLoading, directoryNextCursor, directoryRefreshing, loadDirectoryPage]);
|
||||
|
||||
const refreshDirectory = useCallback(async (): Promise<void> => {
|
||||
await loadDirectoryPage({
|
||||
refresh: true,
|
||||
cursor: null,
|
||||
});
|
||||
}, [loadDirectoryPage]);
|
||||
|
||||
const selectDirectoryProvider = useCallback(
|
||||
(providerId: string): void => {
|
||||
setDirectorySelectedProviderId(providerId);
|
||||
setSelectedProviderId(providerId);
|
||||
setActiveFormProviderId(null);
|
||||
|
||||
const compactProvider = view?.providers.find(
|
||||
(provider) => provider.providerId === providerId
|
||||
);
|
||||
const directoryProvider = directoryEntries.find(
|
||||
(provider) => provider.providerId === providerId
|
||||
);
|
||||
const connected =
|
||||
compactProvider?.state === 'connected' || directoryProvider?.state === 'connected';
|
||||
const modelCount = compactProvider?.modelCount ?? directoryProvider?.modelCount ?? null;
|
||||
|
||||
if (connected && modelCount !== 0) {
|
||||
setModelPickerProviderId(providerId);
|
||||
setModelPickerMode('use');
|
||||
setModelQuery('');
|
||||
setModels([]);
|
||||
setModelsError(null);
|
||||
setSelectedModelId(null);
|
||||
setModelResults({});
|
||||
}
|
||||
},
|
||||
[directoryEntries, view]
|
||||
);
|
||||
|
||||
const searchAllProviders = useCallback((query: string): void => {
|
||||
setDirectoryQuery(query);
|
||||
setDirectoryOpen(true);
|
||||
setDirectoryError(null);
|
||||
setDirectoryNextCursor(null);
|
||||
}, []);
|
||||
|
||||
const startConnect = useCallback((providerId: string): void => {
|
||||
setSelectedProviderId(providerId);
|
||||
|
|
@ -358,6 +608,12 @@ export function useRuntimeProviderManagement(
|
|||
setApiKeyValue('');
|
||||
void Promise.resolve(options.onProviderChanged?.())
|
||||
.then(() => refresh())
|
||||
.then(() => {
|
||||
if (directoryOpen) {
|
||||
return loadDirectoryPage({ refresh: true, cursor: null });
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((refreshError) => {
|
||||
setError(
|
||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||
|
|
@ -372,7 +628,7 @@ export function useRuntimeProviderManagement(
|
|||
setSavingProviderId(null);
|
||||
}
|
||||
},
|
||||
[apiKeyValue, options, refresh]
|
||||
[apiKeyValue, directoryOpen, loadDirectoryPage, options, refresh]
|
||||
);
|
||||
|
||||
const forgetProvider = useCallback(
|
||||
|
|
@ -400,6 +656,12 @@ export function useRuntimeProviderManagement(
|
|||
setSavingProviderId(null);
|
||||
void Promise.resolve(options.onProviderChanged?.())
|
||||
.then(() => refresh())
|
||||
.then(() => {
|
||||
if (directoryOpen) {
|
||||
return loadDirectoryPage({ refresh: true, cursor: null });
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((refreshError) => {
|
||||
setError(
|
||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||
|
|
@ -413,7 +675,7 @@ export function useRuntimeProviderManagement(
|
|||
setSavingProviderId(null);
|
||||
}
|
||||
},
|
||||
[options, refresh]
|
||||
[directoryOpen, loadDirectoryPage, options, refresh]
|
||||
);
|
||||
|
||||
const openModelPicker = useCallback(
|
||||
|
|
@ -549,6 +811,18 @@ export function useRuntimeProviderManagement(
|
|||
providers: view?.providers ?? [],
|
||||
selectedProviderId,
|
||||
providerQuery,
|
||||
directoryOpen,
|
||||
directoryLoading,
|
||||
directoryRefreshing,
|
||||
directoryError,
|
||||
directoryEntries,
|
||||
directoryTotalCount,
|
||||
directoryNextCursor,
|
||||
directoryQuery,
|
||||
directoryFilter,
|
||||
directoryLoaded,
|
||||
directorySelectedProviderId,
|
||||
directorySupported,
|
||||
activeFormProviderId,
|
||||
apiKeyValue,
|
||||
modelPickerProviderId,
|
||||
|
|
@ -569,6 +843,18 @@ export function useRuntimeProviderManagement(
|
|||
[
|
||||
activeFormProviderId,
|
||||
apiKeyValue,
|
||||
directoryEntries,
|
||||
directoryError,
|
||||
directoryFilter,
|
||||
directoryLoaded,
|
||||
directoryLoading,
|
||||
directoryNextCursor,
|
||||
directoryOpen,
|
||||
directoryQuery,
|
||||
directoryRefreshing,
|
||||
directorySelectedProviderId,
|
||||
directorySupported,
|
||||
directoryTotalCount,
|
||||
error,
|
||||
loading,
|
||||
modelPickerMode,
|
||||
|
|
@ -594,6 +880,14 @@ export function useRuntimeProviderManagement(
|
|||
refresh,
|
||||
selectProvider,
|
||||
setProviderQuery,
|
||||
openDirectory,
|
||||
closeDirectory,
|
||||
setDirectoryQuery,
|
||||
setDirectoryFilter,
|
||||
loadMoreDirectory,
|
||||
refreshDirectory,
|
||||
selectDirectoryProvider,
|
||||
searchAllProviders,
|
||||
startConnect,
|
||||
cancelConnect,
|
||||
setApiKeyValue,
|
||||
|
|
@ -609,12 +903,19 @@ export function useRuntimeProviderManagement(
|
|||
}),
|
||||
[
|
||||
cancelConnect,
|
||||
closeDirectory,
|
||||
closeModelPicker,
|
||||
forgetProvider,
|
||||
loadMoreDirectory,
|
||||
openDirectory,
|
||||
openModelPicker,
|
||||
refresh,
|
||||
refreshDirectory,
|
||||
searchAllProviders,
|
||||
selectDirectoryProvider,
|
||||
selectProvider,
|
||||
setDefaultModel,
|
||||
setDirectoryFilter,
|
||||
startConnect,
|
||||
submitConnect,
|
||||
testModel,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '@renderer/utils/openCodeModelRecommendations';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
|
|
@ -36,6 +37,8 @@ import type {
|
|||
} from '../hooks/useRuntimeProviderManagement';
|
||||
import type {
|
||||
RuntimeProviderConnectionDto,
|
||||
RuntimeProviderDirectoryEntryDto,
|
||||
RuntimeProviderDirectoryFilterDto,
|
||||
RuntimeProviderModelDto,
|
||||
RuntimeProviderModelTestResultDto,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
|
|
@ -67,6 +70,49 @@ interface ProviderRowProps {
|
|||
readonly actions: RuntimeProviderManagementActions;
|
||||
}
|
||||
|
||||
const DIRECTORY_FILTERS: Array<{ id: RuntimeProviderDirectoryFilterDto; label: string }> = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'connectable', label: 'Connectable' },
|
||||
{ id: 'connected', label: 'Connected' },
|
||||
{ id: 'configured', label: 'Configured' },
|
||||
{ id: 'manual', label: 'Manual setup' },
|
||||
{ id: 'has-models', label: 'Has models' },
|
||||
];
|
||||
|
||||
function getDirectoryAction(
|
||||
provider: RuntimeProviderDirectoryEntryDto,
|
||||
actionId: RuntimeProviderConnectionDto['actions'][number]['id']
|
||||
) {
|
||||
return provider.actions.find((action) => action.id === actionId) ?? null;
|
||||
}
|
||||
|
||||
function formatDirectorySetupKind(provider: RuntimeProviderDirectoryEntryDto): string {
|
||||
switch (provider.setupKind) {
|
||||
case 'connected':
|
||||
return 'Connected';
|
||||
case 'connect-api-key':
|
||||
return 'Connect';
|
||||
case 'configure-manually':
|
||||
return 'Configure manually';
|
||||
case 'requires-environment':
|
||||
return 'Requires environment';
|
||||
case 'available-readonly':
|
||||
return 'Available';
|
||||
case 'unsupported':
|
||||
return 'Unsupported';
|
||||
}
|
||||
}
|
||||
|
||||
function getDirectoryModelsLabel(provider: RuntimeProviderDirectoryEntryDto): string {
|
||||
if (provider.modelCount === null) {
|
||||
return 'models unknown';
|
||||
}
|
||||
if (provider.modelCount <= 0) {
|
||||
return 'models not reported';
|
||||
}
|
||||
return `${provider.modelCount} model${provider.modelCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function stateClassName(provider: RuntimeProviderConnectionDto): string {
|
||||
switch (provider.state) {
|
||||
case 'connected':
|
||||
|
|
@ -528,6 +574,242 @@ function ProviderRow({
|
|||
);
|
||||
}
|
||||
|
||||
function DirectoryProviderRow({
|
||||
provider,
|
||||
active,
|
||||
disabled,
|
||||
busy,
|
||||
actions,
|
||||
}: {
|
||||
readonly provider: RuntimeProviderDirectoryEntryDto;
|
||||
readonly active: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly busy: boolean;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
}): JSX.Element {
|
||||
const connect = getDirectoryAction(provider, 'connect');
|
||||
const configure = getDirectoryAction(provider, 'configure');
|
||||
const forget = getDirectoryAction(provider, 'forget');
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
data-testid={`runtime-provider-directory-row-${provider.providerId}`}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all hover:border-sky-300/60 hover:bg-sky-400/[0.08] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 ${
|
||||
active
|
||||
? 'border-sky-300/70 bg-sky-400/[0.075] shadow-[0_0_0_1px_rgba(125,211,252,0.22)]'
|
||||
: 'border-[var(--color-border-subtle)] bg-white/[0.02]'
|
||||
}`}
|
||||
onClick={() => actions.selectDirectoryProvider(provider.providerId)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
actions.selectDirectoryProvider(provider.providerId);
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ProviderBrandIcon provider={provider} />
|
||||
<span className="text-sm font-medium text-[var(--color-text)]">
|
||||
{provider.displayName}
|
||||
</span>
|
||||
{provider.recommended ? <Badge variant="secondary">Recommended</Badge> : null}
|
||||
<span
|
||||
className={`rounded-md border px-2 py-0.5 text-[11px] ${
|
||||
provider.state === 'connected'
|
||||
? 'border-emerald-400/35 bg-emerald-400/10 text-emerald-200'
|
||||
: 'border-white/10 bg-white/[0.04] text-[var(--color-text-muted)]'
|
||||
}`}
|
||||
>
|
||||
{formatDirectorySetupKind(provider)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--color-text-secondary)]">
|
||||
<span>{getDirectoryModelsLabel(provider)}</span>
|
||||
{provider.sourceLabel ? <span>{provider.sourceLabel}</span> : null}
|
||||
{provider.providerSource ? <span>{provider.providerSource}</span> : null}
|
||||
{provider.ownership.map((owner) => (
|
||||
<Badge
|
||||
key={owner}
|
||||
variant="outline"
|
||||
className="border-white/10 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{owner}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{provider.detail ? (
|
||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">{provider.detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start justify-end gap-1.5">
|
||||
{connect ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || busy || !connect.enabled}
|
||||
title={connect.disabledReason ?? undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.startConnect(provider.providerId);
|
||||
}}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<KeyRound className="mr-1 size-3.5" />
|
||||
)}
|
||||
{connect.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{forget ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={disabled || busy || !forget.enabled}
|
||||
title={forget.disabledReason ?? undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void actions.forgetProvider(provider.providerId);
|
||||
}}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 size-3.5" />
|
||||
)}
|
||||
{forget.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{!connect && configure ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
title={configure.disabledReason ?? undefined}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{configure.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderDirectoryPanel({
|
||||
state,
|
||||
actions,
|
||||
disabled,
|
||||
}: {
|
||||
readonly state: RuntimeProviderManagementState;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
readonly disabled: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-3"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255,255,255,0.018)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button type="button" size="sm" variant="ghost" onClick={actions.closeDirectory}>
|
||||
<ArrowLeft className="mr-1 size-3.5" />
|
||||
Providers
|
||||
</Button>
|
||||
<div className="text-xs text-[var(--color-text-muted)]">
|
||||
{state.directoryTotalCount === null
|
||||
? 'All OpenCode providers'
|
||||
: `${state.directoryTotalCount} OpenCode providers`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
|
||||
<Input
|
||||
data-testid="runtime-provider-directory-search"
|
||||
value={state.directoryQuery}
|
||||
disabled={disabled || state.directoryLoading}
|
||||
onChange={(event) => actions.setDirectoryQuery(event.target.value)}
|
||||
placeholder="Search all OpenCode providers"
|
||||
className="h-9 pr-3 text-sm"
|
||||
style={{ paddingLeft: 40 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DIRECTORY_FILTERS.map((filter) => (
|
||||
<Button
|
||||
key={filter.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={state.directoryFilter === filter.id ? 'default' : 'outline'}
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={disabled || state.directoryLoading}
|
||||
onClick={() => actions.setDirectoryFilter(filter.id)}
|
||||
>
|
||||
{filter.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.directoryError ? (
|
||||
<div className="mt-3 rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
|
||||
{state.directoryError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 max-h-[48vh] space-y-2 overflow-y-auto pr-1">
|
||||
{state.directoryLoading && state.directoryEntries.length === 0 ? (
|
||||
<RuntimeProviderLoadingPlaceholder />
|
||||
) : null}
|
||||
{state.directoryEntries.map((provider) => (
|
||||
<DirectoryProviderRow
|
||||
key={provider.providerId}
|
||||
provider={provider}
|
||||
active={state.directorySelectedProviderId === provider.providerId}
|
||||
disabled={disabled || state.directoryLoading}
|
||||
busy={state.savingProviderId === provider.providerId}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!state.directoryLoading && state.directoryEntries.length === 0 && !state.directoryError ? (
|
||||
<div className="mt-3 rounded-md border border-white/10 px-3 py-3 text-sm text-[var(--color-text-muted)]">
|
||||
No providers match this search.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.directoryNextCursor ? (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || state.directoryRefreshing}
|
||||
onClick={() => void actions.loadMoreDirectory()}
|
||||
>
|
||||
{state.directoryRefreshing ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelBadges({
|
||||
model,
|
||||
usedForNewTeams,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import opencodeIconUrl from '../assets/provider-icons/opencode-favicon.png';
|
||||
|
||||
import type { RuntimeProviderConnectionDto } from '@features/runtime-provider-management/contracts';
|
||||
import type { CSSProperties, JSX } from 'react';
|
||||
|
||||
type ProviderBrand = {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
interface SvgPath {
|
||||
d: string;
|
||||
fill?: string;
|
||||
|
|
@ -393,7 +397,7 @@ function normalizeProviderKey(value: string): string {
|
|||
.replace(/(?:^-)|(?:-$)/g, '');
|
||||
}
|
||||
|
||||
function getBrandIconKey(provider: RuntimeProviderConnectionDto): string | null {
|
||||
function getBrandIconKey(provider: ProviderBrand): string | null {
|
||||
const providerId = normalizeProviderKey(provider.providerId);
|
||||
const displayName = normalizeProviderKey(provider.displayName);
|
||||
const aliasedProviderId = BRAND_ALIASES[providerId] ?? providerId;
|
||||
|
|
@ -420,7 +424,7 @@ function getBrandIconKey(provider: RuntimeProviderConnectionDto): string | null
|
|||
return null;
|
||||
}
|
||||
|
||||
function fallbackDescriptor(provider: RuntimeProviderConnectionDto): BrandIconDescriptor {
|
||||
function fallbackDescriptor(provider: ProviderBrand): BrandIconDescriptor {
|
||||
const displayName = provider.displayName.trim();
|
||||
return {
|
||||
kind: 'letters',
|
||||
|
|
@ -431,7 +435,7 @@ function fallbackDescriptor(provider: RuntimeProviderConnectionDto): BrandIconDe
|
|||
};
|
||||
}
|
||||
|
||||
function descriptorFor(provider: RuntimeProviderConnectionDto): BrandIconDescriptor {
|
||||
function descriptorFor(provider: ProviderBrand): BrandIconDescriptor {
|
||||
const key = getBrandIconKey(provider);
|
||||
return key
|
||||
? (BRAND_ICONS[key] ?? LETTER_BRANDS[key] ?? fallbackDescriptor(provider))
|
||||
|
|
@ -446,11 +450,7 @@ function shellStyle(descriptor: BrandIconDescriptor): CSSProperties {
|
|||
};
|
||||
}
|
||||
|
||||
export function ProviderBrandIcon({
|
||||
provider,
|
||||
}: {
|
||||
readonly provider: RuntimeProviderConnectionDto;
|
||||
}): JSX.Element {
|
||||
export function ProviderBrandIcon({ provider }: { readonly provider: ProviderBrand }): JSX.Element {
|
||||
const descriptor = descriptorFor(provider);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1198,6 +1198,15 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
recoverable: true,
|
||||
},
|
||||
}),
|
||||
loadProviderDirectory: async (input) => ({
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'runtime-unhealthy',
|
||||
message: 'Runtime provider management is not available in browser mode.',
|
||||
recoverable: true,
|
||||
},
|
||||
}),
|
||||
connectWithApiKey: async (input) => ({
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,34 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('parses JSON error responses from failed forget commands', async () => {
|
||||
const error = new Error('Command failed: /repo/cli-dev runtime providers forget');
|
||||
Object.assign(error, {
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
error: {
|
||||
code: 'unsupported-action',
|
||||
message: 'This OpenCode runtime does not advertise credential removal through /doc',
|
||||
recoverable: true,
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
});
|
||||
execCliMock.mockRejectedValue(error);
|
||||
|
||||
const client = new AgentTeamsRuntimeProviderManagementCliClient();
|
||||
const response = await client.forgetCredential({
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'openrouter',
|
||||
});
|
||||
|
||||
expect(response.error?.code).toBe('unsupported-action');
|
||||
expect(response.error?.message).toBe(
|
||||
'This OpenCode runtime does not advertise credential removal through /doc'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes project path as cwd and CLI flag for project-aware provider management', async () => {
|
||||
execCliMock.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
|
||||
import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main';
|
||||
import type {
|
||||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
|
|
@ -60,6 +61,23 @@ describe('registerRuntimeProviderManagementIpc', () => {
|
|||
detail: null,
|
||||
},
|
||||
};
|
||||
const directoryResponse: RuntimeProviderManagementDirectoryResponse = {
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
directory: {
|
||||
runtimeId: 'opencode',
|
||||
totalCount: 0,
|
||||
returnedCount: 0,
|
||||
query: null,
|
||||
filter: 'all',
|
||||
limit: 100,
|
||||
cursor: null,
|
||||
nextCursor: null,
|
||||
entries: [],
|
||||
diagnostics: [],
|
||||
fetchedAt: '2026-04-25T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
const forgottenResponse: RuntimeProviderManagementProviderResponse = {
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
|
|
@ -101,6 +119,7 @@ describe('registerRuntimeProviderManagementIpc', () => {
|
|||
};
|
||||
const feature: RuntimeProviderManagementFeatureFacade = {
|
||||
loadView: vi.fn(() => Promise.resolve(viewResponse)),
|
||||
loadProviderDirectory: vi.fn(() => Promise.resolve(directoryResponse)),
|
||||
connectWithApiKey: vi.fn(() => Promise.resolve(connectedResponse)),
|
||||
forgetCredential: vi.fn(() => Promise.resolve(forgottenResponse)),
|
||||
loadModels: vi.fn(() => Promise.resolve(modelsResponse)),
|
||||
|
|
|
|||
|
|
@ -53,6 +53,18 @@ function createState(
|
|||
providers: [],
|
||||
selectedProviderId: 'openrouter',
|
||||
providerQuery: '',
|
||||
directoryOpen: false,
|
||||
directoryLoading: false,
|
||||
directoryRefreshing: false,
|
||||
directoryError: null,
|
||||
directoryEntries: [],
|
||||
directoryTotalCount: null,
|
||||
directoryNextCursor: null,
|
||||
directoryQuery: '',
|
||||
directoryFilter: 'all',
|
||||
directoryLoaded: false,
|
||||
directorySelectedProviderId: null,
|
||||
directorySupported: true,
|
||||
activeFormProviderId: null,
|
||||
apiKeyValue: '',
|
||||
modelPickerProviderId: null,
|
||||
|
|
@ -78,6 +90,14 @@ function createActions(): RuntimeProviderManagementActions {
|
|||
refresh: vi.fn(() => Promise.resolve()),
|
||||
selectProvider: vi.fn(),
|
||||
setProviderQuery: vi.fn(),
|
||||
openDirectory: vi.fn(),
|
||||
closeDirectory: vi.fn(),
|
||||
setDirectoryQuery: vi.fn(),
|
||||
setDirectoryFilter: vi.fn(),
|
||||
loadMoreDirectory: vi.fn(() => Promise.resolve()),
|
||||
refreshDirectory: vi.fn(() => Promise.resolve()),
|
||||
selectDirectoryProvider: vi.fn(),
|
||||
searchAllProviders: vi.fn(),
|
||||
startConnect: vi.fn(),
|
||||
cancelConnect: vi.fn(),
|
||||
setApiKeyValue: vi.fn(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue