diff --git a/web/static/css/style.css b/web/static/css/style.css index 683e2345..25306192 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -4430,7 +4430,7 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { .ai-channel-manager { border: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 88%, transparent); - border-radius: 10px; + border-radius: 8px; background: var(--card-bg, #fff); overflow: hidden; } @@ -4446,8 +4446,8 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { } .ai-channel-manager-header h4 { - margin: 0 0 8px; - font-size: 16px; + margin: 0 0 6px; + font-size: 17px; line-height: 1.25; } @@ -4467,55 +4467,75 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { color: var(--error-color, #e53e3e); } +.ai-channel-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 10px; + flex-shrink: 0; +} + .ai-channel-save-btn { flex-shrink: 0; min-width: 92px; } .ai-channel-manager-body { - display: grid; - grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + display: block; min-height: 0; - align-items: stretch; background: var(--card-bg, #fff); overflow: visible; } -.ai-channel-sidebar { - position: relative; - display: flex; - flex-direction: column; - gap: 10px; - min-height: 0; - overflow: hidden; - border-right: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 76%, transparent); - background: color-mix(in srgb, var(--bg-secondary, #f8fafc) 86%, var(--card-bg, #fff)); - padding: 18px; -} - -.ai-channel-sidebar-head { +.ai-channel-switcher { display: flex; align-items: center; justify-content: space-between; - gap: 10px; - margin-bottom: 0; - color: var(--text-secondary, #4a5568); - font-size: 12px; - font-weight: 700; - letter-spacing: 0; + gap: 16px; + padding: 16px 22px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 72%, transparent); + background: color-mix(in srgb, var(--bg-secondary, #f8fafc) 70%, var(--card-bg, #fff)); } -.ai-channel-bulk-actions { +.ai-channel-switcher-field { + display: grid; + grid-template-columns: auto minmax(260px, 420px); + align-items: center; + gap: 10px; + min-width: 0; +} + +.ai-channel-switcher-field label { + color: var(--text-secondary, #4a5568); + font-size: 13px; + font-weight: 700; + white-space: nowrap; +} + +.ai-channel-switch-select { + width: 100%; + min-height: 38px; + padding: 0.45rem 2rem 0.45rem 0.7rem; + border: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 88%, transparent); + border-radius: 7px; + background: color-mix(in srgb, var(--input-bg, var(--card-bg, #fff)) 92%, transparent); + color: var(--text-primary, #2d3748); + font-size: 0.875rem; +} + +.ai-channel-switch-select:focus { + border-color: color-mix(in srgb, var(--accent-color, #3182ce) 70%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color, #3182ce) 12%, transparent); + outline: none; +} + +.ai-channel-switcher-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; - gap: 5px; - margin: 0; - min-width: 0; - padding: 0; - border: 0; - background: transparent; + gap: 8px; } .ai-channel-bulk-btn { @@ -4523,8 +4543,8 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { align-items: center; justify-content: center; min-width: 0; - min-height: 26px; - padding: 4px 8px; + min-height: 28px; + padding: 5px 9px; border: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 68%, transparent); border-radius: 999px; background: color-mix(in srgb, var(--card-bg, #fff) 38%, transparent); @@ -4555,8 +4575,8 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { } .ai-channel-icon-btn { - width: 30px; - height: 30px; + width: 32px; + height: 32px; border: 1px solid var(--border-color, #e2e8f0); border-radius: 8px; background: var(--card-bg, #fff); @@ -4577,19 +4597,7 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { } .ai-channel-list { - position: absolute; - top: 64px; - right: 13px; - bottom: 18px; - left: 18px; - display: flex; - flex-direction: column; - gap: 10px; - min-height: 0; - overflow-y: auto; - overflow-x: hidden; - padding: 2px 5px 2px 1px; - scrollbar-gutter: stable; + display: none; } .ai-channel-list::-webkit-scrollbar { @@ -4616,10 +4624,10 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { align-items: start; gap: 10px; width: 100%; - min-height: 92px; - padding: 12px; + min-height: 86px; + padding: 11px; border: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 64%, transparent); - border-radius: 8px; + border-radius: 7px; background: color-mix(in srgb, var(--card-bg, #fff) 70%, transparent); color: var(--text-color, #2d3748); text-align: left; @@ -4640,7 +4648,7 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { height: 16px; margin: 3px 0 0; border: 1px solid color-mix(in srgb, var(--text-muted, #718096) 52%, transparent); - border-radius: 5px; + border-radius: 4px; appearance: none; -webkit-appearance: none; background-color: color-mix(in srgb, var(--card-bg, #fff) 84%, transparent); @@ -4671,7 +4679,6 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { .ai-channel-list-item:hover { background: var(--card-bg, #fff); border-color: color-mix(in srgb, var(--accent-color, #3182ce) 24%, transparent); - transform: translateY(-1px); } .ai-channel-list-item.active { @@ -4779,17 +4786,17 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { min-width: 0; min-height: 0; overflow-y: visible; - padding: 22px 28px 28px; + padding: 20px 28px 28px; background: var(--card-bg, #fff); } .ai-channel-editor-head { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 18px; - margin-bottom: 18px; - padding-bottom: 18px; + margin-bottom: 6px; + padding-bottom: 16px; border-bottom: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 72%, transparent); } @@ -4804,7 +4811,7 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { .ai-channel-editor-head h5 { margin: 0; color: var(--text-color, #2d3748); - font-size: 22px; + font-size: 21px; font-weight: 700; line-height: 1.2; } @@ -4827,8 +4834,9 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { color: var(--error-color, #e53e3e); } + .ai-channel-editor-form { - max-width: 980px; + max-width: none; } .ai-channel-editor-form .form-group { @@ -4837,7 +4845,7 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { .ai-channel-editor-form .form-group label { display: block; - margin-bottom: 0; + margin-bottom: 6px; color: var(--text-primary); font-size: 0.875rem; font-weight: 500; @@ -4848,10 +4856,14 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { .ai-channel-editor-form input[type="password"], .ai-channel-editor-form input[type="number"], .ai-channel-editor-form select { + width: 100%; min-height: 42px; - border-radius: 8px; + padding: 0.5rem 0.75rem; + border-radius: 7px; border-color: color-mix(in srgb, var(--border-color, #e2e8f0) 88%, transparent); background: color-mix(in srgb, var(--input-bg, var(--card-bg, #fff)) 92%, transparent); + color: var(--text-color, #2d3748); + font-size: 0.875rem; } .ai-channel-editor-form input:focus, @@ -4863,9 +4875,175 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { .ai-channel-editor-form .form-hint, .ai-channel-editor-form small { + display: block; + margin-top: 5px; + color: var(--text-muted, #718096); + font-size: 0.75rem; line-height: 1.45; } +.ai-channel-form-section { + padding: 18px 0; + border-bottom: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 64%, transparent); +} + +.ai-channel-form-section:last-of-type { + border-bottom: 0; +} + +.ai-channel-form-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 14px; +} + +.ai-channel-form-section-head > div:first-child { + max-width: 620px; +} + +.ai-channel-form-section-head h6 { + margin: 0; + color: var(--text-primary, #1f2937); + font-size: 14px; + font-weight: 700; + line-height: 1.35; +} + +.ai-channel-form-section-head p { + margin: 4px 0 0; + color: var(--text-muted, #718096); + font-size: 12px; + line-height: 1.45; +} + +.ai-channel-section-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + min-width: 210px; +} + +.ai-channel-section-actions .form-inline-result { + margin-top: 0; +} + +.ai-channel-section-actions .connection-test-result { + display: none; + max-width: min(760px, 54vw); + padding: 8px 10px; + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 8px; + background: color-mix(in srgb, var(--surface-color, #ffffff) 86%, var(--bg-secondary, #f7fafc)); + color: var(--text-muted, #718096); + font-size: 12px; + line-height: 1.45; + text-align: left; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.ai-channel-section-actions .connection-test-result.is-visible { + display: inline-block; +} + +.ai-channel-section-actions .connection-test-result.is-error { + border-color: color-mix(in srgb, var(--danger-color, #e53e3e) 42%, var(--border-color, #e2e8f0)); + background: color-mix(in srgb, var(--danger-color, #e53e3e) 8%, var(--surface-color, #ffffff)); + color: var(--danger-color, #e53e3e); +} + +.ai-channel-section-actions .connection-test-result.is-success { + border-color: color-mix(in srgb, var(--success-color, #38a169) 38%, var(--border-color, #e2e8f0)); + background: color-mix(in srgb, var(--success-color, #38a169) 9%, var(--surface-color, #ffffff)); + color: var(--success-color, #38a169); +} + +.ai-channel-form-grid, +.ai-channel-reasoning-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + column-gap: 16px; + row-gap: 14px; +} + +.ai-channel-reasoning-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.ai-channel-editor-form .span-2 { + grid-column: 1 / -1; +} + +.required-mark { + color: var(--error-color, #e53e3e); +} + +.form-inline-result { + display: block; + margin-top: 6px; + color: var(--text-muted, #718096); + font-size: 0.75rem; + line-height: 1.45; +} + +.ai-channel-reasoning-toggle { + margin-top: 8px; +} + +.ai-channel-advanced-section { + padding: 10px 0 0; + border-bottom: 0; +} + +.ai-channel-advanced-section summary { + display: flex; + align-items: center; + gap: 10px; + min-height: 38px; + padding: 0; + color: var(--text-primary, #1f2937); + cursor: pointer; + list-style: none; +} + +.ai-channel-advanced-section summary::-webkit-details-marker { + display: none; +} + +.ai-channel-advanced-section summary::before { + content: ""; + width: 8px; + height: 8px; + flex: 0 0 auto; + border-right: 2px solid var(--text-muted, #718096); + border-bottom: 2px solid var(--text-muted, #718096); + transform: rotate(-45deg); + transition: transform 0.16s ease; +} + +.ai-channel-advanced-section[open] summary::before { + transform: rotate(45deg); +} + +.ai-channel-advanced-section summary strong { + display: block; + font-size: 14px; + line-height: 1.35; +} + +.ai-channel-advanced-section summary small { + margin-top: 4px; +} + +.ai-channel-advanced-section[open] .ai-channel-reasoning-grid { + margin-top: 14px; +} + html[data-theme="dark"] .ai-channel-manager { background: #111827; border-color: rgba(71, 85, 105, 0.42); @@ -4876,6 +5054,21 @@ html[data-theme="dark"] .ai-channel-manager-header { border-bottom-color: rgba(71, 85, 105, 0.34); } +html[data-theme="dark"] .ai-channel-switcher { + background: #101827; + border-bottom-color: rgba(71, 85, 105, 0.34); +} + +html[data-theme="dark"] .ai-channel-switcher-field label { + color: #cbd5e1; +} + +html[data-theme="dark"] .ai-channel-switch-select { + background: rgba(15, 23, 42, 0.72); + border-color: rgba(71, 85, 105, 0.44); + color: #e5e7eb; +} + html[data-theme="dark"] .ai-channel-sidebar { background: #101827; border-right-color: rgba(71, 85, 105, 0.34); @@ -4928,6 +5121,42 @@ html[data-theme="dark"] .ai-channel-editor-head { border-bottom-color: rgba(71, 85, 105, 0.34); } +html[data-theme="dark"] .ai-channel-form-section, +html[data-theme="dark"] .ai-channel-advanced-section { + border-color: rgba(71, 85, 105, 0.34); +} + +html[data-theme="dark"] .ai-channel-form-section-head h6 { + color: #e5e7eb; +} + +html[data-theme="dark"] .ai-channel-form-section-head p, +html[data-theme="dark"] .form-inline-result { + color: #94a3b8; +} + +html[data-theme="dark"] .ai-channel-section-actions .connection-test-result { + border-color: rgba(71, 85, 105, 0.52); + background: rgba(15, 23, 42, 0.74); + color: #94a3b8; +} + +html[data-theme="dark"] .ai-channel-section-actions .connection-test-result.is-error { + border-color: rgba(248, 113, 113, 0.42); + background: rgba(127, 29, 29, 0.22); + color: #f87171; +} + +html[data-theme="dark"] .ai-channel-section-actions .connection-test-result.is-success { + border-color: rgba(52, 211, 153, 0.38); + background: rgba(6, 78, 59, 0.22); + color: #34d399; +} + +html[data-theme="dark"] .ai-channel-advanced-section summary { + color: #e5e7eb; +} + html[data-theme="dark"] .ai-channel-icon-btn { background: rgba(15, 23, 42, 0.72); border-color: rgba(71, 85, 105, 0.44); @@ -5009,23 +5238,9 @@ html[data-theme="dark"] .ai-channel-editor-form select { align-items: stretch; } - .ai-channel-manager-body { - grid-template-columns: 1fr; - height: auto; - overflow: visible; - } - - .ai-channel-sidebar { - border-right: 0; - border-bottom: 1px solid var(--border-color, #e2e8f0); - max-height: 420px; - } - - .ai-channel-list { - position: static; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - max-height: 360px; + .ai-channel-switcher { + align-items: stretch; + flex-direction: column; } .ai-channel-editor { @@ -5035,19 +5250,40 @@ html[data-theme="dark"] .ai-channel-editor-form select { @media (max-width: 640px) { .ai-channel-manager-header, - .ai-channel-sidebar, + .ai-channel-switcher, .ai-channel-editor { padding-left: 14px; padding-right: 14px; } - .ai-channel-editor-actions { + .ai-channel-switcher-actions { justify-content: flex-start; } - .ai-channel-bulk-actions { + .ai-channel-header-actions { justify-content: flex-start; } + + .ai-channel-switcher-field { + grid-template-columns: 1fr; + } + + .ai-channel-form-section-head, + .ai-channel-form-grid, + .ai-channel-reasoning-grid { + grid-template-columns: 1fr; + } + + .ai-channel-form-section-head, + .ai-channel-section-actions { + align-items: stretch; + flex-direction: column; + } + + .ai-channel-section-actions { + justify-content: flex-start; + min-width: 0; + } } .chat-reasoning-field-label { @@ -9923,6 +10159,54 @@ html[data-theme="dark"] .robot-binding-service-hint-icon { white-space: nowrap; } +.settings-custom-select-option--probe .settings-custom-select-label { + flex: 1 1 auto; +} + +.settings-custom-select-status { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 5px; + max-width: 42%; + padding: 2px 6px; + border-radius: 999px; + background: var(--bg-secondary, #f5f6f8); + color: var(--text-secondary, #64748b); + font-size: 11px; + font-weight: 600; + line-height: 1.3; +} + +.settings-custom-select-status-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-custom-select-status-dot { + flex: 0 0 auto; + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; +} + +.settings-custom-select-status.ready { + background: color-mix(in srgb, var(--success-color, #38a169) 11%, transparent); + color: var(--success-color, #38a169); +} + +.settings-custom-select-status.failed { + background: color-mix(in srgb, var(--danger-color, #e53e3e) 10%, transparent); + color: var(--danger-color, #e53e3e); +} + +.settings-custom-select-status.testing { + background: color-mix(in srgb, var(--accent-color, #0066ff) 10%, transparent); + color: var(--accent-color, #0066ff); +} + .form-group input:not([type="checkbox"]):not([type="radio"]):focus, .form-group select:focus { outline: none; @@ -38401,6 +38685,26 @@ html[data-theme="dark"] #page-settings .audit-custom-select-option.is-selected { color: #60a5fa !important; } +html[data-theme="dark"] #page-settings .settings-custom-select-status { + background: rgba(148, 163, 184, 0.12); + color: #94a3b8; +} + +html[data-theme="dark"] #page-settings .settings-custom-select-status.ready { + background: rgba(52, 211, 153, 0.14); + color: #34d399; +} + +html[data-theme="dark"] #page-settings .settings-custom-select-status.failed { + background: rgba(248, 113, 113, 0.14); + color: #f87171; +} + +html[data-theme="dark"] #page-settings .settings-custom-select-status.testing { + background: rgba(96, 165, 250, 0.14); + color: #60a5fa; +} + /* Chat @ tool mention panel dark theme. */ html[data-theme="dark"] .mention-suggestions { background: #111827 !important; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index cfc20d0f..d5a6dc73 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -2603,23 +2603,40 @@ "aiChannelNew": "New", "aiChannelCopy": "Copy", "aiChannelDelete": "Delete", - "aiChannelHint": "Saved channels appear on the left. Saving writes to ai.channels; the default channel is used by new chats and tasks without an explicit channel.", + "aiChannelHint": "Use the dropdown to switch saved channels. Saving writes to ai.channels; the default channel is used by new chats and tasks without an explicit channel.", "aiChannelSavedList": "Saved channels", "aiChannelListAria": "AI channel list", "aiChannelEditing": "Editing", + "aiChannelBulkProbe": "Probe all", + "aiChannelBulkProbeTitle": "Check whether all complete channels are available", + "aiChannelSelectAria": "Select {name}", "aiChannelDefaultBadge": "Default", "aiChannelReady": "Ready", + "aiChannelReadyWithLatency": "Ready{latency}", "aiChannelDraft": "Incomplete", "aiChannelDefaultMeta": "Default channel", "aiChannelCustomMeta": "Custom channel", "aiChannelOpenAICompat": "OpenAI compatible", "aiChannelModelMissing": "Model missing", "aiChannelName": "Channel name", + "aiChannelConnectionSection": "Connection", + "aiChannelConnectionHint": "Confirm provider, endpoint, key, and model first. Test connection uses these values.", + "aiChannelModelSection": "Model and limits", + "aiChannelModelHint": "The model name controls routing. Token limits control context and single-response output.", + "aiChannelLimitsSection": "Limits", + "aiChannelLimitsHint": "Token limits control the context window and single-response output.", "aiChannelUntitled": "New Channel", "aiChannelDeleteConfirm": "Delete AI channel \"{name}\"?", "aiChannelSaved": "Channel saved", "aiChannelDefaultSaved": "Default channel saved", "aiChannelCount": "{count} channel(s) saved", + "aiChannelSaving": "Saving channel...", + "aiChannelNewUnsaved": "New channel is not saved yet. Fill it in, then click Save changes.", + "aiChannelCopyUnsaved": "Copied channel is not saved yet. Review it, then click Save changes.", + "aiChannelDeleted": "Channel deleted", + "aiChannelProbeNoComplete": "No complete channel to probe. Fill in Base URL, API Key, and Model first.", + "aiChannelProbing": "Probing {count} channel(s)...", + "aiChannelProbeDone": "Probe complete: {ok}/{total} ready", "apiProvider": "API Provider", "providerOpenAI": "OpenAI / OpenAI-compatible API", "providerClaude": "Claude (Anthropic Messages API)", @@ -2799,7 +2816,12 @@ "testing": "Testing connection...", "testSuccess": "Connection successful", "testFailed": "Connection failed", - "testError": "Test error" + "testError": "Test error", + "testErrorInvalidApiKey": "API Key is invalid or unauthorized. Check that the key is correct.", + "testErrorUnauthorized": "Authentication failed. Check the API Key.", + "testErrorForbidden": "Request denied. Check account permissions or model access.", + "testErrorModelUnavailable": "Model is unavailable or the model name is incorrect.", + "testErrorBaseUrl": "Base URL is unavailable. Check that the endpoint is correct." }, "settingsTerminal": { "title": "Terminal", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 7db25db0..98bd41d8 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -2591,23 +2591,40 @@ "aiChannelNew": "新增", "aiChannelCopy": "复制", "aiChannelDelete": "删除", - "aiChannelHint": "已保存的通道会显示在左侧;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。", + "aiChannelHint": "通过下拉切换已保存通道;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。", "aiChannelSavedList": "已保存通道", "aiChannelListAria": "AI 通道列表", "aiChannelEditing": "正在编辑", + "aiChannelBulkProbe": "批量探活", + "aiChannelBulkProbeTitle": "检测全部完整通道是否可用", + "aiChannelSelectAria": "选择 {name}", "aiChannelDefaultBadge": "默认", "aiChannelReady": "可用", + "aiChannelReadyWithLatency": "可用{latency}", "aiChannelDraft": "待完善", "aiChannelDefaultMeta": "默认通道", "aiChannelCustomMeta": "自定义通道", "aiChannelOpenAICompat": "OpenAI 兼容", "aiChannelModelMissing": "未填写模型", "aiChannelName": "通道名称", + "aiChannelConnectionSection": "连接信息", + "aiChannelConnectionHint": "先确认服务商、地址、密钥和模型,测试连接会使用这些信息。", + "aiChannelModelSection": "模型与额度", + "aiChannelModelHint": "模型名决定请求路由,Token 上限控制上下文和单次输出。", + "aiChannelLimitsSection": "额度设置", + "aiChannelLimitsHint": "Token 上限控制上下文窗口和单次输出。", "aiChannelUntitled": "新通道", "aiChannelDeleteConfirm": "确定删除 AI 通道「{name}」吗?", "aiChannelSaved": "通道已保存", "aiChannelDefaultSaved": "已设为默认通道", "aiChannelCount": "已保存 {count} 个通道", + "aiChannelSaving": "正在保存通道...", + "aiChannelNewUnsaved": "新通道尚未保存,填写后点击「保存更改」。", + "aiChannelCopyUnsaved": "复制的通道尚未保存,确认后点击「保存更改」。", + "aiChannelDeleted": "通道已删除", + "aiChannelProbeNoComplete": "没有可探活的完整通道,请先填写 Base URL、API Key 和模型", + "aiChannelProbing": "正在探活 {count} 个通道...", + "aiChannelProbeDone": "探活完成:{ok}/{total} 可用", "apiProvider": "API 提供商", "providerOpenAI": "OpenAI / 兼容 OpenAI 协议", "providerClaude": "Claude (Anthropic Messages API)", @@ -2787,7 +2804,12 @@ "testing": "测试中...", "testSuccess": "连接成功", "testFailed": "连接失败", - "testError": "测试出错" + "testError": "测试出错", + "testErrorInvalidApiKey": "API Key 无效或无权限,请检查密钥是否填写正确", + "testErrorUnauthorized": "认证失败,请检查 API Key", + "testErrorForbidden": "请求被拒绝,请检查账号权限或模型访问权限", + "testErrorModelUnavailable": "模型不可用或模型名不正确,请检查模型名称", + "testErrorBaseUrl": "Base URL 不可用,请检查地址是否正确" }, "settingsTerminal": { "title": "终端", diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 8187e287..7e575dde 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -142,6 +142,19 @@ function syncSettingsCustomSelect(select) { item.appendChild(check); item.appendChild(label); + + if (select.id === 'ai-channel-select') { + const probeStatus = option.dataset.probeStatus || ''; + const probeMessage = option.dataset.probeMessage || ''; + if (probeStatus) { + item.classList.add('settings-custom-select-option--probe', `probe-${probeStatus}`); + const status = document.createElement('span'); + status.className = `settings-custom-select-status ${probeStatus}`; + status.innerHTML = ``; + status.querySelector('.settings-custom-select-status-text').textContent = probeMessage || probeStatus; + item.appendChild(status); + } + } reg.menu.appendChild(item); }); } @@ -2557,6 +2570,90 @@ function writeAIChannelToMainForm(id) { const allowEl = document.getElementById('openai-reasoning-allow-client'); if (allowEl) allowEl.checked = r.allow_client_reasoning !== false; syncModelListFetchButtons(); + syncAIChannelEditorPreview(); +} + +function displayAIChannelName(id, ch) { + const name = String(ch?.name || '').trim(); + if ((name === '新通道' || name === 'New Channel') && !String(ch?.model || '').trim()) { + return settingsT('settingsBasic.aiChannelUntitled', name || id); + } + return name || id; +} + +function aiChannelSelectLabel(id, ch) { + const marker = id === currentConfig?.ai?.default_channel ? ' *' : ''; + return `${displayAIChannelName(id, ch)}${marker} · ${ch?.model || '-'}`; +} + +function aiChannelOptionProbeMeta(id) { + const probe = aiChannelProbeResults[id]; + if (!probe) return null; + const status = probe.status || ''; + if (!['testing', 'ready', 'failed'].includes(status)) return null; + return { + status, + message: probe.message || (status === 'ready' + ? settingsT('settingsBasic.aiChannelReady', '可用') + : status === 'testing' + ? settingsT('settingsBasic.testing', '测试中...') + : settingsT('settingsBasic.testFailed', '连接失败')) + }; +} + +function updateAIChannelSelectOption(id) { + const select = document.getElementById('ai-channel-select'); + if (!select || !currentConfig?.ai?.channels) return; + const channelId = normalizeAIChannelId(id || selectedAIChannelId || currentConfig.ai.default_channel || 'default'); + const ch = currentConfig.ai.channels[channelId]; + if (!ch) return; + const opt = Array.from(select.options).find((option) => option.value === channelId); + if (opt) { + opt.textContent = aiChannelSelectLabel(channelId, ch); + const probeMeta = aiChannelOptionProbeMeta(channelId); + if (probeMeta) { + opt.dataset.probeStatus = probeMeta.status; + opt.dataset.probeMessage = probeMeta.message; + } else { + delete opt.dataset.probeStatus; + delete opt.dataset.probeMessage; + } + select.value = channelId; + select.selectedIndex = opt.index; + } + if (typeof syncSettingsCustomSelect === 'function') { + syncSettingsCustomSelect(select); + } +} + +function syncSelectedAIChannelUI() { + updateAIChannelSelectOption(selectedAIChannelId); + updateAIChannelEditorChrome(selectedAIChannelId); + renderAIChannelList(); +} + +function syncAIChannelEditorPreview() { + if (!currentConfig?.ai?.channels || !selectedAIChannelId || !currentConfig.ai.channels[selectedAIChannelId]) return; + const id = normalizeAIChannelId(selectedAIChannelId); + const next = readAIChannelFromMainForm(id); + currentConfig.ai.channels[id] = next; + syncSelectedAIChannelUI(); +} + +function bindAIChannelEditorPreviewSync() { + const ids = [ + 'ai-channel-name', + 'openai-provider', + 'openai-base-url', + 'openai-model' + ]; + ids.forEach((fieldId) => { + const el = document.getElementById(fieldId); + if (!el || el.dataset.aiChannelPreviewBound === '1') return; + el.dataset.aiChannelPreviewBound = '1'; + const eventName = el.tagName === 'SELECT' ? 'change' : 'input'; + el.addEventListener(eventName, syncAIChannelEditorPreview); + }); } function renderAIChannelSelect() { @@ -2570,16 +2667,24 @@ function renderAIChannelSelect() { const ch = currentConfig.ai.channels[id] || {}; const opt = document.createElement('option'); opt.value = id; - const marker = id === currentConfig.ai.default_channel ? ' *' : ''; - opt.textContent = `${ch.name || id}${marker} · ${ch.model || '-'}`; + opt.textContent = aiChannelSelectLabel(id, ch); + const probeMeta = aiChannelOptionProbeMeta(id); + if (probeMeta) { + opt.dataset.probeStatus = probeMeta.status; + opt.dataset.probeMessage = probeMeta.message; + } select.appendChild(opt); }); selectedAIChannelId = selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId] ? selectedAIChannelId : currentConfig.ai.default_channel; select.value = selectedAIChannelId; - renderAIChannelList(ids); + updateAIChannelSelectOption(selectedAIChannelId); + if (typeof syncSettingsCustomSelect === 'function') { + syncSettingsCustomSelect(select); + } updateAIChannelEditorChrome(selectedAIChannelId); + renderAIChannelList(ids); const countLabel = typeof window.t === 'function' ? window.t('settingsBasic.aiChannelCount').replace('{count}', String(ids.length)) : `已保存 ${ids.length} 个通道`; @@ -2621,7 +2726,7 @@ function renderAIChannelList(ids) { checkbox.type = 'checkbox'; checkbox.className = 'ai-channel-bulk-check'; checkbox.checked = selectedAIChannelBulkIds.has(id); - checkbox.setAttribute('aria-label', `选择 ${ch.name || id}`); + checkbox.setAttribute('aria-label', settingsT('settingsBasic.aiChannelSelectAria', '选择 {name}').replace('{name}', displayAIChannelName(id, ch))); checkbox.onclick = (event) => { event.stopPropagation(); if (checkbox.checked) { @@ -2631,6 +2736,7 @@ function renderAIChannelList(ids) { } item.classList.toggle('checked', checkbox.checked); }; + const displayName = displayAIChannelName(id, ch); const defaultBadge = isDefault ? `${escapeAIChannelHtml(settingsT('settingsBasic.aiChannelDefaultBadge', '默认'))}` : ''; let statusText = isComplete ? settingsT('settingsBasic.aiChannelReady', '可用') @@ -2645,7 +2751,7 @@ function renderAIChannelList(ids) { body.innerHTML = `
- ${escapeAIChannelHtml(ch.name || id)} + ${escapeAIChannelHtml(displayName)} ${defaultBadge}
${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}
@@ -2674,7 +2780,7 @@ function updateAIChannelEditorChrome(id) { const ch = ai.channels[channelId] || {}; const title = document.getElementById('ai-channel-editor-title'); const meta = document.getElementById('ai-channel-editor-meta'); - if (title) title.textContent = ch.name || channelId; + if (title) title.textContent = displayAIChannelName(channelId, ch); if (meta) { const parts = [ channelId === ai.default_channel @@ -2699,6 +2805,41 @@ function validateSelectedAIChannelPayload(ch) { return ''; } +function resolveSavedAIChannelId(ai, preferredId, preferredPayload) { + const channels = ai?.channels || {}; + const normalizedPreferred = normalizeAIChannelId(preferredId || ''); + if (normalizedPreferred && channels[normalizedPreferred]) return normalizedPreferred; + + const payload = preferredPayload || {}; + const targetName = String(payload.name || '').trim(); + const targetModel = String(payload.model || '').trim(); + const targetBaseUrl = String(payload.base_url || '').trim(); + const targetProvider = String(payload.provider || '').trim(); + const ids = Object.keys(channels).sort(); + const matched = ids.find((id) => { + const ch = channels[id] || {}; + return String(ch.name || '').trim() === targetName + && String(ch.model || '').trim() === targetModel + && String(ch.base_url || '').trim() === targetBaseUrl + && String(ch.provider || '').trim() === targetProvider; + }); + return matched || ai?.default_channel || ids[0] || normalizedPreferred || 'default'; +} + +async function refreshAIChannelsFromServer(preferredId, preferredPayload) { + const response = await apiFetch('/api/config'); + if (!response.ok) return false; + currentConfig = await response.json(); + currentConfig.ai = ensureAIConfigShape(currentConfig); + selectedAIChannelId = resolveSavedAIChannelId(currentConfig.ai, preferredId, preferredPayload); + renderAIChannelSelect(); + writeAIChannelToMainForm(selectedAIChannelId); + if (typeof populateChatAIChannelSelect === 'function') { + populateChatAIChannelSelect(currentConfig.ai); + } + return true; +} + async function persistAIChannelsToServer(successMessage, options = {}) { if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false; if (!currentConfig) currentConfig = {}; @@ -2714,7 +2855,7 @@ async function persistAIChannelsToServer(successMessage, options = {}) { return false; } renderAIChannelSelect(); - showAIChannelSaveHint('正在保存通道...', true); + showAIChannelSaveHint(settingsT('settingsBasic.aiChannelSaving', '正在保存通道...'), true); try { const shouldMergeLatest = options.mergeLatest !== false; const latestResponse = shouldMergeLatest ? await apiFetch('/api/config') : null; @@ -2744,17 +2885,7 @@ async function persistAIChannelsToServer(successMessage, options = {}) { const error = await applyResponse.json().catch(() => ({})); throw new Error(error.error || '应用通道失败'); } - const response = await apiFetch('/api/config'); - if (response.ok) { - currentConfig = await response.json(); - currentConfig.ai = ensureAIConfigShape(currentConfig); - selectedAIChannelId = currentConfig.ai.channels[id] ? id : currentConfig.ai.default_channel; - renderAIChannelSelect(); - writeAIChannelToMainForm(selectedAIChannelId); - if (typeof populateChatAIChannelSelect === 'function') { - populateChatAIChannelSelect(currentConfig.ai); - } - } + await refreshAIChannelsFromServer(id, channelPayload); showAIChannelSaveHint(successMessage || '通道已保存', true); return true; } catch (error) { @@ -2768,7 +2899,7 @@ async function persistAIConfigOnlyToServer(successMessage) { if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false; if (!currentConfig) return false; currentConfig.ai = ensureAIConfigShape(currentConfig); - showAIChannelSaveHint('正在保存通道...', true); + showAIChannelSaveHint(settingsT('settingsBasic.aiChannelSaving', '正在保存通道...'), true); try { const updateResponse = await apiFetch('/api/config', { method: 'PUT', @@ -2784,9 +2915,7 @@ async function persistAIConfigOnlyToServer(successMessage) { const error = await applyResponse.json().catch(() => ({})); throw new Error(error.error || '应用通道失败'); } - if (typeof populateChatAIChannelSelect === 'function') { - populateChatAIChannelSelect(currentConfig.ai); - } + await refreshAIChannelsFromServer(selectedAIChannelId); showAIChannelSaveHint(successMessage || '通道已保存', true); return true; } catch (error) { @@ -2850,7 +2979,7 @@ function createAIChannelFromForm() { selectedAIChannelId = id; renderAIChannelSelect(); writeAIChannelToMainForm(id); - showAIChannelSaveHint('新通道尚未保存,填写后点击「保存更改」。', true); + showAIChannelSaveHint(settingsT('settingsBasic.aiChannelNewUnsaved', '新通道尚未保存,填写后点击「保存更改」。'), true); } function copyAIChannelFromForm() { @@ -2862,10 +2991,10 @@ function copyAIChannelFromForm() { selectedAIChannelId = id; renderAIChannelSelect(); writeAIChannelToMainForm(id); - showAIChannelSaveHint('复制的通道尚未保存,确认后点击「保存更改」。', true); + showAIChannelSaveHint(settingsT('settingsBasic.aiChannelCopyUnsaved', '复制的通道尚未保存,确认后点击「保存更改」。'), true); } -function deleteSelectedAIChannel() { +async function deleteSelectedAIChannel() { if (!currentConfig) return; currentConfig.ai = ensureAIConfigShape(currentConfig); const ids = Object.keys(currentConfig.ai.channels || {}); @@ -2883,10 +3012,21 @@ function deleteSelectedAIChannel() { return; } delete currentConfig.ai.channels[id]; - currentConfig.ai.default_channel = Object.keys(currentConfig.ai.channels).sort()[0]; + delete aiChannelProbeResults[id]; + selectedAIChannelBulkIds.delete(id); + + const remainingIds = Object.keys(currentConfig.ai.channels || {}).sort(); + if (!currentConfig.ai.channels[currentConfig.ai.default_channel]) { + currentConfig.ai.default_channel = remainingIds[0]; + } + selectedAIChannelId = currentConfig.ai.default_channel || remainingIds[0]; renderAIChannelSelect(); - writeAIChannelToMainForm(currentConfig.ai.default_channel); - persistAIChannelsToServer('通道已删除', { mergeLatest: false }); + writeAIChannelToMainForm(selectedAIChannelId); + const saved = await persistAIConfigOnlyToServer(settingsT('settingsBasic.aiChannelDeleted', '通道已删除')); + if (saved) { + renderAIChannelSelect(); + writeAIChannelToMainForm(selectedAIChannelId); + } } function selectedOrAllAIChannelIdsForProbe() { @@ -2904,12 +3044,13 @@ async function probeSelectedAIChannels() { if (typeof requirePermission === 'function' && !requirePermission('config:write')) return; const ids = selectedOrAllAIChannelIdsForProbe(); if (!ids.length) { - alert('没有可探活的完整通道,请先填写 Base URL、API Key 和模型'); + alert(settingsT('settingsBasic.aiChannelProbeNoComplete', '没有可探活的完整通道,请先填写 Base URL、API Key 和模型')); return; } - showAIChannelSaveHint(`正在探活 ${ids.length} 个通道...`, true); + showAIChannelSaveHint(settingsT('settingsBasic.aiChannelProbing', '正在探活 {count} 个通道...').replace('{count}', String(ids.length)), true); ids.forEach((id) => { - aiChannelProbeResults[id] = { status: 'testing', message: '测试中...' }; + aiChannelProbeResults[id] = { status: 'testing', message: settingsT('settingsBasic.testing', '测试中...') }; + updateAIChannelSelectOption(id); }); renderAIChannelList(); let okCount = 0; @@ -2933,13 +3074,14 @@ async function probeSelectedAIChannels() { if (response.ok && result.success) { okCount += 1; const latency = result.latency_ms ? ` ${result.latency_ms}ms` : ''; - aiChannelProbeResults[id] = { status: 'ready', message: `可用${latency}` }; + aiChannelProbeResults[id] = { status: 'ready', message: settingsT('settingsBasic.aiChannelReadyWithLatency', '可用{latency}').replace('{latency}', latency) }; } else { - aiChannelProbeResults[id] = { status: 'failed', message: (result.error || '连接失败') }; + aiChannelProbeResults[id] = { status: 'failed', message: formatConnectionTestError(result.error || settingsT('settingsBasic.testFailed', '连接失败')).message }; } } catch (error) { - aiChannelProbeResults[id] = { status: 'failed', message: error.message || '测试出错' }; + aiChannelProbeResults[id] = { status: 'failed', message: formatConnectionTestError(error.message || settingsT('settingsBasic.testError', '测试出错')).message }; } + updateAIChannelSelectOption(id); renderAIChannelList(); } const workers = Array.from({ length: Math.min(AI_CHANNEL_PROBE_CONCURRENCY, ids.length) }, async function () { @@ -2948,7 +3090,7 @@ async function probeSelectedAIChannels() { } }); await Promise.all(workers); - showAIChannelSaveHint(`探活完成:${okCount}/${ids.length} 可用`, okCount === ids.length); + showAIChannelSaveHint(settingsT('settingsBasic.aiChannelProbeDone', '探活完成:{ok}/{total} 可用').replace('{ok}', String(okCount)).replace('{total}', String(ids.length)), okCount === ids.length); } async function deleteCheckedAIChannels() { @@ -2997,7 +3139,17 @@ if (typeof window !== 'undefined') { window.deleteCheckedAIChannels = deleteCheckedAIChannels; } +if (typeof document !== 'undefined' && !document.__aiChannelI18nBound) { + document.__aiChannelI18nBound = true; + document.addEventListener('languagechange', function () { + if (!currentConfig?.ai) return; + renderAIChannelSelect(); + updateAIChannelEditorChrome(selectedAIChannelId || currentConfig.ai.default_channel); + }); +} + function initModelListControls() { + bindAIChannelEditorPreviewSync(); const providerEl = document.getElementById('openai-provider'); if (providerEl && !providerEl.dataset.modelListBound) { providerEl.dataset.modelListBound = '1'; @@ -3048,6 +3200,9 @@ function bindModelSelect(scope) { if (!select.value) return; const input = document.getElementById(inputId); if (input) input.value = select.value; + if (scope === 'openai') { + syncAIChannelEditorPreview(); + } }); } @@ -3430,6 +3585,36 @@ async function testHitlAuditModelConnection() { } } +function formatConnectionTestError(errorText) { + const raw = String(errorText || '').trim() || settingsT('settingsBasic.testError', '测试出错'); + return { + message: raw, + detail: raw + }; +} + +function setConnectionTestResult(resultEl, state, message, title) { + if (!resultEl) return; + resultEl.classList.remove('is-error', 'is-success', 'is-muted', 'is-visible'); + resultEl.style.color = ''; + resultEl.textContent = message || ''; + resultEl.title = title || ''; + if (message) { + resultEl.classList.add('is-visible', state || 'is-muted'); + } +} + +function showConnectionTestFailure(resultEl, errorText) { + if (!resultEl) return; + const formatted = formatConnectionTestError(errorText); + setConnectionTestResult( + resultEl, + 'is-error', + (typeof window.t === 'function' ? window.t('settingsBasic.testFailed') : '连接失败') + ': ' + formatted.message, + formatted.detail || formatted.message + ); +} + // 测试OpenAI连接 async function testOpenAIConnection() { const btn = document.getElementById('test-openai-btn'); @@ -3441,15 +3626,13 @@ async function testOpenAIConnection() { const model = document.getElementById('openai-model').value.trim(); if (!apiKey || !model) { - resultEl.style.color = 'var(--danger-color, #e53e3e)'; - resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 API Key 和模型'; + setConnectionTestResult(resultEl, 'is-error', typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 API Key 和模型'); return; } btn.style.pointerEvents = 'none'; btn.style.opacity = '0.5'; - resultEl.style.color = 'var(--text-muted, #888)'; - resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testing') : '测试中...'; + setConnectionTestResult(resultEl, 'is-muted', typeof window.t === 'function' ? window.t('settingsBasic.testing') : '测试中...'); try { const response = await apiFetch('/api/config/test-openai', { @@ -3466,17 +3649,14 @@ async function testOpenAIConnection() { const result = await response.json(); if (result.success) { - resultEl.style.color = 'var(--success-color, #38a169)'; const latency = result.latency_ms ? ` (${result.latency_ms}ms)` : ''; const modelInfo = result.model ? ` [${result.model}]` : ''; - resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency; + setConnectionTestResult(resultEl, 'is-success', (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency); } else { - resultEl.style.color = 'var(--danger-color, #e53e3e)'; - resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testFailed') : '连接失败') + ': ' + (result.error || '未知错误'); + showConnectionTestFailure(resultEl, result.error || '未知错误'); } } catch (error) { - resultEl.style.color = 'var(--danger-color, #e53e3e)'; - resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testError') : '测试出错') + ': ' + error.message; + showConnectionTestFailure(resultEl, error.message || '测试出错'); } finally { btn.style.pointerEvents = ''; btn.style.opacity = ''; diff --git a/web/templates/index.html b/web/templates/index.html index 16b5bd3a..2e488084 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -3446,23 +3446,27 @@

AI 通道配置

-

已保存的通道会显示在左侧;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。

+

通过下拉切换已保存通道;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。

+
+
+ +
-
- +
@@ -3470,91 +3474,118 @@
-

-

-
- - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - - 获取列表 -
- - -
-
- - - 内存压缩和攻击链构建共用此配置,默认 120000 -
-
- - - 单次模型回复的输出上限,默认 16384 -
-
- - 作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。 -
- - - - - - -
- -
-
- 测试连接 - -
+
+
+
+
连接信息
+

先确认服务商、地址、密钥和模型,测试连接会使用这些信息。

+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + 获取列表 +
+ + +
+
+
+ +
+
+
+
额度设置
+

Token 上限控制上下文窗口和单次输出。

+
+
+
+
+ + + 内存压缩和攻击链构建共用此配置,默认 120000 +
+
+ + + 单次模型回复的输出上限,默认 16384 +
+
+
+ +
+ + + 推理设置 + 作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。 + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +