feat(web): redesign multi-models console
Overhauls the Models tab in the Web Console with a vendor-first layout and ships a runtime-accurate dispatcher view for vision and image generation.
@@ -137,6 +137,11 @@
|
|||||||
<i class="fas fa-sliders item-icon text-xs w-5 text-center"></i>
|
<i class="fas fa-sliders item-icon text-xs w-5 text-center"></i>
|
||||||
<span data-i18n="menu_config">配置</span>
|
<span data-i18n="menu_config">配置</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||||
|
data-view="models">
|
||||||
|
<i class="fas fa-microchip item-icon text-xs w-5 text-center"></i>
|
||||||
|
<span data-i18n="menu_models">模型</span>
|
||||||
|
</a>
|
||||||
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||||
data-view="skills">
|
data-view="skills">
|
||||||
<i class="fas fa-bolt item-icon text-xs w-5 text-center"></i>
|
<i class="fas fa-bolt item-icon text-xs w-5 text-center"></i>
|
||||||
@@ -850,6 +855,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================== -->
|
||||||
|
<!-- VIEW: Models -->
|
||||||
|
<!-- ====================================================== -->
|
||||||
|
<div id="view-models" class="view">
|
||||||
|
<!-- Tailwind JIT safelist: capability-card icon colors are
|
||||||
|
emitted from JS template strings. Listing them here
|
||||||
|
(display:none) guarantees the CDN-side compiler picks
|
||||||
|
them up regardless of render timing. -->
|
||||||
|
<div class="hidden bg-blue-50 dark:bg-blue-900/30 text-blue-500
|
||||||
|
bg-orange-50 dark:bg-orange-900/30 text-orange-500
|
||||||
|
bg-purple-50 dark:bg-purple-900/30 text-purple-500
|
||||||
|
bg-amber-50 dark:bg-amber-900/30 text-amber-500
|
||||||
|
bg-primary-50 dark:bg-primary-900/30 text-primary-500"></div>
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="models_title">模型管理</h2>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="models_desc">统一管理对话、视觉、语音、向量、图像、搜索能力</p>
|
||||||
|
</div>
|
||||||
|
<button id="models-add-vendor-btn" onclick="openVendorModal('')"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600
|
||||||
|
text-white text-sm font-medium cursor-pointer transition-colors duration-150">
|
||||||
|
<i class="fas fa-plus text-xs"></i>
|
||||||
|
<span data-i18n="models_add_vendor">添加厂商</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="models-loading" class="flex items-center gap-2 py-12 justify-center text-slate-400 dark:text-slate-500 text-sm">
|
||||||
|
<i class="fas fa-spinner fa-spin text-xs"></i><span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div id="models-content" class="grid gap-6 hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ====================================================== -->
|
<!-- ====================================================== -->
|
||||||
<!-- VIEW: Channels -->
|
<!-- VIEW: Channels -->
|
||||||
<!-- ====================================================== -->
|
<!-- ====================================================== -->
|
||||||
@@ -959,7 +999,7 @@
|
|||||||
</div><!-- /app -->
|
</div><!-- /app -->
|
||||||
|
|
||||||
<!-- Confirm Dialog -->
|
<!-- Confirm Dialog -->
|
||||||
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||||
w-full max-w-sm mx-4 overflow-hidden">
|
w-full max-w-sm mx-4 overflow-hidden">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
@@ -984,6 +1024,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Vendor Credentials Modal -->
|
||||||
|
<div id="vendor-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||||
|
w-full max-w-md mx-4 overflow-hidden">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-5">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fas fa-key text-primary-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 id="vendor-modal-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
|
||||||
|
<p id="vendor-modal-subtitle" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider selector (only visible when adding via top button) -->
|
||||||
|
<div id="vendor-modal-picker-wrap" class="mb-4 hidden">
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_provider">厂商</label>
|
||||||
|
<div id="vendor-modal-picker" class="cfg-dropdown" tabindex="0">
|
||||||
|
<div class="cfg-dropdown-selected">
|
||||||
|
<span class="cfg-dropdown-text">--</span>
|
||||||
|
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||||||
|
</div>
|
||||||
|
<div class="cfg-dropdown-menu"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
|
||||||
|
<input id="vendor-modal-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||||
|
placeholder="sk-...">
|
||||||
|
</div>
|
||||||
|
<div id="vendor-modal-base-wrap">
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
|
||||||
|
<input id="vendor-modal-base" type="text"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||||
|
placeholder="https://...../v1">
|
||||||
|
<p id="vendor-modal-base-hint" class="mt-1.5 text-xs text-slate-400 dark:text-slate-500 hidden">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i><span data-i18n="models_base_default_hint">留空将使用官方默认地址</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5">
|
||||||
|
<button id="vendor-modal-clear"
|
||||||
|
class="px-3 py-2 rounded-lg text-xs font-medium
|
||||||
|
text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||||
|
cursor-pointer transition-colors duration-150 hidden"
|
||||||
|
data-i18n="models_clear_credential">清除凭据</button>
|
||||||
|
<span id="vendor-modal-status"
|
||||||
|
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-center"></span>
|
||||||
|
<button id="vendor-modal-cancel"
|
||||||
|
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||||
|
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||||
|
hover:bg-slate-50 dark:hover:bg-white/5
|
||||||
|
cursor-pointer transition-colors duration-150"
|
||||||
|
data-i18n="cancel">取消</button>
|
||||||
|
<button id="vendor-modal-save"
|
||||||
|
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||||
|
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
data-i18n="save">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script defer src="assets/js/console.js"></script>
|
<script defer src="assets/js/console.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -725,6 +725,58 @@
|
|||||||
background: rgba(74, 190, 110, 0.15);
|
background: rgba(74, 190, 110, 0.15);
|
||||||
color: #74E9A4;
|
color: #74E9A4;
|
||||||
}
|
}
|
||||||
|
/* When an item carries a hint (e.g. brand alias next to a technical model
|
||||||
|
id), label/hint are split into two spans so the hint sits on the right in
|
||||||
|
a dim, smaller weight. Without a hint the row stays a plain text node and
|
||||||
|
uses the default ellipsis behaviour, so no layout regressions for old call
|
||||||
|
sites. */
|
||||||
|
.cfg-dropdown-label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.cfg-dropdown-hint {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.dark .cfg-dropdown-hint {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.cfg-dropdown-item.active .cfg-dropdown-hint {
|
||||||
|
/* Tint the hint toward the brand colour on the active row so it doesn't
|
||||||
|
fight with the highlighted label tone. */
|
||||||
|
color: rgba(34, 133, 71, 0.65);
|
||||||
|
}
|
||||||
|
.dark .cfg-dropdown-item.active .cfg-dropdown-hint {
|
||||||
|
color: rgba(116, 233, 164, 0.6);
|
||||||
|
}
|
||||||
|
/* The active row gets a trailing brand-green checkmark via a Font Awesome
|
||||||
|
pseudo-element so every dropdown (chat / vision / image / asr / tts / etc.)
|
||||||
|
surfaces "this is what's currently selected" without per-call JS plumbing.
|
||||||
|
When a hint is present, the ✓ sits to its right with a small gap; without
|
||||||
|
a hint, margin-left:auto pushes the ✓ flush against the right edge. */
|
||||||
|
.cfg-dropdown-item.active::after {
|
||||||
|
content: '\f00c'; /* FontAwesome check glyph */
|
||||||
|
font-family: 'Font Awesome 6 Free', 'Font Awesome 5 Free', 'FontAwesome';
|
||||||
|
font-weight: 900;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #4abe6e;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cfg-dropdown-item.active:has(.cfg-dropdown-hint)::after {
|
||||||
|
/* When hint occupies the auto-margin slot, the ✓ no longer benefits
|
||||||
|
from `margin-left: auto`; replace it with a small fixed gap so the
|
||||||
|
✓ trails the hint cleanly. */
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* API Key masking via CSS (avoids browser password prompts) */
|
/* API Key masking via CSS (avoids browser password prompts) */
|
||||||
.cfg-key-masked {
|
.cfg-key-masked {
|
||||||
@@ -732,6 +784,77 @@
|
|||||||
text-security: disc;
|
text-security: disc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Provider logo image — vendors flagged as `provider-logo-invert-dark`
|
||||||
|
ship a black wordmark that disappears on the dark canvas; we invert their
|
||||||
|
luminance only in dark mode so the brand stays recognizable without
|
||||||
|
touching multi-color marks like Google/MiniMax. */
|
||||||
|
.provider-logo-img {
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
.dark .provider-logo-invert-dark {
|
||||||
|
filter: invert(1) brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Models page — provider dropdown rows.
|
||||||
|
Configured rows look like ordinary picker entries; the .active row's
|
||||||
|
trailing brand-green ✓ already announces "this is what's selected"
|
||||||
|
(handled globally by .cfg-dropdown-item.active::after above).
|
||||||
|
Unconfigured rows are visually subdued and carry a trailing gear icon
|
||||||
|
as a "click to set up" affordance. */
|
||||||
|
.cap-provider-label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.cap-provider-gear {
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cap-provider-item.cap-provider-unconfigured {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.dark .cap-provider-item.cap-provider-unconfigured {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.cap-provider-item.cap-provider-unconfigured:hover {
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.dark .cap-provider-item.cap-provider-unconfigured:hover {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.cap-provider-item.cap-provider-unconfigured:hover .cap-provider-gear {
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.dark .cap-provider-item.cap-provider-unconfigured:hover .cap-provider-gear {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
/* If the active row ever lands on an unconfigured vendor (defensive — the
|
||||||
|
click handler normally diverts to the modal), suppress the global ✓ so
|
||||||
|
the gear remains the sole trailing icon and the row keeps reading as
|
||||||
|
"needs setup" rather than "already selected". */
|
||||||
|
.cap-provider-item.cap-provider-unconfigured.active::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Add vendor" modal picker — each configured row carries a static
|
||||||
|
brand-green ✓ via decorateVendorModalPicker so users can see what's set
|
||||||
|
up at a glance. The active row's global ✓ is suppressed here to avoid
|
||||||
|
showing two checks side by side on configured + selected rows. */
|
||||||
|
.vendor-picker-item.active::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
.vendor-picker-configured-mark {
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #4abe6e;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Chat Input */
|
/* Chat Input */
|
||||||
#chat-input {
|
#chat-input {
|
||||||
resize: none; height: 42px; max-height: 180px;
|
resize: none; height: 42px; max-height: 180px;
|
||||||
|
|||||||
1
channel/web/static/logos/claudeAPI.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251656961" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="18432" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M252.8 652.8l167.893333-94.293333 2.773334-8.106667-2.773334-4.48h-8.106666l-28.16-1.706667-96-2.56-83.2-3.413333-80.64-4.266667-20.266667-4.266666L85.333333 504.746667l1.92-12.586667 17.066667-11.52 24.32 2.133333 53.973333 3.626667 81.066667 5.546667 58.666667 3.413333 87.04 9.173333h13.866666l1.92-5.546666-4.693333-3.413334-3.626667-3.413333-83.84-56.746667-90.666666-60.16-47.573334-34.56-25.813333-17.493333-13.013333-16.426667-5.546667-35.84 23.253333-25.813333 31.36 2.133333 7.893334 2.133334 31.786666 24.32 67.84 52.48L401.066667 391.466667l13.013333 10.88 5.12-3.626667 0.64-2.56-5.76-9.813333-48.213333-87.04L314.453333 210.773333l-22.826666-36.693333-5.973334-21.973333a107.861333 107.861333 0 0 1-3.626666-26.026667l26.666666-36.053333L323.413333 85.333333l35.413334 4.693334 14.933333 13.013333 21.973333 50.346667 35.626667 79.36 55.253333 107.733333 16.213334 32 8.746666 29.653333 3.2 9.173334h5.546667v-5.12l4.48-60.8 8.32-74.453334 8.106667-96 2.773333-27.093333 13.44-32.426667 26.666667-17.493333 20.693333 10.026667 17.066667 24.32-2.346667 15.786666-10.24 65.92-19.84 103.253334-13.013333 69.12h7.466666l8.746667-8.746667 34.986667-46.506667 58.666666-73.386666 26.026667-29.226667 30.293333-32.213333 19.413334-15.36h36.693333l27.093333 40.106666-12.16 41.386667-37.76 48-31.36 40.533333-45.013333 60.586667-28.16 48.426667 2.56 3.84 6.613333-0.64 101.546667-21.546667 54.826667-10.026667 65.493333-11.306666 29.653333 13.866666 3.2 14.08-11.733333 28.8-69.973333 17.28-82.133334 16.426667-122.24 29.013333-1.493333 1.066667 1.706667 2.133333 55.04 5.12 23.466666 1.28h57.6l107.306667 7.893334 28.16 18.56 16.853333 22.613333-2.773333 17.28-43.306667 21.973333-58.24-13.866666-136.106666-32.426667-46.72-11.733333h-6.4v3.84l38.826666 37.973333 71.253334 64.426667 89.173333 82.986666 4.48 20.48-11.52 16.213334-12.16-1.706667-78.506667-58.88-30.293333-26.666667-68.48-57.6h-4.48v5.973334l15.786667 23.04 83.413333 125.226666 4.266667 38.4-5.973334 12.586667-21.546666 7.466667-23.68-4.266667-48.853334-68.48-50.346666-77.226667-40.533334-69.12-4.906666 2.773334-23.893334 258.133333-11.306666 13.226667-26.026667 10.026666-21.546667-16.426666-11.52-26.666667 11.52-52.48 13.866667-68.48 11.306667-54.4 10.24-67.626667 5.973333-22.4-0.426667-1.493333-4.906666 0.64-50.986667 69.973333-77.653333 104.746667-61.44 65.706667-14.72 5.76-25.386667-13.226667 2.346667-23.466667 14.293333-20.906666 84.906667-107.946667 51.2-66.986667 33.066666-38.613333v-5.546667h-2.133333l-225.493333 146.56-40.106667 5.12-17.28-16.213333 2.133333-26.666667 8.106667-8.746666 67.84-46.72h-0.213333l0.853333 0.853333z" fill="#D97757" p-id="18433"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
10
channel/web/static/logos/custom.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="200" height="200" fill="none" stroke="#475569" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Horizontal slider tracks -->
|
||||||
|
<line x1="4" y1="7" x2="20" y2="7"/>
|
||||||
|
<line x1="4" y1="12" x2="20" y2="12"/>
|
||||||
|
<line x1="4" y1="17" x2="20" y2="17"/>
|
||||||
|
<!-- Knobs (filled circles) -->
|
||||||
|
<circle cx="9" cy="7" r="2.2" fill="#475569" stroke="none"/>
|
||||||
|
<circle cx="15" cy="12" r="2.2" fill="#475569" stroke="none"/>
|
||||||
|
<circle cx="7" cy="17" r="2.2" fill="#475569" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 573 B |
1
channel/web/static/logos/dashscope.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251621200" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="17444" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M1019.364785 620.816931L891.797142 397.807295 946.450846 293.15069a29.097778 29.097778 0 0 0 6.399732-36.393472l-70.184053-126.586684a30.078737 30.078737 0 0 0-24.574968-13.652427H597.4945L539.171949 14.549389a27.348852 27.348852 0 0 0-20.906122-14.549389H380.628607a29.139776 29.139776 0 0 0-24.616967 14.549389v5.545767L225.797108 243.062793H100.919352a29.182775 29.182775 0 0 0-25.513928 13.653427L3.428446 384.11187a32.766624 32.766624 0 0 0 0 29.182775L132.831012 638.096205 74.508461 740.064923a32.766624 32.766624 0 0 0 0 29.05478l66.514207 116.561105a29.905744 29.905744 0 0 0 25.513929 14.505391H427.132654l62.845361 109.222414A30.078737 30.078737 0 0 0 512.762058 1024H660.382859a29.139776 29.139776 0 0 0 24.574968-14.549389l128.463606-224.843558h114.76818a31.91366 31.91366 0 0 0 24.660965-15.444352l66.471208-117.414069a28.158818 28.158818 0 0 0 0-30.9747l0.042999 0.042999z m-161.273228 14.591387L791.57735 512.490479 518.265827 993.964261l-74.748861-122.87484h-273.268525l65.618244-119.205994h139.386147L101.856313 272.244568h143.055993L380.671605 30.121735l68.34913 119.247993-70.184053 122.87484H925.501726l-69.202094 121.936879 137.594222 241.183873H858.134555z" fill="#605BEC" p-id="17445"></path><path d="M499.962596 699.320634l174.371677-274.719464H324.694955z" fill="#605BEC" p-id="17446"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
1
channel/web/static/logos/deepseek.svg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
1
channel/web/static/logos/doubao.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779261485522" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5381" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M958.976 439.808C804.864 336.896 642.56 321.536 642.56 321.536s8.192 235.008-10.752 306.176c-0.512 9.728-11.776 75.264-43.008 157.696-10.752 28.16-24.064 55.296-39.424 81.408-40.96 74.24-89.6 127.488-89.6 127.488 119.808-48.64 205.312-92.672 309.76-175.616 122.88-96.768 229.376-254.464 189.44-378.88z" fill="#37E1BE" p-id="5382"></path><path d="M329.728 395.776c158.208-100.864 308.736-78.848 312.32-74.752 0.512 0.512 1.024 0.512 1.024 0.512 0-14.336-6.656-60.928-13.312-106.496-11.776-60.928-22.528-124.928-23.04-133.632-170.496-139.264-356.864-78.336-448 25.6-61.44 70.144-103.424 169.984-102.4 224.256V762.88c0.512-12.8 1.536-20.48 2.048-20.48 17.92-197.12 271.36-346.624 271.36-346.624z" fill="#A569FF" p-id="5383"></path><path d="M792.064 272.384c-41.984-43.52-87.552-88.576-122.368-125.44-33.28-34.816-59.392-60.928-62.976-65.536 0.512 8.704 11.264 72.704 23.04 133.632 6.656 45.568 12.8 92.672 13.312 106.496 0 0 162.304 15.36 316.416 118.272-0.512 0-83.456-80.384-167.424-167.424zM549.888 866.816c-2.56 1.024-198.656 107.008-292.352-30.72-20.992-30.72-31.744-68.096-33.28-106.496-3.072-74.752 5.12-227.84 105.472-333.824 0 0-253.44 149.504-270.848 346.624-0.512 0.512-2.048 8.192-2.048 20.48-1.024 32.768 4.608 98.304 43.008 155.136 52.224 78.336 193.024 138.752 328.192 85.504l33.28-9.728c-1.024 0.512 47.616-52.224 88.576-126.976z" fill="#1E37FC" p-id="5384"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
channel/web/static/logos/gemini.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251750646" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="29551" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M214.101333 512c0-32.512 5.546667-63.701333 15.36-92.928L57.173333 290.218667A491.861333 491.861333 0 0 0 4.693333 512c0 79.701333 18.858667 154.88 52.394667 221.610667l172.202667-129.066667A290.56 290.56 0 0 1 214.101333 512" fill="#FBBC05" p-id="29552"></path><path d="M516.693333 216.192c72.106667 0 137.258667 25.002667 188.458667 65.962667L854.101333 136.533333C763.349333 59.178667 646.997333 11.392 516.693333 11.392c-202.325333 0-376.234667 113.28-459.52 278.826667l172.373334 128.853333c39.68-118.016 152.832-202.88 287.146666-202.88" fill="#EA4335" p-id="29553"></path><path d="M516.693333 807.808c-134.357333 0-247.509333-84.864-287.232-202.88l-172.288 128.853333c83.242667 165.546667 257.152 278.826667 459.52 278.826667 124.842667 0 244.053333-43.392 333.568-124.757333l-163.584-123.818667c-46.122667 28.458667-104.234667 43.776-170.026666 43.776" fill="#34A853" p-id="29554"></path><path d="M1005.397333 512c0-29.568-4.693333-61.44-11.648-91.008H516.650667V614.4h274.602666c-13.696 65.962667-51.072 116.650667-104.533333 149.632l163.541333 123.818667c93.994667-85.418667 155.136-212.650667 155.136-375.850667" fill="#4285F4" p-id="29555"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
channel/web/static/logos/linkai.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
1
channel/web/static/logos/minimax.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251514432" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11888" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M415.392 475.808v329.984c-22.304 111.744-170.56 82.944-171.2 1.92-0.672-101.824 0-202.976 0-304.064v-117.184c0-14.656-3.2-26.24-16-35.392-24.96-18.72-54.944 3.264-55.584 30.208-1.408 36.16-0.704 71.616-1.408 107.264 0 28.16 0 55.52 0.64 83.648-18.368 123.776-168.32 103.232-171.808 0.704V487.04c0-28.032 54.944-34.624 52.256 7.36-1.792 20.8-0.64 42.272-1.344 62.912-0.64 36.8 55.648 61.6 68.896 1.408 0.64-49.632 0.64-99.264 0.64-149.344 0-62.752 17.824-113.856 84.352-118.624 28.8-2.56 47.968 9.504 66.336 30.304 7.04 7.36 23.68 30.72 24.32 56.16 0 23.456 0.64 46.752 0.64 70.464 0 46.72-0.64 93.76-0.64 140.48 0 30.304 0.64 60.256 0.64 89.856 0 37.536 0 75.552-0.64 113.152-0.64 48.864 58.816 48.16 68.352-0.768 0-57.632 0.64-114.56 0.64-172.192 0-141.984-0.64-283.968-0.64-425.856 0-14.72-2.048-55.584 5.76-70.464 41.504-101.12 167.392-56.96 168.544 26.72 2.432 171.52 0 344.896 0.64 516.8 0 59.616-48.416 46.816-51.104 23.488 0-178.88 0-358.4 0.64-537.024-2.368-44.832-68.832-38.72-72.672-6.592-1.28 36.864-0.64 74.4-1.28 111.232v219.008h0.64l0.448 0.256h-0.064z" fill="#D4367A" p-id="11889"></path><path d="M610.016 473.184v242.336V143.648c21.632-112.512 169.824-83.264 170.464-2.176 0.704 101.12 0 202.912 0.704 304 0 38.784 0 77.728-0.64 116.544 0 15.36 3.776 26.176 16.64 36.032 24.32 18.24 54.24-3.2 55.584-30.592 1.344-35.488 0.64-70.976 0.64-107.328V376.96c18.56-123.776 168.128-103.232 171.264-0.704v310.592c0 28.16-54.304 34.848-51.872-7.296 1.472-21.44 0-267.104 0.768-288.64 1.28-36.16-55.712-61.664-68.928-0.768v148.576c0 63.68-17.856 113.92-84.96 119.36-63.264 1.504-88.704-42.24-90.752-86.432V271.328c0-38.24 0-75.552 0.64-113.088 0.64-48.864-58.784-48.864-68.896 0.704V831.36c0 14.592 2.048 55.52-5.184 70.432-41.44 101.056-168 56.864-169.152-26.752v-79.616c3.136-53.6 48.416-40.864 50.464-18.176v94.464c2.432 44.928 68.928 39.488 72.064 6.656 1.344-36.896 1.344-73.728 1.344-111.296v-293.824h-0.192v-0.064z" fill="#ED6D48" p-id="11890"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
1
channel/web/static/logos/moonshot.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251592968" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16416" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M117.9648 684.6464l342.30272 93.57312v75.34592l209.7152 58.5728A428.99456 428.99456 0 0 1 512 942.08c-176.128 0-327.53664-105.8816-394.0352-257.4336zM83.29216 477.42976l407.30624 112.64-9.6256 37.00736-6.0416 35.0208 383.3856 104.96a432.5376 432.5376 0 0 1-65.10592 70.32832l-688.18944-185.9584A429.4656 429.4656 0 0 1 81.92 512c0-11.63264 0.47104-23.1424 1.37216-34.54976z m57.344-182.4768l429.07648 114.21696a279.94112 279.94112 0 0 0-23.06048 35.55328 201.17504 201.17504 0 0 0-14.70464 34.93888l403.08736 110.26432a426.8032 426.8032 0 0 1-23.552 81.7152L86.54848 448.7168a427.25376 427.25376 0 0 1 54.0672-153.76384z m158.47424-156.75392l404.23424 108.31872a190.2592 190.2592 0 0 0-32.80896 24.90368c-9.13408 8.8064-19.8656 21.4016-32.1536 37.74464l285.24544 77.78304c9.216 30.45376 15.03232 61.8496 17.32608 93.5936L156.61056 269.68064a432.27136 432.27136 0 0 1 142.49984-131.4816zM512 81.92c142.90944 0 269.55776 69.71392 347.7504 176.98816L337.26464 118.90688A428.50304 428.50304 0 0 1 512 81.92z" fill="#000000" p-id="16417"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
channel/web/static/logos/openai.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251225589" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9015" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M881.664 431.488a218.88 218.88 0 0 0-18.176-177.088A218.624 218.624 0 0 0 628.992 149.76c-40.576-45.824-100.288-71.424-162.176-71.424a219.136 219.136 0 0 0-208 150.4 215.68 215.68 0 0 0-144 104.512 218.944 218.944 0 0 0 26.688 254.912 218.752 218.752 0 0 0 19.2 177.152 217.088 217.088 0 0 0 234.624 104.512 219.136 219.136 0 0 0 162.112 72.512 219.136 219.136 0 0 0 208-150.4 215.68 215.68 0 0 0 144-104.512 219.008 219.008 0 0 0-27.712-256z m-324.288 454.4a158.08 158.08 0 0 1-103.424-37.376c1.088-1.088 4.288-2.176 5.376-3.2l171.712-99.2a28.16 28.16 0 0 0 13.824-24.512V479.488l72.576 41.6c1.024 0 1.024 1.024 1.024 2.112v200.512a160.512 160.512 0 0 1-161.088 162.112z m-347.712-148.288c-19.2-33.088-25.6-71.488-19.2-108.8 1.088 1.024 3.2 2.176 5.376 3.2l171.712 99.2a25.984 25.984 0 0 0 27.712 0l210.112-121.6v84.224c0 1.152 0 2.176-1.024 2.176L430.464 796.16c-76.8 44.8-176 18.176-220.8-58.624z m-44.736-375.424c19.2-32.64 48.896-57.856 84.224-71.488v204.8c0 9.6 5.376 19.2 13.888 24.512l210.176 121.6-72.576 41.6c-1.024 0-2.112 1.088-2.112 0L224.64 582.912a160.448 160.448 0 0 1-59.776-220.8h0.064z m597.312 138.688l-210.112-121.6 72.512-41.6c1.088 0 2.176-1.088 2.176 0l173.824 100.224a161.088 161.088 0 0 1-25.6 291.2V525.44a26.304 26.304 0 0 0-12.8-24.512z m71.488-108.8a23.232 23.232 0 0 0-5.312-3.2L656.64 289.536a26.048 26.048 0 0 0-27.712 0l-210.176 121.6V326.912c0-1.088 0-2.176 1.088-2.176l173.824-100.224a161.152 161.152 0 0 1 220.8 59.712c19.2 32 25.6 70.4 19.2 107.776z m-454.4 149.248l-72.64-41.6c-1.024 0-1.024-1.088-1.024-2.176V297.088A162.048 162.048 0 0 1 467.84 135.04a158.08 158.08 0 0 1 103.424 37.312 22.848 22.848 0 0 1-5.312 3.2L394.24 274.688a28.16 28.16 0 0 0-13.888 24.512v242.112h-1.088z m39.424-85.312l93.824-54.4 93.888 54.4v107.712l-93.888 54.4-93.824-54.4V456z" fill="#000000" p-id="9016"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
1
channel/web/static/logos/qianfan.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251568791" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14450" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M96.20121136 636.3124965c-0.1472897-113.41305959-0.29457937-226.8261192-0.29457937-340.23917879 0-14.87625845 7.65906378-26.51214381 20.4732666-34.02391789 45.51251353-26.65943349 91.02502705-53.31886698 136.83211997-79.53643141 71.1409192-40.94653321 142.42912809-81.59848704 213.71733698-122.39773055 7.36448439-4.12411126 14.58167909-8.3955122 21.50429441-13.2560719 19.44223878-13.40336159 39.03176725-16.05457598 60.09419263-3.53495252 27.39588193 16.34915535 54.93905355 32.25644163 82.48222516 48.16372793 88.0792333 50.96223197 176.30575629 101.77717426 264.38498958 152.59211653 9.86840908 5.74429781 19.88410785 11.19401627 29.60522725 17.0856038 14.13981003 8.54280189 21.50429441 21.06242535 21.50429443 37.70616007 0 147.73155685 0.29457937 295.46311371-0.1472897 443.19467057 0 15.46541722-7.2171947 28.57419943-21.7988738 36.96971163-34.7603663 20.17868721-70.55176044 38.88447758-104.57567833 59.94690293-48.90017634 30.19438599-100.00969801 56.11737105-148.76258466 86.60633642-29.01606849 18.11663161-59.50503387 34.02391789-89.11026112 50.96223197-13.10878221 7.51177407-26.07027474 15.17083783-39.03176726 22.9771913-13.84523065 8.3955122-27.83775099 8.83738127-41.97756102 0.73644843-56.41195043-32.55102101-112.82390085-65.10204201-169.38314098-97.653063-61.86166887-35.64410444-123.72333775-71.1409192-185.4377169-106.78502365-11.19401627-6.48074626-22.24074286-12.81420285-32.99289009-19.88410785-11.48859565-7.65906378-17.08560379-19.14765941-17.08560378-32.69831069-0.1472897-34.7603663 0.1472897-69.52073264 0.29457938-104.28109895 1.62018657-0.58915875 1.62018657-1.62018657-0.29457938-2.65121438z m356.58833414-225.500512c2.20934532-1.76747625 4.41869063-3.68224221 6.77532565-5.15513907 68.93157389-39.62092601 137.86314777-79.24185204 206.94201135-118.86277807 2.79850407-1.62018657 6.48074626-1.62018657 6.62803594-6.18616688 0.1472897-4.8605597-4.12411126-4.71327001-6.77532564-6.18616688-40.65195383-23.56635005-81.59848704-46.83812071-122.10315117-70.84633984-16.79102442-10.01569877-32.84560039-8.54280189-48.45830728 0.58915876-45.9543826 26.51214381-91.46689612 53.61344636-137.27398903 80.42016953-31.96186226 18.70579035-64.21830387 37.11700133-96.32745581 55.67550198-18.41121097 10.60485751-27.54317163 25.33382629-27.24859225 47.72185885 0.88373813 89.55213018 0.58915875 179.10426036 0.14728969 268.65639053-0.1472897 20.17868721 9.27925033 33.58204881 25.33382629 43.15587853 31.3727035 18.70579035 63.18727606 37.11700133 95.14913832 54.93905355 10.89943689 6.03887719 21.06242535 13.99252034 35.79139414 18.41121096V505.51925374c6.48074626 19.58952848 18.55850066 34.02391789 36.67513226 44.6287754 27.83775099 16.20186565 63.18727606 12.51962347 86.31175705-10.45756784 26.95401286-26.65943349 28.72148912-62.89269668 12.81420282-90.14128893-16.34915535-28.42690974-43.59774757-37.55887038-74.38129233-38.73718787z m82.48222517 429.64401928c14.28709972-3.82953187 25.92298506-13.99252034 38.88447758-21.35700473 40.94653321-23.27177067 81.30390766-47.72185885 122.54502023-70.55176046 26.95401286-15.02354815 52.87699792-31.66728287 80.71474891-45.21793415 16.79102442-8.10093283 29.60522723-22.53532223 29.60522726-43.4504579 0.1472897-92.939793 0.29457937-185.73229631 0.14728969-278.6720893 0-11.19401627-5.15513907-13.99252034-13.84523067-7.06990501-26.51214381 20.76784598-57.29568854 34.46578693-86.16446735 51.25681135-54.49718448 31.81457257-109.14165865 63.33456576-163.78613282 95.00184862-8.54280189 4.8605597-11.78317502 10.45756784-11.63588535 20.47326662 0.29457937 96.18016613 0.1472897 192.50762194 0.1472897 288.68778806-0.29457937 3.5349525-1.47289687 7.65906378 3.38766282 10.8994369z" fill="#066AF3" p-id="14451"></path><path d="M96.20121136 636.3124965c1.91476594 1.03102783 1.91476594 2.06205563 0 3.09308345v-3.09308345z" fill="#4372E0" p-id="14452"></path><path d="M391.3697457 505.37196405c-5.44971845-44.33419602 13.84523065-74.08671296 61.4197998-94.55997955 30.93083443 1.17831749 58.03213699 10.31027814 74.38129233 38.5898982 15.75999659 27.39588193 14.13981003 63.48185543-12.81420282 90.14128893-23.27177067 22.97719129-58.47400606 26.65943349-86.31175705 10.45756783-18.11663161-10.60485751-30.34167568-25.03924691-36.67513226-44.62877541z" fill="#002A9A" p-id="14453"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
1
channel/web/static/logos/zhipu.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779251419020" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10062" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M520.063496 0v77.563152c0 269.231173-144.758953 414.054122-434.212862 434.340854L86.106618 511.968002H76.827198V255.984001l443.236298-255.984001z" fill="#5B55F6" p-id="10063"></path><path d="M520.063496 1023.936004v-77.563152c0-269.231173-144.758953-414.054122-434.212862-434.340854L86.042622 511.968002H76.827198v255.984001l443.236298 255.984001z" fill="#376AF3" p-id="10064"></path><path d="M520.063496 0v77.563152c0 269.231173 144.758953 414.054122 434.276858 434.340854L954.08437 511.968002h9.215424V255.984001L520.063496 0z" fill="#5B55F6" p-id="10065"></path><path d="M520.063496 1023.936004v-77.563152c0-269.231173 144.758953-414.054122 434.276858-434.340854L954.08437 511.968002h9.27942v255.984001l-443.236298 255.984001z" fill="#376AF3" p-id="10066"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -9,7 +9,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from queue import Queue, Empty
|
from queue import Queue, Empty
|
||||||
from typing import Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
import web
|
import web
|
||||||
|
|
||||||
@@ -750,6 +750,7 @@ class WebChannel(ChatChannel):
|
|||||||
'/stream', 'StreamHandler',
|
'/stream', 'StreamHandler',
|
||||||
'/chat', 'ChatHandler',
|
'/chat', 'ChatHandler',
|
||||||
'/config', 'ConfigHandler',
|
'/config', 'ConfigHandler',
|
||||||
|
'/api/models', 'ModelsHandler',
|
||||||
'/api/channels', 'ChannelsHandler',
|
'/api/channels', 'ChannelsHandler',
|
||||||
'/api/weixin/qrlogin', 'WeixinQrHandler',
|
'/api/weixin/qrlogin', 'WeixinQrHandler',
|
||||||
'/api/feishu/register', 'FeishuRegisterHandler',
|
'/api/feishu/register', 'FeishuRegisterHandler',
|
||||||
@@ -1212,6 +1213,744 @@ class ConfigHandler:
|
|||||||
return json.dumps({"status": "error", "message": str(e)})
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class ModelsHandler:
|
||||||
|
"""API for the unified Models console.
|
||||||
|
|
||||||
|
Layered model:
|
||||||
|
Layer 1 (providers): vendor credentials shared across capabilities.
|
||||||
|
Stored as flat *_api_key / *_api_base fields in
|
||||||
|
config.json — the same fields ConfigHandler
|
||||||
|
already manages.
|
||||||
|
Layer 2 (capabilities): which provider/model is used by chat / vision /
|
||||||
|
asr / tts / embedding / image / search.
|
||||||
|
|
||||||
|
GET /api/models -> overview (providers + capabilities)
|
||||||
|
POST /api/models/provider -> upsert a vendor credential
|
||||||
|
DELETE /api/models/provider -> clear a vendor credential
|
||||||
|
POST /api/models/capability -> set provider/model for a capability
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Capability -> editable flag, current-value resolver, and supported provider
|
||||||
|
# ids drawn from ConfigHandler.PROVIDER_MODELS where applicable.
|
||||||
|
_ASR_PROVIDERS = ["openai", "linkai", "baidu", "ali", "xunfei", "azure", "google"]
|
||||||
|
_TTS_PROVIDERS = ["openai", "linkai", "minimax", "baidu", "ali", "xunfei", "azure", "google", "elevenlabs", "edge", "pytts"]
|
||||||
|
_EMBEDDING_PROVIDERS = ["openai", "linkai", "dashscope", "doubao", "zhipu"]
|
||||||
|
|
||||||
|
# Capability-scoped model catalogs. The chat dropdown can reuse the
|
||||||
|
# provider's generic model list, but vision and image generation are
|
||||||
|
# served by a narrower subset that the runtime actually dispatches to —
|
||||||
|
# see agent/tools/vision/vision.py and skills/image-generation/SKILL.md.
|
||||||
|
# Anything not listed here intentionally hides the model dropdown so
|
||||||
|
# users cannot pin a chat-only model and silently get a 4xx at runtime.
|
||||||
|
_VISION_PROVIDER_MODELS = {
|
||||||
|
# OpenAI ordering matches the recommended GPT-5.4 family first, then
|
||||||
|
# GPT-5 and the GPT-4.1/4o backstops.
|
||||||
|
"openai": [
|
||||||
|
const.GPT_54_MINI,
|
||||||
|
const.GPT_54_NANO,
|
||||||
|
const.GPT_54,
|
||||||
|
const.GPT_5,
|
||||||
|
const.GPT_41,
|
||||||
|
const.GPT_41_MINI,
|
||||||
|
const.GPT_4o,
|
||||||
|
],
|
||||||
|
"doubao": [const.DOUBAO_SEED_2_PRO],
|
||||||
|
"moonshot": [const.KIMI_K2_6],
|
||||||
|
"dashscope": [const.QWEN36_PLUS, const.QWEN35_PLUS, const.QWEN3_MAX],
|
||||||
|
"claudeAPI": [const.CLAUDE_4_6_SONNET, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_OPUS],
|
||||||
|
"gemini": [const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
||||||
|
"qianfan": [const.ERNIE_45_TURBO_VL],
|
||||||
|
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
||||||
|
# name is passed in (see models/zhipuai/zhipuai_bot.py::call_vision),
|
||||||
|
# so listing the chat models here would silently route to the same
|
||||||
|
# endpoint. Surface only the model the runtime can truly dispatch to.
|
||||||
|
"zhipu": [const.GLM_5V_TURBO],
|
||||||
|
# MiniMax's vision endpoint is similarly hard-coded to MiniMax-Text-01
|
||||||
|
# (see models/minimax/minimax_bot.py::call_vision); the M2.x chat
|
||||||
|
# family is text-only.
|
||||||
|
"minimax": [const.MINIMAX_TEXT_01],
|
||||||
|
# LinkAI proxies the underlying vendor; surface a curated set of
|
||||||
|
# multimodal models. Order: gpt-4.1-mini → gpt-5.4-mini as the
|
||||||
|
# cross-vendor baselines, then each vendor's recommended default.
|
||||||
|
"linkai": [
|
||||||
|
const.GPT_41_MINI,
|
||||||
|
const.GPT_54_MINI,
|
||||||
|
const.QWEN36_PLUS,
|
||||||
|
const.DOUBAO_SEED_2_PRO,
|
||||||
|
const.KIMI_K2_6,
|
||||||
|
const.CLAUDE_4_6_SONNET,
|
||||||
|
const.GEMINI_31_FLASH_LITE_PRE,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Image-generation catalog. Source of truth: skills/image-generation/SKILL.md.
|
||||||
|
# Listed verbatim (not via const.*) because these are skill-side names
|
||||||
|
# the script forwards directly to the vendor's image endpoint.
|
||||||
|
#
|
||||||
|
# Two shapes are accepted per model entry:
|
||||||
|
# - bare string → the model id, no hint
|
||||||
|
# - {"value": ..., "hint": "..."} → model id + dim secondary
|
||||||
|
# label rendered on the right
|
||||||
|
# of the dropdown row. Useful
|
||||||
|
# for surfacing brand names
|
||||||
|
# (e.g. "Nano Banana 2" next
|
||||||
|
# to gemini-3.1-flash-image-preview).
|
||||||
|
# The skill itself maps either form to the real vendor endpoint, so the
|
||||||
|
# hint is purely cosmetic.
|
||||||
|
_IMAGE_PROVIDER_MODELS = {
|
||||||
|
"openai": ["gpt-image-2", "gpt-image-1"],
|
||||||
|
"gemini": [
|
||||||
|
{"value": "gemini-3.1-flash-image-preview", "hint": "Nano Banana 2"},
|
||||||
|
{"value": "gemini-3-pro-image-preview", "hint": "Nano Banana Pro"},
|
||||||
|
{"value": "gemini-2.5-flash-image", "hint": "Nano Banana"},
|
||||||
|
],
|
||||||
|
"doubao": ["seedream-5.0-lite", "seedream-4.5"],
|
||||||
|
"dashscope": ["qwen-image-2.0-pro", "qwen-image-2.0"],
|
||||||
|
"minimax": ["image-01"],
|
||||||
|
"linkai": ["gpt-image-2", "gemini-3-pro-image-preview", "seedream-5.0-lite"],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _config_path() -> str:
|
||||||
|
return os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"config.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _read_file_config(cls) -> dict:
|
||||||
|
path = cls._config_path()
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return {}
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _write_file_config(cls, data: dict) -> None:
|
||||||
|
with open(cls._config_path(), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_real_key(value: str) -> bool:
|
||||||
|
return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _provider_overview(cls) -> List[dict]:
|
||||||
|
"""All known providers (configured first, unconfigured after).
|
||||||
|
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list."""
|
||||||
|
local_config = conf()
|
||||||
|
items = []
|
||||||
|
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
|
||||||
|
key_field = p.get("api_key_field")
|
||||||
|
base_field = p.get("api_base_key")
|
||||||
|
raw_key = local_config.get(key_field, "") if key_field else ""
|
||||||
|
raw_base = local_config.get(base_field, "") if base_field else ""
|
||||||
|
configured = cls._is_real_key(raw_key)
|
||||||
|
items.append({
|
||||||
|
"id": pid,
|
||||||
|
"label": p["label"],
|
||||||
|
"configured": configured,
|
||||||
|
"api_key_field": key_field,
|
||||||
|
"api_base_field": base_field,
|
||||||
|
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
||||||
|
"api_base": raw_base or (p.get("api_base_default") or ""),
|
||||||
|
"api_base_default": p.get("api_base_default") or "",
|
||||||
|
"api_base_placeholder": p.get("api_base_placeholder") or "",
|
||||||
|
"models": list(p.get("models") or []),
|
||||||
|
})
|
||||||
|
items.sort(key=lambda it: (0 if it["configured"] else 1, list(ConfigHandler.PROVIDER_MODELS.keys()).index(it["id"])))
|
||||||
|
return items
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _chat_capability(cls, local_config: dict) -> dict:
|
||||||
|
"""Main chat model — drives the agent. bot_type maps to a provider id."""
|
||||||
|
bot_type = local_config.get("bot_type") or ""
|
||||||
|
provider_id = "openai" if bot_type == "chatGPT" else bot_type
|
||||||
|
if provider_id not in ConfigHandler.PROVIDER_MODELS and local_config.get("use_linkai"):
|
||||||
|
provider_id = "linkai"
|
||||||
|
return {
|
||||||
|
"editable": True,
|
||||||
|
"current_provider": provider_id,
|
||||||
|
"current_model": local_config.get("model", ""),
|
||||||
|
"providers": list(ConfigHandler.PROVIDER_MODELS.keys()),
|
||||||
|
"use_linkai": bool(local_config.get("use_linkai", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-fallback order for vision when no explicit model is pinned.
|
||||||
|
# Mirrors agent/tools/vision/vision.py::_resolve_providers — DeepSeek and
|
||||||
|
# other text-only chat bots are intentionally absent, since they cannot
|
||||||
|
# actually serve a vision request. Each entry is
|
||||||
|
# (provider_id, api_key_field, default_vision_model)
|
||||||
|
# and lookups are case-insensitive on the api_key_field. LinkAI and
|
||||||
|
# OpenAI are handled separately below so use_linkai can promote LinkAI
|
||||||
|
# to the front of the chain.
|
||||||
|
_VISION_AUTO_ORDER = [
|
||||||
|
("moonshot", "moonshot_api_key", const.KIMI_K2_6),
|
||||||
|
("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO),
|
||||||
|
("dashscope", "dashscope_api_key", const.QWEN36_PLUS),
|
||||||
|
("claudeAPI", "claude_api_key", const.CLAUDE_4_6_SONNET),
|
||||||
|
("gemini", "gemini_api_key", const.GEMINI_31_FLASH_LITE_PRE),
|
||||||
|
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
|
||||||
|
("zhipu", "zhipu_ai_api_key", const.GLM_5V_TURBO),
|
||||||
|
("minimax", "minimax_api_key", const.MINIMAX_TEXT_01),
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _predict_vision_auto(cls, local_config: dict) -> dict:
|
||||||
|
"""Predict which provider vision.py will actually dispatch to when
|
||||||
|
no tool.vision.model is set. Mirrors the fallback order in
|
||||||
|
agent/tools/vision/vision.py::_resolve_providers so the UI hint
|
||||||
|
matches reality."""
|
||||||
|
chat = cls._chat_capability(local_config)
|
||||||
|
main_provider = chat["current_provider"]
|
||||||
|
main_model = chat["current_model"]
|
||||||
|
use_linkai_flag = bool(local_config.get("use_linkai", False))
|
||||||
|
linkai_configured = cls._is_real_key(local_config.get("linkai_api_key", ""))
|
||||||
|
|
||||||
|
def _try(pid: str, model_default: str):
|
||||||
|
# Look up the api_key for this provider via the canonical
|
||||||
|
# provider table so we don't hardcode field names here.
|
||||||
|
meta = ConfigHandler.PROVIDER_MODELS.get(pid) or {}
|
||||||
|
key_field = meta.get("api_key_field")
|
||||||
|
if not key_field:
|
||||||
|
return None
|
||||||
|
if not cls._is_real_key(local_config.get(key_field, "")):
|
||||||
|
return None
|
||||||
|
# Pick a model that the vision runtime can actually dispatch to
|
||||||
|
# for this provider. Using `main_model` here is unsafe — for
|
||||||
|
# vendors like Zhipu/MiniMax the bot hard-codes the vision model
|
||||||
|
# name regardless of the chat-model name, so surfacing the chat
|
||||||
|
# model name in the hint is misleading. Trust the curated
|
||||||
|
# _VISION_PROVIDER_MODELS list: prefer the main model only if
|
||||||
|
# it appears there; otherwise show the vendor's first vision-
|
||||||
|
# capable model.
|
||||||
|
allowed = cls._VISION_PROVIDER_MODELS.get(pid, [])
|
||||||
|
if pid == main_provider and main_model and main_model in allowed:
|
||||||
|
return {"provider": pid, "model": main_model}
|
||||||
|
fallback = allowed[0] if allowed else model_default
|
||||||
|
return {"provider": pid, "model": fallback}
|
||||||
|
|
||||||
|
# 1. use_linkai → suppress the hint entirely. LinkAI is a proxy and
|
||||||
|
# we don't observe which underlying model it picks; surfacing
|
||||||
|
# "LinkAI" with no model would not tell the user anything useful.
|
||||||
|
if use_linkai_flag and linkai_configured:
|
||||||
|
return {"provider": "", "model": ""}
|
||||||
|
|
||||||
|
# 2. Main bot — only when it natively supports vision. We approximate
|
||||||
|
# "natively supports" by membership in _VISION_PROVIDER_MODELS,
|
||||||
|
# which is the same set vision.py's _DISCOVERABLE_MODELS covers
|
||||||
|
# (minus the chat-only DeepSeek family).
|
||||||
|
if main_provider in cls._VISION_PROVIDER_MODELS:
|
||||||
|
hit = _try(main_provider, main_model)
|
||||||
|
if hit:
|
||||||
|
return hit
|
||||||
|
|
||||||
|
# 3. Other discoverable providers in declared order
|
||||||
|
for pid, _key, default_model in cls._VISION_AUTO_ORDER:
|
||||||
|
hit = _try(pid, default_model)
|
||||||
|
if hit:
|
||||||
|
return hit
|
||||||
|
|
||||||
|
# 4. OpenAI raw HTTP
|
||||||
|
if cls._is_real_key(local_config.get("open_ai_api_key", "")):
|
||||||
|
return {"provider": "openai", "model": const.GPT_41_MINI}
|
||||||
|
|
||||||
|
# 5. LinkAI as last resort (only reached when use_linkai is off)
|
||||||
|
if linkai_configured:
|
||||||
|
return {"provider": "linkai", "model": const.GPT_41_MINI}
|
||||||
|
|
||||||
|
return {"provider": "", "model": ""}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _vision_capability(cls, local_config: dict) -> dict:
|
||||||
|
"""Vision model. tool.vision.model is the explicit override; otherwise
|
||||||
|
the runtime fallback chain in agent/tools/vision/vision.py decides."""
|
||||||
|
tool_conf = local_config.get("tool") or {}
|
||||||
|
if not isinstance(tool_conf, dict):
|
||||||
|
tool_conf = {}
|
||||||
|
vision_conf = tool_conf.get("vision") or {}
|
||||||
|
if not isinstance(vision_conf, dict):
|
||||||
|
vision_conf = {}
|
||||||
|
user_specified = (vision_conf.get("model") or "").strip()
|
||||||
|
|
||||||
|
# When the user pinned a specific model, infer which vendor card to
|
||||||
|
# highlight by scanning the per-provider model lists. Falls back to
|
||||||
|
# an empty provider so the dropdown stays on "auto" if we can't tell.
|
||||||
|
inferred_provider = ""
|
||||||
|
if user_specified:
|
||||||
|
for pid, models in cls._VISION_PROVIDER_MODELS.items():
|
||||||
|
if user_specified in models:
|
||||||
|
inferred_provider = pid
|
||||||
|
break
|
||||||
|
|
||||||
|
# In auto mode the hint should reflect what vision.py will actually
|
||||||
|
# dispatch to — surface that prediction via fallback_* so the UI
|
||||||
|
# shows e.g. "openai / gpt-4.1-mini" instead of the chat-model name.
|
||||||
|
predicted = cls._predict_vision_auto(local_config)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"editable": True,
|
||||||
|
"strategy": "specified" if user_specified else "auto",
|
||||||
|
"user_specified_model": user_specified,
|
||||||
|
"current_provider": inferred_provider,
|
||||||
|
"current_model": user_specified,
|
||||||
|
"fallback_provider": predicted["provider"],
|
||||||
|
"fallback_model": predicted["model"],
|
||||||
|
"providers": list(cls._VISION_PROVIDER_MODELS.keys()),
|
||||||
|
"provider_models": cls._VISION_PROVIDER_MODELS,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _asr_capability(cls, local_config: dict) -> dict:
|
||||||
|
provider_id = (local_config.get("voice_to_text") or "openai").strip().lower()
|
||||||
|
return {
|
||||||
|
"editable": True,
|
||||||
|
"current_provider": provider_id,
|
||||||
|
"current_model": "",
|
||||||
|
"providers": cls._ASR_PROVIDERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _tts_capability(cls, local_config: dict) -> dict:
|
||||||
|
provider_id = (local_config.get("text_to_voice") or "openai").strip().lower()
|
||||||
|
return {
|
||||||
|
"editable": True,
|
||||||
|
"current_provider": provider_id,
|
||||||
|
"current_model": local_config.get("text_to_voice_model", "") or "",
|
||||||
|
"providers": cls._TTS_PROVIDERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _embedding_capability(cls, local_config: dict) -> dict:
|
||||||
|
explicit = (local_config.get("embedding_provider") or "").strip().lower()
|
||||||
|
# When unset, the legacy auto path in agent_initializer.py picks
|
||||||
|
# openai -> linkai. We surface "auto" + an estimate of what it'd
|
||||||
|
# actually use, but don't probe the runtime here.
|
||||||
|
if not explicit:
|
||||||
|
if cls._is_real_key(local_config.get("open_ai_api_key", "")):
|
||||||
|
effective = "openai"
|
||||||
|
elif cls._is_real_key(local_config.get("linkai_api_key", "")):
|
||||||
|
effective = "linkai"
|
||||||
|
else:
|
||||||
|
effective = ""
|
||||||
|
return {
|
||||||
|
"editable": True,
|
||||||
|
"strategy": "auto",
|
||||||
|
"current_provider": effective,
|
||||||
|
"current_model": local_config.get("embedding_model", "") or "",
|
||||||
|
"current_dim": int(local_config.get("embedding_dimensions") or 0) or None,
|
||||||
|
"providers": cls._EMBEDDING_PROVIDERS,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"editable": True,
|
||||||
|
"strategy": "specified",
|
||||||
|
"current_provider": explicit,
|
||||||
|
"current_model": local_config.get("embedding_model", "") or "",
|
||||||
|
"current_dim": int(local_config.get("embedding_dimensions") or 0) or None,
|
||||||
|
"providers": cls._EMBEDDING_PROVIDERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-fallback order for image generation. Mirrors the global priority
|
||||||
|
# used inside skills/image-generation/scripts/generate.py
|
||||||
|
# (`_DEFAULT_PROVIDER_ORDER`): OpenAI → Gemini → Seedream(Ark/doubao) →
|
||||||
|
# Qwen(dashscope) → MiniMax → LinkAI. Each entry maps the
|
||||||
|
# provider-card id to the script's per-provider DEFAULT_MODEL so the
|
||||||
|
# hint matches what the runtime would actually request.
|
||||||
|
_IMAGE_AUTO_ORDER = [
|
||||||
|
("openai", "gpt-image-2"),
|
||||||
|
("gemini", "gemini-3.1-flash-image-preview"), # nano-banana-2
|
||||||
|
("doubao", "seedream-5.0-lite"),
|
||||||
|
("dashscope", "qwen-image-2.0"),
|
||||||
|
("minimax", "image-01"),
|
||||||
|
("linkai", "gpt-image-2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _predict_image_auto(cls, local_config: dict) -> dict:
|
||||||
|
"""Predict which provider/model the image-generation skill will hit
|
||||||
|
when no SKILL_IMAGE_GENERATION_MODEL override is set. Mirrors
|
||||||
|
skills/image-generation/scripts/generate.py::_build_providers so
|
||||||
|
the UI hint matches reality. Chat-only providers (DeepSeek etc.)
|
||||||
|
are absent by design — image generation never falls back to a chat
|
||||||
|
bot regardless of the main model.
|
||||||
|
|
||||||
|
When use_linkai is enabled the hint is suppressed entirely — LinkAI
|
||||||
|
proxies to whichever backend it deems appropriate and surfacing
|
||||||
|
"LinkAI" alone tells the user nothing actionable."""
|
||||||
|
use_linkai_flag = bool(local_config.get("use_linkai", False))
|
||||||
|
linkai_configured = cls._is_real_key(local_config.get("linkai_api_key", ""))
|
||||||
|
if use_linkai_flag and linkai_configured:
|
||||||
|
return {"provider": "", "model": ""}
|
||||||
|
|
||||||
|
for pid, default_model in cls._IMAGE_AUTO_ORDER:
|
||||||
|
meta = ConfigHandler.PROVIDER_MODELS.get(pid) or {}
|
||||||
|
key_field = meta.get("api_key_field")
|
||||||
|
if not key_field:
|
||||||
|
continue
|
||||||
|
if cls._is_real_key(local_config.get(key_field, "")):
|
||||||
|
return {"provider": pid, "model": default_model}
|
||||||
|
return {"provider": "", "model": ""}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _image_capability(cls, local_config: dict) -> dict:
|
||||||
|
"""Image generation. Source of truth: config["skill"]["image-generation"]["model"]
|
||||||
|
(mirrors the per-skill config schema documented in skills/image-generation).
|
||||||
|
The runtime resolver in skills/image-generation/scripts/generate.py
|
||||||
|
reads this via the SKILL_IMAGE_GENERATION_MODEL env var that the
|
||||||
|
agent_initializer syncs at startup; provider is inferred from the
|
||||||
|
model name prefix, mirroring vision.py's design.
|
||||||
|
"""
|
||||||
|
skill_node = local_config.get("skill") or {}
|
||||||
|
if not isinstance(skill_node, dict):
|
||||||
|
skill_node = {}
|
||||||
|
img_node = skill_node.get("image-generation") or {}
|
||||||
|
if not isinstance(img_node, dict):
|
||||||
|
img_node = {}
|
||||||
|
explicit_model = (img_node.get("model") or "").strip()
|
||||||
|
|
||||||
|
# Infer the provider card to highlight by scanning per-provider
|
||||||
|
# model lists, including alias values inside {value, hint} entries.
|
||||||
|
inferred_provider = ""
|
||||||
|
if explicit_model:
|
||||||
|
for pid, models in cls._IMAGE_PROVIDER_MODELS.items():
|
||||||
|
for entry in models:
|
||||||
|
val = entry if isinstance(entry, str) else (entry.get("value") or "")
|
||||||
|
if val == explicit_model:
|
||||||
|
inferred_provider = pid
|
||||||
|
break
|
||||||
|
if inferred_provider:
|
||||||
|
break
|
||||||
|
|
||||||
|
# In auto mode the hint should reflect what generate.py will actually
|
||||||
|
# dispatch to — surface that prediction via fallback_* so the UI
|
||||||
|
# never claims a chat-only bot (e.g. minimax/MiniMax-M2.7) "would
|
||||||
|
# generate the image", which is impossible.
|
||||||
|
predicted = cls._predict_image_auto(local_config)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"editable": True,
|
||||||
|
"strategy": "specified" if explicit_model else "auto",
|
||||||
|
"current_provider": inferred_provider,
|
||||||
|
"current_model": explicit_model,
|
||||||
|
"fallback_provider": predicted["provider"],
|
||||||
|
"fallback_model": predicted["model"],
|
||||||
|
"providers": list(cls._IMAGE_PROVIDER_MODELS.keys()),
|
||||||
|
"provider_models": cls._IMAGE_PROVIDER_MODELS,
|
||||||
|
# The dispatcher that honors a pinned provider isn't wired up
|
||||||
|
# yet; advertise this so the UI can show a "saved but not active"
|
||||||
|
# banner until the runtime catches up.
|
||||||
|
"runtime_active": False,
|
||||||
|
"note": "router_pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _search_capability(cls, local_config: dict) -> dict:
|
||||||
|
"""Web search resolves at runtime via env vars (BOCHA -> LINKAI)."""
|
||||||
|
if cls._is_real_key(os.environ.get("BOCHA_API_KEY", "")):
|
||||||
|
current = "bocha"
|
||||||
|
elif cls._is_real_key(local_config.get("linkai_api_key", "")) or cls._is_real_key(os.environ.get("LINKAI_API_KEY", "")):
|
||||||
|
current = "linkai"
|
||||||
|
else:
|
||||||
|
current = ""
|
||||||
|
return {
|
||||||
|
"editable": False,
|
||||||
|
"current_provider": current,
|
||||||
|
"available": bool(current),
|
||||||
|
"note": "set_BOCHA_API_KEY_env" if not current else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _capabilities(cls, local_config: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"chat": cls._chat_capability(local_config),
|
||||||
|
"vision": cls._vision_capability(local_config),
|
||||||
|
"asr": cls._asr_capability(local_config),
|
||||||
|
"tts": cls._tts_capability(local_config),
|
||||||
|
"embedding": cls._embedding_capability(local_config),
|
||||||
|
"image": cls._image_capability(local_config),
|
||||||
|
"search": cls._search_capability(local_config),
|
||||||
|
}
|
||||||
|
|
||||||
|
def GET(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
try:
|
||||||
|
local_config = conf()
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"providers": self._provider_overview(),
|
||||||
|
"capabilities": self._capabilities(local_config),
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ModelsHandler] GET failed: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
try:
|
||||||
|
data = json.loads(web.data() or b"{}")
|
||||||
|
action = data.get("action") or ""
|
||||||
|
if action == "set_provider":
|
||||||
|
return self._handle_set_provider(data)
|
||||||
|
if action == "delete_provider":
|
||||||
|
return self._handle_delete_provider(data)
|
||||||
|
if action == "set_capability":
|
||||||
|
return self._handle_set_capability(data)
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown action: {action!r}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ModelsHandler] POST failed: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
def _handle_set_provider(self, data: dict) -> str:
|
||||||
|
provider_id = (data.get("provider_id") or "").strip()
|
||||||
|
meta = ConfigHandler.PROVIDER_MODELS.get(provider_id)
|
||||||
|
if not meta:
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
||||||
|
|
||||||
|
# api_key absent / empty / null => leave the existing key untouched
|
||||||
|
# (used by the "edit only base url" flow). To clear the key, callers
|
||||||
|
# must use action=delete_provider explicitly.
|
||||||
|
api_key_raw = data.get("api_key")
|
||||||
|
api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else ""
|
||||||
|
|
||||||
|
# api_base presence is significant: an explicit "" means "reset to
|
||||||
|
# default", whereas a missing key means "no change".
|
||||||
|
api_base_present = "api_base" in data
|
||||||
|
api_base = (data.get("api_base") or "").strip() if api_base_present else None
|
||||||
|
|
||||||
|
applied = {}
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
|
||||||
|
key_field = meta.get("api_key_field")
|
||||||
|
if key_field and api_key:
|
||||||
|
local_config[key_field] = api_key
|
||||||
|
file_cfg[key_field] = api_key
|
||||||
|
applied[key_field] = True
|
||||||
|
base_field = meta.get("api_base_key")
|
||||||
|
if base_field and api_base_present:
|
||||||
|
local_config[base_field] = api_base
|
||||||
|
file_cfg[base_field] = api_base
|
||||||
|
applied[base_field] = True
|
||||||
|
|
||||||
|
if not applied:
|
||||||
|
# Nothing actually changed (e.g. user opened the modal and hit
|
||||||
|
# save without editing). Treat as a successful no-op so the
|
||||||
|
# frontend can show "Saved" instead of surfacing an error.
|
||||||
|
return json.dumps({"status": "success", "provider": provider_id, "noop": True})
|
||||||
|
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
logger.info(f"[ModelsHandler] provider {provider_id} updated: {sorted(applied.keys())}")
|
||||||
|
|
||||||
|
# Vendor credentials affect bot routing for any capability that uses
|
||||||
|
# them; safest to reset Bridge so the next request rebuilds bots.
|
||||||
|
self._reset_bridge()
|
||||||
|
return json.dumps({"status": "success", "provider": provider_id})
|
||||||
|
|
||||||
|
def _handle_delete_provider(self, data: dict) -> str:
|
||||||
|
provider_id = (data.get("provider_id") or "").strip()
|
||||||
|
meta = ConfigHandler.PROVIDER_MODELS.get(provider_id)
|
||||||
|
if not meta:
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
|
||||||
|
cleared = []
|
||||||
|
for field_name in (meta.get("api_key_field"), meta.get("api_base_key")):
|
||||||
|
if not field_name:
|
||||||
|
continue
|
||||||
|
if field_name in local_config:
|
||||||
|
local_config[field_name] = ""
|
||||||
|
file_cfg[field_name] = ""
|
||||||
|
cleared.append(field_name)
|
||||||
|
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
logger.info(f"[ModelsHandler] provider {provider_id} cleared: {cleared}")
|
||||||
|
self._reset_bridge()
|
||||||
|
return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared})
|
||||||
|
|
||||||
|
def _handle_set_capability(self, data: dict) -> str:
|
||||||
|
capability = (data.get("capability") or "").strip()
|
||||||
|
provider_id = (data.get("provider_id") or "").strip()
|
||||||
|
model = (data.get("model") or "").strip()
|
||||||
|
|
||||||
|
if capability == "chat":
|
||||||
|
return self._set_chat(provider_id, model)
|
||||||
|
if capability == "vision":
|
||||||
|
return self._set_vision(provider_id, model)
|
||||||
|
if capability == "asr":
|
||||||
|
return self._set_simple("voice_to_text", provider_id)
|
||||||
|
if capability == "tts":
|
||||||
|
return self._set_tts(provider_id, model)
|
||||||
|
if capability == "embedding":
|
||||||
|
return self._set_embedding(provider_id, model)
|
||||||
|
if capability == "image":
|
||||||
|
return self._set_image(provider_id, model)
|
||||||
|
return json.dumps({"status": "error", "message": f"capability not editable: {capability}"})
|
||||||
|
|
||||||
|
def _set_image(self, provider_id: str, model: str) -> str:
|
||||||
|
# Source of truth: config["skill"]["image-generation"]["model"].
|
||||||
|
# provider_id is informational only (used by the UI to highlight a
|
||||||
|
# vendor card); the runtime resolver infers the provider from the
|
||||||
|
# model name prefix at request time, mirroring vision.py's design.
|
||||||
|
# An empty model means "switch back to auto / let the script pick".
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
|
||||||
|
def _ensure_skill_node(cfg: dict) -> dict:
|
||||||
|
skill_node = cfg.get("skill") or {}
|
||||||
|
if not isinstance(skill_node, dict):
|
||||||
|
skill_node = {}
|
||||||
|
img_node = skill_node.get("image-generation") or {}
|
||||||
|
if not isinstance(img_node, dict):
|
||||||
|
img_node = {}
|
||||||
|
skill_node["image-generation"] = img_node
|
||||||
|
cfg["skill"] = skill_node
|
||||||
|
return img_node
|
||||||
|
|
||||||
|
_ensure_skill_node(local_config)["model"] = model or ""
|
||||||
|
_ensure_skill_node(file_cfg)["model"] = model or ""
|
||||||
|
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
|
||||||
|
# The skill subprocess (skills/image-generation/scripts/generate.py)
|
||||||
|
# reads SKILL_IMAGE_GENERATION_MODEL from its environment, which is
|
||||||
|
# only synced from config["skill"] at startup. Update os.environ live
|
||||||
|
# so changes take effect on the next call without a restart. An empty
|
||||||
|
# model means "clear the override" → drop the env var entirely.
|
||||||
|
env_key = "SKILL_IMAGE_GENERATION_MODEL"
|
||||||
|
if model:
|
||||||
|
os.environ[env_key] = model
|
||||||
|
else:
|
||||||
|
os.environ.pop(env_key, None)
|
||||||
|
|
||||||
|
logger.info(f"[ModelsHandler] image updated: provider_hint={provider_id!r} model={model!r}")
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"provider": provider_id,
|
||||||
|
"model": model,
|
||||||
|
"router_pending": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _set_chat(self, provider_id: str, model: str) -> str:
|
||||||
|
if provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
||||||
|
|
||||||
|
applied = {}
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
|
||||||
|
if provider_id:
|
||||||
|
bot_type_value = "chatGPT" if provider_id == "openai" else provider_id
|
||||||
|
local_config["bot_type"] = bot_type_value
|
||||||
|
file_cfg["bot_type"] = bot_type_value
|
||||||
|
applied["bot_type"] = bot_type_value
|
||||||
|
use_linkai = (provider_id == "linkai")
|
||||||
|
local_config["use_linkai"] = use_linkai
|
||||||
|
file_cfg["use_linkai"] = use_linkai
|
||||||
|
applied["use_linkai"] = use_linkai
|
||||||
|
if model:
|
||||||
|
local_config["model"] = model
|
||||||
|
file_cfg["model"] = model
|
||||||
|
applied["model"] = model
|
||||||
|
|
||||||
|
if not applied:
|
||||||
|
# No-op save (nothing to write). Return success so the UI can
|
||||||
|
# confirm the click without showing a misleading error.
|
||||||
|
return json.dumps({"status": "success", "applied": {}, "noop": True})
|
||||||
|
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
logger.info(f"[ModelsHandler] chat updated: {applied}")
|
||||||
|
self._reset_bridge()
|
||||||
|
return json.dumps({"status": "success", "applied": applied})
|
||||||
|
|
||||||
|
def _set_vision(self, provider_id: str, model: str) -> str:
|
||||||
|
# Vision uses tool.vision.model (nested). provider_id is informational
|
||||||
|
# only; the runtime resolver auto-routes by model name prefix.
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
tool_node = file_cfg.get("tool") or {}
|
||||||
|
if not isinstance(tool_node, dict):
|
||||||
|
tool_node = {}
|
||||||
|
vision_node = tool_node.get("vision") or {}
|
||||||
|
if not isinstance(vision_node, dict):
|
||||||
|
vision_node = {}
|
||||||
|
vision_node["model"] = model
|
||||||
|
tool_node["vision"] = vision_node
|
||||||
|
file_cfg["tool"] = tool_node
|
||||||
|
# Mirror into in-memory config so the live agent sees the change.
|
||||||
|
runtime_tool = local_config.get("tool") or {}
|
||||||
|
if not isinstance(runtime_tool, dict):
|
||||||
|
runtime_tool = {}
|
||||||
|
runtime_vision = runtime_tool.get("vision") or {}
|
||||||
|
if not isinstance(runtime_vision, dict):
|
||||||
|
runtime_vision = {}
|
||||||
|
runtime_vision["model"] = model
|
||||||
|
runtime_tool["vision"] = runtime_vision
|
||||||
|
local_config["tool"] = runtime_tool
|
||||||
|
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
logger.info(f"[ModelsHandler] vision model set: {model!r}")
|
||||||
|
return json.dumps({"status": "success", "model": model})
|
||||||
|
|
||||||
|
def _set_simple(self, key: str, value: str) -> str:
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
local_config[key] = value
|
||||||
|
file_cfg[key] = value
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
logger.info(f"[ModelsHandler] {key} set: {value!r}")
|
||||||
|
return json.dumps({"status": "success", key: value})
|
||||||
|
|
||||||
|
def _set_tts(self, provider_id: str, model: str) -> str:
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
if provider_id:
|
||||||
|
local_config["text_to_voice"] = provider_id
|
||||||
|
file_cfg["text_to_voice"] = provider_id
|
||||||
|
if model:
|
||||||
|
local_config["text_to_voice_model"] = model
|
||||||
|
file_cfg["text_to_voice_model"] = model
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
logger.info(f"[ModelsHandler] tts updated: provider={provider_id!r} model={model!r}")
|
||||||
|
return json.dumps({"status": "success", "provider": provider_id, "model": model})
|
||||||
|
|
||||||
|
def _set_embedding(self, provider_id: str, model: str) -> str:
|
||||||
|
# provider_id="" + model="" means "switch back to legacy auto mode".
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
local_config["embedding_provider"] = provider_id
|
||||||
|
file_cfg["embedding_provider"] = provider_id
|
||||||
|
if model:
|
||||||
|
local_config["embedding_model"] = model
|
||||||
|
file_cfg["embedding_model"] = model
|
||||||
|
else:
|
||||||
|
local_config["embedding_model"] = ""
|
||||||
|
file_cfg["embedding_model"] = ""
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
logger.info(f"[ModelsHandler] embedding updated: provider={provider_id!r} model={model!r}")
|
||||||
|
# Embedding switches don't go through Bridge bots; the agent's
|
||||||
|
# MemoryManager rebuilds its provider on next process restart, but
|
||||||
|
# the index dim may now mismatch — frontend should warn the user.
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"provider": provider_id,
|
||||||
|
"model": model,
|
||||||
|
"warn_rebuild_index": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reset_bridge() -> None:
|
||||||
|
try:
|
||||||
|
from bridge.bridge import Bridge
|
||||||
|
Bridge().reset_bot()
|
||||||
|
logger.info("[ModelsHandler] Bridge bot routing reset")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ModelsHandler] Bridge reset failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
class ChannelsHandler:
|
class ChannelsHandler:
|
||||||
"""API for managing external channel configurations (feishu, dingtalk, etc)."""
|
"""API for managing external channel configurations (feishu, dingtalk, etc)."""
|
||||||
|
|
||||||
@@ -2242,7 +2981,12 @@ class AssetsHandler:
|
|||||||
raise web.notfound()
|
raise web.notfound()
|
||||||
|
|
||||||
if not os.path.exists(full_path) or not os.path.isfile(full_path):
|
if not os.path.exists(full_path) or not os.path.isfile(full_path):
|
||||||
logger.error(f"File not found: {full_path}")
|
# Browsers routinely probe optional asset variants (e.g. a
|
||||||
|
# .ttf fallback declared alongside .woff2 in @font-face);
|
||||||
|
# logging these as errors floods the console with harmless
|
||||||
|
# noise. Keep it at debug level — real misconfigurations
|
||||||
|
# will still surface via the network panel.
|
||||||
|
logger.debug(f"Static file not found: {full_path}")
|
||||||
raise web.notfound()
|
raise web.notfound()
|
||||||
|
|
||||||
# 设置正确的Content-Type
|
# 设置正确的Content-Type
|
||||||
@@ -2257,8 +3001,12 @@ class AssetsHandler:
|
|||||||
with open(full_path, 'rb') as f:
|
with open(full_path, 'rb') as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
|
except web.HTTPError:
|
||||||
|
# The 404 path above already logged at debug; re-raise as-is so
|
||||||
|
# web.py returns the original status to the client.
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error serving static file: {e}", exc_info=True) # 添加更详细的错误信息
|
logger.error(f"Error serving static file: {e}", exc_info=True)
|
||||||
raise web.notfound()
|
raise web.notfound()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ QWQ_PLUS = "qwq-plus"
|
|||||||
|
|
||||||
# MiniMax
|
# MiniMax
|
||||||
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 - Latest
|
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 - Latest
|
||||||
|
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision)
|
||||||
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
|
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
|
||||||
MINIMAX_M2_5 = "MiniMax-M2.5" # MiniMax M2.5
|
MINIMAX_M2_5 = "MiniMax-M2.5" # MiniMax M2.5
|
||||||
MINIMAX_M2_1 = "MiniMax-M2.1" # MiniMax M2.1
|
MINIMAX_M2_1 = "MiniMax-M2.1" # MiniMax M2.1
|
||||||
@@ -119,6 +120,7 @@ MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
|
|||||||
GLM_5_1 = "glm-5.1" # 智谱 GLM-5.1 - Agent recommended model (default)
|
GLM_5_1 = "glm-5.1" # 智谱 GLM-5.1 - Agent recommended model (default)
|
||||||
GLM_5_TURBO = "glm-5-turbo" # 智谱 GLM-5-Turbo
|
GLM_5_TURBO = "glm-5-turbo" # 智谱 GLM-5-Turbo
|
||||||
GLM_5 = "glm-5" # 智谱 GLM-5
|
GLM_5 = "glm-5" # 智谱 GLM-5
|
||||||
|
GLM_5V_TURBO = "glm-5v-turbo" # 智谱多模态 (vision)
|
||||||
GLM_4 = "glm-4"
|
GLM_4 = "glm-4"
|
||||||
GLM_4_PLUS = "glm-4-plus"
|
GLM_4_PLUS = "glm-4-plus"
|
||||||
GLM_4_flash = "glm-4-flash"
|
GLM_4_flash = "glm-4-flash"
|
||||||
|
|||||||