diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index 5d33c75..d7401ef 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -65,30 +65,67 @@ class AgentLLMModel(LLMModel): LLM Model adapter that uses COW's existing bot infrastructure """ + _MODEL_BOT_TYPE_MAP = { + "wenxin": const.BAIDU, "wenxin-4": const.BAIDU, + "xunfei": const.XUNFEI, const.QWEN: const.QWEN, + const.MODELSCOPE: const.MODELSCOPE, + } + _MODEL_PREFIX_MAP = [ + ("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE), ("qvq", const.QWEN_DASHSCOPE), + ("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), ("claude", const.CLAUDEAPI), + ("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT), + ("doubao", const.DOUBAO), + ] + def __init__(self, bridge: Bridge, bot_type: str = "chat"): - # Get model name directly from config from config import conf - model_name = conf().get("model", const.GPT_41) - super().__init__(model=model_name) + super().__init__(model=conf().get("model", const.GPT_41)) self.bridge = bridge self.bot_type = bot_type self._bot = None - self._use_linkai = conf().get("use_linkai", False) and conf().get("linkai_api_key") - + self._bot_model = None + + @property + def model(self): + from config import conf + return conf().get("model", const.GPT_41) + + @model.setter + def model(self, value): + pass + + def _resolve_bot_type(self, model_name: str) -> str: + """Resolve bot type from model name, matching Bridge.__init__ logic.""" + from config import conf + if conf().get("use_linkai", False) and conf().get("linkai_api_key"): + return const.LINKAI + if not model_name or not isinstance(model_name, str): + return const.CHATGPT + if model_name in self._MODEL_BOT_TYPE_MAP: + return self._MODEL_BOT_TYPE_MAP[model_name] + if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]: + return const.MiniMax + if model_name in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]: + return const.QWEN_DASHSCOPE + if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]: + return const.MOONSHOT + if model_name in [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER]: + return const.CHATGPT + for prefix, btype in self._MODEL_PREFIX_MAP: + if model_name.startswith(prefix): + return btype + return const.CHATGPT + @property def bot(self): - """Lazy load the bot and enhance it with tool calling if needed""" - if self._bot is None: - # If use_linkai is enabled, use LinkAI bot directly - if self._use_linkai: - self._bot = self.bridge.find_chat_bot(const.LINKAI) - else: - self._bot = self.bridge.get_bot(self.bot_type) - # Automatically add tool calling support if not present - self._bot = add_openai_compatible_support(self._bot) - - # Log bot info - bot_name = type(self._bot).__name__ + """Lazy load the bot, re-create when model changes""" + from models.bot_factory import create_bot + cur_model = self.model + if self._bot is None or self._bot_model != cur_model: + bot_type = self._resolve_bot_type(cur_model) + self._bot = create_bot(bot_type) + self._bot = add_openai_compatible_support(self._bot) + self._bot_model = cur_model return self._bot def call(self, request: LLMRequest): diff --git a/channel/feishu/feishu_channel.py b/channel/feishu/feishu_channel.py index 1a6a1fc..b9f4908 100644 --- a/channel/feishu/feishu_channel.py +++ b/channel/feishu/feishu_channel.py @@ -159,7 +159,7 @@ class FeiShuChanel(ChatChannel): self.feishu_app_id, self.feishu_app_secret, event_handler=event_handler, - log_level=lark.LogLevel.DEBUG if conf().get("debug") else lark.LogLevel.WARNING + log_level=lark.LogLevel.WARNING ) logger.debug("[FeiShu] Websocket client starting...") diff --git a/channel/web/chat.html b/channel/web/chat.html index 904eb45..a9aabea 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -294,68 +294,122 @@
+ -
-
+
+

Model Configuration

-
-
- Model - -- +
+ +
+ +
+
+ -- + +
+
+
+
+ +
+ +
+
+ -- + +
+
+
+ +
+ +
+ +
+ + +
+
+ + + +
+ +
+ -
-
+
+

Agent Configuration

-
- 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):