Merge pull request #6032 from nesquena/stage/r2-batch
Release exp: Round-2 batch — picker dedup (#5997) + zh-CN i18n (#6024)
This commit is contained in:
commit
41f1eb5624
5 changed files with 338 additions and 74 deletions
|
|
@ -13,6 +13,10 @@
|
|||
|
||||
- **Deep-link straight to a profile with `?profile=<name>`.** Opening the WebUI with a `?profile=NAME` query parameter now switches to that profile on boot. The name is validated against the existing profile allowlist (a nonexistent profile boots normally, path-traversal / cross-profile attempts are rejected), the switch goes through the same access-enforced `switchToProfile`, and boot fails safe on anything invalid. Thanks @rodboev. (#5730, #5682)
|
||||
|
||||
- **Model picker no longer duplicates or drops proxy/catalog models.** When the same model was reachable via both a custom proxy and a first-party catalog (e.g. `grok-4.5`), the picker could show it twice; the dedup fix collapses true duplicates to one selectable row while keeping genuinely-distinct models that merely share a name suffix — including colon-suffixed proxy models like `…:free` / `…:thinking` / `…:latest` — independently selectable. The currently-selected model always survives dedup, and model restore no longer substitutes a suffix-sharing sibling for a missing model. Thanks @rodboev. (#5997, #5989)
|
||||
|
||||
- **Simplified Chinese (zh-CN) localization fill.** Untranslated English strings in the zh-CN locale are now translated, so Simplified Chinese users no longer see English fall-through text across recently-added UI. Thanks @Loukky. (#6024)
|
||||
|
||||
- **WebUI-created git worktrees can now be cleaned up (no more orphan accumulation).** The agent locks every worktree it creates, and git refuses to remove a locked worktree with a single `--force`, so WebUI-created worktrees were piling up unremovable. Session-worktree removal now runs a fail-soft `git worktree unlock` right before the remove (mirroring the agent's own cleanup ordering); the existing dirty/uncommitted-changes guard still runs first, so user data stays protected. Thanks @ayxuerui. (#6028, #6023)
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
110
static/i18n.js
110
static/i18n.js
|
|
@ -10424,8 +10424,8 @@ const LOCALES = {
|
|||
thinking: '思考过程',
|
||||
expand_all: '全部展开',
|
||||
collapse_all: '全部折叠',
|
||||
show_earlier_step_one: 'Show 1 earlier step',
|
||||
show_earlier_steps: 'Show {0} earlier steps',
|
||||
show_earlier_step_one: '显示前 1 个步骤',
|
||||
show_earlier_steps: '显示前 {0} 个步骤',
|
||||
edit_failed: '编辑失败:',
|
||||
regen_failed: '重新生成失败:',
|
||||
reconnect_active: '回复仍在生成中,准备好后要重新加载吗?',
|
||||
|
|
@ -10623,9 +10623,9 @@ const LOCALES = {
|
|||
settings_default_message_mode_interrupt: '中断当前轮次',
|
||||
settings_default_message_mode_steer: '引导(中间修正)',
|
||||
settings_label_busy_placeholder_hint: '显示忙碌占位提示',
|
||||
settings_label_new_chat_on_workspace_switch: 'Start a new chat when switching workspace',
|
||||
settings_desc_new_chat_on_workspace_switch: 'When on, selecting a different workspace starts a fresh conversation bound to that workspace and leaves your current chat on its original workspace, avoiding stale cross-workspace context. When off (default), switching workspace changes the current workspace in place.',
|
||||
workspace_switched_new_chat: 'Started a new chat in {0}',
|
||||
settings_label_new_chat_on_workspace_switch: '切换工作区时开始新的聊天',
|
||||
settings_desc_new_chat_on_workspace_switch: '开启此功能后,选择不同的工作区会开启一个与该工作区绑定的全新对话,你当前的聊天则保留在原工作区,避免出现过时的跨工作区上下文。关闭此功能(默认状态)后,切换工作区会直接更改当前工作区。 ',
|
||||
workspace_switched_new_chat: '在{0}中开始了新的聊天',
|
||||
settings_desc_busy_placeholder_hint: '在会话运行且草稿为空时,显示带有当前忙碌操作的编辑器占位符。',
|
||||
composer_placeholder_busy_queue: 'Enter = 队列 | /interrupt | /background | /steer',
|
||||
composer_placeholder_busy_interrupt: 'Enter = 中断 | /queue | /background | /steer',
|
||||
|
|
@ -10648,16 +10648,16 @@ const LOCALES = {
|
|||
workspace_empty_dir: '此工作区为空。',
|
||||
workspace_show_hidden_files: '显示隐藏文件',
|
||||
workspace_show_hidden_files_desc: '将 .DS_Store、.git、node_modules 以及其他隐藏文件/系统文件包含在文件树中。',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
workspace_panel_close: 'Close workspace panel',
|
||||
workspace_panel_toggle: 'Toggle workspace files panel',
|
||||
workspace_panel_views: 'Workspace panel views',
|
||||
workspace_parent_directory: 'Parent directory',
|
||||
workspace_upload_file: 'Upload file',
|
||||
workspace_close_preview: 'Close preview',
|
||||
workspace_files_tab: 'Files',
|
||||
workspace_artifacts_tab: 'Artifacts',
|
||||
workspace_panel_show: '显示工作区面板',
|
||||
workspace_panel_hide: '隐藏工作区面板',
|
||||
workspace_panel_close: '关闭工作区面板',
|
||||
workspace_panel_toggle: '切换工作区文件面板',
|
||||
workspace_panel_views: '工作区面板视图',
|
||||
workspace_parent_directory: '父目录',
|
||||
workspace_upload_file: '上传文件',
|
||||
workspace_close_preview: '关闭预览',
|
||||
workspace_files_tab: '文件',
|
||||
workspace_artifacts_tab: '产物',
|
||||
workspace_hidden_files_visible: '隐藏 显示',
|
||||
workspace_hidden_files_visible_title: '隐藏文件已显示 — 点击查看选项',
|
||||
workspace_options: '工作区选项',
|
||||
|
|
@ -10740,20 +10740,20 @@ const LOCALES = {
|
|||
session_copy_link_desc: '复制此对话的直接链接',
|
||||
session_link_copied: '对话链接已复制到剪贴板',
|
||||
session_link_copy_failed: '无法复制对话链接:',
|
||||
share_session: 'Share',
|
||||
share_session_tooltip: 'Create a public read-only share link',
|
||||
share_session_status_active: 'Public share active',
|
||||
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
|
||||
share_session_copy_existing: 'Copy existing',
|
||||
share_session_refresh_snapshot: 'Refresh snapshot',
|
||||
share_session_link_copied: 'Share link copied to clipboard',
|
||||
share_session_created: 'Public share link created',
|
||||
share_session_failed: 'Share failed: ',
|
||||
stop_sharing_session: 'Stop sharing',
|
||||
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
|
||||
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
|
||||
share_session_revoked: 'Public share link revoked',
|
||||
share_session_revoke_failed: 'Failed to revoke share link: ',
|
||||
share_session: '共享',
|
||||
share_session_tooltip: '创建一个公开的只读共享链接',
|
||||
share_session_status_active: '公开共享已激活',
|
||||
share_session_existing_confirm: '此对话已拥有一个公开的只读共享链接。\n\n选择 复制现有链接 以复用该链接,或选择 刷新快照,依据最新可见消息重建公开快照。',
|
||||
share_session_copy_existing: '复制现有链接',
|
||||
share_session_refresh_snapshot: '刷新快照',
|
||||
share_session_link_copied: '共享链接已复制到剪贴板',
|
||||
share_session_created: '已创建公开共享链接',
|
||||
share_session_failed: '共享失败: ',
|
||||
stop_sharing_session: '停止共享',
|
||||
stop_sharing_session_tooltip: '撤销此对话的公开只读共享链接',
|
||||
stop_sharing_session_confirm: '撤销此公开共享链接吗?任何打开旧链接的人都会看到该链接已不可用。',
|
||||
share_session_revoked: '公开共享链接已撤销',
|
||||
share_session_revoke_failed: '撤销共享链接失败: ',
|
||||
new_file_prompt: '新文件名(例如 notes.md):',
|
||||
new_file: '新建文件',
|
||||
new_folder: '新建文件夹',
|
||||
|
|
@ -10881,7 +10881,7 @@ const LOCALES = {
|
|||
settings_label_sidebar_density: '侧边栏密度',
|
||||
cmd_reasoning: '切换思维可见性(显示/隐藏)、设置工作强度或查看当前状态',
|
||||
settings_label_external_sessions: '显示外部会话',
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_claude_code_sessions: '显示 Claude Code 会话',
|
||||
settings_label_cron_sessions: '显示定时任务会话',
|
||||
settings_label_webhook_sessions: '显示Webhook会话',
|
||||
settings_label_previous_messaging_sessions: '显示以前的消息会话',
|
||||
|
|
@ -11225,7 +11225,7 @@ const LOCALES = {
|
|||
settings_auto_title_refresh_20: '每 20 轮对话',
|
||||
settings_desc_auto_title_refresh: '基于最新对话自动重新生成会话标题,使其随对话发展保持相关。需要配置 LLM 标题生成模型。',
|
||||
settings_desc_external_sessions: '在会话列表中显示来自 CLI、Telegram、Discord、Slack 等渠道的对话。点击可导入并继续对话。',
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_claude_code_sessions: '在侧边栏中显示已导入的Claude Code会话,或者隐藏它们,同时保持其他外部会话可见。',
|
||||
settings_desc_cron_sessions: '将定时任务输出以对话形式显示在侧边栏。仅在启用非 WebUI 会话时此功能才会生效。默认关闭;高频任务可能会使侧边栏信息泛滥。',
|
||||
settings_desc_webhook_sessions: '将Webhook运行情况以对话形式显示在侧边栏。仅在启用非 WebUI 会话时此功能才会生效。默认关闭;大量的路由信息可能会使侧边栏信息泛滥。',
|
||||
settings_desc_previous_messaging_sessions: '显示被 reset 或 compression 替换的较旧的 Discord、Telegram、Slack 和 Weixin 会话。',
|
||||
|
|
@ -11563,18 +11563,18 @@ const LOCALES = {
|
|||
cron_schedule_preset_weekly: '每周',
|
||||
cron_schedule_preset_monthly: '每月',
|
||||
cron_schedule_preset_custom: '自定义',
|
||||
cron_schedule_time_label: 'Time',
|
||||
cron_schedule_conj_on: 'on',
|
||||
cron_schedule_conj_on_day: 'on day',
|
||||
cron_schedule_conj_at: 'at',
|
||||
cron_schedule_conj_at_minute: 'at minute',
|
||||
cron_weekday_sun: 'Sunday',
|
||||
cron_weekday_mon: 'Monday',
|
||||
cron_weekday_tue: 'Tuesday',
|
||||
cron_weekday_wed: 'Wednesday',
|
||||
cron_weekday_thu: 'Thursday',
|
||||
cron_weekday_fri: 'Friday',
|
||||
cron_weekday_sat: 'Saturday',
|
||||
cron_schedule_time_label: '时间',
|
||||
cron_schedule_conj_on: '在',
|
||||
cron_schedule_conj_on_day: '指定天数',
|
||||
cron_schedule_conj_at: '在',
|
||||
cron_schedule_conj_at_minute: '指定分钟数',
|
||||
cron_weekday_sun: '星期日',
|
||||
cron_weekday_mon: '星期一',
|
||||
cron_weekday_tue: '星期二',
|
||||
cron_weekday_wed: '星期三',
|
||||
cron_weekday_thu: '星期四',
|
||||
cron_weekday_fri: '星期五',
|
||||
cron_weekday_sat: '星期六',
|
||||
cron_schedule_hour_label: '小时',
|
||||
cron_schedule_minute_label: '分钟',
|
||||
cron_schedule_weekday_label: '星期 (0-6,周日-周六)',
|
||||
|
|
@ -11643,8 +11643,8 @@ const LOCALES = {
|
|||
session_worktree_remove_ahead_warning: (ahead) => `${ahead} 个未推送的提交将丢失。`,
|
||||
session_duplicate: '复制会话',
|
||||
session_duplicate_desc: '用相同工作区和模型创建副本',
|
||||
session_export_html: 'Export as HTML',
|
||||
session_export_html_desc: 'Download this conversation as a self-contained HTML file',
|
||||
session_export_html: '导出为 HTML',
|
||||
session_export_html_desc: '将此对话下载为一个独立的HTML文件',
|
||||
session_duplicate_failed: '复制失败:',
|
||||
session_stop_response: '停止回复',
|
||||
session_stop_response_desc: '取消此会话正在进行的回复',
|
||||
|
|
@ -11689,15 +11689,15 @@ const LOCALES = {
|
|||
settings_check_now: '立即检查',
|
||||
settings_checking: '检查中\u2026',
|
||||
settings_up_to_date: '已是最新 \u2713',
|
||||
update_later: 'Later',
|
||||
update_now: 'Update Now',
|
||||
update_updating: 'Updating…',
|
||||
update_force: 'Force update',
|
||||
update_clear_lock_retry: 'Clear lock and retry update',
|
||||
update_hard_refresh_now: 'Hard refresh now',
|
||||
update_no_target: 'No update target selected. Refresh update status and retry.',
|
||||
update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.',
|
||||
update_failed_prefix: 'Update failed: ',
|
||||
update_later: '稍后',
|
||||
update_now: '现在更新',
|
||||
update_updating: '更新中…',
|
||||
update_force: '强制更新',
|
||||
update_clear_lock_retry: '清除锁定并重试更新',
|
||||
update_hard_refresh_now: '立即强制刷新',
|
||||
update_no_target: '未选择更新目标。请刷新更新状态并重试。',
|
||||
update_failed_network: '更新失败:无法连接到 WebUI 服务器。服务可能已重启,或者连接被中断。请稍等几秒,重新加载页面,如果服务器仍未恢复,请检查服务器状态。 ',
|
||||
update_failed_prefix: '更新失败: ',
|
||||
settings_updates_available: '有 {count} 个更新可用',
|
||||
settings_updates_disabled: '更新检查已禁用',
|
||||
settings_update_check_failed: '更新检查失败',
|
||||
|
|
@ -11745,7 +11745,7 @@ const LOCALES = {
|
|||
done: '完成',
|
||||
settings_option_compact_worklog: '紧凑工作日志',
|
||||
settings_option_transparent_stream: '透明流模式',
|
||||
settings_option_final_answer_only: 'Final answer only',
|
||||
settings_option_final_answer_only: '只给出最终答案',
|
||||
|
||||
settings_label_tab_visibility: '侧边栏标签',
|
||||
settings_desc_tab_visibility: '选择在侧边栏和导航栏中显示哪些标签。聊天和设置始终可见。',
|
||||
|
|
|
|||
109
static/ui.js
109
static/ui.js
|
|
@ -2732,6 +2732,49 @@ function _providerFromModelValue(modelId){
|
|||
if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
|
||||
return '';
|
||||
}
|
||||
function _modelPickerOptionIdentity(modelId, providerId){
|
||||
let value=String(modelId||'');
|
||||
const provider=String(providerId||'').trim();
|
||||
if(value.startsWith('@')&&value.includes(':')){
|
||||
const exactPrefix=provider ? `@${provider}:` : '';
|
||||
if(exactPrefix && value.toLowerCase().startsWith(exactPrefix.toLowerCase())){
|
||||
value=value.substring(exactPrefix.length);
|
||||
}else if(value.startsWith('@custom:')){
|
||||
const namedProvider=value.substring('@custom:'.length);
|
||||
const splitAt=namedProvider.indexOf(':');
|
||||
value=splitAt>=0 ? namedProvider.substring(splitAt+1) : namedProvider;
|
||||
}else{
|
||||
value=value.substring(value.indexOf(':')+1);
|
||||
}
|
||||
}
|
||||
return value.replace(/-/g,'.').toLowerCase();
|
||||
}
|
||||
function _deduplicateModelPickerOptions(sel,selectedValue){
|
||||
if(!sel||!sel.querySelectorAll) return 0;
|
||||
let removed=0;
|
||||
for(const group of sel.querySelectorAll('optgroup')){
|
||||
const options=Array.from(group.children||[]).filter(opt=>opt&&opt.tagName==='OPTION');
|
||||
const byIdentity=new Map();
|
||||
for(const opt of options){
|
||||
const identity=_modelPickerOptionIdentity(opt.value,_getOptionProviderId(opt));
|
||||
if(!identity) continue;
|
||||
if(!byIdentity.has(identity)) byIdentity.set(identity,[]);
|
||||
byIdentity.get(identity).push(opt);
|
||||
}
|
||||
for(const candidates of byIdentity.values()){
|
||||
if(candidates.length<2) continue;
|
||||
const selected=candidates.find(opt=>opt.value===selectedValue);
|
||||
const routable=candidates.find(opt=>String(opt.value||'').startsWith('@'));
|
||||
const survivor=selected||routable||candidates[0];
|
||||
for(const opt of candidates){
|
||||
if(opt===survivor) continue;
|
||||
group.removeChild(opt);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
function _providerSkipsModelMismatchWarning(providerId){
|
||||
const p=String(providerId||'').toLowerCase();
|
||||
return !p||p==='custom'||p.startsWith('custom:')||p==='openrouter';
|
||||
|
|
@ -3018,9 +3061,14 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){
|
|||
const pref=String(preferredProviderId||'').toLowerCase();
|
||||
if(!pref || !exactProv || exactProv===pref) return modelId;
|
||||
}
|
||||
// 1. Normalize: lowercase, strip namespace prefix, replace hyphens→dots.
|
||||
// Also strip @provider: prefix from deduplicated model IDs (#1228, #1313).
|
||||
const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/^@([^:]+:)+/,'').replace(/-/g,'.');
|
||||
// 1. Restore lookup keeps the older hierarchy-preserving matcher instead of
|
||||
// the picker-dedup identity, so missing qualified models do not substitute a
|
||||
// different suffix-sharing sibling.
|
||||
const norm=s=>String(s||'')
|
||||
.toLowerCase()
|
||||
.replace(/^@([^:]+:)+/,'')
|
||||
.replace(/^[^/]+\//,'')
|
||||
.replace(/-/g,'.');
|
||||
const target=norm(modelId);
|
||||
let explicitProvider='';
|
||||
const rawModel=String(modelId||'');
|
||||
|
|
@ -3122,6 +3170,7 @@ function _applyModelToDropdown(modelId, sel, preferredProviderId, opts){
|
|||
}
|
||||
function _ensureModelOptionInDropdown(modelId, sel, preferredProviderId){
|
||||
if(!modelId||!sel) return null;
|
||||
if(typeof _deduplicateModelPickerOptions==='function') _deduplicateModelPickerOptions(sel,sel.value);
|
||||
const applied=_applyModelToDropdown(modelId,sel,preferredProviderId);
|
||||
if(applied) return applied;
|
||||
const value=modelId;
|
||||
|
|
@ -3296,6 +3345,9 @@ async function populateModelDropdown(opts={}){
|
|||
}
|
||||
sel.appendChild(og);
|
||||
}
|
||||
if(typeof _deduplicateModelPickerOptions==='function'){
|
||||
_deduplicateModelPickerOptions(sel,previousSelection&&previousSelection.model||'');
|
||||
}
|
||||
_reconcileModelDropdownSelection(sel,data,previousSelection,opts);
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
const dd=$('composerModelDropdown');
|
||||
|
|
@ -3346,32 +3398,47 @@ function _addLiveModelsToSelect(provider, models, sel){
|
|||
providerGroup.dataset.provider=provider;
|
||||
}
|
||||
const existingIds=new Set([...sel.options].map(o=>o.value));
|
||||
// Normalized dedup strips provider/custom prefixes and namespaces (#907, #3478).
|
||||
const _normId=id=>{
|
||||
let s=String(id||'');
|
||||
if(s.startsWith('@')&&s.includes(':')){
|
||||
if(s.startsWith('@custom:')){
|
||||
s=s.substring(s.lastIndexOf(':')+1)||s;
|
||||
}else{
|
||||
s=s.substring(s.indexOf(':')+1);
|
||||
}
|
||||
}
|
||||
s=s.split('/').pop();
|
||||
return s.replace(/-/g,'.').toLowerCase();
|
||||
};
|
||||
const existingNorm=new Set([...sel.options].map(o=>_normId(o.value)));
|
||||
let added=0;
|
||||
const _ap=(window._activeProvider||'').toLowerCase();
|
||||
const _providerLower=String(provider||'').toLowerCase();
|
||||
const _isNamedCustomActiveProvider=_ap.startsWith('custom:');
|
||||
const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && _ap!=='openai-codex' && (_providerLower===_ap||_isNamedCustomActiveProvider&&_providerLower===_ap);
|
||||
// Keep existingNorm.has( within the #907 source slice.
|
||||
const optionIdentity=typeof _modelPickerOptionIdentity==='function'
|
||||
? (modelId,providerId)=>_modelPickerOptionIdentity(modelId,providerId)
|
||||
: (modelId,providerId)=>{
|
||||
let value=String(modelId||'');
|
||||
const provider=String(providerId||'').trim();
|
||||
if(value.startsWith('@')&&value.includes(':')){
|
||||
const exactPrefix=provider ? `@${provider}:` : '';
|
||||
if(exactPrefix && value.toLowerCase().startsWith(exactPrefix.toLowerCase())){
|
||||
value=value.substring(exactPrefix.length);
|
||||
}else if(value.startsWith('@custom:')){
|
||||
const namedProvider=value.substring('@custom:'.length);
|
||||
const splitAt=namedProvider.indexOf(':');
|
||||
value=splitAt>=0 ? namedProvider.substring(splitAt+1) : namedProvider;
|
||||
}else{
|
||||
value=value.substring(value.indexOf(':')+1);
|
||||
}
|
||||
}
|
||||
return value.split('/').pop().replace(/-/g,'.').toLowerCase();
|
||||
};
|
||||
const existingNorm=new Set([...sel.options].map(o=>optionIdentity(o.value,_getOptionProviderId(o))));
|
||||
let added=0;
|
||||
for(const m of models){
|
||||
let mid=m.id;
|
||||
if(_isPortalFetch && !mid.startsWith('@')){
|
||||
mid=`@${provider}:${mid}`;
|
||||
}
|
||||
if(existingIds.has(mid)) continue;
|
||||
if(existingNorm.has(_normId(mid))) continue; // dedup cross-prefix duplicates (#907)
|
||||
const identity=optionIdentity(mid,provider);
|
||||
if(existingNorm.has(identity)){
|
||||
const sameGroup=Array.from(providerGroup.children||[]).find(o=>optionIdentity(o.value,_getOptionProviderId(o))===identity);
|
||||
if(sameGroup){
|
||||
const incomingRoutable=String(mid).startsWith('@');
|
||||
const existingRoutable=String(sameGroup.value||'').startsWith('@');
|
||||
if(!(!existingRoutable&&incomingRoutable)) continue; // let proxy replace catalog twin
|
||||
}
|
||||
}
|
||||
const opt=document.createElement('option');
|
||||
opt.value=mid;
|
||||
opt.textContent=m.label||m.id;
|
||||
|
|
@ -3383,9 +3450,12 @@ function _addLiveModelsToSelect(provider, models, sel){
|
|||
opt.dataset.fast='0';
|
||||
}
|
||||
providerGroup.appendChild(opt);
|
||||
existingIds.add(mid);
|
||||
existingNorm.add(identity);
|
||||
_dynamicModelLabels[mid]=m.label||m.id;
|
||||
added++;
|
||||
}
|
||||
if(typeof _deduplicateModelPickerOptions==='function') _deduplicateModelPickerOptions(sel,currentVal);
|
||||
const currentState=(currentVal&&typeof _modelStateForSelect==='function')
|
||||
? _modelStateForSelect(sel, currentVal)
|
||||
: {model:currentVal||'', model_provider:(S.session&&S.session.model_provider)||null};
|
||||
|
|
@ -3763,6 +3833,7 @@ function renderModelDropdown(){
|
|||
const dd=$(opts.dropdownId||'composerModelDropdown');
|
||||
const sel=$(opts.selectId||'modelSelect');
|
||||
if(!dd||!sel) return;
|
||||
if(typeof _deduplicateModelPickerOptions==='function') _deduplicateModelPickerOptions(sel,sel.value);
|
||||
// Whether the search input should auto-grab focus on (re-)render. Default true
|
||||
// preserves the composer picker's behavior exactly; the settings picker passes
|
||||
// false on coarse-pointer devices so opening it doesn't pop the mobile keyboard.
|
||||
|
|
|
|||
|
|
@ -262,6 +262,29 @@ console.log(_findModelInDropdown('google/gemma-4-27b', sel, 'custom:beta') || ''
|
|||
resolved = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=True)
|
||||
assert resolved.stdout.strip() == "@custom:beta:google/gemma-4-27b"
|
||||
|
||||
def test_find_model_returns_no_match_for_missing_qualified_same_suffix_sibling(self):
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
src = self._read_js()
|
||||
helper = re.search(r"function _getOptionProviderId\(opt\)\{.*?\n\}", src, re.S)
|
||||
finder = re.search(r"function _findModelInDropdown\(modelId, sel, preferredProviderId\)\{.*?\n\}", src, re.S)
|
||||
assert helper, "_getOptionProviderId() not found in ui.js"
|
||||
assert finder, "_findModelInDropdown() not found in ui.js"
|
||||
|
||||
script = f"""
|
||||
{helper.group(0)}
|
||||
{finder.group(0)}
|
||||
const sel = {{
|
||||
options: [
|
||||
{{ value: 'vendor-b/catalog/deepseek-v4-pro', parentElement: {{ tagName: 'OPTGROUP', dataset: {{ provider: 'custom:beta' }} }} }},
|
||||
]
|
||||
}};
|
||||
console.log(_findModelInDropdown('vendor-a/deepseek-v4-pro', sel, 'custom:alpha') || '');
|
||||
"""
|
||||
resolved = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=True)
|
||||
assert resolved.stdout.strip() == ""
|
||||
|
||||
|
||||
class TestResolveModelProviderColonInProviderId(unittest.TestCase):
|
||||
"""resolve_model_provider() must handle provider_ids containing ':'.
|
||||
|
|
|
|||
166
tests/test_issue5989_custom_proxy_picker_dedup.py
Normal file
166
tests/test_issue5989_custom_proxy_picker_dedup.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Regression coverage for #5989's cross-source model picker duplicate."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function(source, name):
|
||||
match = re.search(rf"function {name}\([^)]*\)\{{", source)
|
||||
assert match, f"{name} not found in ui.js"
|
||||
start = match.start()
|
||||
depth = 0
|
||||
for index in range(match.end() - 1, len(source)):
|
||||
if source[index] == "{":
|
||||
depth += 1
|
||||
elif source[index] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start:index + 1]
|
||||
raise AssertionError(f"unterminated {name}")
|
||||
|
||||
|
||||
def _run_harness():
|
||||
provider_helper = _function(UI_JS, "_getOptionProviderId")
|
||||
identity = _function(UI_JS, "_modelPickerOptionIdentity")
|
||||
helper = _function(UI_JS, "_deduplicateModelPickerOptions")
|
||||
live_add = _function(UI_JS, "_addLiveModelsToSelect")
|
||||
script = f"""
|
||||
{provider_helper}
|
||||
{identity}
|
||||
{helper}
|
||||
{live_add}
|
||||
class Node {{
|
||||
constructor(tag) {{ this.tagName=tag.toUpperCase(); this.children=[]; this.dataset={{}}; this.parentElement=null; }}
|
||||
appendChild(child) {{ child.parentElement=this; this.children.push(child); return child; }}
|
||||
removeChild(child) {{ this.children=this.children.filter(item=>item!==child); child.parentElement=null; }}
|
||||
querySelectorAll(selector) {{
|
||||
if(selector==='optgroup') return this.children.filter(child=>child.tagName==='OPTGROUP');
|
||||
return [];
|
||||
}}
|
||||
get options() {{
|
||||
return this.tagName==='SELECT'
|
||||
? this.children.flatMap(child=>child.tagName==='OPTGROUP'?child.children:[child])
|
||||
: undefined;
|
||||
}}
|
||||
}}
|
||||
globalThis.window={{_activeProvider:'custom:llm-proxy'}};
|
||||
globalThis.document={{createElement:tag=>new Node(tag)}};
|
||||
globalThis._dynamicModelLabels={{}};
|
||||
globalThis._modelStateForSelect=()=>({{model:'',model_provider:null}});
|
||||
globalThis._applyModelToDropdown=()=>null;
|
||||
globalThis.S={{session:null}};
|
||||
function addCatalog(select, value) {{
|
||||
const group=select.querySelectorAll('optgroup')[0] || (()=>{{
|
||||
const item=new Node('optgroup'); item.dataset.provider='custom:llm-proxy'; select.appendChild(item); return item;
|
||||
}})();
|
||||
const catalog=new Node('option'); catalog.value=value; catalog.textContent=value; group.appendChild(catalog);
|
||||
}}
|
||||
function snapshot(select) {{
|
||||
if(!select.value && select.options[0]) select.value=select.options[0].value;
|
||||
_deduplicateModelPickerOptions(select,select.value);
|
||||
return {{groups:select.querySelectorAll('optgroup').map(item=>item.children.map(option=>option.value)),selected:select.value}};
|
||||
}}
|
||||
function makeSelect() {{
|
||||
const select=new Node('select');
|
||||
const group=new Node('optgroup'); group.dataset.provider='custom:llm-proxy'; select.appendChild(group);
|
||||
return select;
|
||||
}}
|
||||
const liveFirst=makeSelect();
|
||||
_addLiveModelsToSelect('custom:llm-proxy',[{{id:'x-ai/grok-4.5',label:'Grok 4.5'}}],liveFirst);
|
||||
addCatalog(liveFirst,'x-ai/grok-4.5');
|
||||
liveFirst.value=liveFirst.options[0].value;
|
||||
_addLiveModelsToSelect('custom:llm-proxy',[{{id:'x-ai/grok-4.5',label:'Grok 4.5'}}],liveFirst);
|
||||
const otherGroup=new Node('optgroup'); otherGroup.dataset.provider='custom:other-proxy';
|
||||
const other=new Node('option'); other.value='x-ai/grok-4.5'; otherGroup.appendChild(other); liveFirst.appendChild(otherGroup);
|
||||
const catalogFirst=makeSelect();
|
||||
addCatalog(catalogFirst,'x-ai/grok-4.5');
|
||||
_addLiveModelsToSelect('custom:llm-proxy',[{{id:'x-ai/grok-4.5',label:'Grok 4.5'}}],catalogFirst);
|
||||
const selectedBare=makeSelect();
|
||||
addCatalog(selectedBare,'x-ai/grok-4.5');
|
||||
selectedBare.value='x-ai/grok-4.5';
|
||||
_addLiveModelsToSelect('custom:llm-proxy',[{{id:'x-ai/grok-4.5',label:'Grok 4.5'}}],selectedBare);
|
||||
const sameSuffix=makeSelect();
|
||||
addCatalog(sameSuffix,'vendor-a/deepseek-v4-pro');
|
||||
addCatalog(sameSuffix,'vendor-b/catalog/deepseek-v4-pro');
|
||||
sameSuffix.value='vendor-a/deepseek-v4-pro';
|
||||
const sameGroupColonSuffix=makeSelect();
|
||||
_addLiveModelsToSelect('custom:llm-proxy',[{{id:'@custom:llm-proxy:deepseek/deepseek-r1:free',label:'DeepSeek R1 Free'}}],sameGroupColonSuffix);
|
||||
_addLiveModelsToSelect('custom:llm-proxy',[{{id:'@custom:llm-proxy:meta-llama/llama-3.3:free',label:'Llama 3.3 Free'}}],sameGroupColonSuffix);
|
||||
const crossProviderLive=new Node('select');
|
||||
_addLiveModelsToSelect('custom:a',[{{id:'@custom:a:gpt-4o',label:'GPT-4o A'}}],crossProviderLive);
|
||||
_addLiveModelsToSelect('custom:b',[{{id:'@custom:b:gpt-4o',label:'GPT-4o B'}}],crossProviderLive);
|
||||
const unnamespaced=makeSelect();
|
||||
addCatalog(unnamespaced,'gpt-4o');
|
||||
_addLiveModelsToSelect('custom:llm-proxy',[{{id:'@custom:llm-proxy:gpt-4o',label:'GPT-4o'}}],unnamespaced);
|
||||
console.log(JSON.stringify({{
|
||||
liveFirst:snapshot(liveFirst),
|
||||
catalogFirst:snapshot(catalogFirst),
|
||||
selectedBare:snapshot(selectedBare),
|
||||
sameSuffix:snapshot(sameSuffix),
|
||||
sameGroupColonSuffix:snapshot(sameGroupColonSuffix),
|
||||
crossProviderLive:snapshot(crossProviderLive),
|
||||
unnamespaced:snapshot(unnamespaced),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_cross_source_proxy_and_catalog_twin_has_one_routable_row():
|
||||
result = _run_harness()
|
||||
assert result["liveFirst"] == {
|
||||
"groups": [["@custom:llm-proxy:x-ai/grok-4.5"], ["x-ai/grok-4.5"]],
|
||||
"selected": "@custom:llm-proxy:x-ai/grok-4.5",
|
||||
}
|
||||
|
||||
|
||||
def test_catalog_first_keeps_the_routable_proxy_row():
|
||||
assert _run_harness()["catalogFirst"] == {
|
||||
"groups": [["@custom:llm-proxy:x-ai/grok-4.5"]],
|
||||
"selected": "@custom:llm-proxy:x-ai/grok-4.5",
|
||||
}
|
||||
|
||||
|
||||
def test_selected_bare_occurrence_survives_same_group_dedup():
|
||||
assert _run_harness()["selectedBare"] == {
|
||||
"groups": [["x-ai/grok-4.5"]],
|
||||
"selected": "x-ai/grok-4.5",
|
||||
}
|
||||
|
||||
|
||||
def test_same_suffix_models_in_one_group_do_not_collapse():
|
||||
assert _run_harness()["sameSuffix"] == {
|
||||
"groups": [["vendor-a/deepseek-v4-pro", "vendor-b/catalog/deepseek-v4-pro"]],
|
||||
"selected": "vendor-a/deepseek-v4-pro",
|
||||
}
|
||||
|
||||
|
||||
def test_same_group_colon_suffixed_proxy_models_remain_distinct():
|
||||
assert _run_harness()["sameGroupColonSuffix"] == {
|
||||
"groups": [[
|
||||
"@custom:llm-proxy:deepseek/deepseek-r1:free",
|
||||
"@custom:llm-proxy:meta-llama/llama-3.3:free",
|
||||
]],
|
||||
"selected": "@custom:llm-proxy:deepseek/deepseek-r1:free",
|
||||
}
|
||||
|
||||
|
||||
def test_live_models_with_same_identity_survive_in_different_provider_groups():
|
||||
assert _run_harness()["crossProviderLive"] == {
|
||||
"groups": [["@custom:a:gpt-4o"], ["@custom:b:gpt-4o"]],
|
||||
"selected": "@custom:a:gpt-4o",
|
||||
}
|
||||
|
||||
|
||||
def test_unnamespaced_custom_proxy_value_deduplicates_with_catalog():
|
||||
assert _run_harness()["unnamespaced"] == {
|
||||
"groups": [["@custom:llm-proxy:gpt-4o"]],
|
||||
"selected": "@custom:llm-proxy:gpt-4o",
|
||||
}
|
||||
Loading…
Reference in a new issue