feat(scheduler): add scheduled jobs with channel-aware delivery

Introduce persisted cron/interval jobs, gateway scheduler loop, worker turns,
and admin CRUD/edit UI. Route proactive reminders via the originating chat
channel (WhatsApp vs WeChat), harden Weixin outbound with durable queue and
PG-compatible polling, and fix chat UI to show each scheduled reminder separately.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-26 15:56:10 +08:00
parent 5336f8835f
commit 47813f6a94
45 changed files with 4956 additions and 43 deletions

View file

@ -1986,6 +1986,194 @@ def build_admin_router() -> APIRouter:
},
}
@router.get("/admin/api/scheduled-jobs/meta/targets")
def api_scheduled_jobs_meta_targets(
tenant_id: str = Query(default=""),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
from runtime.scheduler.session_resolver import resolve_weixin_binding
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
tid = str(tenant_id or ctx.get("tenant_id") or "default").strip()
_require_tenant_scope(ctx, tid)
aid = str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
known = store.list_whatsapp_known_groups(tenant_id=tid, account_id=aid)
weixin = resolve_weixin_binding(store, tenant_id=tid)
return {"ok": True, "whatsapp_groups": known, "weixin_binding": weixin, "whatsapp_account_id": aid}
@router.get("/admin/api/scheduled-jobs")
def api_scheduled_jobs_list(
status: str | None = Query(default=None),
limit: int = Query(default=100),
offset: int = Query(default=0),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
tenant_id = str(ctx.get("tenant_id") or "")
rows = store.scheduled_job_list(
tenant_id=tenant_id,
status=str(status or "").strip() or None,
limit=max(1, min(int(limit or 100), 300)),
offset=max(0, int(offset or 0)),
)
return {"ok": True, "items": [store.scheduled_job_to_dict(r) for r in rows]}
@router.get("/admin/api/scheduled-jobs/{job_id}")
def api_scheduled_jobs_get(
job_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
tenant_id = str(ctx.get("tenant_id") or "")
row = store.scheduled_job_get(job_id=str(job_id), tenant_id=tenant_id)
return {"ok": True, "job": None if not row else store.scheduled_job_to_dict(row)}
@router.post("/admin/api/scheduled-jobs")
def api_scheduled_jobs_create(
payload: dict[str, Any],
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
from runtime.scheduler.cron_service import build_default_delivery
from runtime.scheduler.expressions import normalize_schedule_kind
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
name = str(payload.get("name") or "").strip()
prompt_text = str(payload.get("prompt_text") or "").strip()
schedule_kind = normalize_schedule_kind(payload.get("schedule_kind") or "cron")
schedule_expr = str(payload.get("schedule_expr") or "").strip()
if not name or not prompt_text or not schedule_expr:
return {"ok": False, "error": "name, prompt_text, schedule_expr are required"}
delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else None
if delivery is None:
wa_chat = str((payload.get("whatsapp") or {}).get("chat_id") if isinstance(payload.get("whatsapp"), dict) else payload.get("whatsapp_chat_id") or "")
delivery = build_default_delivery(store=store, tenant_id=tenant_id, whatsapp_chat_id=wa_chat)
row = store.scheduled_job_create(
tenant_id=tenant_id,
name=name,
prompt_text=prompt_text,
schedule_kind=schedule_kind,
schedule_expr=schedule_expr,
timezone_name=str(payload.get("timezone") or "Asia/Shanghai"),
description=str(payload.get("description") or ""),
interaction_mode=normalize_interaction_mode(payload.get("interaction_mode") or "expert"),
specialist=normalize_requested_specialist(payload.get("specialist") or "generalist"),
lang=str(payload.get("lang") or "zh"),
delivery=delivery,
source_session_id=str(payload.get("source_session_id") or "").strip() or None,
created_by_user_id=str(ctx.get("user_id") or ""),
source="admin",
)
store.add_admin_audit_log(
actor_tenant_id=tenant_id,
actor_user_id=str(ctx.get("user_id") or ""),
action="scheduled_job_create",
target_type="scheduled_job",
target_id=row.id,
status="ok",
)
return {"ok": True, "job": store.scheduled_job_to_dict(row)}
@router.patch("/admin/api/scheduled-jobs/{job_id}")
def api_scheduled_jobs_patch(
job_id: str,
payload: dict[str, Any],
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
row = store.scheduled_job_update(tenant_id=tenant_id, job_id=str(job_id), patch=dict(payload or {}))
if not row:
return {"ok": False, "error": "job_not_found"}
store.add_admin_audit_log(
actor_tenant_id=tenant_id,
actor_user_id=str(ctx.get("user_id") or ""),
action="scheduled_job_update",
target_type="scheduled_job",
target_id=str(job_id),
status="ok",
)
return {"ok": True, "job": store.scheduled_job_to_dict(row)}
@router.post("/admin/api/scheduled-jobs/{job_id}/pause")
def api_scheduled_jobs_pause(
job_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=str(job_id), status="paused")
return {"ok": bool(ok)}
@router.post("/admin/api/scheduled-jobs/{job_id}/resume")
def api_scheduled_jobs_resume(
job_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=str(job_id), status="active")
return {"ok": bool(ok)}
@router.delete("/admin/api/scheduled-jobs/{job_id}")
def api_scheduled_jobs_delete(
job_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=str(job_id))
return {"ok": bool(ok)}
@router.post("/admin/api/scheduled-jobs/{job_id}/run-now")
def api_scheduled_jobs_run_now(
job_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
from runtime.scheduler.service import run_scheduled_job_now
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
out = run_scheduled_job_now(store, tenant_id=tenant_id, job_id=str(job_id))
return out
@router.get("/admin/api/scheduled-jobs/{job_id}/runs")
def api_scheduled_jobs_runs(
job_id: str,
limit: int = Query(default=50),
offset: int = Query(default=0),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
tenant_id = str(ctx.get("tenant_id") or "")
rows = store.scheduled_job_run_list(
job_id=str(job_id),
tenant_id=tenant_id,
limit=max(1, min(int(limit or 50), 200)),
offset=max(0, int(offset or 0)),
)
return {"ok": True, "items": [store.scheduled_job_run_to_dict(r) for r in rows]}
@router.get("/admin/api/replay/turn")
def api_replay_turn(
session_id: str,

View file

@ -5,6 +5,7 @@ const I18N = {
"nav.models": "模型管理",
"nav.apiGrants": "API 使用授权",
"nav.stack": "运行时",
"nav.scheduledJobs": "定时任务",
"nav.users": "用户管理",
"nav.workspacePaths": "工作区路径",
"nav.memory": "记忆",
@ -18,6 +19,41 @@ const I18N = {
"notice.noLogin": "v2 已启用登录鉴权:请仅在内网访问",
"action.refresh": "刷新",
"title.stack": "运行时",
"title.scheduledJobs": "定时任务",
"scheduledJobs.all": "全部",
"scheduledJobs.statusActive": "运行中",
"scheduledJobs.statusPaused": "已暂停",
"scheduledJobs.loading": "Loading…",
"scheduledJobs.count": "{count} 个任务",
"scheduledJobs.colName": "名称",
"scheduledJobs.colSchedule": "计划",
"scheduledJobs.colStatus": "状态",
"scheduledJobs.colNextRun": "下次运行",
"scheduledJobs.colLastRun": "上次运行",
"scheduledJobs.colSpecialist": "专家",
"scheduledJobs.colDelivery": "投递",
"scheduledJobs.colActions": "操作",
"scheduledJobs.menuTitle": "任务操作",
"scheduledJobs.viewRuns": "查看运行记录",
"scheduledJobs.pause": "暂停",
"scheduledJobs.resume": "恢复",
"scheduledJobs.runNow": "立即运行",
"scheduledJobs.delete": "删除",
"scheduledJobs.deleteConfirm": "确定删除该定时任务?",
"scheduledJobs.triggered": "已触发",
"scheduledJobs.createTitle": "新建任务",
"scheduledJobs.create": "创建",
"scheduledJobs.edit": "编辑",
"scheduledJobs.editTitle": "编辑任务",
"scheduledJobs.updated": "已保存",
"scheduledJobs.cancel": "取消",
"scheduledJobs.runHistory": "运行记录",
"scheduledJobs.runHistoryHint": "最近运行(自动刷新)",
"scheduledJobs.noRuns": "暂无运行记录",
"scheduledJobs.weixinFixed": "微信固定投递:{id}",
"scheduledJobs.weixinMissing": "未找到微信绑定(需管理员渠道身份)",
"scheduledJobs.deliveryWeixin": "微信",
"scheduledJobs.deliveryWhatsapp": "WhatsApp",
"title.users": "用户管理",
"title.workspacePaths": "工作区路径(按用户)",
"title.memory": "记忆",
@ -447,6 +483,7 @@ const I18N = {
"nav.models": "Models",
"nav.apiGrants": "API access",
"nav.stack": "Runtime",
"nav.scheduledJobs": "Scheduled Jobs",
"nav.users": "Users",
"nav.workspacePaths": "Workspace paths",
"nav.memory": "Memory",
@ -460,6 +497,41 @@ const I18N = {
"notice.noLogin": "v2 login enabled: internal network only",
"action.refresh": "Refresh",
"title.stack": "Runtime",
"title.scheduledJobs": "Scheduled Jobs",
"scheduledJobs.all": "All",
"scheduledJobs.statusActive": "active",
"scheduledJobs.statusPaused": "paused",
"scheduledJobs.loading": "Loading…",
"scheduledJobs.count": "{count} job(s)",
"scheduledJobs.colName": "name",
"scheduledJobs.colSchedule": "schedule",
"scheduledJobs.colStatus": "status",
"scheduledJobs.colNextRun": "next_run",
"scheduledJobs.colLastRun": "last_run",
"scheduledJobs.colSpecialist": "specialist",
"scheduledJobs.colDelivery": "delivery",
"scheduledJobs.colActions": "actions",
"scheduledJobs.menuTitle": "Job actions",
"scheduledJobs.viewRuns": "View runs",
"scheduledJobs.pause": "Pause",
"scheduledJobs.resume": "Resume",
"scheduledJobs.runNow": "Run now",
"scheduledJobs.delete": "Delete",
"scheduledJobs.deleteConfirm": "Delete this scheduled job?",
"scheduledJobs.triggered": "Triggered.",
"scheduledJobs.createTitle": "Create job",
"scheduledJobs.create": "Create",
"scheduledJobs.edit": "Edit",
"scheduledJobs.editTitle": "Edit job",
"scheduledJobs.updated": "Saved.",
"scheduledJobs.cancel": "Cancel",
"scheduledJobs.runHistory": "Run history",
"scheduledJobs.runHistoryHint": "Recent runs (auto refresh)",
"scheduledJobs.noRuns": "No runs yet",
"scheduledJobs.weixinFixed": "Weixin fixed target: {id}",
"scheduledJobs.weixinMissing": "Weixin binding not found (administrator channel identity required)",
"scheduledJobs.deliveryWeixin": "WeChat",
"scheduledJobs.deliveryWhatsapp": "WhatsApp",
"title.users": "Users",
"title.workspacePaths": "Workspace paths (per user)",
"title.memory": "Memory",
@ -9221,6 +9293,366 @@ async function renderSkills() {
]);
}
function formatScheduledJobDelivery(job) {
const d = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {};
const parts = [];
if (d.weixin && d.weixin.enabled) parts.push(t("scheduledJobs.deliveryWeixin"));
if (d.whatsapp && d.whatsapp.enabled) parts.push(t("scheduledJobs.deliveryWhatsapp"));
return parts.length ? parts.join(" + ") : "—";
}
function buildScheduledJobDeliveryPayload(job, waChatId) {
const existing = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {};
const wa = existing.whatsapp && typeof existing.whatsapp === "object" ? { ...existing.whatsapp } : {};
const wx =
existing.weixin && typeof existing.weixin === "object"
? { ...existing.weixin }
: { enabled: true, fixed: true };
const chat = String(waChatId || "").trim();
wa.enabled = Boolean(chat);
wa.chat_id = chat;
wa.target_type = chat.includes("@g.us") ? "group" : "direct";
if (wx.enabled === undefined) wx.enabled = true;
return { whatsapp: wa, weixin: wx };
}
async function renderScheduledJobs() {
let scheduledJobMenuEl = null;
let editingJob = null;
const canWrite = hasPermission("admin:runtime:write");
const closeScheduledJobMenu = () => {
if (scheduledJobMenuEl && scheduledJobMenuEl.parentNode) {
scheduledJobMenuEl.remove();
}
scheduledJobMenuEl = null;
};
const status = el("select", {}, [
el("option", { value: "", text: t("scheduledJobs.all") }),
el("option", { value: "active", text: t("scheduledJobs.statusActive") }),
el("option", { value: "paused", text: t("scheduledJobs.statusPaused") }),
]);
const tbody = el("tbody", {});
const runsBox = el("pre", { class: "code", style: "max-height:220px;overflow:auto;white-space:pre-wrap;" });
const msg = el("div", { class: "muted", text: "" });
const editModal = el("div", { class: "session-monitor-modal", style: "display:none;" });
const editTitle = el("div", { class: "card__title", text: t("scheduledJobs.editTitle") });
const editNameInput = el("input", { class: "input", placeholder: "name" });
const editKindInput = el("select", {}, [
el("option", { value: "cron", text: "cron" }),
el("option", { value: "once", text: "once" }),
el("option", { value: "interval", text: "interval" }),
]);
const editExprInput = el("input", { class: "input", placeholder: "schedule_expr (cron / ISO / seconds)" });
const editPromptInput = el("textarea", { class: "input", rows: "4", placeholder: "prompt_text" });
const editSpecialistInput = el("input", { class: "input", placeholder: "specialist" });
const editWaChatInput = el("input", { class: "input", placeholder: "whatsapp chat_id (optional)" });
const editWeixinInfo = el("div", { class: "muted", text: "" });
const closeEditModal = () => {
editingJob = null;
editModal.style.display = "none";
};
const openEditModal = (job) => {
if (!job || !job.id) return;
editingJob = job;
editTitle.textContent = `${t("scheduledJobs.editTitle")}: ${String(job.name || job.id)}`;
editNameInput.value = String(job.name || "");
editKindInput.value = String(job.schedule_kind || "cron");
editExprInput.value = String(job.schedule_expr || "");
editPromptInput.value = String(job.prompt_text || "");
editSpecialistInput.value = String(job.specialist || "generalist");
const wa = job.delivery && job.delivery.whatsapp ? job.delivery.whatsapp : {};
editWaChatInput.value = String(wa.chat_id || "");
const wx = job.delivery && job.delivery.weixin ? job.delivery.weixin : {};
editWeixinInfo.textContent = wx.external_user_id
? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) })
: weixinInfo.textContent || t("scheduledJobs.weixinMissing");
editModal.style.display = "flex";
setTimeout(() => editNameInput.focus(), 0);
};
editModal.addEventListener("click", (e) => {
if (e.target === editModal) closeEditModal();
});
const editSaveBtn = el("button", {
class: "btn btn--primary",
text: t("action.save"),
onclick: async () => {
if (!editingJob || !editingJob.id) return;
const name = String(editNameInput.value || "").trim();
const promptText = String(editPromptInput.value || "").trim();
const scheduleExpr = String(editExprInput.value || "").trim();
if (!name || !promptText || !scheduleExpr) {
msg.textContent = "name, prompt_text, schedule_expr are required";
return;
}
try {
await apiRequest("PATCH", `/admin/api/scheduled-jobs/${encodeURIComponent(String(editingJob.id))}`, {
name,
schedule_kind: editKindInput.value,
schedule_expr: scheduleExpr,
prompt_text: promptText,
specialist: String(editSpecialistInput.value || "generalist").trim() || "generalist",
delivery: buildScheduledJobDeliveryPayload(editingJob, editWaChatInput.value),
});
closeEditModal();
msg.textContent = t("scheduledJobs.updated");
await loadJobs();
} catch (e) {
msg.textContent = String(e && e.message ? e.message : e);
}
},
});
const editModalCard = el("div", { class: "card session-monitor-modal__card", style: "width:min(640px,96vw);" }, [
editTitle,
editWeixinInfo,
editNameInput,
el("div", { class: "row", style: "gap:8px;" }, [editKindInput, editExprInput]),
editPromptInput,
editSpecialistInput,
editWaChatInput,
el("div", { class: "row", style: "gap:8px;justify-content:flex-end;margin-top:10px;" }, [
el("button", { class: "btn", text: t("scheduledJobs.cancel"), onclick: closeEditModal }),
editSaveBtn,
]),
]);
editModal.appendChild(editModalCard);
async function loadLatestRuns(items) {
const jobs = Array.isArray(items) ? items : [];
if (!jobs.length) {
runsBox.textContent = t("scheduledJobs.noRuns");
return;
}
const withLast = jobs.filter((j) => String(j.last_run_at || "").trim());
const target = withLast.length ? withLast[0] : jobs[0];
if (!target || !target.id) {
runsBox.textContent = t("scheduledJobs.noRuns");
return;
}
const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(String(target.id))}/runs?limit=10`);
const runItems = runs.items || [];
runsBox.textContent = runItems.length
? JSON.stringify(runItems, null, 2)
: t("scheduledJobs.noRuns");
}
async function loadJobs() {
closeScheduledJobMenu();
msg.textContent = t("scheduledJobs.loading");
const st = String(status.value || "").trim();
const q = st ? `?status=${encodeURIComponent(st)}` : "";
const resp = await apiGet(`/admin/api/scheduled-jobs${q}`);
tbody.replaceChildren();
for (const job of resp.items || []) {
const jobId = String(job.id || "");
const btnMore = el("button", {
class: "chat-sess-more",
text: "⋯",
title: t("scheduledJobs.menuTitle"),
onclick: (ev) => {
ev.stopPropagation();
closeScheduledJobMenu();
const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;z-index:250;" }, [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.viewRuns"),
onclick: async () => {
closeScheduledJobMenu();
const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/runs`);
runsBox.textContent = JSON.stringify(runs.items || [], null, 2);
},
}),
...(canWrite
? [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.edit"),
onclick: () => {
closeScheduledJobMenu();
openEditModal(job);
},
}),
]
: []),
el("button", {
class: "chat-sess-menu-item",
text: job.status === "active" ? t("scheduledJobs.pause") : t("scheduledJobs.resume"),
onclick: async () => {
closeScheduledJobMenu();
const path =
job.status === "active"
? `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/pause`
: `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/resume`;
await apiPost(path, {});
await loadJobs();
},
}),
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.runNow"),
onclick: async () => {
closeScheduledJobMenu();
await apiPost(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/run-now`, {});
msg.textContent = t("scheduledJobs.triggered");
},
}),
...(canWrite
? [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.delete"),
onclick: async () => {
closeScheduledJobMenu();
if (!window.confirm(t("scheduledJobs.deleteConfirm"))) return;
await fetch(resolveAdminApiUrl(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}`), {
method: "DELETE",
headers: { authorization: `Bearer ${getStoredAuthToken()}`, accept: "application/json" },
});
await loadJobs();
},
}),
]
: []),
]);
const rect = ev.currentTarget.getBoundingClientRect();
document.body.appendChild(menu);
const mrect = menu.getBoundingClientRect();
const pad = 8;
let left = rect.left - 120;
let top = rect.bottom + 4;
if (top + mrect.height > window.innerHeight - pad) {
top = rect.top - 4 - mrect.height;
}
left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width));
top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
scheduledJobMenuEl = menu;
const close = (e) => {
if (!menu.contains(e.target)) {
closeScheduledJobMenu();
document.removeEventListener("click", close);
}
};
setTimeout(() => document.addEventListener("click", close), 0);
},
});
const lastRun = job.last_run_at
? `${formatSystemLocalDateTime(String(job.last_run_at))}${job.last_run_status ? ` (${job.last_run_status})` : ""}`
: "—";
const tr = el("tr", {}, [
tdCell(job.name || "", 24),
tdCell(`${job.schedule_kind}:${job.schedule_expr}`, 28),
tdCell(job.status || "", 10),
tdCell(job.next_run_at || "—", 20),
tdCell(lastRun, 24),
tdCell(job.specialist || "", 14),
tdCell(formatScheduledJobDelivery(job), 12),
el("td", { class: "table__cell--actions" }, [
el("div", { class: "table__cell-actions" }, [btnMore]),
]),
]);
tbody.appendChild(tr);
}
msg.textContent = tf("scheduledJobs.count", { count: String((resp.items || []).length) });
await loadLatestRuns(resp.items || []);
}
const nameInput = el("input", { class: "input", placeholder: "name" });
const kindInput = el("select", {}, [
el("option", { value: "cron", text: "cron" }),
el("option", { value: "once", text: "once" }),
el("option", { value: "interval", text: "interval" }),
]);
const exprInput = el("input", { class: "input", placeholder: "schedule_expr (cron / ISO / seconds)" });
const promptInput = el("textarea", { class: "input", rows: "3", placeholder: "prompt_text" });
const specialistInput = el("input", { class: "input", placeholder: "specialist", value: "generalist" });
const waChatInput = el("input", { class: "input", placeholder: "whatsapp chat_id (optional)" });
const weixinInfo = el("div", { class: "muted", text: "" });
async function loadMeta() {
const meta = await apiGet("/admin/api/scheduled-jobs/meta/targets");
const wx = meta.weixin_binding || {};
weixinInfo.textContent = wx.external_user_id
? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) })
: t("scheduledJobs.weixinMissing");
}
await loadMeta();
await loadJobs();
const createCardChildren = [
el("div", { class: "card__title", text: t("scheduledJobs.createTitle") }),
weixinInfo,
nameInput,
el("div", { class: "row", style: "gap:8px;" }, [kindInput, exprInput]),
promptInput,
specialistInput,
waChatInput,
el("button", {
class: "btn btn--primary",
text: t("scheduledJobs.create"),
onclick: async () => {
await apiPost("/admin/api/scheduled-jobs", {
name: nameInput.value,
schedule_kind: kindInput.value,
schedule_expr: exprInput.value,
prompt_text: promptInput.value,
specialist: specialistInput.value,
whatsapp_chat_id: waChatInput.value,
delivery: buildScheduledJobDeliveryPayload(null, waChatInput.value),
});
await loadJobs();
},
}),
];
return el("div", {}, [
editModal,
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.scheduledJobs") }),
el("div", { class: "row", style: "gap:8px;align-items:center;" }, [
status,
el("button", { class: "btn", text: t("action.refresh"), onclick: () => loadJobs() }),
msg,
]),
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("colgroup", {}, [
el("col", { style: "width:16%" }),
el("col", { style: "width:18%" }),
el("col", { style: "width:8%" }),
el("col", { style: "width:14%" }),
el("col", { style: "width:14%" }),
el("col", { style: "width:10%" }),
el("col", { style: "width:10%" }),
el("col", { style: "width:10%" }),
]),
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("scheduledJobs.colName") }),
el("th", { text: t("scheduledJobs.colSchedule") }),
el("th", { text: t("scheduledJobs.colStatus") }),
el("th", { text: t("scheduledJobs.colNextRun") }),
el("th", { text: t("scheduledJobs.colLastRun") }),
el("th", { text: t("scheduledJobs.colSpecialist") }),
el("th", { text: t("scheduledJobs.colDelivery") }),
el("th", { text: t("scheduledJobs.colActions") }),
]),
]),
tbody,
]),
]),
]),
...(canWrite ? [el("div", { class: "card" }, createCardChildren)] : []),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: `${t("scheduledJobs.runHistory")} · ${t("scheduledJobs.runHistoryHint")}` }),
runsBox,
]),
]);
}
async function router() {
const route = getRoute();
const page = route.page;
@ -9243,6 +9675,7 @@ async function router() {
document.querySelectorAll(".nav__item").forEach((a) => {
const p = String(a.dataset.page || "");
if (p === "stack" && !hasPermission("admin:runtime:write")) a.style.display = "none";
else if (p === "scheduled-jobs" && !hasPermission("admin:read")) a.style.display = "none";
else if (p === "users" && !hasPermission("admin:user:read")) a.style.display = "none";
else if (p === "session-monitor" && !isAdministratorUsername()) a.style.display = "none";
else if (p === "admin-audit" && !hasPermission("admin:user:write")) a.style.display = "none";
@ -9271,6 +9704,9 @@ async function router() {
);
view = await renderStack();
}
else if (page === "scheduled-jobs") {
view = hasPermission("admin:read") ? await renderScheduledJobs() : forbiddenCard();
}
else if (page === "users") {
view = hasPermission("admin:user:read") ? await renderUserManagement() : forbiddenCard();
} else if (page === "memory") view = await renderMemory();

View file

@ -574,7 +574,7 @@
<script defer src="/admin/assets/admin-theme.js"></script>
<script
defer
src="/admin/assets/chat.js?v=20260515-2"
src="/admin/assets/chat.js?v=20260626-1"
onerror="(function(){var s=document.getElementById('chat-boot-splash');if(s){s.querySelector('.chat-boot-splash__title span:last-child').textContent='无法加载 chat.js';s.querySelector('.chat-boot-splash__muted').textContent='请确认网关已启动且 /admin/assets/chat.js 可访问。';}})()"
></script>
</head>

View file

@ -408,6 +408,58 @@ function _normalizeEventType(v) {
return String(v || "").trim().toLowerCase();
}
function _isAssistantBodyEventType(eventType) {
const et = _normalizeEventType(eventType);
return !et || et === "assistant_text" || et === "assistant" || et === "scheduled_reminder";
}
function _parseEventPayload(raw) {
let ep = raw;
if (typeof ep === "string" && String(ep).trim()) {
try {
ep = JSON.parse(ep);
} catch (_) {
ep = null;
}
}
return ep && typeof ep === "object" && !Array.isArray(ep) ? ep : null;
}
function _isScheduledProactiveMessage(m) {
const eventType = _normalizeEventType(m && m.event_type);
if (eventType === "scheduled_reminder") return true;
const ep = _parseEventPayload(m && m.event_payload);
if (ep && ep.scheduled_proactive) return true;
const rc = String((ep && ep.reasoning_content) || "").trim();
if (!rc) return false;
if (!/定时主动提醒|定时任务模式|scheduled reminder|proactive reminder/i.test(rc)) return false;
return eventType === "assistant_text" || eventType === "assistant" || !eventType;
}
function _messageTurnUuid(m) {
return String((m && m.turn_uuid) || "").trim();
}
function _pushScheduledAssistantRow(rows, m, content, eventType) {
const body = String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (!body && !attsParsed.length) return;
const piece = {
kind: "assistant_text",
text: content,
assistantEventType: eventType || "assistant_text",
};
if (attsParsed.length) piece.attachments = attsParsed;
rows.push({
role: "assistant",
content: body,
timestamp: (m && m.timestamp) != null ? m.timestamp : "",
attachments: m && m.attachments ? m.attachments : null,
_items: [piece],
_message_ids: m && m.id != null ? [m.id] : [],
});
}
function _collapsedBlockNode(title, text) {
const raw = String(text || "");
const clipped = raw.length > REASONING_BLOCK_MAX_CHARS ? raw.slice(0, REASONING_BLOCK_MAX_CHARS) : raw;
@ -610,6 +662,15 @@ function _buildRenderRows(msgs) {
continue;
}
if (role === "assistant" || role === "tool" || role === "function") {
const turnUuid = _messageTurnUuid(m);
if (agg && turnUuid && agg._turn_uuid && turnUuid !== agg._turn_uuid) {
flush();
}
if (role === "assistant" && _isScheduledProactiveMessage(m)) {
flush();
_pushScheduledAssistantRow(rows, m, content, eventType);
continue;
}
if (!agg) {
agg = {
role: "assistant",
@ -618,20 +679,16 @@ function _buildRenderRows(msgs) {
attachments: null,
_items: [],
_message_ids: [],
_turn_uuid: turnUuid,
};
} else if (turnUuid && !agg._turn_uuid) {
agg._turn_uuid = turnUuid;
}
if (m && m.id != null) agg._message_ids.push(m.id);
if (role === "assistant") {
// thinking_mode_enabled: reasoning lives in event_payload.reasoning_content (no separate reasoning rows).
let ep = m && m.event_payload;
if (typeof ep === "string" && String(ep).trim()) {
try {
ep = JSON.parse(ep);
} catch (_) {
ep = null;
}
}
if (ep && typeof ep === "object" && !Array.isArray(ep)) {
const ep = _parseEventPayload(m && m.event_payload);
if (ep) {
const rc = String(ep.reasoning_content || "").trim();
if (rc) {
agg._items.push({ kind: "reasoning", text: rc });
@ -649,7 +706,7 @@ function _buildRenderRows(msgs) {
if (attsParsed.length) piece.attachments = attsParsed;
agg._items.push(piece);
}
} else if (eventType === "assistant_text" || eventType === "assistant" || !eventType) {
} else if (_isAssistantBodyEventType(eventType)) {
const hasText = !!String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (hasText || attsParsed.length) {
@ -4308,7 +4365,7 @@ async function renderChatUi() {
).trim();
const hasBodyText = out.some((row) => {
const et = _normalizeEventType(row.event_type);
return et === "assistant_text" || et === "tool_call";
return _isAssistantBodyEventType(et) || et === "tool_call";
});
if (textFallback && !hasBodyText) {
out.push({ ...base, content: textFallback, event_type: fallbackEventType });
@ -4327,7 +4384,7 @@ async function renderChatUi() {
if (out.length) {
const _isBodyRow = (row) => {
const et = _normalizeEventType(row && row.event_type);
return et === "assistant_text" || et === "tool_call";
return _isAssistantBodyEventType(et) || et === "tool_call";
};
let attachIdx = out.length - 1;
for (let i = out.length - 1; i >= 0; i -= 1) {
@ -4408,7 +4465,7 @@ async function renderChatUi() {
// Recovery should only accept visible assistant body, not intermediate
// reasoning/tool-call events; otherwise we may terminate on a partial line.
const et = String((m && m.event_type) || "").trim().toLowerCase();
if (et && et !== "assistant_text" && et !== "assistant") continue;
if (et && !_isAssistantBodyEventType(et)) continue;
if (!String((m && m.content) || "").trim()) continue;
{
last = m;

View file

@ -65,6 +65,7 @@
<div class="nav__group">
<div class="nav__groupTitle">系统与运行</div>
<a class="nav__item" data-page="stack" href="#/stack" data-i18n="nav.stack">Runtime</a>
<a class="nav__item" data-page="scheduled-jobs" href="#/scheduled-jobs" data-i18n="nav.scheduledJobs">定时任务</a>
<a class="nav__item" data-page="session-monitor" href="#/session-monitor" data-i18n="nav.sessionMonitor">会话监控</a>
<a class="nav__item" data-page="workspace-paths" href="#/workspace-paths" data-i18n="nav.workspacePaths">工作区路径</a>
<a class="nav__item" data-page="attachments" href="#/attachments" data-i18n="nav.attachments">附件</a>

View file

@ -5,6 +5,7 @@ from typing import Any
from runtime.skill_installer import install_skill_from_registry_archive
from runtime.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
from runtime.tools.skills.clawhub_client import search_skills as clawhub_search_skills
from runtime.scheduler.cron_service import CronService
def build_common_gateway_context(*, store: Any) -> dict[str, Any]:
@ -80,6 +81,7 @@ def build_common_gateway_context(*, store: Any) -> dict[str, Any]:
return {
"store": store,
"cron": CronService(store=store),
"search_clawhub_skills": _search_clawhub_skills,
"fetch_clawhub_skill_detail": _fetch_clawhub_skill_detail,
"install_skill_from_clawhub": _install_skill_from_clawhub,

View file

@ -4,6 +4,7 @@ from interfaces.gateway.server_methods.agent import agent_handlers
from interfaces.gateway.server_methods.chat import chat_handlers
from interfaces.gateway.server_methods.commands import commands_handlers
from interfaces.gateway.server_methods.connect import connect_handlers
from interfaces.gateway.server_methods.cron import cron_handlers
from interfaces.gateway.server_methods.sessions import sessions_handlers
from interfaces.gateway.server_methods.skills import skills_handlers
from interfaces.gateway.server_methods.shared_types import GatewayRequestHandlers
@ -19,6 +20,7 @@ def build_gateway_method_handlers() -> GatewayRequestHandlers:
sessions_handlers,
skills_handlers,
agent_handlers,
cron_handlers,
):
for method, handler in (group or {}).items():
merged[str(method)] = handler

View file

@ -70,6 +70,8 @@ def _cron_list_handler(opts: dict[str, Any]) -> None:
"enabled": params.get("enabled"),
"sortBy": params.get("sortBy"),
"sortDir": params.get("sortDir"),
"tenantId": params.get("tenantId"),
"tenant_id": params.get("tenant_id"),
}
),
)

View file

@ -217,6 +217,15 @@ def _run_startup_hooks(app: FastAPI) -> None:
_log_info(f"[startup-prebuild] scheduler_started interval_s={_prewarm_interval_seconds()}")
except Exception as exc:
_log_warn(f"[startup-prebuild] scheduler_start_failed: {exc}")
try:
from svc.persistence.assistant_store import get_assistant_store
from runtime.scheduler.service import ensure_scheduler_started
store = get_assistant_store()
sid = ensure_scheduler_started(store=store)
_log_info(f"[scheduled-jobs] scheduler_started thread={sid}")
except Exception as exc:
_log_warn(f"[scheduled-jobs] scheduler_start_failed: {exc}")
_relocate_root_scan_artifacts()
startup_targets = _resolve_startup_workspace_dirs(cfg)
initialize_hooks_runtime(cfg=cfg, workspace_dir=startup_targets[0][1])
@ -302,6 +311,31 @@ def create_app() -> FastAPI:
changed = store.ack_channel_outbound_message(message_id=msg_id, ok=ok, error=err)
return {"ok": changed}
@app.get("/weixin/outbound/pending")
def weixin_outbound_pending(account_id: str = "", limit: int = 20) -> dict[str, Any]:
from svc.persistence.assistant_store import get_assistant_store
store = get_assistant_store()
lister = getattr(store, "list_pending_weixin_outbound_messages", None)
if not callable(lister):
return {"ok": True, "items": []}
items = lister(account_id=str(account_id or "").strip(), limit=limit)
return {"ok": True, "items": items}
@app.post("/weixin/outbound/ack")
def weixin_outbound_ack(payload: dict[str, Any]) -> dict[str, Any]:
from svc.persistence.assistant_store import get_assistant_store
body = payload if isinstance(payload, dict) else {}
msg_id = str(body.get("id") or "").strip()
if not msg_id:
return {"ok": False, "error": "missing id"}
ok = bool(body.get("ok", True))
err = str(body.get("error") or "").strip()
store = get_assistant_store()
changed = store.ack_channel_outbound_message(message_id=msg_id, ok=ok, error=err)
return {"ok": changed}
@app.post("/wecom/inbound")
async def wecom_inbound(payload: dict[str, Any]) -> dict[str, Any]:
payload = payload if isinstance(payload, dict) else {}

View file

@ -262,12 +262,23 @@ class _IlinkBridge:
if eid <= cursor:
continue
if str(event.get("token") or "") != token:
continue
ev_tok = str(event.get("token") or "")
if ev_tok and ev_tok not in ("default", token):
continue
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
if channel and str(payload.get("channel") or "") != channel:
continue
if account_id and str(payload.get("account_id") or "") != account_id:
continue
alt = {"wechat", "weixin"}
if not (
channel in alt
and str(payload.get("channel") or "") in alt
):
continue
req_acct = str(account_id or "").strip()
pay_acct = str(payload.get("account_id") or "").strip()
if req_acct and pay_acct and pay_acct != req_acct:
legacy = {"", "weixin-default"}
if pay_acct not in legacy and req_acct not in legacy:
continue
next_cursor = max(next_cursor, eid)
item = dict(payload)
item["id"] = str(eid)
@ -319,6 +330,23 @@ async def _process_inbound_and_enqueue(
return ""
ctx_token = _extract_context_token(payload)
try:
from runtime.scheduler.channel_delivery import persist_channel_context_token
from svc.persistence.assistant_store import get_assistant_store
store = get_assistant_store()
persist_channel_context_token(
store,
tenant_id=str(payload.get("tenant_id") or ""),
channel=channel,
account_id=account_id,
external_chat_id=str(chat_id or user_id or ""),
context_token=ctx_token,
)
except Exception:
pass
try:
out = await asyncio.wait_for(asyncio.to_thread(_process_inbound_payload_usecase, payload), timeout=60.0)
except asyncio.TimeoutError:
@ -534,5 +562,32 @@ async def whatsapp_native_reply(
}
__all__ = ["router"]
def enqueue_weixin_outbound_reply(
*,
channel: str,
account_id: str,
chat_id: str,
text: str,
context_token: str = "",
token: str = "",
) -> str:
"""Enqueue a proactive Weixin/WeChat outbound message for the sidecar poll loop."""
tok = str(
token or os.getenv("AIA_WEIXIN_ILINK_TOKEN") or os.getenv("AIA_ILINK_TOKEN") or "default"
).strip()
ch = str(channel or "wechat").strip().lower()
if ch == "weixin":
ch = "wechat"
_BRIDGE.enqueue_reply(
token=tok,
channel=ch,
account_id=str(account_id or "").strip(),
chat_id=str(chat_id or "").strip(),
text=str(text or ""),
context_token=str(context_token or "").strip(),
)
return str(_BRIDGE._seq)
__all__ = ["router", "enqueue_weixin_outbound_reply"]