diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index 7d08307b..9f92506e 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -52,6 +52,18 @@ const I18N = { "scheduledJobs.editTitle": "编辑任务", "scheduledJobs.updated": "已保存", "scheduledJobs.cancel": "取消", + "scheduledJobs.fieldName": "名称", + "scheduledJobs.fieldSchedule": "计划", + "scheduledJobs.fieldKind": "类型", + "scheduledJobs.fieldExpr": "表达式", + "scheduledJobs.fieldPrompt": "提示词", + "scheduledJobs.fieldSpecialist": "专家", + "scheduledJobs.fieldWhatsapp": "WhatsApp chat_id", + "scheduledJobs.phName": "任务名称", + "scheduledJobs.phExpr": "cron / ISO 时间 / 秒数", + "scheduledJobs.phPrompt": "到期时要执行的提示词", + "scheduledJobs.phSpecialist": "例如 generalist", + "scheduledJobs.phWhatsapp": "可选,群或私聊 chat_id", "scheduledJobs.runHistory": "运行记录", "scheduledJobs.runHistoryHint": "最近运行(自动刷新)", "scheduledJobs.noRuns": "暂无运行记录", @@ -535,6 +547,18 @@ const I18N = { "scheduledJobs.editTitle": "Edit job", "scheduledJobs.updated": "Saved.", "scheduledJobs.cancel": "Cancel", + "scheduledJobs.fieldName": "Name", + "scheduledJobs.fieldSchedule": "Schedule", + "scheduledJobs.fieldKind": "Kind", + "scheduledJobs.fieldExpr": "Expression", + "scheduledJobs.fieldPrompt": "Prompt", + "scheduledJobs.fieldSpecialist": "Specialist", + "scheduledJobs.fieldWhatsapp": "WhatsApp chat_id", + "scheduledJobs.phName": "Job name", + "scheduledJobs.phExpr": "cron / ISO time / seconds", + "scheduledJobs.phPrompt": "Prompt to run when due", + "scheduledJobs.phSpecialist": "e.g. generalist", + "scheduledJobs.phWhatsapp": "Optional group or DM chat_id", "scheduledJobs.runHistory": "Run history", "scheduledJobs.runHistoryHint": "Recent runs (auto refresh)", "scheduledJobs.noRuns": "No runs yet", @@ -1518,17 +1542,30 @@ function renderPageShell(opts = {}, children = []) { } function renderSectionCard(title, subtitle, bodyNodes = [], opts = {}) { - const nodes = [el("div", { class: "card__title", text: title })]; - if (subtitle) nodes.push(el("div", { class: "muted", text: subtitle })); + const headlineKids = [el("div", { class: "card__title", text: title })]; + if (subtitle) headlineKids.push(el("div", { class: "card__subtitle muted", text: subtitle })); + const headKids = [el("div", { class: "card__headline" }, headlineKids)]; if (Array.isArray(opts.actions) && opts.actions.length) { - nodes.push(el("div", { class: "row section-card__actions" }, opts.actions)); + headKids.push(el("div", { class: "card__actions row" }, opts.actions)); } + const nodes = [el("div", { class: "card__head" }, headKids)]; bodyNodes.filter(Boolean).forEach((node) => nodes.push(node)); const attrs = { class: "card section-card" }; if (opts.id) attrs.id = String(opts.id); return el("div", attrs, nodes); } +function renderMetaChips(entries) { + const items = []; + const src = entries && typeof entries === "object" ? entries : {}; + Object.keys(src).forEach((k) => { + const v = src[k]; + if (v == null || v === "") return; + items.push(el("span", { class: "meta-chip", text: `${k}=${v}` })); + }); + return el("div", { class: "meta-line" }, items.length ? items : [el("span", { class: "muted", text: "—" })]); +} + function shortText(v, maxLen = 200) { const s = String(v ?? ""); if (s.length <= maxLen) return s; @@ -1579,8 +1616,6 @@ function markPrewarmReminder(reason) { async function renderStack() { const results = await Promise.allSettled([ - apiGetNoHang("/admin/api/stack/status"), - apiGetNoHang("/admin/api/runtime/anomalies"), apiGetNoHang("/admin/api/runtime/scan-artifacts"), apiGetNoHang("/admin/api/runtime/prewarm/status"), apiGetNoHang("/admin/api/runtime/prewarm/prompts?role=generalist"), @@ -1590,64 +1625,22 @@ async function renderStack() { apiGetNoHang("/admin/api/whatsapp/groups?tenant_id=default"), apiGetNoHang("/admin/api/whatsapp/access?tenant_id=default"), ]); - const st = results[0].status === "fulfilled" ? results[0].value : null; - const anomaliesResp = results[1].status === "fulfilled" ? results[1].value : null; - const scanResp = results[2].status === "fulfilled" ? results[2].value : null; - const prewarmStatusResp = results[3].status === "fulfilled" ? results[3].value : null; - const prewarmPromptsResp = results[4].status === "fulfilled" ? results[4].value : null; - const channelSpecResp = results[5].status === "fulfilled" ? results[5].value : null; - const weixinDispatchResp = results[6].status === "fulfilled" ? results[6].value : null; - const whatsappDispatchResp = results[7].status === "fulfilled" ? results[7].value : null; - const whatsappGroupsResp = results[8].status === "fulfilled" ? results[8].value : null; - const whatsappAccessResp = results[9].status === "fulfilled" ? results[9].value : null; + const scanResp = results[0].status === "fulfilled" ? results[0].value : null; + const prewarmStatusResp = results[1].status === "fulfilled" ? results[1].value : null; + const prewarmPromptsResp = results[2].status === "fulfilled" ? results[2].value : null; + const channelSpecResp = results[3].status === "fulfilled" ? results[3].value : null; + const weixinDispatchResp = results[4].status === "fulfilled" ? results[4].value : null; + const whatsappDispatchResp = results[5].status === "fulfilled" ? results[5].value : null; + const whatsappGroupsResp = results[6].status === "fulfilled" ? results[6].value : null; + const whatsappAccessResp = results[7].status === "fulfilled" ? results[7].value : null; // If auth failed (401), show a gentle message instead of an infinite spinner. - if (!st) { + if (!channelSpecResp && !weixinDispatchResp && !prewarmStatusResp) { return el("div", { class: "card" }, [ el("div", { class: "card__title", text: t("title.stack") || "Stack" }), el("div", { class: "muted", text: currentLang === "zh" ? "未登录或会话已过期,请重新登录。" : "Not logged in or session expired. Please log in again." }), ]); } - const requiredServices = ["gateway", "channel:wecom"]; - const runningNames = new Set( - (Array.isArray(st.items) ? st.items : []) - .filter((x) => Boolean(x && x.running)) - .map((x) => String(x.name || "")), - ); - const missingRequired = requiredServices.filter((n) => !runningNames.has(n)); - const items = (st.items || []).flatMap((x) => { - const name = String((x && x.name) || ""); - const running = Boolean(x && x.running); - const allPids = Array.isArray(x && x.all_pids) ? x.all_pids.map((v) => String(v || "").trim()).filter(Boolean) : []; - const primary = String((x && x.pid) || "").trim(); - const pidPorts = (x && x.pid_ports && typeof x.pid_ports === "object") ? x.pid_ports : {}; - const pidRunning = (x && x.pid_running && typeof x.pid_running === "object") ? x.pid_running : {}; - const rows = allPids.length ? allPids : (primary ? [primary] : []); - return rows.map((pid) => { - const ports = Array.isArray(pidPorts[pid]) ? pidPorts[pid].map((v) => String(v || "").trim()).filter(Boolean) : []; - const isPrimary = primary && pid === primary; - const isRunning = Boolean(pidRunning[pid]); - return el("tr", {}, [ - el("td", {}, [ - el("div", { text: pid || "-" }), - isPrimary ? el("div", { class: "muted", text: "primary" }) : el("span"), - ]), - el("td", { text: name }), - el("td", { text: ports.length ? ports.join(", ") : "-" }), - el("td", {}, [ - el("span", { class: "badge " + (isRunning ? "badge--ok" : "badge--bad"), text: isRunning ? t("status.running") : t("status.stopped") }), - ]), - ]); - }); - }); - const btnUp = el("button", { class: "btn btn--primary", text: t("stack.up"), onclick: async () => { - await apiPost("/admin/api/stack/up", { channel: "wecom" }); - router(); - }}); - const btnDown = el("button", { class: "btn btn--danger", text: t("stack.down"), onclick: async () => { - 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"]; @@ -1663,7 +1656,11 @@ async function renderStack() { el("option", { value: "zh", text: currentLang === "zh" ? "中文" : "Chinese", selected: curLang === "zh" ? "selected" : undefined }), el("option", { value: "en", text: currentLang === "zh" ? "英文" : "English", selected: curLang === "en" ? "selected" : undefined }), ]); - const status = el("div", { class: "muted", text: `mode=${curMode} specialist=${curSpecialist} lang=${curLang}` }); + const status = renderMetaChips({ mode: curMode, specialist: curSpecialist, lang: curLang }); + const paintStatus = (obj) => { + const fresh = renderMetaChips(obj); + status.replaceChildren(...Array.from(fresh.childNodes)); + }; const saveExpertBtn = el("button", { class: "btn btn--primary", text: currentLang === "zh" ? "绑定专家" : "Bind specialist", @@ -1675,20 +1672,35 @@ async function renderStack() { specialist, lang, }); - status.textContent = `mode=${String(resp.interaction_mode || "expert")} specialist=${String(resp.specialist || specialist)} lang=${String(resp.lang || lang)}`; + paintStatus({ + mode: String(resp.interaction_mode || "expert"), + specialist: String(resp.specialist || specialist), + lang: String(resp.lang || lang), + }); }, }); return el("div", { class: "card" }, [ - el("div", { class: "card__title", text: title }), - el("div", { class: "row" }, [ - el("label", { text: currentLang === "zh" ? "专家" : "Specialist" }), - specialistSel, - el("label", { text: currentLang === "zh" ? "语言" : "Lang" }), - langSel, - saveExpertBtn, + el("div", { class: "card__head" }, [ + el("div", { class: "card__headline" }, [ + el("div", { class: "card__title", text: title }), + el("div", { + class: "card__subtitle", + text: currentLang === "zh" ? "通道统一走专家模式;选择默认专家即可。" : "Channels are expert-only; pick the default specialist.", + }), + ]), + ]), + el("div", { class: "form-inline" }, [ + el("div", { class: "form-inline__field" }, [ + el("label", { text: currentLang === "zh" ? "专家" : "Specialist" }), + specialistSel, + ]), + el("div", { class: "form-inline__field" }, [ + el("label", { text: currentLang === "zh" ? "语言" : "Lang" }), + langSel, + ]), + el("div", { class: "form-inline__actions" }, [saveExpertBtn]), ]), status, - el("div", { class: "muted", text: currentLang === "zh" ? "通道统一走专家模式;选择默认专家即可。" : "Channels are expert-only; pick the default specialist." }), ]); }; const weixinDispatchCard = createChannelDispatchCard("weixin", "Weixin dispatch", weixinDispatchResp || {}); @@ -1753,9 +1765,14 @@ async function renderStack() { else if (lt === "whitelist") waCounts.whitelist += 1; else if (lt === "blacklist") waCounts.blacklist += 1; }); - const waAccessStatus = el("div", { - class: "muted", - text: `mode=${String(waAccessCfg.access_mode || "blacklist")} lang=${String(waAccessCfg.lang || "en")} admin=${waCounts.admin} whitelist=${waCounts.whitelist} blacklist=${waCounts.blacklist} pending=${waPending.length} denied=${waDenied.length}`, + const waAccessStatus = renderMetaChips({ + mode: String(waAccessCfg.access_mode || "blacklist"), + lang: String(waAccessCfg.lang || "en"), + admin: waCounts.admin, + whitelist: waCounts.whitelist, + blacklist: waCounts.blacklist, + pending: waPending.length, + denied: waDenied.length, }); const waContactFilterSel = el("select", { class: "input" }, [ el("option", { value: "all", text: currentLang === "zh" ? "全部" : "All" }), @@ -1979,156 +1996,177 @@ async function renderStack() { ]); }); const whatsappAccessCard = el("div", { class: "card" }, [ - el("div", { class: "card__title", text: currentLang === "zh" ? "WhatsApp 访问控制" : "WhatsApp access control" }), - el("div", { class: "muted", text: currentLang === "zh" ? "默认黑名单模式:未授权用户进入待审批;拒绝后进入黑名单/已拒绝列表,可一键加白名单。" : "Default blacklist mode: unauthorized users go to Pending; denied users go to blacklist/denied list and can be whitelisted." }), - el("div", { class: "row" }, [ - el("label", { text: currentLang === "zh" ? "模式" : "Mode" }), - waAccessModeSel, - el("label", { text: currentLang === "zh" ? "提示语言" : "Message lang" }), - waAccessLangSel, - el("button", { - class: "btn btn--primary", - text: currentLang === "zh" ? "保存配置" : "Save config", - onclick: async () => { - const resp = await apiPost("/admin/api/whatsapp/access/config", { - tenant_id: "default", - access_mode: String(waAccessModeSel.value || "blacklist"), - lang: String(waAccessLangSel.value || "en"), - }); - const cfg = (resp && resp.config) || {}; - waAccessStatus.textContent = `mode=${String(cfg.access_mode || "blacklist")} lang=${String(cfg.lang || "en")}`; - }, - }), + el("div", { class: "card__head" }, [ + el("div", { class: "card__headline" }, [ + el("div", { class: "card__title", text: currentLang === "zh" ? "WhatsApp 访问控制" : "WhatsApp access control" }), + el("div", { + class: "card__subtitle", + text: currentLang === "zh" ? "默认黑名单模式:未授权用户进入待审批;拒绝后进入黑名单/已拒绝列表,可一键加白名单。" : "Default blacklist mode: unauthorized users go to Pending; denied users go to blacklist/denied list and can be whitelisted.", + }), + ]), + ]), + el("div", { class: "form-inline" }, [ + el("div", { class: "form-inline__field" }, [ + el("label", { text: currentLang === "zh" ? "模式" : "Mode" }), + waAccessModeSel, + ]), + el("div", { class: "form-inline__field" }, [ + el("label", { text: currentLang === "zh" ? "提示语言" : "Message lang" }), + waAccessLangSel, + ]), + el("div", { class: "form-inline__actions" }, [ + el("button", { + class: "btn btn--primary", + text: currentLang === "zh" ? "保存配置" : "Save config", + onclick: async () => { + const resp = await apiPost("/admin/api/whatsapp/access/config", { + tenant_id: "default", + access_mode: String(waAccessModeSel.value || "blacklist"), + lang: String(waAccessLangSel.value || "en"), + }); + const cfg = (resp && resp.config) || {}; + waAccessStatus.replaceChildren( + ...Array.from( + renderMetaChips({ + mode: String(cfg.access_mode || "blacklist"), + lang: String(cfg.lang || "en"), + admin: waCounts.admin, + whitelist: waCounts.whitelist, + blacklist: waCounts.blacklist, + pending: waPending.length, + denied: waDenied.length, + }).childNodes, + ), + ); + }, + }), + ]), ]), waAccessStatus, - el("div", { class: "row" }, [ - el("label", { text: currentLang === "zh" ? "筛选" : "Filter" }), - waContactFilterSel, + el("div", { class: "form-inline" }, [ + el("div", { class: "form-inline__field" }, [ + el("label", { text: currentLang === "zh" ? "筛选" : "Filter" }), + waContactFilterSel, + ]), ]), - el("div", { class: "row" }, [waPendingPickSel]), - el("div", { class: "row" }, [waContactPhoneInput, waContactNameInput, waContactTypeSel]), - el("div", { class: "row" }, [ - el("button", { - class: "btn", - text: currentLang === "zh" ? "添加联系人" : "Add contact", - onclick: async () => { - const phone = String(waContactPhoneInput.value || "").trim(); - if (!phone) return; - try { - const resp = await apiPost("/admin/api/whatsapp/access/contacts", { - phone, - push_name: String(waContactNameInput.value || "").trim(), - list_type: String(waContactTypeSel.value || "whitelist"), - }); - if (!resp || resp.ok === false) { - window.alert(String((resp && resp.error) || (currentLang === "zh" ? "添加失败" : "Add failed"))); - return; + el("div", { class: "form-inline" }, [ + el("div", { class: "form-inline__field form-inline__field--wide" }, [waPendingPickSel]), + ]), + el("div", { class: "form-inline" }, [ + el("div", { class: "form-inline__field" }, [waContactPhoneInput]), + el("div", { class: "form-inline__field" }, [waContactNameInput]), + el("div", { class: "form-inline__field" }, [waContactTypeSel]), + el("div", { class: "form-inline__actions" }, [ + el("button", { + class: "btn", + text: currentLang === "zh" ? "添加联系人" : "Add contact", + onclick: async () => { + const phone = String(waContactPhoneInput.value || "").trim(); + if (!phone) return; + try { + const resp = await apiPost("/admin/api/whatsapp/access/contacts", { + phone, + push_name: String(waContactNameInput.value || "").trim(), + list_type: String(waContactTypeSel.value || "whitelist"), + }); + if (!resp || resp.ok === false) { + window.alert(String((resp && resp.error) || (currentLang === "zh" ? "添加失败" : "Add failed"))); + return; + } + router(); + } catch (err) { + window.alert(String(err)); } - router(); - } catch (err) { - window.alert(String(err)); - } - }, - }), + }, + }), + ]), ]), - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: currentLang === "zh" ? "类型" : "Type" }), - el("th", { text: currentLang === "zh" ? "名称" : "Name" }), - el("th", { text: currentLang === "zh" ? "电话" : "Phone" }), - el("th", { text: currentLang === "zh" ? "操作" : "Action" }), - ])]), - waContactsTbody, + el("div", { class: "table-wrap" }, [ + el("table", { class: "table" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: currentLang === "zh" ? "类型" : "Type" }), + el("th", { text: currentLang === "zh" ? "名称" : "Name" }), + el("th", { text: currentLang === "zh" ? "电话" : "Phone" }), + el("th", { text: currentLang === "zh" ? "操作" : "Action" }), + ])]), + waContactsTbody, + ]), ]), el("div", { class: "card__title", text: currentLang === "zh" ? "待审批请求" : "Pending requests" }), - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: currentLang === "zh" ? "名称" : "Name" }), - el("th", { text: currentLang === "zh" ? "电话" : "Phone" }), - el("th", { text: "JID" }), - el("th", { text: currentLang === "zh" ? "消息" : "Message" }), - el("th", { text: currentLang === "zh" ? "时间" : "Time" }), - el("th", { text: currentLang === "zh" ? "操作" : "Action" }), - ])]), - el("tbody", {}, waPendingRows.length ? waPendingRows : [el("tr", {}, [el("td", { colspan: "6", text: currentLang === "zh" ? "无待审批" : "None" })])]), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: currentLang === "zh" ? "名称" : "Name" }), + el("th", { text: currentLang === "zh" ? "电话" : "Phone" }), + el("th", { text: "JID" }), + el("th", { text: currentLang === "zh" ? "消息" : "Message" }), + el("th", { text: currentLang === "zh" ? "时间" : "Time" }), + el("th", { text: currentLang === "zh" ? "操作" : "Action" }), + ])]), + el("tbody", {}, waPendingRows.length ? waPendingRows : [el("tr", {}, [el("td", { colspan: "6", text: currentLang === "zh" ? "无待审批" : "None" })])]), + ]), ]), el("div", { class: "card__title", text: currentLang === "zh" ? "已拒绝 / 黑名单记录" : "Denied / blacklist records" }), - el("div", { class: "muted", text: currentLang === "zh" ? "点「拒绝」后用户会进入黑名单联系人,并保留在此列表;可一键加白名单恢复访问。" : "Denied users are blacklisted and listed here; use Whitelist to restore access." }), - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: currentLang === "zh" ? "名称" : "Name" }), - el("th", { text: currentLang === "zh" ? "电话" : "Phone" }), - el("th", { text: currentLang === "zh" ? "消息" : "Message" }), - el("th", { text: currentLang === "zh" ? "拒绝时间" : "Denied at" }), - el("th", { text: currentLang === "zh" ? "操作" : "Action" }), - ])]), - el("tbody", {}, waDeniedRows.length ? waDeniedRows : [el("tr", {}, [el("td", { colspan: "5", text: currentLang === "zh" ? "无已拒绝记录" : "None" })])]), + el("div", { class: "card__hint", text: currentLang === "zh" ? "点「拒绝」后用户会进入黑名单联系人,并保留在此列表;可一键加白名单恢复访问。" : "Denied users are blacklisted and listed here; use Whitelist to restore access." }), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: currentLang === "zh" ? "名称" : "Name" }), + el("th", { text: currentLang === "zh" ? "电话" : "Phone" }), + el("th", { text: currentLang === "zh" ? "消息" : "Message" }), + el("th", { text: currentLang === "zh" ? "拒绝时间" : "Denied at" }), + el("th", { text: currentLang === "zh" ? "操作" : "Action" }), + ])]), + el("tbody", {}, waDeniedRows.length ? waDeniedRows : [el("tr", {}, [el("td", { colspan: "5", text: currentLang === "zh" ? "无已拒绝记录" : "None" })])]), + ]), ]), ]); const whatsappAlertBindingCard = el("div", { class: "card" }, [ - el("div", { class: "card__title", text: currentLang === "zh" ? "WhatsApp 告警群绑定" : "WhatsApp alert group" }), - el("div", { class: "muted", text: currentLang === "zh" ? "NetX 关键告警将推送到此群;与 Chat 会话删除无关。" : "NetX key alerts go to this group; independent of chat sessions." }), - el("div", { class: "row" }, [waGroupSel]), - el("div", { class: "row" }, [ - el("button", { - class: "btn btn--primary", - text: currentLang === "zh" ? "保存绑定" : "Save binding", - onclick: async () => { - const group_jid = String(waGroupSel.value || "").trim(); - if (!group_jid) return; - const picked = waGroups.find((g) => String((g && g.group_jid) || "").trim() === group_jid) || {}; - const resp = await apiPost("/admin/api/whatsapp/alert-binding", { - tenant_id: "default", - group_jid, - group_name: String((picked && picked.group_name) || ""), - enabled: true, - }); - const b = (resp && resp.binding) || {}; - waBindingStatus.textContent = b.group_jid - ? `binding=${b.group_jid} enabled=${Boolean(b.enabled)}` - : (currentLang === "zh" ? "绑定失败" : "Bind failed"); - }, - }), - el("button", { - class: "btn", - text: currentLang === "zh" ? "测试推送" : "Test push", - onclick: async () => { - const resp = await apiPost("/admin/api/whatsapp/alert-binding/test", { tenant_id: "default" }); - waBindingStatus.textContent = resp && resp.ok - ? `test ok outbound_id=${String(resp.outbound_id || "")}` - : `test failed: ${String((resp && resp.error) || "unknown")}`; - }, - }), + el("div", { class: "card__head" }, [ + el("div", { class: "card__headline" }, [ + el("div", { class: "card__title", text: currentLang === "zh" ? "WhatsApp 告警群绑定" : "WhatsApp alert group" }), + el("div", { + class: "card__subtitle", + text: currentLang === "zh" ? "NetX 关键告警将推送到此群;与 Chat 会话删除无关。" : "NetX key alerts go to this group; independent of chat sessions.", + }), + ]), + ]), + el("div", { class: "form-inline" }, [ + el("div", { class: "form-inline__field form-inline__field--wide" }, [waGroupSel]), + el("div", { class: "form-inline__actions" }, [ + el("button", { + class: "btn btn--primary", + text: currentLang === "zh" ? "保存绑定" : "Save binding", + onclick: async () => { + const group_jid = String(waGroupSel.value || "").trim(); + if (!group_jid) return; + const picked = waGroups.find((g) => String((g && g.group_jid) || "").trim() === group_jid) || {}; + const resp = await apiPost("/admin/api/whatsapp/alert-binding", { + tenant_id: "default", + group_jid, + group_name: String((picked && picked.group_name) || ""), + enabled: true, + }); + const b = (resp && resp.binding) || {}; + waBindingStatus.textContent = b.group_jid + ? `binding=${b.group_jid} enabled=${Boolean(b.enabled)}` + : (currentLang === "zh" ? "绑定失败" : "Bind failed"); + }, + }), + el("button", { + class: "btn", + text: currentLang === "zh" ? "测试推送" : "Test push", + onclick: async () => { + const resp = await apiPost("/admin/api/whatsapp/alert-binding/test", { tenant_id: "default" }); + waBindingStatus.textContent = resp && resp.ok + ? `test ok outbound_id=${String(resp.outbound_id || "")}` + : `test failed: ${String((resp && resp.error) || "unknown")}`; + }, + }), + ]), ]), waBindingStatus, ]); - 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", {}); - const killed = Array.isArray(resp.killed) ? resp.killed : []; - const total = killed.reduce((acc, x) => acc + ((x && Array.isArray(x.killed_pids)) ? x.killed_pids.length : 0), 0); - cleanupStatus.textContent = total > 0 ? tf("stack.cleanupDone", { count: total }) : t("stack.cleanupNone"); - router(); - }}); - const alerts = Array.isArray(anomaliesResp.items) ? anomaliesResp.items : []; - const mustCleanup = Boolean(anomaliesResp.must_cleanup); - const mergedAlerts = missingRequired.length - ? [] - : alerts.filter((a) => { - const typ = String((a && a.type) || ""); - return typ !== "duplicate_process_ambiguous"; - }); - if (missingRequired.length) { - mergedAlerts.unshift({ - severity: "critical", - message: tf("stack.missingServices", { services: missingRequired.join(", ") }), - }); - } - const alertRows = mergedAlerts.length - ? mergedAlerts.map((a) => el("li", { - text: `[${String(a.severity || "info").toUpperCase()}] ${String(a.message || "")}`, - })) - : [el("li", { text: t("stack.noAlerts") })]; const scanItems = Array.isArray(scanResp && scanResp.items) ? scanResp.items : []; const scanDir = String((scanResp && scanResp.dir) || ""); const scanStatus = el("div", { class: "muted", text: "" }); @@ -2269,88 +2307,92 @@ async function renderStack() { renderPromptCards(resp); }); renderPromptCards(prewarmPromptsResp); - return el("div", {}, [ - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("stack.alerts") }), - (mustCleanup || missingRequired.length) - ? el("div", { - class: "alert alert--critical", - text: missingRequired.length ? t("stack.missingRequired") : t("stack.mustCleanup"), - }) - : el("div", { class: "muted", text: t("stack.noAlerts") }), - el("ul", { class: "alert-list" }, alertRows), - missingRequired.length ? el("div", { class: "muted", text: t("stack.missingRequired") }) : el("div", { class: "row" }, [btnCleanup]), - cleanupStatus, + return el("div", { class: "page-shell" }, [ + el("div", { class: "page-grid page-grid--two" }, [ + weixinDispatchCard, + whatsappDispatchCard, ]), - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("stack.title") }), - el("div", { class: "row" }, [btnUp, btnDown]), - el("div", { class: "u-h-10" }), - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: t("table.pid") }), el("th", { text: t("table.service") }), el("th", { text: "端口" }), el("th", { text: t("table.status") })])]), - el("tbody", {}, items), - ]), - ]), - weixinDispatchCard, - whatsappDispatchCard, whatsappAccessCard, whatsappAlertBindingCard, el("div", { class: "card" }, [ - el("div", { class: "card__title", text: currentLang === "zh" ? "提示词/工具预热" : "Prompt/Tool Prewarm" }), - el("div", { class: "muted", text: prewarmSummary }), - runtimePrewarmReminder ? el("div", { class: "alert alert--warning", text: runtimePrewarmReminder }) : el("span"), + el("div", { class: "card__head" }, [ + el("div", { class: "card__headline" }, [ + el("div", { class: "card__title", text: currentLang === "zh" ? "提示词/工具预热" : "Prompt/Tool Prewarm" }), + el("div", { class: "card__subtitle", text: prewarmSummary }), + ]), + el("div", { class: "card__actions" }, [btnPrewarm]), + ]), + runtimePrewarmReminder ? el("div", { class: "alert alert--warning", text: runtimePrewarmReminder }) : null, el( "div", { - class: "muted", + class: "card__hint", text: currentLang === "zh" ? "任何 skill/tool/角色/提示词变更后,请立即预热;复杂变更可直接重启。系统每10分钟自动异步预热一次。" : "After any skill/tool/role/prompt change, run prewarm immediately; restart for complex changes. System also auto-prewarms every 10 minutes.", }, ), - el("div", { class: "row" }, [btnPrewarm]), prewarmStatus, - ]), + ].filter(Boolean)), el("div", { class: "card" }, [ el("div", { class: "card__title", text: currentLang === "zh" ? "预热历史(最近20次)" : "Prewarm History (latest 20)" }), - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "finished_at" }), - el("th", { text: "reason" }), - el("th", { text: "ok" }), - el("th", { text: "elapsed_ms" }), - el("th", { text: "error" }), - ])]), - el("tbody", {}, historyRows), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "finished_at" }), + el("th", { text: "reason" }), + el("th", { text: "ok" }), + el("th", { text: "elapsed_ms" }), + el("th", { text: "error" }), + ])]), + el("tbody", {}, historyRows), + ]), ]), ]), el("div", { class: "card" }, [ - el("div", { class: "card__title", text: currentLang === "zh" ? "预热后提示词(按专家)" : "Prewarmed Prompts by Role" }), - el("div", { class: "muted", text: currentLang === "zh" ? "展示各专家预热后的提示词内容。" : "Shows prewarmed prompt content for specialists." }), - el("div", { class: "row" }, [ - el("label", { text: currentLang === "zh" ? "角色筛选" : "Role filter" }), - promptsRoleSelect, - btnReloadPrompts, + el("div", { class: "card__head" }, [ + el("div", { class: "card__headline" }, [ + el("div", { class: "card__title", text: currentLang === "zh" ? "预热后提示词(按专家)" : "Prewarmed Prompts by Role" }), + el("div", { + class: "card__subtitle", + text: currentLang === "zh" ? "展示各专家预热后的提示词内容。" : "Shows prewarmed prompt content for specialists.", + }), + ]), + ]), + el("div", { class: "form-inline" }, [ + el("div", { class: "form-inline__field" }, [ + el("label", { text: currentLang === "zh" ? "角色筛选" : "Role filter" }), + promptsRoleSelect, + ]), + el("div", { class: "form-inline__actions" }, [btnReloadPrompts]), ]), promptsSectionBody, ]), el("div", { class: "card" }, [ - el("div", { class: "card__title", text: "扫描缓存文件" }), - el("div", { class: "muted", text: scanDir || "-" }), - el("div", { class: "muted", text: "仅清理 history_entries_*.json 与 state_scan_*.json" }), - el("div", { class: "row" }, [ - el("label", { text: "keep_latest" }), - keepLatestInput, - el("label", { text: "max_age_days" }), - maxAgeDaysInput, - btnPruneScan, - btnCleanScan, + el("div", { class: "card__head" }, [ + el("div", { class: "card__headline" }, [ + el("div", { class: "card__title", text: "扫描缓存文件" }), + el("div", { class: "card__subtitle", text: `${scanDir || "-"} · 仅清理 history_entries_*.json 与 state_scan_*.json` }), + ]), + ]), + el("div", { class: "form-inline" }, [ + el("div", { class: "form-inline__field" }, [ + el("label", { text: "keep_latest" }), + keepLatestInput, + ]), + el("div", { class: "form-inline__field" }, [ + el("label", { text: "max_age_days" }), + maxAgeDaysInput, + ]), + el("div", { class: "form-inline__actions" }, [btnPruneScan, btnCleanScan]), ]), scanStatus, - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "name" }), el("th", { text: "bytes" }), el("th", { text: "modified_at" })])]), - el("tbody", {}, scanRows), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table" }, [ + el("thead", {}, [el("tr", {}, [el("th", { text: "name" }), el("th", { text: "bytes" }), el("th", { text: "modified_at" })])]), + el("tbody", {}, scanRows), + ]), ]), ]), ]); @@ -4135,7 +4177,7 @@ async function renderModels() { secretsCard.style.display = "none"; return; } - secretsCard.style.display = ""; + secretsCard.style.display = "block"; if (!canMigrate) { secretsMigrateBtn.disabled = true; secretsStatusMsg.textContent = t("secrets.migrateForbidden"); @@ -4478,7 +4520,7 @@ async function renderModels() { modelsGrantsLinkRow.innerHTML = ""; if (state.can_manage_llm_grants) { - modelsGrantsLinkRow.style.display = ""; + modelsGrantsLinkRow.style.display = "block"; modelsGrantsLinkRow.appendChild(el("span", { text: `${t("models.grantsNavHint")} ` })); modelsGrantsLinkRow.appendChild(el("a", { href: "#/api-grants", text: t("models.linkApiGrants") })); } else { @@ -5908,13 +5950,13 @@ async function renderPlugins() { healthBtn.className = "chat-sess-menu-item"; syncBtn.className = "chat-sess-menu-item"; const actionMenuBtn = el("button", { - class: "chat-sess-more u-overlay-fixed", + class: "chat-sess-more", text: "⋯", title: "MCP actions", onclick: (ev) => { ev.stopPropagation(); closeMcpActionMenu(); - const menu = el("div", { class: "chat-sess-menu-pop" }, [ + const menu = el("div", { class: "chat-sess-menu-pop u-overlay-fixed" }, [ toggleBtn, healthBtn, syncBtn, @@ -8757,13 +8799,13 @@ async function renderSkills() { }, }); const actionMenuBtn = el("button", { - class: "chat-sess-more u-overlay-fixed", + class: "chat-sess-more", text: "⋯", title: "Skill actions", onclick: (ev) => { ev.stopPropagation(); closeSkillActionMenu(); - const menu = el("div", { class: "chat-sess-menu-pop" }, [ + const menu = el("div", { class: "chat-sess-menu-pop u-overlay-fixed" }, [ toggleBtn, testRunBtn, repairDepsBtn, @@ -9115,7 +9157,7 @@ async function renderScheduledJobs() { scheduledJobMenuEl = null; }; - const status = el("select", {}, [ + const status = el("select", { class: "input input--compact" }, [ el("option", { value: "", text: t("scheduledJobs.all") }), el("option", { value: "active", text: t("scheduledJobs.statusActive") }), el("option", { value: "paused", text: t("scheduledJobs.statusPaused") }), @@ -9124,18 +9166,24 @@ async function renderScheduledJobs() { const runsBox = el("pre", { class: "code", style: "max-height:220px;overflow:auto;white-space:pre-wrap;" }); const msg = el("div", { class: "muted", text: "" }); + const field = (label, control, wide) => + el("div", { class: wide ? "form-field form-field--full" : "form-field" }, [ + el("label", { class: "form-field__label", text: label }), + control, + ]); + const editModal = el("div", { class: "session-monitor-modal u-hidden" }); const editTitle = el("div", { class: "card__title", text: t("scheduledJobs.editTitle") }); - const editNameInput = el("input", { class: "input", placeholder: "name" }); - const editKindInput = el("select", {}, [ + const editNameInput = el("input", { class: "input", placeholder: t("scheduledJobs.phName") }); + const editKindInput = el("select", { class: "input" }, [ 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 editExprInput = el("input", { class: "input", placeholder: t("scheduledJobs.phExpr") }); + const editPromptInput = el("textarea", { class: "input", rows: "5", placeholder: t("scheduledJobs.phPrompt") }); + const editSpecialistInput = el("input", { class: "input", placeholder: t("scheduledJobs.phSpecialist") }); + const editWaChatInput = el("input", { class: "input", placeholder: t("scheduledJobs.phWhatsapp") }); const editWeixinInfo = el("div", { class: "muted", text: "" }); const closeEditModal = () => { editingJob = null; @@ -9194,14 +9242,21 @@ async function renderScheduledJobs() { const editModalCard = el("div", { class: "card session-monitor-modal__card", style: "width:min(640px,96vw);" }, [ editTitle, editWeixinInfo, - editNameInput, - el("div", { class: "row u-gap-8" }, [editKindInput, editExprInput]), - editPromptInput, - editSpecialistInput, - editWaChatInput, - el("div", { class: "row u-row-end" }, [ - el("button", { class: "btn", text: t("scheduledJobs.cancel"), onclick: closeEditModal }), - editSaveBtn, + el("div", { class: "form-stack" }, [ + field(t("scheduledJobs.fieldName"), editNameInput, true), + el("div", { class: "form-grid" }, [ + field(t("scheduledJobs.fieldKind"), editKindInput), + field(t("scheduledJobs.fieldExpr"), editExprInput), + ]), + field(t("scheduledJobs.fieldPrompt"), editPromptInput, true), + el("div", { class: "form-grid" }, [ + field(t("scheduledJobs.fieldSpecialist"), editSpecialistInput), + field(t("scheduledJobs.fieldWhatsapp"), editWaChatInput), + ]), + el("div", { class: "form-actions" }, [ + el("button", { class: "btn", text: t("scheduledJobs.cancel"), onclick: closeEditModal }), + editSaveBtn, + ]), ]), ]); editModal.appendChild(editModalCard); @@ -9235,13 +9290,13 @@ async function renderScheduledJobs() { for (const job of resp.items || []) { const jobId = String(job.id || ""); const btnMore = el("button", { - class: "chat-sess-more u-overlay-fixed", + class: "chat-sess-more", text: "⋯", title: t("scheduledJobs.menuTitle"), onclick: (ev) => { ev.stopPropagation(); closeScheduledJobMenu(); - const menu = el("div", { class: "chat-sess-menu-pop" }, [ + const menu = el("div", { class: "chat-sess-menu-pop u-overlay-fixed" }, [ el("button", { class: "chat-sess-menu-item", text: t("scheduledJobs.viewRuns"), @@ -9353,16 +9408,16 @@ async function renderScheduledJobs() { await loadLatestRuns(resp.items || []); } - const nameInput = el("input", { class: "input", placeholder: "name" }); - const kindInput = el("select", {}, [ + const nameInput = el("input", { class: "input", placeholder: t("scheduledJobs.phName") }); + const kindInput = el("select", { class: "input" }, [ 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 exprInput = el("input", { class: "input", placeholder: t("scheduledJobs.phExpr") }); + const promptInput = el("textarea", { class: "input", rows: "5", placeholder: t("scheduledJobs.phPrompt") }); + const specialistInput = el("input", { class: "input", placeholder: t("scheduledJobs.phSpecialist"), value: "generalist" }); + const waChatInput = el("input", { class: "input", placeholder: t("scheduledJobs.phWhatsapp") }); const weixinInfo = el("div", { class: "muted", text: "" }); async function loadMeta() { @@ -9379,27 +9434,36 @@ async function renderScheduledJobs() { const createCardChildren = [ el("div", { class: "card__title", text: t("scheduledJobs.createTitle") }), weixinInfo, - nameInput, - el("div", { class: "row u-gap-8" }, [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(); - }, - }), + el("div", { class: "form-stack" }, [ + field(t("scheduledJobs.fieldName"), nameInput, true), + el("div", { class: "form-grid" }, [ + field(t("scheduledJobs.fieldKind"), kindInput), + field(t("scheduledJobs.fieldExpr"), exprInput), + ]), + field(t("scheduledJobs.fieldPrompt"), promptInput, true), + el("div", { class: "form-grid" }, [ + field(t("scheduledJobs.fieldSpecialist"), specialistInput), + field(t("scheduledJobs.fieldWhatsapp"), waChatInput), + ]), + el("div", { class: "form-actions" }, [ + 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", {}, [ diff --git a/interfaces/admin/static/chat.html b/interfaces/admin/static/chat.html index 60066ef6..e624c93f 100644 --- a/interfaces/admin/static/chat.html +++ b/interfaces/admin/static/chat.html @@ -51,18 +51,19 @@ display: flex; align-items: stretch; gap: 0; - margin-bottom: 4px; + margin-bottom: 2px; + min-height: 40px; border: 1px solid transparent; - border-radius: 0; + border-radius: 8px; background: transparent; } .chat-sess-row:hover { background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.05)); - border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.09)); + border-color: transparent; } .chat-sess-row--active { - background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062)); - border-color: rgba(255, 255, 255, 0.08); + background: var(--chat-sess-active-bg, rgba(148, 163, 184, 0.12)); + border-color: transparent; } .chat-sess-row .chat-sess-btn { flex: 1; @@ -117,7 +118,8 @@ background: transparent; } .chat-sess-menu-pop { - z-index: 200; + /* Must stay above .u-overlay-scrim (250) or menu clicks hit the transparent scrim. */ + z-index: 300; background: var(--ds-panel, var(--ds-surface, #222)); border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12)); border-radius: 8px; @@ -260,16 +262,16 @@ opacity: 0.9; } .chat-att-ref { - min-width: 180px; - max-width: 260px; + min-width: 200px; + max-width: 280px; border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.14)); - border-radius: 8px; - background: rgba(255, 255, 255, 0.03); - padding: 8px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); + padding: 10px 12px; box-sizing: border-box; } .chat-att-ref__name { - font-size: 12px; + font-size: 13px; font-weight: 600; line-height: 1.35; margin-bottom: 4px; @@ -281,32 +283,42 @@ opacity: 0.75; word-break: break-all; } - .chat-att-ref__link { - display: inline-block; - margin-top: 6px; - font-size: 12px; - color: var(--ds-accent, #5eb3ff); - text-decoration: none; - } - .chat-att-ref__link:hover { - text-decoration: underline; + .chat-att-ref__actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; } + .chat-att-ref__action, + .chat-att-ref__link, .chat-att-ref__btn { - margin-top: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0; border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.14)); - background: rgba(255, 255, 255, 0.04); - color: inherit; + background: rgba(255, 255, 255, 0.05); + color: var(--ds-text, #e8e8e8); border-radius: 6px; font-size: 12px; + font-weight: 500; line-height: 1.2; - padding: 4px 8px; + padding: 4px 9px; + text-decoration: none; cursor: pointer; } + .chat-att-ref__action:hover, + .chat-att-ref__link:hover, .chat-att-ref__btn:hover { - background: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.09); + text-decoration: none; + } + .chat-att-ref__action:disabled { + opacity: 0.55; + cursor: not-allowed; } .chat-att-ref__preview { - margin: 6px 0 0; + margin: 8px 0 0; padding: 8px; border-radius: 6px; border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.14)); @@ -321,10 +333,64 @@ .chat-att-ref__preview:empty { display: none; } + .chat-composer-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 2px 4px 8px; + } + .chat-composer-chip, + .chat-composer-chip--select { + appearance: none; + -webkit-appearance: none; + height: 30px; + min-height: 30px; + max-height: 30px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.28)); + background-color: var(--ds-surface-2, #152033); + color: var(--ds-text, #e2e8f0); + font-size: 12px; + font-weight: 500; + line-height: 1; + box-sizing: border-box; + cursor: pointer; + } + .chat-composer-chip--select { + min-width: 148px; + max-width: 240px; + padding-right: 28px; + background-color: var(--ds-surface-2, #152033); + background-image: + linear-gradient(45deg, transparent 50%, var(--ds-text-muted, rgba(148, 163, 184, 0.85)) 50%), + linear-gradient(135deg, var(--ds-text-muted, rgba(148, 163, 184, 0.85)) 50%, transparent 50%); + background-position: + calc(100% - 14px) calc(50% - 2px), + calc(100% - 9px) calc(50% - 2px); + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + } + .chat-composer-chip--select option { + background-color: var(--ds-surface, #0f172a); + color: var(--ds-text, #e2e8f0); + } + .chat-composer-chip:hover, + .chat-composer-chip--select:hover { + background-color: var(--ds-menu-hover-bg, rgba(148, 163, 184, 0.16)); + border-color: var(--ds-accent-border, rgba(94, 179, 255, 0.35)); + } + .chat-composer-chip:focus-visible, + .chat-composer-chip--select:focus-visible { + outline: none; + border-color: var(--ds-accent-border-strong, rgba(94, 179, 255, 0.5)); + box-shadow: 0 0 0 2px var(--ds-accent-soft, rgba(94, 179, 255, 0.2)); + } .chat-composer-shell { border-radius: 12px; - border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12)); - background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.18)); + background: var(--ds-composer-shell-bg, rgba(0, 0, 0, 0.28)); padding: 6px 8px 6px 6px; } .chat-composer-shell--busy .chat-composer__field { @@ -349,7 +415,7 @@ max-width: 100%; padding: 2px 8px 2px 10px; border-radius: 999px; - background: rgba(255, 255, 255, 0.08); + background: var(--ds-menu-hover-bg, rgba(148, 163, 184, 0.12)); font-size: 11px; } .chat-pending-row .muted { @@ -373,7 +439,7 @@ } .chat-pending-row .chat-pending-remove:hover { opacity: 1; - background: rgba(255, 255, 255, 0.12); + background: var(--ds-menu-hover-bg, rgba(148, 163, 184, 0.16)); } .chat-composer-row { display: flex; @@ -390,15 +456,15 @@ border: none; border-radius: 10px; background: transparent; - color: var(--ds-text-muted, rgba(232, 232, 232, 0.75)); + color: var(--ds-text-muted, rgba(148, 163, 184, 0.85)); cursor: pointer; display: flex; align-items: center; justify-content: center; } .chat-composer-iconbtn:hover { - background: rgba(255, 255, 255, 0.08); - color: var(--ds-text, #e8e8e8); + background: var(--ds-menu-hover-bg, rgba(148, 163, 184, 0.12)); + color: var(--ds-text, #e2e8f0); } .chat-composer-iconbtn:focus-visible { outline: 1px solid var(--chat-sess-border, rgba(255, 255, 255, 0.25)); @@ -677,22 +743,35 @@ white-space: pre-wrap; } - - - - - + + + + + +
@@ -700,10 +779,10 @@
- 正在加载登录界面… + 正在加载…
- 若长时间停留在此,请检查网络或稍后重试;桌面端可查看日志目录中的 desktop.log / backend.log。 + 正在启动本地界面;若长时间停留,请确认网关已启动。桌面端可查看 desktop.log / backend.log。
diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js index f1223c1f..d9eb5073 100644 --- a/interfaces/admin/static/chat.js +++ b/interfaces/admin/static/chat.js @@ -690,7 +690,7 @@ async function _buildAggregatedAssistantBubble(tsIso, items) { } const hasVisible = !!inner.querySelector(".chat-msg__md, .chat-msg__reasoning, .chat-msg__wiki") || - !!inner.querySelector(".chat-att-wrap, img.chat-att-img, a.chat-att-ref__link, .chat-att-chip") || + !!inner.querySelector(".chat-att-wrap, img.chat-att-img, a.chat-att-ref__link, a.chat-att-ref__action, .chat-att-chip") || String(inner.textContent || "").trim().length > 0; if (!hasVisible) { inner.appendChild(el("div", { class: "muted", text: t("chat.tools.hidden") })); @@ -1442,6 +1442,19 @@ function replaceSessionUrl(sessionId) { history.replaceState(null, "", path + q); } +function withTimeout(promise, ms, label) { + let timer = null; + const timeoutMs = Math.max(200, Number(ms) || 0); + return Promise.race([ + Promise.resolve(promise), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(label || `timeout:${timeoutMs}`)), timeoutMs); + }), + ]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + async function apiGet(path) { const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; const headers = { accept: "application/json" }; @@ -1535,6 +1548,160 @@ function escapeHtml(s) { .replace(/"/g, """); } +/** Pretty-print channel session titles like `whatsapp|AI nms · Oliver · wa-default+86…@s.whatsapp.net`. */ +function formatChatSessionTitle(raw, peerNameHint) { + const full = String(raw || "").trim(); + const badgeMap = { + whatsapp: "WA", + wechat: "WX", + weixin: "WX", + wecom: "WC", + telegram: "TG", + discord: "DC", + slack: "SL", + local: "Local", + }; + if (!full) { + return { channel: "local", badge: badgeMap.local, label: "—", sublabel: "", full: "" }; + } + const m = full.match(/^(whatsapp|wechat|weixin|wecom|telegram|discord|slack)\|(.+)$/i); + if (!m) { + return { channel: "local", badge: badgeMap.local, label: full, sublabel: "", full }; + } + const channel = String(m[1] || "").toLowerCase(); + const rest = String(m[2] || "").trim(); + const segments = rest + .split(/\s*·\s*/) + .map((s) => String(s || "").trim()) + .filter(Boolean); + + const isTech = (s) => /[+]/.test(s) || /@/.test(s) || /^wa-default/i.test(s); + let techIdx = -1; + for (let i = 0; i < segments.length; i += 1) { + if (isTech(segments[i])) techIdx = i; + } + let labels = []; + let tech = rest; + if (techIdx >= 0) { + labels = segments.slice(0, techIdx); + tech = segments.slice(techIdx).join(" · "); + } else if (segments.length > 1) { + labels = segments.slice(0, -1); + tech = segments[segments.length - 1] || ""; + } + + // Legacy titles buried group name after the JID: account+jid@domain+GroupName + let techBody = String(tech || "").replace(/^wa-default\+/i, ""); + const plusParts = techBody.split("+").map((s) => String(s || "").trim()).filter(Boolean); + const idParts = []; + const legacyNames = []; + for (const p of plusParts) { + if (p.includes("@") || /^[0-9]{8,}$/.test(p) || /^[0-9a-f]{16,}$/i.test(p)) idParts.push(p); + else legacyNames.push(p); + } + for (let i = legacyNames.length - 1; i >= 0; i -= 1) { + const n = legacyNames[i]; + if (n && !labels.includes(n)) labels.unshift(n); // group name first + } + let idRaw = idParts.join("+") || (legacyNames.length ? "" : techBody); + let idShort = idRaw + .replace(/@s\.whatsapp\.net$/i, "") + .replace(/@g\.us$/i, "") + .replace(/@lid$/i, "") + .replace(/@im\.bot[^\s]*/i, "") + .replace(/@[^\s]+$/i, ""); + if (/^[0-9a-f]{20,}$/i.test(idShort)) idShort = `${idShort.slice(0, 8)}…`; + else if (idShort.length > 18) idShort = `${idShort.slice(0, 16)}…`; + + const hint = String(peerNameHint || "").trim(); + if (hint && !labels.some((l) => l.toLowerCase() === hint.toLowerCase())) { + // Prefer keeping server-provided group/nick labels; append contact hint when useful. + if (!labels.length) labels.push(hint); + else if (labels.length === 1 && labels[0] !== hint) labels.push(hint); + } + + const badge = badgeMap[channel] || channel.slice(0, 2).toUpperCase(); + let label = ""; + if (labels.length) { + label = labels.join(" · "); + // DM / unknown: single nick still benefits from a short id. + if (labels.length === 1 && idShort && labels[0] === hint) { + label = `${labels[0]} · ${idShort}`; + } + } else { + label = idShort || full; + } + return { + channel, + badge, + label, + sublabel: "", + full, + idKey: idRaw || techBody, + }; +} + +function _peerNameLookupKeys(title, pretty) { + const keys = []; + const push = (v) => { + const s = String(v || "").trim().toLowerCase(); + if (s) keys.push(s); + }; + push(pretty && pretty.idKey); + const raw = String(title || ""); + const m = raw.match(/^(whatsapp|wechat|weixin|wecom)\|(.+)$/i); + if (m) { + let body = String(m[2] || ""); + const segs = body.split(/\s*·\s*/).map((s) => String(s || "").trim()).filter(Boolean); + let tech = body; + for (let i = segs.length - 1; i >= 0; i -= 1) { + if (/[+]/.test(segs[i]) || /@/.test(segs[i]) || /^wa-default/i.test(segs[i])) { + tech = segs[i]; + break; + } + } + push(tech); + push(tech.replace(/^wa-default\+/i, "")); + const noAt = tech.replace(/@.*$/, ""); + push(noAt); + push(noAt.replace(/^wa-default\+/i, "")); + // Drop trailing +GroupName legacy suffix for contact id matching. + const core = noAt.replace(/^wa-default\+/i, "").split("+")[0] || ""; + push(core); + push(core.replace(/\D/g, "")); + } + return keys; +} + +let _channelPeerNameMap = new Map(); + +function lookupChannelPeerName(title, pretty) { + for (const k of _peerNameLookupKeys(title, pretty)) { + const hit = _channelPeerNameMap.get(k); + if (hit) return hit; + } + return ""; +} + +async function refreshChannelPeerNameMap() { + const next = new Map(); + try { + const r = await apiGet("/admin/api/whatsapp/access"); + const contacts = Array.isArray(r && r.contacts) ? r.contacts : []; + for (const c of contacts) { + const name = String((c && c.push_name) || "").trim(); + if (!name) continue; + const eid = String((c && c.external_user_id) || "").trim(); + const phone = String((c && c.phone) || "").trim(); + for (const raw of [eid, phone, eid.replace(/@.*$/, ""), phone.replace(/\D/g, "")]) { + const k = String(raw || "").trim().toLowerCase(); + if (k) next.set(k, name); + } + } + } catch (_) {} + _channelPeerNameMap = next; +} + let _chatLightboxKeyHandler = null; let _chatLightboxPrevOverflow = ""; @@ -2677,15 +2844,16 @@ async function renderAttachmentsEl(raw) { const bytes = Number(att.bytes || 0); const sizeLabel = bytes > 0 ? `${Math.round((bytes / 1024) * 10) / 10} KB` : ""; const card = el("div", { class: "chat-att-ref" }); + if (aid) card.title = `id: ${aid}`; card.appendChild(el("div", { class: "chat-att-ref__name", text: name })); card.appendChild(el("div", { class: "chat-att-ref__meta", text: `${typ} · ${mime}${sizeLabel ? ` · ${sizeLabel}` : ""}` })); - if (aid) card.appendChild(el("div", { class: "chat-att-ref__meta", text: `id: ${aid.slice(0, 16)}...` })); + const actions = el("div", { class: "chat-att-ref__actions" }); if (aid) { const url = await fetchAttachmentBlobUrl(aid); if (url) { - card.appendChild( + actions.appendChild( el("a", { - class: "chat-att-ref__link", + class: "chat-att-ref__action", href: url, target: "_blank", rel: "noopener noreferrer", @@ -2715,9 +2883,9 @@ async function renderAttachmentsEl(raw) { src: remote, }), ); - card.appendChild( + actions.appendChild( el("a", { - class: "chat-att-ref__link", + class: "chat-att-ref__action", href: remote, target: "_blank", rel: "noopener noreferrer", @@ -2730,7 +2898,7 @@ async function renderAttachmentsEl(raw) { if (canPreviewText) { const preview = el("button", { type: "button", - class: "chat-att-ref__btn", + class: "chat-att-ref__action", text: t("chat.attachment.preview"), }); const pre = el("pre", { class: "chat-att-ref__preview" }); @@ -2753,8 +2921,11 @@ async function renderAttachmentsEl(raw) { preview.textContent = t("chat.attachment.preview"); } }); - card.appendChild(preview); + actions.appendChild(preview); + card.appendChild(actions); card.appendChild(pre); + } else if (actions.childNodes.length) { + card.appendChild(actions); } return card; }; @@ -2804,7 +2975,7 @@ async function renderAttachmentsEl(raw) { if (!parent) return; parent.replaceChild( el("a", { - class: "chat-att-ref__link", + class: "chat-att-ref__action", href: src, target: "_blank", rel: "noopener noreferrer", @@ -2969,11 +3140,126 @@ function syncAuthUserLabel() { .trim() .toLowerCase() === "administrator"; if (!name) return; + const openUserMenu = (anchorEl) => { + clearChatPageBlockers(); + const items = [ + el("button", { + type: "button", + class: "chat-sess-menu-item", + "data-menu-action": "profile", + text: t("chat.myProfile"), + }), + el("button", { + type: "button", + class: "chat-sess-menu-item", + "data-menu-action": "jobs", + text: t("chat.jobs"), + }), + ]; + items.push(el("div", { class: "chat-sess-menu-sep" })); + items.push( + el("div", { class: "muted u-menu-label", text: t("theme.label") }), + ); + const themeSelMenu = el("select", { + class: "input u-menu-select", + }); + try { + (window.OclawAdminTheme && window.OclawAdminTheme.THEMES ? window.OclawAdminTheme.THEMES : ["netx"]).forEach((tid) => { + themeSelMenu.appendChild(el("option", { value: tid, text: t(`theme.${tid}`) })); + }); + themeSelMenu.value = window.OclawAdminTheme ? window.OclawAdminTheme.currentAdminTheme() : "netx"; + } catch (_) {} + themeSelMenu.addEventListener("change", () => { + try { + if (window.OclawAdminTheme) window.OclawAdminTheme.persistAdminTheme(themeSelMenu.value); + } catch (_) {} + }); + items.push(themeSelMenu); + const bridge = window.__chatUserMenuPrefs; + if (bridge && typeof bridge === "object") { + items.push(el("div", { class: "chat-sess-menu-sep" })); + items.push(el("div", { class: "muted u-menu-label", text: t("chat.modeLabel") })); + const modeSel = el("select", { class: "input u-menu-select" }); + try { + const rows = Array.isArray(bridge.getModeOptions && bridge.getModeOptions()) ? bridge.getModeOptions() : []; + rows.forEach((r) => modeSel.appendChild(el("option", { value: String(r.value || ""), text: String(r.label || r.value || "") }))); + modeSel.value = String((bridge.getModeValue && bridge.getModeValue()) || ""); + } catch (_) {} + modeSel.addEventListener("change", async () => { + const v = modeSel.value; + try { + modeSel.disabled = true; + if (bridge.setModeValue) await bridge.setModeValue(v); + } catch (_) { + // errors are surfaced by saveUserGlobalModePreference() + } finally { + modeSel.disabled = false; + } + }); + items.push(modeSel); + const reasonWrap = el("label", { class: "switch-wrap u-switch-menu" }, [ + el("input", { type: "checkbox", class: "switch-input" }), + el("span", { class: "switch-slider" }), + el("span", { class: "muted", text: t("chat.tools") }), + ]); + const reasonCb = reasonWrap.querySelector("input.switch-input"); + try { + reasonCb.checked = !!(bridge.getReasoningVisible && bridge.getReasoningVisible()); + } catch (_) {} + reasonCb.addEventListener("change", () => { + try { + Promise.resolve(bridge.setReasoningVisible && bridge.setReasoningVisible(!!reasonCb.checked)).catch(() => {}); + } catch (_) {} + }); + items.push(reasonWrap); + } + items.push(el("div", { class: "chat-sess-menu-sep" })); + items.push( + el("button", { + type: "button", + class: "chat-sess-menu-item", + "data-menu-action": "lang", + text: t("lang.switch"), + }), + ); + items.push(el("div", { class: "chat-sess-menu-sep" })); + items.push( + el("button", { + type: "button", + class: "chat-sess-menu-item", + "data-menu-action": "logout", + text: t("auth.logout"), + }), + ); + const menu = el("div", { + class: "chat-sess-menu-pop u-pop-menu", + }, items); + // Inline position/z-index: chat.html used to override class z-index below the scrim. + menu.style.position = "fixed"; + menu.style.zIndex = "300"; + const rect = (anchorEl || moreBtn).getBoundingClientRect(); + attachChatMenuDismiss(menu); + const mrect = menu.getBoundingClientRect(); + const pad = 8; + let left = rect.left; + 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`; + }; const nameBtn = el("button", { type: "button", class: "chat-sess-btn", text: name, title: name, + onclick: (ev) => { + ev.stopPropagation(); + openUserMenu(ev.currentTarget); + }, }); const moreBtn = el("button", { type: "button", @@ -2982,112 +3268,7 @@ function syncAuthUserLabel() { title: t("chat.sessionMenu"), onclick: (ev) => { ev.stopPropagation(); - clearChatPageBlockers(); - const items = [ - el("button", { - type: "button", - class: "chat-sess-menu-item", - "data-menu-action": "profile", - text: t("chat.myProfile"), - }), - el("button", { - type: "button", - class: "chat-sess-menu-item", - "data-menu-action": "jobs", - text: t("chat.jobs"), - }), - ]; - items.push(el("div", { class: "chat-sess-menu-sep" })); - items.push( - el("div", { class: "muted u-menu-label", text: t("theme.label") }), - ); - const themeSelMenu = el("select", { - class: "input u-menu-select", - }); - try { - (window.OclawAdminTheme && window.OclawAdminTheme.THEMES ? window.OclawAdminTheme.THEMES : ["netx"]).forEach((tid) => { - themeSelMenu.appendChild(el("option", { value: tid, text: t(`theme.${tid}`) })); - }); - themeSelMenu.value = window.OclawAdminTheme ? window.OclawAdminTheme.currentAdminTheme() : "netx"; - } catch (_) {} - themeSelMenu.addEventListener("change", () => { - try { - if (window.OclawAdminTheme) window.OclawAdminTheme.persistAdminTheme(themeSelMenu.value); - } catch (_) {} - }); - items.push(themeSelMenu); - const bridge = window.__chatUserMenuPrefs; - if (bridge && typeof bridge === "object") { - items.push(el("div", { class: "chat-sess-menu-sep" })); - items.push(el("div", { class: "muted u-menu-label", text: t("chat.modeLabel") })); - const modeSel = el("select", { class: "input u-menu-select" }); - try { - const rows = Array.isArray(bridge.getModeOptions && bridge.getModeOptions()) ? bridge.getModeOptions() : []; - rows.forEach((r) => modeSel.appendChild(el("option", { value: String(r.value || ""), text: String(r.label || r.value || "") }))); - modeSel.value = String((bridge.getModeValue && bridge.getModeValue()) || ""); - } catch (_) {} - modeSel.addEventListener("change", async () => { - const v = modeSel.value; - try { - modeSel.disabled = true; - if (bridge.setModeValue) await bridge.setModeValue(v); - } catch (_) { - // errors are surfaced by saveUserGlobalModePreference() - } finally { - modeSel.disabled = false; - } - }); - items.push(modeSel); - const reasonWrap = el("label", { class: "switch-wrap u-switch-menu" }, [ - el("input", { type: "checkbox", class: "switch-input" }), - el("span", { class: "switch-slider" }), - el("span", { class: "muted", text: t("chat.tools") }), - ]); - const reasonCb = reasonWrap.querySelector("input.switch-input"); - try { - reasonCb.checked = !!(bridge.getReasoningVisible && bridge.getReasoningVisible()); - } catch (_) {} - reasonCb.addEventListener("change", () => { - try { - Promise.resolve(bridge.setReasoningVisible && bridge.setReasoningVisible(!!reasonCb.checked)).catch(() => {}); - } catch (_) {} - }); - items.push(reasonWrap); - } - items.push(el("div", { class: "chat-sess-menu-sep" })); - items.push( - el("button", { - type: "button", - class: "chat-sess-menu-item", - "data-menu-action": "lang", - text: t("lang.switch"), - }), - ); - items.push(el("div", { class: "chat-sess-menu-sep" })); - items.push( - el("button", { - type: "button", - class: "chat-sess-menu-item", - "data-menu-action": "logout", - text: t("auth.logout"), - }), - ); - const menu = el("div", { - class: "chat-sess-menu-pop u-pop-menu", - }, items); - const rect = moreBtn.getBoundingClientRect(); - attachChatMenuDismiss(menu); - const mrect = menu.getBoundingClientRect(); - const pad = 8; - let left = rect.left; - 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`; + openUserMenu(ev.currentTarget); }, }); const row = el("div", { class: "chat-user-row" }, [nameBtn, moreBtn]); @@ -3293,7 +3474,7 @@ async function renderChatUi() { let globalMenuModeValue = String(localStorage.getItem(CHAT_USER_MENU_MODE_KEY) || MAIN_MODE_VALUE).toLowerCase(); if (globalMenuModeValue === "comprehensive" || globalMenuModeValue === "main") globalMenuModeValue = MAIN_MODE_VALUE; const modelSelect = el("select", { - class: "input u-select-mode", + class: "chat-composer-chip chat-composer-chip--select", title: t("chat.activeModelLabel"), }); let modelSelectNameToId = new Map(); @@ -3566,41 +3747,42 @@ async function renderChatUi() { }; await refreshActiveModelText(); refreshExecUi(); - const composerMetaBar = el("div", { class: "row u-gap-8 u-composer-meta" }, [ + const compressBtn = el("button", { + type: "button", + class: "chat-composer-chip", + text: t("chat.compressHistory"), + onclick: async () => { + const sid = String(activeId || ""); + if (!sid) return; + if (!(await confirmChatAction(t("chat.compressHistoryPrompt")))) return; + try { + const resp = await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(sid)}/compress-history`, {}); + const r = resp && resp.result ? resp.result : {}; + if (!resp || !resp.ok) { + const err = String((resp && (resp.error || resp.detail)) || "failed"); + showToast(t("chat.compressHistoryFail", { error: err.slice(0, 180) }), { kind: "error", ttlMs: 6500 }); + return; + } + showToast( + t("chat.compressHistoryOk", { + scanned: String(Number(r.scanned_tool_messages || 0)), + rewritten: String(Number(r.rewritten_all_tool_messages || 0)), + compacted: String(Number(r.compacted_tool_messages || 0)), + skipped: String(Number(r.skipped_already_guarded || 0)), + }), + { kind: "info", ttlMs: 6500 }, + ); + // Reload messages so UI reflects compacted history. + loadMessagesForActive().catch(() => {}); + } catch (e) { + showToast(t("chat.compressHistoryFail", { error: String(e || "failed").slice(0, 180) }), { kind: "error", ttlMs: 6500 }); + } + }, + }); + const composerMetaBar = el("div", { class: "chat-composer-meta" }, [ execSelectWrap, modelSelect, - el("button", { - type: "button", - class: "btn u-btn-compact", - text: t("chat.compressHistory"), - onclick: async () => { - const sid = String(activeId || ""); - if (!sid) return; - if (!(await confirmChatAction(t("chat.compressHistoryPrompt")))) return; - try { - const resp = await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(sid)}/compress-history`, {}); - const r = resp && resp.result ? resp.result : {}; - if (!resp || !resp.ok) { - const err = String((resp && (resp.error || resp.detail)) || "failed"); - showToast(t("chat.compressHistoryFail", { error: err.slice(0, 180) }), { kind: "error", ttlMs: 6500 }); - return; - } - showToast( - t("chat.compressHistoryOk", { - scanned: String(Number(r.scanned_tool_messages || 0)), - rewritten: String(Number(r.rewritten_all_tool_messages || 0)), - compacted: String(Number(r.compacted_tool_messages || 0)), - skipped: String(Number(r.skipped_already_guarded || 0)), - }), - { kind: "info", ttlMs: 6500 }, - ); - // Reload messages so UI reflects compacted history. - loadMessagesForActive().catch(() => {}); - } catch (e) { - showToast(t("chat.compressHistoryFail", { error: String(e || "failed").slice(0, 180) }), { kind: "error", ttlMs: 6500 }); - } - }, - }), + compressBtn, ]); const fitComposerTextarea = () => { @@ -3958,20 +4140,44 @@ async function renderChatUi() { for (const s of sessions) { const sid = String(s.id || ""); const title = String(s.title || sid || ""); + const peerHint = String(s.peer_name || s.peer_display_name || "").trim(); + let pretty = formatChatSessionTitle(title, peerHint); + if (!pretty.sublabel && pretty.channel) { + const looked = lookupChannelPeerName(title, pretty); + if (looked) pretty = formatChatSessionTitle(title, looked); + } const row = el("div", { class: "chat-sess-row" + (idsMatch(activeId, sid) ? " chat-sess-row--active" : "") }); - const btn = el("button", { - class: "chat-sess-btn" + (idsMatch(activeId, sid) ? " chat-sess-btn--active" : ""), - text: title, - onclick: () => { - activeId = sid; - replaceSessionUrl(sid); - paintSessions(); - Promise.all([loadMessagesForActive(), loadSessionModePreference()]).catch((e) => { - statusBar.textContent = `${t("chat.error")}: ${String(e)}`; - }); - startWikiPoller(); + const btnChildren = []; + if (pretty.badge) { + btnChildren.push( + el("span", { + class: `chat-sess-badge chat-sess-badge--${pretty.channel || "other"}`, + text: pretty.badge, + }), + ); + } + const labelKids = [el("span", { class: "chat-sess-btn__name", text: pretty.label })]; + if (pretty.sublabel) { + labelKids.push(el("span", { class: "chat-sess-btn__id", text: pretty.sublabel })); + } + btnChildren.push(el("span", { class: "chat-sess-btn__label" }, labelKids)); + const btn = el( + "button", + { + class: "chat-sess-btn" + (idsMatch(activeId, sid) ? " chat-sess-btn--active" : ""), + title: pretty.full || title, + onclick: () => { + activeId = sid; + replaceSessionUrl(sid); + paintSessions(); + Promise.all([loadMessagesForActive(), loadSessionModePreference()]).catch((e) => { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + }); + startWikiPoller(); + }, }, - }); + btnChildren, + ); const more = el("button", { type: "button", class: "chat-sess-more" + (idsMatch(activeId, sid) ? " chat-sess-more--active" : ""), @@ -4106,6 +4312,7 @@ async function renderChatUi() { }; const reloadSessionsOnly = async () => { + await refreshChannelPeerNameMap().catch(() => {}); const resp = await apiGet(`/admin/api/chat/sessions?limit=${PAGE_SIZE}&offset=0`); sessions = Array.isArray(resp.sessions) ? resp.sessions : []; sessionTotal = intOr(resp.total, sessions.length); @@ -4114,6 +4321,7 @@ async function renderChatUi() { const refreshSessions = async () => { statusBar.textContent = t("chat.loading"); + await refreshChannelPeerNameMap().catch(() => {}); const resp = await apiGet(`/admin/api/chat/sessions?limit=${PAGE_SIZE}&offset=0`); sessions = Array.isArray(resp.sessions) ? resp.sessions : []; sessionTotal = intOr(resp.total, sessions.length); @@ -5709,15 +5917,19 @@ async function boot() { localStorage.removeItem(AUTH_SESSION_KEY); authSession = null; try { - await apiPost("/admin/api/auth/bootstrap", {}); + await withTimeout(apiPost("/admin/api/auth/bootstrap", {}), 2500, "auth_bootstrap_timeout"); } catch (_) {} mount(await renderLogin()); applyI18nStatic(); syncAuthUserLabel(); return; } - await syncLangFromServer(); - await loadMeProfile(); + try { + await withTimeout(syncLangFromServer(), 2500, "ui_lang_timeout"); + } catch (_) {} + try { + await withTimeout(loadMeProfile(), 2500, "me_profile_timeout"); + } catch (_) {} try { mount(await renderChatUi()); } catch (err) { diff --git a/interfaces/admin/static/index.html b/interfaces/admin/static/index.html index 4061c0f0..7b65f5d4 100644 --- a/interfaces/admin/static/index.html +++ b/interfaces/admin/static/index.html @@ -95,7 +95,7 @@
Runtime
-
diff --git a/interfaces/admin/static/styles.css b/interfaces/admin/static/styles.css index 183a7a5d..c0a2fe48 100644 --- a/interfaces/admin/static/styles.css +++ b/interfaces/admin/static/styles.css @@ -110,23 +110,32 @@ body { } .nav__groupTitle { font-size: 11px; - letter-spacing: 0.04em; + letter-spacing: 0.06em; text-transform: uppercase; color: var(--ds-text-muted, #94a3b8); - padding: 6px 12px 2px; + padding: 10px 12px 4px; + font-weight: 600; + opacity: 0.9; } .nav__item { text-decoration: none; - color: var(--ds-text, #e2e8f0); - padding: 10px 12px; + color: var(--ds-text-muted, #94a3b8); + padding: 9px 12px; border-radius: 8px; border: 1px solid transparent; + font-size: 13px; + font-weight: 500; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} +.nav__item:hover { + background: var(--ds-menu-hover-bg, rgba(148, 163, 184, 0.1)); + color: var(--ds-text, #e2e8f0); } -.nav__item:hover { background: var(--ds-menu-hover-bg, rgba(148, 163, 184, 0.1)); } .nav__item--active { background: var(--ds-accent-soft, rgba(59, 130, 246, 0.22)); border-color: var(--ds-accent-border, rgba(59, 130, 246, 0.4)); color: var(--ds-text, #e2e8f0); + box-shadow: inset 3px 0 0 var(--ds-accent, #3b82f6); } .sidebar__footer { margin-top: auto; @@ -161,43 +170,144 @@ body { letter-spacing: 0.02em; } .topbar .row { align-items: center; gap: 8px; } -.topbar .input--compact { height: 30px; padding: 4px 8px; font-size: 12px; } -.topbar .btn { height: 30px; padding: 4px 10px; font-size: 12px; } .topbar__actions { display: inline-flex; align-items: center; gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} +.topbar__actions > #authUser { + margin-right: 4px; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 4px 10px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.08); + border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.16)); + font-size: 12px; +} +.admin-theme-wrap { + display: inline-flex; + align-items: center; + gap: 8px; + margin-right: 4px; + padding-right: 10px; + border-right: 1px solid var(--ds-border, rgba(148, 163, 184, 0.18)); +} +.admin-theme-wrap > .muted { + white-space: nowrap; +} +/* Shared chrome size: buttons + theme select must match in the 48px topbar. */ +.topbar .btn, +.topbar a.btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + min-height: 32px; + max-height: 32px; + padding: 0 12px; + font-size: 12px; + font-weight: 500; + line-height: 1; + border-radius: 7px; + box-sizing: border-box; + text-decoration: none; + white-space: nowrap; + flex: 0 0 auto; + transform: none; +} +.topbar .btn--primary { + box-shadow: none; +} +.topbar .btn--ghost { + background: transparent; +} +.topbar .input, +.topbar .input--compact, +.topbar select.input { + height: 32px; + min-height: 32px; + max-height: 32px; + padding: 0 28px 0 10px; + font-size: 12px; + line-height: 1; + border-radius: 7px; + box-sizing: border-box; +} +.u-theme-select { + min-width: 140px; + max-width: 200px; + width: auto; } -.admin-theme-wrap { margin-right: 4px; } -.u-theme-select { min-width: 132px; max-width: 200px; padding: 6px 8px; } .btn { - background: rgba(148, 163, 184, 0.1); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + background: rgba(148, 163, 184, 0.08); border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.25)); color: var(--ds-text, #e2e8f0); border-radius: 8px; - padding: 8px 12px; + padding: 10px 14px; + min-height: 40px; + font-size: 13px; + font-weight: 500; + line-height: 1.3; + box-sizing: border-box; cursor: pointer; + text-decoration: none; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; } .btn:hover { background: var(--ds-menu-hover-bg, rgba(148, 163, 184, 0.16)); } +.btn:active { transform: translateY(0.5px); } .btn--primary { - background: var(--ds-accent-soft, rgba(59, 130, 246, 0.25)); - border-color: var(--ds-accent-border, rgba(59, 130, 246, 0.4)); + background: var(--ds-accent, #3b82f6); + border-color: var(--ds-accent, #3b82f6); + color: #f8fafc; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset; +} +.btn--primary:hover { + background: var(--ds-accent-deep, #2563eb); + border-color: var(--ds-accent-deep, #2563eb); +} +.btn--danger { + background: rgba(239, 68, 68, 0.16); + border-color: rgba(239, 68, 68, 0.4); + color: #fecaca; +} +.btn--danger:hover { + background: rgba(239, 68, 68, 0.24); + border-color: rgba(239, 68, 68, 0.55); +} +.btn--ghost { + background: transparent; + border-color: var(--ds-border, rgba(148, 163, 184, 0.22)); + color: var(--ds-text-muted, #94a3b8); +} +.btn--ghost:hover { + color: var(--ds-text, #e2e8f0); + background: var(--ds-menu-hover-bg, rgba(148, 163, 184, 0.1)); } -.btn--danger { background: rgba(239, 68, 68, 0.18); border-color: rgba(239, 68, 68, 0.35); } .content { - padding: 18px 20px 28px; + padding: 20px 22px 32px; overflow: auto; color: var(--ds-text, #e2e8f0); background: - radial-gradient(1200px 480px at 12% -10%, rgba(37, 99, 235, 0.14), transparent 55%), - radial-gradient(900px 420px at 88% 0%, rgba(14, 165, 233, 0.08), transparent 50%), + radial-gradient(1200px 480px at 12% -10%, rgba(37, 99, 235, 0.12), transparent 55%), + radial-gradient(900px 420px at 88% 0%, rgba(14, 165, 233, 0.07), transparent 50%), var(--ds-bg, #070b14); } .page-shell { display: flex; flex-direction: column; - gap: 12px; + gap: 14px; + max-width: 1280px; } .page-shell__header { display: flex; @@ -246,7 +356,7 @@ body { .page-grid { display: grid; - gap: 12px; + gap: 14px; } .page-grid--single { grid-template-columns: minmax(0, 1fr); @@ -254,6 +364,10 @@ body { .page-grid--two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.page-grid > .card { + margin-bottom: 0; + height: 100%; +} .section-card { margin-bottom: 0; scroll-margin-top: 76px; @@ -274,12 +388,176 @@ body { .card { background: var(--ds-panel, rgba(15, 23, 42, 0.92)); border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.18)); - border-radius: 12px; - padding: 14px; - margin-bottom: 12px; + border-radius: 14px; + padding: 18px 20px; + margin-bottom: 14px; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.03) inset, 0 8px 24px rgba(2, 6, 23, 0.18); +} +.card__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.card__headline { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.card__title { + font-weight: 650; + margin: 0 0 12px; + font-size: 15px; + letter-spacing: 0.01em; + color: var(--ds-text, #e2e8f0); +} +.card__head .card__title { margin-bottom: 0; } +.card__subtitle { + margin: 0; + font-size: 12px; + line-height: 1.45; + color: var(--ds-text-muted, #94a3b8); + max-width: 72ch; +} +.card__actions { + flex: 0 0 auto; + justify-content: flex-end; +} +.card__body { + display: flex; + flex-direction: column; + gap: 12px; +} +.card__hint { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--ds-text-muted, #94a3b8); } -.card__title { font-weight: 650; margin-bottom: 10px; } .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} +.form-inline { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px 14px; +} +.form-inline__field { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 160px; + min-width: 140px; + max-width: 280px; +} +.form-inline__field > label, +.form-inline__label { + font-size: 12px; + font-weight: 550; + color: var(--ds-text-muted, #94a3b8); +} +.form-inline__field .input, +.form-inline__field select.input { + width: 100%; + min-width: 0; +} +.form-inline__field--wide { + max-width: none; + flex: 1 1 240px; +} +.form-inline__actions { + flex: 0 0 auto; + display: inline-flex; + gap: 8px; + align-items: center; + padding-bottom: 1px; +} +.meta-line { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.meta-chip { + display: inline-flex; + align-items: center; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 11px; + line-height: 1.3; + padding: 4px 8px; + border-radius: 6px; + color: var(--ds-text-muted, #94a3b8); + background: rgba(148, 163, 184, 0.08); + border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.16)); +} +.pid-cell { + display: flex; + flex-direction: column; + gap: 2px; + align-items: flex-start; +} +.pid-cell__id { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 13px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; +} +.pid-cell__tag { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ds-accent-link, #60a5fa); + opacity: 0.9; +} +.form-stack { + display: flex; + flex-direction: column; + gap: 14px; +} +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 14px; +} +.form-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.form-field--full { + grid-column: 1 / -1; +} +.form-field__label { + font-size: 12px; + font-weight: 550; + letter-spacing: 0.01em; + color: var(--ds-text-muted, #94a3b8); +} +.form-field .input, +.form-field select.input, +.form-field textarea.input { + width: 100%; + min-width: 0; +} +.form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 2px; +} +@media (max-width: 720px) { + .form-grid { grid-template-columns: minmax(0, 1fr); } +} .section-card .row { width: 100%; flex-wrap: nowrap; @@ -349,13 +627,66 @@ body { width: 100%; box-sizing: border-box; } -.input { - background: var(--ds-surface, #0f172a); - border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.25)); +.input, +select.input, +textarea.input { + appearance: none; + -webkit-appearance: none; + background: var(--ds-surface-2, #152033); + border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.28)); color: var(--ds-text, #e2e8f0); border-radius: 8px; - padding: 8px 10px; + padding: 10px 12px; + min-height: 40px; min-width: 240px; + font-size: 13px; + line-height: 1.4; + font-family: inherit; + box-sizing: border-box; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} +textarea.input { + min-height: 108px; + resize: vertical; + line-height: 1.5; + padding-top: 10px; + padding-bottom: 10px; +} +select.input { + background-image: + linear-gradient(45deg, transparent 50%, var(--ds-text-muted, #94a3b8) 50%), + linear-gradient(135deg, var(--ds-text-muted, #94a3b8) 50%, transparent 50%); + background-position: + calc(100% - 16px) calc(50% - 2px), + calc(100% - 11px) calc(50% - 2px); + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + padding-right: 32px; + cursor: pointer; +} +.input::placeholder, +textarea.input::placeholder { + color: var(--ds-text-muted, #94a3b8); + opacity: 0.85; +} +.input:hover, +select.input:hover, +textarea.input:hover { + border-color: var(--ds-accent-border, rgba(59, 130, 246, 0.4)); +} +.input:focus, +select.input:focus, +textarea.input:focus { + outline: none; + border-color: var(--ds-accent-border-strong, rgba(59, 130, 246, 0.55)); + box-shadow: 0 0 0 3px var(--ds-accent-soft, rgba(59, 130, 246, 0.22)); + background: var(--ds-surface, #0f172a); +} +.input:disabled, +select.input:disabled, +textarea.input:disabled { + opacity: 0.55; + cursor: not-allowed; } /* Unified switch style for admin/chat checkboxes */ @@ -405,7 +736,9 @@ input[type="checkbox"]:checked::before, } .input--compact { min-width: 0; + min-height: 32px; padding: 6px 8px; + font-size: 12px; } .input--readonly { max-width: min(100%, 720px); @@ -419,17 +752,32 @@ input[type="checkbox"]:checked::before, } .table { width: 100%; border-collapse: collapse; } .table th, .table td { - border-bottom: 1px solid var(--ds-border, rgba(148, 163, 184, 0.18)); - padding: 10px 8px; + border-bottom: 1px solid var(--ds-border, rgba(148, 163, 184, 0.14)); + padding: 11px 12px; text-align: left; font-size: 13px; + vertical-align: middle; +} +.table th { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ds-text-muted, #94a3b8); + background: rgba(148, 163, 184, 0.05); +} +.table tbody tr:hover td { + background: rgba(148, 163, 184, 0.04); +} +.table tbody tr:last-child td { + border-bottom: none; } .table-wrap { width: 100%; overflow-x: auto; - border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.12)); - border-radius: 8px; - background: var(--ds-panel, rgba(15, 23, 42, 0.65)); + border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.14)); + border-radius: 10px; + background: rgba(2, 6, 23, 0.22); } .table--compact { table-layout: fixed; min-width: 860px; } .table--compact th, .table--compact td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -615,6 +963,19 @@ body.col-resize-active { width: 100%; max-width: 100%; box-sizing: border-box; + min-height: 34px; + padding: 6px 10px; +} +.table .input, +.table select.input { + min-width: 0; + min-height: 34px; + padding: 6px 10px; +} +.btn--small { + min-height: 32px; + padding: 6px 10px; + font-size: 12px; } .cell-copyable { cursor: copy; position: relative; } .cell-copyable:hover { background: var(--ds-accent-soft, rgba(59, 130, 246, 0.12)); } @@ -645,15 +1006,28 @@ body.col-resize-active { } .plugins-pager .btn.btn--small { min-width: 72px; } .badge { - display: inline-block; - padding: 2px 8px; - border-radius: 8px; - font-size: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.25)); background: rgba(148, 163, 184, 0.08); + line-height: 1.3; +} +.badge--ok { + border-color: rgba(34, 197, 94, 0.45); + background: rgba(34, 197, 94, 0.14); + color: #86efac; +} +.badge--bad { + border-color: rgba(239, 68, 68, 0.5); + background: rgba(239, 68, 68, 0.14); + color: #fca5a5; } -.badge--ok { border-color: rgba(34, 197, 94, 0.4); background: rgba(34, 197, 94, 0.12); } -.badge--bad { border-color: rgba(239, 68, 68, 0.45); background: rgba(239, 68, 68, 0.12); } .badge--mode-restricted { border-color: rgba(250, 204, 21, 0.45); background: rgba(250, 204, 21, 0.12); } .badge--mode-unrestricted { border-color: var(--ds-accent-border, rgba(59, 130, 246, 0.45)); @@ -665,15 +1039,54 @@ body.col-resize-active { font-size: 12px; } .alert { - margin: 8px 0; - padding: 10px 12px; - border-radius: 8px; + margin: 0; + padding: 14px 16px; + border-radius: 10px; border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.25)); font-size: 13px; + line-height: 1.45; + display: flex; + flex-direction: column; + gap: 10px; +} +.alert__title { + font-weight: 650; + font-size: 13px; +} +.alert__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 2px; +} +.alert--critical { + border-color: rgba(239, 68, 68, 0.55); + background: linear-gradient(180deg, rgba(239, 68, 68, 0.16), rgba(239, 68, 68, 0.08)); + color: #fecaca; +} +.alert--warning { + border-color: rgba(250, 204, 21, 0.45); + background: linear-gradient(180deg, rgba(250, 204, 21, 0.14), rgba(250, 204, 21, 0.06)); + color: #fde68a; +} +.alert--ok { + border-color: rgba(34, 197, 94, 0.4); + background: rgba(34, 197, 94, 0.1); + color: #bbf7d0; +} +.alert-list { + margin: 0; + padding-left: 18px; + color: inherit; + opacity: 0.92; + font-size: 12px; } -.alert--critical { border-color: rgba(239, 68, 68, 0.6); background: rgba(239, 68, 68, 0.12); color: #fecaca; } -.alert-list { margin: 8px 0 0; padding-left: 18px; color: var(--ds-text-muted, #94a3b8); font-size: 13px; } .alert-list li { margin: 4px 0; } +.alert--critical .alert-list, +.alert--warning .alert-list { + color: inherit; +} /* Light theme: prefer light color-scheme for native controls */ body.theme-ds-body[data-admin-theme="light"] { @@ -691,7 +1104,8 @@ body.theme-ds-body[data-admin-theme="light"] .content { .u-mb-6 { margin: 0 0 6px 0; } .u-h-8 { height: 8px; } .u-h-10 { height: 10px; } -.u-hidden { display: none !important; } +/* No !important: many modals/panels toggle via element.style.display. */ +.u-hidden { display: none; } .u-drag { cursor: pointer; user-select: none; } .u-wrap-lh { white-space: normal; line-height: 1.5; } .u-muted-block { margin-top: 6px; line-height: 1.45; } @@ -713,7 +1127,7 @@ body.theme-ds-body[data-admin-theme="light"] .content { .u-w-12p { width: 12%; } .u-w-full-mt-8 { width: 100%; margin-top: 8px; } .u-w-full-mt-10 { width: 100%; margin-top: 10px; } -.u-overlay-fixed { position: fixed; z-index: 250; } +.u-overlay-fixed { position: fixed; z-index: 300; } .u-overlay-scrim { position: fixed; inset: 0; z-index: 250; background: transparent; } .u-z-modal { z-index: 9998; } .u-z-modal-top { z-index: 9999; } @@ -758,8 +1172,20 @@ body.theme-ds-body[data-admin-theme="light"] .content { .u-select-exec { min-width: 96px; max-width: 140px; padding: 6px 8px; } .u-inline-flex { display: inline-flex; align-items: center; } .u-switch-menu { margin: 2px 8px 8px; } -.u-pop-menu { position: fixed; min-width: 220px; z-index: 300; } -.u-pop-tip { position: fixed; max-width: 420px; white-space: pre-wrap; line-height: 1.4; } +.u-pop-menu, +.chat-sess-menu-pop.u-pop-menu { + position: fixed; + min-width: 220px; + z-index: 300; +} +.u-pop-tip, +.chat-sess-menu-pop.u-pop-tip { + position: fixed; + max-width: 420px; + z-index: 300; + white-space: pre-wrap; + line-height: 1.4; +} .u-empty-pad { padding: 10px 0 10px 8px; } .u-avatar-chip { width: 48px; diff --git a/interfaces/admin/static/theme-deepseek.css b/interfaces/admin/static/theme-deepseek.css index 043d22b3..1fd91132 100644 --- a/interfaces/admin/static/theme-deepseek.css +++ b/interfaces/admin/static/theme-deepseek.css @@ -26,9 +26,9 @@ body.theme-ds-body { --chat-sess-hover-bg: rgba(255, 255, 255, 0.05); --chat-sess-active-bg: rgba(255, 255, 255, 0.062); --chat-sess-border: rgba(255, 255, 255, 0.09); - /* 用户消息气泡(微信式绿底深字) */ - --chat-user-bubble-bg: #42b983; - --chat-user-bubble-fg: #111111; + /* 用户消息气泡:磨砂玻璃(半透明蓝) */ + --chat-user-bubble-bg: rgba(94, 179, 255, 0.18); + --chat-user-bubble-fg: #e8e8e8; /* 弹层 / 菜单 / Toast */ --ds-overlay-bg: rgba(0, 0, 0, 0.58); --ds-menu-hover-bg: rgba(255, 255, 255, 0.06); @@ -330,10 +330,12 @@ body.theme-ds-body .card { } .chat-sess-btn { - display: block; + display: flex; + align-items: center; + gap: 8px; width: 100%; text-align: left; - padding: 10px 0 10px 8px; + padding: 8px 6px 8px 8px; margin-bottom: 0; border-radius: 0; border: 1px solid transparent; @@ -341,6 +343,67 @@ body.theme-ds-body .card { color: inherit; cursor: pointer; font-size: 13px; + line-height: 1.3; + min-width: 0; + min-height: 40px; + box-sizing: border-box; +} + +.chat-sess-btn__label { + flex: 1; + min-width: 0; + display: block; + overflow: hidden; +} + +.chat-sess-btn__name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-sess-btn__id { + display: none; +} + +.chat-sess-badge { + flex: 0 0 2.6rem; + width: 2.6rem; + min-width: 2.6rem; + box-sizing: border-box; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1; + padding: 4px 0; + border-radius: 6px; + text-align: center; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(148, 163, 184, 0.1); + color: #94a3b8; +} + +/* Keep channel tint very subtle so the list reads as one system. */ +.chat-sess-badge--whatsapp { + border-color: rgba(37, 211, 102, 0.28); + color: #86efac; +} + +.chat-sess-badge--wechat, +.chat-sess-badge--weixin { + border-color: rgba(7, 193, 96, 0.28); + color: #86efac; +} + +.chat-sess-badge--wecom { + border-color: rgba(59, 130, 246, 0.28); + color: #93c5fd; +} + +.chat-sess-badge--local { + border-color: rgba(148, 163, 184, 0.28); + color: #cbd5e1; } .chat-sess-btn:hover { @@ -349,17 +412,19 @@ body.theme-ds-body .card { .chat-sess-row { border: 1px solid transparent; - border-radius: 0; + border-radius: 8px; + margin-bottom: 2px; + min-height: 40px; } .chat-sess-row:hover { - background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06)); - border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12)); + background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.05)); + border-color: transparent; } .chat-sess-row--active { - background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062)); - border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.08)); + background: var(--chat-sess-active-bg, rgba(148, 163, 184, 0.12)); + border-color: transparent; } .chat-sess-row--active .chat-sess-btn { @@ -369,11 +434,14 @@ body.theme-ds-body .card { .chat-sess-row .chat-sess-more { opacity: 0; pointer-events: none; + flex: 0 0 2rem; + min-width: 2rem; } .chat-sess-row:hover .chat-sess-more, -.chat-sess-row:focus-within .chat-sess-more { - opacity: 1; +.chat-sess-row:focus-within .chat-sess-more, +.chat-sess-row--active .chat-sess-more { + opacity: 0.85; pointer-events: auto; } @@ -398,10 +466,10 @@ body.theme-ds-body .card { flex: 1; min-height: 0; overflow-y: auto; - padding: 12px 14px; + padding: 16px 18px 20px; display: flex; flex-direction: column; - gap: 10px; + gap: 14px; } /* 滚动条:管理台等仍用隐藏式;独立 /chat 页见文件末尾 body.chat-standalone-page */ @@ -475,10 +543,10 @@ body.theme-ds-body .card { .chat-msg { max-width: 92%; - padding: 6px 12px; - border-radius: 12px; + padding: 8px 12px; + border-radius: 10px; font-size: 14px; - line-height: 1.5; + line-height: 1.55; white-space: pre-wrap; word-break: break-word; } @@ -589,9 +657,17 @@ body.theme-ds-body .card { .chat-msg--user { align-self: flex-end; - background: var(--chat-user-bubble-bg, #42b983); - color: var(--chat-user-bubble-fg, #111); - border: 1px solid rgba(0, 0, 0, 0.12); + padding: 10px 14px; + border-radius: 14px; + color: var(--chat-user-bubble-fg, #f1f5f9); + background: var(--chat-user-bubble-bg, rgba(59, 130, 246, 0.22)); + border: 1px solid rgba(148, 163, 184, 0.22); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 8px 24px rgba(2, 6, 23, 0.18); + backdrop-filter: blur(14px) saturate(1.25); + -webkit-backdrop-filter: blur(14px) saturate(1.25); + filter: none; } .chat-msg--user .chat-msg__md, @@ -600,22 +676,30 @@ body.theme-ds-body .card { } .chat-msg--user .chat-msg__md a { - color: #0b4d8c; + color: #93c5fd; } .chat-msg--user .chat-msg__md pre, .chat-msg--user .chat-msg__md code { - background: rgba(0, 0, 0, 0.12); - color: #111; - border-color: rgba(0, 0, 0, 0.12); + background: rgba(2, 6, 23, 0.28); + color: #e2e8f0; + border-color: rgba(148, 163, 184, 0.2); } .chat-msg--assistant { align-self: flex-start; width: auto; max-width: 100%; - background: var(--ds-splash-bg, rgba(148, 163, 184, 0.06)); - border: 1px solid var(--ds-border, rgba(148, 163, 184, 0.18)); + padding: 10px 14px; + border-radius: 14px; + color: var(--ds-text, #e8e8e8); + background: rgba(148, 163, 184, 0.1); + border: 1px solid rgba(148, 163, 184, 0.18); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 8px 24px rgba(2, 6, 23, 0.14); + backdrop-filter: blur(14px) saturate(1.2); + -webkit-backdrop-filter: blur(14px) saturate(1.2); } .chat-msg__actions { diff --git a/interfaces/admin/static/theme-presets.css b/interfaces/admin/static/theme-presets.css index 664f33cf..136e761b 100644 --- a/interfaces/admin/static/theme-presets.css +++ b/interfaces/admin/static/theme-presets.css @@ -11,6 +11,7 @@ body.theme-ds-body[data-admin-theme="netx"] { --ds-text: #e2e8f0; --ds-text-muted: #94a3b8; --ds-accent: #3b82f6; + --ds-accent-deep: #2563eb; --ds-accent-soft: rgba(59, 130, 246, 0.22); --ds-sidebar: #0c1220; --ds-topbar-bg: rgba(6, 10, 18, 0.94); @@ -24,10 +25,10 @@ body.theme-ds-body[data-admin-theme="netx"] { --ds-chat-scroll-thumb: #475569; --ds-chat-scroll-thumb-hover: #64748b; --chat-sess-hover-bg: rgba(148, 163, 184, 0.08); - --chat-sess-active-bg: rgba(59, 130, 246, 0.16); + --chat-sess-active-bg: rgba(148, 163, 184, 0.12); --chat-sess-border: rgba(148, 163, 184, 0.18); - --chat-user-bubble-bg: #2563eb; - --chat-user-bubble-fg: #f8fafc; + --chat-user-bubble-bg: rgba(59, 130, 246, 0.22); + --chat-user-bubble-fg: #e2e8f0; --ds-overlay-bg: rgba(2, 6, 23, 0.72); --ds-menu-hover-bg: rgba(148, 163, 184, 0.1); --ds-menu-sep: rgba(148, 163, 184, 0.16); @@ -226,6 +227,7 @@ body.theme-ds-body[data-admin-theme="light"] { --ds-splash-border: rgba(15, 23, 42, 0.12); --ds-splash-spinner-track: rgba(15, 23, 42, 0.12); --ds-splash-spinner-active: rgba(15, 23, 42, 0.55); + --ds-composer-shell-bg: #f1f5f9; } body.theme-ds-body[data-admin-theme="light"] .nav__groupTitle { @@ -255,8 +257,32 @@ body.theme-ds-body[data-admin-theme="light"] .chat-avatar--userBuiltin { } body.theme-ds-body[data-admin-theme="light"] .chat-msg--assistant { - background: rgba(15, 23, 42, 0.045); - border-color: var(--ds-border); + background: rgba(255, 255, 255, 0.55); + border: 1px solid rgba(15, 23, 42, 0.1); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.7), + 0 8px 20px rgba(15, 23, 42, 0.06); + backdrop-filter: blur(14px) saturate(1.2); + -webkit-backdrop-filter: blur(14px) saturate(1.2); + color: #0f172a; +} + +body.theme-ds-body[data-admin-theme="light"] .chat-msg--user { + background: rgba(59, 130, 246, 0.16); + border: 1px solid rgba(37, 99, 235, 0.2); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.55), + 0 8px 20px rgba(15, 23, 42, 0.06); + backdrop-filter: blur(14px) saturate(1.25); + -webkit-backdrop-filter: blur(14px) saturate(1.25); + color: #0f172a; + filter: none; +} + +body.theme-ds-body[data-admin-theme="light"] .chat-sess-badge--local { + border-color: rgba(15, 23, 42, 0.2); + background: rgba(15, 23, 42, 0.06); + color: rgba(15, 23, 42, 0.72); } body.theme-ds-body[data-admin-theme="light"] .chat-msg__action-btn { @@ -293,6 +319,41 @@ body.theme-ds-body[data-admin-theme="light"] .chat-composer { border-top-color: rgba(15, 23, 42, 0.08); } +body.theme-ds-body[data-admin-theme="light"] .chat-composer-chip, +body.theme-ds-body[data-admin-theme="light"] .chat-composer-chip--select { + background-color: #ffffff; + border-color: rgba(15, 23, 42, 0.16); + color: #0f172a; +} + +body.theme-ds-body[data-admin-theme="light"] .chat-composer-chip--select { + background-color: #ffffff; + background-image: + linear-gradient(45deg, transparent 50%, rgba(15, 23, 42, 0.55) 50%), + linear-gradient(135deg, rgba(15, 23, 42, 0.55) 50%, transparent 50%); + background-position: + calc(100% - 14px) calc(50% - 2px), + calc(100% - 9px) calc(50% - 2px); + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} + +body.theme-ds-body[data-admin-theme="light"] .chat-composer-chip--select option { + background-color: #ffffff; + color: #0f172a; +} + +body.theme-ds-body[data-admin-theme="light"] .chat-composer-chip:hover, +body.theme-ds-body[data-admin-theme="light"] .chat-composer-chip--select:hover { + background-color: #f8fafc; + border-color: rgba(37, 99, 235, 0.35); +} + +body.theme-ds-body[data-admin-theme="light"] .chat-composer-shell { + background: #f1f5f9; + border-color: rgba(15, 23, 42, 0.12); +} + body.theme-ds-body[data-admin-theme="light"] .chat-msg__reasoning { border-color: rgba(15, 23, 42, 0.1); background: rgba(15, 23, 42, 0.03); diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index c4ecc8f3..0ee7393f 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -495,13 +495,13 @@ def _extract_group_name(inbound: Any) -> str: cands.append(str(v).strip()) raw = inbound.metadata.get("raw") if isinstance(raw, dict): - for key in ("chat_name", "group_name", "room_name", "conversation_name", "chatname"): + for key in ("chat_name", "group_name", "room_name", "conversation_name", "chatname", "subject"): v = raw.get(key) if v is not None: cands.append(str(v).strip()) chat_obj = raw.get("chat") if isinstance(chat_obj, dict): - for key in ("name", "chat_name", "group_name"): + for key in ("name", "chat_name", "group_name", "subject"): v = chat_obj.get(key) if v is not None: cands.append(str(v).strip()) @@ -511,16 +511,74 @@ def _extract_group_name(inbound: Any) -> str: return "" -def _build_wecom_session_title(*, account_name: str, external_user_id: str, is_group: bool, group_name: str) -> str: +def _lookup_known_whatsapp_group_name(store: Any, *, account_id: str, group_jid: str) -> str: + jid = str(group_jid or "").strip() + if not jid or not hasattr(store, "list_whatsapp_known_groups"): + return "" + try: + import os + + tid = str(os.getenv("OCLAW_DEFAULT_TENANT_ID") or "default") + rows = store.list_whatsapp_known_groups(tenant_id=tid, account_id=str(account_id or "").strip() or None) + except Exception: + return "" + for row in rows or []: + if not isinstance(row, dict): + continue + if str(row.get("group_jid") or "").strip() == jid: + name = str(row.get("group_name") or "").strip() + if name: + return name + return "" + + +def _resolve_inbound_group_name(store: Any, inbound: Any, *, account_id: str) -> str: + name = _extract_group_name(inbound) + if name: + return name + if not bool(getattr(inbound, "is_group", False)): + return "" + chat_id = str(getattr(inbound, "external_chat_id", "") or "").strip() + ch = str(getattr(inbound, "channel", "") or "").strip().lower() + if ch == "whatsapp" and chat_id: + known = _lookup_known_whatsapp_group_name(store, account_id=account_id, group_jid=chat_id) + if known: + return known + # Last-resort readable hint from group JID (still better than sender phone only). + if chat_id and "@" in chat_id and chat_id.lower().endswith("@g.us"): + return chat_id.split("@", 1)[0] or "" + return "" + + +def _build_wecom_session_title( + *, + account_name: str, + external_user_id: str, + is_group: bool, + group_name: str, + display_name: str = "", +) -> str: base = f"{str(account_name or '').strip() or 'WeCom'}+{str(external_user_id or '').strip() or 'unknown'}" if is_group and str(group_name or "").strip(): body = f"{base}+{str(group_name).strip()}" else: body = base + nick = str(display_name or "").strip() + eid = str(external_user_id or "").strip() + if nick and nick != eid and nick.lower() not in body.lower(): + body = f"{nick} · {body}" return f"wechat|{body}" -def _build_channel_session_title(*, channel: str, account_name: str, external_user_id: str, is_group: bool, group_name: str) -> str: +def _build_channel_session_title( + *, + channel: str, + account_name: str, + external_user_id: str, + is_group: bool, + group_name: str, + display_name: str = "", +) -> str: ch = str(channel or "").strip().lower() or "channel" if ch == "wecom": return _build_wecom_session_title( @@ -528,9 +586,24 @@ def _build_channel_session_title(*, channel: str, account_name: str, external_us external_user_id=external_user_id, is_group=is_group, group_name=group_name, + display_name=display_name, ) - base = f"{str(account_name or '').strip() or ch}+{str(external_user_id or '').strip() or 'unknown'}" - body = f"{base}+{str(group_name or '').strip()}" if is_group and str(group_name or "").strip() else base + eid = str(external_user_id or "").strip() or "unknown" + base = f"{str(account_name or '').strip() or ch}+{eid}" + gname = str(group_name or "").strip() + nick = str(display_name or "").strip() + # Put human labels *before* the technical account+id body so UI parsers + # (and humans) can see which WhatsApp group a per-sender session belongs to. + # Legacy form `account+jid+groupName` buried the group after @domain and got stripped. + labels: list[str] = [] + if is_group and gname and gname != eid and gname.lower() not in base.lower(): + g_label = gname + if gname.isdigit() and len(gname) > 14: + g_label = f"{gname[:12]}…" + labels.append(g_label) + if nick and nick != eid and nick != gname and nick.lower() not in base.lower(): + labels.append(nick) + body = f"{' · '.join(labels)} · {base}" if labels else base return f"{ch}|{body}" @@ -1208,7 +1281,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: user_id = str(ident.get("user_id") or "") role = str(ident.get("role") or "member") account_name = str(account.get("name") or "").strip() or account_id - group_name = _extract_group_name(inbound) + group_name = _resolve_inbound_group_name(store, inbound, account_id=account_id) session_external_user_id = session_user_key( is_group=inbound.is_group, external_user_id=inbound.external_user_id, @@ -1221,20 +1294,61 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: group_name=group_name, external_chat_id=inbound.external_chat_id, ) + inbound_meta = inbound.metadata if isinstance(inbound.metadata, dict) else {} + peer_display_name = str( + inbound_meta.get("push_name") + or inbound_meta.get("pushName") + or inbound_meta.get("display_name") + or inbound_meta.get("displayName") + or "" + ).strip() + if not peer_display_name and isinstance(inbound_meta.get("raw"), dict): + raw_meta = inbound_meta.get("raw") or {} + peer_display_name = str( + raw_meta.get("pushName") + or raw_meta.get("push_name") + or raw_meta.get("displayName") + or raw_meta.get("display_name") + or "" + ).strip() + session_title = _build_channel_session_title( + channel=inbound.channel, + account_name=account_name, + external_user_id=title_user_label, + is_group=inbound.is_group, + group_name=group_name, + display_name=peer_display_name, + ) session_id = store.get_or_create_channel_session_v2( tenant_id=tenant_id, channel=inbound.channel, account_id=account_id, external_user_id=session_external_user_id, external_chat_id=inbound.external_chat_id, - session_title=_build_channel_session_title( - channel=inbound.channel, - account_name=account_name, - external_user_id=title_user_label, - is_group=inbound.is_group, - group_name=group_name, - ), + session_title=session_title, ) + # Refresh list title when we learn group name and/or peer nickname later. + try: + cur = store.get_session(str(session_id)) if hasattr(store, "get_session") else None + cur_title = str((cur or {}).get("title") if isinstance(cur, dict) else getattr(cur, "title", "") or "") + richer = False + if session_title and cur_title and session_title != cur_title: + if group_name and group_name not in cur_title and group_name in session_title: + richer = True + elif peer_display_name and " · " in session_title and ( + " · " not in cur_title or peer_display_name.lower() not in cur_title.lower() + ): + richer = True + elif inbound.is_group and " · " in session_title and "+" in cur_title and " · " not in cur_title: + # Migrate legacy `account+jid+group` titles to the readable prefix form. + richer = True + if richer: + if hasattr(store, "rename_session"): + store.rename_session(str(session_id), session_title[:120]) + elif hasattr(store, "rename_chat_session"): + store.rename_chat_session(session_id=str(session_id), title=session_title[:120]) + except Exception: + pass channel_session_id = str(session_id) from runtime.application.gateway.channel_session_owner import ( assign_channel_session_to_account_owner,