feat: 重新设计模型选择栏与 Think 模式
模型选择栏: - 去掉原生 select 样式,自定义 appearance:none + 箭头图标 - 模型列表显示参数大小 (如 qwen2.5 · 7B) - 选中模型后展示能力标签 (🧠 Think / 👁️ Vision) Think 模式: - 从模型栏移至输入区域,紧邻发送按钮 - 使用灯泡图标按钮替代原来的 toggle 开关 - 默认 disabled,切换模型时调用 /api/show 检测 capabilities - capabilities 包含 'thinking' 时才启用,否则禁用并灰显 - 模型能力结果缓存,避免重复请求
This commit is contained in:
+92
-4
@@ -248,24 +248,34 @@ body::before {
|
|||||||
.model-bar {
|
.model-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
padding: 6px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border-glass);
|
border-bottom: 1px solid var(--border-glass);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-select {
|
.model-select-wrap {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 6px 12px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 32px 7px 12px;
|
||||||
background: var(--bg-glass-light);
|
background: var(--bg-glass-light);
|
||||||
border: 1px solid var(--border-glass);
|
border: 1px solid var(--border-glass);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-select:focus {
|
.model-select:focus {
|
||||||
@@ -276,6 +286,84 @@ body::before {
|
|||||||
.model-select option {
|
.model-select option {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-select-arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-select-arrow svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-info-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--bg-glass-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-glass);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Think 按钮(输入栏内) ── */
|
||||||
|
.think-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-toggle input { display: none; }
|
||||||
|
|
||||||
|
.think-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-glass-light);
|
||||||
|
border: 1px solid var(--border-glass);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: var(--transition);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-toggle:not(.unavailable):hover .think-btn {
|
||||||
|
border-color: var(--accent-purple);
|
||||||
|
color: var(--accent-purple);
|
||||||
|
background: rgba(123, 47, 247, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-toggle input:checked + .think-btn {
|
||||||
|
background: rgba(123, 47, 247, 0.15);
|
||||||
|
border-color: var(--accent-purple);
|
||||||
|
color: var(--accent-purple);
|
||||||
|
box-shadow: 0 0 10px rgba(123, 47, 247, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-toggle.unavailable .think-btn {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-toggle.unavailable .think-btn svg {
|
||||||
|
stroke: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 开关控件 ── */
|
/* ── 开关控件 ── */
|
||||||
|
|||||||
+15
-5
@@ -47,14 +47,15 @@
|
|||||||
|
|
||||||
<!-- ═══════════════ 模型选择栏 ═══════════════ -->
|
<!-- ═══════════════ 模型选择栏 ═══════════════ -->
|
||||||
<div class="model-bar">
|
<div class="model-bar">
|
||||||
|
<div class="model-select-wrap">
|
||||||
<select class="model-select" id="modelSelect">
|
<select class="model-select" id="modelSelect">
|
||||||
<option value="">正在加载模型...</option>
|
<option value="">正在加载模型...</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="toggle-label" title="Think 模式:启用深度推理">
|
<span class="model-select-arrow">
|
||||||
<input type="checkbox" id="toggleThink">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
<span class="toggle-slider"></span>
|
</span>
|
||||||
<span>Think</span>
|
</div>
|
||||||
</label>
|
<span class="model-info-badge" id="modelInfoBadge" title="模型信息"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══════════════ 聊天区域 ═══════════════ -->
|
<!-- ═══════════════ 聊天区域 ═══════════════ -->
|
||||||
@@ -94,6 +95,15 @@
|
|||||||
</button>
|
</button>
|
||||||
<input type="file" id="fileInput" accept="image/*" multiple style="display:none;">
|
<input type="file" id="fileInput" accept="image/*" multiple style="display:none;">
|
||||||
<textarea class="chat-input" id="chatInput" placeholder="输入消息..." rows="1"></textarea>
|
<textarea class="chat-input" id="chatInput" placeholder="输入消息..." rows="1"></textarea>
|
||||||
|
<label class="think-toggle" id="thinkToggleWrap" title="Think 模式未可用">
|
||||||
|
<input type="checkbox" id="toggleThink" disabled>
|
||||||
|
<span class="think-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/>
|
||||||
|
<line x1="9" y1="21" x2="15" y2="21"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<button class="send-btn" id="btnSend">
|
<button class="send-btn" id="btnSend">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
|||||||
+107
-16
@@ -1,61 +1,152 @@
|
|||||||
/**
|
/**
|
||||||
* ModelBar - 模型选择栏组件
|
* ModelBar - 模型选择栏组件
|
||||||
|
* 包含模型下拉选择、Think 能力检测、模型信息展示
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { state, KEYS } from '../state.js';
|
import { state, KEYS } from '../state.js';
|
||||||
import { formatSize } from '../utils.js';
|
import { formatSize } from '../utils.js';
|
||||||
import { showToast } from './toast.js';
|
|
||||||
|
|
||||||
let modelSelectEl, toggleThinkEl;
|
let modelSelectEl, modelInfoBadgeEl, thinkCheckbox, thinkWrap;
|
||||||
|
|
||||||
|
/** 缓存模型能力 { modelName: { think: bool, vision: bool } } */
|
||||||
|
const modelCapabilityCache = new Map();
|
||||||
|
|
||||||
export function initModelBar() {
|
export function initModelBar() {
|
||||||
modelSelectEl = document.querySelector('#modelSelect');
|
modelSelectEl = document.querySelector('#modelSelect');
|
||||||
toggleThinkEl = document.querySelector('#toggleThink');
|
modelInfoBadgeEl = document.querySelector('#modelInfoBadge');
|
||||||
|
thinkCheckbox = document.querySelector('#toggleThink');
|
||||||
|
thinkWrap = document.querySelector('#thinkToggleWrap');
|
||||||
|
|
||||||
// 模型选择变化 → 保存到设置
|
// 模型切换 → 检测能力 + 保存设置
|
||||||
modelSelectEl.addEventListener('change', async () => {
|
modelSelectEl.addEventListener('change', async () => {
|
||||||
const db = state.get(KEYS.DB);
|
const model = modelSelectEl.value;
|
||||||
if (db) await db.saveSetting('selectedModel', modelSelectEl.value);
|
if (db()) await db().saveSetting('selectedModel', model);
|
||||||
|
if (model) {
|
||||||
|
await checkModelCapability(model);
|
||||||
|
} else {
|
||||||
|
setThinkAvailable(false);
|
||||||
|
setModelBadge('');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function db() { return state.get(KEYS.DB); }
|
||||||
|
function api() { return state.get(KEYS.API); }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载模型列表
|
* 加载模型列表
|
||||||
*/
|
*/
|
||||||
export async function loadModels() {
|
export async function loadModels() {
|
||||||
const api = state.get(KEYS.API);
|
if (!api()) return;
|
||||||
const db = state.get(KEYS.DB);
|
|
||||||
const currentSession = state.get(KEYS.CURRENT_SESSION);
|
|
||||||
|
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.listModels();
|
const data = await api().listModels();
|
||||||
modelSelectEl.innerHTML = '';
|
modelSelectEl.innerHTML = '';
|
||||||
|
|
||||||
if (!data.models || data.models.length === 0) {
|
if (!data.models || data.models.length === 0) {
|
||||||
modelSelectEl.innerHTML = '<option value="">未安装任何模型</option>';
|
modelSelectEl.innerHTML = '<option value="">未安装任何模型</option>';
|
||||||
|
setModelBadge('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
data.models.forEach(m => {
|
data.models.forEach(m => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = m.name;
|
opt.value = m.name;
|
||||||
opt.textContent = `${m.name} (${formatSize(m.size)})`;
|
const paramSize = m.details?.parameter_size || '';
|
||||||
|
const family = m.details?.family || '';
|
||||||
|
const label = paramSize ? `${m.name} · ${paramSize}` : m.name;
|
||||||
|
opt.textContent = label;
|
||||||
modelSelectEl.appendChild(opt);
|
modelSelectEl.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 恢复上次选择的模型
|
// 恢复上次选择的模型
|
||||||
if (currentSession && currentSession.model) {
|
const currentSession = state.get(KEYS.CURRENT_SESSION);
|
||||||
|
if (currentSession?.model) {
|
||||||
modelSelectEl.value = currentSession.model;
|
modelSelectEl.value = currentSession.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (db) await db.saveSetting('selectedModel', modelSelectEl.value);
|
if (db()) await db().saveSetting('selectedModel', modelSelectEl.value);
|
||||||
|
|
||||||
|
// 检测当前选中模型的能力
|
||||||
|
if (modelSelectEl.value) {
|
||||||
|
await checkModelCapability(modelSelectEl.value);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ModelBar] 加载模型失败:', err);
|
console.error('[ModelBar] 加载模型失败:', err);
|
||||||
modelSelectEl.innerHTML = '<option value="">加载失败 - 检查连接</option>';
|
modelSelectEl.innerHTML = '<option value="">加载失败 - 检查连接</option>';
|
||||||
|
setModelBadge('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 /api/show 检测模型能力
|
||||||
|
* @param {string} modelName
|
||||||
|
*/
|
||||||
|
async function checkModelCapability(modelName) {
|
||||||
|
// 命中缓存
|
||||||
|
if (modelCapabilityCache.has(modelName)) {
|
||||||
|
applyCapability(modelName, modelCapabilityCache.get(modelName));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!api()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await api().showModel(modelName);
|
||||||
|
const capabilities = info.capabilities || [];
|
||||||
|
const caps = {
|
||||||
|
think: capabilities.includes('thinking'),
|
||||||
|
vision: capabilities.includes('vision'),
|
||||||
|
completion: capabilities.includes('completion'),
|
||||||
|
};
|
||||||
|
modelCapabilityCache.set(modelName, caps);
|
||||||
|
applyCapability(modelName, caps);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ModelBar] 获取模型能力失败:', err);
|
||||||
|
// 降级:不支持 think
|
||||||
|
const fallback = { think: false, vision: false, completion: true };
|
||||||
|
modelCapabilityCache.set(modelName, fallback);
|
||||||
|
applyCapability(modelName, fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据能力设置 UI 状态
|
||||||
|
*/
|
||||||
|
function applyCapability(modelName, caps) {
|
||||||
|
setThinkAvailable(caps.think);
|
||||||
|
|
||||||
|
// 更新信息标签
|
||||||
|
const tags = [];
|
||||||
|
if (caps.think) tags.push('🧠 Think');
|
||||||
|
if (caps.vision) tags.push('👁️ Vision');
|
||||||
|
setModelBadge(tags.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 Think 开关是否可用
|
||||||
|
*/
|
||||||
|
function setThinkAvailable(available) {
|
||||||
|
if (available) {
|
||||||
|
thinkCheckbox.disabled = false;
|
||||||
|
thinkWrap.title = 'Think 模式:启用深度推理';
|
||||||
|
thinkWrap.classList.remove('unavailable');
|
||||||
|
} else {
|
||||||
|
thinkCheckbox.checked = false;
|
||||||
|
thinkCheckbox.disabled = true;
|
||||||
|
thinkWrap.title = '当前模型不支持 Think 模式';
|
||||||
|
thinkWrap.classList.add('unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模型信息标签
|
||||||
|
*/
|
||||||
|
function setModelBadge(text) {
|
||||||
|
modelInfoBadgeEl.textContent = text;
|
||||||
|
modelInfoBadgeEl.style.display = text ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前选择的模型
|
* 获取当前选择的模型
|
||||||
*/
|
*/
|
||||||
@@ -74,5 +165,5 @@ export function setSelectedModel(modelName) {
|
|||||||
* Think 模式是否开启
|
* Think 模式是否开启
|
||||||
*/
|
*/
|
||||||
export function isThinkEnabled() {
|
export function isThinkEnabled() {
|
||||||
return toggleThinkEl.checked;
|
return thinkCheckbox.checked && !thinkCheckbox.disabled;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user