diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 27ef7cdc..74cfb240 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -301,6 +301,9 @@ pub async fn start_server( "/api/settings/{key}", axum::routing::delete(settings_delete_handler), ) + // LLM utilities + .route("/api/llm/test_connection", post(llm_test_connection_handler)) + .route("/api/llm/list_models", post(llm_list_models_handler)) // Gateway control plane .route("/api/gateway/status", get(gateway_status_handler)) // OpenAI-compatible API @@ -2589,6 +2592,266 @@ async fn settings_delete_handler( Ok(StatusCode::NO_CONTENT) } +#[derive(serde::Deserialize)] +struct TestConnectionRequest { + adapter: String, + base_url: String, + #[serde(default)] + api_key: Option, + #[serde(default)] + model: Option, +} + +#[derive(serde::Serialize)] +struct TestConnectionResponse { + ok: bool, + message: String, +} + +async fn llm_test_connection_handler( + Json(body): Json, +) -> Json { + Json(test_provider_connection(body).await) +} + +async fn test_provider_connection(req: TestConnectionRequest) -> TestConnectionResponse { + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + { + Ok(c) => c, + Err(e) => { + return TestConnectionResponse { + ok: false, + message: format!("Failed to build HTTP client: {e}"), + } + } + }; + + let base = req.base_url.trim_end_matches('/'); + + match req.adapter.as_str() { + "ollama" => { + let url = format!("{base}/api/tags"); + match client.get(&url).send().await { + Ok(r) if r.status().is_success() => TestConnectionResponse { + ok: true, + message: format!("Connected ({})", r.status()), + }, + Ok(r) => TestConnectionResponse { + ok: false, + message: format!("Server returned {}", r.status()), + }, + Err(e) => TestConnectionResponse { + ok: false, + message: format!("Connection failed: {e}"), + }, + } + } + "anthropic" => { + let url = format!("{base}/messages"); + let model = req.model.as_deref().unwrap_or("claude-3-haiku-20240307"); + let payload = serde_json::json!({ + "model": model, + "max_tokens": 1, + "messages": [{"role": "user", "content": "hi"}] + }); + let mut builder = client + .post(&url) + .header("anthropic-version", "2023-06-01") + .json(&payload); + if let Some(key) = req.api_key.as_deref().filter(|k| !k.is_empty()) { + builder = builder.header("x-api-key", key); + } + interpret_chat_response(builder.send().await) + } + _ => { + // OpenAI-compatible + let url = format!("{base}/chat/completions"); + let model = req.model.as_deref().unwrap_or("gpt-4o-mini"); + let payload = serde_json::json!({ + "model": model, + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 1 + }); + let mut builder = client.post(&url).json(&payload); + if let Some(key) = req.api_key.as_deref().filter(|k| !k.is_empty()) { + builder = builder.header("Authorization", format!("Bearer {key}")); + } + interpret_chat_response(builder.send().await) + } + } +} + +fn interpret_chat_response( + result: Result, +) -> TestConnectionResponse { + match result { + Ok(r) => { + let status = r.status(); + if status.is_success() { + TestConnectionResponse { ok: true, message: format!("Connected ({})", status) } + } else if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + { + TestConnectionResponse { + ok: false, + message: format!("Authentication failed ({})", status), + } + } else if status.is_client_error() { + // 400/422 = server reachable, likely wrong model name — still a success for connectivity + TestConnectionResponse { + ok: true, + message: format!("Server reachable ({})", status), + } + } else { + TestConnectionResponse { + ok: false, + message: format!("Server error ({})", status), + } + } + } + Err(e) => TestConnectionResponse { ok: false, message: format!("Connection failed: {e}") }, + } +} + +#[derive(serde::Deserialize)] +struct ListModelsRequest { + adapter: String, + base_url: String, + #[serde(default)] + api_key: Option, +} + +#[derive(serde::Serialize)] +struct ListModelsResponse { + ok: bool, + models: Vec, + message: String, +} + +async fn llm_list_models_handler( + Json(body): Json, +) -> Json { + Json(fetch_provider_models(body).await) +} + +async fn fetch_provider_models(req: ListModelsRequest) -> ListModelsResponse { + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + { + Ok(c) => c, + Err(e) => { + return ListModelsResponse { + ok: false, + models: vec![], + message: format!("Failed to build HTTP client: {e}"), + } + } + }; + + let base = req.base_url.trim_end_matches('/'); + let auth = req.api_key.as_deref().filter(|k| !k.is_empty()); + + match req.adapter.as_str() { + "ollama" => { + let url = format!("{base}/api/tags"); + match client.get(&url).send().await { + Ok(r) if r.status().is_success() => { + let body: serde_json::Value = r.json().await.unwrap_or_default(); + let models: Vec = body["models"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| m["name"].as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + if models.is_empty() { + ListModelsResponse { + ok: false, + models: vec![], + message: "No models found".to_string(), + } + } else { + ListModelsResponse { + ok: true, + message: format!("{} model(s) found", models.len()), + models, + } + } + } + Ok(r) => ListModelsResponse { + ok: false, + models: vec![], + message: format!("Server returned {}", r.status()), + }, + Err(e) => ListModelsResponse { + ok: false, + models: vec![], + message: format!("Connection failed: {e}"), + }, + } + } + _ => { + // OpenAI-compatible and Anthropic both support GET /models + let url = format!("{base}/models"); + let mut builder = client.get(&url); + if let Some(key) = auth { + builder = builder.header("Authorization", format!("Bearer {key}")); + } + // Anthropic also needs the version header and uses x-api-key + if req.adapter == "anthropic" { + if let Some(key) = auth { + builder = client + .get(&url) + .header("x-api-key", key) + .header("anthropic-version", "2023-06-01"); + } + } + match builder.send().await { + Ok(r) if r.status().is_success() => { + let body: serde_json::Value = r.json().await.unwrap_or_default(); + // OpenAI: {"data": [{"id": "..."}]} + // Anthropic: {"data": [{"id": "..."}]} + let models: Vec = body["data"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| m["id"].as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + if models.is_empty() { + ListModelsResponse { + ok: false, + models: vec![], + message: "No models found in response".to_string(), + } + } else { + ListModelsResponse { + ok: true, + message: format!("{} model(s) found", models.len()), + models, + } + } + } + Ok(r) => ListModelsResponse { + ok: false, + models: vec![], + message: format!("Server returned {} — list models not supported", r.status()), + }, + Err(e) => ListModelsResponse { + ok: false, + models: vec![], + message: format!("Connection failed: {e}"), + }, + } + } + } +} + async fn settings_export_handler( State(state): State>, ) -> Result, StatusCode> { diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 4129f9a5..a604a5d5 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -4686,6 +4686,9 @@ document.addEventListener('click', function(e) { case 'delete-custom-provider': deleteCustomProvider(el.dataset.id); break; + case 'edit-custom-provider': + editCustomProvider(el.dataset.id); + break; } }); @@ -4748,6 +4751,8 @@ const ADAPTER_LABELS = { let _customProviders = []; let _activeLlmBackend = ''; +let _selectedModel = ''; +let _editingProviderId = null; let _configLoaded = false; function loadConfig() { @@ -4757,6 +4762,7 @@ function loadConfig() { apiFetch('/api/settings/export').then((d) => { const s = (d && d.settings) ? d.settings : {}; _activeLlmBackend = s['llm_backend'] ? String(s['llm_backend']) : 'nearai'; + _selectedModel = s['selected_model'] ? String(s['selected_model']) : ''; try { const val = s['llm_custom_providers']; _customProviders = Array.isArray(val) ? val : (val ? JSON.parse(val) : []); @@ -4767,6 +4773,7 @@ function loadConfig() { renderProviders(); }).catch(() => { _activeLlmBackend = 'nearai'; + _selectedModel = ''; _customProviders = []; _configLoaded = true; renderProviders(); @@ -4798,12 +4805,18 @@ function renderProviders() { const deleteBtn = !p.builtin && !isActive ? '' : ''; + const editBtn = !p.builtin + ? '' + : ''; const useBtn = !isActive ? '' : ''; const baseUrlText = p.base_url ? '' + escHtml(p.base_url) + '' : ''; + const modelText = isActive && _selectedModel + ? '' + escHtml(I18n.t('config.currentModel', { model: _selectedModel })) + '' + : ''; return '
' + '
' @@ -4814,9 +4827,10 @@ function renderProviders() { + '
' + '' + escHtml(adapterLabel) + '' + baseUrlText + + modelText + '
' + '
' - + useBtn + deleteBtn + + useBtn + editBtn + deleteBtn + '
' + '
'; }).join(''); @@ -4827,10 +4841,16 @@ function escHtml(s) { } function setActiveProvider(id) { + const provider = [...BUILTIN_PROVIDERS, ..._customProviders].find((p) => p.id === id); + const defaultModel = provider && provider.default_model ? provider.default_model : null; + const modelUpdate = defaultModel + ? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: defaultModel } }) + : apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' }); apiFetchVoid('/api/settings/llm_backend', { method: 'PUT', body: { value: id } }) - .then(() => apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' })) + .then(() => modelUpdate) .then(() => { _activeLlmBackend = id; + _selectedModel = defaultModel || ''; renderProviders(); document.getElementById('providers-list').scrollIntoView({ behavior: 'smooth', block: 'start' }); document.getElementById('config-restart-notice').style.display = 'flex'; @@ -4856,18 +4876,85 @@ function saveCustomProviders() { return apiFetchVoid('/api/settings/llm_custom_providers', { method: 'PUT', body: { value: _customProviders } }); } +function editCustomProvider(id) { + const p = _customProviders.find((p) => p.id === id); + if (!p) return; + _editingProviderId = id; + const titleEl = document.getElementById('provider-form-title'); + titleEl.textContent = I18n.t('config.editProvider'); + titleEl.removeAttribute('data-i18n'); + document.getElementById('provider-name').value = p.name || ''; + const idField = document.getElementById('provider-id'); + idField.value = p.id; + idField.readOnly = true; + idField.style.opacity = '0.6'; + document.getElementById('provider-adapter').value = p.adapter || 'open_ai_completions'; + document.getElementById('provider-base-url').value = p.base_url || ''; + document.getElementById('provider-api-key').value = p.api_key || ''; + document.getElementById('provider-model').value = p.default_model || ''; + openProviderDialog(true); + document.getElementById('provider-name').focus(); +} + // Add provider form document.getElementById('add-provider-btn').addEventListener('click', () => { - document.getElementById('add-provider-form').style.display = ''; - document.getElementById('add-provider-btn').style.display = 'none'; - document.getElementById('provider-name').focus(); + openProviderDialog(false); }); document.getElementById('cancel-provider-btn').addEventListener('click', () => { resetProviderForm(); }); +document.getElementById('cancel-provider-footer-btn').addEventListener('click', () => { + resetProviderForm(); +}); + +document.getElementById('provider-dialog-overlay').addEventListener('click', () => { + resetProviderForm(); +}); + +function openProviderDialog(isEdit) { + document.getElementById('provider-dialog').style.display = 'flex'; + if (!isEdit) { + document.getElementById('provider-name').focus(); + } +} + +document.getElementById('test-provider-btn').addEventListener('click', () => { + const adapter = document.getElementById('provider-adapter').value; + const baseUrl = document.getElementById('provider-base-url').value.trim(); + const apiKey = document.getElementById('provider-api-key').value.trim(); + const model = document.getElementById('provider-model').value.trim(); + + const btn = document.getElementById('test-provider-btn'); + const result = document.getElementById('test-connection-result'); + + btn.disabled = true; + btn.textContent = I18n.t('config.testing'); + result.style.display = 'none'; + result.className = 'test-connection-result'; + + apiFetch('/api/llm/test_connection', { + method: 'POST', + body: { adapter, base_url: baseUrl, api_key: apiKey || undefined, model: model || undefined }, + }) + .then((data) => { + result.textContent = data.message; + result.className = 'test-connection-result ' + (data.ok ? 'test-ok' : 'test-fail'); + result.style.display = ''; + }) + .catch((e) => { + result.textContent = e.message; + result.className = 'test-connection-result test-fail'; + result.style.display = ''; + }) + .finally(() => { + btn.disabled = false; + btn.textContent = I18n.t('config.testConnection'); + }); +}); + document.getElementById('save-provider-btn').addEventListener('click', () => { const name = document.getElementById('provider-name').value.trim(); const id = document.getElementById('provider-id').value.trim(); @@ -4880,6 +4967,32 @@ document.getElementById('save-provider-btn').addEventListener('click', () => { showToast(I18n.t('config.providerFieldsRequired'), 'error'); return; } + + if (_editingProviderId) { + // Update existing provider + const idx = _customProviders.findIndex((p) => p.id === _editingProviderId); + if (idx === -1) return; + const original = _customProviders[idx]; + _customProviders[idx] = { ...original, name, adapter, base_url: baseUrl, default_model: model || undefined, api_key: apiKey || undefined }; + const isActive = _editingProviderId === _activeLlmBackend; + const modelUpdate = isActive + ? (model + ? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: model } }) + : apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' })) + : Promise.resolve(); + saveCustomProviders().then(() => modelUpdate).then(() => { + if (isActive) _selectedModel = model; + renderProviders(); + resetProviderForm(); + document.getElementById('config-restart-notice').style.display = 'flex'; + showToast(I18n.t('config.providerUpdated', { name })); + }).catch((e) => { + _customProviders[idx] = original; + showToast(I18n.t('error.unknown') + ': ' + e.message, 'error'); + }); + return; + } + if (!/^[a-z0-9_-]+$/.test(id)) { showToast(I18n.t('config.providerIdInvalid'), 'error'); return; @@ -4905,14 +5018,66 @@ document.getElementById('save-provider-btn').addEventListener('click', () => { }); function resetProviderForm() { - document.getElementById('add-provider-form').style.display = 'none'; - document.getElementById('add-provider-btn').style.display = ''; + _editingProviderId = null; + document.getElementById('provider-dialog').style.display = 'none'; + const titleEl = document.getElementById('provider-form-title'); + titleEl.setAttribute('data-i18n', 'config.newProvider'); + titleEl.textContent = I18n.t('config.newProvider'); + const idField = document.getElementById('provider-id'); + idField.readOnly = false; + idField.style.opacity = ''; ['provider-name', 'provider-id', 'provider-base-url', 'provider-api-key', 'provider-model'].forEach((id) => { document.getElementById(id).value = ''; }); document.getElementById('provider-adapter').selectedIndex = 0; + const sel = document.getElementById('provider-model-select'); + sel.innerHTML = ''; + sel.style.display = 'none'; + document.getElementById('test-connection-result').style.display = 'none'; } +document.getElementById('provider-model-select').addEventListener('change', (e) => { + document.getElementById('provider-model').value = e.target.value; +}); + +document.getElementById('fetch-models-btn').addEventListener('click', () => { + const adapter = document.getElementById('provider-adapter').value; + const baseUrl = document.getElementById('provider-base-url').value.trim(); + const apiKey = document.getElementById('provider-api-key').value.trim(); + + if (!baseUrl) { + showToast(I18n.t('config.providerBaseUrlRequired'), 'error'); + return; + } + + const btn = document.getElementById('fetch-models-btn'); + btn.disabled = true; + btn.textContent = '…'; + + apiFetch('/api/llm/list_models', { + method: 'POST', + body: { adapter, base_url: baseUrl, api_key: apiKey || undefined }, + }) + .then((data) => { + const select = document.getElementById('provider-model-select'); + if (data.ok && data.models && data.models.length > 0) { + const currentModel = document.getElementById('provider-model').value; + select.innerHTML = data.models + .map((m) => ``) + .join(''); + select.style.display = ''; + showToast(I18n.t('config.modelsFetched', { count: data.models.length })); + } else { + showToast(data.message || I18n.t('config.modelsFetchFailed'), 'error'); + } + }) + .catch((e) => showToast(e.message, 'error')) + .finally(() => { + btn.disabled = false; + btn.textContent = '↻'; + }); +}); + // Auto-fill provider ID from name document.getElementById('provider-name').addEventListener('input', (e) => { const idField = document.getElementById('provider-id'); diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index 1917fd26..971786f5 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -348,6 +348,7 @@ I18n.register('en', { 'config.restartNotice': 'Changes take effect after restart.', 'config.builtin': 'built-in', 'config.useProvider': 'Use', + 'config.currentModel': 'Model: {model}', 'config.providerName': 'Display Name', 'config.providerNamePlaceholder': 'My Provider', 'config.providerId': 'Provider ID', @@ -362,9 +363,17 @@ I18n.register('en', { 'config.providerModel': 'Default Model', 'config.providerActivated': 'Switched to {name} (restart to apply)', 'config.providerAdded': 'Added provider "{name}" (restart to apply)', + 'config.providerUpdated': 'Provider "{name}" updated (restart to apply)', + 'config.editProvider': 'Edit Provider', 'config.providerDeleted': 'Provider deleted', 'config.confirmDeleteProvider': 'Delete provider "{id}"?', 'config.cannotDeleteActiveProvider': 'Cannot delete the active provider. Switch to another provider first.', + 'config.testConnection': 'Test', + 'config.testing': 'Testing…', + 'config.fetchModels': 'Fetch available models', + 'config.modelsFetched': '{count} model(s) loaded — type to filter', + 'config.modelsFetchFailed': 'Failed to fetch models', + 'config.providerBaseUrlRequired': 'Base URL is required to fetch models', 'config.providerFieldsRequired': 'Display name and Provider ID are required', 'config.providerIdInvalid': 'Provider ID: use only lowercase letters, numbers, hyphens', 'config.providerIdTaken': 'Provider ID "{id}" is already taken', diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index 8f715ee7..e5a406b1 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -348,6 +348,7 @@ I18n.register('zh-CN', { 'config.restartNotice': '更改将在重启后生效。', 'config.builtin': '内置', 'config.useProvider': '使用', + 'config.currentModel': '模型:{model}', 'config.providerName': '显示名称', 'config.providerNamePlaceholder': '我的提供商', 'config.providerId': '提供商 ID', @@ -362,9 +363,17 @@ I18n.register('zh-CN', { 'config.providerModel': '默认模型', 'config.providerActivated': '已切换到 {name}(重启后生效)', 'config.providerAdded': '已添加提供商 "{name}"(重启后生效)', + 'config.providerUpdated': '提供商 "{name}" 已更新(重启后生效)', + 'config.editProvider': '编辑提供商', 'config.providerDeleted': '提供商已删除', 'config.confirmDeleteProvider': '确定删除提供商 "{id}"?', 'config.cannotDeleteActiveProvider': '无法删除当前正在使用的提供商,请先切换到其他提供商。', + 'config.testConnection': '测试', + 'config.testing': '测试中…', + 'config.fetchModels': '获取可用模型', + 'config.modelsFetched': '已加载 {count} 个模型,可输入过滤', + 'config.modelsFetchFailed': '获取模型列表失败', + 'config.providerBaseUrlRequired': '请先填写 Base URL', 'config.providerFieldsRequired': '显示名称和提供商 ID 为必填项', 'config.providerIdInvalid': '提供商 ID 只能包含小写字母、数字和连字符', 'config.providerIdTaken': '提供商 ID "{id}" 已被占用', diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index f725a40c..dace6e4a 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -44,6 +44,60 @@
+ + + - diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 8f64826d..31861421 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -4281,6 +4281,13 @@ mark { opacity: 0.7; } +.provider-current-model { + font-size: 11px; + color: var(--accent); + font-family: var(--font-mono); + font-weight: 500; +} + .provider-card-actions { display: flex; gap: 6px; @@ -4311,18 +4318,125 @@ mark { /* Config form */ -.config-form-section { +.provider-dialog { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +.provider-dialog-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +.provider-dialog-content { + position: relative; + z-index: 10000; + background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 16px; - background: var(--bg-secondary); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4); + width: 100%; + max-width: 480px; + margin: 0 1rem; + display: flex; + flex-direction: column; + max-height: 90vh; +} + +.provider-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.provider-dialog-header h2 { + font-size: 14px; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.provider-dialog-close { + color: var(--text-secondary); + font-size: 18px; + line-height: 1; + padding: 2px 6px; + background: transparent; + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: color 0.15s, background 0.15s; +} + +.provider-dialog-close:hover { + color: var(--text); + background: var(--bg-hover); +} + +.provider-dialog-body { + padding: 18px; + overflow-y: auto; + flex: 1; +} + +.provider-dialog-footer { + display: flex; + gap: 8px; + padding: 14px 18px; + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +.provider-dialog-footer button { + padding: 6px 18px; + border-radius: var(--radius); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, transform 0.2s; +} + +.provider-dialog-footer button:first-child { + background: var(--accent); + color: #09090b; + border: none; +} + +.provider-dialog-footer button:first-child:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +.provider-dialog-footer .btn-secondary { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.provider-dialog-footer .btn-secondary:hover { + color: var(--text); + border-color: rgba(255, 255, 255, 0.2); } .config-form { display: flex; flex-direction: column; gap: 12px; - max-width: 480px; } .config-form-row { @@ -4396,3 +4510,55 @@ mark { color: var(--text); border-color: rgba(255, 255, 255, 0.2); } + +.provider-model-input-group { + display: flex; + gap: 6px; + align-items: center; +} + +.provider-model-input-group input { + flex: 1; +} + +.btn-fetch-models { + padding: 5px 10px; + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-secondary); + cursor: pointer; + font-size: 15px; + line-height: 1; + transition: color 0.15s, border-color 0.15s; + flex-shrink: 0; +} + +.btn-fetch-models:hover { + color: var(--text); + border-color: rgba(255, 255, 255, 0.2); +} + +.btn-fetch-models:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.test-connection-result { + margin-top: 8px; + padding: 6px 12px; + border-radius: var(--radius); + font-size: 13px; +} + +.test-connection-result.test-ok { + background: rgba(74, 222, 128, 0.12); + color: #4ade80; + border: 1px solid rgba(74, 222, 128, 0.3); +} + +.test-connection-result.test-fail { + background: rgba(248, 113, 113, 0.12); + color: #f87171; + border: 1px solid rgba(248, 113, 113, 0.3); +} diff --git a/src/llm/rig_adapter.rs b/src/llm/rig_adapter.rs index 5c1faef7..5ca4fec0 100644 --- a/src/llm/rig_adapter.rs +++ b/src/llm/rig_adapter.rs @@ -290,6 +290,10 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option, Vec { if msg.content_parts.is_empty() { + // Skip empty user messages — some providers (e.g. Kimi) reject "content": "" + if msg.content.is_empty() { + continue; + } history.push(RigMessage::user(&msg.content)); } else { // Build multimodal user message with text + image parts @@ -353,6 +357,12 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option, Vec