feat: model and agent config in web console

This commit is contained in:
zhayujie
2026-02-26 21:01:37 +08:00
parent 3ddbdd713d
commit 5edbf4ce32
6 changed files with 794 additions and 84 deletions

View File

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

View File

@@ -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...")

View File

@@ -294,68 +294,122 @@
</div>
</div>
<div class="grid gap-6">
<!-- Model Config Card -->
<div class="placeholder-card bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-9 h-9 rounded-lg bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
<i class="fas fa-microchip text-primary-500 text-sm"></i>
</div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_model">Model Configuration</h3>
</div>
<div class="space-y-4">
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0">Model</span>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-model">--</span>
<div class="space-y-5">
<!-- Provider -->
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_provider">Provider</label>
<div id="cfg-provider" class="cfg-dropdown" tabindex="0">
<div class="cfg-dropdown-selected">
<span class="cfg-dropdown-text">--</span>
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
</div>
<div class="cfg-dropdown-menu"></div>
</div>
</div>
<!-- Model -->
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_model_name">Model</label>
<div id="cfg-model-select" class="cfg-dropdown" tabindex="0">
<div class="cfg-dropdown-selected">
<span class="cfg-dropdown-text">--</span>
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
</div>
<div class="cfg-dropdown-menu"></div>
</div>
<div id="cfg-model-custom-wrap" class="mt-2 hidden">
<input id="cfg-model-custom" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors"
data-i18n-placeholder="config_custom_model_hint" placeholder="Enter custom model name">
</div>
</div>
<!-- API Key -->
<div id="cfg-api-key-wrap">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
<div class="relative">
<input id="cfg-api-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 pr-10 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors cfg-key-masked"
placeholder="sk-...">
<button type="button" id="cfg-api-key-toggle"
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600
dark:hover:text-slate-300 cursor-pointer transition-colors p-1"
onclick="toggleApiKeyVisibility()">
<i class="fas fa-eye text-xs"></i>
</button>
</div>
</div>
<!-- API Base -->
<div id="cfg-api-base-wrap" class="hidden">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
<input id="cfg-api-base" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="https://...">
</div>
<!-- Save Model Button -->
<div class="flex items-center justify-end gap-3 pt-1">
<span id="cfg-model-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
<button id="cfg-model-save"
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
onclick="saveModelConfig()" data-i18n="config_save">Save</button>
</div>
</div>
</div>
<!-- Agent Config Card -->
<div class="placeholder-card bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-9 h-9 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
<i class="fas fa-robot text-emerald-500 text-sm"></i>
</div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_agent">Agent Configuration</h3>
</div>
<div class="space-y-4">
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_agent_enabled">Agent Mode</span>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1" id="cfg-agent">--</span>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_max_tokens">Max Context Tokens</label>
<input id="cfg-max-tokens" type="number" min="1000" max="200000" step="1000"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors">
</div>
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_max_tokens">Max Tokens</span>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-max-tokens">--</span>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_max_turns">Max Context Turns</label>
<input id="cfg-max-turns" type="number" min="1" max="100" step="1"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors">
</div>
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_max_turns">Max Turns</span>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-max-turns">--</span>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_max_steps">Max Steps</label>
<input id="cfg-max-steps" type="number" min="1" max="50" step="1"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors">
</div>
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_max_steps">Max Steps</span>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-max-steps">--</span>
<div class="flex items-center justify-end gap-3 pt-1">
<span id="cfg-agent-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
<button id="cfg-agent-save"
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
onclick="saveAgentConfig()" data-i18n="config_save">Save</button>
</div>
</div>
</div>
<!-- Channel Config Card -->
<div class="placeholder-card bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-lg bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
<i class="fas fa-tower-broadcast text-amber-500 text-sm"></i>
</div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_channel">Channel Configuration</h3>
</div>
<div class="space-y-4">
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_channel_type">Channel Type</span>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-channel">--</span>
</div>
</div>
</div>
</div>
<!-- Coming Soon Banner -->
<div class="mt-6 p-4 rounded-xl bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800/50 flex items-center gap-3">
<i class="fas fa-info-circle text-primary-500"></i>
<span class="text-sm text-primary-700 dark:text-primary-300" data-i18n="config_coming_soon">Full editing capability coming soon. Currently displaying read-only configuration.</span>
</div>
</div>
</div>

View File

@@ -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;

View File

@@ -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');

View File

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