feat(openai): implement model discovery UI and functionality for fetching models

This commit is contained in:
Supra4E8C
2025-11-21 12:35:46 +08:00
parent c8dc446268
commit d088be8e65
4 changed files with 480 additions and 2 deletions

3
app.js
View File

@@ -614,6 +614,9 @@ class CLIProxyManager {
// 关闭模态框 // 关闭模态框
closeModal() { closeModal() {
document.getElementById('modal').style.display = 'none'; document.getElementById('modal').style.display = 'none';
if (typeof this.closeOpenAIModelDiscovery === 'function') {
this.closeOpenAIModelDiscovery();
}
} }
} }

24
i18n.js
View File

@@ -210,6 +210,18 @@ const i18n = {
'ai_providers.openai_model_name_placeholder': '模型名称,如 moonshotai/kimi-k2:free', 'ai_providers.openai_model_name_placeholder': '模型名称,如 moonshotai/kimi-k2:free',
'ai_providers.openai_model_alias_placeholder': '模型别名 (可选)', 'ai_providers.openai_model_alias_placeholder': '模型别名 (可选)',
'ai_providers.openai_models_add_btn': '添加模型', 'ai_providers.openai_models_add_btn': '添加模型',
'ai_providers.openai_models_fetch_button': '从 /v1/model 获取',
'ai_providers.openai_models_fetch_title': '从 /v1/model 选择模型',
'ai_providers.openai_models_fetch_hint': '使用上方 Base URL 调用 /v1/model 端点,附带首个 API KeyBearer与自定义请求头。',
'ai_providers.openai_models_fetch_url_label': '请求地址',
'ai_providers.openai_models_fetch_refresh': '重新获取',
'ai_providers.openai_models_fetch_loading': '正在从 /v1/model 获取模型列表...',
'ai_providers.openai_models_fetch_empty': '未获取到模型,请检查端点或鉴权信息。',
'ai_providers.openai_models_fetch_error': '获取模型失败',
'ai_providers.openai_models_fetch_back': '返回编辑',
'ai_providers.openai_models_fetch_apply': '添加所选模型',
'ai_providers.openai_models_fetch_invalid_url': '请先填写有效的 Base URL',
'ai_providers.openai_models_fetch_added': '已添加 {count} 个新模型',
'ai_providers.openai_edit_modal_title': '编辑OpenAI兼容提供商', 'ai_providers.openai_edit_modal_title': '编辑OpenAI兼容提供商',
'ai_providers.openai_edit_modal_name_label': '提供商名称:', 'ai_providers.openai_edit_modal_name_label': '提供商名称:',
'ai_providers.openai_edit_modal_url_label': 'Base URL:', 'ai_providers.openai_edit_modal_url_label': 'Base URL:',
@@ -718,6 +730,18 @@ const i18n = {
'ai_providers.openai_model_name_placeholder': 'Model name, e.g. moonshotai/kimi-k2:free', 'ai_providers.openai_model_name_placeholder': 'Model name, e.g. moonshotai/kimi-k2:free',
'ai_providers.openai_model_alias_placeholder': 'Model alias (optional)', 'ai_providers.openai_model_alias_placeholder': 'Model alias (optional)',
'ai_providers.openai_models_add_btn': 'Add Model', 'ai_providers.openai_models_add_btn': 'Add Model',
'ai_providers.openai_models_fetch_button': 'Fetch via /v1/model',
'ai_providers.openai_models_fetch_title': 'Pick Models from /v1/model',
'ai_providers.openai_models_fetch_hint': 'Call the /v1/model endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.',
'ai_providers.openai_models_fetch_url_label': 'Request URL',
'ai_providers.openai_models_fetch_refresh': 'Refresh',
'ai_providers.openai_models_fetch_loading': 'Fetching models from /v1/model...',
'ai_providers.openai_models_fetch_empty': 'No models returned. Please check the endpoint or auth.',
'ai_providers.openai_models_fetch_error': 'Failed to fetch models',
'ai_providers.openai_models_fetch_back': 'Back to edit',
'ai_providers.openai_models_fetch_apply': 'Add selected models',
'ai_providers.openai_models_fetch_invalid_url': 'Please enter a valid Base URL first',
'ai_providers.openai_models_fetch_added': '{count} new models added',
'ai_providers.openai_edit_modal_title': 'Edit OpenAI Compatible Provider', 'ai_providers.openai_edit_modal_title': 'Edit OpenAI Compatible Provider',
'ai_providers.openai_edit_modal_name_label': 'Provider Name:', 'ai_providers.openai_edit_modal_name_label': 'Provider Name:',
'ai_providers.openai_edit_modal_url_label': 'Base URL:', 'ai_providers.openai_edit_modal_url_label': 'Base URL:',

View File

@@ -9,6 +9,54 @@ const getStatsBySource = (stats) => {
return stats || {}; return stats || {};
}; };
const buildModelEndpoint = (baseUrl) => {
if (!baseUrl) return '';
try {
return new URL('/v1/model', baseUrl).toString();
} catch (_) {
const trimmed = String(baseUrl).trim().replace(/\/+$/g, '');
return trimmed ? `${trimmed}/v1/model` : '';
}
};
const normalizeModelList = (payload) => {
const toModel = (entry) => {
if (typeof entry === 'string') {
return { name: entry };
}
if (!entry || typeof entry !== 'object') {
return null;
}
const name = entry.id || entry.name || entry.model || entry.value;
if (!name) return null;
const alias = entry.alias || entry.display_name || entry.displayName;
const description = entry.description || entry.note || entry.comment;
const model = { name: String(name) };
if (alias && alias !== name) {
model.alias = String(alias);
}
if (description) {
model.description = String(description);
}
return model;
};
if (Array.isArray(payload)) {
return payload.map(toModel).filter(Boolean);
}
if (payload && typeof payload === 'object') {
if (Array.isArray(payload.data)) {
return payload.data.map(toModel).filter(Boolean);
}
if (Array.isArray(payload.models)) {
return payload.models.map(toModel).filter(Boolean);
}
}
return [];
};
export async function loadGeminiKeys() { export async function loadGeminiKeys() {
try { try {
const config = await this.getConfig(); const config = await this.getConfig();
@@ -979,6 +1027,251 @@ export async function renderOpenAIProviders(providers, keyStats = null) {
}).join(''); }).join('');
} }
const getOpenAIContext = (mode = 'new') => {
const isEdit = mode === 'edit';
return {
mode: isEdit ? 'edit' : 'new',
baseUrlInputId: isEdit ? 'edit-provider-url' : 'new-provider-url',
apiKeyWrapperId: isEdit ? 'edit-openai-keys-wrapper' : 'new-openai-keys-wrapper',
headerWrapperId: isEdit ? 'edit-openai-headers-wrapper' : 'new-openai-headers-wrapper',
modelWrapperId: isEdit ? 'edit-provider-models-wrapper' : 'new-provider-models-wrapper'
};
};
function ensureOpenAIModelDiscoveryCard(manager) {
let overlay = document.getElementById('openai-model-discovery');
if (overlay) {
return overlay;
}
overlay = document.createElement('div');
overlay.id = 'openai-model-discovery';
overlay.className = 'model-discovery-overlay';
overlay.innerHTML = `
<div class="model-discovery-card">
<div class="model-discovery-header">
<div class="model-discovery-title">
<h3>${i18n.t('ai_providers.openai_models_fetch_title')}</h3>
<p class="form-hint">${i18n.t('ai_providers.openai_models_fetch_hint')}</p>
</div>
<button type="button" class="btn btn-secondary" id="openai-model-discovery-back">${i18n.t('ai_providers.openai_models_fetch_back')}</button>
</div>
<div class="form-group">
<label>${i18n.t('ai_providers.openai_models_fetch_url_label')}</label>
<div class="input-group">
<input type="text" id="openai-model-discovery-url" readonly>
<button type="button" class="btn btn-secondary" id="openai-model-discovery-refresh">${i18n.t('ai_providers.openai_models_fetch_refresh')}</button>
</div>
</div>
<div id="openai-model-discovery-status" class="model-discovery-status"></div>
<div id="openai-model-discovery-list" class="model-discovery-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="openai-model-discovery-cancel">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" id="openai-model-discovery-apply">${i18n.t('ai_providers.openai_models_fetch_apply')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const bind = (id, handler) => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('click', handler);
}
};
bind('openai-model-discovery-back', () => manager.closeOpenAIModelDiscovery());
bind('openai-model-discovery-cancel', () => manager.closeOpenAIModelDiscovery());
bind('openai-model-discovery-refresh', () => manager.refreshOpenAIModelDiscovery());
bind('openai-model-discovery-apply', () => manager.applyOpenAIModelDiscoverySelection());
return overlay;
}
export function setOpenAIModelDiscoveryStatus(message = '', type = 'info') {
const status = document.getElementById('openai-model-discovery-status');
if (!status) return;
status.textContent = message;
status.className = `model-discovery-status ${type}`;
}
export function renderOpenAIModelDiscoveryList(models = []) {
const list = document.getElementById('openai-model-discovery-list');
if (!list) return;
if (!models.length) {
list.innerHTML = `
<div class="model-discovery-empty">
<i class="fas fa-box-open"></i>
<span>${i18n.t('ai_providers.openai_models_fetch_empty')}</span>
</div>
`;
return;
}
list.innerHTML = models.map((model, index) => {
const name = this.escapeHtml(model.name || '');
const alias = model.alias ? `<span class="model-discovery-alias">${this.escapeHtml(model.alias)}</span>` : '';
const desc = model.description ? `<div class="model-discovery-desc">${this.escapeHtml(model.description)}</div>` : '';
return `
<label class="model-discovery-row">
<input type="checkbox" class="model-discovery-checkbox" data-model-index="${index}" checked>
<div class="model-discovery-meta">
<div class="model-discovery-name">${name} ${alias}</div>
${desc}
</div>
</label>
`;
}).join('');
}
export function openOpenAIModelDiscovery(mode = 'new') {
const context = getOpenAIContext(mode);
const baseInput = document.getElementById(context.baseUrlInputId);
const baseUrl = baseInput ? baseInput.value.trim() : '';
if (!baseUrl) {
this.showNotification(i18n.t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
const endpoint = buildModelEndpoint(baseUrl);
if (!endpoint) {
this.showNotification(i18n.t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
const apiKeyEntries = this.collectApiKeyEntryInputs(context.apiKeyWrapperId);
const firstKey = Array.isArray(apiKeyEntries) ? apiKeyEntries.find(entry => entry && entry['api-key']) : null;
const headers = this.collectHeaderInputs(context.headerWrapperId) || {};
if (firstKey && !headers.Authorization && !headers.authorization) {
headers.Authorization = `Bearer ${firstKey['api-key']}`;
}
ensureOpenAIModelDiscoveryCard(this).classList.add('active');
this.openAIModelDiscoveryContext = {
...context,
endpoint,
headers,
discoveredModels: []
};
const urlInput = document.getElementById('openai-model-discovery-url');
if (urlInput) {
urlInput.value = endpoint;
}
this.renderOpenAIModelDiscoveryList([]);
this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_loading'), 'info');
this.refreshOpenAIModelDiscovery();
}
export async function refreshOpenAIModelDiscovery() {
const context = this.openAIModelDiscoveryContext;
if (!context || !context.endpoint) {
return;
}
this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_loading'), 'info');
const list = document.getElementById('openai-model-discovery-list');
if (list) {
list.innerHTML = '<div class="model-discovery-empty"><i class="fas fa-spinner fa-spin"></i></div>';
}
try {
const response = await fetch(context.endpoint, {
headers: context.headers || {}
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
let data;
try {
data = await response.json();
} catch (err) {
const text = await response.text();
throw new Error(text || err.message || 'Invalid JSON');
}
const models = normalizeModelList(data);
context.discoveredModels = models;
this.renderOpenAIModelDiscoveryList(models);
if (!models.length) {
this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_empty'), 'warning');
} else {
this.setOpenAIModelDiscoveryStatus('', 'info');
}
} catch (error) {
context.discoveredModels = [];
this.renderOpenAIModelDiscoveryList([]);
this.setOpenAIModelDiscoveryStatus(`${i18n.t('ai_providers.openai_models_fetch_error')}: ${error.message}`, 'error');
}
}
export function applyOpenAIModelDiscoverySelection() {
const context = this.openAIModelDiscoveryContext;
if (!context || !Array.isArray(context.discoveredModels) || !context.discoveredModels.length) {
this.closeOpenAIModelDiscovery();
return;
}
const list = document.getElementById('openai-model-discovery-list');
if (!list) {
this.closeOpenAIModelDiscovery();
return;
}
const selectedIndices = Array.from(list.querySelectorAll('.model-discovery-checkbox:checked'))
.map(input => Number.parseInt(input.getAttribute('data-model-index') || '-1', 10))
.filter(index => Number.isFinite(index) && index >= 0 && index < context.discoveredModels.length);
const selectedModels = selectedIndices.map(index => context.discoveredModels[index]);
if (!selectedModels.length) {
this.closeOpenAIModelDiscovery();
return;
}
const existing = this.collectModelInputs(context.modelWrapperId);
const mergedMap = new Map();
existing.forEach(model => {
if (model && model.name) {
mergedMap.set(model.name, { ...model });
}
});
let addedCount = 0;
selectedModels.forEach(model => {
const name = model && model.name;
if (!name) return;
if (!mergedMap.has(name)) {
mergedMap.set(name, { name, ...(model.alias ? { alias: model.alias } : {}) });
addedCount++;
}
});
this.populateModelFields(context.modelWrapperId, Array.from(mergedMap.values()));
this.closeOpenAIModelDiscovery();
if (addedCount > 0) {
const template = i18n.t('ai_providers.openai_models_fetch_added');
const message = template.replace('{count}', addedCount);
this.showNotification(message, 'success');
}
}
export function closeOpenAIModelDiscovery() {
const overlay = document.getElementById('openai-model-discovery');
if (overlay) {
overlay.classList.remove('active');
}
this.openAIModelDiscoveryContext = null;
}
export function showAddOpenAIProviderModal() { export function showAddOpenAIProviderModal() {
const modal = document.getElementById('modal'); const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body'); const modalBody = document.getElementById('modal-body');
@@ -1009,7 +1302,12 @@ export function showAddOpenAIProviderModal() {
<label>${i18n.t('ai_providers.openai_add_modal_models_label')}</label> <label>${i18n.t('ai_providers.openai_add_modal_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.openai_models_hint')}</p> <p class="form-hint">${i18n.t('ai_providers.openai_models_hint')}</p>
<div id="new-provider-models-wrapper" class="model-input-list"></div> <div id="new-provider-models-wrapper" class="model-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('new-provider-models-wrapper')">${i18n.t('ai_providers.openai_models_add_btn')}</button> <div class="model-actions-inline">
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('new-provider-models-wrapper')">${i18n.t('ai_providers.openai_models_add_btn')}</button>
<button type="button" class="btn btn-secondary" onclick="manager.openOpenAIModelDiscovery('new')">
<i class="fas fa-download"></i> ${i18n.t('ai_providers.openai_models_fetch_button')}
</button>
</div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button> <button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
@@ -1104,7 +1402,12 @@ export function editOpenAIProvider(index, provider) {
<label>${i18n.t('ai_providers.openai_edit_modal_models_label')}</label> <label>${i18n.t('ai_providers.openai_edit_modal_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.openai_models_hint')}</p> <p class="form-hint">${i18n.t('ai_providers.openai_models_hint')}</p>
<div id="edit-provider-models-wrapper" class="model-input-list"></div> <div id="edit-provider-models-wrapper" class="model-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('edit-provider-models-wrapper')">${i18n.t('ai_providers.openai_models_add_btn')}</button> <div class="model-actions-inline">
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('edit-provider-models-wrapper')">${i18n.t('ai_providers.openai_models_add_btn')}</button>
<button type="button" class="btn btn-secondary" onclick="manager.openOpenAIModelDiscovery('edit')">
<i class="fas fa-download"></i> ${i18n.t('ai_providers.openai_models_fetch_button')}
</button>
</div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button> <button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
@@ -1292,6 +1595,12 @@ export const aiProvidersModule = {
editOpenAIProvider, editOpenAIProvider,
updateOpenAIProvider, updateOpenAIProvider,
deleteOpenAIProvider, deleteOpenAIProvider,
openOpenAIModelDiscovery,
refreshOpenAIModelDiscovery,
renderOpenAIModelDiscoveryList,
setOpenAIModelDiscoveryStatus,
applyOpenAIModelDiscoverySelection,
closeOpenAIModelDiscovery,
addModelField, addModelField,
populateModelFields, populateModelFields,
collectModelInputs, collectModelInputs,

View File

@@ -2251,6 +2251,148 @@ input:checked+.slider:before {
overflow: hidden; overflow: hidden;
} }
.model-actions-inline {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
}
.model-discovery-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: none;
align-items: center;
justify-content: center;
z-index: 1100;
padding: 18px;
}
.model-discovery-overlay.active {
display: flex;
}
.model-discovery-card {
background: var(--bg-secondary);
color: var(--text-primary);
width: 95%;
max-width: 760px;
max-height: 90vh;
border-radius: 14px;
box-shadow: var(--shadow-modal);
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
overflow: hidden;
}
.model-discovery-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.model-discovery-header h3 {
margin: 0 0 6px 0;
}
.model-discovery-status {
min-height: 22px;
font-size: 0.95rem;
color: var(--text-secondary);
}
.model-discovery-status.error {
color: #dc2626;
}
.model-discovery-status.warning {
color: #d97706;
}
.model-discovery-list {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px;
overflow-y: auto;
max-height: 46vh;
}
.model-discovery-row {
display: flex;
gap: 12px;
padding: 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.model-discovery-row:hover {
background: rgba(59, 130, 246, 0.08);
}
.model-discovery-checkbox {
margin-top: 4px;
}
.model-discovery-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.model-discovery-name {
font-weight: 700;
color: var(--text-primary);
}
.model-discovery-alias {
margin-left: 6px;
padding: 2px 8px;
border-radius: 999px;
background: #eef2ff;
color: #4338ca;
font-size: 0.85rem;
font-weight: 600;
}
.model-discovery-desc {
color: var(--text-secondary);
font-size: 0.9rem;
}
.model-discovery-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px 12px;
color: var(--text-secondary);
}
[data-theme="dark"] .model-discovery-card {
background: var(--bg-secondary);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
}
[data-theme="dark"] .model-discovery-list {
background: var(--bg-secondary);
border-color: var(--border-color);
}
[data-theme="dark"] .model-discovery-row:hover {
background: rgba(96, 165, 250, 0.18);
}
[data-theme="dark"] .model-discovery-alias {
background: #312e81;
color: #c7d2fe;
}
@keyframes modalSlideIn { @keyframes modalSlideIn {
from { from {
opacity: 0; opacity: 0;