+
-
-
Agent Mode
-
--
+
+
+
-
-
Max Tokens
-
--
+
+
+
-
-
Max Turns
-
--
+
+
+
-
-
Max Steps
-
--
+
+
+
-
-
-
-
-
-
-
Channel Configuration
-
-
-
- Channel Type
- --
-
-
-
-
-
-
-
- Full editing capability coming soon. Currently displaying read-only configuration.
+
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css
index 2390569..8d0442f 100644
--- a/channel/web/static/css/console.css
+++ b/channel/web/static/css/console.css
@@ -222,6 +222,121 @@
/* Tool failed state */
.agent-tool-step.tool-failed .tool-name { color: #f87171; }
+/* Config form controls */
+#view-config input[type="text"],
+#view-config input[type="number"],
+#view-config input[type="password"] {
+ height: 40px;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+#view-config input:focus {
+ border-color: #4ABE6E;
+ box-shadow: 0 0 0 3px rgba(74, 190, 110, 0.12);
+}
+#view-config input[type="text"]:hover,
+#view-config input[type="number"]:hover,
+#view-config input[type="password"]:hover {
+ border-color: #94a3b8;
+}
+.dark #view-config input[type="text"]:hover,
+.dark #view-config input[type="number"]:hover,
+.dark #view-config input[type="password"]:hover {
+ border-color: #64748b;
+}
+
+/* Custom dropdown */
+.cfg-dropdown {
+ position: relative;
+ outline: none;
+}
+.cfg-dropdown-selected {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 40px;
+ padding: 0 0.75rem;
+ border-radius: 0.5rem;
+ border: 1px solid #e2e8f0;
+ background: #f8fafc;
+ font-size: 0.875rem;
+ color: #1e293b;
+ cursor: pointer;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+ user-select: none;
+}
+.dark .cfg-dropdown-selected {
+ border-color: #475569;
+ background: rgba(255, 255, 255, 0.05);
+ color: #f1f5f9;
+}
+.cfg-dropdown-selected:hover { border-color: #94a3b8; }
+.dark .cfg-dropdown-selected:hover { border-color: #64748b; }
+.cfg-dropdown.open .cfg-dropdown-selected,
+.cfg-dropdown:focus .cfg-dropdown-selected {
+ border-color: #4ABE6E;
+ box-shadow: 0 0 0 3px rgba(74, 190, 110, 0.12);
+}
+.cfg-dropdown-arrow {
+ font-size: 0.625rem;
+ color: #94a3b8;
+ transition: transform 0.2s ease;
+ flex-shrink: 0;
+ margin-left: 0.5rem;
+}
+.cfg-dropdown.open .cfg-dropdown-arrow { transform: rotate(180deg); }
+.cfg-dropdown-menu {
+ display: none;
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ z-index: 50;
+ max-height: 240px;
+ overflow-y: auto;
+ border-radius: 0.5rem;
+ border: 1px solid #e2e8f0;
+ background: #ffffff;
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 4px 10px -5px rgba(0, 0, 0, 0.04);
+ padding: 4px;
+}
+.dark .cfg-dropdown-menu {
+ border-color: #334155;
+ background: #1e1e1e;
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
+}
+.cfg-dropdown.open .cfg-dropdown-menu { display: block; }
+.cfg-dropdown-item {
+ display: flex;
+ align-items: center;
+ padding: 8px 10px;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ color: #334155;
+ cursor: pointer;
+ transition: background 0.15s ease;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.dark .cfg-dropdown-item { color: #cbd5e1; }
+.cfg-dropdown-item:hover { background: #f1f5f9; }
+.dark .cfg-dropdown-item:hover { background: rgba(255, 255, 255, 0.08); }
+.cfg-dropdown-item.active {
+ background: rgba(74, 190, 110, 0.1);
+ color: #228547;
+ font-weight: 500;
+}
+.dark .cfg-dropdown-item.active {
+ background: rgba(74, 190, 110, 0.15);
+ color: #74E9A4;
+}
+
+/* API Key masking via CSS (avoids browser password prompts) */
+.cfg-key-masked {
+ -webkit-text-security: disc;
+ text-security: disc;
+}
+
/* Chat Input */
#chat-input {
resize: none; height: 42px; max-height: 180px;
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 7320d7e..1a0e720 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -28,7 +28,11 @@ const I18N = {
config_agent_enabled: 'Agent 模式', config_max_tokens: '最大 Token',
config_max_turns: '最大轮次', config_max_steps: '最大步数',
config_channel_type: '通道类型',
- config_coming_soon: '完整编辑功能即将推出,当前为只读展示。',
+ config_provider: '模型厂商', config_model_name: '模型',
+ config_custom_model_hint: '输入自定义模型名称',
+ config_save: '保存', config_saved: '已保存',
+ config_save_error: '保存失败',
+ config_custom_option: '自定义...',
skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能',
skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处',
memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容',
@@ -60,7 +64,11 @@ const I18N = {
config_agent_enabled: 'Agent Mode', config_max_tokens: 'Max Tokens',
config_max_turns: 'Max Turns', config_max_steps: 'Max Steps',
config_channel_type: 'Channel Type',
- config_coming_soon: 'Full editing capability coming soon. Currently displaying read-only configuration.',
+ config_provider: 'Provider', config_model_name: 'Model',
+ config_custom_model_hint: 'Enter custom model name',
+ config_save: 'Save', config_saved: 'Saved',
+ config_save_error: 'Save failed',
+ config_custom_option: 'Custom...',
skills_title: 'Skills', skills_desc: 'View, enable, or disable agent skills',
skills_loading: 'Loading skills...', skills_loading_desc: 'Skills will be displayed here after loading',
memory_title: 'Memory', memory_desc: 'View agent memory files and contents',
@@ -236,7 +244,7 @@ let isPolling = false;
let loadingContainers = {};
let activeStreams = {}; // request_id -> EventSource
let isComposing = false;
-let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '' };
+let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '', providers: {}, api_bases: {} };
const SESSION_ID_KEY = 'cow_session_id';
@@ -268,14 +276,8 @@ fetch('/config').then(r => r.json()).then(data => {
appConfig = data;
const title = data.title || 'CowAgent';
document.getElementById('welcome-title').textContent = title;
- document.getElementById('cfg-model').textContent = data.model || '--';
- document.getElementById('cfg-agent').textContent = data.use_agent ? 'Enabled' : 'Disabled';
- document.getElementById('cfg-max-tokens').textContent = data.agent_max_context_tokens || '--';
- document.getElementById('cfg-max-turns').textContent = data.agent_max_context_turns || '--';
- document.getElementById('cfg-max-steps').textContent = data.agent_max_steps || '--';
- document.getElementById('cfg-channel').textContent = data.channel_type || '--';
+ initConfigView(data);
}
- // Load conversation history after config is ready
loadHistory(1);
}).catch(() => { loadHistory(1); });
@@ -820,15 +822,312 @@ function applyHighlighting(container) {
// =====================================================================
// Config View
// =====================================================================
+let configProviders = {};
+let configApiBases = {};
+let configApiKeys = {};
+let configCurrentModel = '';
+let cfgProviderValue = '';
+let cfgModelValue = '';
+
+// --- Custom dropdown helper ---
+function initDropdown(el, options, selectedValue, onChange) {
+ const textEl = el.querySelector('.cfg-dropdown-text');
+ const menuEl = el.querySelector('.cfg-dropdown-menu');
+ const selEl = el.querySelector('.cfg-dropdown-selected');
+
+ el._ddValue = selectedValue || '';
+ el._ddOnChange = onChange;
+
+ function render() {
+ menuEl.innerHTML = '';
+ options.forEach(opt => {
+ const item = document.createElement('div');
+ item.className = 'cfg-dropdown-item' + (opt.value === el._ddValue ? ' active' : '');
+ item.textContent = opt.label;
+ item.dataset.value = opt.value;
+ item.addEventListener('click', (e) => {
+ e.stopPropagation();
+ el._ddValue = opt.value;
+ textEl.textContent = opt.label;
+ menuEl.querySelectorAll('.cfg-dropdown-item').forEach(i => i.classList.remove('active'));
+ item.classList.add('active');
+ el.classList.remove('open');
+ if (el._ddOnChange) el._ddOnChange(opt.value);
+ });
+ menuEl.appendChild(item);
+ });
+ const sel = options.find(o => o.value === el._ddValue);
+ textEl.textContent = sel ? sel.label : (options[0] ? options[0].label : '--');
+ if (!sel && options[0]) el._ddValue = options[0].value;
+ }
+
+ render();
+
+ if (!el._ddBound) {
+ selEl.addEventListener('click', (e) => {
+ e.stopPropagation();
+ document.querySelectorAll('.cfg-dropdown.open').forEach(d => { if (d !== el) d.classList.remove('open'); });
+ el.classList.toggle('open');
+ });
+ el._ddBound = true;
+ }
+}
+
+document.addEventListener('click', () => {
+ document.querySelectorAll('.cfg-dropdown.open').forEach(d => d.classList.remove('open'));
+});
+
+function getDropdownValue(el) { return el._ddValue || ''; }
+
+// --- Config init ---
+function initConfigView(data) {
+ configProviders = data.providers || {};
+ configApiBases = data.api_bases || {};
+ configApiKeys = data.api_keys || {};
+ configCurrentModel = data.model || '';
+
+ const providerEl = document.getElementById('cfg-provider');
+ const providerOpts = Object.entries(configProviders).map(([pid, p]) => ({ value: pid, label: p.label }));
+
+ const detected = detectProvider(configCurrentModel);
+ cfgProviderValue = detected || (providerOpts[0] ? providerOpts[0].value : '');
+
+ initDropdown(providerEl, providerOpts, cfgProviderValue, onProviderChange);
+
+ onProviderChange(cfgProviderValue);
+ syncModelSelection(configCurrentModel);
+
+ document.getElementById('cfg-max-tokens').value = data.agent_max_context_tokens || 50000;
+ document.getElementById('cfg-max-turns').value = data.agent_max_context_turns || 30;
+ document.getElementById('cfg-max-steps').value = data.agent_max_steps || 15;
+}
+
+function detectProvider(model) {
+ if (!model) return Object.keys(configProviders)[0] || '';
+ for (const [pid, p] of Object.entries(configProviders)) {
+ if (pid === 'linkai') continue;
+ if (p.models && p.models.includes(model)) return pid;
+ }
+ return Object.keys(configProviders)[0] || '';
+}
+
+function onProviderChange(pid) {
+ cfgProviderValue = pid || getDropdownValue(document.getElementById('cfg-provider'));
+ const p = configProviders[cfgProviderValue];
+ if (!p) return;
+
+ const modelEl = document.getElementById('cfg-model-select');
+ const modelOpts = (p.models || []).map(m => ({ value: m, label: m }));
+ modelOpts.push({ value: '__custom__', label: t('config_custom_option') });
+
+ initDropdown(modelEl, modelOpts, modelOpts[0] ? modelOpts[0].value : '', onModelSelectChange);
+
+ // API Key
+ const keyField = p.api_key_field;
+ const keyWrap = document.getElementById('cfg-api-key-wrap');
+ const keyInput = document.getElementById('cfg-api-key');
+ if (keyField) {
+ keyWrap.classList.remove('hidden');
+ keyInput.classList.add('cfg-key-masked');
+ const maskedVal = configApiKeys[keyField] || '';
+ keyInput.value = maskedVal;
+ keyInput.dataset.field = keyField;
+ keyInput.dataset.masked = maskedVal ? '1' : '';
+ keyInput.dataset.maskedVal = maskedVal;
+ const toggleIcon = document.querySelector('#cfg-api-key-toggle i');
+ if (toggleIcon) toggleIcon.className = 'fas fa-eye text-xs';
+
+ if (!keyInput._cfgBound) {
+ keyInput.addEventListener('focus', function() {
+ if (this.dataset.masked === '1') {
+ this.value = '';
+ this.dataset.masked = '';
+ this.classList.remove('cfg-key-masked');
+ }
+ });
+ keyInput.addEventListener('blur', function() {
+ if (!this.value.trim() && this.dataset.maskedVal) {
+ this.value = this.dataset.maskedVal;
+ this.dataset.masked = '1';
+ this.classList.add('cfg-key-masked');
+ }
+ });
+ keyInput.addEventListener('input', function() {
+ this.dataset.masked = '';
+ });
+ keyInput._cfgBound = true;
+ }
+ } else {
+ keyWrap.classList.add('hidden');
+ keyInput.value = '';
+ keyInput.dataset.field = '';
+ }
+
+ // API Base
+ if (p.api_base_key) {
+ document.getElementById('cfg-api-base-wrap').classList.remove('hidden');
+ document.getElementById('cfg-api-base').value = configApiBases[p.api_base_key] || p.api_base_default || '';
+ } else {
+ document.getElementById('cfg-api-base-wrap').classList.add('hidden');
+ document.getElementById('cfg-api-base').value = '';
+ }
+
+ onModelSelectChange(modelOpts[0] ? modelOpts[0].value : '');
+}
+
+function onModelSelectChange(val) {
+ cfgModelValue = val || getDropdownValue(document.getElementById('cfg-model-select'));
+ const customWrap = document.getElementById('cfg-model-custom-wrap');
+ if (cfgModelValue === '__custom__') {
+ customWrap.classList.remove('hidden');
+ document.getElementById('cfg-model-custom').focus();
+ } else {
+ customWrap.classList.add('hidden');
+ document.getElementById('cfg-model-custom').value = '';
+ }
+}
+
+function syncModelSelection(model) {
+ const p = configProviders[cfgProviderValue];
+ if (!p) return;
+
+ const modelEl = document.getElementById('cfg-model-select');
+ if (p.models && p.models.includes(model)) {
+ const modelOpts = (p.models || []).map(m => ({ value: m, label: m }));
+ modelOpts.push({ value: '__custom__', label: t('config_custom_option') });
+ initDropdown(modelEl, modelOpts, model, onModelSelectChange);
+ cfgModelValue = model;
+ document.getElementById('cfg-model-custom-wrap').classList.add('hidden');
+ } else {
+ cfgModelValue = '__custom__';
+ const modelOpts = (p.models || []).map(m => ({ value: m, label: m }));
+ modelOpts.push({ value: '__custom__', label: t('config_custom_option') });
+ initDropdown(modelEl, modelOpts, '__custom__', onModelSelectChange);
+ document.getElementById('cfg-model-custom-wrap').classList.remove('hidden');
+ document.getElementById('cfg-model-custom').value = model;
+ }
+}
+
+function getSelectedModel() {
+ if (cfgModelValue === '__custom__') {
+ return document.getElementById('cfg-model-custom').value.trim();
+ }
+ return cfgModelValue;
+}
+
+function toggleApiKeyVisibility() {
+ const input = document.getElementById('cfg-api-key');
+ const icon = document.querySelector('#cfg-api-key-toggle i');
+ if (input.classList.contains('cfg-key-masked')) {
+ input.classList.remove('cfg-key-masked');
+ icon.className = 'fas fa-eye-slash text-xs';
+ } else {
+ input.classList.add('cfg-key-masked');
+ icon.className = 'fas fa-eye text-xs';
+ }
+}
+
+function showStatus(elId, msgKey, isError) {
+ const el = document.getElementById(elId);
+ el.textContent = t(msgKey);
+ el.classList.toggle('text-red-500', !!isError);
+ el.classList.toggle('text-primary-500', !isError);
+ el.classList.remove('opacity-0');
+ setTimeout(() => el.classList.add('opacity-0'), 2500);
+}
+
+function saveModelConfig() {
+ const model = getSelectedModel();
+ if (!model) return;
+
+ const updates = { model: model };
+ const p = configProviders[cfgProviderValue];
+ updates.use_linkai = (cfgProviderValue === 'linkai');
+ if (p && p.api_base_key) {
+ const base = document.getElementById('cfg-api-base').value.trim();
+ if (base) updates[p.api_base_key] = base;
+ }
+ if (p && p.api_key_field) {
+ const keyInput = document.getElementById('cfg-api-key');
+ const rawVal = keyInput.value.trim();
+ if (rawVal && keyInput.dataset.masked !== '1') {
+ updates[p.api_key_field] = rawVal;
+ }
+ }
+
+ const btn = document.getElementById('cfg-model-save');
+ btn.disabled = true;
+ fetch('/config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ updates })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'success') {
+ configCurrentModel = model;
+ if (data.applied) {
+ const keyInput = document.getElementById('cfg-api-key');
+ Object.entries(data.applied).forEach(([k, v]) => {
+ if (k === 'model') return;
+ if (k.includes('api_key')) {
+ const masked = v.length > 8
+ ? v.substring(0, 4) + '*'.repeat(v.length - 8) + v.substring(v.length - 4)
+ : v;
+ configApiKeys[k] = masked;
+ if (keyInput.dataset.field === k) {
+ keyInput.value = masked;
+ keyInput.dataset.masked = '1';
+ keyInput.dataset.maskedVal = masked;
+ keyInput.classList.add('cfg-key-masked');
+ const toggleIcon = document.querySelector('#cfg-api-key-toggle i');
+ if (toggleIcon) toggleIcon.className = 'fas fa-eye text-xs';
+ }
+ } else {
+ configApiBases[k] = v;
+ }
+ });
+ }
+ showStatus('cfg-model-status', 'config_saved', false);
+ } else {
+ showStatus('cfg-model-status', 'config_save_error', true);
+ }
+ })
+ .catch(() => showStatus('cfg-model-status', 'config_save_error', true))
+ .finally(() => { btn.disabled = false; });
+}
+
+function saveAgentConfig() {
+ const updates = {
+ agent_max_context_tokens: parseInt(document.getElementById('cfg-max-tokens').value) || 50000,
+ agent_max_context_turns: parseInt(document.getElementById('cfg-max-turns').value) || 30,
+ agent_max_steps: parseInt(document.getElementById('cfg-max-steps').value) || 15,
+ };
+
+ const btn = document.getElementById('cfg-agent-save');
+ btn.disabled = true;
+ fetch('/config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ updates })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'success') {
+ showStatus('cfg-agent-status', 'config_saved', false);
+ } else {
+ showStatus('cfg-agent-status', 'config_save_error', true);
+ }
+ })
+ .catch(() => showStatus('cfg-agent-status', 'config_save_error', true))
+ .finally(() => { btn.disabled = false; });
+}
+
function loadConfigView() {
fetch('/config').then(r => r.json()).then(data => {
if (data.status !== 'success') return;
- document.getElementById('cfg-model').textContent = data.model || '--';
- document.getElementById('cfg-agent').textContent = data.use_agent ? 'Enabled' : 'Disabled';
- document.getElementById('cfg-max-tokens').textContent = data.agent_max_context_tokens || '--';
- document.getElementById('cfg-max-turns').textContent = data.agent_max_context_turns || '--';
- document.getElementById('cfg-max-steps').textContent = data.agent_max_steps || '--';
- document.getElementById('cfg-channel').textContent = data.channel_type || '--';
+ appConfig = data;
+ initConfigView(data);
}).catch(() => {});
}
@@ -1089,7 +1388,8 @@ navigateTo = function(viewId) {
_origNavigateTo(viewId);
// Lazy-load view data
- if (viewId === 'skills') loadSkillsView();
+ if (viewId === 'config') loadConfigView();
+ else if (viewId === 'skills') loadSkillsView();
else if (viewId === 'memory') {
// Always start from the list panel when navigating to memory
document.getElementById('memory-panel-viewer').classList.add('hidden');
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index f167c70..81920d7 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -14,6 +14,8 @@ from bridge.context import *
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.chat_message import ChatMessage
+from collections import OrderedDict
+from common import const
from common.log import logger
from common.singleton import singleton
from config import conf
@@ -379,16 +381,137 @@ class ChatHandler:
class ConfigHandler:
+
+ _RECOMMENDED_MODELS = [
+ const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
+ const.GLM_5, const.GLM_4_7,
+ const.QWEN3_MAX, const.QWEN35_PLUS,
+ const.KIMI_K2_5, const.KIMI_K2,
+ const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
+ const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET,
+ const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
+ const.GPT_5, const.GPT_41, const.GPT_4o,
+ const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER,
+ ]
+
+ PROVIDER_MODELS = OrderedDict([
+ ("minimax", {
+ "label": "MiniMax",
+ "api_key_field": "minimax_api_key",
+ "api_base_key": None,
+ "api_base_default": None,
+ "models": [const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING],
+ }),
+ ("glm-4", {
+ "label": "智谱AI",
+ "api_key_field": "zhipu_ai_api_key",
+ "api_base_key": "zhipu_ai_api_base",
+ "api_base_default": "https://open.bigmodel.cn/api/paas/v4",
+ "models": [const.GLM_5, const.GLM_4_7],
+ }),
+ ("dashscope", {
+ "label": "通义千问",
+ "api_key_field": "dashscope_api_key",
+ "api_base_key": None,
+ "api_base_default": None,
+ "models": [const.QWEN3_MAX, const.QWEN35_PLUS],
+ }),
+ ("moonshot", {
+ "label": "Kimi",
+ "api_key_field": "moonshot_api_key",
+ "api_base_key": "moonshot_base_url",
+ "api_base_default": "https://api.moonshot.cn/v1",
+ "models": [const.KIMI_K2_5, const.KIMI_K2],
+ }),
+ ("doubao", {
+ "label": "豆包",
+ "api_key_field": "ark_api_key",
+ "api_base_key": "ark_base_url",
+ "api_base_default": "https://ark.cn-beijing.volces.com/api/v3",
+ "models": [const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE],
+ }),
+ ("claudeAPI", {
+ "label": "Claude",
+ "api_key_field": "claude_api_key",
+ "api_base_key": "claude_api_base",
+ "api_base_default": "https://api.anthropic.com/v1",
+ "models": [const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET],
+ }),
+ ("gemini", {
+ "label": "Gemini",
+ "api_key_field": "gemini_api_key",
+ "api_base_key": "gemini_api_base",
+ "api_base_default": "https://generativelanguage.googleapis.com",
+ "models": [const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
+ }),
+ ("openAI", {
+ "label": "OpenAI",
+ "api_key_field": "open_ai_api_key",
+ "api_base_key": "open_ai_api_base",
+ "api_base_default": "https://api.openai.com/v1",
+ "models": [const.GPT_5, const.GPT_41, const.GPT_4o],
+ }),
+ ("deepseek", {
+ "label": "DeepSeek",
+ "api_key_field": "open_ai_api_key",
+ "api_base_key": None,
+ "api_base_default": None,
+ "models": [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER],
+ }),
+ ("linkai", {
+ "label": "LinkAI",
+ "api_key_field": "linkai_api_key",
+ "api_base_key": None,
+ "api_base_default": None,
+ "models": _RECOMMENDED_MODELS,
+ }),
+ ])
+
+ EDITABLE_KEYS = {
+ "model", "use_linkai",
+ "open_ai_api_base", "claude_api_base", "gemini_api_base",
+ "zhipu_ai_api_base", "moonshot_base_url", "ark_base_url",
+ "open_ai_api_key", "claude_api_key", "gemini_api_key",
+ "zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
+ "ark_api_key", "minimax_api_key", "linkai_api_key",
+ "agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
+ }
+
+ @staticmethod
+ def _mask_key(value: str) -> str:
+ """Mask the middle part of an API key for display."""
+ if not value or len(value) <= 8:
+ return value
+ return value[:4] + "*" * (len(value) - 8) + value[-4:]
+
def GET(self):
- """Return configuration info for the web console."""
+ """Return configuration info and provider/model metadata."""
+ web.header('Content-Type', 'application/json; charset=utf-8')
try:
local_config = conf()
use_agent = local_config.get("agent", False)
+ title = "CowAgent" if use_agent else "AI Assistant"
- if use_agent:
- title = "CowAgent"
- else:
- title = "AI Assistant"
+ api_bases = {}
+ api_keys_masked = {}
+ for pid, pinfo in self.PROVIDER_MODELS.items():
+ base_key = pinfo.get("api_base_key")
+ if base_key:
+ api_bases[base_key] = local_config.get(base_key, pinfo["api_base_default"])
+ key_field = pinfo.get("api_key_field")
+ if key_field and key_field not in api_keys_masked:
+ raw = local_config.get(key_field, "")
+ api_keys_masked[key_field] = self._mask_key(raw) if raw else ""
+
+ providers = {}
+ for pid, p in self.PROVIDER_MODELS.items():
+ providers[pid] = {
+ "label": p["label"],
+ "models": p["models"],
+ "api_base_key": p["api_base_key"],
+ "api_base_default": p["api_base_default"],
+ "api_key_field": p.get("api_key_field"),
+ }
return json.dumps({
"status": "success",
@@ -396,14 +519,58 @@ class ConfigHandler:
"title": title,
"model": local_config.get("model", ""),
"channel_type": local_config.get("channel_type", ""),
- "agent_max_context_tokens": local_config.get("agent_max_context_tokens", ""),
- "agent_max_context_turns": local_config.get("agent_max_context_turns", ""),
- "agent_max_steps": local_config.get("agent_max_steps", ""),
- })
+ "agent_max_context_tokens": local_config.get("agent_max_context_tokens", 50000),
+ "agent_max_context_turns": local_config.get("agent_max_context_turns", 30),
+ "agent_max_steps": local_config.get("agent_max_steps", 15),
+ "api_bases": api_bases,
+ "api_keys": api_keys_masked,
+ "providers": providers,
+ }, ensure_ascii=False)
except Exception as e:
logger.error(f"Error getting config: {e}")
return json.dumps({"status": "error", "message": str(e)})
+ def POST(self):
+ """Update configuration values in memory and persist to config.json."""
+ web.header('Content-Type', 'application/json; charset=utf-8')
+ try:
+ data = json.loads(web.data())
+ updates = data.get("updates", {})
+ if not updates:
+ return json.dumps({"status": "error", "message": "no updates provided"})
+
+ local_config = conf()
+ applied = {}
+ for key, value in updates.items():
+ if key not in self.EDITABLE_KEYS:
+ continue
+ if key in ("agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps"):
+ value = int(value)
+ if key == "use_linkai":
+ value = bool(value)
+ local_config[key] = value
+ applied[key] = value
+
+ if not applied:
+ return json.dumps({"status": "error", "message": "no valid keys to update"})
+
+ config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
+ os.path.abspath(__file__)))), "config.json")
+ if os.path.exists(config_path):
+ with open(config_path, "r", encoding="utf-8") as f:
+ file_cfg = json.load(f)
+ else:
+ file_cfg = {}
+ file_cfg.update(applied)
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(file_cfg, f, indent=4, ensure_ascii=False)
+
+ logger.info(f"[WebChannel] Config updated: {list(applied.keys())}")
+ return json.dumps({"status": "success", "applied": applied}, ensure_ascii=False)
+ except Exception as e:
+ logger.error(f"Error updating config: {e}")
+ return json.dumps({"status": "error", "message": str(e)})
+
def _get_workspace_root():
"""Resolve the agent workspace directory."""
@@ -411,6 +578,19 @@ def _get_workspace_root():
return expand_path(conf().get("agent_workspace", "~/cow"))
+class ToolsHandler:
+ def GET(self):
+ web.header('Content-Type', 'application/json; charset=utf-8')
+ try:
+ from agent.tools.tool_manager import ToolManager
+ tm = ToolManager()
+ loaded = list(tm.tool_classes.keys())
+ return json.dumps({"status": "success", "tools": loaded}, ensure_ascii=False)
+ except Exception as e:
+ logger.error(f"[WebChannel] Tools API error: {e}")
+ return json.dumps({"status": "error", "message": str(e)})
+
+
class SkillsHandler:
def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8')
@@ -426,6 +606,30 @@ class SkillsHandler:
logger.error(f"[WebChannel] Skills API error: {e}")
return json.dumps({"status": "error", "message": str(e)})
+ def POST(self):
+ web.header('Content-Type', 'application/json; charset=utf-8')
+ try:
+ from agent.skills.service import SkillService
+ from agent.skills.manager import SkillManager
+ body = json.loads(web.data())
+ action = body.get("action")
+ name = body.get("name")
+ if not action or not name:
+ return json.dumps({"status": "error", "message": "action and name are required"})
+ workspace_root = _get_workspace_root()
+ manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills"))
+ service = SkillService(manager)
+ if action == "open":
+ service.open({"name": name})
+ elif action == "close":
+ service.close({"name": name})
+ else:
+ return json.dumps({"status": "error", "message": f"unknown action: {action}"})
+ return json.dumps({"status": "success"}, ensure_ascii=False)
+ except Exception as e:
+ logger.error(f"[WebChannel] Skills POST error: {e}")
+ return json.dumps({"status": "error", "message": str(e)})
+
class MemoryHandler:
def GET(self):