mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-19 12:47:25 +08:00
Merge remote-tracking branch 'upstream/master'
# Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
This commit is contained in:
@@ -1009,6 +1009,14 @@
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="tasks_title">定时任务</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="tasks_desc">查看和管理定时任务</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="task-refresh-btn" onclick="refreshTasksView()"
|
||||
class="px-3 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||
text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5
|
||||
text-sm font-medium cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-refresh text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tasks-empty" class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
|
||||
@@ -1290,6 +1298,180 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Edit Modal -->
|
||||
<div id="task-edit-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||
w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-clock text-primary-500"></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base" data-i18n="task_edit_title">编辑定时任务</h3>
|
||||
<p id="task-edit-modal-subtitle" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 任务名称和启用状态 -->
|
||||
<div class="flex gap-4 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_name">任务名称</span>
|
||||
</label>
|
||||
<input id="task-edit-name" 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 transition-colors"
|
||||
placeholder="任务名称">
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pb-[2px]">
|
||||
<label class="text-xs font-medium text-slate-600 dark:text-slate-400">
|
||||
<span data-i18n="task_enabled">启用</span>
|
||||
</label>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="task-edit-enabled" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 调度类型 + 调度值 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_schedule_type">调度类型</span>
|
||||
</label>
|
||||
<select id="task-edit-schedule-type"
|
||||
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors
|
||||
appearance-none bg-no-repeat bg-right"
|
||||
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||
<option value="cron" data-i18n="task_schedule_cron">Cron 表达式</option>
|
||||
<option value="interval" data-i18n="task_schedule_interval">固定间隔</option>
|
||||
<option value="once" data-i18n="task_schedule_once">一次性任务</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Cron 表达式 -->
|
||||
<div id="task-edit-cron-wrap">
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_cron_expression">Cron 表达式</span>
|
||||
</label>
|
||||
<input id="task-edit-cron-expression" 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="0 9 * * *">
|
||||
</div>
|
||||
|
||||
<!-- 固定间隔 -->
|
||||
<div id="task-edit-interval-wrap" class="hidden">
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_interval_seconds">间隔秒数</span>
|
||||
</label>
|
||||
<input id="task-edit-interval-seconds" type="number" min="60"
|
||||
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 transition-colors"
|
||||
placeholder="3600">
|
||||
</div>
|
||||
|
||||
<!-- 一次性任务时间 -->
|
||||
<div id="task-edit-once-wrap" class="hidden">
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_once_time">执行时间</span>
|
||||
</label>
|
||||
<input id="task-edit-once-time" type="datetime-local" 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 transition-colors
|
||||
cursor-pointer"
|
||||
onclick="this.showPicker && this.showPicker()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron/Interval 提示 -->
|
||||
<p id="task-edit-cron-hint" class="text-xs text-slate-400 dark:text-slate-500">
|
||||
<span data-i18n="task_cron_hint">格式: 分 时 日 月 周,例如 "0 9 * * *" 表示每天 9:00</span>
|
||||
</p>
|
||||
<p id="task-edit-interval-hint" class="text-xs text-slate-400 dark:text-slate-500 hidden">
|
||||
<span data-i18n="task_interval_hint">最小 60 秒,例如 3600 表示每小时执行一次</span>
|
||||
</p>
|
||||
|
||||
<!-- 动作类型 + 通道类型 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_action_type">动作类型</span>
|
||||
</label>
|
||||
<select id="task-edit-action-type"
|
||||
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors
|
||||
appearance-none bg-no-repeat bg-right"
|
||||
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||
<option value="send_message" data-i18n="task_action_send_message">发送消息</option>
|
||||
<option value="agent_task" data-i18n="task_action_agent_task">AI 任务</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_channel_type">通道类型</span>
|
||||
</label>
|
||||
<select id="task-edit-channel-type"
|
||||
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors
|
||||
appearance-none bg-no-repeat bg-right
|
||||
disabled:opacity-100 disabled:text-slate-800 dark:disabled:text-slate-300 disabled:bg-slate-100 dark:disabled:bg-[#252525] disabled:cursor-not-allowed"
|
||||
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||
</select>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||
<span data-i18n="task_channel_hint">选择定时消息发送的通道</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的接收者ID字段(自动填充) -->
|
||||
<input id="task-edit-receiver" type="hidden" value="">
|
||||
|
||||
<!-- 消息内容/任务描述 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span id="task-edit-content-label" data-i18n="task_message_content">消息内容</span>
|
||||
</label>
|
||||
<textarea id="task-edit-content" rows="3"
|
||||
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 transition-colors resize-none"
|
||||
placeholder="输入消息内容或任务描述"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
|
||||
<button id="task-edit-modal-delete"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||
cursor-pointer transition-colors duration-150 hidden"
|
||||
data-i18n="task_delete_btn">删除任务</button>
|
||||
<span id="task-edit-modal-status"
|
||||
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
|
||||
<button id="task-edit-modal-cancel"
|
||||
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||
hover:bg-slate-50 dark:hover:bg-white/5
|
||||
cursor-pointer transition-colors duration-150"
|
||||
data-i18n="cancel">取消</button>
|
||||
<button id="task-edit-modal-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"
|
||||
data-i18n="save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script defer src="assets/js/console.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -244,6 +244,52 @@
|
||||
}
|
||||
.dark .session-delete:hover { background: rgba(239, 68, 68, 0.15); }
|
||||
|
||||
/* Rename button: shares the look of the delete button, sits to its left.
|
||||
Negative right margin tightens the gap to the delete button only. */
|
||||
.session-rename {
|
||||
flex-shrink: 0;
|
||||
margin-right: -6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.session-item:hover .session-rename { opacity: 1; }
|
||||
.session-rename:hover {
|
||||
color: #4ABE6E;
|
||||
background: rgba(74, 190, 110, 0.12);
|
||||
}
|
||||
.dark .session-rename:hover { background: rgba(74, 190, 110, 0.18); }
|
||||
|
||||
/* Inline title editor */
|
||||
.session-title-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: #111827;
|
||||
background: #ffffff;
|
||||
border: 1px solid #4ABE6E;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
outline: none;
|
||||
}
|
||||
.dark .session-title-input {
|
||||
color: #e5e5e5;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: #4ABE6E;
|
||||
}
|
||||
|
||||
/* Context Divider */
|
||||
.context-divider {
|
||||
display: flex;
|
||||
|
||||
@@ -191,6 +191,30 @@ const I18N = {
|
||||
feishu_mode_scan: '扫码创建', feishu_mode_manual: '手动填写',
|
||||
tasks_title: '定时任务', tasks_desc: '查看和管理定时任务',
|
||||
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
|
||||
task_add_btn: '新增任务',
|
||||
task_edit_title: '编辑定时任务',
|
||||
task_add_title: '新增定时任务',
|
||||
task_name: '任务名称',
|
||||
task_enabled: '启用任务',
|
||||
task_schedule_type: '调度类型',
|
||||
task_schedule_cron: 'Cron 表达式',
|
||||
task_schedule_interval: '固定间隔',
|
||||
task_schedule_once: '一次性任务',
|
||||
task_cron_expression: 'Cron 表达式',
|
||||
task_cron_hint: '格式: 分 时 日 月 周,例如 "0 9 * * *" 表示每天 9:00',
|
||||
task_interval_seconds: '间隔秒数',
|
||||
task_interval_hint: '最小 60 秒,例如 3600 表示每小时执行一次',
|
||||
task_once_time: '执行时间',
|
||||
task_action_type: '动作类型',
|
||||
task_action_send_message: '发送消息',
|
||||
task_action_agent_task: 'AI 任务',
|
||||
task_channel_type: '通道类型',
|
||||
task_channel_hint: '选择定时消息发送的通道',
|
||||
task_message_content: '消息内容',
|
||||
task_task_description: '任务描述',
|
||||
task_delete_btn: '删除任务',
|
||||
task_delete_confirm_title: '删除定时任务',
|
||||
task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。',
|
||||
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
||||
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
|
||||
new_chat: '新对话',
|
||||
@@ -198,6 +222,7 @@ const I18N = {
|
||||
today: '今天', yesterday: '昨天', earlier: '更早',
|
||||
delete_session_confirm: '确认删除该会话?所有消息将被清除。',
|
||||
delete_session_title: '删除会话',
|
||||
rename_session: '重命名',
|
||||
delete_message_confirm: '确认删除这条消息?',
|
||||
delete_message_title: '删除消息',
|
||||
edit_disabled_reply_active: '正在生成回复,暂时无法编辑。',
|
||||
@@ -409,6 +434,30 @@ const I18N = {
|
||||
feishu_mode_scan: 'Scan QR', feishu_mode_manual: 'Manual',
|
||||
tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
|
||||
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
|
||||
task_add_btn: 'Add Task',
|
||||
task_edit_title: 'Edit Task',
|
||||
task_add_title: 'Add Task',
|
||||
task_name: 'Task Name',
|
||||
task_enabled: 'Enable Task',
|
||||
task_schedule_type: 'Schedule Type',
|
||||
task_schedule_cron: 'Cron Expression',
|
||||
task_schedule_interval: 'Fixed Interval',
|
||||
task_schedule_once: 'One-time Task',
|
||||
task_cron_expression: 'Cron Expression',
|
||||
task_cron_hint: 'Format: minute hour day month weekday, e.g. "0 9 * * *" means daily at 9:00',
|
||||
task_interval_seconds: 'Interval (seconds)',
|
||||
task_interval_hint: 'Minimum 60 seconds, e.g. 3600 means once per hour',
|
||||
task_once_time: 'Execution Time',
|
||||
task_action_type: 'Action Type',
|
||||
task_action_send_message: 'Send Message',
|
||||
task_action_agent_task: 'AI Task',
|
||||
task_channel_type: 'Channel Type',
|
||||
task_channel_hint: 'Select the channel to send scheduled messages',
|
||||
task_message_content: 'Message Content',
|
||||
task_task_description: 'Task Description',
|
||||
task_delete_btn: 'Delete Task',
|
||||
task_delete_confirm_title: 'Delete Task',
|
||||
task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.',
|
||||
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
||||
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
|
||||
new_chat: 'New Chat',
|
||||
@@ -416,6 +465,7 @@ const I18N = {
|
||||
today: 'Today', yesterday: 'Yesterday', earlier: 'Earlier',
|
||||
delete_session_confirm: 'Delete this session? All messages will be removed.',
|
||||
delete_session_title: 'Delete Session',
|
||||
rename_session: 'Rename',
|
||||
delete_message_confirm: 'Delete this message?',
|
||||
delete_message_title: 'Delete Message',
|
||||
edit_disabled_reply_active: 'Reply is being generated; editing is temporarily unavailable.',
|
||||
@@ -566,6 +616,11 @@ function rerenderDynamicViews() {
|
||||
&& modelsState && (modelsState.providers || modelsState.capabilities)) {
|
||||
renderModelsView();
|
||||
}
|
||||
// Reload task list after language switch
|
||||
if (currentView === 'tasks') {
|
||||
tasksLoaded = false;
|
||||
loadTasksView();
|
||||
}
|
||||
}
|
||||
|
||||
// Floating tooltip portal for [data-tip-key] elements. Tooltip nodes are
|
||||
@@ -3621,6 +3676,9 @@ function _addOptimisticSessionItem(sid) {
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-message session-icon"></i>
|
||||
<span class="session-title" title="${escapeHtml(title)}">${escapeHtml(title)}</span>
|
||||
<button class="session-rename" onclick="event.stopPropagation(); renameSession('${sid}')" title="${escapeHtml(t('rename_session'))}">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<button class="session-delete" onclick="event.stopPropagation(); deleteSession('${sid}')" title="Delete">
|
||||
<i class="fas fa-trash-can"></i>
|
||||
</button>
|
||||
@@ -3708,6 +3766,9 @@ function _fetchSessionPage(page, clear, onDone) {
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-message session-icon"></i>
|
||||
<span class="session-title" title="${escapeHtml(title)}">${escapeHtml(title)}</span>
|
||||
<button class="session-rename" onclick="event.stopPropagation(); renameSession('${s.session_id}')" title="${escapeHtml(t('rename_session'))}">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<button class="session-delete" onclick="event.stopPropagation(); deleteSession('${s.session_id}')" title="Delete">
|
||||
<i class="fas fa-trash-can"></i>
|
||||
</button>
|
||||
@@ -3826,6 +3887,84 @@ function switchSession(newSessionId) {
|
||||
if (currentView !== 'chat') navigateTo('chat');
|
||||
}
|
||||
|
||||
// In-place rename a session title: replace the title <span> with an <input>,
|
||||
// commit on Enter/blur, cancel on Escape. Persists via PUT /api/sessions/<id>.
|
||||
function renameSession(sid) {
|
||||
const item = document.querySelector(`.session-item[data-session-id="${sid}"]`);
|
||||
if (!item) return;
|
||||
const titleEl = item.querySelector('.session-title');
|
||||
if (!titleEl || item.querySelector('.session-title-input')) return;
|
||||
|
||||
const oldTitle = titleEl.textContent;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'session-title-input';
|
||||
input.value = oldTitle;
|
||||
input.maxLength = 100;
|
||||
|
||||
// Avoid switching session while interacting with the input
|
||||
const stop = e => e.stopPropagation();
|
||||
input.addEventListener('click', stop);
|
||||
input.addEventListener('mousedown', stop);
|
||||
|
||||
titleEl.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
let done = false;
|
||||
|
||||
const restore = (title) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const span = document.createElement('span');
|
||||
span.className = 'session-title';
|
||||
span.title = title;
|
||||
span.textContent = title;
|
||||
input.replaceWith(span);
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (done) return;
|
||||
const newTitle = input.value.trim();
|
||||
if (!newTitle || newTitle === oldTitle) {
|
||||
restore(oldTitle);
|
||||
return;
|
||||
}
|
||||
// Optimistically show the new title, then persist.
|
||||
restore(newTitle);
|
||||
fetch(`/api/sessions/${encodeURIComponent(sid)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: newTitle })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status !== 'success') {
|
||||
// Revert UI on failure
|
||||
const span = item.querySelector('.session-title');
|
||||
if (span) {
|
||||
span.title = oldTitle;
|
||||
span.textContent = oldTitle;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const span = item.querySelector('.session-title');
|
||||
if (span) {
|
||||
span.title = oldTitle;
|
||||
span.textContent = oldTitle;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); restore(oldTitle); }
|
||||
});
|
||||
input.addEventListener('blur', commit);
|
||||
}
|
||||
|
||||
function deleteSession(sid) {
|
||||
showConfirmModal(t('delete_session_title'), t('delete_session_confirm'), () => {
|
||||
// Before deleting, find the next real session to fall back to when the
|
||||
@@ -4749,7 +4888,7 @@ const MODELS_CAPABILITY_DEFS = [
|
||||
iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' },
|
||||
{ id: 'tts', icon: 'fa-volume-high', editable: true, needsModel: true, titleKey: 'models_capability_tts', descKey: 'models_capability_tts_desc',
|
||||
iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' },
|
||||
{ id: 'embedding', icon: 'fa-vector-square', editable: true, needsModel: false, titleKey: 'models_capability_embedding', descKey: 'models_capability_embedding_desc',
|
||||
{ id: 'embedding', icon: 'fa-vector-square', editable: true, needsModel: true, titleKey: 'models_capability_embedding', descKey: 'models_capability_embedding_desc',
|
||||
iconChip: 'bg-purple-50 dark:bg-purple-900/30', iconGlyph: 'text-purple-500' },
|
||||
{ id: 'search', icon: 'fa-magnifying-glass', editable: true, needsModel: false, titleKey: 'models_capability_search', descKey: 'models_capability_search_desc',
|
||||
iconChip: 'bg-orange-50 dark:bg-orange-900/30', iconGlyph: 'text-orange-500' },
|
||||
@@ -5470,8 +5609,10 @@ function renderCapabilityBody(def, cap, body) {
|
||||
|
||||
if (def.needsModel) {
|
||||
rebuildCapabilityModelDropdown(def, initialProviderValue, cap.current_model || '', body);
|
||||
// Hide model picker in auto mode — fallback hint below covers it.
|
||||
setCapabilityModelPickerVisible(def, initialProviderValue !== '' || !capabilitySupportsAuto(def.id), body);
|
||||
// Embedding: hide model picker when no provider is selected.
|
||||
const showModel = def.id === 'embedding' ? initialProviderValue !== '' :
|
||||
(initialProviderValue !== '' || !capabilitySupportsAuto(def.id));
|
||||
setCapabilityModelPickerVisible(def, showModel, body);
|
||||
}
|
||||
|
||||
if (def.id === 'tts') {
|
||||
@@ -5766,6 +5907,9 @@ function rebuildCapabilityModelDropdown(def, providerId, selectedModel, scope) {
|
||||
let rawList;
|
||||
if (capModelMap[providerId]) {
|
||||
rawList = capModelMap[providerId].slice();
|
||||
} else if (providerId.startsWith('custom:') && capModelMap['custom']) {
|
||||
// Expanded custom:<id> entries share the same preset model list
|
||||
rawList = capModelMap['custom'].slice();
|
||||
} else {
|
||||
const provider = modelsState.providers.find(p => p.id === providerId);
|
||||
rawList = (provider && provider.models) ? provider.models.slice() : [];
|
||||
@@ -5896,12 +6040,13 @@ function rebuildCapabilityVoiceDropdown(providerId, selectedVoice, scope, modelI
|
||||
|
||||
function onCapabilityProviderChange(def, providerId, scope) {
|
||||
if (def.needsModel) {
|
||||
// Empty sentinel hides the model picker (capability is in auto mode).
|
||||
const isAuto = providerId === '' && capabilitySupportsAuto(def.id);
|
||||
if (!isAuto) {
|
||||
// Embedding: hide model picker when no provider is selected.
|
||||
const showModel = def.id === 'embedding' ? providerId !== '' :
|
||||
!(providerId === '' && capabilitySupportsAuto(def.id));
|
||||
if (showModel) {
|
||||
rebuildCapabilityModelDropdown(def, providerId, '', scope);
|
||||
}
|
||||
setCapabilityModelPickerVisible(def, !isAuto, scope);
|
||||
setCapabilityModelPickerVisible(def, showModel, scope);
|
||||
}
|
||||
if (def.id === 'tts') {
|
||||
rebuildCapabilityVoiceDropdown(providerId, '', scope);
|
||||
@@ -5936,7 +6081,9 @@ function saveCapability(capId) {
|
||||
// hidden and any value left in it is stale; persist an empty model so
|
||||
// the backend treats this as "fall back to the runtime chain".
|
||||
const isAuto = provider === '' && capabilitySupportsAuto(capId);
|
||||
const model = isAuto ? '' : getCapabilityModelValue(def);
|
||||
// Embedding without a provider similarly means "cleared" — don't leak
|
||||
// a stale model value into config.
|
||||
const model = (isAuto || (capId === 'embedding' && !provider)) ? '' : getCapabilityModelValue(def);
|
||||
// TTS carries an extra voice timbre (supports free-text custom ids).
|
||||
let voice = '';
|
||||
if (capId === 'tts' && !isAuto) {
|
||||
@@ -7383,6 +7530,26 @@ function connectFeishuAfterRegister(appId, appSecret) {
|
||||
// Scheduler View
|
||||
// =====================================================================
|
||||
let tasksLoaded = false;
|
||||
function refreshTasksView() {
|
||||
const btn = document.getElementById('task-refresh-btn');
|
||||
const icon = btn.querySelector('i');
|
||||
|
||||
// Add spin animation
|
||||
icon.classList.add('fa-spin');
|
||||
btn.disabled = true;
|
||||
|
||||
tasksLoaded = false;
|
||||
const listEl = document.getElementById('tasks-list');
|
||||
listEl.innerHTML = '';
|
||||
|
||||
loadTasksView();
|
||||
|
||||
// Restore button after animation ends
|
||||
setTimeout(() => {
|
||||
icon.classList.remove('fa-spin');
|
||||
btn.disabled = false;
|
||||
}, 500);
|
||||
}
|
||||
function loadTasksView() {
|
||||
if (tasksLoaded) return;
|
||||
fetch('/api/scheduler').then(r => r.json()).then(data => {
|
||||
@@ -7390,39 +7557,94 @@ function loadTasksView() {
|
||||
const emptyEl = document.getElementById('tasks-empty');
|
||||
const listEl = document.getElementById('tasks-list');
|
||||
const allTasks = data.tasks || [];
|
||||
// Only show active (enabled) tasks
|
||||
const tasks = allTasks.filter(t => t.enabled !== false);
|
||||
if (tasks.length === 0) {
|
||||
// Backend already sorted by enabled and next_run_at, no need to re-sort on frontend
|
||||
if (allTasks.length === 0) {
|
||||
emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无定时任务' : 'No scheduled tasks';
|
||||
emptyEl.classList.remove('hidden');
|
||||
listEl.classList.add('hidden');
|
||||
tasksLoaded = true;
|
||||
return;
|
||||
}
|
||||
emptyEl.classList.add('hidden');
|
||||
listEl.classList.remove('hidden');
|
||||
listEl.innerHTML = '';
|
||||
|
||||
tasks.forEach(task => {
|
||||
allTasks.forEach(task => {
|
||||
const isEnabled = task.enabled !== false;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-4';
|
||||
const typeLabel = task.type === 'cron'
|
||||
? `<span class="text-xs font-mono text-slate-400">${escapeHtml(task.cron || '')}</span>`
|
||||
: `<span class="text-xs text-slate-400">${escapeHtml(task.type || 'once')}</span>`;
|
||||
card.dataset.taskId = task.id;
|
||||
if (!isEnabled) card.classList.add('opacity-50');
|
||||
const schedule = task.schedule || {};
|
||||
let typeLabel = '';
|
||||
if (schedule.type === 'cron') {
|
||||
typeLabel = `<span class="text-xs font-mono text-slate-400">${escapeHtml(schedule.expression || '')}</span>`;
|
||||
} else if (schedule.type === 'interval') {
|
||||
const seconds = schedule.seconds || 0;
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
let intervalText = [];
|
||||
if (hours > 0) intervalText.push(`${hours}h`);
|
||||
if (mins > 0) intervalText.push(`${mins}m`);
|
||||
if (secs > 0 || intervalText.length === 0) intervalText.push(`${secs}s`);
|
||||
typeLabel = `<span class="text-xs text-slate-400">${intervalText.join(' ')}</span>`;
|
||||
} else {
|
||||
typeLabel = `<span class="text-xs text-slate-400">${escapeHtml(schedule.type || 'once')}</span>`;
|
||||
}
|
||||
let nextRun = '--';
|
||||
if (task.next_run_at) {
|
||||
// next_run_at is an ISO string, not a Unix timestamp
|
||||
const d = new Date(task.next_run_at);
|
||||
if (!isNaN(d.getTime())) nextRun = d.toLocaleString();
|
||||
}
|
||||
const action = task.action || {};
|
||||
const taskContent = action.content || action.task_description || '';
|
||||
const toggleId = 'toggle-' + task.id;
|
||||
card.innerHTML = `
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="w-2 h-2 rounded-full bg-primary-400"></span>
|
||||
<span class="w-2 h-2 rounded-full ${isEnabled ? 'bg-primary-400' : 'bg-slate-300 dark:bg-slate-600'}"></span>
|
||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200">${escapeHtml(task.name || task.id || '--')}</span>
|
||||
<div class="flex-1"></div>
|
||||
${typeLabel}
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-2 line-clamp-2">${escapeHtml(task.prompt || task.description || '')}</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-2 line-clamp-2">${escapeHtml(taskContent)}</p>
|
||||
<div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
|
||||
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
|
||||
<div class="flex-1"></div>
|
||||
<label class="relative inline-flex items-center cursor-pointer" for="${toggleId}">
|
||||
<input type="checkbox" id="${toggleId}" class="sr-only peer" ${isEnabled ? 'checked' : ''}>
|
||||
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>`;
|
||||
const checkbox = card.querySelector('#' + toggleId);
|
||||
checkbox.addEventListener('change', function() {
|
||||
const newEnabled = this.checked;
|
||||
fetch('/api/scheduler/toggle', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({task_id: task.id, enabled: newEnabled})
|
||||
}).then(r => r.json()).then(res => {
|
||||
if (res.status === 'success') {
|
||||
const dot = card.querySelector('.rounded-full.w-2');
|
||||
if (newEnabled) {
|
||||
card.classList.remove('opacity-50');
|
||||
if (dot) { dot.classList.remove('bg-slate-300','dark:bg-slate-600'); dot.classList.add('bg-primary-400'); }
|
||||
} else {
|
||||
card.classList.add('opacity-50');
|
||||
if (dot) { dot.classList.remove('bg-primary-400'); dot.classList.add('bg-slate-300','dark:bg-slate-600'); }
|
||||
}
|
||||
} else {
|
||||
this.checked = !newEnabled;
|
||||
}
|
||||
}).catch(() => { this.checked = !newEnabled; });
|
||||
});
|
||||
// Card click event (excluding toggle switch clicks)
|
||||
card.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('label') && !e.target.closest('input[type="checkbox"]')) {
|
||||
openTaskEditModal(task);
|
||||
}
|
||||
});
|
||||
card.style.cursor = 'pointer';
|
||||
listEl.appendChild(card);
|
||||
});
|
||||
tasksLoaded = true;
|
||||
@@ -8527,3 +8749,382 @@ fetch('/auth/check').then(r => r.json()).then(data => {
|
||||
requestAnimationFrame(() => {
|
||||
document.body.classList.add('transition-colors', 'duration-200');
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Task Edit Modal
|
||||
// =====================================================================
|
||||
let currentEditingTask = null;
|
||||
|
||||
function loadTaskChannelOptions(selectedChannelType) {
|
||||
const select = document.getElementById('task-edit-channel-type');
|
||||
select.innerHTML = '';
|
||||
fetch('/api/channels').then(r => r.json()).then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
const allChannels = data.channels || [];
|
||||
// Only include currently active channels, strictly following the channel management page logic
|
||||
let channels = allChannels.filter(c => c.active).map(c => {
|
||||
const label = (typeof c.label === 'object') ? (c.label[currentLang] || c.label.en || c.name) : (c.label || c.name);
|
||||
return { name: c.name, label: label };
|
||||
});
|
||||
const channelNames = channels.map(c => c.name);
|
||||
// Always include the web console channel
|
||||
if (!channelNames.includes('web')) {
|
||||
channels.unshift({ name: 'web', label: currentLang === 'zh' ? 'Web' : 'Web' });
|
||||
}
|
||||
// If the currently selected channel is not in the active list (e.g. disabled), append it to preserve selection
|
||||
if (selectedChannelType && !channelNames.includes(selectedChannelType) && selectedChannelType !== 'web') {
|
||||
const ch = allChannels.find(c => c.name === selectedChannelType);
|
||||
const label = ch
|
||||
? ((typeof ch.label === 'object') ? (ch.label[currentLang] || ch.label.en || ch.name) : (ch.label || ch.name))
|
||||
: selectedChannelType;
|
||||
channels.push({ name: selectedChannelType, label: label });
|
||||
}
|
||||
channels.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.name;
|
||||
opt.textContent = c.label;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
// Set selected value
|
||||
if (selectedChannelType) {
|
||||
select.value = selectedChannelType;
|
||||
}
|
||||
}).catch(() => {
|
||||
// fallback: at least keep the current selection and web
|
||||
select.innerHTML = '';
|
||||
const webOpt = document.createElement('option');
|
||||
webOpt.value = 'web';
|
||||
webOpt.textContent = 'Web';
|
||||
select.appendChild(webOpt);
|
||||
|
||||
if (selectedChannelType && selectedChannelType !== 'web') {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = selectedChannelType;
|
||||
opt.textContent = selectedChannelType;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
if (selectedChannelType) {
|
||||
select.value = selectedChannelType;
|
||||
}
|
||||
|
||||
// Show error message
|
||||
console.error('Failed to load channel options');
|
||||
});
|
||||
}
|
||||
|
||||
function openTaskEditModal(task) {
|
||||
currentEditingTask = task;
|
||||
const overlay = document.getElementById('task-edit-modal-overlay');
|
||||
const titleEl = document.querySelector('#task-edit-modal-overlay h3');
|
||||
const subtitle = document.getElementById('task-edit-modal-subtitle');
|
||||
const deleteBtn = document.getElementById('task-edit-modal-delete');
|
||||
const nameInput = document.getElementById('task-edit-name');
|
||||
const enabledInput = document.getElementById('task-edit-enabled');
|
||||
const scheduleTypeSelect = document.getElementById('task-edit-schedule-type');
|
||||
const cronInput = document.getElementById('task-edit-cron-expression');
|
||||
const intervalInput = document.getElementById('task-edit-interval-seconds');
|
||||
const onceInput = document.getElementById('task-edit-once-time');
|
||||
const actionTypeSelect = document.getElementById('task-edit-action-type');
|
||||
const receiverInput = document.getElementById('task-edit-receiver');
|
||||
const contentInput = document.getElementById('task-edit-content');
|
||||
|
||||
// Set title and subtitle
|
||||
titleEl.textContent = t('task_edit_title');
|
||||
subtitle.textContent = task.id;
|
||||
deleteBtn.classList.remove('hidden');
|
||||
|
||||
// Populate data
|
||||
nameInput.value = task.name || '';
|
||||
enabledInput.checked = task.enabled !== false;
|
||||
|
||||
const schedule = task.schedule || {};
|
||||
scheduleTypeSelect.value = schedule.type || 'cron';
|
||||
|
||||
// Clear all schedule type input values first to avoid stale data
|
||||
cronInput.value = '';
|
||||
intervalInput.value = '';
|
||||
onceInput.value = '';
|
||||
|
||||
if (schedule.type === 'cron') {
|
||||
cronInput.value = schedule.expression || '';
|
||||
} else if (schedule.type === 'interval') {
|
||||
intervalInput.value = schedule.seconds || '';
|
||||
} else if (schedule.type === 'once') {
|
||||
if (schedule.run_at) {
|
||||
// Manually parse ISO time string to avoid cross-browser timezone issues with new Date()
|
||||
// run_at format: "YYYY-MM-DDTHH:mm:ss" or "YYYY-MM-DDTHH:mm:ss.ffffff"
|
||||
const parts = schedule.run_at.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/);
|
||||
if (parts) {
|
||||
const timeInput = document.getElementById('task-edit-once-time');
|
||||
timeInput.value = `${parts[1]}-${parts[2]}-${parts[3]}T${parts[4]}:${parts[5]}:${parts[6]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const action = task.action || {};
|
||||
actionTypeSelect.value = action.type || 'send_message';
|
||||
receiverInput.value = action.receiver || '';
|
||||
contentInput.value = action.content || action.task_description || '';
|
||||
|
||||
// Load channel options and set selected value
|
||||
loadTaskChannelOptions(action.channel_type || 'web');
|
||||
|
||||
// Disable channel type selector — channel is read-only when editing.
|
||||
// Switching the channel after a task is created is problematic because:
|
||||
// 1. The WeChat (weixin/ilink) bot requires a valid context_token that is tied
|
||||
// to a specific user-session on that channel. Changing the channel to weixin
|
||||
// would invalidate the existing token — the new receiver on weixin may not
|
||||
// have an active context_token, causing the scheduled push to silently fail.
|
||||
// 2. Other channels (DingTalk, Feishu, etc.) also carry channel-specific fields
|
||||
// (e.g. dingtalk_sender_staff_id) that cannot be trivially re-populated for
|
||||
// a different channel type without user intervention.
|
||||
// 3. The receiver identity itself is channel-bound — a weixin user-id means
|
||||
// nothing on a Feishu channel, so changing the channel would orphan the task.
|
||||
// For these reasons, the channel type is intentionally frozen once a task exists.
|
||||
// Users who need a task on a different channel should create a new task through
|
||||
// the chat interface (by asking the bot) rather than editing an existing one.
|
||||
document.getElementById('task-edit-channel-type').disabled = true;
|
||||
|
||||
// Update UI
|
||||
updateTaskScheduleFields();
|
||||
updateTaskActionLabel();
|
||||
|
||||
overlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeTaskEditModal() {
|
||||
document.getElementById('task-edit-modal-overlay').classList.add('hidden');
|
||||
currentEditingTask = null;
|
||||
}
|
||||
|
||||
function updateTaskScheduleFields() {
|
||||
const scheduleType = document.getElementById('task-edit-schedule-type').value;
|
||||
const cronWrap = document.getElementById('task-edit-cron-wrap');
|
||||
const intervalWrap = document.getElementById('task-edit-interval-wrap');
|
||||
const onceWrap = document.getElementById('task-edit-once-wrap');
|
||||
const cronHint = document.getElementById('task-edit-cron-hint');
|
||||
const intervalHint = document.getElementById('task-edit-interval-hint');
|
||||
|
||||
cronWrap.classList.toggle('hidden', scheduleType !== 'cron');
|
||||
intervalWrap.classList.toggle('hidden', scheduleType !== 'interval');
|
||||
onceWrap.classList.toggle('hidden', scheduleType !== 'once');
|
||||
|
||||
if (cronHint) cronHint.classList.toggle('hidden', scheduleType !== 'cron');
|
||||
if (intervalHint) intervalHint.classList.toggle('hidden', scheduleType !== 'interval');
|
||||
}
|
||||
|
||||
function updateTaskActionLabel() {
|
||||
const actionType = document.getElementById('task-edit-action-type').value;
|
||||
const label = document.getElementById('task-edit-content-label');
|
||||
const content = document.getElementById('task-edit-content');
|
||||
|
||||
if (actionType === 'send_message') {
|
||||
label.textContent = t('task_message_content');
|
||||
content.placeholder = t('task_message_content');
|
||||
} else {
|
||||
label.textContent = t('task_task_description');
|
||||
content.placeholder = t('task_task_description');
|
||||
}
|
||||
}
|
||||
|
||||
function saveTaskEdit() {
|
||||
const nameInput = document.getElementById('task-edit-name');
|
||||
const enabledInput = document.getElementById('task-edit-enabled');
|
||||
const scheduleTypeSelect = document.getElementById('task-edit-schedule-type');
|
||||
const cronInput = document.getElementById('task-edit-cron-expression');
|
||||
const intervalInput = document.getElementById('task-edit-interval-seconds');
|
||||
const onceInput = document.getElementById('task-edit-once-time');
|
||||
const actionTypeSelect = document.getElementById('task-edit-action-type');
|
||||
const channelTypeSelect = document.getElementById('task-edit-channel-type');
|
||||
const receiverInput = document.getElementById('task-edit-receiver');
|
||||
const contentInput = document.getElementById('task-edit-content');
|
||||
const statusEl = document.getElementById('task-edit-modal-status');
|
||||
const saveBtn = document.getElementById('task-edit-modal-save');
|
||||
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) {
|
||||
statusEl.textContent = currentLang === 'zh' ? '请输入任务名称' : 'Please enter task name';
|
||||
statusEl.style.opacity = '1';
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleType = scheduleTypeSelect.value;
|
||||
const schedule = { type: scheduleType };
|
||||
|
||||
if (scheduleType === 'cron') {
|
||||
const expr = cronInput.value.trim();
|
||||
if (!expr) {
|
||||
statusEl.textContent = currentLang === 'zh' ? '请输入 Cron 表达式' : 'Please enter cron expression';
|
||||
statusEl.style.opacity = '1';
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
return;
|
||||
}
|
||||
// Basic cron expression format validation: 5 or 6 fields
|
||||
const fields = expr.split(/\s+/);
|
||||
if (fields.length < 5 || fields.length > 6) {
|
||||
statusEl.textContent = currentLang === 'zh' ? 'Cron 表达式格式错误,应为 5 或 6 个字段(分 时 日 月 周)' : 'Invalid cron expression, expected 5 or 6 fields (min hour day month weekday)';
|
||||
statusEl.style.opacity = '1';
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
return;
|
||||
}
|
||||
schedule.expression = expr;
|
||||
// Note: detailed cron expression validity is verified by the backend croniter library; frontend only does basic format validation
|
||||
} else if (scheduleType === 'interval') {
|
||||
const seconds = parseInt(intervalInput.value);
|
||||
if (!seconds || seconds < 60) {
|
||||
statusEl.textContent = currentLang === 'zh' ? '间隔秒数最小为 60 秒' : 'Interval must be at least 60 seconds';
|
||||
statusEl.style.opacity = '1';
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
return;
|
||||
}
|
||||
schedule.seconds = seconds;
|
||||
} else if (scheduleType === 'once') {
|
||||
const time = onceInput.value;
|
||||
if (!time) {
|
||||
statusEl.textContent = currentLang === 'zh' ? '请选择执行时间' : 'Please select execution time';
|
||||
statusEl.style.opacity = '1';
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
return;
|
||||
}
|
||||
// Validate execution time format
|
||||
const selectedTime = new Date(time);
|
||||
if (isNaN(selectedTime.getTime())) {
|
||||
statusEl.textContent = currentLang === 'zh' ? '执行时间格式错误' : 'Invalid execution time format';
|
||||
statusEl.style.opacity = '1';
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
return;
|
||||
}
|
||||
// Validate that time is in the future for one-time tasks
|
||||
if (selectedTime <= new Date()) {
|
||||
statusEl.textContent = currentLang === 'zh' ? '执行时间必须在当前时间之后' : 'Execution time must be in the future';
|
||||
statusEl.style.opacity = '1';
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
return;
|
||||
}
|
||||
// datetime-local value with step="1" is already in YYYY-MM-DDTHH:mm:ss format
|
||||
// Backend _parse_naive_local treats strings without timezone suffix as local time
|
||||
schedule.run_at = time;
|
||||
}
|
||||
|
||||
const actionType = actionTypeSelect.value;
|
||||
const channelType = channelTypeSelect.value;
|
||||
const content = contentInput.value.trim();
|
||||
|
||||
if (!content) {
|
||||
statusEl.textContent = currentLang === 'zh' ? '请输入内容' : 'Please enter content';
|
||||
statusEl.style.opacity = '1';
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build action with only necessary fields to avoid stale data
|
||||
const action = {
|
||||
type: actionType,
|
||||
channel_type: channelType,
|
||||
receiver: '',
|
||||
receiver_name: '',
|
||||
is_group: false,
|
||||
notify_session_id: ''
|
||||
};
|
||||
|
||||
if (actionType === 'send_message') {
|
||||
action.content = content;
|
||||
} else {
|
||||
action.task_description = content;
|
||||
}
|
||||
|
||||
// Preserve the original receiver info (channel is read-only, so it never changes)
|
||||
if (currentEditingTask && currentEditingTask.action) {
|
||||
action.receiver = currentEditingTask.action.receiver || '';
|
||||
action.receiver_name = currentEditingTask.action.receiver_name || '';
|
||||
action.is_group = currentEditingTask.action.is_group || false;
|
||||
action.notify_session_id = currentEditingTask.action.notify_session_id || '';
|
||||
|
||||
// Preserve channel-specific fields (e.g. DingTalk sender_staff_id)
|
||||
if (channelType === 'dingtalk' && currentEditingTask.action.dingtalk_sender_staff_id) {
|
||||
action.dingtalk_sender_staff_id = currentEditingTask.action.dingtalk_sender_staff_id;
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.disabled = true;
|
||||
|
||||
const payload = {
|
||||
task_id: currentEditingTask.id,
|
||||
name: name,
|
||||
enabled: enabledInput.checked,
|
||||
schedule: schedule,
|
||||
action: action
|
||||
};
|
||||
|
||||
fetch('/api/scheduler/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}).then(r => r.json()).then(res => {
|
||||
saveBtn.disabled = false;
|
||||
if (res.status === 'success') {
|
||||
closeTaskEditModal();
|
||||
tasksLoaded = false;
|
||||
loadTasksView();
|
||||
} else {
|
||||
statusEl.textContent = res.message || (currentLang === 'zh' ? '保存失败' : 'Save failed');
|
||||
statusEl.style.opacity = '1';
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
}
|
||||
}).catch(() => {
|
||||
saveBtn.disabled = false;
|
||||
statusEl.textContent = currentLang === 'zh' ? '网络错误' : 'Network error';
|
||||
statusEl.style.opacity = '1';
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteTask() {
|
||||
if (!currentEditingTask) return;
|
||||
|
||||
const taskName = currentEditingTask.name || currentEditingTask.id || '未知任务';
|
||||
const taskId = currentEditingTask.id; // Capture early to avoid closure race condition
|
||||
showConfirmDialog({
|
||||
title: t('task_delete_confirm_title'),
|
||||
message: (currentLang === 'zh' ? `确定要删除任务「${taskName}」吗?` : `Are you sure to delete task "${taskName}"?`),
|
||||
onConfirm: () => {
|
||||
fetch('/api/scheduler/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: taskId })
|
||||
}).then(r => r.json()).then(res => {
|
||||
if (res.status === 'success') {
|
||||
closeTaskEditModal();
|
||||
tasksLoaded = false;
|
||||
loadTasksView();
|
||||
} else {
|
||||
const statusEl = document.getElementById('task-edit-modal-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = res.message || 'Delete failed';
|
||||
statusEl.classList.remove('hidden', 'text-green-500');
|
||||
statusEl.classList.add('text-red-500');
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
const statusEl = document.getElementById('task-edit-modal-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'Network error';
|
||||
statusEl.classList.remove('hidden', 'text-green-500');
|
||||
statusEl.classList.add('text-red-500');
|
||||
setTimeout(() => { statusEl.style.opacity = '0'; }, 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('task-edit-schedule-type').addEventListener('change', updateTaskScheduleFields);
|
||||
document.getElementById('task-edit-action-type').addEventListener('change', updateTaskActionLabel);
|
||||
document.getElementById('task-edit-modal-cancel').addEventListener('click', closeTaskEditModal);
|
||||
document.getElementById('task-edit-modal-save').addEventListener('click', saveTaskEdit);
|
||||
document.getElementById('task-edit-modal-delete').addEventListener('click', deleteTask);
|
||||
document.getElementById('task-edit-modal-overlay').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeTaskEditModal();
|
||||
});
|
||||
|
||||
@@ -1180,6 +1180,9 @@ class WebChannel(ChatChannel):
|
||||
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||
'/api/knowledge/import', 'KnowledgeImportHandler',
|
||||
'/api/scheduler', 'SchedulerHandler',
|
||||
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||
'/api/scheduler/delete', 'SchedulerDeleteHandler',
|
||||
'/api/sessions', 'SessionsHandler',
|
||||
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
|
||||
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
|
||||
@@ -1500,10 +1503,10 @@ class ConfigHandler:
|
||||
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
||||
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||
const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
||||
const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||
const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
||||
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||
]
|
||||
@@ -1566,7 +1569,7 @@ class ConfigHandler:
|
||||
"api_base_key": "zhipu_ai_api_base",
|
||||
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"api_base_placeholder": _PLACEHOLDER_ZHIPU,
|
||||
"models": [const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7],
|
||||
"models": [const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7],
|
||||
}),
|
||||
("dashscope", {
|
||||
"label": {"zh": "通义千问", "en": "Qwen"},
|
||||
@@ -1590,7 +1593,7 @@ class ConfigHandler:
|
||||
"api_base_key": "moonshot_base_url",
|
||||
"api_base_default": "https://api.moonshot.cn/v1",
|
||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||
"models": [const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||
}),
|
||||
("qianfan", {
|
||||
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
||||
@@ -2083,7 +2086,20 @@ class ModelsHandler:
|
||||
],
|
||||
},
|
||||
}
|
||||
_EMBEDDING_PROVIDERS = ["openai", "dashscope", "doubao", "zhipu", "linkai"]
|
||||
_EMBEDDING_PROVIDERS = ["openai", "dashscope", "doubao", "zhipu", "linkai", "custom"]
|
||||
|
||||
# Embedding model catalog per provider. Mirrors the default_model in
|
||||
# agent/memory/embedding/provider.py::EMBEDDING_VENDORS.
|
||||
# Custom providers have no preset list — model names vary per vendor,
|
||||
# so the user always types the model id manually.
|
||||
_EMBEDDING_PROVIDER_MODELS = {
|
||||
"openai": ["text-embedding-3-small", "text-embedding-3-large"],
|
||||
"dashscope": ["text-embedding-v4"],
|
||||
"doubao": ["doubao-embedding-vision-251215"],
|
||||
"zhipu": ["embedding-3"],
|
||||
"linkai": ["text-embedding-3-small"],
|
||||
"custom": [],
|
||||
}
|
||||
|
||||
# Capability-scoped model catalogs. The chat dropdown can reuse the
|
||||
# provider's generic model list, but vision and image generation are
|
||||
@@ -2133,6 +2149,9 @@ class ModelsHandler:
|
||||
const.CLAUDE_4_6_SONNET,
|
||||
const.GEMINI_31_FLASH_LITE_PRE,
|
||||
],
|
||||
# Custom OpenAI-compatible providers have no preset list — model
|
||||
# names vary per vendor, so the user types the model id manually.
|
||||
"custom": [],
|
||||
}
|
||||
|
||||
# Image-generation catalog. Source of truth: skills/image-generation/SKILL.md.
|
||||
@@ -2446,18 +2465,31 @@ class ModelsHandler:
|
||||
user_specified = (vision_conf.get("model") or "").strip()
|
||||
explicit_provider = (vision_conf.get("provider") or "").strip()
|
||||
|
||||
# Build provider list: built-in providers + expanded custom:<id> entries.
|
||||
# Same pattern as _embedding_capability — each user-created custom
|
||||
# provider gets its own dropdown entry showing the user-chosen name.
|
||||
providers = []
|
||||
custom_cards = cls._custom_provider_cards(local_config)
|
||||
for pid in cls._VISION_PROVIDER_MODELS:
|
||||
if pid == "custom":
|
||||
if custom_cards:
|
||||
providers.extend(c["id"] for c in custom_cards)
|
||||
else:
|
||||
providers.append(pid)
|
||||
|
||||
# Provider resolution priority:
|
||||
# 1. Explicit `tools.vision.provider` (persisted via UI; supports
|
||||
# custom model names that prefix-inference can't recognize).
|
||||
# 2. Scan per-provider model lists by model name.
|
||||
# Empty provider keeps the dropdown on "auto" when we can't tell.
|
||||
inferred_provider = ""
|
||||
if explicit_provider and explicit_provider in cls._VISION_PROVIDER_MODELS:
|
||||
if explicit_provider and explicit_provider in providers:
|
||||
inferred_provider = explicit_provider
|
||||
elif user_specified:
|
||||
for pid, models in cls._VISION_PROVIDER_MODELS.items():
|
||||
if user_specified in models:
|
||||
inferred_provider = pid
|
||||
# For "custom" key, map to the first custom card
|
||||
inferred_provider = custom_cards[0]["id"] if pid == "custom" and custom_cards else pid
|
||||
break
|
||||
|
||||
# In auto mode the hint should reflect what vision.py will actually
|
||||
@@ -2473,7 +2505,7 @@ class ModelsHandler:
|
||||
"current_model": user_specified,
|
||||
"fallback_provider": predicted["provider"],
|
||||
"fallback_model": predicted["model"],
|
||||
"providers": list(cls._VISION_PROVIDER_MODELS.keys()),
|
||||
"providers": providers,
|
||||
"provider_models": cls._VISION_PROVIDER_MODELS,
|
||||
}
|
||||
|
||||
@@ -2546,18 +2578,40 @@ class ModelsHandler:
|
||||
suggested = ""
|
||||
if not explicit:
|
||||
for pid in cls._EMBEDDING_PROVIDERS:
|
||||
if pid == "custom":
|
||||
continue
|
||||
meta = ConfigHandler.PROVIDER_MODELS.get(pid) or {}
|
||||
key_field = meta.get("api_key_field")
|
||||
if key_field and cls._is_real_key(local_config.get(key_field, "")):
|
||||
suggested = pid
|
||||
break
|
||||
if not suggested:
|
||||
custom_cards = cls._custom_provider_cards(local_config)
|
||||
if custom_cards:
|
||||
suggested = custom_cards[0]["id"]
|
||||
|
||||
# Build provider list: built-in providers + expanded custom:<id> entries
|
||||
# Same pattern as _chat_capability — each user-created custom provider
|
||||
# gets its own dropdown entry showing the user-chosen name.
|
||||
providers = []
|
||||
custom_cards = cls._custom_provider_cards(local_config)
|
||||
for pid in cls._EMBEDDING_PROVIDERS:
|
||||
if pid == "custom":
|
||||
if custom_cards:
|
||||
providers.extend(c["id"] for c in custom_cards)
|
||||
# No custom providers configured — skip the bare "custom" entry
|
||||
# since the runtime cannot resolve its credentials.
|
||||
else:
|
||||
providers.append(pid)
|
||||
|
||||
return {
|
||||
"editable": True,
|
||||
"current_provider": explicit,
|
||||
"suggested_provider": suggested,
|
||||
"current_model": local_config.get("embedding_model", "") or "",
|
||||
"current_dim": int(local_config.get("embedding_dimensions") or 0) or None,
|
||||
"providers": cls._EMBEDDING_PROVIDERS,
|
||||
"providers": providers,
|
||||
"provider_models": cls._EMBEDDING_PROVIDER_MODELS,
|
||||
}
|
||||
|
||||
# Auto-fallback order for image generation. Mirrors the global priority
|
||||
@@ -2919,10 +2973,10 @@ class ModelsHandler:
|
||||
{
|
||||
"action": "set_custom_provider",
|
||||
"id": "3f2a9c1b", # required for edit; omit for create
|
||||
"name": "siliconflow", # required, display label
|
||||
"name": "my-provider", # required, display label
|
||||
"api_base": "https://...", # required when creating
|
||||
"api_key": "sk-...", # optional on edit (keep existing)
|
||||
"model": "deepseek-ai/...", # optional default model
|
||||
"model": "model-name", # optional default model
|
||||
"make_active": true # optional, also activate it
|
||||
}
|
||||
"""
|
||||
@@ -3143,6 +3197,25 @@ class ModelsHandler:
|
||||
# is persisted so users picking a custom model under a specific vendor
|
||||
# still get routed there — runtime falls back to model-name prefix
|
||||
# inference only when provider is empty.
|
||||
# Validate provider_id — mirrors _set_chat / _set_embedding pattern.
|
||||
if provider_id.startswith("custom:"):
|
||||
from models.custom_provider import parse_custom_bot_type
|
||||
_, custom_id = parse_custom_bot_type(provider_id)
|
||||
providers = self._normalize_custom_providers(conf().get("custom_providers"))
|
||||
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
|
||||
if custom_provider is None:
|
||||
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
|
||||
if not model:
|
||||
model = custom_provider.get("model") or ""
|
||||
elif provider_id and provider_id not in {k for k in ModelsHandler._VISION_PROVIDER_MODELS if k != "custom"}:
|
||||
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
||||
|
||||
if provider_id and not model:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"message": "vision model is required when a provider is selected",
|
||||
})
|
||||
|
||||
local_config = conf()
|
||||
file_cfg = self._read_file_config()
|
||||
self._set_nested_namespace_value(file_cfg, "tools", "vision", "model", model)
|
||||
@@ -3268,7 +3341,20 @@ class ModelsHandler:
|
||||
logger.warning(f"[ModelsHandler] Bridge voice refresh failed: {e}")
|
||||
|
||||
def _set_embedding(self, provider_id: str, model: str) -> str:
|
||||
# Two valid states: both empty (reset to pick-or-empty) OR both set.
|
||||
# Validate provider_id — mirrors _set_chat's validation pattern.
|
||||
if provider_id.startswith("custom:"):
|
||||
from models.custom_provider import parse_custom_bot_type
|
||||
_, custom_id = parse_custom_bot_type(provider_id)
|
||||
providers = self._normalize_custom_providers(conf().get("custom_providers"))
|
||||
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
|
||||
if custom_provider is None:
|
||||
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
|
||||
# Fall back to the custom provider's default model when none is given.
|
||||
if not model:
|
||||
model = custom_provider.get("model") or ""
|
||||
elif provider_id and provider_id not in {p for p in ModelsHandler._EMBEDDING_PROVIDERS if p != "custom"}:
|
||||
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
||||
|
||||
# A provider without a model leaves the runtime in a broken half-state,
|
||||
# so reject that explicitly instead of silently writing it through.
|
||||
if provider_id and not model:
|
||||
@@ -4186,6 +4272,141 @@ class SchedulerHandler:
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SchedulerToggleHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
body = json.loads(web.data())
|
||||
task_id = body.get("task_id")
|
||||
enabled = body.get("enabled", True)
|
||||
if not task_id:
|
||||
return json.dumps({"status": "error", "message": "task_id required"})
|
||||
from agent.tools.scheduler.task_store import TaskStore
|
||||
workspace_root = _get_workspace_root()
|
||||
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||
store = TaskStore(store_path)
|
||||
store.enable_task(task_id, enabled)
|
||||
task = store.get_task(task_id)
|
||||
return json.dumps({"status": "success", "task": task}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Scheduler toggle error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SchedulerUpdateHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
body = json.loads(web.data())
|
||||
task_id = body.get("task_id")
|
||||
if not task_id:
|
||||
return json.dumps({"status": "error", "message": "task_id required"})
|
||||
|
||||
from agent.tools.scheduler.task_store import TaskStore
|
||||
from agent.tools.scheduler.scheduler_service import SchedulerService
|
||||
from datetime import datetime
|
||||
workspace_root = _get_workspace_root()
|
||||
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||
store = TaskStore(store_path)
|
||||
|
||||
# Get original task (single query to avoid repeated I/O)
|
||||
original_task = store.get_task(task_id)
|
||||
if not original_task:
|
||||
return json.dumps({"status": "error", "message": f"Task '{task_id}' not found"})
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if "name" in body:
|
||||
updates["name"] = body["name"]
|
||||
if "enabled" in body:
|
||||
updates["enabled"] = body["enabled"]
|
||||
|
||||
# Update schedule
|
||||
if "schedule" in body:
|
||||
updates["schedule"] = body["schedule"]
|
||||
# If schedule config changed, recalculate next_run_at
|
||||
# Build merged temp task data for calculation (without modifying the original object)
|
||||
merged = dict(original_task)
|
||||
merged.update(updates)
|
||||
if "action" in body:
|
||||
merged["action"] = body["action"]
|
||||
temp_service = SchedulerService(store, lambda t: None)
|
||||
next_run = temp_service._calculate_next_run(merged, datetime.now())
|
||||
if next_run:
|
||||
updates["next_run_at"] = next_run.isoformat()
|
||||
else:
|
||||
# Cannot calculate next run time, schedule config may be invalid
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"message": "Cannot calculate next run time. Please check the schedule config (e.g., cron expression format, or whether the one-time task time has already passed)."
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# Update action
|
||||
if "action" in body:
|
||||
action = body["action"]
|
||||
channel_type = action.get("channel_type", "web")
|
||||
|
||||
# Get the task's original channel_type
|
||||
old_channel = original_task.get("action", {}).get("channel_type", "web")
|
||||
|
||||
# If channel type changed or no receiver, reject the update.
|
||||
# Note: the web UI disables the channel selector, so this branch
|
||||
# is only reachable via direct API calls. Changing a task's channel
|
||||
# after creation is not supported because the receiver identity is
|
||||
# channel-bound and cannot be trivially re-populated (e.g. weixin
|
||||
# requires a valid context_token tied to the original user-session).
|
||||
if old_channel and old_channel != channel_type:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"message": f"Cannot change channel type from '{old_channel}' to '{channel_type}'. Please create a new task on the target channel instead."
|
||||
}, ensure_ascii=False)
|
||||
if not action.get("receiver"):
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"message": "Receiver is required. Please create a new task through the chat interface."
|
||||
}, ensure_ascii=False)
|
||||
updates["action"] = action
|
||||
|
||||
# If schedule was not updated but action was, ensure next_run_at exists
|
||||
if "schedule" not in body and "next_run_at" not in original_task:
|
||||
merged = dict(original_task)
|
||||
merged.update(updates)
|
||||
temp_service = SchedulerService(store, lambda t: None)
|
||||
next_run = temp_service._calculate_next_run(merged, datetime.now())
|
||||
if next_run:
|
||||
updates["next_run_at"] = next_run.isoformat()
|
||||
|
||||
store.update_task(task_id, updates)
|
||||
task = store.get_task(task_id)
|
||||
return json.dumps({"status": "success", "task": task}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Scheduler update error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SchedulerDeleteHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
body = json.loads(web.data())
|
||||
task_id = body.get("task_id")
|
||||
if not task_id:
|
||||
return json.dumps({"status": "error", "message": "task_id required"})
|
||||
|
||||
from agent.tools.scheduler.task_store import TaskStore
|
||||
workspace_root = _get_workspace_root()
|
||||
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||
store = TaskStore(store_path)
|
||||
store.delete_task(task_id)
|
||||
return json.dumps({"status": "success"}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Scheduler delete error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SessionsHandler:
|
||||
def GET(self):
|
||||
_require_auth()
|
||||
@@ -4362,7 +4583,7 @@ class MessageDeleteHandler:
|
||||
# 2. Sync agent's in-memory context so its next turn sees the
|
||||
# same history as the DB. Handled by the agent_bridge helper.
|
||||
try:
|
||||
from bridge import Bridge
|
||||
from bridge.bridge import Bridge
|
||||
Bridge().get_agent_bridge().sync_session_messages_from_store(session_id)
|
||||
except Exception as sync_err:
|
||||
logger.warning(f"[WebChannel] Failed to sync agent memory: {sync_err}")
|
||||
|
||||
@@ -12,16 +12,19 @@ import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
import web
|
||||
import websocket
|
||||
|
||||
from bridge.context import Context, ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from channel.chat_channel import ChatChannel, check_prefix
|
||||
from channel.wecom_bot.wecom_bot_crypt import WecomBotCrypt
|
||||
from channel.wecom_bot.wecom_bot_message import WecomBotMessage
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
@@ -32,6 +35,9 @@ from config import conf
|
||||
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
|
||||
HEARTBEAT_INTERVAL = 30
|
||||
MEDIA_CHUNK_SIZE = 512 * 1024 # 512KB per chunk (before base64 encoding)
|
||||
# Fixed URL path for the callback (webhook) HTTP server. The bot's
|
||||
# receive-message URL must point at this path, e.g. http://host:9892/wecombot
|
||||
CALLBACK_PATH = "/wecombot"
|
||||
|
||||
|
||||
def _escape_control_chars_inside_json_strings(s: str) -> str:
|
||||
@@ -97,6 +103,14 @@ class WecomBotChannel(ChatChannel):
|
||||
self._pending_lock = threading.Lock()
|
||||
self._stream_states = {} # req_id -> {"stream_id": str, "content": str}
|
||||
|
||||
# Transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
|
||||
self.mode = "websocket"
|
||||
self._crypt = None
|
||||
self._http_server = None
|
||||
# stream_id -> {"committed", "current", "finished", "images", "last_access"}
|
||||
self._callback_streams = ExpiredDict(60 * 10) # auto-expire after 10min (max poll window is 6min)
|
||||
self._callback_lock = threading.Lock()
|
||||
|
||||
conf()["group_name_white_list"] = ["ALL_GROUP"]
|
||||
conf()["single_chat_prefix"] = [""]
|
||||
|
||||
@@ -105,6 +119,11 @@ class WecomBotChannel(ChatChannel):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def startup(self):
|
||||
self.mode = conf().get("wecom_bot_mode", "websocket")
|
||||
if self.mode == "webhook":
|
||||
self._startup_callback()
|
||||
return
|
||||
|
||||
self.bot_id = conf().get("wecom_bot_id", "")
|
||||
self.bot_secret = conf().get("wecom_bot_secret", "")
|
||||
|
||||
@@ -127,6 +146,13 @@ class WecomBotChannel(ChatChannel):
|
||||
pass
|
||||
self._ws = None
|
||||
self._connected = False
|
||||
if self._http_server:
|
||||
try:
|
||||
self._http_server.stop()
|
||||
logger.info("[WecomBot] Callback HTTP server stopped")
|
||||
except Exception as e:
|
||||
logger.warning(f"[WecomBot] Error stopping HTTP server: {e}")
|
||||
self._http_server = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket connection
|
||||
@@ -183,6 +209,192 @@ class WecomBotChannel(ChatChannel):
|
||||
def _gen_req_id(self) -> str:
|
||||
return uuid.uuid4().hex[:16]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Callback (webhook) mode
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _startup_callback(self):
|
||||
"""Start an HTTP server that receives encrypted callbacks (webhook mode).
|
||||
|
||||
The bot's "接收消息" URL in the WeCom admin console should point at this
|
||||
server (any path is accepted). Verification (GET) and message delivery
|
||||
(POST) are both handled by ``WecomBotCallbackController``.
|
||||
"""
|
||||
token = conf().get("wecom_bot_token", "")
|
||||
aes_key = conf().get("wecom_bot_encoding_aes_key", "")
|
||||
if not token or not aes_key:
|
||||
err = "[WecomBot] callback mode requires wecom_bot_token and wecom_bot_encoding_aes_key"
|
||||
logger.error(err)
|
||||
self.report_startup_error(err)
|
||||
return
|
||||
|
||||
try:
|
||||
# Enterprise-internal smart bot: receive_id is an empty string.
|
||||
self._crypt = WecomBotCrypt(token, aes_key, "")
|
||||
except Exception as e:
|
||||
err = f"[WecomBot] invalid callback credentials: {e}"
|
||||
logger.error(err)
|
||||
self.report_startup_error(err)
|
||||
return
|
||||
|
||||
port = int(conf().get("wecom_bot_port", 9892))
|
||||
logger.info(f"[WecomBot] Starting callback (webhook) server on port {port}, path {CALLBACK_PATH} ...")
|
||||
# Only serve the fixed callback path; everything else 404s instead of being
|
||||
# treated as a (signature-failing) WeCom callback.
|
||||
urls = (re.escape(CALLBACK_PATH), "channel.wecom_bot.wecom_bot_channel.WecomBotCallbackController")
|
||||
app = web.application(urls, globals(), autoreload=False)
|
||||
func = web.httpserver.StaticMiddleware(app.wsgifunc())
|
||||
func = web.httpserver.LogMiddleware(func)
|
||||
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
|
||||
self._http_server = server
|
||||
self.report_startup_success()
|
||||
try:
|
||||
server.start()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
server.stop()
|
||||
|
||||
def _new_callback_stream(self, response_url: str = "") -> str:
|
||||
"""Create a new stream state and return its id."""
|
||||
stream_id = uuid.uuid4().hex[:16]
|
||||
now = time.time()
|
||||
with self._callback_lock:
|
||||
self._callback_streams[stream_id] = {
|
||||
"committed": "",
|
||||
"current": "",
|
||||
"finished": False,
|
||||
"images": [], # list of (base64_str, md5_str), flushed only at finish
|
||||
"image_urls": [], # public http(s) image urls (usable in response_url markdown)
|
||||
"image_pending": False, # an image reply is being prepared; don't finish on text yet
|
||||
"last_access": now,
|
||||
"created_at": now,
|
||||
"response_url": response_url or "",
|
||||
"delivered": False, # final answer handed to WeCom via a poll
|
||||
"url_sent": False, # final answer pushed via response_url (active reply)
|
||||
}
|
||||
return stream_id
|
||||
|
||||
def _callback_handle_message(self, data: dict) -> dict:
|
||||
"""Handle a freshly-received user message in callback mode.
|
||||
|
||||
Produces the context for async processing and returns the initial passive
|
||||
reply (a stream packet with finish=false) so WeCom starts polling for the
|
||||
agent's streamed answer. Returns ``None`` when there's nothing to reply
|
||||
(e.g. an image/file silently cached for the next query).
|
||||
"""
|
||||
msg_id = data.get("msgid", "")
|
||||
if msg_id and self.received_msgs.get(msg_id):
|
||||
logger.debug(f"[WecomBot] Duplicate msg filtered: {msg_id}")
|
||||
return None
|
||||
if msg_id:
|
||||
self.received_msgs[msg_id] = True
|
||||
|
||||
chattype = data.get("chattype", "single")
|
||||
is_group = chattype == "group"
|
||||
|
||||
default_aeskey = conf().get("wecom_bot_encoding_aes_key", "")
|
||||
result = self._build_context(data, is_group, default_aeskey=default_aeskey)
|
||||
if not result:
|
||||
return None
|
||||
context, wecom_msg = result
|
||||
|
||||
# response_url lets us actively reply once within 1h, used as a fallback
|
||||
# when the agent finishes after WeCom stops polling (max ~6min window).
|
||||
response_url = data.get("response_url", "") or ""
|
||||
stream_id = self._new_callback_stream(response_url=response_url)
|
||||
wecom_msg.stream_id = stream_id
|
||||
context["wecom_stream_id"] = stream_id
|
||||
context["on_event"] = self._make_callback_stream_callback(stream_id)
|
||||
self.produce(context)
|
||||
|
||||
# First passive reply: register the stream id, WeCom will poll for updates.
|
||||
return {
|
||||
"msgtype": "stream",
|
||||
"stream": {"id": stream_id, "finish": False, "content": ""},
|
||||
}
|
||||
|
||||
def _callback_handle_stream_poll(self, data: dict) -> dict:
|
||||
"""Handle a "流式消息刷新" poll: return the latest accumulated content."""
|
||||
stream_id = data.get("stream", {}).get("id", "")
|
||||
with self._callback_lock:
|
||||
state = self._callback_streams.get(stream_id)
|
||||
if state is None:
|
||||
# Unknown / expired stream: tell WeCom we're done to stop polling.
|
||||
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
|
||||
state["last_access"] = time.time()
|
||||
if state.get("url_sent"):
|
||||
# Final answer already pushed via response_url; finish silently.
|
||||
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
|
||||
# We never force-finish on a timer: while a task is still running the
|
||||
# bubble should keep spinning until either the task finishes or the
|
||||
# user cancels. If WeCom's 6min window closes before completion, the
|
||||
# answer is delivered later via response_url instead.
|
||||
finished = state["finished"]
|
||||
content = state["committed"] + state["current"]
|
||||
images = state["images"] if finished else []
|
||||
if finished:
|
||||
state["delivered"] = True
|
||||
logger.debug(f"[WecomBot] stream {stream_id} delivered via poll, len={len(content)}, images={len(images)}")
|
||||
|
||||
stream = {"id": stream_id, "finish": finished, "content": content}
|
||||
if images:
|
||||
stream["msg_item"] = [
|
||||
{"msgtype": "image", "image": {"base64": b64, "md5": md5}}
|
||||
for (b64, md5) in images
|
||||
]
|
||||
return {"msgtype": "stream", "stream": stream}
|
||||
|
||||
def _make_callback_stream_callback(self, stream_id: str):
|
||||
"""Build an on_event callback that accumulates agent output into stream state.
|
||||
|
||||
Mirrors the websocket streaming behaviour: intermediate turns (text before
|
||||
a tool call) are committed with a '---' separator; WeCom reads the full
|
||||
accumulated content on each poll.
|
||||
"""
|
||||
def on_event(event: dict):
|
||||
event_type = event.get("type")
|
||||
edata = event.get("data", {})
|
||||
cancelled = False
|
||||
with self._callback_lock:
|
||||
state = self._callback_streams.get(stream_id)
|
||||
if not state:
|
||||
return
|
||||
|
||||
if event_type == "turn_start":
|
||||
state["current"] = ""
|
||||
elif event_type == "message_update":
|
||||
delta = edata.get("delta", "")
|
||||
if delta:
|
||||
state["current"] += delta
|
||||
elif event_type == "message_end":
|
||||
tool_calls = edata.get("tool_calls", [])
|
||||
if tool_calls:
|
||||
if state["current"].strip():
|
||||
state["committed"] += state["current"].strip() + "\n\n---\n\n"
|
||||
state["current"] = ""
|
||||
else:
|
||||
state["committed"] += state["current"]
|
||||
state["current"] = ""
|
||||
elif event_type == "agent_cancelled":
|
||||
# Mechanism 1: a cancelled run never reaches send(), so finalize
|
||||
# its stream here to stop the "···" bubble immediately.
|
||||
if state["current"]:
|
||||
state["committed"] += state["current"]
|
||||
state["current"] = ""
|
||||
state["committed"] = state["committed"].rstrip()
|
||||
if state["committed"].endswith("---"):
|
||||
state["committed"] = state["committed"][:-3].rstrip()
|
||||
if not state["committed"].strip():
|
||||
state["committed"] = "🛑 已中止"
|
||||
state["finished"] = True
|
||||
state["last_access"] = time.time()
|
||||
cancelled = True
|
||||
|
||||
if cancelled:
|
||||
# Outside the lock: response_url fallback re-acquires it.
|
||||
self._schedule_response_url_fallback(stream_id)
|
||||
|
||||
return on_event
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subscribe & heartbeat
|
||||
# ------------------------------------------------------------------
|
||||
@@ -287,16 +499,31 @@ class WecomBotChannel(ChatChannel):
|
||||
chattype = body.get("chattype", "single")
|
||||
is_group = chattype == "group"
|
||||
|
||||
result = self._build_context(body, is_group)
|
||||
if not result:
|
||||
return
|
||||
context, wecom_msg = result
|
||||
wecom_msg.req_id = req_id
|
||||
if req_id:
|
||||
context["on_event"] = self._make_stream_callback(req_id)
|
||||
self.produce(context)
|
||||
|
||||
def _build_context(self, body: dict, is_group: bool, default_aeskey: str = ""):
|
||||
"""Parse a wecom message body into a Context, applying file-cache logic.
|
||||
|
||||
Shared by both the websocket (long-connection) and callback (webhook)
|
||||
receive paths. Returns ``(context, wecom_msg)`` when the message should be
|
||||
handed to the agent, or ``None`` when it was consumed (cached image/file,
|
||||
parse failure, etc.).
|
||||
"""
|
||||
try:
|
||||
wecom_msg = WecomBotMessage(body, is_group=is_group)
|
||||
wecom_msg = WecomBotMessage(body, is_group=is_group, default_aeskey=default_aeskey)
|
||||
except NotImplementedError as e:
|
||||
logger.warning(f"[WecomBot] {e}")
|
||||
return
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
wecom_msg.req_id = req_id
|
||||
return None
|
||||
|
||||
# File cache logic (same pattern as feishu)
|
||||
from channel.file_cache import get_file_cache
|
||||
@@ -314,13 +541,13 @@ class WecomBotChannel(ChatChannel):
|
||||
if hasattr(wecom_msg, "image_path") and wecom_msg.image_path:
|
||||
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
|
||||
logger.info(f"[WecomBot] Image cached for session {session_id}")
|
||||
return
|
||||
return None
|
||||
|
||||
if wecom_msg.ctype == ContextType.FILE:
|
||||
wecom_msg.prepare()
|
||||
file_cache.add(session_id, wecom_msg.content, file_type="file")
|
||||
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
|
||||
return
|
||||
return None
|
||||
|
||||
if wecom_msg.ctype == ContextType.TEXT:
|
||||
cached_files = file_cache.get(session_id)
|
||||
@@ -346,10 +573,9 @@ class WecomBotChannel(ChatChannel):
|
||||
msg=wecom_msg,
|
||||
no_need_at=True,
|
||||
)
|
||||
if context:
|
||||
if req_id:
|
||||
context["on_event"] = self._make_stream_callback(req_id)
|
||||
self.produce(context)
|
||||
if not context:
|
||||
return None
|
||||
return context, wecom_msg
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event callback
|
||||
@@ -490,11 +716,233 @@ class WecomBotChannel(ChatChannel):
|
||||
|
||||
return context
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Callback (webhook) send: write the final reply into the stream state
|
||||
# so the next "流式消息刷新" poll returns it with finish=true.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _callback_send(self, reply: Reply, context: Context):
|
||||
msg = context.get("msg")
|
||||
stream_id = getattr(msg, "stream_id", None) if msg else None
|
||||
if not stream_id:
|
||||
stream_id = context.get("wecom_stream_id")
|
||||
if not stream_id:
|
||||
logger.warning("[WecomBot] callback send without stream_id, dropping reply")
|
||||
return
|
||||
|
||||
if reply.type == ReplyType.TEXT:
|
||||
self._callback_finalize_text(stream_id, reply.content)
|
||||
elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
|
||||
self._callback_finalize_image(stream_id, reply.content)
|
||||
elif reply.type == ReplyType.FILE:
|
||||
# Passive callback replies only support text + image (base64); files
|
||||
# are not supported by the protocol, so append a notice to whatever
|
||||
# text the agent already streamed (do not drop it).
|
||||
text = getattr(reply, "text_content", "") or ""
|
||||
note = (text + "\n\n" if text else "") + "(文件无法在企微回调模式下直接发送)"
|
||||
self._callback_finalize_text(stream_id, note, append=True)
|
||||
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL, ReplyType.VOICE):
|
||||
logger.warning(f"[WecomBot] reply type {reply.type} not supported in callback mode")
|
||||
text = getattr(reply, "text_content", "") or ""
|
||||
note = (text + "\n\n" if text else "") + "(该消息类型无法在企微回调模式下直接发送)"
|
||||
self._callback_finalize_text(stream_id, note, append=True)
|
||||
else:
|
||||
self._callback_finalize_text(stream_id, str(reply.content))
|
||||
|
||||
def _callback_get_or_create_state(self, stream_id: str) -> dict:
|
||||
state = self._callback_streams.get(stream_id)
|
||||
if state is None:
|
||||
now = time.time()
|
||||
state = {
|
||||
"committed": "",
|
||||
"current": "",
|
||||
"finished": False,
|
||||
"images": [],
|
||||
"image_urls": [],
|
||||
"image_pending": False,
|
||||
"last_access": now,
|
||||
"created_at": now,
|
||||
"response_url": "",
|
||||
"delivered": False,
|
||||
"url_sent": False,
|
||||
}
|
||||
self._callback_streams[stream_id] = state
|
||||
return state
|
||||
|
||||
def _callback_finalize_text(self, stream_id: str, content: str, append: bool = False):
|
||||
with self._callback_lock:
|
||||
state = self._callback_get_or_create_state(stream_id)
|
||||
accumulated = (state["committed"] + state["current"]).strip()
|
||||
if append and accumulated:
|
||||
state["committed"] = (accumulated + "\n\n" + (content or "")).strip()
|
||||
else:
|
||||
state["committed"] = accumulated if accumulated else (content or "")
|
||||
state["current"] = ""
|
||||
state["last_access"] = time.time()
|
||||
# Don't finish synchronously: chat_channel splits an image-with-caption
|
||||
# reply into a TEXT send followed (0.3s later) by the IMAGE send. If the
|
||||
# text finished the stream immediately, WeCom would close it before the
|
||||
# image arrives. Defer the finish so a trailing image can merge in.
|
||||
self._schedule_text_finish(stream_id)
|
||||
|
||||
def _schedule_text_finish(self, stream_id: str, delay: float = 1.2):
|
||||
def _run():
|
||||
time.sleep(delay)
|
||||
with self._callback_lock:
|
||||
state = self._callback_streams.get(stream_id)
|
||||
if not state or state["finished"] or state.get("image_pending"):
|
||||
return # already finished, or an image reply is on its way
|
||||
state["finished"] = True
|
||||
state["last_access"] = time.time()
|
||||
self._schedule_response_url_fallback(stream_id)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name=f"wecom-textfin-{stream_id}").start()
|
||||
|
||||
def _callback_finalize_image(self, stream_id: str, img_path_or_url: str):
|
||||
# Mark the image as pending up front (before the slow load/compress) so a
|
||||
# preceding text finalize won't close the stream while we work.
|
||||
with self._callback_lock:
|
||||
self._callback_get_or_create_state(stream_id)["image_pending"] = True
|
||||
b64md5 = self._load_image_base64(img_path_or_url)
|
||||
with self._callback_lock:
|
||||
state = self._callback_get_or_create_state(stream_id)
|
||||
accumulated = (state["committed"] + state["current"]).strip()
|
||||
state["current"] = ""
|
||||
if b64md5:
|
||||
state["images"].append(b64md5)
|
||||
state["committed"] = accumulated
|
||||
# Remember the public url (if any) so the response_url fallback
|
||||
# can embed it as markdown when the poll window has closed.
|
||||
if img_path_or_url.startswith(("http://", "https://")):
|
||||
state["image_urls"].append(img_path_or_url)
|
||||
else:
|
||||
state["committed"] = accumulated or "[图片发送失败]"
|
||||
state["finished"] = True
|
||||
state["image_pending"] = False
|
||||
state["last_access"] = time.time()
|
||||
self._schedule_response_url_fallback(stream_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Active reply fallback (response_url): rescue replies that finish after
|
||||
# WeCom stops polling (the passive stream window is ~6 min from the user's
|
||||
# message). A short delay lets an in-flight poll deliver first; only if no
|
||||
# poll picks up the finished answer do we push it actively via response_url.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _schedule_response_url_fallback(self, stream_id: str, delay: float = 3.0):
|
||||
def _run():
|
||||
time.sleep(delay)
|
||||
with self._callback_lock:
|
||||
state = self._callback_streams.get(stream_id)
|
||||
if not state:
|
||||
return
|
||||
if state.get("delivered") or state.get("url_sent"):
|
||||
return # a poll already delivered (or fallback already ran)
|
||||
response_url = state.get("response_url") or ""
|
||||
if not response_url:
|
||||
logger.warning(
|
||||
f"[WecomBot] stream {stream_id} finished after poll window but no response_url; reply dropped"
|
||||
)
|
||||
return
|
||||
content = (state["committed"] + state["current"]).strip()
|
||||
image_urls = list(state.get("image_urls") or [])
|
||||
has_images = bool(state.get("images"))
|
||||
state["url_sent"] = True
|
||||
|
||||
self._send_via_response_url(stream_id, response_url, content, image_urls, has_images)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name=f"wecom-respurl-{stream_id}").start()
|
||||
|
||||
def _send_via_response_url(self, stream_id, response_url, content, image_urls, has_images):
|
||||
"""Push a one-shot active markdown reply to response_url (valid 1h, single use)."""
|
||||
md = content or ""
|
||||
if image_urls:
|
||||
md += ("\n\n" if md else "") + "\n".join(f"" for u in image_urls)
|
||||
elif has_images:
|
||||
md += ("\n\n" if md else "") + "(图片已生成,但因处理超时无法通过回调发送)"
|
||||
if not md:
|
||||
md = "(处理完成)"
|
||||
payload = {"msgtype": "markdown", "markdown": {"content": md}}
|
||||
try:
|
||||
resp = requests.post(response_url, json=payload, timeout=15)
|
||||
logger.info(
|
||||
f"[WecomBot] response_url active reply sent for {stream_id}: "
|
||||
f"status={resp.status_code}, body={resp.text[:200]}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] response_url active reply failed for {stream_id}: {e}")
|
||||
|
||||
def _load_image_base64(self, img_path_or_url: str):
|
||||
"""Load a local/remote image, ensure JPG/PNG within 10MB, return (base64, md5)."""
|
||||
local_path = img_path_or_url
|
||||
if local_path.startswith("file://"):
|
||||
local_path = local_path[7:]
|
||||
|
||||
# Temp files we create here (downloads/conversions/compressions) must be
|
||||
# cleaned up afterwards; the caller's original local file must not be.
|
||||
temp_files = []
|
||||
try:
|
||||
if local_path.startswith(("http://", "https://")):
|
||||
try:
|
||||
resp = requests.get(local_path, timeout=30)
|
||||
resp.raise_for_status()
|
||||
tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}"
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
temp_files.append(tmp_path)
|
||||
local_path = tmp_path
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] Failed to download image for callback reply: {e}")
|
||||
return None
|
||||
|
||||
if not os.path.exists(local_path):
|
||||
logger.error(f"[WecomBot] Image file not found: {local_path}")
|
||||
return None
|
||||
|
||||
formatted = self._ensure_image_format(local_path)
|
||||
if not formatted:
|
||||
return None
|
||||
if formatted != local_path:
|
||||
temp_files.append(formatted)
|
||||
local_path = formatted
|
||||
|
||||
# Unlike the long-connection path (which uploads and sends only a tiny
|
||||
# media_id), the callback reply embeds the whole image as base64 inside
|
||||
# an AES-encrypted body that is returned on EVERY poll. Empirically a
|
||||
# ~1.5MB image (base64 ~2.1MB, encrypted ~2.8MB) makes WeCom reject the
|
||||
# finish packet and poll forever, so cap well below that.
|
||||
callback_max_size = 512 * 1024
|
||||
if os.path.getsize(local_path) > callback_max_size:
|
||||
compressed = self._compress_image(local_path, callback_max_size)
|
||||
if compressed:
|
||||
temp_files.append(compressed)
|
||||
local_path = compressed
|
||||
else:
|
||||
logger.warning("[WecomBot] callback image compress failed; sending original (may be rejected)")
|
||||
|
||||
try:
|
||||
with open(local_path, "rb") as f:
|
||||
raw = f.read()
|
||||
return base64.b64encode(raw).decode("utf-8"), hashlib.md5(raw).hexdigest()
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] Failed to read image for callback reply: {e}")
|
||||
return None
|
||||
finally:
|
||||
for path in temp_files:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Send reply
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def send(self, reply: Reply, context: Context):
|
||||
if self.mode == "webhook":
|
||||
self._callback_send(reply, context)
|
||||
return
|
||||
|
||||
msg = context.get("msg")
|
||||
is_group = context.get("isgroup", False)
|
||||
receiver = context.get("receiver", "")
|
||||
@@ -906,3 +1354,85 @@ class WecomBotChannel(ChatChannel):
|
||||
else:
|
||||
logger.error("[WecomBot] Failed to get media_id from finish response")
|
||||
return media_id
|
||||
|
||||
|
||||
class WecomBotCallbackController:
|
||||
"""HTTP controller for wecom bot callback (webhook) mode.
|
||||
|
||||
- GET : URL verification (echo the decrypted echostr).
|
||||
- POST : encrypted message / stream-refresh / event callbacks; returns an
|
||||
encrypted passive reply (or "success" for an empty reply).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _channel() -> "WecomBotChannel":
|
||||
return WecomBotChannel()
|
||||
|
||||
def GET(self):
|
||||
channel = self._channel()
|
||||
params = web.input(msg_signature="", timestamp="", nonce="", echostr="")
|
||||
if not channel._crypt:
|
||||
return "wecom bot callback not ready"
|
||||
ret, echo = channel._crypt.verify_url(
|
||||
params.msg_signature, params.timestamp, params.nonce, params.echostr
|
||||
)
|
||||
if ret != 0:
|
||||
logger.error(f"[WecomBot] URL verify failed: ret={ret}")
|
||||
return "verify fail"
|
||||
if isinstance(echo, bytes):
|
||||
echo = echo.decode("utf-8")
|
||||
return echo
|
||||
|
||||
def POST(self):
|
||||
channel = self._channel()
|
||||
if not channel._crypt:
|
||||
return "success"
|
||||
|
||||
params = web.input(msg_signature="", timestamp="", nonce="")
|
||||
body = web.data()
|
||||
ret, plain = channel._crypt.decrypt_msg(
|
||||
body, params.msg_signature, params.timestamp, params.nonce
|
||||
)
|
||||
if ret != 0:
|
||||
logger.error(f"[WecomBot] callback decrypt failed: ret={ret}")
|
||||
return "success"
|
||||
|
||||
try:
|
||||
data = json.loads(plain)
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] callback json parse failed: {e}")
|
||||
return "success"
|
||||
|
||||
msgtype = data.get("msgtype", "")
|
||||
# Stream polls arrive ~1/s; logging each is noisy, so only log non-poll
|
||||
# callbacks here (poll completion is logged in the stream-poll handler).
|
||||
if msgtype != "stream":
|
||||
logger.debug(f"[WecomBot] callback received msgtype={msgtype}")
|
||||
|
||||
try:
|
||||
if msgtype == "stream":
|
||||
reply = channel._callback_handle_stream_poll(data)
|
||||
elif msgtype == "event":
|
||||
event_type = data.get("event", {}).get("eventtype", "")
|
||||
logger.info(f"[WecomBot] callback event: {event_type}")
|
||||
reply = None
|
||||
elif msgtype in ("text", "image", "voice", "file", "video", "mixed"):
|
||||
reply = channel._callback_handle_message(data)
|
||||
else:
|
||||
logger.warning(f"[WecomBot] unsupported callback msgtype: {msgtype}")
|
||||
reply = None
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] callback handling error: {e}", exc_info=True)
|
||||
reply = None
|
||||
|
||||
if not reply:
|
||||
# Empty reply package is acceptable.
|
||||
return "success"
|
||||
|
||||
plain_reply = json.dumps(reply, ensure_ascii=False)
|
||||
ret, enc = channel._crypt.encrypt_msg(plain_reply, params.nonce, params.timestamp)
|
||||
if ret != 0:
|
||||
logger.error(f"[WecomBot] callback encrypt failed: ret={ret}")
|
||||
return "success"
|
||||
web.header("Content-Type", "application/json; charset=utf-8")
|
||||
return json.dumps(enc, ensure_ascii=False)
|
||||
|
||||
203
channel/wecom_bot/wecom_bot_crypt.py
Normal file
203
channel/wecom_bot/wecom_bot_crypt.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
WeCom (企业微信) smart-bot callback message encryption/decryption.
|
||||
|
||||
Adapted from the official `WXBizJsonMsgCrypt` sample (JSON variant) used by the
|
||||
AI bot callback (webhook) mode. The bot's receive-message callback delivers
|
||||
AES-256-CBC encrypted JSON payloads, and passive replies must be encrypted the
|
||||
same way before being returned in the HTTP response.
|
||||
|
||||
For an enterprise-internal smart bot, ``receive_id`` is always an empty string.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from common.log import logger
|
||||
|
||||
# Error codes (mirrors the official ierror.py)
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseJson_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnJson_Error = -40011
|
||||
|
||||
|
||||
class FormatException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _gen_sha1(token, timestamp, nonce, encrypt):
|
||||
"""Compute the WeCom message signature with SHA1 over the sorted parts."""
|
||||
try:
|
||||
if isinstance(encrypt, bytes):
|
||||
encrypt = encrypt.decode("utf-8")
|
||||
sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist).encode("utf-8"))
|
||||
return WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] compute signature error: {e}")
|
||||
return WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class _PKCS7Encoder:
|
||||
"""PKCS#7 padding with a 32-byte block size (AES-256)."""
|
||||
|
||||
block_size = 32
|
||||
|
||||
def encode(self, text: bytes) -> bytes:
|
||||
text_length = len(text)
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
pad = bytes([amount_to_pad])
|
||||
return text + pad * amount_to_pad
|
||||
|
||||
def decode(self, decrypted: bytes) -> bytes:
|
||||
pad = decrypted[-1]
|
||||
if pad < 1 or pad > 32:
|
||||
pad = 0
|
||||
return decrypted[:-pad] if pad else decrypted
|
||||
|
||||
|
||||
class _Prpcrypt:
|
||||
"""AES-256-CBC encrypt/decrypt for WeCom callback messages."""
|
||||
|
||||
def __init__(self, key: bytes):
|
||||
self.key = key
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
def encrypt(self, text: str, receive_id: str):
|
||||
text_bytes = text.encode()
|
||||
# 16-byte random prefix + network-order length + body + receive_id
|
||||
text_bytes = (
|
||||
self._get_random_str()
|
||||
+ struct.pack("I", socket.htonl(len(text_bytes)))
|
||||
+ text_bytes
|
||||
+ receive_id.encode()
|
||||
)
|
||||
text_bytes = _PKCS7Encoder().encode(text_bytes)
|
||||
try:
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
ciphertext = cryptor.encrypt(text_bytes)
|
||||
return WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] AES encrypt error: {e}")
|
||||
return WXBizMsgCrypt_EncryptAES_Error, None
|
||||
|
||||
def decrypt(self, text, receive_id: str):
|
||||
try:
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] AES decrypt error: {e}")
|
||||
return WXBizMsgCrypt_DecryptAES_Error, None
|
||||
try:
|
||||
pad = plain_text[-1]
|
||||
content = plain_text[16:-pad]
|
||||
json_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||
json_content = content[4 : json_len + 4].decode("utf-8")
|
||||
from_receive_id = content[json_len + 4 :].decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] illegal buffer when decrypting: {e}")
|
||||
return WXBizMsgCrypt_IllegalBuffer, None
|
||||
if from_receive_id != receive_id:
|
||||
logger.error(
|
||||
f"[WecomBot] receive_id not match: expect={receive_id}, got={from_receive_id}"
|
||||
)
|
||||
return WXBizMsgCrypt_ValidateCorpid_Error, None
|
||||
return WXBizMsgCrypt_OK, json_content
|
||||
|
||||
@staticmethod
|
||||
def _get_random_str() -> bytes:
|
||||
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||
|
||||
|
||||
class WecomBotCrypt:
|
||||
"""High-level helper for verifying URLs and (de)crypting callback messages."""
|
||||
|
||||
def __init__(self, token: str, encoding_aes_key: str, receive_id: str = ""):
|
||||
try:
|
||||
self.key = base64.b64decode(encoding_aes_key + "=")
|
||||
assert len(self.key) == 32
|
||||
except Exception:
|
||||
raise FormatException("[WecomBot] invalid EncodingAESKey")
|
||||
self.token = token
|
||||
self.receive_id = receive_id
|
||||
|
||||
def verify_url(self, msg_signature, timestamp, nonce, echostr):
|
||||
ret, signature = _gen_sha1(self.token, timestamp, nonce, echostr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if signature != msg_signature:
|
||||
return WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = _Prpcrypt(self.key)
|
||||
return pc.decrypt(echostr, self.receive_id)
|
||||
|
||||
def encrypt_msg(self, reply_msg: str, nonce: str, timestamp: str = None):
|
||||
"""Encrypt a passive-reply JSON string and return the full response JSON.
|
||||
|
||||
Returns (ret, response_dict). On success ret==0 and response_dict is a
|
||||
dict with encrypt/msgsignature/timestamp/nonce fields.
|
||||
"""
|
||||
pc = _Prpcrypt(self.key)
|
||||
ret, encrypt = pc.encrypt(reply_msg, self.receive_id)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
encrypt = encrypt.decode("utf-8")
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
return WXBizMsgCrypt_OK, {
|
||||
"encrypt": encrypt,
|
||||
"msgsignature": signature,
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
}
|
||||
|
||||
def decrypt_msg(self, post_data, msg_signature, timestamp, nonce):
|
||||
"""Verify signature and decrypt the encrypted callback payload.
|
||||
|
||||
``post_data`` may be the raw request body (bytes/str) containing
|
||||
``{"encrypt": "..."}`` or the already-extracted encrypt string.
|
||||
Returns (ret, plaintext_json_str).
|
||||
"""
|
||||
import json
|
||||
|
||||
encrypt = None
|
||||
if isinstance(post_data, (bytes, bytearray)):
|
||||
post_data = post_data.decode("utf-8")
|
||||
if isinstance(post_data, str):
|
||||
try:
|
||||
encrypt = json.loads(post_data).get("encrypt")
|
||||
except Exception:
|
||||
encrypt = post_data
|
||||
elif isinstance(post_data, dict):
|
||||
encrypt = post_data.get("encrypt")
|
||||
if not encrypt:
|
||||
return WXBizMsgCrypt_ParseJson_Error, None
|
||||
|
||||
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if signature != msg_signature:
|
||||
logger.error("[WecomBot] callback signature not match")
|
||||
return WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = _Prpcrypt(self.key)
|
||||
return pc.decrypt(encrypt, self.receive_id)
|
||||
@@ -87,11 +87,14 @@ def _get_tmp_dir() -> str:
|
||||
class WecomBotMessage(ChatMessage):
|
||||
"""Message wrapper for wecom bot (websocket long-connection mode)."""
|
||||
|
||||
def __init__(self, msg_body: dict, is_group: bool = False):
|
||||
def __init__(self, msg_body: dict, is_group: bool = False, default_aeskey: str = ""):
|
||||
super().__init__(msg_body)
|
||||
self.msg_id = msg_body.get("msgid")
|
||||
self.create_time = msg_body.get("create_time")
|
||||
self.is_group = is_group
|
||||
# In callback (webhook) mode the media bodies carry no per-message aeskey;
|
||||
# the download url is encrypted with the bot's EncodingAESKey instead.
|
||||
self._default_aeskey = default_aeskey
|
||||
|
||||
msg_type = msg_body.get("msgtype")
|
||||
from_userid = msg_body.get("from", {}).get("userid", "")
|
||||
@@ -113,7 +116,7 @@ class WecomBotMessage(ChatMessage):
|
||||
self.ctype = ContextType.IMAGE
|
||||
image_info = msg_body.get("image", {})
|
||||
image_url = image_info.get("url", "")
|
||||
aeskey = image_info.get("aeskey", "")
|
||||
aeskey = image_info.get("aeskey", "") or self._default_aeskey
|
||||
tmp_dir = _get_tmp_dir()
|
||||
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
|
||||
|
||||
@@ -147,7 +150,7 @@ class WecomBotMessage(ChatMessage):
|
||||
elif item_type == "image":
|
||||
img_info = item.get("image", {})
|
||||
img_url = img_info.get("url", "")
|
||||
img_aeskey = img_info.get("aeskey", "")
|
||||
img_aeskey = img_info.get("aeskey", "") or self._default_aeskey
|
||||
img_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}_{idx}.png")
|
||||
try:
|
||||
img_data = _decrypt_media(img_url, img_aeskey)
|
||||
@@ -166,7 +169,7 @@ class WecomBotMessage(ChatMessage):
|
||||
self.ctype = ContextType.FILE
|
||||
file_info = msg_body.get("file", {})
|
||||
file_url = file_info.get("url", "")
|
||||
aeskey = file_info.get("aeskey", "")
|
||||
aeskey = file_info.get("aeskey", "") or self._default_aeskey
|
||||
tmp_dir = _get_tmp_dir()
|
||||
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
|
||||
self.content = base_path
|
||||
@@ -188,7 +191,7 @@ class WecomBotMessage(ChatMessage):
|
||||
self.ctype = ContextType.FILE
|
||||
video_info = msg_body.get("video", {})
|
||||
video_url = video_info.get("url", "")
|
||||
aeskey = video_info.get("aeskey", "")
|
||||
aeskey = video_info.get("aeskey", "") or self._default_aeskey
|
||||
tmp_dir = _get_tmp_dir()
|
||||
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user