feat(web): add password protection for web console

- Add `web_password` config to enable login authentication
- Use stateless HMAC-signed token (survives restart, invalidates on password change)
- Add `web_session_expire_days` config (default 30 days)
- Protect all API endpoints with auth check (401 on failure)
- Add login page UI with auto-redirect on session expiry
- Add password management in config page (masked display, inline edit)
- Add tooltip hints for Agent config fields
- Update default agent_max_context_turns to 20, agent_max_steps to 20
- Update docs and docker-compose.yml
This commit is contained in:
zhayujie
2026-04-12 20:37:04 +08:00
parent 4dd497fb6d
commit fbe48a4b4e
14 changed files with 498 additions and 56 deletions

View File

@@ -54,6 +54,41 @@
</script>
</head>
<body class="h-screen overflow-hidden bg-gray-50 dark:bg-[#111111] text-slate-800 dark:text-slate-200 font-sans">
<!-- Login Overlay -->
<div id="login-overlay" class="fixed inset-0 z-[200] bg-gray-50 dark:bg-[#111111] flex items-center justify-center hidden">
<div class="w-full max-w-sm mx-4">
<div class="flex flex-col items-center mb-8">
<img src="assets/logo.jpg" alt="CowAgent" class="w-16 h-16 rounded-2xl mb-4 shadow-lg">
<h1 class="text-xl font-bold text-slate-800 dark:text-slate-100">CowAgent</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" id="login-subtitle">请输入密码以访问控制台</p>
</div>
<form id="login-form" class="space-y-4" onsubmit="return false;">
<div class="relative">
<input id="login-password" type="password" autocomplete="current-password"
placeholder="Password"
class="w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-white/10
bg-white dark:bg-[#1A1A1A] text-slate-800 dark:text-slate-200
placeholder-slate-400 dark:placeholder-slate-500
focus:outline-none focus:ring-2 focus:ring-primary-400/50 focus:border-primary-400
transition-all duration-150 text-sm">
<button type="button" id="login-toggle-pwd"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600
dark:hover:text-slate-300 cursor-pointer transition-colors"
onclick="toggleLoginPassword()">
<i class="fas fa-eye text-sm"></i>
</button>
</div>
<p id="login-error" class="text-sm text-red-500 hidden"></p>
<button id="login-btn" type="submit"
class="w-full py-3 rounded-xl bg-primary-500 hover:bg-primary-600 text-white font-medium
text-sm cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed">
登录
</button>
</form>
</div>
</div>
<div id="app" class="flex h-screen">
<!-- ================================================================ -->
@@ -414,21 +449,30 @@
</div>
<div class="space-y-4">
<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>
<label class="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="config_max_tokens">Max Context Tokens</span>
<span class="cfg-tip" data-tip-key="config_max_tokens_hint"><i class="fas fa-circle-question"></i></span>
</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>
<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>
<label class="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="config_max_turns">Max Memory Turns</span>
<span class="cfg-tip" data-tip-key="config_max_turns_hint"><i class="fas fa-circle-question"></i></span>
</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>
<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>
<label class="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="config_max_steps">Max Steps</span>
<span class="cfg-tip" data-tip-key="config_max_steps_hint"><i class="fas fa-circle-question"></i></span>
</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
@@ -444,6 +488,35 @@
</div>
</div>
<!-- Security Config Card -->
<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-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
<i class="fas fa-lock text-amber-500 text-sm"></i>
</div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_security">Security</h3>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_password">Password</label>
<input id="cfg-password" type="password" autocomplete="new-password"
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
cfg-key-masked"
data-masked="1">
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1.5" data-i18n="config_password_hint">Leave empty to disable password protection</p>
</div>
<div class="flex items-center justify-end gap-3 pt-1">
<span id="cfg-password-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
<button id="cfg-password-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="savePasswordConfig()" data-i18n="config_save">Save</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -689,3 +689,42 @@
border-color: rgba(255,255,255,0.1);
color: #e2e8f0;
}
/* Config field tooltip */
.cfg-tip {
position: relative;
display: inline-flex;
align-items: center;
color: #94a3b8;
cursor: help;
font-size: 12px;
}
.cfg-tip:hover { color: #64748b; }
.dark .cfg-tip:hover { color: #cbd5e1; }
.cfg-tip::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 6px);
transform: translateX(-50%);
padding: 6px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
white-space: nowrap;
background: #1e293b;
color: #e2e8f0;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 50;
}
.dark .cfg-tip::after {
background: #334155;
color: #f1f5f9;
}
.cfg-tip:hover::after {
opacity: 1;
}

View File

@@ -31,14 +31,20 @@ const I18N = {
config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
config_model: '模型配置', config_agent: 'Agent 配置',
config_channel: '通道配置',
config_agent_enabled: 'Agent 模式', config_max_tokens: '最大 Token',
config_max_turns: '最大轮次', config_max_steps: '最大步数',
config_agent_enabled: 'Agent 模式',
config_max_tokens: '最大上下文 Token', config_max_tokens_hint: '对话中 Agent 能输入的最大 Token 长度,超过后会智能压缩处理',
config_max_turns: '最大记忆轮次', config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理',
config_max_steps: '最大执行步数', config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数',
config_channel_type: '通道类型',
config_provider: '模型厂商', config_model_name: '模型',
config_custom_model_hint: '输入自定义模型名称',
config_save: '保存', config_saved: '已保存',
config_save_error: '保存失败',
config_custom_option: '自定义...',
config_security: '安全设置', config_password: '访问密码',
config_password_hint: '留空则不启用密码保护',
config_password_changed: '密码已更新,请重新登录',
config_password_cleared: '密码已清除',
skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能', skills_hub_btn: '探索技能广场',
skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处',
tools_section_title: '内置工具', tools_loading: '加载工具中...',
@@ -92,14 +98,20 @@ const I18N = {
config_title: 'Configuration', config_desc: 'Manage model and agent settings',
config_model: 'Model Configuration', config_agent: 'Agent Configuration',
config_channel: 'Channel Configuration',
config_agent_enabled: 'Agent Mode', config_max_tokens: 'Max Tokens',
config_max_turns: 'Max Turns', config_max_steps: 'Max Steps',
config_agent_enabled: 'Agent Mode',
config_max_tokens: 'Max Context Tokens', config_max_tokens_hint: 'Max tokens the Agent can input per conversation, auto-compressed when exceeded',
config_max_turns: 'Max Memory Turns', config_max_turns_hint: 'One Q&A pair = one turn, auto-compressed when exceeded',
config_max_steps: 'Max Steps', config_max_steps_hint: 'Max tool calls the Agent can make in a single conversation',
config_channel_type: 'Channel Type',
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...',
config_security: 'Security', config_password: 'Password',
config_password_hint: 'Leave empty to disable password protection',
config_password_changed: 'Password updated, please re-login',
config_password_cleared: 'Password cleared',
skills_title: 'Skills', skills_desc: 'View, enable, or disable agent skills', skills_hub_btn: 'Skill Hub',
skills_loading: 'Loading skills...', skills_loading_desc: 'Skills will be displayed here after loading',
tools_section_title: 'Built-in Tools', tools_loading: 'Loading tools...',
@@ -151,6 +163,9 @@ function applyI18n() {
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.dataset['i18nPlaceholder']);
});
document.querySelectorAll('[data-tip-key]').forEach(el => {
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
});
document.getElementById('lang-label').textContent = currentLang === 'zh' ? 'EN' : '中文';
}
@@ -1572,8 +1587,35 @@ function initConfigView(data) {
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;
document.getElementById('cfg-max-turns').value = data.agent_max_context_turns || 20;
document.getElementById('cfg-max-steps').value = data.agent_max_steps || 20;
const pwdInput = document.getElementById('cfg-password');
const maskedPwd = data.web_password_masked || '';
pwdInput.value = maskedPwd;
pwdInput.dataset.masked = maskedPwd ? '1' : '';
pwdInput.dataset.maskedVal = maskedPwd;
pwdInput.classList.toggle('cfg-key-masked', !!maskedPwd);
if (maskedPwd) {
pwdInput.placeholder = '••••••••';
} else {
pwdInput.placeholder = '';
}
if (!pwdInput._cfgBound) {
pwdInput.addEventListener('focus', function() {
if (this.dataset.masked === '1') {
this.value = '';
this.dataset.masked = '';
this.classList.remove('cfg-key-masked');
}
});
pwdInput.addEventListener('input', function() {
this.dataset.masked = '';
});
pwdInput._cfgBound = true;
}
}
function detectProvider(model) {
@@ -1779,8 +1821,8 @@ function saveModelConfig() {
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,
agent_max_context_turns: parseInt(document.getElementById('cfg-max-turns').value) || 20,
agent_max_steps: parseInt(document.getElementById('cfg-max-steps').value) || 20,
};
const btn = document.getElementById('cfg-agent-save');
@@ -1802,6 +1844,40 @@ function saveAgentConfig() {
.finally(() => { btn.disabled = false; });
}
function savePasswordConfig() {
const input = document.getElementById('cfg-password');
if (input.dataset.masked === '1') {
showStatus('cfg-password-status', 'config_saved', false);
return;
}
const newPwd = input.value.trim();
const btn = document.getElementById('cfg-password-save');
btn.disabled = true;
fetch('/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: { web_password: newPwd } })
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
if (newPwd) {
showStatus('cfg-password-status', 'config_password_changed', false);
setTimeout(() => { window.location.reload(); }, 1500);
} else {
input.dataset.masked = '';
input.dataset.maskedVal = '';
input.classList.remove('cfg-key-masked');
showStatus('cfg-password-status', 'config_password_cleared', false);
}
} else {
showStatus('cfg-password-status', 'config_save_error', true);
}
})
.catch(() => showStatus('cfg-password-status', 'config_save_error', true))
.finally(() => { btn.disabled = false; });
}
function loadConfigView() {
fetch('/config').then(r => r.json()).then(data => {
if (data.status !== 'success') return;
@@ -3342,29 +3418,120 @@ function renderKnowledgeGraph(container, nodes, links) {
container.appendChild(legendDiv);
}
// =====================================================================
// Authentication
// =====================================================================
function toggleLoginPassword() {
const input = document.getElementById('login-password');
const icon = document.querySelector('#login-toggle-pwd i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('fa-eye', 'fa-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('fa-eye-slash', 'fa-eye');
}
}
window.toggleLoginPassword = toggleLoginPassword;
function showLoginScreen() {
const overlay = document.getElementById('login-overlay');
if (!overlay) return;
overlay.classList.remove('hidden');
document.getElementById('app').classList.add('hidden');
const subtitle = document.getElementById('login-subtitle');
const loginBtn = document.getElementById('login-btn');
if (currentLang === 'en') {
subtitle.textContent = 'Enter password to access the console';
loginBtn.textContent = 'Login';
} else {
subtitle.textContent = '请输入密码以访问控制台';
loginBtn.textContent = '登录';
}
const form = document.getElementById('login-form');
const pwdInput = document.getElementById('login-password');
pwdInput.focus();
form.onsubmit = function(e) {
e.preventDefault();
const pwd = pwdInput.value;
if (!pwd) return;
const btn = document.getElementById('login-btn');
const errEl = document.getElementById('login-error');
btn.disabled = true;
errEl.classList.add('hidden');
fetch('/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password: pwd})
}).then(r => r.json()).then(data => {
if (data.status === 'success') {
overlay.classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
initApp();
} else {
errEl.textContent = currentLang === 'zh' ? '密码错误' : 'Wrong password';
errEl.classList.remove('hidden');
pwdInput.value = '';
pwdInput.focus();
}
btn.disabled = false;
}).catch(() => {
errEl.textContent = currentLang === 'zh' ? '网络错误,请重试' : 'Network error, please retry';
errEl.classList.remove('hidden');
btn.disabled = false;
});
return false;
};
}
// Intercept 401 responses globally to show login screen on session expiry
const _originalFetch = window.fetch;
window.fetch = function(...args) {
return _originalFetch.apply(this, args).then(response => {
if (response.status === 401) {
const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || '');
if (!url.startsWith('/auth/')) {
showLoginScreen();
}
}
return response;
});
};
function initApp() {
fetch('/api/knowledge/list').then(r => r.json()).then(data => {
if (data.status === 'success') _knowledgeTreeData = data.tree || [];
}).catch(() => {});
fetch('/api/version').then(r => r.json()).then(data => {
APP_VERSION = `v${data.version}`;
document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`;
}).catch(() => {
document.getElementById('sidebar-version').textContent = 'CowAgent';
});
chatInput.focus();
}
// =====================================================================
// Initialization
// =====================================================================
applyTheme();
applyI18n();
// Pre-fetch knowledge tree for chat link resolution
fetch('/api/knowledge/list').then(r => r.json()).then(data => {
if (data.status === 'success') _knowledgeTreeData = data.tree || [];
}).catch(() => {});
fetch('/api/version').then(r => r.json()).then(data => {
APP_VERSION = `v${data.version}`;
document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`;
fetch('/auth/check').then(r => r.json()).then(data => {
if (data.auth_required && !data.authenticated) {
showLoginScreen();
} else {
initApp();
}
}).catch(() => {
document.getElementById('sidebar-version').textContent = 'CowAgent';
initApp();
});
chatInput.focus();
// Re-enable color transition AFTER first paint so the theme applied in <head>
// doesn't produce an animated flash on load. The class is missing from the
// body initially; adding it here means transitions only fire on user-triggered
// theme toggles, not on page load.
requestAnimationFrame(() => {
document.body.classList.add('transition-colors', 'duration-200');
});

View File

@@ -1,3 +1,5 @@
import hashlib
import hmac
import time
import json
import logging
@@ -23,6 +25,62 @@ from config import conf
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
def _is_password_enabled():
return bool(conf().get("web_password", ""))
def _session_expire_seconds():
return int(conf().get("web_session_expire_days", 30)) * 86400
def _create_auth_token():
"""Create a stateless signed token: ``<timestamp_hex>.<hmac_hex>``."""
ts = format(int(time.time()), "x")
sig = hmac.new(
conf().get("web_password", "").encode(),
ts.encode(),
hashlib.sha256,
).hexdigest()
return f"{ts}.{sig}"
def _verify_auth_token(token):
"""Verify a signed token is valid and not expired.
The token is derived from the password, so it survives server restarts
and automatically invalidates when the password changes.
"""
if not token or "." not in token:
return False
ts_hex, sig = token.split(".", 1)
try:
ts = int(ts_hex, 16)
except ValueError:
return False
if time.time() - ts > _session_expire_seconds():
return False
expected = hmac.new(
conf().get("web_password", "").encode(),
ts_hex.encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(sig, expected)
def _check_auth():
"""Return True if request is authenticated or password not enabled."""
if not _is_password_enabled():
return True
return _verify_auth_token(web.cookies().get("cow_auth_token", ""))
def _require_auth():
"""Raise 401 if not authenticated. Call at the top of protected handlers."""
if not _check_auth():
raise web.HTTPError("401 Unauthorized",
{"Content-Type": "application/json; charset=utf-8"},
json.dumps({"status": "error", "message": "Unauthorized"}))
def _get_upload_dir() -> str:
from common.utils import expand_path
@@ -440,6 +498,9 @@ class WebChannel(ChatChannel):
urls = (
'/', 'RootHandler',
'/auth/login', 'AuthLoginHandler',
'/auth/check', 'AuthCheckHandler',
'/auth/logout', 'AuthLogoutHandler',
'/message', 'MessageHandler',
'/upload', 'UploadHandler',
'/uploads/(.*)', 'UploadsHandler',
@@ -502,24 +563,62 @@ class WebChannel(ChatChannel):
class RootHandler:
def GET(self):
# 重定向到/chat
raise web.seeother('/chat')
class AuthCheckHandler:
def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8')
if not _is_password_enabled():
return json.dumps({"status": "success", "auth_required": False})
if _check_auth():
return json.dumps({"status": "success", "auth_required": True, "authenticated": True})
return json.dumps({"status": "success", "auth_required": True, "authenticated": False})
class AuthLoginHandler:
def POST(self):
web.header('Content-Type', 'application/json; charset=utf-8')
if not _is_password_enabled():
return json.dumps({"status": "success"})
try:
data = json.loads(web.data())
except Exception:
return json.dumps({"status": "error", "message": "Invalid request"})
password = data.get("password", "")
expected = conf().get("web_password", "")
if not hmac.compare_digest(password, expected):
logger.warning("[WebChannel] Invalid login attempt")
return json.dumps({"status": "error", "message": "Wrong password"})
token = _create_auth_token()
web.setcookie("cow_auth_token", token, expires=_session_expire_seconds(),
path="/", httponly=True, samesite="Lax")
return json.dumps({"status": "success"})
class AuthLogoutHandler:
def POST(self):
web.header('Content-Type', 'application/json; charset=utf-8')
web.setcookie("cow_auth_token", "", expires=-1, path="/")
return json.dumps({"status": "success"})
class MessageHandler:
def POST(self):
_require_auth()
return WebChannel().post_message()
class UploadHandler:
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
return WebChannel().upload_file()
class UploadsHandler:
def GET(self, file_name):
"""Serve uploaded files from workspace/tmp/ for preview."""
_require_auth()
try:
upload_dir = _get_upload_dir()
full_path = os.path.normpath(os.path.join(upload_dir, file_name))
@@ -541,7 +640,7 @@ class UploadsHandler:
class FileServeHandler:
def GET(self):
"""Serve a local file by absolute path (for agent send tool)."""
_require_auth()
try:
params = web.input(path="")
file_path = params.path
@@ -567,11 +666,13 @@ class FileServeHandler:
class PollHandler:
def POST(self):
_require_auth()
return WebChannel().poll_response()
class StreamHandler:
def GET(self):
_require_auth()
params = web.input(request_id='')
request_id = params.request_id
if not request_id:
@@ -695,6 +796,7 @@ class ConfigHandler:
"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",
"web_password",
}
@staticmethod
@@ -705,7 +807,7 @@ class ConfigHandler:
return value[:4] + "*" * (len(value) - 8) + value[-4:]
def GET(self):
"""Return configuration info and provider/model metadata."""
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
local_config = conf()
@@ -733,6 +835,9 @@ class ConfigHandler:
"api_key_field": p.get("api_key_field"),
}
raw_pwd = local_config.get("web_password", "")
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
return json.dumps({
"status": "success",
"use_agent": use_agent,
@@ -743,17 +848,18 @@ class ConfigHandler:
"channel_type": local_config.get("channel_type", ""),
"agent_max_context_tokens": local_config.get("agent_max_context_tokens", 50000),
"agent_max_context_turns": local_config.get("agent_max_context_turns", 20),
"agent_max_steps": local_config.get("agent_max_steps", 15),
"agent_max_steps": local_config.get("agent_max_steps", 20),
"api_bases": api_bases,
"api_keys": api_keys_masked,
"providers": providers,
"web_password_masked": masked_pwd,
}, 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."""
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
data = json.loads(web.data())
@@ -902,6 +1008,7 @@ class ChannelsHandler:
return set(cls._parse_channel_list(conf().get("channel_type", "")))
def GET(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
local_config = conf()
@@ -939,6 +1046,7 @@ class ChannelsHandler:
return json.dumps({"status": "error", "message": str(e)})
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
body = json.loads(web.data())
@@ -1192,6 +1300,7 @@ class WeixinQrHandler:
return None
def GET(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
running_ch = self._get_running_channel()
@@ -1224,6 +1333,7 @@ class WeixinQrHandler:
return json.dumps({"status": "error", "message": str(e)})
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
body = json.loads(web.data())
@@ -1311,6 +1421,7 @@ def _get_workspace_root():
class ToolsHandler:
def GET(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.tools.tool_manager import ToolManager
@@ -1335,6 +1446,7 @@ class ToolsHandler:
class SkillsHandler:
def GET(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.skills.service import SkillService
@@ -1349,6 +1461,7 @@ class SkillsHandler:
return json.dumps({"status": "error", "message": str(e)})
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.skills.service import SkillService
@@ -1375,6 +1488,7 @@ class SkillsHandler:
class MemoryHandler:
def GET(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.memory.service import MemoryService
@@ -1390,6 +1504,7 @@ class MemoryHandler:
class MemoryContentHandler:
def GET(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.memory.service import MemoryService
@@ -1411,6 +1526,7 @@ class MemoryContentHandler:
class SchedulerHandler:
def GET(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.tools.scheduler.task_store import TaskStore
@@ -1426,14 +1542,7 @@ class SchedulerHandler:
class HistoryHandler:
def GET(self):
"""
Return paginated conversation history for a session.
Query params:
session_id (required)
page int, default 1 (1 = most recent messages)
page_size int, default 20
"""
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
web.header('Access-Control-Allow-Origin', '*')
try:
@@ -1457,7 +1566,7 @@ class HistoryHandler:
class LogsHandler:
def GET(self):
"""Stream the last N lines of run.log as SSE, then tail new lines."""
_require_auth()
web.header('Content-Type', 'text/event-stream; charset=utf-8')
web.header('Cache-Control', 'no-cache')
web.header('X-Accel-Buffering', 'no')
@@ -1545,6 +1654,7 @@ class AssetsHandler:
class KnowledgeListHandler:
def GET(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.knowledge.service import KnowledgeService
@@ -1558,6 +1668,7 @@ class KnowledgeListHandler:
class KnowledgeReadHandler:
def GET(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.knowledge.service import KnowledgeService
@@ -1574,6 +1685,7 @@ class KnowledgeReadHandler:
class KnowledgeGraphHandler:
def GET(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.knowledge.service import KnowledgeService