From 286511d0a843bffff925418f119f8dff8283396d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:48:50 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 10 + web/static/i18n/en-US.json | 5 +- web/static/i18n/zh-CN.json | 5 +- web/static/js/chat-scroll-refresh.test.cjs | 4 +- web/static/js/chat.js | 393 ++++++++++++++------- web/static/js/hitl-approval-ui.test.cjs | 31 +- web/templates/index.html | 79 +++-- 7 files changed, 340 insertions(+), 187 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index a8018887..dcb8b92b 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -45920,6 +45920,16 @@ html[data-theme="dark"] .conversation-sidebar .recent-conversations-section { font-family: inherit; } +.chat-system-channel-option-label { + font-family: inherit; +} + +.chat-system-model-section-divider { + height: 1px; + margin: 5px 10px; + background: var(--border-color, #e5e7eb); +} + .chat-system-model-current { flex: 0 0 auto; padding: 3px 7px; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index a7dcd404..ba8a69d3 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -773,9 +773,9 @@ "reasoningPanelHint": "Only Eino single- and multi-agent requests use these; merged with defaults in Settings.", "sessionSettingsTitle": "Session settings", "sessionSettingsAria": "Open session settings", - "sessionSettingsHint": "AI channel, reasoning, and HITL settings only affect future messages.", + "sessionSettingsHint": "Human-in-the-loop settings only affect future messages.", "sessionShortcutAuditAgent": "Agent review", - "modelSettingsAria": "Choose model and reasoning effort", + "modelSettingsAria": "Choose AI channel, model, and reasoning settings", "systemModelPickerTitle": "Choose system model", "systemModelField": "Model", "systemModelLoading": "Fetching model list…", @@ -783,6 +783,7 @@ "systemModelCurrent": "Current", "systemModelSaving": "Saving…", "systemModelSaved": "Saved automatically", + "reasoningSessionUpdated": "Session reasoning updated", "systemModelLoadFailed": "Failed to fetch models", "systemModelSaveFailed": "Failed to save model", "systemModelApplyFailed": "Failed to apply model", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 2690b6b1..5b0fb38e 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -761,9 +761,9 @@ "reasoningPanelHint": "仅 Eino 单代理与多代理请求会带上这些参数;与系统设置中的默认值合并。", "sessionSettingsTitle": "会话设置", "sessionSettingsAria": "打开会话设置", - "sessionSettingsHint": "AI 通道、推理设置与人机协同只影响后续消息。", + "sessionSettingsHint": "人机协同设置只影响后续消息。", "sessionShortcutAuditAgent": "Agent 审查", - "modelSettingsAria": "选择模型与推理强度", + "modelSettingsAria": "选择 AI 通道、模型与推理设置", "systemModelPickerTitle": "选择系统模型", "systemModelField": "模型", "systemModelLoading": "正在获取模型列表…", @@ -771,6 +771,7 @@ "systemModelCurrent": "当前", "systemModelSaving": "正在保存…", "systemModelSaved": "已自动保存", + "reasoningSessionUpdated": "会话推理设置已更新", "systemModelLoadFailed": "获取模型失败", "systemModelSaveFailed": "保存模型失败", "systemModelApplyFailed": "应用模型失败", diff --git a/web/static/js/chat-scroll-refresh.test.cjs b/web/static/js/chat-scroll-refresh.test.cjs index 151063fa..7472ac7c 100644 --- a/web/static/js/chat-scroll-refresh.test.cjs +++ b/web/static/js/chat-scroll-refresh.test.cjs @@ -398,7 +398,7 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状 assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/); assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/); assert.match(html, /router\.js\?v=20260813-2/); - assert.match(html, /chat\.js\?v=20260815-2/); + assert.match(html, /chat\.js\?v=20260818-3/); }); test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => { @@ -462,5 +462,5 @@ test('暗色模式对话三点悬浮不会触发浅色父行背景', () => { const css = fs.readFileSync('web/static/css/style.css', 'utf8'); assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/); assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/); - assert.match(html, /style\.css\?v=20260815-2/); + assert.match(html, /style\.css\?v=20260818-3/); }); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index a26c05f0..3e9f518a 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -111,6 +111,8 @@ let chatSystemModelCloseTimer = null; let chatSystemModelOptions = []; let chatSystemModelCurrent = ''; let chatSystemModelLoadError = ''; +const CHAT_SYSTEM_MODEL_CACHE_TTL_MS = 5 * 60 * 1000; +const chatSystemModelCache = new Map(); // 人机协同(HITL)会话级配置 const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl'; @@ -1061,27 +1063,31 @@ function currentHitlAuditModelLabel() { return chatHitlAuditModelName || currentSystemModelLabel(); } -function currentSystemReasoningEffort() { - const ch = chatDefaultAIChannel ? chatAIChannels[chatDefaultAIChannel] : null; - const reasoning = ch && ch.reasoning && typeof ch.reasoning === 'object' ? ch.reasoning : {}; - const effort = typeof reasoning.effort === 'string' ? reasoning.effort.trim() : ''; - return ['', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort) ? effort : ''; +function resolveChatPickerChannelId() { + return selectedChatAIChannelId() || chatDefaultAIChannel; } -function chatSystemModelConfigState(cfg) { +function chatSystemModelConfigState(cfg, preferredChannelId) { const source = cfg && typeof cfg === 'object' ? cfg : {}; const sourceAI = source.ai && typeof source.ai === 'object' ? source.ai : {}; const channels = sourceAI.channels && typeof sourceAI.channels === 'object' ? { ...sourceAI.channels } : {}; - let channelId = String(sourceAI.default_channel || '').trim(); + const resolveFromChannels = function (value) { + const raw = String(value || '').trim(); + if (raw && channels[raw]) return raw; + const normalized = normalizeChatAIChannelId(raw); + return normalized + ? Object.keys(channels).find(function (id) { + return normalizeChatAIChannelId(id) === normalized; + }) || '' + : ''; + }; + let defaultChannelId = resolveFromChannels(sourceAI.default_channel); + let channelId = resolveFromChannels(preferredChannelId) || defaultChannelId; if (!channels[channelId]) { - const normalized = normalizeChatAIChannelId(channelId); - channelId = Object.keys(channels).find(function (id) { - return normalizeChatAIChannelId(id) === normalized; - }) || ''; + channelId = Object.keys(channels)[0] || 'default'; } - if (!channelId) channelId = Object.keys(channels)[0] || 'default'; if (!channels[channelId]) { const legacy = source.openai && typeof source.openai === 'object' ? source.openai : {}; channels[channelId] = { @@ -1092,8 +1098,9 @@ function chatSystemModelConfigState(cfg) { model: legacy.model || '' }; } + if (!defaultChannelId) defaultChannelId = channelId; return { - ai: { ...sourceAI, default_channel: channelId, channels: channels }, + ai: { ...sourceAI, default_channel: defaultChannelId, channels: channels }, channelId: channelId, channel: channels[channelId] }; @@ -1110,7 +1117,9 @@ function chatSystemModelElements() { list: document.getElementById('chat-system-model-list'), status: document.getElementById('chat-system-model-status'), subviewStatus: document.getElementById('chat-system-model-subview-status'), + channelValue: document.getElementById('chat-system-model-channel-value'), currentValue: document.getElementById('chat-system-model-current-value'), + modeValue: document.getElementById('chat-system-model-mode-value'), effortValue: document.getElementById('chat-system-model-effort-value') }; } @@ -1140,11 +1149,28 @@ function currentChatReasoningEffort() { return effort ? String(effort.value || '').trim() : ''; } +function currentChatReasoningMode() { + const mode = document.getElementById('chat-reasoning-mode'); + const value = mode ? String(mode.value || 'default').trim() : 'default'; + return ['default', 'off', 'on', 'auto'].includes(value) ? value : 'default'; +} + +function currentChatReasoningMenuLabel() { + const modeValue = currentChatReasoningMode(); + if (modeValue === 'off') return chatTranslate('chat.reasoningModeOff', '关闭'); + const effort = currentChatReasoningEffort(); + return effort ? chatReasoningEffortLabel(effort) : reasoningSummaryModeLabel(modeValue); +} + function updateChatSystemModelPickerValues() { const ui = chatSystemModelElements(); - const model = currentSystemModelLabel(); - const effort = chatReasoningEffortLabel(currentSystemReasoningEffort()); + const channel = currentChatAIChannelLabel(); + const model = currentChatModelLabel(); + const mode = reasoningSummaryModeLabel(currentChatReasoningMode()); + const effort = currentChatReasoningMenuLabel(); + if (ui.channelValue) ui.channelValue.textContent = channel; if (ui.currentValue) ui.currentValue.textContent = model; + if (ui.modeValue) ui.modeValue.textContent = mode; if (ui.effortValue) ui.effortValue.textContent = effort; const composerEffort = document.getElementById('chat-model-shortcut-effort'); if (composerEffort) composerEffort.textContent = effort; @@ -1218,7 +1244,7 @@ function renderChatReasoningEffortOptions() { const ui = chatSystemModelElements(); if (!ui.list) return; ui.list.innerHTML = ''; - const currentEffort = currentSystemReasoningEffort(); + const currentEffort = currentChatReasoningEffort(); ['', 'low', 'medium', 'high', 'xhigh', 'max'].forEach(function (effort) { const option = document.createElement('button'); option.type = 'button'; @@ -1247,61 +1273,68 @@ function renderChatReasoningEffortOptions() { }); } -async function selectChatReasoningEffort(effort) { - if (chatSystemModelSaving) return; - if (typeof requirePermission === 'function' && !requirePermission('config:write')) return; - const chosen = ['', 'low', 'medium', 'high', 'xhigh', 'max'].includes(String(effort || '').trim()) - ? String(effort || '').trim() - : ''; +function renderChatReasoningModeOptions() { const ui = chatSystemModelElements(); - chatSystemModelSaving = true; - if (ui.list) { - ui.list.querySelectorAll('button').forEach(function (button) { button.disabled = true; }); - } - setChatSystemModelStatus(chatTranslate('chat.systemModelSaving', '正在保存…'), 'loading'); - try { - const latestResponse = await apiFetch('/api/config'); - if (!latestResponse.ok) { - throw new Error(await readChatSystemModelError(latestResponse, chatTranslate('chat.systemModelSaveFailed', '保存失败'))); + if (!ui.list) return; + ui.list.innerHTML = ''; + const currentMode = currentChatReasoningMode(); + ['default', 'off', 'on', 'auto'].forEach(function (mode) { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'chat-system-model-option'; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', mode === currentMode ? 'true' : 'false'); + if (mode === currentMode) option.classList.add('is-selected'); + const label = document.createElement('span'); + label.className = 'chat-system-model-option-label chat-system-effort-option-label'; + label.textContent = reasoningSummaryModeLabel(mode); + option.appendChild(label); + if (mode === currentMode) { + const current = document.createElement('span'); + current.className = 'chat-system-model-current'; + current.textContent = chatTranslate('chat.systemModelCurrent', '当前'); + option.appendChild(current); } - const latest = await latestResponse.json(); - const state = chatSystemModelConfigState(latest); - state.ai.channels[state.channelId] = { - ...state.channel, - reasoning: { ...(state.channel.reasoning || {}), effort: chosen } - }; - const updateResponse = await apiFetch('/api/config', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ai: state.ai }) + option.addEventListener('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + selectChatReasoningMode(mode); }); - if (!updateResponse.ok) { - throw new Error(await readChatSystemModelError(updateResponse, chatTranslate('chat.systemModelSaveFailed', '保存失败'))); - } - const applyResponse = await apiFetch('/api/config/apply', { method: 'POST' }); - if (!applyResponse.ok) { - throw new Error(await readChatSystemModelError(applyResponse, chatTranslate('chat.systemModelApplyFailed', '应用模型失败'))); - } - chatAIChannels = state.ai.channels; - chatDefaultAIChannel = state.channelId; - await initChatAgentModeFromConfig(); - updateChatComposerSessionShortcuts(); - renderChatReasoningEffortOptions(); - setChatSystemModelStatus(chatTranslate('chat.systemModelSaved', '已自动保存'), 'success'); - if (chatSystemModelCloseTimer) window.clearTimeout(chatSystemModelCloseTimer); - chatSystemModelCloseTimer = window.setTimeout(function () { - chatSystemModelSaving = false; - closeChatSystemModelPicker(true); - }, 650); - return; - } catch (error) { - console.error('selectChatReasoningEffort', error); - setChatSystemModelStatus(error.message || chatTranslate('chat.systemModelSaveFailed', '保存失败'), 'error'); - } - chatSystemModelSaving = false; - if (ui.list) { - ui.list.querySelectorAll('button').forEach(function (button) { button.disabled = false; }); - } + ui.list.appendChild(option); + }); +} + +function finishChatReasoningPickerUpdate() { + persistChatReasoningPrefs(); + setChatSystemModelStatus(chatTranslate('chat.reasoningSessionUpdated', '会话推理设置已更新'), 'success'); + if (chatSystemModelCloseTimer) window.clearTimeout(chatSystemModelCloseTimer); + chatSystemModelCloseTimer = window.setTimeout(function () { + chatSystemModelSaving = false; + closeChatSystemModelPicker(true); + }, 450); +} + +function selectChatReasoningMode(mode) { + if (chatSystemModelSaving) return; + const chosen = ['default', 'off', 'on', 'auto'].includes(String(mode || '').trim()) + ? String(mode || '').trim() + : 'default'; + const modeControl = document.getElementById('chat-reasoning-mode'); + if (!modeControl) return; + chatSystemModelSaving = true; + modeControl.value = chosen; + finishChatReasoningPickerUpdate(); +} + +function selectChatReasoningEffort(effort) { + if (chatSystemModelSaving) return; + const raw = String(effort || '').trim(); + const chosen = ['', 'low', 'medium', 'high', 'xhigh', 'max'].includes(raw) ? raw : ''; + const effortControl = document.getElementById('chat-reasoning-effort'); + if (!effortControl) return; + chatSystemModelSaving = true; + effortControl.value = chosen; + finishChatReasoningPickerUpdate(); } function renderChatSystemModelRetry() { @@ -1313,12 +1346,62 @@ function renderChatSystemModelRetry() { retry.className = 'chat-system-model-retry'; retry.textContent = chatTranslate('chat.systemModelRetry', '重新获取'); retry.addEventListener('click', function (retryEvent) { - closeChatSystemModelPicker(true); - openChatSystemModelPicker(retryEvent); + retryEvent.preventDefault(); + retryEvent.stopPropagation(); + fetchChatSystemModelsForChannel(resolveChatPickerChannelId(), { force: true }); }); ui.list.appendChild(retry); } +function renderChatAIChannelOptions() { + const ui = chatSystemModelElements(); + if (!ui.list) return; + ui.list.innerHTML = ''; + const selected = selectedChatAIChannelId(); + const choices = [{ id: '', label: chatTranslate('chat.aiChannelDefault', '跟随默认通道') }] + .concat(Object.keys(chatAIChannels).sort().map(function (id) { + const channel = chatAIChannels[id] || {}; + return { id: id, label: channel.name || id }; + })); + choices.forEach(function (choice) { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'chat-system-model-option'; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', choice.id === selected ? 'true' : 'false'); + if (choice.id === selected) option.classList.add('is-selected'); + const label = document.createElement('span'); + label.className = 'chat-system-model-option-label chat-system-channel-option-label'; + label.textContent = choice.label; + option.appendChild(label); + if (choice.id === selected) { + const current = document.createElement('span'); + current.className = 'chat-system-model-current'; + current.textContent = chatTranslate('chat.systemModelCurrent', '当前'); + option.appendChild(current); + } + option.addEventListener('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + selectChatAIChannel(choice.id); + }); + ui.list.appendChild(option); + }); +} + +async function selectChatAIChannel(channelId) { + const select = document.getElementById('chat-ai-channel-select'); + if (!select) return; + const resolved = resolveChatAIChannelId(channelId); + select.value = resolved || ''; + persistChatAIChannelPref(); + refreshSessionSettingsSelects(); + updateChatSystemModelPickerValues(); + openChatSystemModelView('main'); + setChatSystemModelStatus(chatTranslate('chat.systemModelLoading', '正在获取模型列表…'), 'loading'); + await fetchChatSystemModelsForChannel(resolveChatPickerChannelId(), { force: true }); +} + function openChatSystemModelView(view, event) { if (event) { event.preventDefault(); @@ -1335,6 +1418,18 @@ function openChatSystemModelView(view, event) { ui.main.hidden = true; ui.subview.hidden = false; ui.subview.dataset.view = view; + if (view === 'channel') { + if (ui.subviewTitle) ui.subviewTitle.textContent = chatTranslate('chat.aiChannelLabel', 'AI 通道'); + setChatSystemModelStatus('', ''); + renderChatAIChannelOptions(); + return; + } + if (view === 'mode') { + if (ui.subviewTitle) ui.subviewTitle.textContent = chatTranslate('chat.reasoningModeLabel', '推理模式'); + setChatSystemModelStatus('', ''); + renderChatReasoningModeOptions(); + return; + } if (view === 'effort') { if (ui.subviewTitle) ui.subviewTitle.textContent = chatTranslate('chat.reasoningEffortLabel', '推理强度'); setChatSystemModelStatus('', ''); @@ -1374,7 +1469,7 @@ async function selectChatSystemModel(model) { throw new Error(await readChatSystemModelError(latestResponse, chatTranslate('chat.systemModelSaveFailed', '保存失败'))); } const latest = await latestResponse.json(); - const state = chatSystemModelConfigState(latest); + const state = chatSystemModelConfigState(latest, resolveChatPickerChannelId()); state.ai.channels[state.channelId] = { ...state.channel, model: chosen }; const updateResponse = await apiFetch('/api/config', { method: 'PUT', @@ -1389,7 +1484,7 @@ async function selectChatSystemModel(model) { throw new Error(await readChatSystemModelError(applyResponse, chatTranslate('chat.systemModelApplyFailed', '应用模型失败'))); } chatAIChannels = state.ai.channels; - chatDefaultAIChannel = state.channelId; + chatDefaultAIChannel = resolveChatAIChannelId(state.ai.default_channel) || state.channelId; updateChatComposerSessionShortcuts(); await initChatAgentModeFromConfig(); chatSystemModelCurrent = chosen; @@ -1413,6 +1508,89 @@ async function selectChatSystemModel(model) { } } +function chatSystemModelCacheKey(channelId, channel) { + return [ + String(channelId || ''), + String(channel && channel.provider || 'openai'), + String(channel && channel.base_url || '').trim() + ].join('|'); +} + +async function fetchChatSystemModelsForChannel(channelId, options) { + const opts = options || {}; + const ui = chatSystemModelElements(); + if (!ui.menu || !ui.list || ui.menu.hidden) return; + const resolvedChannelId = resolveChatAIChannelId(channelId) || chatDefaultAIChannel; + const channel = resolvedChannelId ? chatAIChannels[resolvedChannelId] || {} : {}; + const cacheKey = chatSystemModelCacheKey(resolvedChannelId, channel); + const cached = chatSystemModelCache.get(cacheKey); + const requestId = ++chatSystemModelRequestSeq; + chatSystemModelCurrent = String(channel.model || '').trim(); + chatSystemModelOptions = []; + chatSystemModelLoadError = ''; + if (!opts.force && cached && Date.now() - cached.fetchedAt < CHAT_SYSTEM_MODEL_CACHE_TTL_MS) { + chatSystemModelOptions = cached.models.slice(); + if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') { + renderChatSystemModelOptions(chatSystemModelOptions, chatSystemModelCurrent); + } + const cachedCount = [chatSystemModelCurrent].concat(chatSystemModelOptions) + .map(function (model) { return String(model || '').trim(); }) + .filter(function (model, index, all) { return model && all.indexOf(model) === index; }) + .length; + setChatSystemModelStatus( + chatTranslate('chat.systemModelLoaded', '已获取 {count} 个模型').replace('{count}', String(cachedCount)), + 'success' + ); + return; + } + if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') { + ui.list.innerHTML = ''; + } + setChatSystemModelStatus(chatTranslate('chat.systemModelLoading', '正在获取模型列表…'), 'loading'); + try { + if (!String(channel.api_key || '').trim()) { + throw new Error(chatTranslate('chat.systemModelNeedApiKey', '请先在系统设置中配置 API Key')); + } + const listResponse = await apiFetch('/api/config/list-models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: channel.provider || 'openai', + base_url: String(channel.base_url || '').trim(), + api_key: String(channel.api_key || '').trim() + }) + }); + const result = await listResponse.json().catch(function () { return {}; }); + if (!listResponse.ok || !result.success) { + throw new Error(result.error || chatTranslate('chat.systemModelLoadFailed', '获取模型失败')); + } + if (requestId !== chatSystemModelRequestSeq || ui.menu.hidden) return; + chatSystemModelOptions = Array.isArray(result.models) ? result.models.slice() : []; + chatSystemModelCache.set(cacheKey, { + models: chatSystemModelOptions.slice(), + fetchedAt: Date.now() + }); + const count = [chatSystemModelCurrent].concat(chatSystemModelOptions) + .map(function (model) { return String(model || '').trim(); }) + .filter(function (model, index, all) { return model && all.indexOf(model) === index; }) + .length; + if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') { + renderChatSystemModelOptions(chatSystemModelOptions, chatSystemModelCurrent); + } + setChatSystemModelStatus( + chatTranslate('chat.systemModelLoaded', '已获取 {count} 个模型').replace('{count}', String(count)), + 'success' + ); + } catch (error) { + if (requestId !== chatSystemModelRequestSeq || ui.menu.hidden) return; + chatSystemModelLoadError = error.message || chatTranslate('chat.systemModelLoadFailed', '获取模型失败'); + if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') { + renderChatSystemModelRetry(); + } + setChatSystemModelStatus(chatSystemModelLoadError, 'error'); + } +} + async function openChatSystemModelPicker(event) { if (event) { event.preventDefault(); @@ -1435,58 +1613,8 @@ async function openChatSystemModelPicker(event) { if (ui.main) ui.main.hidden = false; if (ui.subview) ui.subview.hidden = true; ui.list.innerHTML = ''; - chatSystemModelOptions = []; - chatSystemModelCurrent = currentSystemModelLabel(); - chatSystemModelLoadError = ''; updateChatSystemModelPickerValues(); - setChatSystemModelStatus(chatTranslate('chat.systemModelLoading', '正在获取模型列表…'), 'loading'); - const requestId = ++chatSystemModelRequestSeq; - try { - const configResponse = await apiFetch('/api/config'); - if (!configResponse.ok) { - throw new Error(await readChatSystemModelError(configResponse, chatTranslate('chat.systemModelLoadFailed', '获取模型失败'))); - } - const cfg = await configResponse.json(); - const state = chatSystemModelConfigState(cfg); - const channel = state.channel || {}; - if (!String(channel.api_key || '').trim()) { - throw new Error(chatTranslate('chat.systemModelNeedApiKey', '请先在系统设置中配置 API Key')); - } - const listResponse = await apiFetch('/api/config/list-models', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - provider: channel.provider || 'openai', - base_url: String(channel.base_url || '').trim(), - api_key: String(channel.api_key || '').trim() - }) - }); - const result = await listResponse.json().catch(function () { return {}; }); - if (!listResponse.ok || !result.success) { - throw new Error(result.error || chatTranslate('chat.systemModelLoadFailed', '获取模型失败')); - } - if (requestId !== chatSystemModelRequestSeq || ui.menu.hidden) return; - chatSystemModelCurrent = String(channel.model || '').trim(); - chatSystemModelOptions = Array.isArray(result.models) ? result.models.slice() : []; - const count = [chatSystemModelCurrent].concat(chatSystemModelOptions) - .map(function (model) { return String(model || '').trim(); }) - .filter(function (model, index, all) { return model && all.indexOf(model) === index; }) - .length; - if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') { - renderChatSystemModelOptions(chatSystemModelOptions, chatSystemModelCurrent); - } - setChatSystemModelStatus( - chatTranslate('chat.systemModelLoaded', '已获取 {count} 个模型').replace('{count}', String(count)), - 'success' - ); - } catch (error) { - if (requestId !== chatSystemModelRequestSeq || ui.menu.hidden) return; - chatSystemModelLoadError = error.message || chatTranslate('chat.systemModelLoadFailed', '获取模型失败'); - if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') { - renderChatSystemModelRetry(); - } - setChatSystemModelStatus(chatSystemModelLoadError, 'error'); - } + await fetchChatSystemModelsForChannel(resolveChatPickerChannelId()); } function truncateChatAIChannelSummaryLabel(label) { @@ -1502,6 +1630,7 @@ function persistChatAIChannelPref() { else localStorage.removeItem(AI_CHANNEL_STORAGE_KEY); } catch (e) {} updateChatReasoningSummary(); + updateChatSystemModelPickerValues(); } function reasoningSummaryModeLabel(mode) { @@ -1533,9 +1662,8 @@ function updateChatReasoningSummary() { } const channelPart = currentChatAIChannelLabel(); const modelPart = currentChatModelLabel(); - const parts = [truncateChatAIChannelSummaryLabel(channelPart), reasoningPart, hitlPart].filter(Boolean); - el.textContent = parts.join(' / '); - el.title = [channelPart, reasoningPart, hitlPart].filter(Boolean).join(' / '); + el.textContent = hitlPart; + el.title = hitlPart; updateChatComposerSessionShortcuts({ channel: channelPart, model: modelPart, @@ -1549,16 +1677,17 @@ function updateChatComposerSessionShortcuts(summary) { const modelEl = document.getElementById('chat-model-shortcut-text'); const hitlEl = document.getElementById('chat-hitl-shortcut-text'); if (modelEl) { - // 输入框右侧只展示系统默认主模型;审批模型只出现在 HITL 入口。 - const label = currentSystemModelLabel(); + // 输入框右侧展示当前会话通道的模型;审批模型只出现在 HITL 入口。 + const label = currentChatModelLabel(); modelEl.textContent = truncateChatAIChannelSummaryLabel(label); modelEl.title = label; const shortcut = document.getElementById('chat-model-shortcut'); if (shortcut) { - const effort = chatReasoningEffortLabel(currentSystemReasoningEffort()); - const action = chatTranslate('chat.modelSettingsAria', '选择模型与推理强度'); - shortcut.setAttribute('aria-label', action + ':' + label + ' · ' + effort); - shortcut.title = action + ':' + label + ' · ' + effort; + const channel = currentChatAIChannelLabel(); + const effort = currentChatReasoningMenuLabel(); + const action = chatTranslate('chat.modelSettingsAria', '选择 AI 通道、模型与推理设置'); + shortcut.setAttribute('aria-label', action + ':' + channel + ' · ' + label + ' · ' + effort); + shortcut.title = action + ':' + channel + ' · ' + label + ' · ' + effort; } updateChatSystemModelPickerValues(); } diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs index cb78246e..05bf9218 100644 --- a/web/static/js/hitl-approval-ui.test.cjs +++ b/web/static/js/hitl-approval-ui.test.cjs @@ -22,29 +22,38 @@ test('输入区提供独立审批入口并暴露可配置等待时限', () => { assert.match(chat, /body\.hitl = \{[\s\S]*?timeoutSeconds: normalizeHitlTimeoutForChat\(hitlCfg\.timeoutSeconds/); }); -test('输入框可直接保存系统模型和系统推理强度且审批模型只出现在审计 Agent 入口', () => { +test('输入框可按会话通道获取模型并双向同步会话推理且审批模型只出现在审计 Agent 入口', () => { assert.match(chat, /function currentSystemModelLabel\(\)/); assert.match(chat, /chatDefaultAIChannel \? chatAIChannels\[chatDefaultAIChannel\]/); assert.match(chat, /function currentHitlAuditModelLabel\(\)/); - assert.match(chat, /const label = currentSystemModelLabel\(\)/); + assert.match(chat, /const label = currentChatModelLabel\(\)/); assert.doesNotMatch(chat, /const label = data\.model \|\| currentChatModelLabel\(\)/); assert.match(chat, /const approvalModel = auditAgent \? currentHitlAuditModelLabel\(\) : ''/); assert.match(chat, /hitlAuditModel\.model\.trim\(\)/); assert.match(template, /id="chat-model-shortcut"[^>]+onclick="openChatSystemModelPicker\(event\)"/); assert.match(template, /id="chat-system-model-menu"[^>]+hidden/); assert.doesNotMatch(template, /id="chat-reasoning-shortcut"/); - assert.match(template, /openChatSystemModelView\('model', event\)[\s\S]{0,1200}openChatSystemModelView\('effort', event\)/); + assert.doesNotMatch(template, /session-settings-group-ai/); + assert.match(template, /class="chat-ai-session-state" hidden[\s\S]{0,500}id="chat-ai-channel-select"/); + assert.match(template, /openChatSystemModelView\('channel', event\)[\s\S]{0,1200}openChatSystemModelView\('model', event\)[\s\S]{0,1200}openChatSystemModelView\('mode', event\)[\s\S]{0,1200}openChatSystemModelView\('effort', event\)/); assert.match(chat, /function renderChatReasoningEffortOptions\(\)/); - assert.match(chat, /function currentSystemReasoningEffort\(\)[\s\S]{0,500}reasoning\.effort/); + assert.match(chat, /function renderChatReasoningModeOptions\(\)/); assert.match(chat, /case 'low': return 'low'[\s\S]{0,300}case 'max': return 'max'/); assert.match(chat, /chatTranslate\('chat\.reasoningEffortUnset', '不指定'\)/); - assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,2400}reasoning: \{ \.\.\.\(state\.channel\.reasoning \|\| \{\}\), effort: chosen \}/); - assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,4200}body: JSON\.stringify\(\{ ai: state\.ai \}\)[\s\S]{0,900}apiFetch\('\/api\/config\/apply'/); - assert.match(chat, /function openChatSystemModelPicker\(event\)[\s\S]{0,4200}apiFetch\('\/api\/config\/list-models'/); + assert.match(chat, /\['default', 'off', 'on', 'auto'\]/); + assert.match(chat, /\['', 'low', 'medium', 'high', 'xhigh', 'max'\]/); + assert.match(chat, /function selectChatReasoningMode\(mode\)[\s\S]{0,700}modeControl\.value = chosen[\s\S]{0,200}finishChatReasoningPickerUpdate\(\)/); + assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,700}effortControl\.value = chosen[\s\S]{0,200}finishChatReasoningPickerUpdate\(\)/); + assert.match(chat, /function fetchChatSystemModelsForChannel\(channelId, options\)[\s\S]{0,4200}apiFetch\('\/api\/config\/list-models'/); + assert.match(chat, /function selectChatAIChannel\(channelId\)[\s\S]{0,900}fetchChatSystemModelsForChannel\(resolveChatPickerChannelId\(\), \{ force: true \}\)/); + assert.match(chat, /const chatSystemModelCache = new Map\(\)/); + assert.match(chat, /Date\.now\(\) - cached\.fetchedAt < CHAT_SYSTEM_MODEL_CACHE_TTL_MS/); assert.match(chat, /function selectChatSystemModel\(model\)[\s\S]{0,2600}method: 'PUT'[\s\S]{0,900}apiFetch\('\/api\/config\/apply'/); assert.match(chat, /body: JSON\.stringify\(\{ ai: state\.ai \}\)/); - assert.equal(zh.chat.modelSettingsAria, '选择模型与推理强度'); - assert.equal(en.chat.modelSettingsAria, 'Choose model and reasoning effort'); + assert.equal(zh.chat.modelSettingsAria, '选择 AI 通道、模型与推理设置'); + assert.equal(en.chat.modelSettingsAria, 'Choose AI channel, model, and reasoning settings'); + assert.equal(zh.chat.reasoningSessionUpdated, '会话推理设置已更新'); + assert.equal(en.chat.reasoningSessionUpdated, 'Session reasoning updated'); }); test('审批请求按浏览器、命令、文件和通用工具动态描述', () => { @@ -255,8 +264,8 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状 assert.match(chat, /signal: conversationLoadController\.signal/); assert.match(template, /monitor\.js\?v=20260815-2/); assert.match(template, /chat-scroll\.js\?v=20260815-1/); - assert.match(template, /chat\.js\?v=20260815-2/); - assert.match(template, /style\.css\?v=20260815-2/); + assert.match(template, /chat\.js\?v=20260818-3/); + assert.match(template, /style\.css\?v=20260818-3/); }); test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => { diff --git a/web/templates/index.html b/web/templates/index.html index c898b36d..5bbd28ae 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -31,7 +31,7 @@ } })(); - + @@ -1025,39 +1025,8 @@
AI 通道、推理设置与人机协同只影响后续消息。
+人机协同设置只影响后续消息。