mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
Update channel dispatch UX and harden sidecar startup behavior.
This aligns Weixin/WhatsApp with admin-managed model routing by adding channel/account-level specialist controls, improves OSS startup resilience by skipping missing sidecars gracefully, and refreshes runbook/readme guidance for the new default behavior and Weixin OpenAI-key silent handling. Made-with: Cursor
This commit is contained in:
parent
b1a0f94cd6
commit
5ab69e88d3
29 changed files with 1226 additions and 100 deletions
|
|
@ -108,6 +108,23 @@ _DISPATCH_REASON_LABELS: dict[str, dict[str, str]] = {
|
|||
}
|
||||
_DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
|
||||
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
|
||||
_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode."
|
||||
_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist."
|
||||
|
||||
|
||||
def _channel_dispatch_interaction_key(channel: str) -> str:
|
||||
return f"{_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||||
|
||||
|
||||
def _channel_dispatch_specialist_key(channel: str) -> str:
|
||||
return f"{_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||||
|
||||
|
||||
def _normalize_channel_dispatch_channel(raw: Any) -> str:
|
||||
ch = str(raw or "").strip().lower()
|
||||
if ch not in {"weixin", "whatsapp"}:
|
||||
raise HTTPException(status_code=400, detail="invalid_channel")
|
||||
return ch
|
||||
def _chat_specialist_ids() -> tuple[str, ...]:
|
||||
return tuple(str(x) for x in specialist_ids() if str(x).strip())
|
||||
DEFAULT_TABULAR_SQL_TIMEOUT_MS = 8_000
|
||||
|
|
@ -1289,6 +1306,53 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
flags = _specialist_flags_with_overrides(store)
|
||||
return {"ok": True, "saved": clean, "flags": flags}
|
||||
|
||||
@chat.get("/settings/channel-dispatch/{channel}")
|
||||
def api_chat_get_channel_dispatch(
|
||||
channel: str,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_administrator_chat_viewer(ctx)
|
||||
ch = _normalize_channel_dispatch_channel(channel)
|
||||
interaction_mode = normalize_interaction_mode(
|
||||
store.get_setting(_channel_dispatch_interaction_key(ch)) or "expert"
|
||||
)
|
||||
specialist = normalize_requested_specialist(
|
||||
store.get_setting(_channel_dispatch_specialist_key(ch)) or "generalist"
|
||||
)
|
||||
specialist = _apply_specialist_flags(store, specialist)
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": ch,
|
||||
"interaction_mode": interaction_mode,
|
||||
"specialist": specialist,
|
||||
"available_specialists": [sid for sid in _chat_specialist_ids() if bool(_specialist_flags_with_overrides(store).get(sid, True))],
|
||||
}
|
||||
|
||||
@chat.post("/settings/channel-dispatch/{channel}")
|
||||
def api_chat_set_channel_dispatch(
|
||||
channel: str,
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
body = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_administrator_chat_viewer(ctx)
|
||||
ch = _normalize_channel_dispatch_channel(channel)
|
||||
interaction_mode = normalize_interaction_mode(body.get("interaction_mode") or "expert")
|
||||
specialist = normalize_requested_specialist(body.get("specialist") or "generalist")
|
||||
specialist = _apply_specialist_flags(store, specialist)
|
||||
store.set_setting(_channel_dispatch_interaction_key(ch), interaction_mode)
|
||||
store.set_setting(_channel_dispatch_specialist_key(ch), specialist)
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": ch,
|
||||
"interaction_mode": interaction_mode,
|
||||
"specialist": specialist,
|
||||
}
|
||||
|
||||
@chat.get("/settings/attachment-limits")
|
||||
def api_chat_get_attachment_limits(
|
||||
authorization: str | None = Header(default=None),
|
||||
|
|
|
|||
|
|
@ -1424,12 +1424,15 @@ function markPrewarmReminder(reason) {
|
|||
}
|
||||
|
||||
async function renderStack() {
|
||||
const [st, anomaliesResp, scanResp, prewarmStatusResp, prewarmPromptsResp] = await Promise.all([
|
||||
const [st, anomaliesResp, scanResp, prewarmStatusResp, prewarmPromptsResp, channelSpecResp, weixinDispatchResp, whatsappDispatchResp] = await Promise.all([
|
||||
apiGet("/admin/api/stack/status"),
|
||||
apiGet("/admin/api/runtime/anomalies"),
|
||||
apiGet("/admin/api/runtime/scan-artifacts"),
|
||||
apiGet("/admin/api/runtime/prewarm/status"),
|
||||
apiGet("/admin/api/runtime/prewarm/prompts?role=manager"),
|
||||
apiGet("/admin/api/chat/settings/specialist-flags"),
|
||||
apiGet("/admin/api/chat/settings/channel-dispatch/weixin"),
|
||||
apiGet("/admin/api/chat/settings/channel-dispatch/whatsapp"),
|
||||
]);
|
||||
const requiredServices = ["gateway", "channel:wecom"];
|
||||
const runningNames = new Set(
|
||||
|
|
@ -1471,6 +1474,54 @@ async function renderStack() {
|
|||
await apiPost("/admin/api/stack/down", {});
|
||||
router();
|
||||
}});
|
||||
const availableDispatchSpecialists = Array.isArray(channelSpecResp && channelSpecResp.available_specialists) && channelSpecResp.available_specialists.length
|
||||
? channelSpecResp.available_specialists.map((x) => String(x || "").trim()).filter(Boolean)
|
||||
: ["generalist"];
|
||||
const createChannelDispatchCard = (channel, title, initial) => {
|
||||
const curMode = String((initial && initial.interaction_mode) || "expert").trim() || "expert";
|
||||
const curSpecialist = String((initial && initial.specialist) || "generalist").trim() || "generalist";
|
||||
const specialistSel = el("select", { class: "input" }, availableDispatchSpecialists.map((sid) =>
|
||||
el("option", { value: sid, text: sid, selected: sid === curSpecialist ? "selected" : undefined }),
|
||||
));
|
||||
const status = el("div", { class: "muted", text: `mode=${curMode} specialist=${curSpecialist}` });
|
||||
const saveExpertBtn = el("button", {
|
||||
class: "btn",
|
||||
text: currentLang === "zh" ? "绑定专家" : "Bind specialist",
|
||||
onclick: async () => {
|
||||
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
|
||||
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
|
||||
interaction_mode: "expert",
|
||||
specialist,
|
||||
});
|
||||
status.textContent = `mode=${String(resp.interaction_mode || "expert")} specialist=${String(resp.specialist || specialist)}`;
|
||||
},
|
||||
});
|
||||
const saveComprehensiveBtn = el("button", {
|
||||
class: "btn btn--primary",
|
||||
text: currentLang === "zh" ? "综合" : "Comprehensive",
|
||||
onclick: async () => {
|
||||
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
|
||||
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
|
||||
interaction_mode: "comprehensive",
|
||||
specialist,
|
||||
});
|
||||
status.textContent = `mode=${String(resp.interaction_mode || "comprehensive")} specialist=${String(resp.specialist || specialist)}`;
|
||||
},
|
||||
});
|
||||
return el("div", { class: "card" }, [
|
||||
el("div", { class: "card__title", text: title }),
|
||||
el("div", { class: "row" }, [
|
||||
el("label", { text: currentLang === "zh" ? "专家" : "Specialist" }),
|
||||
specialistSel,
|
||||
saveExpertBtn,
|
||||
saveComprehensiveBtn,
|
||||
]),
|
||||
status,
|
||||
el("div", { class: "muted", text: currentLang === "zh" ? "默认绑定通用专家;综合模式下由全能者分派。" : "Defaults to generalist; comprehensive mode lets manager dispatch." }),
|
||||
]);
|
||||
};
|
||||
const weixinDispatchCard = createChannelDispatchCard("weixin", "Weixin dispatch", weixinDispatchResp || {});
|
||||
const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {});
|
||||
const cleanupStatus = el("div", { class: "muted", text: "" });
|
||||
const btnCleanup = el("button", { class: "btn btn--danger", text: t("stack.cleanup"), onclick: async () => {
|
||||
const resp = await apiPost("/admin/api/runtime/cleanup", {});
|
||||
|
|
@ -1643,6 +1694,8 @@ async function renderStack() {
|
|||
el("tbody", {}, items),
|
||||
]),
|
||||
]),
|
||||
weixinDispatchCard,
|
||||
whatsappDispatchCard,
|
||||
el("div", { class: "card" }, [
|
||||
el("div", { class: "card__title", text: currentLang === "zh" ? "提示词/工具预热" : "Prompt/Tool Prewarm" }),
|
||||
el("div", { class: "muted", text: prewarmSummary }),
|
||||
|
|
@ -1815,15 +1868,24 @@ async function renderUserManagement() {
|
|||
placeholder: t("users.wecomUserEmpty"),
|
||||
});
|
||||
const accountBody = el("tbody");
|
||||
const accountChannelInput = el("select", { class: "input" }, [
|
||||
el("option", { value: "wecom", text: "wecom" }),
|
||||
el("option", { value: "weixin", text: "weixin" }),
|
||||
el("option", { value: "whatsapp", text: "whatsapp" }),
|
||||
]);
|
||||
const accountIdInput = el("input", { class: "input", placeholder: t("users.wecomBotId") });
|
||||
const accountNameInput = el("input", { class: "input", placeholder: t("users.wecomInstanceName") });
|
||||
const botSecretInput = el("input", { class: "input", type: "password", placeholder: t("users.wecomBotSecret") });
|
||||
const botSecretField = el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotSecret") }), botSecretInput]);
|
||||
const clearBotChk = el("input", { type: "checkbox" });
|
||||
const clearBotField = el("label", { class: "kv row--wecom-form__chk" }, [clearBotChk, document.createTextNode(" " + t("users.clearBotSecret"))]);
|
||||
const accountActiveInput = el("input", { type: "checkbox" });
|
||||
const accountSpecialistInput = el("select", { class: "input" }, [el("option", { value: "generalist", text: "generalist" })]);
|
||||
accountActiveInput.checked = true;
|
||||
let selectedUserId = "";
|
||||
let selectedUsername = "";
|
||||
let selectedDisplayName = "";
|
||||
let availableAccountSpecialists = ["generalist"];
|
||||
|
||||
const refreshSelectedUserField = () => {
|
||||
if (!selectedUserId) {
|
||||
|
|
@ -1840,6 +1902,26 @@ async function renderUserManagement() {
|
|||
botSecretInput.value = "";
|
||||
clearBotChk.checked = false;
|
||||
accountActiveInput.checked = true;
|
||||
accountSpecialistInput.value = "generalist";
|
||||
};
|
||||
const currentAccountChannel = () => String(accountChannelInput.value || "wecom").trim().toLowerCase() || "wecom";
|
||||
const refreshAccountFormByChannel = () => {
|
||||
const isWecom = currentAccountChannel() === "wecom";
|
||||
botSecretField.style.display = isWecom ? "" : "none";
|
||||
clearBotField.style.display = isWecom ? "" : "none";
|
||||
};
|
||||
const setAccountSpecialistOptions = (specialists) => {
|
||||
const opts = Array.isArray(specialists) && specialists.length
|
||||
? specialists.map((x) => String(x || "").trim()).filter(Boolean)
|
||||
: ["generalist"];
|
||||
const uniq = Array.from(new Set(["generalist", ...opts]));
|
||||
availableAccountSpecialists = uniq;
|
||||
const old = String(accountSpecialistInput.value || "generalist").trim() || "generalist";
|
||||
accountSpecialistInput.innerHTML = "";
|
||||
uniq.forEach((sid) => {
|
||||
accountSpecialistInput.appendChild(el("option", { value: sid, text: sid, selected: sid === old ? "selected" : undefined }));
|
||||
});
|
||||
if (!uniq.includes(old)) accountSpecialistInput.value = "generalist";
|
||||
};
|
||||
|
||||
const newUserName = el("input", { class: "input", placeholder: t("users.createNamePlaceholder") });
|
||||
|
|
@ -1888,6 +1970,12 @@ async function renderUserManagement() {
|
|||
} else {
|
||||
accountActiveInput.checked = true;
|
||||
}
|
||||
const mode = String(((inst && inst.config) || {}).interaction_mode || "expert").trim().toLowerCase();
|
||||
const specialist = String(((inst && inst.config) || {}).specialist || "generalist").trim().toLowerCase() || "generalist";
|
||||
accountSpecialistInput.value = availableAccountSpecialists.includes(specialist) ? specialist : "generalist";
|
||||
if (mode === "comprehensive") {
|
||||
accountStatus.textContent = currentLang === "zh" ? "当前账号模式:综合" : "Current account mode: comprehensive";
|
||||
}
|
||||
botSecretInput.value = "";
|
||||
clearBotChk.checked = false;
|
||||
};
|
||||
|
|
@ -1898,18 +1986,19 @@ async function renderUserManagement() {
|
|||
const tenantId = getTenantId();
|
||||
if (!selectedUserId) {
|
||||
accountStatus.textContent = "";
|
||||
accountBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "8" })]));
|
||||
accountBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "10" })]));
|
||||
return;
|
||||
}
|
||||
const bindUrl = `/admin/api/bindings?tenant_id=${encodeURIComponent(tenantId)}&channel=wecom&user_id=${encodeURIComponent(selectedUserId)}`;
|
||||
const accUrl = `/admin/api/user-channel-accounts?tenant_id=${encodeURIComponent(tenantId)}&user_id=${encodeURIComponent(selectedUserId)}&channel=wecom&include_inactive=1`;
|
||||
const [bindResp, accResp] = await Promise.all([apiGet(bindUrl), apiGet(accUrl)]);
|
||||
const bindings = Array.isArray(bindResp.bindings) ? bindResp.bindings : [];
|
||||
const ch = currentAccountChannel();
|
||||
const bindUrl = `/admin/api/bindings?tenant_id=${encodeURIComponent(tenantId)}&channel=${encodeURIComponent(ch)}&user_id=${encodeURIComponent(selectedUserId)}`;
|
||||
const accUrl = `/admin/api/user-channel-accounts?tenant_id=${encodeURIComponent(tenantId)}&user_id=${encodeURIComponent(selectedUserId)}&channel=${encodeURIComponent(ch)}&include_inactive=1`;
|
||||
const [bindResp, accResp] = await Promise.all([ch === "wecom" ? apiGet(bindUrl) : Promise.resolve({ bindings: [] }), apiGet(accUrl)]);
|
||||
const bindings = Array.isArray(bindResp && bindResp.bindings) ? bindResp.bindings : [];
|
||||
const items = Array.isArray(accResp.items) ? accResp.items : [];
|
||||
const merged = mergeWecomBindingAndInstances(bindings, items);
|
||||
const merged = ch === "wecom" ? mergeWecomBindingAndInstances(bindings, items) : items.map((it) => ({ binding: null, instance: it }));
|
||||
accountStatus.textContent = "";
|
||||
if (!merged.length) {
|
||||
accountBody.appendChild(el("tr", {}, [el("td", { text: t("users.noBindingsForUser"), colspan: "8" })]));
|
||||
accountBody.appendChild(el("tr", {}, [el("td", { text: t("users.noBindingsForUser"), colspan: "10" })]));
|
||||
return;
|
||||
}
|
||||
merged.forEach((m) => {
|
||||
|
|
@ -1919,6 +2008,9 @@ async function renderUserManagement() {
|
|||
const displayName = (inst && String(inst.name || "").trim()) || (bind && String(bind.account_name || "").trim()) || "";
|
||||
const extUid = bind ? String(bind.external_user_id || "") : "";
|
||||
const ts = (inst && String(inst.updated_at || "").trim()) || (bind && String(bind.created_at || "").trim()) || "";
|
||||
const cfg = (inst && inst.config && typeof inst.config === "object") ? inst.config : {};
|
||||
const modeText = String(cfg.interaction_mode || "expert");
|
||||
const specialistText = String(cfg.specialist || "generalist");
|
||||
const btnFill = el("button", {
|
||||
class: "btn",
|
||||
text: t("users.loadForm"),
|
||||
|
|
@ -1929,16 +2021,54 @@ async function renderUserManagement() {
|
|||
});
|
||||
const deleteCell = el("td", {});
|
||||
if (inst) {
|
||||
const btnBindExpert = el("button", {
|
||||
class: "btn",
|
||||
text: currentLang === "zh" ? "绑定专家" : "Bind specialist",
|
||||
onclick: async (e) => {
|
||||
e.stopPropagation();
|
||||
const specialist = String(accountSpecialistInput.value || "generalist").trim() || "generalist";
|
||||
await apiPost("/admin/api/user-channel-accounts/upsert", {
|
||||
tenant_id: tenantId,
|
||||
user_id: selectedUserId,
|
||||
channel: ch,
|
||||
account_id: accountId,
|
||||
name: displayName,
|
||||
is_active: !!inst.is_active,
|
||||
config: { interaction_mode: "expert", specialist },
|
||||
});
|
||||
await loadAccounts();
|
||||
},
|
||||
});
|
||||
const btnComprehensive = el("button", {
|
||||
class: "btn btn--primary",
|
||||
text: currentLang === "zh" ? "综合" : "Comprehensive",
|
||||
onclick: async (e) => {
|
||||
e.stopPropagation();
|
||||
const specialist = String(accountSpecialistInput.value || "generalist").trim() || "generalist";
|
||||
await apiPost("/admin/api/user-channel-accounts/upsert", {
|
||||
tenant_id: tenantId,
|
||||
user_id: selectedUserId,
|
||||
channel: ch,
|
||||
account_id: accountId,
|
||||
name: displayName,
|
||||
is_active: !!inst.is_active,
|
||||
config: { interaction_mode: "comprehensive", specialist },
|
||||
});
|
||||
await loadAccounts();
|
||||
},
|
||||
});
|
||||
const btnDeleteAccount = el("button", { class: "btn btn--danger", text: t("users.accountDelete"), onclick: async (e) => {
|
||||
e.stopPropagation();
|
||||
await apiPost("/admin/api/user-channel-accounts/delete", {
|
||||
tenant_id: tenantId,
|
||||
user_id: selectedUserId,
|
||||
channel: "wecom",
|
||||
channel: ch,
|
||||
account_id: accountId,
|
||||
});
|
||||
await loadAccounts();
|
||||
}});
|
||||
deleteCell.appendChild(btnBindExpert);
|
||||
deleteCell.appendChild(btnComprehensive);
|
||||
deleteCell.appendChild(btnDeleteAccount);
|
||||
} else {
|
||||
deleteCell.appendChild(document.createTextNode("—"));
|
||||
|
|
@ -1947,8 +2077,10 @@ async function renderUserManagement() {
|
|||
tdCell(accountId, 22),
|
||||
tdCell(displayName, 16),
|
||||
tdCell(extUid || "—", 18),
|
||||
tdCell(inst ? (inst.has_bot_secret ? "Y" : "-") : "—", 4),
|
||||
tdCell(inst ? (ch === "wecom" ? (inst.has_bot_secret ? "Y" : "-") : "-") : "—", 4),
|
||||
tdCell(inst ? (inst.is_active ? "1" : "0") : "—", 4),
|
||||
tdCell(modeText || "expert", 8),
|
||||
tdCell(specialistText || "generalist", 10),
|
||||
tdCell(ts, 20),
|
||||
el("td", {}, [btnFill]),
|
||||
deleteCell,
|
||||
|
|
@ -1967,16 +2099,22 @@ async function renderUserManagement() {
|
|||
return;
|
||||
}
|
||||
const tenantId = getTenantId();
|
||||
const ch = currentAccountChannel();
|
||||
const specialist = String(accountSpecialistInput.value || "generalist").trim() || "generalist";
|
||||
const resp = await apiPost("/admin/api/user-channel-accounts/upsert", {
|
||||
tenant_id: tenantId,
|
||||
user_id: selectedUserId,
|
||||
channel: "wecom",
|
||||
channel: ch,
|
||||
account_id: aid,
|
||||
name: accountNameInput.value.trim(),
|
||||
wecom_mode: "bot_api",
|
||||
wecom_mode: ch === "wecom" ? "bot_api" : undefined,
|
||||
bot_secret: botSecretInput.value.trim(),
|
||||
clear_bot_secret: !!clearBotChk.checked,
|
||||
is_active: !!accountActiveInput.checked,
|
||||
config: {
|
||||
interaction_mode: "expert",
|
||||
specialist,
|
||||
},
|
||||
});
|
||||
accountStatus.textContent = resp.ok ? "ok" : String(resp.error || "error");
|
||||
if (resp.ok) {
|
||||
|
|
@ -1986,6 +2124,11 @@ async function renderUserManagement() {
|
|||
await loadList();
|
||||
}
|
||||
}});
|
||||
accountChannelInput.addEventListener("change", async () => {
|
||||
clearWecomForm();
|
||||
refreshAccountFormByChannel();
|
||||
await loadAccounts();
|
||||
});
|
||||
|
||||
const loadList = async () => {
|
||||
if (!hasPermission("admin:user:read")) {
|
||||
|
|
@ -2201,8 +2344,15 @@ async function renderUserManagement() {
|
|||
});
|
||||
});
|
||||
await loadBindingsAndCodes();
|
||||
try {
|
||||
const sf = await apiGet("/admin/api/chat/settings/specialist-flags");
|
||||
setAccountSpecialistOptions(sf.available_specialists || ["generalist"]);
|
||||
} catch (_) {
|
||||
setAccountSpecialistOptions(["generalist"]);
|
||||
}
|
||||
await loadList();
|
||||
refreshSelectedUserField();
|
||||
refreshAccountFormByChannel();
|
||||
await loadAccounts();
|
||||
|
||||
const tenantsListHint = allTenants.length > 1
|
||||
|
|
@ -2250,11 +2400,13 @@ async function renderUserManagement() {
|
|||
el("div", {}, [el("div", { class: "muted", text: t("users.wecomUserLabel") }), selectedUserField]),
|
||||
el("div", { class: "muted", style: "margin-bottom:8px", text: t("users.accountLoadHint") }),
|
||||
el("div", { class: "row row--wecom-form" }, [
|
||||
el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: "channel" }), accountChannelInput]),
|
||||
el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotId") }), accountIdInput]),
|
||||
el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomInstanceName") }), accountNameInput]),
|
||||
el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotSecret") }), botSecretInput]),
|
||||
el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: currentLang === "zh" ? "专家" : "Specialist" }), accountSpecialistInput]),
|
||||
botSecretField,
|
||||
el("label", { class: "kv row--wecom-form__chk" }, [accountActiveInput, document.createTextNode(" " + t("users.accountActive"))]),
|
||||
el("label", { class: "kv row--wecom-form__chk" }, [clearBotChk, document.createTextNode(" " + t("users.clearBotSecret"))]),
|
||||
clearBotField,
|
||||
btnSaveAccount,
|
||||
]),
|
||||
accountStatus,
|
||||
|
|
@ -2265,6 +2417,8 @@ async function renderUserManagement() {
|
|||
el("th", { text: t("table.externalUserId") }),
|
||||
el("th", { text: t("users.hasBotSecret") }),
|
||||
el("th", { text: t("users.accountActive") }),
|
||||
el("th", { text: currentLang === "zh" ? "模式" : "Mode" }),
|
||||
el("th", { text: t("table.specialist") }),
|
||||
el("th", { text: t("table.timestamp") }),
|
||||
el("th", { text: t("users.loadForm") }),
|
||||
el("th", { text: t("users.accountActions") }),
|
||||
|
|
|
|||
|
|
@ -2584,6 +2584,49 @@ async function renderChatUi() {
|
|||
backdrop.appendChild(card);
|
||||
document.body.appendChild(backdrop);
|
||||
});
|
||||
const promptChatText = (message, initialValue = "") =>
|
||||
new Promise((resolve) => {
|
||||
const backdrop = el("div", { class: "chat-confirm-backdrop" });
|
||||
const card = el("div", { class: "chat-confirm-card" });
|
||||
const text = el("div", { class: "chat-confirm-text", text: String(message || "") });
|
||||
const input = el("input", {
|
||||
class: "input",
|
||||
type: "text",
|
||||
value: String(initialValue || ""),
|
||||
style: "width:100%;margin-top:10px;",
|
||||
});
|
||||
const btnCancel = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsCancel") });
|
||||
const btnOk = el("button", { type: "button", class: "btn btn--primary", text: t("chat.dispatchLabelsSave") });
|
||||
const close = (ok) => {
|
||||
if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);
|
||||
resolve(ok ? String(input.value || "") : null);
|
||||
};
|
||||
btnCancel.addEventListener("click", () => close(false));
|
||||
btnOk.addEventListener("click", () => close(true));
|
||||
input.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
close(true);
|
||||
} else if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
close(false);
|
||||
}
|
||||
});
|
||||
backdrop.addEventListener("click", (ev) => {
|
||||
if (ev.target === backdrop) close(false);
|
||||
});
|
||||
card.appendChild(text);
|
||||
card.appendChild(input);
|
||||
card.appendChild(el("div", { class: "row", style: "gap:8px;justify-content:flex-end;margin-top:10px;" }, [btnCancel, btnOk]));
|
||||
backdrop.appendChild(card);
|
||||
document.body.appendChild(backdrop);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
input.focus();
|
||||
input.select();
|
||||
} catch (_) {}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const adoptCreatedSession = (resp) => {
|
||||
const s = resp && resp.session ? resp.session : {};
|
||||
|
|
@ -2748,7 +2791,7 @@ async function renderChatUi() {
|
|||
});
|
||||
menu.appendChild(
|
||||
mk(t("chat.rename"), async () => {
|
||||
const nv = window.prompt(t("chat.rename"), title);
|
||||
const nv = await promptChatText(t("chat.rename"), title);
|
||||
if (nv == null) return;
|
||||
try {
|
||||
await apiPatch(`/admin/api/chat/sessions/${encodeURIComponent(sid)}`, { title: nv.trim() });
|
||||
|
|
|
|||
|
|
@ -131,8 +131,8 @@ def _extract_inbound_identity(body: dict[str, Any]) -> tuple[str, str]:
|
|||
return user_id, chat_id
|
||||
|
||||
|
||||
def _build_native_reply_payload(body: dict[str, Any]) -> dict[str, Any]:
|
||||
channel = _normalize_channel(body.get("channel"))
|
||||
def _build_native_reply_payload(body: dict[str, Any], *, default_channel: str, source: str) -> dict[str, Any]:
|
||||
channel = _normalize_channel(body.get("channel") or default_channel)
|
||||
account_id = _resolve_account_id(body)
|
||||
ctx = body.get("ctx") if isinstance(body.get("ctx"), dict) else {}
|
||||
metadata = body.get("metadata") if isinstance(body.get("metadata"), dict) else {}
|
||||
|
|
@ -155,10 +155,10 @@ def _build_native_reply_payload(body: dict[str, Any]) -> dict[str, Any]:
|
|||
).strip()
|
||||
text = str(body.get("text") or ctx.get("Body") or ctx.get("CommandBody") or "").strip()
|
||||
if not metadata:
|
||||
metadata = {"source": "weixin_official_native"}
|
||||
metadata = {"source": source}
|
||||
else:
|
||||
metadata = dict(metadata)
|
||||
metadata.setdefault("source", "weixin_official_native")
|
||||
metadata.setdefault("source", source)
|
||||
if ctx:
|
||||
metadata["weixin_ctx"] = ctx
|
||||
|
||||
|
|
@ -463,7 +463,29 @@ async def weixin_native_reply(
|
|||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
) -> dict[str, Any]:
|
||||
_require_ilink_auth(authorization_type=authorizationtype, authorization=authorization)
|
||||
payload = _build_native_reply_payload(body)
|
||||
payload = _build_native_reply_payload(body, default_channel="wechat", source="weixin_official_native")
|
||||
if not str(payload.get("user_id") or "").strip():
|
||||
return {"ok": False, "error": "missing user_id", "replies": []}
|
||||
if not str(payload.get("account_id") or "").strip():
|
||||
return {"ok": False, "error": "missing account_id", "replies": []}
|
||||
out = await asyncio.to_thread(_process_inbound_payload_usecase, payload)
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
replies = []
|
||||
return {
|
||||
"ok": bool((out or {}).get("ok", True)) if isinstance(out, dict) else True,
|
||||
"replies": [r for r in replies if isinstance(r, dict)],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/whatsapp/native/reply")
|
||||
async def whatsapp_native_reply(
|
||||
body: dict[str, Any],
|
||||
authorizationtype: str | None = Header(default=None, alias="AuthorizationType"),
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
) -> dict[str, Any]:
|
||||
_require_ilink_auth(authorization_type=authorizationtype, authorization=authorization)
|
||||
payload = _build_native_reply_payload(body, default_channel="whatsapp", source="whatsapp_official_native")
|
||||
if not str(payload.get("user_id") or "").strip():
|
||||
return {"ok": False, "error": "missing user_id", "replies": []}
|
||||
if not str(payload.get("account_id") or "").strip():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue