/* ──────────────────────────────────────────────────────────────────────────── * SOY AI Labs — 표준 AI 모델 셀렉터 로더 * * 관리자 페이지(`관리 > AI 설정 > 사용 가능한 AI 목록`)에서 활성화한 모델만 * 화면 드롭다운에 노출합니다. 모든 사용자/창작 화면은 이 모듈만 사용해야 합니다. * * 표준 API: GET /api/enabled-models * 응답: { * success: true, * models: [{ name: "gemini:xxx" | "ollama-xxx", type: "gemini" | "ollama" }], * default_analysis_model: "...", * default_answer_model: "..." * } * * 사용 예: * SoyEnabledModels.populate(document.getElementById('modelSelect'), { * kind: 'analysis', // 'analysis' | 'answer' (기본값 자동선택용) * preset: 'gemini:xxx', // 우선 선택할 모델 (선택) * placeholder: '모델 선택', // 첫 옵션의 라벨 (선택, 없으면 옵션 미추가) * includeAll: false, // '전체 모델' 옵션 추가 여부 (기본 false) * strip: true, // gemini: 접두어 제거하여 표시 (기본 true) * typeFilter: ['gemini'], // 특정 타입만 표시 (선택, 예: ['gemini']) * onChange: function (val) { ... } // 선택 변경 콜백 (선택) * }); * ──────────────────────────────────────────────────────────────────────────── */ (function (global) { 'use strict'; var STATE = { cache: null, inflight: null }; function fetchEnabledModels(force) { if (!force && STATE.cache) return Promise.resolve(STATE.cache); if (STATE.inflight) return STATE.inflight; STATE.inflight = fetch('/api/enabled-models', { credentials: 'include' }) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (data) { STATE.cache = { models: Array.isArray(data.models) ? data.models : [], default_analysis_model: data.default_analysis_model || '', default_answer_model: data.default_answer_model || '', }; return STATE.cache; }) .finally(function () { STATE.inflight = null; }); return STATE.inflight; } function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (m) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]; }); } function displayName(name, strip) { if (!name) return ''; return (strip !== false && name.indexOf('gemini:') === 0) ? name.substring(7) : name; } function buildHtml(models, opts) { var parts = []; if (opts.placeholder) { parts.push(''); } if (opts.includeAll) { parts.push(''); } var gemini = models.filter(function (m) { return m.type === 'gemini'; }); var ollama = models.filter(function (m) { return m.type === 'ollama'; }); var etc = models.filter(function (m) { return m.type !== 'gemini' && m.type !== 'ollama'; }); function group(label, list) { if (!list.length) return; parts.push(''); list.forEach(function (m) { parts.push(''); }); parts.push(''); } group('✨ Gemini', gemini); group('🤖 Ollama', ollama); group('기타', etc); return parts.join(''); } /** * 셀렉트 요소를 표준 모델 목록으로 채웁니다. * 반환: Promise — 성공 시 선택된 모델명(string|null)을 resolve */ function populate(selectEl, opts) { opts = opts || {}; if (!selectEl) return Promise.resolve(null); var prevPlaceholder = selectEl.innerHTML; selectEl.disabled = true; if (!selectEl.dataset.soyOriginalPlaceholder) { selectEl.innerHTML = ''; } return fetchEnabledModels(opts.force).then(function (data) { // 타입 필터 적용 (예: 콘티 어시스트는 ['gemini']만) var models = data.models; if (Array.isArray(opts.typeFilter) && opts.typeFilter.length) { models = models.filter(function (m) { return opts.typeFilter.indexOf(m.type) !== -1; }); } var html = ''; if (!models.length) { html = ''; selectEl.innerHTML = html; selectEl.disabled = false; return null; } html = buildHtml(models, opts); selectEl.innerHTML = html; // 기본 선택값 결정: preset → kind 기반 default → 첫 옵션 var fallbackKey = (opts.kind === 'analysis') ? 'default_analysis_model' : 'default_answer_model'; var candidates = [opts.preset, data[fallbackKey]].filter(Boolean); var chosen = null; for (var i = 0; i < candidates.length; i++) { if (models.some(function (m) { return m.name === candidates[i]; })) { chosen = candidates[i]; break; } } if (!chosen && opts.includeAll) chosen = 'all'; if (chosen) selectEl.value = chosen; else if (!opts.placeholder && selectEl.options.length > 0) { // 플레이스홀더가 없으면 첫 실제 옵션 선택 for (var j = 0; j < selectEl.options.length; j++) { if (selectEl.options[j].value) { selectEl.value = selectEl.options[j].value; break; } } chosen = selectEl.value || null; } selectEl.disabled = false; if (typeof opts.onChange === 'function' && chosen) { try { opts.onChange(chosen); } catch (e) { /* ignore */ } } return chosen; }).catch(function (err) { console.warn('[SoyEnabledModels] 로드 실패:', err); selectEl.innerHTML = prevPlaceholder.indexOf('option') === -1 ? '' : prevPlaceholder; selectEl.disabled = false; return null; }); } /** 여러 셀렉트를 한 번의 API 호출로 동시에 채웁니다. */ function populateAll(targets) { return fetchEnabledModels().then(function () { return Promise.all((targets || []).map(function (t) { return populate(t.element, t.options || {}); })); }); } function refresh() { STATE.cache = null; return fetchEnabledModels(true); } global.SoyEnabledModels = { populate: populate, populateAll: populateAll, fetch: fetchEnabledModels, refresh: refresh, }; })(window);