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.
This commit is contained in:
zhayujie
2026-05-20 20:59:04 +08:00
parent 16b7271826
commit c181e500bc
17 changed files with 1995 additions and 7 deletions

View File

@@ -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>

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View 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

View 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

View 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

View 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

View 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

View File

@@ -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()

View File

@@ -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"