Files
WeChat-AI/apps/api/public/app.html
T

6288 lines
221 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5" />
<meta name="theme-color" content="#f5f5f7" id="themeColor" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="format-detection" content="telephone=no" />
<meta name="description" content="WeChat-AI:基于微信的多用户角色扮演机器人平台。扫码绑定机器人、人设广场、长期记忆与私聊分配,一站管理你的智能体。" />
<!-- Open Graph / Twitter:绝对 URL 由服务端按 PUBLIC_BASE_URL 注入;下方为可抓取的默认值 -->
<meta property="og:title" content="WeChat-AI — 微信角色扮演机器人" />
<meta property="og:description" content="WeChat-AI:基于微信的多用户角色扮演机器人平台。扫码绑定机器人、人设广场、长期记忆与私聊分配,一站管理你的智能体。支持 LINUX DO 登录与多机器人。" />
<meta property="og:image" content="/og.jpg" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:alt" content="WeChat-AI" />
<meta property="og:url" content="/app" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="WeChat-AI" />
<meta property="og:locale" content="zh_CN" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="WeChat-AI — 微信角色扮演机器人" />
<meta name="twitter:description" content="扫码绑定机器人、人设广场、长期记忆与私聊分配。多用户角色扮演,一站管理智能体。" />
<meta name="twitter:image" content="/og.jpg" />
<link rel="canonical" href="/app" />
<!-- Robot favicon (inline SVG) -->
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%235e5ce6'/%3E%3Cstop offset='100%25' stop-color='%23bf5af2'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Cg fill='none' stroke='%23fff' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='14' y='22' width='36' height='28' rx='8'/%3E%3Ccircle cx='26' cy='36' r='2.4' fill='%23fff' stroke='none'/%3E%3Ccircle cx='38' cy='36' r='2.4' fill='%23fff' stroke='none'/%3E%3Cpath d='M32 14v8M24 50v4M40 50v4M14 34h-4M54 34h-4'/%3E%3C/g%3E%3C/svg%3E"
/>
<script>
(function () {
try {
var k = "wa_theme";
var t = localStorage.getItem(k);
if (t === "system" || (t !== "light" && t !== "dark")) {
if (localStorage.getItem(k) === "system") {
document.documentElement.setAttribute("data-theme-pref", "system");
}
t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
document.documentElement.setAttribute("data-theme", t);
} catch (e) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>
<!-- Icon font: render-blocking cross-origin CSS that then chain-loads a woff2.
preconnect collapses the DNS+TLS round trips; media=print + onload takes it
off the critical path so first paint never waits on jsdelivr. -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css"
media="print" onload="this.media='all';this.onload=null" />
<noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css" /></noscript>
<title>WeChat-AI — 微信角色扮演机器人</title>
<style>
:root {
--bg: #f5f5f7;
--bg-elevated: rgba(255, 255, 255, 0.72);
--surface: #ffffff;
--surface-2: #f5f5f7;
--text: #1d1d1f;
--text-secondary: #6e6e73;
--text-tertiary: #86868b;
--separator: rgba(0, 0, 0, 0.08);
--blue: #0071e3;
--blue-hover: #0077ed;
--blue-soft: rgba(0, 113, 227, 0.1);
--green: #34c759;
--green-soft: rgba(52, 199, 89, 0.12);
--green-text: #1a7f37;
--red: #ff3b30;
--red-soft: rgba(255, 59, 48, 0.12);
--orange: #ff9f0a;
--orange-soft: rgba(255, 159, 10, 0.14);
--orange-text: #b45309;
--chip: rgba(0, 0, 0, 0.05);
--input-bg: rgba(0, 0, 0, 0.03);
--code-bg: rgba(0, 0, 0, 0.04);
--glow-a: rgba(0, 113, 227, 0.08);
--glow-b: rgba(175, 82, 222, 0.06);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 24px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.08);
--toast-bg: rgba(29, 29, 31, 0.92);
--toast-err: rgba(255, 59, 48, 0.95);
--focus-border: rgba(0, 113, 227, 0.55);
--ring: var(--blue-soft);
--scrim: rgba(0, 0, 0, 0.42);
--radius: 18px;
--radius-sm: 12px;
--radius-xs: 8px;
--radius-lg: 22px;
--radius-pill: 980px;
--control-h: 42px;
--control-h-sm: 36px;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
"Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
--ease: cubic-bezier(0.25, 0.1, 0.25, 1);
--theme-icon-sun: none;
--theme-icon-moon: inline;
--safe-t: env(safe-area-inset-top, 0px);
--safe-b: env(safe-area-inset-bottom, 0px);
--safe-l: env(safe-area-inset-left, 0px);
--safe-r: env(safe-area-inset-right, 0px);
--nav-h: 56px;
/* Fluid page gutter + content width: continuous phone → 4K, no jumps */
--gutter: clamp(14px, 3.4vw, 26px);
--content-max: 1080px;
/* ---- Liquid glass ----
Layered translucency: tinted base + blurred backdrop + specular rim
(bright top edge, dim bottom edge) that reads as glass thickness
rather than a flat frosted panel. */
--glass-bg: rgba(255, 255, 255, 0.6);
--glass-bg-strong: rgba(255, 255, 255, 0.76);
--glass-blur: 24px;
--glass-hi: rgba(255, 255, 255, 0.95);
--glass-lo: rgba(255, 255, 255, 0.3);
--glass-ring: rgba(0, 0, 0, 0.07);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 2px 8px rgba(0, 0, 0, 0.05);
--sk-sheen: rgba(255, 255, 255, 0.6);
/* ---- 动态玻璃:滚动深度驱动 ---- */
--glass-rgb: 255, 255, 255;
--glass-a-min: 0.3;
--glass-a-max: 0.76;
--glass-blur-min: 4px;
--glass-blur-max: 24px;
--glass-progress: 0;
--glass-a: calc(var(--glass-a-min) + (var(--glass-a-max) - var(--glass-a-min)) * var(--glass-progress));
--glass-blur-live: calc(var(--glass-blur-min) + (var(--glass-blur-max) - var(--glass-blur-min)) * var(--glass-progress));
}
@media (min-width: 1600px) {
:root { --content-max: 1240px; }
}
html[data-theme="dark"] {
--bg: #000000;
--bg-elevated: rgba(28, 28, 30, 0.72);
--surface: #1c1c1e;
--surface-2: #2c2c2e;
--text: #f5f5f7;
--text-secondary: #a1a1a6;
--text-tertiary: #6e6e73;
--separator: rgba(255, 255, 255, 0.1);
--blue: #0a84ff;
--blue-hover: #409cff;
--blue-soft: rgba(10, 132, 255, 0.18);
--green: #30d158;
--green-soft: rgba(48, 209, 88, 0.16);
--green-text: #30d158;
--red: #ff453a;
--red-soft: rgba(255, 69, 58, 0.16);
--orange: #ff9f0a;
--orange-soft: rgba(255, 159, 10, 0.16);
--orange-text: #ffb340;
--chip: rgba(255, 255, 255, 0.08);
--input-bg: rgba(255, 255, 255, 0.06);
--code-bg: rgba(255, 255, 255, 0.08);
--glow-a: rgba(10, 132, 255, 0.12);
--glow-b: rgba(191, 90, 242, 0.1);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 4px 24px rgba(0, 0, 0, 0.45);
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.55);
--toast-bg: rgba(44, 44, 46, 0.95);
--toast-err: rgba(255, 69, 58, 0.95);
--focus-border: rgba(10, 132, 255, 0.65);
--ring: var(--blue-soft);
--scrim: rgba(0, 0, 0, 0.55);
--theme-icon-sun: inline;
--theme-icon-moon: none;
--glass-bg: rgba(30, 30, 32, 0.58);
--glass-bg-strong: rgba(32, 32, 34, 0.78);
--glass-hi: rgba(255, 255, 255, 0.16);
--glass-lo: rgba(255, 255, 255, 0.04);
--glass-ring: rgba(255, 255, 255, 0.1);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.35);
--sk-sheen: rgba(255, 255, 255, 0.08);
--glass-rgb: 30, 30, 32;
--glass-a-min: 0.32;
--glass-a-max: 0.78;
}
* { box-sizing: border-box; }
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color-scheme: light;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: var(--chip) transparent;
}
/* Thin Apple-like scrollbars (Windows/Linux) */
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--chip);
border-radius: 99px;
border: 2px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover { background: var(--separator); background-clip: padding-box; }
html[data-theme="dark"] { color-scheme: dark; }
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
font-family: var(--font);
color: var(--text);
background:
radial-gradient(1200px 600px at 10% -10%, var(--glow-a), transparent 50%),
radial-gradient(900px 500px at 100% 0%, var(--glow-b), transparent 45%),
var(--bg);
line-height: 1.47;
transition: background 0.25s var(--ease), color 0.2s var(--ease);
overflow-x: hidden;
}
button, .btn, .theme-toggle, .tabs button, .var-chip, .vis-seg label {
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.btn, .tabs button, .theme-toggle, .var-chip, .fab-refresh, .badge, .tag, .vis-seg label {
-webkit-user-select: none;
user-select: none;
}
a { color: var(--blue); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ---------- Liquid glass material ---------- */
.glass {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(180%);
box-shadow:
inset 0 1px 0 0 var(--glass-hi),
inset 0 -1px 0 0 var(--glass-lo),
var(--glass-shadow);
}
.glass-strong { background: var(--glass-bg-strong); }
/* 吸顶层专用:底下有内容滚过时才逐渐加深,滚动到顶部时近乎透明。
与 iOS 导航栏一致;.glass 保持静态,供不随滚动变化的浮层使用。 */
.glass-dynamic {
background: rgba(var(--glass-rgb), var(--glass-a));
backdrop-filter: blur(var(--glass-blur-live)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur-live)) saturate(180%);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, calc(0.95 * var(--glass-progress))),
0 1px 0 0 rgba(0, 0, 0, calc(0.08 * var(--glass-progress)));
}
html[data-theme="dark"] .glass-dynamic {
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, calc(0.16 * var(--glass-progress))),
0 1px 0 0 rgba(255, 255, 255, calc(0.1 * var(--glass-progress)));
}
.wa-lens-defs { position: absolute; width: 0; height: 0; overflow: hidden; }
/* 折射只在 Chromium 桌面开启,见下方网关脚本。降低模糊让折射看得见——
已经糊成一团的内容没法再折射,两者此消彼长。 */
html.wa-lens .glass,
html.wa-lens .glass-strong {
backdrop-filter: blur(14px) saturate(180%) url(#waGlassLens);
-webkit-backdrop-filter: blur(14px) saturate(180%);
}
/* Specular rim traced around the shape, masked to a 1px ring so the fill
stays clear. Gives the edge-lit look that separates liquid glass from
plain frosted blur. */
.glass-rim::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(145deg, var(--glass-hi) 0%, transparent 38%, transparent 62%, var(--glass-lo) 100%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
pointer-events: none;
z-index: 3;
}
.glass-rim > * { position: relative; z-index: 2; }
/* ---------- Staggered entrance for injected cards ---------- */
@keyframes riseIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
/* "backwards", not "both": a forwards-filling animation keeps holding its
final keyframe values (transform: none) at the animation cascade origin,
which outranks normal declarations and would permanently kill
.persona-card:hover { transform: translateY(-1px) }. "backwards" still
applies the from-state during the stagger delay (no flash-in) but
releases the properties once the animation ends. */
.stagger > * { animation: riseIn 0.42s var(--ease) backwards; }
.stagger > *:nth-child(1) { animation-delay: 0.02s; }
.stagger > *:nth-child(2) { animation-delay: 0.06s; }
.stagger > *:nth-child(3) { animation-delay: 0.1s; }
.stagger > *:nth-child(4) { animation-delay: 0.14s; }
.stagger > *:nth-child(5) { animation-delay: 0.18s; }
.stagger > *:nth-child(6) { animation-delay: 0.22s; }
.stagger > *:nth-child(7) { animation-delay: 0.26s; }
.stagger > *:nth-child(8) { animation-delay: 0.3s; }
.stagger > *:nth-child(n + 9) { animation-delay: 0.34s; }
/* ---------- Generic busy button spinner ---------- */
.btn.is-busy {
pointer-events: none;
position: relative;
color: transparent !important;
}
.btn.is-busy > * { visibility: hidden; }
.btn.is-busy::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 15px;
height: 15px;
margin: -8px 0 0 -8px;
border-radius: 50%;
border: 2px solid var(--spin-track, rgba(0, 0, 0, 0.2));
border-top-color: var(--spin-head, var(--text));
animation: spin 0.7s linear infinite;
}
.btn-primary.is-busy, .btn-danger.is-busy { --spin-track: rgba(255, 255, 255, 0.35); --spin-head: #fff; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------- Top loading bar (in-flight requests) ---------- */
.load-bar {
position: fixed; top: 0; left: 0; right: 0; height: 2px; z-index: 90;
pointer-events: none; opacity: 0; background: transparent;
transition: opacity 0.15s;
}
.load-bar.on {
opacity: 1;
background: linear-gradient(90deg, transparent, var(--blue), transparent);
background-size: 40% 100%;
animation: loadSlide 0.9s linear infinite;
}
@keyframes loadSlide { from { background-position: -40% 0; } to { background-position: 140% 0; } }
/* Nav */
.nav {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: var(--nav-h);
padding: calc(10px + var(--safe-t)) max(16px, var(--safe-r)) 10px max(16px, var(--safe-l));
/* 底色、模糊、上边高光与下边分隔线全部来自 .glass-dynamic:滚动到顶部时
导航栏近乎透明,内容滚过时才逐渐凝实。原来的静态阴影栈(以及配套的
.nav.scrolled 二段式阴影和 box-shadow 过渡)会盖住动态值,已移除;
.scrolled 类仍由脚本照常切换,只是不再有对应样式。 */
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 15px;
letter-spacing: -0.01em;
}
.brand-mark {
width: 28px;
height: 28px;
border-radius: 8px;
flex-shrink: 0;
display: inline-grid;
place-items: center;
background: linear-gradient(145deg, #5e5ce6 0%, #bf5af2 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.28),
0 2px 8px rgba(94, 92, 230, 0.28);
color: #fff;
font-size: 14px;
line-height: 1;
}
.brand-mark .bi {
font-size: 0.95em;
line-height: 1;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
}
.nav-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.theme-toggle {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--chip);
color: var(--text);
cursor: pointer;
display: grid;
place-items: center;
font-size: 16px;
transition: background 0.15s, transform 0.15s var(--ease);
}
.theme-toggle:hover { background: var(--blue-soft); }
.theme-toggle:active { transform: scale(0.95); }
.theme-toggle .sun { display: var(--theme-icon-sun); }
.theme-toggle .moon { display: var(--theme-icon-moon); }
html[data-theme-pref="system"] .theme-toggle .sun,
html[data-theme-pref="system"] .theme-toggle .moon { display: none; }
html[data-theme-pref="system"] .theme-toggle .auto { display: grid !important; place-items: center; }
.avatar-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 4px;
border-radius: var(--radius-pill);
background: var(--chip);
font-size: 13px;
color: var(--text-secondary);
}
.avatar-dot {
width: 26px;
height: 26px;
border-radius: 50%;
background: linear-gradient(145deg, #64d2ff, #0071e3);
color: #fff;
font-size: 11px;
font-weight: 600;
display: grid;
place-items: center;
overflow: hidden;
flex-shrink: 0;
}
.avatar-dot img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-dot.has-img {
background: var(--chip);
color: transparent;
}
/* Buttons */
.btn {
appearance: none;
border: none;
font-family: inherit;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
border-radius: var(--radius-pill);
padding: 9px 18px;
min-height: var(--control-h-sm);
white-space: nowrap;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: transform 0.15s var(--ease), background 0.15s var(--ease), opacity 0.15s;
}
a.btn:hover { text-decoration: none; }
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.btn:focus-visible,
.tabs button:focus-visible,
.theme-toggle:focus-visible,
.var-chip:focus-visible,
.fab-refresh:focus-visible,
a:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--ring);
}
.btn-primary {
background: var(--blue);
color: #fff;
box-shadow: 0 1px 2px color-mix(in srgb, var(--blue) 28%, transparent);
}
.btn-primary:hover:not(:disabled) { background: var(--blue-hover); }
.btn-secondary {
background: var(--chip);
color: var(--text);
}
.btn-secondary:hover:not(:disabled) { filter: brightness(0.96); }
html[data-theme="dark"] .btn-secondary:hover:not(:disabled) { filter: brightness(1.15); }
.btn-danger {
background: var(--red-soft);
color: var(--red);
}
.btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--red) 18%, transparent); }
.btn-ghost {
background: transparent;
color: var(--blue);
padding: 9px 12px;
}
.btn-ghost:hover:not(:disabled) {
background: var(--blue-soft);
color: var(--blue);
}
.btn-fork {
background: linear-gradient(
135deg,
rgba(175, 82, 222, 0.14),
rgba(0, 113, 227, 0.12)
);
color: #7b46c1;
border: 1px solid rgba(175, 82, 222, 0.22);
gap: 6px;
}
.btn-fork:hover:not(:disabled) {
filter: brightness(0.97);
border-color: rgba(175, 82, 222, 0.4);
}
html[data-theme="dark"] .btn-fork {
color: #d4a8ff;
background: linear-gradient(
135deg,
rgba(191, 90, 242, 0.18),
rgba(10, 132, 255, 0.12)
);
border-color: rgba(191, 90, 242, 0.28);
}
html[data-theme="dark"] .btn-fork:hover:not(:disabled) {
filter: brightness(1.12);
}
.btn-fork i { font-size: 14px; line-height: 1; }
.badge-fork {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 3px 9px;
border-radius: var(--radius-pill);
background: rgba(175, 82, 222, 0.12);
color: #7b46c1;
border: 1px solid rgba(175, 82, 222, 0.18);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
html[data-theme="dark"] .badge-fork {
background: rgba(191, 90, 242, 0.16);
color: #d4a8ff;
border-color: rgba(191, 90, 242, 0.28);
}
.badge-fork i { font-size: 11px; opacity: 0.9; }
.btn-lg { padding: 14px 28px; font-size: 16px; }
/* Layout */
.wrap {
width: min(var(--content-max), 100%);
margin: 0 auto;
padding: clamp(20px, 3vw, 36px)
max(var(--gutter), var(--safe-r))
calc(clamp(56px, 7vw, 80px) + var(--safe-b))
max(var(--gutter), var(--safe-l));
}
.page-title {
font-size: clamp(25px, 4.6vw, 40px);
font-weight: 700;
letter-spacing: -0.03em;
margin: 0 0 8px;
line-height: 1.1;
}
.page-sub {
margin: 0 0 clamp(20px, 3vw, 32px);
color: var(--text-secondary);
font-size: clamp(14.5px, 1.9vw, 17px);
font-weight: 400;
}
.card {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--separator);
padding: 24px 28px;
margin-bottom: 20px;
}
.card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.card h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
letter-spacing: -0.02em;
}
.card-desc {
margin: 4px 0 0;
font-size: 14px;
color: var(--text-tertiary);
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 18px;
}
input, select {
font-family: inherit;
font-size: 14px;
color: var(--text);
background: var(--input-bg);
border: 1px solid var(--separator);
border-radius: var(--radius-sm);
padding: 10px 14px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
min-width: 0;
}
.toolbar input,
.toolbar select,
.form-field input,
.form-fields input,
.name-edit input {
min-width: 160px;
}
input:focus, select:focus {
border-color: var(--focus-border);
box-shadow: 0 0 0 4px var(--ring);
}
input::placeholder { color: var(--text-tertiary); }
.table-wrap {
overflow-x: auto;
margin: 0;
-webkit-overflow-scrolling: touch;
border-radius: var(--radius-xs);
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 14px;
table-layout: auto;
}
th {
text-align: left;
font-weight: 500;
font-size: 12px;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-tertiary);
padding: 10px 12px;
border-bottom: 1px solid var(--separator);
white-space: nowrap;
}
td {
padding: 14px 12px;
border-bottom: 1px solid var(--separator);
vertical-align: middle;
transition: background 0.12s;
}
tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: var(--surface-2); }
code {
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
color: var(--text-secondary);
background: var(--code-bg);
padding: 3px 7px;
border-radius: 6px;
}
/* Peers table: prevent column crush / vertical CJK text */
table.peers-table {
table-layout: fixed;
min-width: 720px;
}
table.peers-table th:nth-child(1),
table.peers-table td:nth-child(1) { width: 22%; }
table.peers-table th:nth-child(2),
table.peers-table td:nth-child(2) { width: 14%; }
table.peers-table th:nth-child(3),
table.peers-table td:nth-child(3) { width: 10%; }
table.peers-table th:nth-child(4),
table.peers-table td:nth-child(4) { width: 22%; }
table.peers-table th:nth-child(5),
table.peers-table td:nth-child(5) { width: 12%; }
table.peers-table th:nth-child(6),
table.peers-table td:nth-child(6) { width: 20%; }
table.peers-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
table.peers-table td.col-persona,
table.peers-table td.col-actions {
overflow: visible;
white-space: normal;
}
table.peers-table .id-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
table.peers-table .id-cell code {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
table.peers-table .id-cell .sub {
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
}
table.peers-table .persona-select {
width: 100%;
min-width: 0;
max-width: 100%;
padding: 8px 10px;
font-size: 13px;
}
table.peers-table .col-actions .row-actions {
flex-wrap: nowrap;
gap: 6px;
}
table.peers-table .col-actions .btn {
flex-shrink: 0;
}
table.peers-table .badge {
white-space: nowrap;
flex-shrink: 0;
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: var(--radius-pill);
font-size: 12px;
font-weight: 500;
background: var(--chip);
color: var(--text-secondary);
white-space: nowrap;
}
.badge::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.7;
flex-shrink: 0;
}
.badge.ok {
background: var(--green-soft);
color: var(--green-text);
}
.badge.warn {
background: var(--orange-soft);
color: var(--orange-text);
}
.badge.err {
background: var(--red-soft);
color: var(--red);
}
.badge.accent {
background: var(--blue-soft);
color: var(--blue);
}
table td .row-actions {
flex-wrap: wrap;
justify-content: flex-start;
gap: 8px;
}
table td .row-actions .btn {
white-space: nowrap;
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
/* Specular sweep skeleton */
.skeleton {
position: relative;
overflow: hidden;
background: var(--chip);
border-radius: 10px;
height: 14px;
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, var(--sk-sheen), transparent);
animation: skSweep 1.5s var(--ease) infinite;
}
@keyframes skSweep { to { transform: translateX(100%); } }
.sk-rows { display: grid; gap: 10px; padding: 6px 0; }
.sk-rows .skeleton { height: 48px; border-radius: 12px; }
.sk-tile { height: 190px; border-radius: var(--radius); }
/* boot splash while /auth/me resolves — avoids flashing the login hero */
#bootSplash {
min-height: calc(100dvh - var(--nav-h) - var(--safe-t));
display: flex;
align-items: center;
justify-content: center;
}
#bootSplash .brand-mark {
width: 44px;
height: 44px;
border-radius: 12px;
font-size: 22px;
animation: bootPulse 1.2s ease-in-out infinite;
}
@keyframes bootPulse {
0%, 100% { opacity: 0.45; transform: scale(0.96); }
50% { opacity: 1; transform: scale(1); }
}
.tabs {
display: flex;
gap: 6px;
flex-wrap: nowrap;
margin-bottom: 24px;
padding: 4px;
/* 底色与模糊来自 .glass-dynamic,边缘高光来自 .glass-rim。
position: relative 原先由 .glass 提供,换成动态玻璃后要在这里补回,
否则桌面端(非 sticky)的 .glass-rim::before 会脱离胶囊定位。 */
position: relative;
border-radius: var(--radius-pill);
width: fit-content;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
overscroll-behavior-x: contain;
}
/* 胶囊形状不适合 .glass-dynamic 默认的「上高光 + 下分隔线」,
改成一圈柔和落影,同样按滚动深度淡入。 */
.tabs.glass-dynamic { box-shadow: inset 0 1px 0 0 rgba(255,255,255,calc(0.9*var(--glass-progress))), 0 2px 12px rgba(0,0,0,calc(0.06*var(--glass-progress))); }
html[data-theme="dark"] .tabs.glass-dynamic { box-shadow: inset 0 1px 0 0 rgba(255,255,255,calc(0.14*var(--glass-progress))), 0 2px 12px rgba(0,0,0,calc(0.4*var(--glass-progress))); }
.tabs::-webkit-scrollbar { display: none; }
.tabs button {
border: none;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 14px;
font-weight: 500;
padding: 10px 16px;
min-height: 40px;
border-radius: var(--radius-pill);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: color 0.15s, transform 0.12s var(--ease);
}
.tabs button:active { transform: scale(0.96); }
.tabs button.active {
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-sm);
}
.pane { display: none; }
.pane.active { display: block; animation: paneIn 0.22s var(--ease); }
/* opacity-only: transform would create a containing block and break sticky tabs */
@keyframes paneIn {
from { opacity: 0; }
to { opacity: 1; }
}
.persona-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
@media (min-width: 861px) and (max-width: 1100px) {
.persona-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
.persona-grid > .empty {
grid-column: 1 / -1;
}
.pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--separator);
font-size: 13px;
color: var(--text-secondary);
}
.pager .row-actions { gap: 6px; }
.pager .btn:disabled { opacity: 0.45; }
.persona-card {
background: var(--surface);
border: 1px solid var(--separator);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 10px;
transition: transform 0.15s var(--ease), box-shadow 0.15s;
}
.persona-card:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.persona-card h4 {
margin: 0;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.02em;
}
.persona-card .desc {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.persona-card .meta-line {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.persona-card .card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 2px;
}
.persona-card .card-actions .btn {
min-height: 36px;
}
.persona-card .card-links {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
align-items: center;
margin-top: auto;
padding-top: 2px;
}
.persona-card .card-links .btn-ghost {
padding: 4px 2px;
font-size: 13px;
min-height: auto;
}
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
.tag {
font-size: 11px;
padding: 2px 8px;
border-radius: var(--radius-pill);
background: var(--blue-soft);
color: var(--blue);
}
.form-grid {
display: grid;
gap: 16px;
}
.form-grid > label,
.form-field {
display: grid;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
min-width: 0;
}
.form-grid input,
.form-grid textarea,
.form-field input,
.form-field textarea {
width: 100%;
min-width: 0;
max-width: 100%;
}
/* name | desc | tags — 桌面三列,窄屏自动折行 */
.form-fields {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr) minmax(0, 1fr);
gap: 12px;
}
.form-grid select,
.form-field select {
width: 100%;
min-width: 0;
max-width: 100%;
font: inherit;
color: var(--text);
background: var(--input-bg);
border: 1px solid var(--separator);
border-radius: var(--radius-sm);
padding: 12px 14px;
outline: none;
}
.form-grid select:focus,
.form-field select:focus {
border-color: var(--focus-border);
box-shadow: 0 0 0 4px var(--ring);
}
.check-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-top: 4px;
}
.check-row input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--blue);
flex-shrink: 0;
}
.llm-prov-list {
display: grid;
gap: 10px;
margin-top: 4px;
}
.llm-prov-card {
display: grid;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--separator);
border-radius: var(--radius-sm);
background: var(--surface-2);
}
.llm-prov-card .row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.llm-prov-card .meta {
font-size: 12px;
color: var(--text-tertiary);
word-break: break-all;
line-height: 1.45;
}
.llm-prov-card h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.badge.soft {
background: var(--chip);
color: var(--text-secondary);
}
.badge.mode-cf {
background: rgba(175, 82, 222, 0.14);
color: #af52de;
}
textarea {
font-family: inherit;
font-size: 14px;
color: var(--text);
background: var(--input-bg);
border: 1px solid var(--separator);
border-radius: var(--radius-sm);
padding: 12px 14px;
min-height: 140px;
resize: vertical;
outline: none;
}
textarea:focus {
border-color: var(--focus-border);
box-shadow: 0 0 0 4px var(--ring);
}
/* Persona editor: variable chips + panel */
.editor-block {
display: grid;
gap: 10px;
padding: 14px 16px 16px;
background: var(--surface-2);
border: 1px solid var(--separator);
border-radius: var(--radius-sm);
}
.editor-block > .field-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.var-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.var-toolbar .hint {
font-size: 12px;
font-weight: 400;
color: var(--text-tertiary);
margin-right: 2px;
}
.var-chip {
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
font-weight: 500;
padding: 6px 12px;
border-radius: var(--radius-pill);
border: 1px solid var(--separator);
background: var(--surface);
color: var(--blue);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.1s;
}
.var-chip:hover {
background: var(--blue-soft);
border-color: rgba(0, 113, 227, 0.35);
}
.var-chip:active { transform: scale(0.97); }
.editor-help {
margin: 0;
font-size: 12px;
font-weight: 400;
color: var(--text-tertiary);
line-height: 1.55;
}
.editor-help code {
font-size: 11px;
}
.name-edit {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.name-edit input {
min-width: 100px;
max-width: 160px;
padding: 8px 12px;
font-weight: 600;
}
/* Proactive contact: compact button + modal (no inline form) */
.pro-open-btn {
gap: 6px;
}
.pro-open-btn .pro-pill {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
line-height: 1.3;
}
.pro-open-btn .pro-pill.on {
background: var(--green-soft, rgba(52, 199, 89, 0.16));
color: var(--green, #30d158);
}
.pro-open-btn .pro-pill.off {
background: var(--chip);
color: var(--text-tertiary);
}
.pro-open-btn.is-disabled {
opacity: 0.55;
}
.pro-modal-form {
display: grid;
gap: 14px;
margin: 14px 0 4px;
}
.pro-modal-form .pro-switch {
display: flex;
align-items: center;
gap: 10px;
font-weight: 500;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.pro-modal-form .pro-switch input {
width: 18px;
height: 18px;
accent-color: var(--blue);
}
.pro-modal-form .pro-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.pro-modal-form .pro-grid label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
min-width: 0;
}
.pro-modal-form .pro-grid label .hint {
font-weight: 400;
opacity: 0.75;
margin-left: 4px;
}
.pro-modal-form .pro-grid input {
font-size: 14px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--separator);
background: var(--bg, var(--surface-2));
color: var(--text);
width: 100%;
box-sizing: border-box;
min-height: 42px;
}
.pro-modal-form .pro-grid input:focus {
outline: none;
border-color: var(--focus-border);
box-shadow: 0 0 0 3px var(--ring);
}
.pro-modal-form .pro-note {
margin: 0;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
}
.pro-modal-form .pro-note.warn { color: var(--orange-text); }
@media (max-width: 520px) {
.pro-modal-form .pro-grid { grid-template-columns: 1fr; }
}
label.peer-pro {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
color: var(--text-secondary);
user-select: none;
}
label.peer-pro input {
width: 16px;
height: 16px;
margin: 0;
flex-shrink: 0;
accent-color: var(--blue, #0a84ff);
}
label.peer-pro.is-disabled {
opacity: 0.45;
cursor: not-allowed;
}
label.peer-pro .peer-pro-txt { color: var(--text); font-weight: 500; }
.btn-sm {
padding: 5px 12px;
font-size: 13px;
min-height: 32px;
}
@media (pointer: coarse) {
.btn { min-height: 42px; }
.btn-sm { min-height: 38px; }
}
#pubPrompt {
min-height: 180px;
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.55;
background: var(--surface);
}
/* 可见性分段控件 + 操作栏 */
.form-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
margin-top: 2px;
padding-top: 16px;
border-top: 1px solid var(--separator);
}
.vis-seg {
display: inline-flex;
padding: 3px;
background: var(--chip);
border-radius: var(--radius-pill);
gap: 2px;
}
.vis-seg label {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
padding: 8px 14px;
min-height: 36px;
border-radius: var(--radius-pill);
transition: color 0.15s, background 0.15s, box-shadow 0.15s;
-webkit-user-select: none;
user-select: none;
}
.vis-seg label:hover { color: var(--text); }
.vis-seg input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
min-width: 0;
margin: 0;
padding: 0;
border: none;
pointer-events: none;
}
.vis-seg label:has(input:checked) {
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-sm);
}
.vis-seg label:has(input:focus-visible) {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.form-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
/* 紧凑空状态 */
.empty {
text-align: center;
padding: 28px 16px;
color: var(--text-tertiary);
font-size: 14px;
line-height: 1.5;
}
.empty.compact {
padding: 20px 12px;
background: var(--surface-2);
border: 1px dashed var(--separator);
border-radius: var(--radius-sm);
}
.empty .empty-title {
display: block;
font-size: 15px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 4px;
}
.empty .btn { margin-top: 12px; }
/* empty rows aren't interactive — no row-hover flash */
tbody tr:has(.empty):hover td,
.empty:hover { background: transparent !important; }
/* Login gate */
#loginGate {
min-height: calc(100dvh - var(--nav-h) - var(--safe-t));
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px calc(40px + var(--safe-b));
}
.hero {
width: min(440px, 100%);
text-align: center;
padding: 40px 36px 36px;
background: var(--surface);
border-radius: calc(var(--radius-lg) + 6px);
box-shadow: var(--shadow-lg);
border: 1px solid var(--separator);
animation: heroIn 0.45s var(--ease) both;
position: relative;
overflow: clip;
}
.hero::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, #5e5ce6, #0071e3, #bf5af2);
opacity: 0.9;
}
@keyframes heroIn {
from { opacity: 0; transform: translateY(14px) scale(0.985); }
to { opacity: 1; transform: none; }
}
.hero .brand-mark {
width: 56px;
height: 56px;
border-radius: 16px;
margin: 0 auto 16px;
font-size: 28px;
}
.hero h1 {
margin: 0 0 8px;
font-size: 26px;
font-weight: 700;
letter-spacing: -0.03em;
}
.hero > p,
.hero .hero-sub {
margin: 0 0 20px;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.55;
}
/* Invite welcome banner (from ?invite= link) */
.invite-banner {
display: none;
text-align: left;
margin: 0 0 18px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--green) 28%, transparent);
background: linear-gradient(
135deg,
var(--green-soft),
color-mix(in srgb, var(--blue) 8%, transparent)
);
gap: 12px;
align-items: flex-start;
}
.invite-banner.show {
display: flex;
animation: inviteIn 0.35s var(--ease) both;
}
.invite-banner.invalid {
border-color: color-mix(in srgb, var(--red) 28%, transparent);
background: linear-gradient(
135deg,
var(--red-soft),
var(--orange-soft)
);
}
.invite-banner .invite-banner-icon {
width: 36px;
height: 36px;
border-radius: 10px;
flex-shrink: 0;
display: grid;
place-items: center;
background: rgba(52, 199, 89, 0.16);
color: var(--green);
font-size: 18px;
}
.invite-banner.invalid .invite-banner-icon {
background: var(--red-soft);
color: var(--red);
}
.invite-banner .invite-banner-body {
min-width: 0;
flex: 1;
}
.invite-banner .invite-banner-title {
margin: 0 0 2px;
font-size: 14px;
font-weight: 600;
color: var(--text);
letter-spacing: -0.01em;
}
.invite-banner .invite-banner-meta {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.45;
word-break: break-word;
}
.invite-banner .invite-banner-meta strong {
color: var(--text);
font-weight: 600;
}
.invite-banner .invite-banner-meta code {
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
font-size: 11.5px;
padding: 1px 6px;
border-radius: 6px;
background: var(--code-bg);
color: var(--text);
word-break: break-all;
}
html[data-theme="dark"] .invite-banner {
border-color: rgba(48, 209, 88, 0.28);
background: linear-gradient(
135deg,
rgba(48, 209, 88, 0.12),
rgba(10, 132, 255, 0.08)
);
}
html[data-theme="dark"] .invite-banner.invalid {
border-color: rgba(255, 69, 58, 0.3);
background: linear-gradient(
135deg,
rgba(255, 69, 58, 0.12),
rgba(255, 159, 10, 0.06)
);
}
@keyframes inviteIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.auth-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin: 0 0 18px;
padding: 4px;
background: var(--chip);
border-radius: 14px;
border: 1px solid var(--separator);
}
.auth-tabs button {
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 11px;
padding: 10px 12px;
font-size: 14px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background 0.15s var(--ease), color 0.15s, box-shadow 0.15s;
min-height: 42px;
}
.auth-tabs button:hover:not(.active) {
color: var(--text);
}
.auth-tabs button.active {
background: var(--surface);
color: var(--text);
font-weight: 600;
box-shadow: var(--shadow-sm);
}
.auth-form {
text-align: left;
display: grid;
gap: 12px;
margin-bottom: 4px;
}
.auth-form label {
display: grid;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.auth-field {
position: relative;
}
.auth-field .field-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
font-size: 15px;
pointer-events: none;
line-height: 1;
z-index: 1;
}
.auth-form input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--separator);
border-radius: 12px;
padding: 12px 14px;
font-size: 16px;
background: var(--input-bg);
color: var(--text);
font-family: inherit;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
min-height: 46px;
-webkit-appearance: none;
appearance: none;
}
.auth-field input {
padding-left: 40px;
}
.auth-field.has-toggle input {
padding-right: 44px;
}
.auth-form input:focus {
border-color: var(--focus-border);
box-shadow: 0 0 0 4px var(--ring);
background: var(--surface);
}
.auth-form input:read-only {
background: var(--surface-2);
color: var(--text);
cursor: default;
letter-spacing: 0.04em;
font-weight: 600;
}
.auth-form input:user-invalid:not(:placeholder-shown):not(:focus) {
border-color: color-mix(in srgb, var(--red) 55%, transparent);
}
.auth-form input::placeholder {
color: var(--text-tertiary);
font-weight: 400;
}
.auth-pw-toggle {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
display: grid;
place-items: center;
font-size: 15px;
padding: 0;
font-family: inherit;
transition: color 0.15s, background 0.15s;
}
.auth-pw-toggle:hover {
color: var(--text);
background: var(--chip);
}
.auth-pw-toggle:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 1px;
}
.auth-hint {
margin: -4px 0 0;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.auth-form .btn {
width: 100%;
justify-content: center;
min-height: 48px;
font-size: 15px;
margin-top: 4px;
}
.auth-form .btn .bi {
margin-right: 2px;
}
.auth-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 18px 0 14px;
color: var(--text-tertiary);
font-size: 12px;
font-weight: 500;
}
.auth-divider::before,
.auth-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--separator);
}
.auth-error {
color: var(--red);
font-size: 13px;
margin: 0 0 12px;
min-height: 0;
padding: 10px 12px;
border-radius: 12px;
background: var(--red-soft);
text-align: left;
line-height: 1.45;
display: flex;
align-items: flex-start;
gap: 8px;
}
.auth-error[hidden] {
display: none !important;
}
.auth-error .auth-error-icon {
flex-shrink: 0;
font-size: 15px;
line-height: 1.35;
margin-top: 0.5px;
}
.auth-error .auth-error-text {
flex: 1;
min-width: 0;
}
.auth-form .btn.is-loading {
pointer-events: none;
opacity: 0.72;
}
.auth-form .btn.is-loading .btn-label-idle { display: none; }
.auth-form .btn .btn-label-busy { display: none; }
.auth-form .btn.is-loading .btn-label-busy { display: inline-flex; align-items: center; gap: 6px; }
.auth-form .btn .spin {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: authSpin 0.7s linear infinite;
vertical-align: -2px;
}
@keyframes authSpin {
to { transform: rotate(360deg); }
}
#loginBtn.btn-lg {
min-height: 48px;
font-size: 15px;
border-radius: var(--radius-pill);
}
.auth-footer-note {
margin: 16px 0 0;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.45;
}
.invite-quota {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 8px;
}
/* List containers — never qr-status (that is inline-flex for status lines) */
.list-panel {
display: block;
width: 100%;
max-width: 100%;
font-size: 14px;
color: var(--text-secondary);
}
.invite-list {
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
max-width: 100%;
margin-top: 8px;
font-size: 13px;
color: var(--text);
}
.invite-list > .empty {
padding: 8px 0;
color: var(--text-secondary);
}
.invite-row {
display: flex;
flex-wrap: nowrap;
gap: 12px 16px;
align-items: flex-start;
justify-content: space-between;
width: 100%;
min-width: 0;
padding: 14px 0;
border-bottom: 1px solid var(--separator);
box-sizing: border-box;
}
.invite-row:last-child { border-bottom: none; }
.invite-row .invite-meta {
flex: 1 1 auto;
min-width: 0;
}
.invite-row .invite-code-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
margin-bottom: 6px;
line-height: 1.4;
}
.invite-row .invite-code-line strong {
flex-shrink: 0;
font-weight: 600;
color: var(--text-secondary);
font-size: 12px;
}
.invite-row code {
display: inline-block;
max-width: 100%;
word-break: break-all;
font-size: 12.5px;
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
padding: 3px 8px;
border-radius: 6px;
background: var(--surface-2);
border: 1px solid var(--separator);
line-height: 1.35;
}
.invite-row .invite-url {
font-size: 12px;
color: var(--text-secondary);
word-break: break-all;
overflow-wrap: anywhere;
line-height: 1.45;
margin: 0 0 4px;
}
.invite-row .invite-exp {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.invite-row .invite-actions {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 6px;
align-items: center;
justify-content: flex-end;
padding-top: 1px;
}
/* QR panel */
.qr-panel {
--qr-size: 200px;
display: none;
margin-top: 8px;
padding: 20px;
border-radius: var(--radius-sm);
background: var(--surface-2);
border: 1px solid var(--separator);
text-align: center;
}
.qr-panel.show { display: block; animation: qrIn 0.25s var(--ease); }
@keyframes qrIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.qr-status {
margin: 0 0 12px;
font-size: 14px;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
max-width: 100%;
}
.qr-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--blue);
flex-shrink: 0;
}
.qr-status.wait .dot { animation: qrPulse 1.4s ease infinite; }
.qr-status.ok .dot { background: var(--green); animation: none; }
.qr-status.warn .dot { background: var(--orange); animation: none; }
.qr-status.err .dot { background: var(--red); animation: none; }
@keyframes qrPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.8); }
}
.qr-skel {
width: var(--qr-size);
max-width: 82vw;
aspect-ratio: 1;
height: auto;
border-radius: 12px;
margin: 0 auto;
}
.qr-panel img {
width: var(--qr-size);
max-width: 82vw;
aspect-ratio: 1;
height: auto;
border-radius: 12px;
box-shadow: var(--shadow-md);
background: #fff;
padding: 10px;
}
.qr-link {
margin-top: 12px;
font-size: 13px;
word-break: break-all;
color: var(--text-tertiary);
}
.toast {
position: fixed;
left: 50%;
bottom: calc(28px + var(--safe-b));
transform: translateX(-50%) translateY(20px);
background: var(--toast-bg);
color: #fff;
padding: 12px 20px;
border-radius: var(--radius-pill);
font-size: 14px;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s var(--ease);
z-index: 100;
max-width: min(92vw, 360px);
text-align: center;
/* Liquid glass baked into the element's own rule: toast() reassigns
className on every show, so the .glass/.glass-rim classes in the markup
cannot be relied on here. The dark --toast-bg tint is kept on purpose
so the white text stays readable in both themes. */
backdrop-filter: blur(var(--glass-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(180%);
box-shadow:
inset 0 1px 0 0 var(--glass-hi),
inset 0 -1px 0 0 var(--glass-lo),
var(--glass-shadow);
}
/* The rim comes from .glass-rim — toast() re-applies that class on every
show, so no toast-specific copy of the rule is needed here. */
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.toast.err { background: var(--toast-err); }
.confirm-backdrop {
position: fixed;
inset: 0;
z-index: 90;
background: var(--scrim);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
padding-bottom: calc(20px + var(--safe-b));
}
.confirm-backdrop.show { display: flex; }
.confirm-card {
width: min(400px, 100%);
/* background / blur / edge come from .glass + .glass-strong + .glass-rim */
border-radius: var(--radius);
padding: 22px 20px 16px;
animation: confirmIn 0.2s var(--ease);
}
@keyframes confirmIn {
from { opacity: 0; transform: scale(0.96) translateY(8px); }
to { opacity: 1; transform: none; }
}
.confirm-card h3 {
margin: 0 0 8px;
font-size: 18px;
letter-spacing: -0.02em;
}
.confirm-card p {
margin: 0 0 18px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
}
.confirm-card .row-actions { justify-content: flex-end; }
@media (max-width: 860px) {
.confirm-card .row-actions {
flex-direction: column-reverse;
align-items: stretch;
}
.confirm-card .row-actions .btn { width: 100%; min-height: 44px; }
}
.modal-backdrop {
position: fixed;
inset: 0;
background: var(--scrim);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: none;
align-items: center;
justify-content: center;
z-index: 80;
padding: 20px;
padding-left: max(20px, var(--safe-l));
padding-right: max(20px, var(--safe-r));
}
.modal-backdrop.show { display: flex; }
.modal-backdrop.show .modal { animation: modalIn 0.28s var(--ease); }
@keyframes modalIn {
from { opacity: 0; transform: translateY(18px) scale(0.985); }
to { opacity: 1; transform: none; }
}
.modal {
width: min(520px, 100%);
max-height: min(80vh, 720px);
overflow: auto;
-webkit-overflow-scrolling: touch;
/* background / blur / edge come from .glass + .glass-strong + .glass-rim */
border-radius: var(--radius-lg);
padding: 24px;
overscroll-behavior: contain;
}
.modal-handle {
display: none;
width: 36px;
height: 5px;
border-radius: 99px;
background: var(--separator);
margin: 0 auto 14px;
}
.modal h3 { margin: 0 0 8px; font-size: 20px; letter-spacing: -0.02em; }
.modal pre {
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.5;
background: var(--input-bg);
border-radius: 12px;
padding: 14px;
max-height: 280px;
overflow: auto;
color: var(--text-secondary);
margin: 12px 0 16px;
}
/* Try-chat thinking placeholder */
/* Try-chat modal layout (replaces heavy inline styles) */
.modal.try-chat-modal {
width: min(480px, 100%);
display: flex;
flex-direction: column;
max-height: min(88vh, 720px);
}
.try-chat-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.try-chat-head h3 { margin: 0 0 4px; }
.try-chat-head .desc {
color: var(--text-secondary);
font-size: 13px;
margin: 0;
}
.try-chat-log {
flex: 1;
overflow: auto;
margin: 12px 0;
padding: 10px;
border-radius: var(--radius-sm);
background: var(--chip);
border: 1px solid var(--separator);
min-height: 200px;
max-height: 42vh;
display: flex;
flex-direction: column;
gap: 8px;
-webkit-overflow-scrolling: touch;
}
.try-chat-quota {
margin: 0 0 8px;
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.try-chat-compose {
margin: 0;
gap: 8px;
}
.try-chat-compose input { flex: 1; min-width: 0; }
.try-chat-actions {
margin-top: 12px;
flex-wrap: wrap;
gap: 8px;
}
.try-chat-bubble {
max-width: 88%;
padding: 8px 12px;
font-size: 14px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.try-chat-bubble.me {
align-self: flex-end;
border-radius: 14px 14px 4px 14px;
background: var(--blue);
color: #fff;
}
.try-chat-bubble.bot {
align-self: flex-start;
border-radius: 14px 14px 14px 4px;
background: var(--surface);
border: 1px solid var(--separator);
color: var(--text);
}
.try-chat-thinking {
align-self: flex-start;
max-width: 88%;
padding: 8px 12px;
border-radius: 14px 14px 14px 4px;
background: var(--surface);
border: 1px solid var(--separator);
font-size: 14px;
line-height: 1.45;
color: var(--text-secondary);
display: inline-flex;
align-items: baseline;
gap: 0;
user-select: none;
}
/* Sticker card media row */
.sticker-row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.sticker-thumb {
width: 56px;
height: 56px;
object-fit: contain;
border-radius: var(--radius-sm);
background: var(--chip);
border: 1px solid var(--separator);
flex-shrink: 0;
}
.sticker-body { flex: 1; min-width: 0; }
.sticker-body .row-actions.between { justify-content: space-between; }
.sticker-body h4 { margin: 0; }
.sticker-body .desc { margin: 6px 0; }
.sticker-body .row-actions.actions { margin-top: 8px; }
.modal-meta {
margin: 8px 0 0;
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.modal-meta + .modal-meta { margin-top: 4px; }
.modal .tag-row { margin-top: 10px; }
.modal .desc.sub {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
}
.try-chat-thinking .try-chat-dot {
display: inline-block;
opacity: 0.25;
animation: tryChatDot 1.2s ease-in-out infinite;
}
.try-chat-thinking .try-chat-dot:nth-child(2) { animation-delay: 0.2s; }
.try-chat-thinking .try-chat-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes tryChatDot {
0%, 80%, 100% { opacity: 0.25; }
40% { opacity: 1; }
}
/* Mobile list cards (bots / peers) */
.mobile-cards { display: none; }
.mcard {
background: var(--surface-2);
border: 1px solid var(--separator);
border-radius: 16px;
padding: 14px 16px;
margin-bottom: 10px;
}
.mcard:last-child { margin-bottom: 0; }
.mcard .m-title {
margin: 0 0 6px;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.02em;
word-break: break-word;
}
.mcard .m-meta {
margin: 0 0 10px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.45;
word-break: break-all;
}
.mcard .m-meta code { font-size: 11px; }
.mcard .name-edit { width: 100%; }
.mcard .name-edit input {
flex: 1;
min-width: 0;
max-width: none;
width: 100%;
}
.mcard .row-actions {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mcard .row-actions .btn { flex: 1 1 auto; min-width: 0; }
.mcard select {
min-width: 0;
width: 100%;
max-width: 100%;
}
.table-desktop { display: block; }
.fab-refresh {
display: none;
position: fixed;
right: max(16px, var(--safe-r));
bottom: calc(20px + var(--safe-b));
z-index: 40;
width: 48px;
height: 48px;
border-radius: 50%;
/* background / blur / edge come from .glass + .glass-rim */
border: none;
color: var(--text);
font-size: 20px;
cursor: pointer;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.fab-refresh:active { transform: scale(0.96); }
.fab-refresh.spin { animation: fab-spin 0.6s var(--ease); }
@keyframes fab-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.offline-banner {
display: none;
position: sticky;
top: calc(var(--nav-h) + var(--safe-t));
z-index: 45;
background: var(--orange);
color: #1d1d1f;
text-align: center;
font-size: 13px;
font-weight: 500;
padding: 9px 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.offline-banner.show { display: block; }
/* push the sticky tabs down while the banner is visible */
body.offline { --banner-h: 37px; }
@media (max-width: 900px) {
.form-fields {
grid-template-columns: 1fr 1fr;
}
.form-fields .form-field:last-child {
grid-column: 1 / -1;
}
}
/* 吸顶胶囊的落影改由 .tabs.glass-dynamic 按滚动深度连续给出,
原来的 .tabs.scrolled 二段式静态阴影会盖住动态值,已移除。 */
@media (max-width: 860px) {
/* Compact nav — aligned with admin 860px breakpoint for tablet/phone */
:root { --nav-h: 52px; --control-h: 44px; --control-h-sm: 36px; }
.nav {
padding-left: max(12px, var(--safe-l));
padding-right: max(12px, var(--safe-r));
gap: 8px;
flex-wrap: nowrap;
align-items: center;
}
.brand {
flex-shrink: 0;
min-width: 0;
gap: 8px;
}
.brand span {
font-size: 14px;
max-width: 42vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-mark { width: 26px; height: 26px; border-radius: 7px; }
.avatar-chip {
max-width: none;
padding: 2px;
gap: 0;
background: transparent;
}
.avatar-chip #userName {
display: none;
}
.avatar-dot {
width: 32px;
height: 32px;
font-size: 12px;
}
.nav-actions {
gap: 6px;
flex-wrap: nowrap;
flex-shrink: 0;
min-width: 0;
}
.nav-actions .btn {
padding: 0;
min-height: 36px;
min-width: 36px;
width: 36px;
height: 36px;
gap: 0;
border-radius: 50%;
}
.nav-actions .btn .btn-label { display: none; }
.nav-actions .btn .bi {
margin-right: 0;
font-size: 1rem;
}
.theme-toggle { width: 36px; height: 36px; flex-shrink: 0; }
#userBar {
align-items: center;
gap: 4px;
flex-wrap: nowrap;
flex-shrink: 0;
}
#adminLink:not(.show-admin-mobile) { display: none !important; }
#adminLink.show-admin-mobile {
display: inline-flex !important;
}
.pro-open-btn .pro-label-full { display: none; }
.row-actions .pro-open-btn { flex: 1 1 auto; }
.card {
padding: 16px 14px;
border-radius: 16px;
margin-bottom: 14px;
}
.card-head {
flex-direction: column;
align-items: flex-start;
margin-bottom: 14px;
}
.card h2 { font-size: 19px; }
/* extra bottom room for the refresh FAB (base padding comes from --gutter clamps) */
.wrap { padding-bottom: calc(88px + var(--safe-b)); }
.hero {
padding: 28px 18px 24px;
border-radius: 22px;
width: 100%;
}
.hero h1 { font-size: 22px; }
.hero .brand-mark {
width: 48px;
height: 48px;
border-radius: 14px;
font-size: 24px;
margin-bottom: 12px;
}
.hero > p,
.hero .hero-sub {
font-size: 13.5px;
margin-bottom: 16px;
}
.auth-tabs {
margin-bottom: 14px;
}
.auth-tabs button {
min-height: 44px;
font-size: 14px;
padding: 10px 8px;
}
.auth-form {
gap: 11px;
}
.auth-form input,
.auth-field input {
min-height: 48px;
font-size: 16px; /* prevent iOS zoom */
}
.auth-form .btn,
#loginBtn.btn-lg {
min-height: 50px;
}
.invite-banner {
padding: 11px 12px;
margin-bottom: 14px;
}
.invite-banner .invite-banner-icon {
width: 34px;
height: 34px;
font-size: 16px;
}
.invite-row {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.invite-row .invite-actions {
justify-content: flex-start;
width: 100%;
}
.invite-row .invite-actions .btn {
min-height: 40px;
}
#loginGate {
min-height: calc(100dvh - var(--nav-h) - var(--safe-t));
padding: 20px max(14px, var(--safe-l)) calc(28px + var(--safe-b)) max(14px, var(--safe-r));
align-items: flex-start;
padding-top: max(28px, calc(12px + var(--safe-t)));
}
.tabs {
position: sticky;
top: calc(var(--nav-h) + var(--safe-t) + var(--banner-h, 0px));
z-index: 30;
width: 100%;
max-width: 100%;
margin-left: 0;
margin-right: 0;
margin-bottom: 16px;
border-radius: 14px;
/* same glass recipe as the desktop pill — the .glass-rim ring replaces
the old 1px border so the edge is not drawn twice */
}
.tabs button {
flex: 1 1 auto;
text-align: center;
padding: 10px 8px;
font-size: 12px;
}
.tabs button .bi {
display: block;
margin: 0 auto 2px;
font-size: 1rem;
}
.toolbar {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.toolbar input,
.toolbar select,
.toolbar .btn {
width: 100%;
min-width: 0;
max-width: 100%;
}
.toolbar .btn { min-height: 44px; }
input, select, textarea {
font-size: 16px; /* avoid iOS zoom on focus */
min-width: 0;
width: 100%;
max-width: 100%;
}
.name-edit {
flex-direction: column;
align-items: stretch;
}
.name-edit input {
max-width: none;
width: 100%;
}
.row-actions .btn { min-height: 40px; }
.card, .qr-panel, .persona-card {
scroll-margin-top: calc(var(--nav-h) + var(--safe-t) + 68px);
}
.form-fields {
grid-template-columns: 1fr;
gap: 12px;
}
.form-footer {
flex-direction: column;
align-items: stretch;
gap: 14px;
}
.vis-seg {
width: 100%;
display: flex;
}
.vis-seg label {
flex: 1;
text-align: center;
min-height: 40px;
padding: 8px 10px;
}
.form-actions {
flex-direction: column;
align-items: stretch;
}
.form-actions .btn { width: 100%; min-height: 44px; }
.persona-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.persona-card {
padding: 16px;
}
.persona-card .row-actions {
gap: 8px;
}
.persona-card .row-actions .btn {
flex: 1 1 auto;
min-height: 40px;
}
.qr-panel {
--qr-size: min(240px, 72vw);
padding: 16px 12px;
margin-top: 4px;
}
#pubPrompt { font-size: 16px; } /* ID selector beats the generic 16px rule above */
.table-desktop { display: none !important; }
.mobile-cards { display: block; }
.modal-handle { display: block; }
.modal-backdrop {
padding: 0;
align-items: flex-end;
}
.modal {
width: 100%;
max-height: min(92dvh, 900px);
border-radius: var(--radius) var(--radius) 0 0;
padding: 10px 18px calc(18px + var(--safe-b));
border-bottom: none;
}
.modal.try-chat-modal {
max-height: min(92dvh, 900px);
}
.modal pre { max-height: 36vh; }
.modal .row-actions {
flex-direction: column;
align-items: stretch;
position: sticky;
bottom: 0;
padding-top: 8px;
background: linear-gradient(transparent, var(--surface) 30%);
}
.modal .row-actions .btn { width: 100%; min-height: 44px; }
.fab-refresh { display: flex; }
/* keep theme-toggle at 36px to match other nav controls (avoid --nav-h drift) */
.theme-toggle { width: 36px; height: 36px; flex-shrink: 0; }
}
@media (max-width: 380px) {
.avatar-chip #userName { display: none; }
.avatar-chip { padding: 2px; }
.brand span { max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hero { padding: 22px 14px 20px; border-radius: 18px; }
.hero h1 { font-size: 20px; }
.auth-tabs button { font-size: 13px; padding: 9px 6px; }
.invite-banner .invite-banner-title { font-size: 13px; }
.invite-banner .invite-banner-meta { font-size: 11.5px; }
}
@media (max-height: 500px) and (orientation: landscape) {
:root { --nav-h: 44px; }
.page-sub { display: none; }
.wrap { padding-top: 12px; padding-bottom: calc(56px + var(--safe-b)); }
#loginGate {
padding-top: 12px;
padding-bottom: 16px;
align-items: flex-start;
min-height: auto;
}
.hero {
padding: 18px 16px 16px;
border-radius: 16px;
}
.hero .brand-mark { width: 40px; height: 40px; margin-bottom: 8px; font-size: 20px; }
.hero h1 { font-size: 18px; margin-bottom: 4px; }
.hero > p,
.hero .hero-sub { margin-bottom: 10px; font-size: 13px; }
.auth-form { gap: 8px; }
.auth-form input,
.auth-field input { min-height: 42px; }
.auth-form .btn,
#loginBtn.btn-lg { min-height: 42px; }
.invite-banner { margin-bottom: 10px; padding: 8px 10px; }
.tabs { top: calc(var(--nav-h) + var(--safe-t)); }
.nav {
min-height: 44px;
padding-top: calc(4px + var(--safe-t));
padding-bottom: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* The block above zeroes durations but not delays; the stagger's
backwards fill would still hold cards at opacity 0 for up to 340ms. */
.stagger > * { animation: none !important; }
}
/* Higher-contrast separators & secondary text when requested */
@media (prefers-contrast: more) {
:root {
--separator: rgba(0, 0, 0, 0.24);
--text-secondary: #515154;
--text-tertiary: #6e6e73;
}
html[data-theme="dark"] {
--separator: rgba(255, 255, 255, 0.3);
--text-secondary: #c7c7cc;
--text-tertiary: #98989d;
}
.badge, .tag { font-weight: 600; }
}
/* Solid surfaces instead of glass when transparency is reduced */
@media (prefers-reduced-transparency: reduce) {
:root { --bg-elevated: #f5f5f7; --toast-bg: #1d1d1f; }
html[data-theme="dark"] { --bg-elevated: #1c1c1e; --toast-bg: #2c2c2e; }
.nav, .tabs, .toast, .modal-backdrop, .confirm-backdrop, .fab-refresh,
.modal, .confirm-card, .glass, .glass-dynamic {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* 边缘折射一并关掉:html.wa-lens 那条选择器更特异,不显式覆盖会漏网 */
html.wa-lens .glass,
html.wa-lens .glass-strong,
html.wa-lens .glass-dynamic {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* every glass surface falls back to an opaque fill; the tab pill also
gets a hairline back because its fill matches the page background */
.nav, .tabs, .fab-refresh { background: var(--bg-elevated); }
/* 动态阴影按滚动深度淡入,不透明模式下改回固定值:
.nav 拿回一条分隔线,胶囊靠下面的 1px 边框收边。
!important 是为了盖过 html[data-theme="dark"] .tabs.glass-dynamic。 */
.nav.glass-dynamic { box-shadow: 0 1px 0 0 var(--separator) !important; }
.tabs.glass-dynamic { box-shadow: none !important; }
.tabs, .fab-refresh { border: 1px solid var(--separator); }
.modal, .confirm-card { background: var(--surface); border: 1px solid var(--separator); }
/* specular rim reads as a stray outline without the blur behind it */
.glass-rim::before { display: none !important; }
}
@media (min-width: 861px) {
.table-desktop { display: block; }
.mobile-cards { display: none !important; }
.fab-refresh { display: none !important; }
.modal-handle { display: none; }
}
/* Bootstrap Icons */
.theme-toggle .bi { font-size: 1.1rem; line-height: 1; }
.fab-refresh .bi { font-size: 1.25rem; line-height: 1; }
.tabs button .bi {
margin-right: 6px;
font-size: 0.95rem;
vertical-align: -0.08em;
opacity: 0.9;
}
.tabs button.active .bi { opacity: 1; }
.btn .bi { margin-right: 4px; font-size: 0.95em; vertical-align: -0.05em; }
</style>
<noscript>
<style>
/* 无脚本时滞动进度永远为 0,吸顶栏会停在 0.3 底色 + 4px 模糊,
连分隔线都没有,正文会直接透过去。直接拉满,等同于改造前的静态玻璃。 */
:root { --glass-progress: 1; }
</style>
</noscript>
</head>
<body>
<svg class="wa-lens-defs" width="0" height="0" aria-hidden="true" focusable="false"><defs>
<!-- 边缘折射位移图:R 通道控制横向取样、G 通道控制纵向。中心为中性值 128 故不位移,
四边渐变让取样点向内偏移,形成玻璃厚度的透镜感。
color-interpolation-filters="sRGB" 必须保留:默认的 linearRGB 会把中性点 128
换算成 ~55,导致整个背景被均匀推歪。 -->
<filter id="waGlassLens" x="0%" y="0%" width="100%" height="100%" color-interpolation-filters="sRGB">
<feImage href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' preserveAspectRatio='none'><defs><linearGradient id='x' x1='0' x2='1'><stop offset='0' stop-color='rgb(255,0,0)'/><stop offset='0.25' stop-color='rgb(128,0,0)'/><stop offset='0.75' stop-color='rgb(128,0,0)'/><stop offset='1' stop-color='rgb(0,0,0)'/></linearGradient><linearGradient id='y' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='rgb(0,255,0)'/><stop offset='0.3' stop-color='rgb(0,128,0)'/><stop offset='0.7' stop-color='rgb(0,128,0)'/><stop offset='1' stop-color='rgb(0,0,0)'/></linearGradient></defs><rect width='100' height='100' fill='url(%23x)'/><rect width='100' height='100' fill='url(%23y)' style='mix-blend-mode:plus-lighter'/></svg>" preserveAspectRatio="none" result="wa-map"/>
<feDisplacementMap in="SourceGraphic" in2="wa-map" scale="14" xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs></svg>
<div class="load-bar" id="loadBar" aria-hidden="true"></div>
<header class="nav glass-dynamic">
<div class="brand">
<div class="brand-mark" aria-hidden="true"><i class="bi bi-robot"></i></div>
<span>WeChat-AI</span>
</div>
<div class="nav-actions">
<button class="theme-toggle" id="themeToggle" type="button" title="主题:浅色 / 深色 / 跟随系统" aria-label="切换主题">
<i class="moon bi bi-moon-stars" aria-hidden="true"></i>
<i class="sun bi bi-sun" aria-hidden="true"></i>
<i class="auto bi bi-circle-half" aria-hidden="true" style="display:none"></i>
</button>
<div id="userBar" style="display:none; align-items:center; gap:10px; flex-wrap:wrap">
<div class="avatar-chip">
<span class="avatar-dot" id="userInitial">U</span>
<span id="userName"></span>
</div>
<a class="btn btn-ghost" href="/docs" title="使用文档" aria-label="使用文档"><i class="bi bi-book" aria-hidden="true"></i><span class="btn-label">文档</span></a>
<a class="btn btn-ghost" href="/chatflow" title="Chatflow 流程编辑器" aria-label="Chatflow 流程编辑器"><i class="bi bi-diagram-3" aria-hidden="true"></i><span class="btn-label">Chatflow</span></a>
<a class="btn btn-ghost" href="/admin" id="adminLink" style="display:none" title="管理后台" aria-label="管理后台"><i class="bi bi-shield-lock" aria-hidden="true"></i><span class="btn-label">管理后台</span></a>
<button class="btn btn-secondary" id="logout" type="button" title="退出" aria-label="退出"><i class="bi bi-box-arrow-right" aria-hidden="true"></i><span class="btn-label">退出</span></button>
</div>
</div>
</header>
<div class="offline-banner" id="offlineBanner" role="status">网络已断开,部分操作可能失败</div>
<div id="bootSplash" aria-hidden="true">
<div class="brand-mark" aria-hidden="true"><i class="bi bi-robot"></i></div>
</div>
<div id="loginGate" style="display:none">
<div class="hero">
<div class="brand-mark" aria-hidden="true"><i class="bi bi-robot"></i></div>
<h1 id="loginGateTitle">管理你的微信机器人</h1>
<p class="hero-sub" id="loginGateSub">登录后添加机器人、批准好友、分配角色。本地注册需邀请码;LINUX DO 可直接登录。</p>
<div class="invite-banner" id="inviteBanner" role="status" aria-live="polite">
<div class="invite-banner-icon" aria-hidden="true"><i class="bi bi-envelope-check" id="inviteBannerIcon"></i></div>
<div class="invite-banner-body">
<p class="invite-banner-title" id="inviteBannerTitle">邀请注册</p>
<p class="invite-banner-meta" id="inviteBannerMeta"></p>
</div>
</div>
<div class="auth-tabs" id="authTabs" role="tablist" aria-label="登录方式">
<button type="button" data-auth-tab="login" class="active" role="tab" aria-selected="true" id="authTabLogin">密码登录</button>
<button type="button" data-auth-tab="register" role="tab" aria-selected="false" id="authTabRegister">邀请注册</button>
</div>
<p class="auth-error" id="authError" hidden role="alert"></p>
<div id="authLoginPanel" role="tabpanel" aria-labelledby="authTabLogin">
<form class="auth-form" id="passwordLoginForm" autocomplete="on">
<label>用户名
<div class="auth-field">
<i class="bi bi-person field-icon" aria-hidden="true"></i>
<input id="loginUsername" name="username" autocomplete="username" required maxlength="32" placeholder="你的用户名" enterkeyhint="next" />
</div>
</label>
<label>密码
<div class="auth-field has-toggle">
<i class="bi bi-lock field-icon" aria-hidden="true"></i>
<input id="loginPassword" name="password" type="password" autocomplete="current-password" required maxlength="128" placeholder="登录密码" enterkeyhint="go" />
<button type="button" class="auth-pw-toggle" data-pw-toggle="loginPassword" aria-label="显示密码" title="显示/隐藏密码"><i class="bi bi-eye" aria-hidden="true"></i></button>
</div>
</label>
<button class="btn btn-primary" type="submit" id="passwordLoginSubmit">
<span class="btn-label-idle"><i class="bi bi-box-arrow-in-right" aria-hidden="true"></i>登录</span>
<span class="btn-label-busy"><span class="spin" aria-hidden="true"></span>登录中…</span>
</button>
</form>
</div>
<div id="authRegisterPanel" style="display:none" role="tabpanel" aria-labelledby="authTabRegister">
<form class="auth-form" id="registerForm" autocomplete="on">
<label>邀请码
<div class="auth-field">
<i class="bi bi-ticket-perforated field-icon" aria-hidden="true"></i>
<input id="regInvite" name="invite" autocomplete="off" required maxlength="32" placeholder="好友分享的邀请码" spellcheck="false" autocapitalize="characters" enterkeyhint="next" />
</div>
</label>
<label>用户名
<div class="auth-field">
<i class="bi bi-at field-icon" aria-hidden="true"></i>
<input id="regUsername" name="username" autocomplete="username" required maxlength="32" minlength="3" pattern="[A-Za-z][A-Za-z0-9_]{2,31}" placeholder="字母开头,332 位" enterkeyhint="next" />
</div>
</label>
<p class="auth-hint">仅支持字母、数字、下划线,且以字母开头</p>
<label>显示名<span style="font-weight:400;color:var(--text-tertiary)">(可选)</span>
<div class="auth-field">
<i class="bi bi-card-text field-icon" aria-hidden="true"></i>
<input id="regName" name="name" autocomplete="nickname" maxlength="64" placeholder="展示给他人的名字" enterkeyhint="next" />
</div>
</label>
<label>密码
<div class="auth-field has-toggle">
<i class="bi bi-lock field-icon" aria-hidden="true"></i>
<input id="regPassword" name="password" type="password" autocomplete="new-password" required maxlength="128" minlength="8" placeholder="至少 8 位" enterkeyhint="next" />
<button type="button" class="auth-pw-toggle" data-pw-toggle="regPassword" aria-label="显示密码" title="显示/隐藏密码"><i class="bi bi-eye" aria-hidden="true"></i></button>
</div>
</label>
<label>确认密码
<div class="auth-field has-toggle">
<i class="bi bi-shield-lock field-icon" aria-hidden="true"></i>
<input id="regPassword2" name="password2" type="password" autocomplete="new-password" required maxlength="128" minlength="8" placeholder="再输入一次" enterkeyhint="go" />
<button type="button" class="auth-pw-toggle" data-pw-toggle="regPassword2" aria-label="显示密码" title="显示/隐藏密码"><i class="bi bi-eye" aria-hidden="true"></i></button>
</div>
</label>
<button class="btn btn-primary" type="submit" id="registerSubmit">
<span class="btn-label-idle"><i class="bi bi-person-plus" aria-hidden="true"></i>注册并登录</span>
<span class="btn-label-busy"><span class="spin" aria-hidden="true"></span>注册中…</span>
</button>
</form>
</div>
<div class="auth-divider"></div>
<button class="btn btn-secondary btn-lg" id="loginBtn" type="button" style="width:100%;justify-content:center"><i class="bi bi-box-arrow-in-right" aria-hidden="true"></i>使用 LINUX DO 登录</button>
<p class="auth-footer-note">LINUX DO 账号可直接登录,无需邀请码</p>
</div>
</div>
<main class="wrap" id="main" style="display:none">
<h1 class="page-title">用户中心</h1>
<p class="page-sub">机器人、用户对话、人设与表情,一站管理。</p>
<div class="tabs glass-dynamic glass-rim" id="mainTabs">
<button type="button" data-pane="bots" class="active"><i class="bi bi-robot" aria-hidden="true"></i>机器人</button>
<button type="button" data-pane="chat"><i class="bi bi-chat-dots" aria-hidden="true"></i>用户对话</button>
<button type="button" data-pane="invite"><i class="bi bi-person-plus" aria-hidden="true"></i>邀请</button>
<button type="button" data-pane="square"><i class="bi bi-shop" aria-hidden="true"></i>人设广场</button>
<button type="button" data-pane="stickers"><i class="bi bi-emoji-smile" aria-hidden="true"></i>表情包广场</button>
<button type="button" data-pane="mine"><i class="bi bi-person-vcard" aria-hidden="true"></i>我的人设</button>
<button type="button" data-pane="models"><i class="bi bi-hdd-network" aria-hidden="true"></i>我的模型</button>
</div>
<div class="pane active" id="pane-bots">
<section class="card">
<div class="card-head">
<div>
<h2>我的机器人</h2>
<p class="card-desc">扫码绑定微信;token 失效时点「重新扫码」可刷新凭证并保留好友/人设/记忆</p>
</div>
</div>
<div class="toolbar">
<input id="botName" placeholder="机器人名字(可选,登录后可改)" />
<button class="btn btn-primary" id="addBot" type="button">扫码添加</button>
<button class="btn btn-secondary" id="cancelLogin" type="button" disabled>取消扫码</button>
</div>
<div id="loginBox" class="qr-panel">
<p class="qr-status" id="loginStatus">等待扫码…</p>
<div class="skeleton qr-skel" id="qrSkel" style="display:none" aria-hidden="true"></div>
<img id="qrImg" alt="登录二维码" width="200" height="200" style="display:none" />
<p class="qr-link" id="loginLinkWrap" style="display:none">
或打开链接:<a id="loginLink" target="_blank" rel="noopener"></a>
</p>
</div>
<div class="table-wrap table-desktop">
<table>
<thead>
<tr><th>机器人名字</th><th>ID</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody id="botsBody"></tbody>
</table>
</div>
<div class="mobile-cards" id="botsCards" aria-live="polite"></div>
</section>
<section class="card">
<div class="card-head">
<div>
<h2>机器人设置 · 微信用户</h2>
<p class="card-desc">批准私聊用户、分配人设;可对单个用户开启「主动联系」(空闲后智能体找对方聊天)</p>
</div>
</div>
<div class="toolbar">
<select id="filterBot"><option value="">全部机器人</option></select>
<button class="btn btn-secondary" id="reloadPeers" type="button">刷新</button>
</div>
<div class="table-wrap table-desktop">
<table class="peers-table">
<thead>
<tr>
<th>微信用户</th>
<th>机器人</th>
<th>状态</th>
<th>人设</th>
<th>主动联系</th>
<th>操作</th>
</tr>
</thead>
<tbody id="peersBody"></tbody>
</table>
</div>
<div class="mobile-cards" id="peersCards" aria-live="polite"></div>
</section>
</div>
<div class="pane" id="pane-chat">
<section class="card">
<div class="card-head">
<div>
<h2>微信身份绑定(用户对话)</h2>
<p class="card-desc">绑定后可在微信发送 <code>@LINUX_DO用户名</code> 与其他已绑定用户对话。先生成绑定码,再给<strong>任意机器人</strong>发送 <code>/绑定 验证码</code></p>
</div>
</div>
<div id="wechatBindStatus" class="qr-status" style="margin-bottom:10px"><span class="skeleton" style="display:inline-block;width:9em;height:1em;vertical-align:-0.15em"></span></div>
<div class="toolbar">
<button class="btn btn-primary" id="genBindCode" type="button">生成绑定码</button>
<button class="btn btn-secondary" id="clearBind" type="button">解除绑定</button>
<button class="btn btn-ghost" id="refreshBind" type="button">刷新状态</button>
</div>
<div id="bindCodeBox" class="qr-panel" style="display:none">
<p class="qr-status" id="bindCodeText"></p>
<p class="muted" id="bindCodeHint" style="font-size:13px;opacity:.85"></p>
</div>
</section>
<section class="card">
<div class="card-head">
<div>
<h2>黑名单(用户对话)</h2>
<p class="card-desc">拉黑后双方无法再通过 <code>@用户名</code> 建立对话;进行中的会话会结束。也可在微信发送 <code>/拉黑 用户名</code></p>
</div>
</div>
<div class="toolbar">
<input id="blockUsername" placeholder="LINUX DO 用户名" style="max-width:220px" />
<button class="btn btn-danger" id="addBlock" type="button">拉黑</button>
<button class="btn btn-ghost" id="refreshBlocks" type="button">刷新</button>
</div>
<div id="blockList" class="list-panel" style="margin-top:8px"><div class="sk-rows" aria-hidden="true"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div></div>
</section>
</div>
<div class="pane" id="pane-invite">
<section class="card">
<div class="card-head">
<div>
<h2>邀请好友注册</h2>
<p class="card-desc">生成一次性邀请码或链接,好友可用其注册<strong>用户名+密码</strong>账号。LINUX DO 登录无需邀请。生成受配额限制(每 X 小时 N 个)。</p>
</div>
</div>
<p class="invite-quota" id="inviteQuotaText">加载配额…</p>
<div class="toolbar">
<button class="btn btn-primary" id="genInvite" type="button">生成邀请链接</button>
<button class="btn btn-ghost" id="refreshInvites" type="button">刷新</button>
</div>
<div id="inviteList" class="invite-list"><div class="sk-rows" aria-hidden="true"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div></div>
</section>
</div>
<div class="pane" id="pane-square">
<section class="card">
<div class="card-head">
<div>
<h2>人设广场</h2>
<p class="card-desc">浏览公开人设:试聊体验 → 加入库分配,或 Fork 改编成自己的版本</p>
</div>
</div>
<div class="toolbar">
<input id="squareQ" placeholder="搜索名称、简介、标签…" style="min-width:220px;flex:1" />
<select id="squareSort" title="排序" aria-label="人设排序">
<option value="heat">热度</option>
<option value="use">最多收藏</option>
<option value="recent">最近更新</option>
<option value="name">名称</option>
</select>
<button class="btn btn-primary" id="squareSearch" type="button">搜索</button>
</div>
<div class="persona-grid stagger" id="squareGrid"></div>
<div class="pager" id="squarePager" hidden></div>
</section>
</div>
<div class="pane" id="pane-stickers">
<section class="card">
<div class="card-head">
<div>
<h2>表情包广场</h2>
<p class="card-desc">浏览已审核公开表情,添加到你的库后机器人对话可引用发图。公开投稿需管理员审核。</p>
</div>
</div>
<div class="toolbar">
<input id="stickerSquareQ" placeholder="搜索名称、标签…" style="min-width:220px;flex:1" />
<button class="btn btn-primary" id="stickerSquareSearch" type="button">搜索</button>
</div>
<div class="persona-grid stagger" id="stickerSquareGrid"></div>
<div class="pager" id="stickerSquarePager" hidden></div>
</section>
<section class="card">
<div class="card-head">
<div>
<h2>投稿 / 上传表情</h2>
<p class="card-desc">公开需审核;私有仅自己机器人可用。自动过滤 SVG / 脚本等多语言混合危险图。</p>
</div>
</div>
<div class="form-grid">
<div class="form-fields">
<label class="form-field">名称
<input id="stPubName" placeholder="开心猫" autocomplete="off" />
</label>
<label class="form-field">Slug(可选,英文唯一)
<input id="stPubSlug" placeholder="happy-cat" autocomplete="off" />
</label>
<label class="form-field">简介
<input id="stPubDesc" placeholder="开心时用" autocomplete="off" />
</label>
<label class="form-field">标签
<input id="stPubTags" placeholder="开心, 撒娇" autocomplete="off" />
</label>
<label class="form-field">图片
<input type="file" id="stPubFile" accept="image/png,image/jpeg,image/webp,image/gif" />
</label>
</div>
<div class="form-footer">
<div class="vis-seg" role="radiogroup" aria-label="可见性">
<label title="公开到广场,需管理员审核">
<input type="radio" name="stPubVis" value="public" checked />
<span>公开到广场</span>
</label>
<label title="仅自己使用,无需广场审核">
<input type="radio" name="stPubVis" value="private" />
<span>仅自己使用</span>
</label>
</div>
<div class="form-actions">
<button class="btn btn-primary" id="stPubSubmit" type="button">提交表情</button>
</div>
</div>
</div>
</section>
<section class="card">
<div class="card-head">
<div>
<h2>我创建的表情</h2>
<p class="card-desc">审核状态与私有/公开</p>
</div>
</div>
<div class="persona-grid stagger" id="stickerCreatedGrid"></div>
</section>
<section class="card">
<div class="card-head">
<div>
<h2>我的表情库</h2>
<p class="card-desc">用于你的机器人对话回图</p>
</div>
</div>
<div class="persona-grid stagger" id="stickerLibGrid"></div>
</section>
</div>
<div class="pane" id="pane-mine">
<section class="card" id="personaEditorCard">
<div class="card-head">
<div>
<h2 id="editorTitle">人设编辑器</h2>
<p class="card-desc">创作或修改人设;可用变量引用机器人名字,分配给不同机器人时自动替换</p>
</div>
</div>
<div class="form-grid">
<input type="hidden" id="pubEditId" value="" />
<div class="form-fields">
<label class="form-field">人设名称
<input id="pubName" placeholder="例如:腹黑学姐" autocomplete="off" />
</label>
<label class="form-field">简介
<input id="pubDesc" placeholder="一句话介绍" autocomplete="off" />
</label>
<label class="form-field">标签
<input id="pubTags" placeholder="恋爱, 温柔(逗号分隔)" autocomplete="off" />
</label>
<label class="form-field">运行模式
<select id="pubMode" title="prompt=经典提示词;chatflow=可视化流程">
<option value="prompt">经典 Prompt</option>
<option value="chatflow">Chatflow 流程</option>
</select>
</label>
<label class="form-field">模型(对话出站)
<select id="pubLlmProvider" title="平台默认由管理员配置;自定义经 HF 工具服务发出">
<option value="">平台默认(管理员 LLM</option>
</select>
</label>
</div>
<label class="check-row">
<input type="checkbox" id="pubWebSearch" />
启用联网搜索
</label>
<p class="editor-help" id="pubChatflowHint" style="display:none">
Chatflow 模式下,请在发布后打开流程编辑器编排节点。LLM / 搜索节点只经工具网关出站。
<a href="/chatflow" id="pubChatflowLink" target="_blank" rel="noopener">打开 Chatflow 编辑器</a>
</p>
<div class="editor-block">
<span class="field-label">系统提示词(人设正文)</span>
<div class="var-toolbar">
<span class="hint">插入变量</span>
<button type="button" class="var-chip" data-insert="{{bot_name}}" title="运行时替换为当前机器人名字">{{bot_name}}</button>
<button type="button" class="var-chip" data-insert="{{机器人名字}}" title="同 bot_name,中文写法">{{机器人名字}}</button>
</div>
<textarea id="pubPrompt" placeholder="详细描述性格、说话方式、边界…&#10;&#10;示例:你是「{{bot_name}}」,一只活泼的猫娘…"></textarea>
<p class="editor-help">
对话时系统会自动注入「智能体身份」段,并把
<code>{{bot_name}}</code> /
<code>{{机器人名字}}</code>
替换为该机器人的显示名。人设可复用到多个机器人,名字随绑定机器人变化。
</p>
</div>
<div class="form-footer">
<div class="vis-seg" role="radiogroup" aria-label="可见性">
<label title="发布到人设广场,其他人可浏览添加">
<input type="radio" name="pubVis" value="public" checked />
<span>公开到广场</span>
</label>
<label title="仅自己可见,不出现在广场">
<input type="radio" name="pubVis" value="private" />
<span>仅自己使用</span>
</label>
</div>
<div class="form-actions">
<button class="btn btn-secondary" id="pubCancelEdit" type="button" style="display:none">取消编辑</button>
<button class="btn btn-primary" id="pubSubmit" type="button">发布人设</button>
</div>
</div>
</div>
</section>
<section class="card">
<div class="card-head">
<div>
<h2>我创建的</h2>
<p class="card-desc">可改公开/私有,或删除</p>
</div>
</div>
<div class="persona-grid stagger" id="createdGrid"></div>
</section>
<section class="card">
<div class="card-head">
<div>
<h2>我的库</h2>
<p class="card-desc">已添加的人设,可用于机器人分配</p>
</div>
</div>
<div class="persona-grid stagger" id="libGrid"></div>
</section>
</div>
<div class="pane" id="pane-models">
<section class="card">
<div class="card-head">
<div>
<h2>我的模型连接</h2>
<p class="card-desc">自定义 OpenAI 兼容 API。主站不会直连你的地址,请求经 HuggingFace 工具服务(TOOLS)代发;密钥加密存储,日志不打印明文。</p>
</div>
</div>
<p class="editor-help" id="llmProvGatewayNote">加载网关状态…</p>
<div class="form-grid" style="margin-top:12px">
<div class="form-fields" style="grid-template-columns:minmax(0,1fr) minmax(0,1.2fr)">
<label class="form-field">名称
<input id="llmProvName" placeholder="例如:我的中转" autocomplete="off" />
</label>
<label class="form-field">默认模型
<input id="llmProvModel" placeholder="gpt-4o-mini" autocomplete="off" />
</label>
</div>
<label class="form-field">Base URLOpenAI 兼容,含 /v1
<input id="llmProvBase" placeholder="https://api.example.com/v1" autocomplete="off" />
</label>
<label class="form-field">API Key
<input id="llmProvKey" type="password" placeholder="sk-…" autocomplete="off" />
</label>
<div class="form-footer">
<div class="form-actions">
<button class="btn btn-primary" id="llmProvSubmit" type="button">添加连接</button>
<button class="btn btn-ghost" id="llmProvRefresh" type="button">刷新</button>
</div>
</div>
</div>
<div class="llm-prov-list" id="llmProvList" style="margin-top:16px"></div>
</section>
</div>
</main>
<div class="toast glass glass-rim" id="toast" role="status"></div>
<div class="confirm-backdrop" id="confirmBackdrop" role="alertdialog" aria-modal="true" aria-labelledby="confirmTitle" aria-describedby="confirmMsg">
<div class="confirm-card glass glass-strong glass-rim">
<h3 id="confirmTitle">确认操作</h3>
<p id="confirmMsg"></p>
<div class="row-actions">
<button class="btn btn-secondary" type="button" id="confirmCancel">取消</button>
<button class="btn btn-danger" type="button" id="confirmOk">确认</button>
</div>
</div>
</div>
<button type="button" class="fab-refresh glass glass-rim" id="fabRefresh" title="刷新" aria-label="刷新当前页"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i></button>
<div class="modal-backdrop" id="personaModal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal glass glass-strong glass-rim">
<div class="modal-handle" aria-hidden="true"></div>
<h3 id="modalTitle">人设详情</h3>
<p class="desc sub" id="modalDesc"></p>
<p class="modal-meta" id="modalUseCount"></p>
<p class="modal-meta" id="modalForkFrom"></p>
<div class="tag-row" id="modalTags"></div>
<pre id="modalPrompt"></pre>
<div class="row-actions">
<button class="btn btn-primary" type="button" id="modalTry"><i class="bi bi-chat-dots" aria-hidden="true"></i>试聊</button>
<button class="btn btn-secondary" type="button" id="modalFork"><i class="bi bi-shuffle" aria-hidden="true"></i>改编</button>
<button class="btn btn-primary" type="button" id="modalAdd"><i class="bi bi-plus-lg" aria-hidden="true"></i>添加到库</button>
<button class="btn btn-secondary" type="button" id="modalClose"><i class="bi bi-x-lg" aria-hidden="true"></i>关闭</button>
</div>
</div>
</div>
<!-- 网页试聊弹层 -->
<div class="modal-backdrop" id="tryChatModal" role="dialog" aria-modal="true" aria-labelledby="tryChatTitle">
<div class="modal try-chat-modal glass glass-strong glass-rim">
<div class="modal-handle" aria-hidden="true"></div>
<div class="try-chat-head">
<div>
<h3 id="tryChatTitle">网页试聊</h3>
<p class="desc" id="tryChatSub">不写入微信 · 限量体验</p>
</div>
<button class="btn btn-ghost btn-sm" type="button" id="tryChatClose" aria-label="关闭"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
</div>
<div id="tryChatLog" class="try-chat-log"></div>
<p id="tryChatQuota" class="try-chat-quota"></p>
<div class="toolbar try-chat-compose">
<input id="tryChatInput" placeholder="说点什么…" maxlength="2000" />
<button class="btn btn-primary" type="button" id="tryChatSend">发送</button>
</div>
<div class="row-actions try-chat-actions">
<button class="btn btn-secondary" type="button" id="tryChatFork"><i class="bi bi-shuffle" aria-hidden="true"></i>改编</button>
<button class="btn btn-secondary" type="button" id="tryChatAdd"><i class="bi bi-plus-lg" aria-hidden="true"></i>加入库</button>
</div>
</div>
</div>
<!-- 主动联系设置弹窗 -->
<div class="modal-backdrop" id="proactiveModal" role="dialog" aria-modal="true" aria-labelledby="proModalTitle">
<div class="modal glass glass-strong glass-rim" style="width:min(440px,100%)">
<div class="modal-handle" aria-hidden="true"></div>
<h3 id="proModalTitle" style="margin:0 0 4px">主动联系</h3>
<p class="desc" id="proModalSub" style="color:var(--text-secondary);font-size:13px;margin:0">配置空闲触发参数</p>
<div class="pro-modal-form">
<label class="pro-switch">
<input type="checkbox" id="proEn" />
开启主动联系
</label>
<div class="pro-grid">
<label>空闲小时<span class="hint">≥0.250.5=30分钟</span>
<input type="number" id="proIdle" min="0.25" max="720" step="0.25" inputmode="decimal" />
</label>
<label>最小间隔(时)<span class="hint">0=不限制</span>
<input type="number" id="proMin" min="0" max="720" step="0.25" inputmode="decimal" />
</label>
<label>每日上限<span class="hint">0=不限制</span>
<input type="number" id="proMax" min="0" max="9999" step="1" inputmode="numeric" />
</label>
<label>安静时段<span class="hint">如 0-8,留空关闭</span>
<input type="text" id="proQuiet" placeholder="0-8" autocomplete="off" />
</label>
</div>
<p class="pro-note" id="proModalNote">仅联系已在用户列表开启「允许主动」的好友;对方须曾聊过。</p>
</div>
<div class="row-actions" style="margin-top:16px;justify-content:flex-end">
<button class="btn btn-secondary" type="button" id="proModalCancel">取消</button>
<button class="btn btn-primary" type="button" id="proModalSave"><i class="bi bi-check-lg" aria-hidden="true"></i>保存</button>
</div>
</div>
</div>
<script>
const state = {
user: null,
bots: [],
personas: [],
loginSessionId: null,
timer: null,
editingPersonaId: null,
squarePage: 1,
stickerPage: 1,
};
const PAGE_SIZE = 20;
const PANE_IDS = ["bots", "chat", "invite", "square", "stickers", "mine", "models"];
const $ = (id) => document.getElementById(id);
/** Cached custom LLM providers for persona editor select */
let llmProvidersCache = [];
/** Insert text at cursor in a textarea */
function insertAtCursor(textarea, text) {
if (!textarea) return;
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? start;
const before = textarea.value.slice(0, start);
const after = textarea.value.slice(end);
textarea.value = before + text + after;
const pos = start + text.length;
textarea.focus();
textarea.setSelectionRange(pos, pos);
}
document.querySelectorAll("[data-insert]").forEach((btn) => {
btn.onclick = () => {
insertAtCursor($("pubPrompt"), btn.dataset.insert);
toast("已插入 " + btn.dataset.insert);
};
});
function activePane() {
return (
document.querySelector("#mainTabs button.active")?.dataset.pane || "bots"
);
}
function switchPane(pane, pushHash) {
if (!PANE_IDS.includes(pane)) pane = "bots";
document.querySelectorAll("#mainTabs button").forEach((b) => {
const on = b.dataset.pane === pane;
b.classList.toggle("active", on);
if (on) {
b.setAttribute("aria-current", "page");
try {
b.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
} catch (_) {}
} else {
b.removeAttribute("aria-current");
}
});
document.querySelectorAll(".pane").forEach((p) =>
p.classList.toggle("active", p.id === "pane-" + pane),
);
if (pushHash !== false) {
const next = "#" + pane;
if ((location.hash || "") !== next) {
try {
history.replaceState(null, "", next);
} catch (_) {
location.hash = pane;
}
}
}
const titles = {
bots: "机器人",
chat: "用户对话",
invite: "邀请",
square: "人设广场",
stickers: "表情包广场",
mine: "我的人设",
models: "我的模型",
};
document.title = `WeChat-AI · ${titles[pane] || "用户中心"}`;
if (pane === "chat") refreshChat().catch(() => {});
if (pane === "invite") loadInvites().catch((e) => toast(e.message || "加载失败", true));
if (pane === "square") loadSquare().catch(onSquareError);
if (pane === "stickers") loadStickerSquare().catch((e) => toast(e.message || "加载失败", true));
if (pane === "mine") {
fillPubLlmSelect().catch(() => {});
loadMine().catch(onMineError);
}
if (pane === "models") loadLlmProviders().catch((e) => toast(e.message || "加载失败", true));
try {
window.scrollTo({ top: 0, behavior: "smooth" });
} catch (_) {}
}
document.querySelectorAll("#mainTabs button").forEach((btn) => {
btn.onclick = () => switchPane(btn.dataset.pane);
});
window.addEventListener("hashchange", () => {
const h = (location.hash || "").replace(/^#/, "");
if (PANE_IDS.includes(h)) switchPane(h, false);
});
// Horizontal swipe between tabs on main content
(function setupSwipePanes() {
const main = $("main");
if (!main) return;
let x0 = 0;
let y0 = 0;
let tracking = false;
main.addEventListener(
"touchstart",
(e) => {
if (window.innerWidth > 720) return;
if (!e.touches[0]) return;
if (e.target.closest("input, textarea, select, .modal, .qr-panel, .tabs"))
return;
x0 = e.touches[0].clientX;
y0 = e.touches[0].clientY;
tracking = true;
},
{ passive: true },
);
main.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
if (!t) return;
const dx = t.clientX - x0;
const dy = t.clientY - y0;
if (Math.abs(dx) < 64 || Math.abs(dx) < Math.abs(dy) * 1.2) return;
const i = PANE_IDS.indexOf(activePane());
if (i < 0) return;
if (dx < 0 && i < PANE_IDS.length - 1) switchPane(PANE_IDS[i + 1]);
else if (dx > 0 && i > 0) switchPane(PANE_IDS[i - 1]);
},
{ passive: true },
);
})();
(function setupModalSwipe() {
const bd = $("personaModal");
const modal = bd?.querySelector(".modal");
if (!bd || !modal) return;
let y0 = 0;
let pulling = false;
modal.addEventListener(
"touchstart",
(e) => {
if (window.innerWidth > 720) return;
if (modal.scrollTop > 2) return;
if (!e.touches[0]) return;
if (e.target.closest("input, textarea, pre, button, a")) return;
y0 = e.touches[0].clientY;
pulling = true;
modal.style.transition = "none";
},
{ passive: true },
);
modal.addEventListener(
"touchmove",
(e) => {
if (!pulling || !e.touches[0]) return;
const dy = e.touches[0].clientY - y0;
modal.style.transform = dy > 0 ? `translateY(${Math.min(dy, 280)}px)` : "";
},
{ passive: true },
);
modal.addEventListener(
"touchend",
(e) => {
if (!pulling) return;
pulling = false;
const dy = (e.changedTouches[0]?.clientY || 0) - y0;
modal.style.transition = "transform 0.2s var(--ease)";
if (dy > 110) {
modal.style.transform = "translateY(110%)";
setTimeout(() => {
closeModal();
modal.style.transform = "";
modal.style.transition = "";
}, 180);
} else {
modal.style.transform = "";
}
},
{ passive: true },
);
})();
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("personaModal")?.classList.contains("show")) {
closeModal();
}
});
function setOffline(off) {
$("offlineBanner")?.classList.toggle("show", off);
document.body.classList.toggle("offline", off);
}
window.addEventListener("online", () => setOffline(false));
window.addEventListener("offline", () => setOffline(true));
if (!navigator.onLine) setOffline(true);
window.addEventListener(
"scroll",
() => {
const y = window.scrollY > 4;
document.querySelector(".nav")?.classList.toggle("scrolled", y);
document.querySelector(".tabs")?.classList.toggle("scrolled", y);
},
{ passive: true },
);
$("fabRefresh")?.addEventListener("click", async () => {
const fab = $("fabRefresh");
if (fab) {
fab.classList.remove("spin");
void fab.offsetWidth;
fab.classList.add("spin");
}
try {
const pane = activePane();
if (pane === "square") await loadSquare();
else if (pane === "stickers") await loadStickerSquare();
else if (pane === "mine") await loadMine();
else if (pane === "chat") await refreshChat();
else await refreshBots();
toast("已刷新");
} catch (e) {
toast(e.message || "刷新失败", true);
}
});
const THEME_KEY = "wa_theme";
function currentTheme() {
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
}
function themePref() {
try {
const p = localStorage.getItem(THEME_KEY);
if (p === "system" || p === "light" || p === "dark") return p;
} catch (_) {}
return document.documentElement.getAttribute("data-theme-pref") === "system"
? "system"
: currentTheme();
}
function resolveSystemTheme() {
try {
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} catch (_) {
return "light";
}
}
function applyTheme(theme) {
let pref = theme;
let t;
if (theme === "system") {
pref = "system";
t = resolveSystemTheme();
document.documentElement.setAttribute("data-theme-pref", "system");
} else {
t = theme === "dark" ? "dark" : "light";
pref = t;
document.documentElement.removeAttribute("data-theme-pref");
}
document.documentElement.setAttribute("data-theme", t);
try { localStorage.setItem(THEME_KEY, pref); } catch (_) {}
const meta = $("themeColor");
if (meta) meta.setAttribute("content", t === "dark" ? "#000000" : "#f5f5f7");
const btn = $("themeToggle");
if (btn) {
btn.title =
pref === "system"
? "主题:跟随系统(点击切换)"
: pref === "dark"
? "主题:深色(点击切换)"
: "主题:浅色(点击切换)";
}
}
$("themeToggle").onclick = () => {
const p = themePref();
if (p === "light") applyTheme("dark");
else if (p === "dark") applyTheme("system");
else applyTheme("light");
const labels = { light: "浅色", dark: "深色", system: "跟随系统" };
toast("主题:" + (labels[themePref()] || themePref()));
};
try {
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
let stored = null;
try { stored = localStorage.getItem(THEME_KEY); } catch (_) {}
if (stored === "system") applyTheme("system");
else if (!stored) {
const t = e.matches ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
const meta = $("themeColor");
if (meta) meta.setAttribute("content", t === "dark" ? "#000000" : "#f5f5f7");
}
});
if (localStorage.getItem(THEME_KEY) === "system") applyTheme("system");
} catch (_) {}
// Sync status-bar color + toggle title with the resolved theme (no pref write)
(function () {
const meta = $("themeColor");
if (meta) meta.setAttribute("content", currentTheme() === "dark" ? "#000000" : "#f5f5f7");
const btn = $("themeToggle");
if (btn) {
const p = themePref();
btn.title =
p === "system"
? "主题:跟随系统(点击切换)"
: p === "dark"
? "主题:深色(点击切换)"
: "主题:浅色(点击切换)";
}
})();
function toast(msg, err) {
const el = $("toast");
el.textContent = msg;
// keep the glass material classes — a bare reassignment would strip them
// before the toast is ever painted
el.className = "toast glass glass-rim show" + (err ? " err" : "");
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove("show"), 2800);
}
/** Apple-style confirm sheet; resolves true/false. Falls back to window.confirm. */
function confirmDialog(opts = {}) {
return new Promise((resolve) => {
const bd = $("confirmBackdrop");
if (!bd) {
resolve(window.confirm(opts.message || "确认?"));
return;
}
$("confirmTitle").textContent = opts.title || "确认操作";
$("confirmMsg").textContent = opts.message || "";
const okBtn = $("confirmOk");
okBtn.textContent = opts.okText || "确认";
okBtn.className = "btn " + (opts.danger === false ? "btn-primary" : "btn-danger");
bd.classList.add("show");
document.body.style.overflow = "hidden";
const done = (val) => {
bd.classList.remove("show");
document.body.style.overflow = "";
document.removeEventListener("keydown", onKey, true);
resolve(val);
};
const onKey = (e) => {
if (e.key === "Escape") {
e.stopPropagation();
done(false);
}
};
document.addEventListener("keydown", onKey, true);
okBtn.onclick = () => done(true);
$("confirmCancel").onclick = () => done(false);
bd.onclick = (e) => {
if (e.target === bd) done(false);
};
okBtn.focus({ preventScroll: true });
});
}
// Top progress bar: counts in-flight api() calls (never negative, never stuck)
let apiInflight = 0;
function setLoadBar(on) {
const bar = document.getElementById("loadBar");
if (bar) bar.classList.toggle("on", on);
}
const API_TIMEOUT_MS = 20000;
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (opts.body && typeof opts.body === "object") {
headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(opts.body);
}
// Without this a stalled request leaves the boot splash up forever with
// no error path. Callers can pass opts.timeoutMs (or 0 to opt out).
const timeoutMs = opts.timeoutMs === undefined ? API_TIMEOUT_MS : opts.timeoutMs;
let ctrl = null;
let timer = null;
if (timeoutMs > 0 && typeof AbortController !== "undefined" && !opts.signal) {
ctrl = new AbortController();
timer = setTimeout(() => ctrl.abort(), timeoutMs);
}
apiInflight++;
setLoadBar(true);
try {
const res = await fetch(path, {
...opts,
headers,
credentials: "include",
signal: ctrl ? ctrl.signal : opts.signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message || data.error || res.statusText);
return data;
} catch (err) {
if (ctrl && ctrl.signal.aborted) {
throw new Error("请求超时,请检查网络后重试");
}
throw err;
} finally {
if (timer) clearTimeout(timer);
apiInflight = Math.max(0, apiInflight - 1);
if (apiInflight === 0) setLoadBar(false);
}
}
function skRows(n) {
return `<div class="sk-rows" aria-hidden="true">${'<div class="skeleton"></div>'.repeat(n)}</div>`;
}
function showListSkeleton(bodyId, cardsId, cols, rows) {
const body = $(bodyId);
if (body && !body.children.length) {
body.innerHTML = `<tr><td colspan="${cols}">${skRows(rows)}</td></tr>`;
}
const cards = $(cardsId);
if (cards && !cards.children.length) cards.innerHTML = skRows(rows);
}
function showGridSkeleton(gridId, n) {
const grid = $(gridId);
if (grid && !grid.children.length) {
grid.innerHTML =
`<div class="skeleton sk-tile" aria-hidden="true"></div>`.repeat(n);
}
}
function errorHtml(retryFn) {
return `<div class="empty">加载失败,请检查网络<br/>
<button class="btn btn-secondary btn-sm" type="button" onclick="${retryFn}()">重试</button></div>`;
}
function showListError(bodyId, cardsId, cols, retryFn) {
const body = $(bodyId);
if (body) body.innerHTML = `<tr><td colspan="${cols}">${errorHtml(retryFn)}</td></tr>`;
const cards = $(cardsId);
if (cards) cards.innerHTML = errorHtml(retryFn);
}
function onRefreshError(e) {
toast(e.message || "加载失败", true);
showListError("botsBody", "botsCards", 4, "retryRefresh");
showListError("peersBody", "peersCards", 5, "retryRefresh");
}
function retryRefresh() {
for (const id of ["botsBody", "botsCards", "peersBody", "peersCards"]) {
const el = $(id);
if (el) el.innerHTML = "";
}
showListSkeleton("botsBody", "botsCards", 4, 2);
showListSkeleton("peersBody", "peersCards", 5, 3);
refreshBots().catch(onRefreshError);
}
function onSquareError(e) {
toast(e.message || "加载失败", true);
const grid = $("squareGrid");
if (grid) grid.innerHTML = errorHtml("retrySquare");
}
function retrySquare() {
const grid = $("squareGrid");
if (grid) grid.innerHTML = "";
loadSquare().catch(onSquareError);
}
function onMineError(e) {
toast(e.message || "加载失败", true);
const created = $("createdGrid");
if (created) created.innerHTML = errorHtml("retryMine");
const lib = $("libGrid");
if (lib) lib.innerHTML = "";
}
function retryMine() {
const created = $("createdGrid");
if (created) created.innerHTML = "";
const lib = $("libGrid");
if (lib) lib.innerHTML = "";
loadMine().catch(onMineError);
}
function setUserAvatar(el, url, fallbackName) {
if (!el) return;
const initial = String(fallbackName || "U")
.trim()
.slice(0, 1)
.toUpperCase() || "U";
const safeUrl =
typeof url === "string" &&
(url.startsWith("https://") || url.startsWith("http://"))
? url
: "";
if (!safeUrl) {
el.classList.remove("has-img");
el.innerHTML = "";
el.textContent = initial;
return;
}
el.classList.add("has-img");
el.textContent = "";
const img = document.createElement("img");
img.src = safeUrl;
img.alt = "";
img.referrerPolicy = "no-referrer";
img.decoding = "async";
img.onerror = () => {
el.classList.remove("has-img");
el.innerHTML = "";
el.textContent = initial;
};
el.appendChild(img);
}
function showAuthError(msg) {
const el = $("authError");
if (!el) return;
if (!msg) {
el.hidden = true;
el.innerHTML = "";
return;
}
el.hidden = false;
el.innerHTML =
`<i class="bi bi-exclamation-circle auth-error-icon" aria-hidden="true"></i>` +
`<span class="auth-error-text">${esc(msg)}</span>`;
}
function setAuthBusy(btn, busy) {
if (!btn) return;
btn.classList.toggle("is-loading", !!busy);
btn.disabled = !!busy;
}
function setAuthTab(tab) {
document.querySelectorAll("#authTabs button").forEach((b) => {
const on = b.dataset.authTab === tab;
b.classList.toggle("active", on);
b.setAttribute("aria-selected", on ? "true" : "false");
});
if ($("authLoginPanel")) $("authLoginPanel").style.display = tab === "login" ? "" : "none";
if ($("authRegisterPanel")) $("authRegisterPanel").style.display = tab === "register" ? "" : "none";
showAuthError("");
const title = $("loginGateTitle");
const sub = $("loginGateSub");
const inviteOk = !!(sub && sub.dataset.inviteLocked === "1");
if (tab === "register") {
if (title) title.textContent = inviteOk ? "欢迎加入" : "创建账号";
if (sub) {
sub.textContent = inviteOk
? "使用下方表单完成注册,即可加入 WeChat-AI。"
: "填写邀请码与账号信息,完成后即可管理机器人与人设。";
}
} else {
if (title) title.textContent = "管理你的微信机器人";
if (sub) {
sub.textContent =
"登录后添加机器人、批准好友、分配角色。本地注册需邀请码;LINUX DO 可直接登录。";
}
}
}
function setInviteBanner(state) {
const banner = $("inviteBanner");
if (!banner) return;
const title = $("inviteBannerTitle");
const meta = $("inviteBannerMeta");
const icon = $("inviteBannerIcon");
if (!state) {
banner.classList.remove("show", "invalid");
return;
}
banner.classList.add("show");
banner.classList.toggle("invalid", !!state.invalid);
if (title) title.textContent = state.title || "邀请注册";
if (meta) {
if (state.metaHtml) meta.innerHTML = state.metaHtml;
else meta.textContent = state.meta || "";
}
if (icon) {
icon.className =
"bi " + (state.invalid ? "bi-exclamation-triangle" : "bi-envelope-check");
}
}
async function resolveInviteFromUrl() {
let inv = "";
try {
inv = (new URLSearchParams(location.search).get("invite") || "").trim();
} catch (_) {
inv = "";
}
if (!inv) {
setInviteBanner(null);
return;
}
const code = inv.toUpperCase();
if ($("regInvite")) $("regInvite").value = code;
setAuthTab("register");
setInviteBanner({
title: "正在校验邀请码…",
meta: code,
});
try {
const data = await api(
"/api/v1/auth/invite/" + encodeURIComponent(code),
);
if (data && data.valid) {
const who = data.inviterUsername
? `@${data.inviterUsername}`
: "好友";
const exp = data.expiresAt
? ` · 有效期至 ${String(data.expiresAt).slice(0, 16).replace("T", " ")}`
: "";
setInviteBanner({
title: "你收到了一份注册邀请",
metaHtml: `来自 <strong>${esc(who)}</strong> 的邀请码 <code>${esc(code)}</code>${esc(exp)}`,
});
const sub = $("loginGateSub");
if (sub) {
sub.textContent = `使用下方表单完成注册,即可加入 WeChat-AI。`;
sub.dataset.inviteLocked = "1";
}
const title = $("loginGateTitle");
if (title) title.textContent = "欢迎加入";
const invInput = $("regInvite");
if (invInput) {
invInput.readOnly = true;
invInput.title = "已从邀请链接自动填入";
invInput.style.letterSpacing = "0.06em";
invInput.style.fontWeight = "600";
}
} else {
setInviteBanner({
invalid: true,
title: "邀请码无效或已过期",
metaHtml: `码 <code>${esc(code)}</code> 无法使用。请向好友重新索取邀请链接,或改用 LINUX DO 登录。`,
});
}
} catch (_) {
// Soft-fail: still prefill code, don't block registration
setInviteBanner({
title: "邀请码已填入",
metaHtml: `已自动填入 <code>${esc(code)}</code>,请完善账号信息完成注册。`,
});
}
}
function bindPasswordToggles(root) {
(root || document).querySelectorAll("[data-pw-toggle]").forEach((btn) => {
if (btn.dataset.bound) return;
btn.dataset.bound = "1";
btn.onclick = () => {
const id = btn.getAttribute("data-pw-toggle");
const input = id ? $(id) : null;
if (!input) return;
const show = input.type === "password";
input.type = show ? "text" : "password";
btn.setAttribute("aria-label", show ? "隐藏密码" : "显示密码");
const i = btn.querySelector("i");
if (i) {
i.className = "bi " + (show ? "bi-eye-slash" : "bi-eye");
}
};
});
}
async function showLoginGate() {
$("bootSplash").style.display = "none";
$("loginGate").style.display = "flex";
$("main").style.display = "none";
bindPasswordToggles($("loginGate"));
await resolveInviteFromUrl();
try {
const cfg = await api("/api/v1/auth/config");
if (cfg && cfg.localAuthEnabled === false) {
setAuthTab("login");
if ($("authTabs")) $("authTabs").style.display = "none";
if ($("authLoginPanel")) $("authLoginPanel").style.display = "none";
if ($("authRegisterPanel")) $("authRegisterPanel").style.display = "none";
setInviteBanner(null);
}
} catch (_) {}
}
async function boot() {
let me;
try {
me = await api("/api/v1/auth/me");
} catch (e) {
const msg = e && e.message ? String(e.message) : "";
await showLoginGate();
if (/banned|封禁/i.test(msg)) showAuthError(msg);
return;
}
state.user = me.user;
$("bootSplash").style.display = "none";
$("loginGate").style.display = "none";
$("main").style.display = "block";
$("userBar").style.display = "inline-flex";
$("userName").textContent = me.user.name || me.user.username;
setUserAvatar(
$("userInitial"),
me.user.avatarUrl,
me.user.name || me.user.username || "U",
);
if (me.user.isAdmin) {
const al = $("adminLink");
if (al) {
al.style.display = "inline-flex";
al.classList.add("show-admin-mobile");
al.title = "管理后台";
}
}
showListSkeleton("botsBody", "botsCards", 4, 2);
showListSkeleton("peersBody", "peersCards", 5, 3);
// Switch to the target pane immediately so its own loader starts in
// parallel with refresh() instead of queueing behind it.
const h = (location.hash || "").replace(/^#/, "");
if (PANE_IDS.includes(h)) switchPane(h, false);
else {
try {
history.replaceState(null, "", "#bots");
} catch (_) {}
switchPane("bots", false);
}
try {
await refresh();
} catch (e) {
onRefreshError(e);
}
}
document.querySelectorAll("#authTabs button").forEach((btn) => {
btn.onclick = () => setAuthTab(btn.dataset.authTab);
});
const regInviteInput = $("regInvite");
if (regInviteInput) {
regInviteInput.addEventListener("input", () => {
if (regInviteInput.readOnly) return;
const start = regInviteInput.selectionStart;
const end = regInviteInput.selectionEnd;
const up = regInviteInput.value.toUpperCase().replace(/[^A-Z0-9_-]/g, "");
if (up !== regInviteInput.value) {
regInviteInput.value = up;
try {
regInviteInput.setSelectionRange(start, end);
} catch (_) {}
}
});
}
const passwordLoginForm = $("passwordLoginForm");
if (passwordLoginForm) {
passwordLoginForm.onsubmit = async (ev) => {
ev.preventDefault();
showAuthError("");
const username = ($("loginUsername")?.value || "").trim();
const password = $("loginPassword")?.value || "";
if (!username || !password) {
showAuthError("请输入用户名和密码");
return;
}
const btn = $("passwordLoginSubmit");
setAuthBusy(btn, true);
try {
await api("/api/v1/auth/password-login", {
method: "POST",
body: { username, password },
});
location.href = "/app";
} catch (e) {
showAuthError(e.message || "登录失败");
setAuthBusy(btn, false);
}
};
}
const registerForm = $("registerForm");
if (registerForm) {
registerForm.onsubmit = async (ev) => {
ev.preventDefault();
showAuthError("");
const inviteCode = ($("regInvite")?.value || "").trim();
const username = ($("regUsername")?.value || "").trim();
const name = ($("regName")?.value || "").trim();
const password = $("regPassword")?.value || "";
const password2 = $("regPassword2")?.value || "";
if (!inviteCode) {
showAuthError("请填写邀请码");
return;
}
if (!username) {
showAuthError("请填写用户名");
return;
}
if (!/^[A-Za-z][A-Za-z0-9_]{2,31}$/.test(username)) {
showAuthError("用户名需字母开头,3–32 位,仅字母数字下划线");
return;
}
if (password.length < 8) {
showAuthError("密码至少 8 位");
return;
}
if (password !== password2) {
showAuthError("两次密码不一致");
return;
}
const btn = $("registerSubmit");
setAuthBusy(btn, true);
try {
await api("/api/v1/auth/register", {
method: "POST",
body: {
inviteCode,
username,
password,
name: name || undefined,
},
});
location.href = "/app";
} catch (e) {
showAuthError(e.message || "注册失败");
setAuthBusy(btn, false);
}
};
}
$("loginBtn").onclick = () => {
location.href = "/api/v1/auth/login?redirect=/app";
};
$("logout").onclick = async () => {
await api("/api/v1/auth/logout", { method: "POST", body: {} });
location.reload();
};
async function loadInvites() {
const list = $("inviteList");
const q = $("inviteQuotaText");
if (list) list.textContent = "加载中…";
try {
const data = await api("/api/v1/me/invites");
const maxLabel =
data.quota && data.quota.max != null ? data.quota.max : "不限";
const rem =
data.quota && data.quota.remaining != null
? data.quota.remaining
: "—";
if (q) {
q.textContent = `本周期已生成 ${data.quota?.used ?? 0}/${maxLabel} · 窗口 ${data.quota?.windowHours ?? "—"} 小时 · 剩余 ${rem}${
data.quota?.retryAfterSec
? ` · ${Math.ceil(data.quota.retryAfterSec / 3600)} 小时后可再生成`
: ""
}`;
}
const items = data.items || [];
if (!list) return;
if (!items.length) {
list.innerHTML = `<div class="empty">暂无未使用邀请码,点击上方生成</div>`;
return;
}
list.innerHTML = items
.map(
(it) => `<div class="invite-row">
<div class="invite-meta">
<div class="invite-code-line"><strong>码</strong> <code>${esc(it.code)}</code></div>
<div class="invite-url">${esc(it.inviteUrl)}</div>
<div class="invite-exp">过期 ${esc(it.expiresAt || "")}</div>
</div>
<div class="invite-actions">
<button class="btn btn-secondary btn-sm" type="button" data-copy-invite="${esc(it.inviteUrl)}">复制链接</button>
<button class="btn btn-ghost btn-sm" type="button" data-revoke-invite="${esc(it.code)}">撤销</button>
</div>
</div>`,
)
.join("");
list.querySelectorAll("[data-copy-invite]").forEach((btn) => {
btn.onclick = async () => {
try {
await navigator.clipboard.writeText(btn.dataset.copyInvite);
toast("已复制邀请链接");
} catch {
toast("复制失败", true);
}
};
});
list.querySelectorAll("[data-revoke-invite]").forEach((btn) => {
btn.onclick = async () => {
const ok = await confirmDialog({
title: "撤销邀请码",
message: "撤销后该码将无法再用于注册。",
okText: "撤销",
});
if (!ok) return;
try {
await api("/api/v1/me/invites/" + encodeURIComponent(btn.dataset.revokeInvite), {
method: "DELETE",
});
toast("已撤销");
await loadInvites();
} catch (e) {
toast(e.message || "撤销失败", true);
}
};
});
} catch (e) {
if (list) list.innerHTML = `<div class="empty">${esc(e.message || "加载失败")}</div>`;
}
}
if ($("genInvite")) {
$("genInvite").onclick = async () => {
try {
const data = await api("/api/v1/me/invites", { method: "POST", body: {} });
toast("已生成邀请码");
try {
if (data.inviteUrl) await navigator.clipboard.writeText(data.inviteUrl);
toast("链接已复制");
} catch (_) {}
await loadInvites();
} catch (e) {
toast(e.message || "生成失败", true);
}
};
}
if ($("refreshInvites")) {
$("refreshInvites").onclick = () => loadInvites();
}
function botTokenBadge(b) {
return b.hasToken
? '<span class="badge ok">已绑定</span>'
: '<span class="badge warn">需重新扫码</span>';
}
function botNameEditHtml(b) {
return `<div class="name-edit">
<input type="text" data-name-input="${esc(b.id)}" value="${esc(b.displayName)}" maxlength="32" aria-label="机器人名字" autocomplete="off" />
<button class="btn btn-secondary btn-sm" type="button" data-rename="${esc(b.id)}">保存名字</button>
</div>`;
}
function botProactiveBtnHtml(b) {
const gOn = !!b.globalProactiveEnabled;
const on = !!b.proactiveEnabled;
const pill = on
? '<span class="pro-pill on">已开</span>'
: '<span class="pro-pill off">已关</span>';
const disabled = gOn ? "" : "disabled";
const cls = gOn ? "" : " is-disabled";
const title = gOn
? "打开主动联系设置"
: "服务端未开启 PROACTIVE_ENABLED";
return `<button class="btn btn-secondary btn-sm pro-open-btn${cls}" type="button" data-pro-open="${esc(b.id)}" title="${esc(title)}" ${disabled}>
<i class="bi bi-chat-dots" aria-hidden="true"></i>
<span class="pro-label-full">主动联系</span>${pill}
</button>`;
}
function botActionsHtml(b) {
return `<div class="row-actions">
${botProactiveBtnHtml(b)}
<button class="btn btn-primary btn-sm" type="button" data-rebind="${esc(b.id)}" title="刷新微信 token,保留好友与人设">重新扫码</button>
<button class="btn btn-danger btn-sm" type="button" data-del="${esc(b.id)}">删除</button>
</div>`;
}
let _proEditBotId = null;
function closeProactiveModal() {
const bd = $("proactiveModal");
if (bd) bd.classList.remove("show");
document.body.style.overflow = "";
_proEditBotId = null;
}
function openProactiveModal(botId) {
const b = (state.bots || []).find((x) => x.id === botId);
if (!b) {
toast("机器人不存在", true);
return;
}
const gOn = !!b.globalProactiveEnabled;
_proEditBotId = botId;
$("proModalTitle").textContent = "主动联系";
$("proModalSub").textContent = b.displayName
? `机器人「${b.displayName}」`
: botId;
const en = $("proEn");
en.checked = !!b.proactiveEnabled;
en.disabled = !gOn;
$("proIdle").value = String(b.proactiveIdleHours ?? 0.5);
$("proMin").value = String(b.proactiveMinIntervalHours ?? 0);
$("proMax").value = String(b.proactiveMaxPerDay ?? 0);
$("proQuiet").value = b.proactiveQuietHours ?? "0-8";
["proIdle", "proMin", "proMax", "proQuiet", "proModalSave"].forEach(
(id) => {
const el = $(id);
if (el) el.disabled = !gOn;
},
);
const note = $("proModalNote");
if (note) {
note.className = "pro-note" + (gOn ? "" : " warn");
note.textContent = gOn
? "仅联系已在用户列表开启「允许主动」的好友;对方须曾聊过。间隔/每日填 0 表示不限制。"
: "服务端未开启 PROACTIVE_ENABLED。请在 .env 设置 PROACTIVE_ENABLED=true 后重启。";
}
const bd = $("proactiveModal");
bd.classList.add("show");
document.body.style.overflow = "hidden";
setTimeout(() => {
try {
(gOn ? en : $("proModalCancel")).focus({ preventScroll: true });
} catch (_) {}
}, 30);
}
async function saveProactiveModal() {
const id = _proEditBotId;
if (!id) return;
const idleH = Number($("proIdle").value);
const minH = Number($("proMin").value);
const maxN = Math.floor(Number($("proMax").value));
const quiet = ($("proQuiet").value || "").trim();
if (!Number.isFinite(idleH) || idleH < 0.25 || idleH > 720) {
toast("空闲小时须在 0.257200.5 = 30 分钟)", true);
$("proIdle").focus();
return;
}
if (!Number.isFinite(minH) || minH < 0 || minH > 720) {
toast("最小间隔须在 0720 小时(0 = 不限制)", true);
$("proMin").focus();
return;
}
if (!Number.isFinite(maxN) || maxN < 0 || maxN > 9999) {
toast("每日上限须在 099990 = 不限制)", true);
$("proMax").focus();
return;
}
if (quiet && !/^\d{1,2}-\d{1,2}$/.test(quiet)) {
toast('安静时段格式如 "0-8",或不填', true);
$("proQuiet").focus();
return;
}
try {
await api("/api/v1/me/bots/" + id, {
method: "PATCH",
body: {
proactiveEnabled: !!$("proEn").checked,
proactiveIdleHours: idleH,
proactiveMinIntervalHours: minH,
proactiveMaxPerDay: maxN,
proactiveQuietHours: quiet,
},
});
toast("主动联系设置已保存");
closeProactiveModal();
refreshBots().catch(onRefreshError);
} catch (e) {
toast(e.message, true);
}
}
function bindBotActions() {
document.querySelectorAll("[data-rename]").forEach((btn) => {
btn.onclick = async () => {
const id = btn.dataset.rename;
const input =
btn.closest(".name-edit")?.querySelector("[data-name-input]") ||
document.querySelector(`[data-name-input="${CSS.escape(id)}"]`);
const displayName = (input?.value || "").trim();
if (!displayName) {
toast("名字不能为空", true);
return;
}
try {
await api("/api/v1/me/bots/" + id, {
method: "PATCH",
body: { displayName },
});
toast("机器人名字已更新(将用于对话身份)");
refreshBots().catch(onRefreshError);
} catch (e) {
toast(e.message, true);
}
};
});
document.querySelectorAll("[data-name-input]").forEach((input) => {
input.onkeydown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
input
.closest(".name-edit")
?.querySelector("[data-rename]")
?.click();
}
};
});
document.querySelectorAll("[data-rebind]").forEach((btn) => {
btn.onclick = () => startBotLogin({ rebindBotId: btn.dataset.rebind });
});
document.querySelectorAll("[data-del]").forEach((btn) => {
btn.onclick = async () => {
const ok = await confirmDialog({
title: "删除机器人?",
message:
"好友分配与记忆也会一并清除。若只是 token 失效,请改用「重新扫码」。",
okText: "删除",
});
if (!ok) return;
try {
await api("/api/v1/me/bots/" + btn.dataset.del, {
method: "DELETE",
});
toast("已删除");
refreshBots().catch(onRefreshError);
} catch (e) {
toast(e.message, true);
}
};
});
document.querySelectorAll("[data-pro-open]").forEach((btn) => {
btn.onclick = () => openProactiveModal(btn.dataset.proOpen);
});
}
// Proactive modal chrome (once)
(() => {
const bd = $("proactiveModal");
if (!bd) return;
$("proModalCancel")?.addEventListener("click", closeProactiveModal);
$("proModalSave")?.addEventListener("click", () => {
void saveProactiveModal();
});
bd.addEventListener("click", (e) => {
if (e.target === bd) closeProactiveModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && bd.classList.contains("show")) {
e.preventDefault();
closeProactiveModal();
}
});
})();
async function refreshChat() {
// Independent endpoints — one wave, not two hops
await Promise.all([
loadWechatBind().catch(() => {}),
loadBlocks().catch(() => {}),
]);
}
async function refreshBots() {
// /me/bots and /me/personas do not depend on each other — fetch together.
// loadPeers() still runs after, it reads the rendered #filterBot value.
const personasP = api("/api/v1/me/personas");
const bots = await api("/api/v1/me/bots");
state.bots = bots.bots || [];
const botsEmptyHtml = `<div class="empty">还没有机器人<br/>
<button class="btn btn-primary btn-sm" type="button"
onclick="document.getElementById('botName').focus();document.getElementById('addBot').click()">扫码添加第一个机器人</button></div>`;
$("botsBody").innerHTML =
state.bots
.map((b) => {
const tokenBadge = botTokenBadge(b);
return `<tr>
<td>${botNameEditHtml(b)}</td>
<td><code>${esc(b.id)}</code></td>
<td>${tokenBadge} <span class="badge">${esc(b.status)}</span></td>
<td>${botActionsHtml(b)}</td>
</tr>`;
})
.join("") ||
`<tr><td colspan="4">${botsEmptyHtml}</td></tr>`;
const botsCards = $("botsCards");
if (botsCards) {
botsCards.innerHTML =
state.bots
.map((b) => {
const tokenBadge = botTokenBadge(b);
return `<article class="mcard">
<p class="m-title">${esc(b.displayName || "未命名机器人")}</p>
<p class="m-meta">${tokenBadge} <span class="badge">${esc(b.status)}</span><br/><code>${esc(b.id)}</code></p>
${botNameEditHtml(b)}
<div style="margin-top:10px">${botActionsHtml(b)}</div>
</article>`;
})
.join("") || botsEmptyHtml;
}
bindBotActions();
const fb = $("filterBot");
const prevSel = fb.value;
fb.innerHTML =
`<option value="">全部机器人</option>` +
state.bots
.map((b) => `<option value="${esc(b.id)}">${esc(b.displayName)}</option>`)
.join("");
// keep the user's filter across refreshes (rename/delete/rebind all call refresh)
if (prevSel && state.bots.some((b) => b.id === prevSel)) fb.value = prevSel;
const personas = await personasP;
state.personas = personas.library || [];
await loadPeers();
}
async function refresh() {
// The chat pane's own data loads when that pane is opened (switchPane),
// so boot no longer blocks on it.
await refreshBots();
}
function personaName(p) {
return p.displayName || p.display_name || p.slug || p.id;
}
function peerPersonaOpts(p) {
return (
state.personas
.map(
(x) =>
`<option value="${x.id}" ${p.personaId === x.id ? "selected" : ""}>${esc(personaName(x))}</option>`,
)
.join("") || '<option value="">请先添加人设</option>'
);
}
function peerStatusBadge(p) {
return p.approved
? '<span class="badge ok">已批准</span>'
: '<span class="badge warn">待批准</span>';
}
function peerProactiveHtml(p) {
const key = `${p.bot_account_id}|${p.peer_id}`;
const on = !!(p.proactiveEnabled || p.proactive_enabled);
const can = !!p.approved;
return `<label class="peer-pro ${can ? "" : "is-disabled"}" title="${
can ? "空闲后智能体可主动联系该用户" : "请先批准用户"
}">
<input type="checkbox" data-peer-pro="${esc(key)}" ${on ? "checked" : ""} ${
can ? "" : "disabled"
} />
<span class="peer-pro-txt">${on ? "开" : "关"}</span>
</label>`;
}
function peerPersonaSelectHtml(p) {
const key = `${p.bot_account_id}|${p.peer_id}`;
return `<select class="persona-select" data-asg="${esc(key)}" title="${esc(p.personaName || "选择人设")}">${peerPersonaOpts(p)}</select>`;
}
function peerActionsHtml(p) {
const key = `${p.bot_account_id}|${p.peer_id}`;
return `<div class="row-actions">
${
p.approved
? ""
: `<button class="btn btn-primary btn-sm" type="button" data-ap="${esc(key)}">批准</button>`
}
<button class="btn btn-secondary btn-sm" type="button" data-as="${esc(key)}">分配人设</button>
<button class="btn btn-ghost btn-sm" type="button" data-mem="${esc(key)}">记忆</button>
</div>`;
}
function bindPeerActions() {
document.querySelectorAll("[data-ap]").forEach((btn) => {
btn.onclick = async () => {
const [botAccountId, peerId] = btn.dataset.ap.split("|");
try {
await api("/api/v1/me/peers/approve", {
method: "POST",
body: { botAccountId, peerId },
});
toast("已批准");
loadPeers();
} catch (e) {
toast(e.message, true);
}
};
});
document.querySelectorAll("[data-as]").forEach((btn) => {
btn.onclick = async () => {
const key = btn.dataset.as;
const [botAccountId, peerId] = key.split("|");
const sel =
btn.closest(".row-actions, .mcard, tr")?.querySelector(
`select[data-asg="${CSS.escape(key)}"]`,
) || document.querySelector(`select[data-asg="${CSS.escape(key)}"]`);
if (!sel?.value) {
toast("请先在人设广场添加人设", true);
return;
}
try {
await api("/api/v1/me/assignments", {
method: "PUT",
body: { botAccountId, peerId, personaId: sel.value },
});
toast("人设已分配");
loadPeers();
} catch (e) {
toast(e.message || "分配失败", true);
}
};
});
document.querySelectorAll("[data-mem]").forEach((btn) => {
btn.onclick = async () => {
const [botAccountId, peerId] = btn.dataset.mem.split("|");
try {
const mem = await api(
"/api/v1/me/memories?botAccountId=" +
encodeURIComponent(botAccountId) +
"&peerId=" +
encodeURIComponent(peerId),
);
const groups = mem.groups || [];
const total = mem.total ?? groups.reduce((n, g) => n + (g.memories?.length || 0), 0);
if (!total) {
toast("该用户暂无长期记忆");
return;
}
const bodyHtml = groups
.map((g) => {
const items = (g.memories || [])
.map(
(m) =>
`<li class="mem-item" data-mid="${esc(m.id)}" data-pid="${esc(g.personaId)}">
<span class="mem-text">${esc(m.content)}</span>
<button type="button" class="btn btn-ghost btn-sm mem-del" data-del-mem="${esc(botAccountId)}|${esc(peerId)}|${esc(g.personaId)}|${esc(m.id)}">删除</button>
</li>`,
)
.join("");
return `<div class="mem-group"><h4>${esc(g.personaName || g.personaId)}</h4><ul class="mem-list">${items}</ul></div>`;
})
.join("");
const wrap = document.createElement("div");
wrap.innerHTML = `
<p class="muted" style="margin:0 0 12px">共 ${total} 条 · 删除后下一轮对话不再使用</p>
${bodyHtml}
<div style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap">
<button type="button" class="btn btn-danger btn-sm" id="memResetAll">清空全部记忆</button>
<button type="button" class="btn btn-secondary btn-sm" id="memClose">关闭</button>
</div>`;
// Prefer confirmDialog-style sheet if openModal exists; else simple overlay
let host = document.getElementById("memSheet");
if (!host) {
host = document.createElement("div");
host.id = "memSheet";
host.setAttribute("role", "dialog");
host.style.cssText =
"position:fixed;inset:0;z-index:9999;display:flex;align-items:flex-end;justify-content:center;background:rgba(0,0,0,.35);padding:16px";
document.body.appendChild(host);
}
host.innerHTML = "";
const card = document.createElement("div");
card.style.cssText =
"background:var(--surface,#fff);color:var(--text,#111);border-radius:16px;max-width:520px;width:100%;max-height:80vh;overflow:auto;padding:18px 16px 16px;box-shadow:0 12px 40px rgba(0,0,0,.18)";
card.innerHTML = `<h3 style="margin:0 0 8px;font-size:17px">长期记忆</h3><p class="muted" style="margin:0 0 8px;font-size:12px;word-break:break-all">${esc(peerId)}</p>`;
card.appendChild(wrap);
host.appendChild(card);
host.style.display = "flex";
const close = () => {
host.style.display = "none";
host.innerHTML = "";
};
wrap.querySelector("#memClose")?.addEventListener("click", close);
host.onclick = (ev) => {
if (ev.target === host) close();
};
wrap.querySelector("#memResetAll")?.addEventListener("click", async () => {
const ok = window.confirm
? window.confirm("清除该用户全部人设下的长期记忆?不可恢复。")
: true;
if (!ok) return;
try {
await api("/api/v1/me/memories/reset", {
method: "POST",
body: { botAccountId, peerId },
});
toast("记忆已清空");
close();
} catch (e) {
toast(e.message, true);
}
});
wrap.querySelectorAll("[data-del-mem]").forEach((b) => {
b.onclick = async () => {
const [bid, pid, personaId, memoryId] = b.dataset.delMem.split("|");
try {
await api("/api/v1/me/memories", {
method: "DELETE",
body: { botAccountId: bid, peerId: pid, personaId, memoryId },
});
b.closest("li")?.remove();
toast("已删除");
} catch (e) {
toast(e.message, true);
}
};
});
} catch (e) {
toast(e.message, true);
}
};
});
document.querySelectorAll("[data-peer-pro]").forEach((input) => {
input.onchange = async () => {
const key = input.dataset.peerPro;
const [botAccountId, peerId] = key.split("|");
const enabled = !!input.checked;
const txt = input
.closest("label.peer-pro")
?.querySelector(".peer-pro-txt");
if (txt) txt.textContent = enabled ? "开" : "关";
try {
await api("/api/v1/me/peers/proactive", {
method: "PATCH",
body: { botAccountId, peerId, enabled },
});
toast(enabled ? "已允许主动联系该用户" : "已关闭主动联系");
} catch (e) {
input.checked = !enabled;
if (txt) txt.textContent = enabled ? "关" : "开";
toast(e.message, true);
}
};
});
}
async function loadPeers() {
const botId = $("filterBot").value;
const q = botId ? "?botId=" + encodeURIComponent(botId) : "";
const data = await api("/api/v1/me/peers" + q);
const peers = data.peers || [];
const botLabel = (id) => {
const b = (state.bots || []).find((x) => x.id === id);
return b?.displayName || id;
};
const peersEmptyHtml = `<div class="empty">暂无私聊用户<br/>
<span style="font-size:13px">微信好友给机器人发消息后,会出现在这里等待批准</span></div>`;
$("peersBody").innerHTML =
peers
.map((p) => {
const botName = botLabel(p.bot_account_id);
return `<tr>
<td>
<div class="id-cell" title="${esc(p.peer_id)}">
<code>${esc(p.peer_id)}</code>
</div>
</td>
<td>
<div class="id-cell" title="${esc(p.bot_account_id)}">
<span class="sub">${esc(botName)}</span>
<code>${esc(p.bot_account_id)}</code>
</div>
</td>
<td>${peerStatusBadge(p)}</td>
<td class="col-persona">${peerPersonaSelectHtml(p)}</td>
<td>${peerProactiveHtml(p)}</td>
<td class="col-actions">${peerActionsHtml(p)}</td>
</tr>`;
})
.join("") ||
`<tr><td colspan="6">${peersEmptyHtml}</td></tr>`;
const peersCards = $("peersCards");
if (peersCards) {
peersCards.innerHTML =
peers
.map((p) => {
return `<article class="mcard">
<p class="m-title">${esc(botLabel(p.bot_account_id))}</p>
<p class="m-meta">
${peerStatusBadge(p)}
· 当前人设 ${esc(p.personaName || "默认")}<br/>
<code title="${esc(p.peer_id)}">${esc(p.peer_id)}</code>
</p>
<div style="margin:10px 0 8px">${peerPersonaSelectHtml(p)}</div>
<div style="margin:0 0 10px">${peerProactiveHtml(p)}</div>
${peerActionsHtml(p)}
</article>`;
})
.join("") || peersEmptyHtml;
}
bindPeerActions();
}
function personaUseCount(p) {
const n = Number(p?.useCount ?? p?.use_count ?? 0);
return Number.isFinite(n) && n > 0 ? n : 0;
}
function formatUseCount(n) {
return Number(n || 0).toLocaleString("zh-CN");
}
function personaHeatMeta(p) {
const heat = Number(p?.heatScore ?? p?.heat_score);
const use = personaUseCount(p);
const assign = Number(p?.assignCount ?? p?.assign_count ?? 0) || 0;
const fork = Number(p?.forkCount ?? p?.fork_count ?? 0) || 0;
const heatVal = Number.isFinite(heat)
? heat
: use * 2 + assign * 5 + fork * 3;
return {
heat: heatVal,
use,
assign,
fork,
text:
"热度 " +
formatUseCount(heatVal) +
" · 收藏 " +
formatUseCount(use) +
" · 分配 " +
formatUseCount(assign) +
" · 改编 " +
formatUseCount(fork),
};
}
function personaForkFromLabel(p) {
const f = p?.forkedFrom || p?.forked_from;
if (!f) return "";
const name = f.displayName || f.display_name || f.slug || "";
return name ? "改编自 " + name : "";
}
function forkBtnHtml(id) {
return `<button class="btn btn-fork" type="button" data-fork="${id}" title="复制为人设草稿,可自由编辑">
<i class="bi bi-shuffle" aria-hidden="true"></i>改编
</button>`;
}
function cardHtml(p, mode) {
const tags = (p.tags || [])
.filter((t) => String(t).toLowerCase() !== "fork")
.map((t) => `<span class="tag">${esc(t)}</span>`)
.join("");
const vis =
p.visibility === "private"
? '<span class="badge warn">私有</span>'
: '<span class="badge ok">公开</span>';
const modeBadge =
p.mode === "chatflow"
? '<span class="badge mode-cf">Chatflow</span>'
: "";
const searchBadge = p.webSearchEnabled
? '<span class="badge soft" title="联网搜索">搜索</span>'
: "";
let primary = "";
let links = "";
if (mode === "square") {
primary = `<button class="btn btn-primary" type="button" data-try="${p.id}"><i class="bi bi-chat-dots" aria-hidden="true"></i>试聊</button>`;
primary += p.inLibrary
? ` <button class="btn btn-secondary" type="button" disabled>已在库中</button>`
: ` <button class="btn btn-secondary" type="button" data-add="${p.id}">添加到库</button>`;
primary += ` ${forkBtnHtml(p.id)}`;
links = `<button class="btn btn-ghost" type="button" data-detail="${p.id}">详情</button>`;
} else if (mode === "created") {
const other = p.visibility === "private" ? "public" : "private";
primary = `
<button class="btn btn-primary" type="button" data-edit="${p.id}">编辑</button>
<button class="btn btn-secondary" type="button" data-vis="${p.id}|${other}">改为${other === "public" ? "公开" : "私有"}</button>
<button class="btn btn-danger" type="button" data-rm-created="${p.id}">删除</button>`;
links = `
<button class="btn btn-ghost" type="button" data-try="${p.id}">试聊</button>
${
p.mode === "chatflow"
? `<a class="btn btn-ghost" href="/chatflow?persona=${encodeURIComponent(p.id)}" target="_blank" rel="noopener">流程</a>`
: ""
}
<button class="btn btn-ghost" type="button" data-detail="${p.id}">详情</button>`;
} else if (mode === "lib") {
const system = p.ownerUserId === "system" || p.owner_user_id === "system";
primary = system
? `<span class="badge ok">官方</span>
<button class="btn btn-primary" type="button" data-try="${p.id}"><i class="bi bi-chat-dots" aria-hidden="true"></i>试聊</button>
${forkBtnHtml(p.id)}`
: `<button class="btn btn-primary" type="button" data-try="${p.id}"><i class="bi bi-chat-dots" aria-hidden="true"></i>试聊</button>
${forkBtnHtml(p.id)}
<button class="btn btn-secondary" type="button" data-rm-lib="${p.id}">从库移除</button>`;
links = `<button class="btn btn-ghost" type="button" data-detail="${p.id}">详情</button>`;
}
const meta = personaHeatMeta(p);
const forkLabel = personaForkFromLabel(p);
const useMeta =
mode === "square" || mode === "created"
? `<p class="meta-line" title="${esc(meta.text)}">${esc(meta.text)}</p>`
: "";
const forkMeta = forkLabel
? `<span class="badge-fork" title="${esc(forkLabel)}"><i class="bi bi-shuffle" aria-hidden="true"></i>${esc(forkLabel)}</span>`
: "";
return `<article class="persona-card">
<div class="row-actions" style="justify-content:space-between;align-items:flex-start;gap:8px">
<h4>${esc(personaName(p))}</h4>
<span style="display:flex;flex-wrap:wrap;gap:4px;justify-content:flex-end">${vis}${modeBadge}${searchBadge}</span>
</div>
<p class="desc">${esc(p.description || "暂无简介")}</p>
${useMeta}
${forkMeta}
<div class="tag-row">${tags}</div>
<div class="card-actions">${primary}</div>
${links ? `<div class="card-links">${links}</div>` : ""}
</article>`;
}
const tryChatState = {
sessionId: null,
personaId: null,
personaName: "",
inLibrary: false,
remainingToday: null,
remainingSession: null,
busy: false,
};
async function forkPersonaById(id, opts) {
const data = await api("/api/v1/square/personas/" + id + "/fork", {
method: "POST",
body: {},
});
toast("已创建私有改编,可在「我的人设」编辑");
if (opts?.closeTry) closeTryChat();
closeModal();
switchPane("mine");
if (data?.persona?.id) {
// best-effort open editor if loadMine supports edit
setTimeout(() => {
const btn = document.querySelector(
'[data-edit="' + data.persona.id + '"]',
);
if (btn) btn.click();
}, 400);
}
loadSquare().catch(onSquareError);
return data;
}
function appendTryBubble(role, text) {
const log = $("tryChatLog");
if (!log) return;
const div = document.createElement("div");
div.className =
"try-chat-bubble " + (role === "user" ? "me" : "bot");
div.textContent = text;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
}
function showTryThinking() {
const log = $("tryChatLog");
if (!log) return;
let el = $("tryChatThinking");
if (!el) {
el = document.createElement("div");
el.id = "tryChatThinking";
el.className = "try-chat-thinking";
el.setAttribute("role", "status");
el.setAttribute("aria-live", "polite");
el.setAttribute("aria-label", "思考中");
el.innerHTML =
'思考中<span class="try-chat-dot" aria-hidden="true">.</span><span class="try-chat-dot" aria-hidden="true">.</span><span class="try-chat-dot" aria-hidden="true">.</span>';
log.appendChild(el);
}
log.scrollTop = log.scrollHeight;
}
function removeTryThinking() {
$("tryChatThinking")?.remove();
}
function updateTryQuota() {
const el = $("tryChatQuota");
if (!el) return;
const parts = [];
if (tryChatState.remainingToday != null) {
parts.push("今日剩余 " + tryChatState.remainingToday + " 条");
}
if (tryChatState.remainingSession != null) {
parts.push("本会话剩余 " + tryChatState.remainingSession + " 条");
}
el.textContent = parts.join(" · ") || "网页试聊 · 不写入微信";
}
async function openTryChat(id) {
try {
const data = await api("/api/v1/try-chat/sessions", {
method: "POST",
body: { personaId: id },
});
tryChatState.sessionId = data.sessionId;
tryChatState.personaId = data.persona?.id || id;
tryChatState.personaName =
data.persona?.displayName || data.persona?.display_name || "人设";
tryChatState.remainingToday = data.remainingToday;
tryChatState.remainingSession = null;
// inLibrary now comes back on the session response — no extra
// round trip before the try-chat panel can render.
tryChatState.inLibrary = !!(
data.inLibrary ?? data.persona?.inLibrary
);
if ($("tryChatTitle")) {
$("tryChatTitle").textContent = "试聊 · " + tryChatState.personaName;
}
if ($("tryChatSub")) {
$("tryChatSub").textContent = "网页试聊 · 不写入微信";
}
if ($("tryChatLog")) $("tryChatLog").innerHTML = "";
if ($("tryChatInput")) $("tryChatInput").value = "";
updateTryQuota();
const addBtn = $("tryChatAdd");
if (addBtn) {
addBtn.disabled = tryChatState.inLibrary;
addBtn.textContent = tryChatState.inLibrary ? "已在库中" : "加入库";
}
closeModal();
$("tryChatModal")?.classList.add("show");
document.body.style.overflow = "hidden";
$("tryChatInput")?.focus({ preventScroll: true });
} catch (e) {
toast(e.message || e.error || "无法开始试聊", true);
}
}
async function closeTryChat() {
const sid = tryChatState.sessionId;
tryChatState.sessionId = null;
$("tryChatModal")?.classList.remove("show");
if (!$("personaModal")?.classList.contains("show")) {
document.body.style.overflow = "";
}
if (sid) {
try {
await api("/api/v1/try-chat/sessions/" + sid, { method: "DELETE" });
} catch (_) {}
}
}
async function sendTryChat() {
if (tryChatState.busy || !tryChatState.sessionId) return;
const input = $("tryChatInput");
const text = (input?.value || "").trim();
if (!text) return;
tryChatState.busy = true;
if ($("tryChatSend")) $("tryChatSend").disabled = true;
appendTryBubble("user", text);
if (input) input.value = "";
showTryThinking();
try {
const data = await api(
"/api/v1/try-chat/sessions/" + tryChatState.sessionId + "/messages",
{ method: "POST", body: { text } },
);
removeTryThinking();
const msgs = data.messages || [];
if (msgs.length) {
for (const m of msgs) {
if (m.type === "sticker") {
appendTryBubble("assistant", "[表情:" + (m.slug || "") + "]");
} else {
appendTryBubble("assistant", m.text || "");
}
}
} else if (data.displayText) {
appendTryBubble("assistant", data.displayText);
}
if (data.remainingToday != null) {
tryChatState.remainingToday = data.remainingToday;
}
if (data.remainingSession != null) {
tryChatState.remainingSession = data.remainingSession;
}
updateTryQuota();
} catch (e) {
removeTryThinking();
appendTryBubble("assistant", "(发送失败)" + (e.message || ""));
toast(e.message || "试聊失败", true);
} finally {
removeTryThinking();
tryChatState.busy = false;
if ($("tryChatSend")) $("tryChatSend").disabled = false;
$("tryChatInput")?.focus({ preventScroll: true });
}
}
if ($("tryChatClose")) $("tryChatClose").onclick = () => closeTryChat();
if ($("tryChatSend")) $("tryChatSend").onclick = () => sendTryChat();
if ($("tryChatInput")) {
$("tryChatInput").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendTryChat();
}
});
}
if ($("tryChatModal")) {
$("tryChatModal").onclick = (e) => {
if (e.target === $("tryChatModal")) closeTryChat();
};
}
if ($("tryChatFork")) {
$("tryChatFork").onclick = async () => {
if (!tryChatState.personaId) return;
try {
await forkPersonaById(tryChatState.personaId, { closeTry: true });
} catch (e) {
toast(e.message, true);
}
};
}
if ($("tryChatAdd")) {
$("tryChatAdd").onclick = async () => {
if (!tryChatState.personaId || tryChatState.inLibrary) return;
try {
await api(
"/api/v1/me/personas/" + tryChatState.personaId + "/add",
{ method: "POST", body: {} },
);
tryChatState.inLibrary = true;
$("tryChatAdd").disabled = true;
$("tryChatAdd").textContent = "已在库中";
toast("已添加到我的库");
loadSquare().catch(onSquareError);
} catch (e) {
toast(e.message, true);
}
};
}
async function openDetail(id) {
try {
const data = await api("/api/v1/square/personas/" + id);
const p = data.persona;
$("modalTitle").textContent = personaName(p);
$("modalDesc").textContent = p.description || "暂无简介";
if ($("modalUseCount")) {
$("modalUseCount").textContent = personaHeatMeta(p).text;
}
if ($("modalForkFrom")) {
const label = personaForkFromLabel(p);
if (label) {
$("modalForkFrom").innerHTML =
'<span class="badge-fork"><i class="bi bi-shuffle" aria-hidden="true"></i>' +
esc(label) +
"</span>";
} else {
$("modalForkFrom").textContent = "";
}
}
$("modalTags").innerHTML = (p.tags || [])
.map((t) => `<span class="tag">${esc(t)}</span>`)
.join("");
$("modalPrompt").textContent =
p.systemPrompt || p.system_prompt || "(无提示词)";
const addBtn = $("modalAdd");
if (p.inLibrary) {
addBtn.disabled = true;
addBtn.textContent = "已在库中";
addBtn.onclick = null;
} else {
addBtn.disabled = false;
addBtn.innerHTML =
'<i class="bi bi-plus-lg" aria-hidden="true"></i>添加到库';
addBtn.onclick = async () => {
try {
await api("/api/v1/me/personas/" + id + "/add", {
method: "POST",
body: {},
});
toast("已添加到我的库");
closeModal();
loadSquare().catch(onSquareError);
refreshBots().catch(onRefreshError);
} catch (e) {
toast(e.message, true);
}
};
}
if ($("modalTry")) {
$("modalTry").onclick = () => openTryChat(id);
}
if ($("modalFork")) {
$("modalFork").onclick = async () => {
try {
await forkPersonaById(id);
} catch (e) {
toast(e.message, true);
}
};
}
$("personaModal").classList.add("show");
document.body.style.overflow = "hidden";
$("modalClose")?.focus({ preventScroll: true });
} catch (e) {
toast(e.message, true);
}
}
function closeModal() {
$("personaModal").classList.remove("show");
if (!$("tryChatModal")?.classList.contains("show")) {
document.body.style.overflow = "";
}
}
$("modalClose").onclick = closeModal;
$("personaModal").onclick = (e) => {
if (e.target === $("personaModal")) closeModal();
};
function bindDetailButtons(root) {
(root || document).querySelectorAll("[data-detail]").forEach((btn) => {
btn.onclick = () => openDetail(btn.dataset.detail);
});
}
function bindTryForkButtons(root) {
(root || document).querySelectorAll("[data-try]").forEach((btn) => {
btn.onclick = () => openTryChat(btn.dataset.try);
});
(root || document).querySelectorAll("[data-fork]").forEach((btn) => {
btn.onclick = async () => {
try {
await forkPersonaById(btn.dataset.fork);
} catch (e) {
toast(e.message, true);
}
};
});
}
function getSquareSort() {
const el = $("squareSort");
const v = el?.value || "heat";
return v === "recent" || v === "name" || v === "use" || v === "heat"
? v
: "heat";
}
/** Server-side pager for square lists. info: { page, limit, total } */
function renderSquarePager(containerId, info, onPageChange) {
const el = $(containerId);
if (!el) return;
const total = Number(info?.total) || 0;
const limit = Math.max(Number(info?.limit) || PAGE_SIZE, 1);
const pages = Math.max(1, Math.ceil(total / limit));
let page = Math.max(Number(info?.page) || 1, 1);
if (page > pages) page = pages;
if (!total) {
el.innerHTML = "";
el.hidden = true;
return;
}
el.hidden = false;
const single = pages <= 1;
el.innerHTML = `<span>第 ${page} / ${pages} 页 · 共 ${total} 条</span>
<div class="row-actions">
<button type="button" class="btn btn-secondary btn-sm" data-pg="first" ${single || page <= 1 ? "disabled" : ""}>首页</button>
<button type="button" class="btn btn-secondary btn-sm" data-pg="prev" ${single || page <= 1 ? "disabled" : ""}>上一页</button>
<button type="button" class="btn btn-secondary btn-sm" data-pg="next" ${single || page >= pages ? "disabled" : ""}>下一页</button>
<button type="button" class="btn btn-secondary btn-sm" data-pg="last" ${single || page >= pages ? "disabled" : ""}>末页</button>
</div>`;
el.querySelectorAll("[data-pg]").forEach((btn) => {
btn.onclick = () => {
if (btn.disabled) return;
const a = btn.dataset.pg;
let next = page;
if (a === "first") next = 1;
if (a === "prev") next = Math.max(1, page - 1);
if (a === "next") next = Math.min(pages, page + 1);
if (a === "last") next = pages;
if (next === page) return;
onPageChange(next);
};
});
}
async function loadSquare() {
showGridSkeleton("squareGrid", 6);
const seq = (loadSquare._seq = (loadSquare._seq || 0) + 1);
const q = $("squareQ").value.trim();
const sort = getSquareSort();
let page = Math.max(state.squarePage || 1, 1);
const data = await api(
"/api/v1/square/personas?limit=" +
PAGE_SIZE +
"&page=" +
page +
"&sort=" +
encodeURIComponent(sort) +
(q ? "&q=" + encodeURIComponent(q) : ""),
);
if (seq !== loadSquare._seq) return;
const total = Number(data.total) || 0;
const limit = Number(data.limit) || PAGE_SIZE;
const pages = Math.max(1, Math.ceil(total / limit) || 1);
// If current page is empty after deletes/filter shrink, pull last valid page
if (total > 0 && page > pages) {
state.squarePage = pages;
return loadSquare();
}
if (data.page) state.squarePage = Math.max(Number(data.page) || page, 1);
const list = data.personas || [];
$("squareGrid").innerHTML =
list.map((p) => cardHtml(p, "square")).join("") ||
(q
? `<div class="empty compact">
<span class="empty-title">没有找到「${esc(q)}」相关人设</span>
试试其他关键词,或清除搜索
<br/>
<button class="btn btn-secondary btn-sm" type="button"
onclick="document.getElementById('squareQ').value='';document.getElementById('squareSearch').click()">清除搜索</button>
</div>`
: `<div class="empty compact">
<span class="empty-title">广场还没有公开人设</span>
成为第一个发布者,或到「我的人设」创作
<br/>
<button class="btn btn-primary btn-sm" type="button" onclick="switchPane('mine')">去创作第一个 <i class="bi bi-arrow-right" aria-hidden="true"></i></button>
</div>`);
renderSquarePager(
"squarePager",
{ page: state.squarePage, limit, total },
(next) => {
state.squarePage = next;
loadSquare().catch(onSquareError);
},
);
document.querySelectorAll("[data-add]").forEach((btn) => {
btn.onclick = async () => {
try {
await api("/api/v1/me/personas/" + btn.dataset.add + "/add", {
method: "POST",
body: {},
});
toast("已添加到我的库");
loadSquare().catch(onSquareError);
refreshBots().catch(onRefreshError);
} catch (e) {
toast(e.message, true);
}
};
});
bindDetailButtons($("squareGrid"));
bindTryForkButtons($("squareGrid"));
}
function stickerReviewLabel(s) {
const st = s.reviewStatus || s.review_status || "approved";
if (st === "pending") return '<span class="badge warn">待审核</span>';
if (st === "rejected") return '<span class="badge err">已拒绝</span>';
return '<span class="badge ok">已通过</span>';
}
function stickerCardHtml(s, mode) {
const name = s.displayName || s.display_name || s.slug;
const tags = (s.tags || []).map((t) => `<span class="tag">${esc(t)}</span>`).join("");
const vis =
(s.visibility || "public") === "private"
? '<span class="badge warn">私有</span>'
: '<span class="badge ok">公开</span>';
const img = s.imageUrl || `/api/v1/square/stickers/${s.id}/image`;
let actions = "";
if (mode === "square") {
actions = s.inLibrary
? `<button class="btn btn-secondary" type="button" disabled>已在库中</button>`
: `<button class="btn btn-primary" type="button" data-st-add="${s.id}">添加到库</button>`;
} else if (mode === "created") {
actions = `
${stickerReviewLabel(s)}
<button class="btn btn-danger" type="button" data-st-rm-created="${s.id}">删除</button>`;
} else if (mode === "lib") {
const system = s.ownerUserId === "system" || s.owner_user_id === "system";
actions = system
? `<span class="badge ok">官方</span>`
: `<button class="btn btn-secondary" type="button" data-st-rm-lib="${s.id}">从库移除</button>`;
}
return `<article class="persona-card">
<div class="sticker-row">
<img class="sticker-thumb" src="${esc(img)}" alt="" loading="lazy" />
<div class="sticker-body">
<div class="row-actions between">
<h4>${esc(name)}</h4>
${vis}
</div>
<p class="desc"><code>${esc(s.slug)}</code> · ${esc(s.description || "")}</p>
<div class="tag-row">${tags}</div>
<div class="row-actions actions">${actions}</div>
</div>
</div>
</article>`;
}
async function loadStickerSquare() {
showGridSkeleton("stickerSquareGrid", 4);
if ($("stickerCreatedGrid")) showGridSkeleton("stickerCreatedGrid", 2);
if ($("stickerLibGrid")) showGridSkeleton("stickerLibGrid", 2);
const seq = (loadStickerSquare._seq = (loadStickerSquare._seq || 0) + 1);
const q = ($("stickerSquareQ")?.value || "").trim();
let page = Math.max(state.stickerPage || 1, 1);
const [square, mine] = await Promise.all([
api(
"/api/v1/square/stickers?limit=" +
PAGE_SIZE +
"&page=" +
page +
(q ? "&q=" + encodeURIComponent(q) : ""),
),
api("/api/v1/me/stickers"),
]);
if (seq !== loadStickerSquare._seq) return;
const total = Number(square.total) || 0;
const limit = Number(square.limit) || PAGE_SIZE;
const pages = Math.max(1, Math.ceil(total / limit) || 1);
if (total > 0 && page > pages) {
state.stickerPage = pages;
return loadStickerSquare();
}
if (square.page) state.stickerPage = Math.max(Number(square.page) || page, 1);
const list = square.stickers || [];
$("stickerSquareGrid").innerHTML =
list.map((s) => stickerCardHtml(s, "square")).join("") ||
`<div class="empty compact"><span class="empty-title">暂无已审核表情</span>投稿后需管理员通过才会出现在此</div>`;
renderSquarePager(
"stickerSquarePager",
{ page: state.stickerPage, limit, total },
(next) => {
state.stickerPage = next;
loadStickerSquare().catch((e) => toast(e.message, true));
},
);
$("stickerCreatedGrid").innerHTML =
(mine.created || []).map((s) => stickerCardHtml(s, "created")).join("") ||
`<div class="empty compact">还没有投稿</div>`;
$("stickerLibGrid").innerHTML =
(mine.library || []).map((s) => stickerCardHtml(s, "lib")).join("") ||
`<div class="empty compact">库为空,从广场添加</div>`;
document.querySelectorAll("[data-st-add]").forEach((btn) => {
btn.onclick = async () => {
try {
await api("/api/v1/me/stickers/" + btn.dataset.stAdd + "/add", {
method: "POST",
body: {},
});
toast("已添加到表情库");
loadStickerSquare().catch((e) => toast(e.message, true));
} catch (e) {
toast(e.message, true);
}
};
});
document.querySelectorAll("[data-st-rm-lib]").forEach((btn) => {
btn.onclick = async () => {
try {
await api("/api/v1/me/stickers/" + btn.dataset.stRmLib, {
method: "DELETE",
});
toast("已从库移除");
loadStickerSquare().catch((e) => toast(e.message, true));
} catch (e) {
toast(e.message, true);
}
};
});
document.querySelectorAll("[data-st-rm-created]").forEach((btn) => {
btn.onclick = async () => {
if (!confirm("删除该表情?")) return;
try {
await api("/api/v1/square/stickers/" + btn.dataset.stRmCreated, {
method: "DELETE",
});
toast("已删除");
loadStickerSquare().catch((e) => toast(e.message, true));
} catch (e) {
toast(e.message, true);
}
};
});
}
$("stickerSquareSearch")?.addEventListener("click", () => {
state.stickerPage = 1;
loadStickerSquare().catch((e) => toast(e.message, true));
});
$("stickerSquareQ")?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
state.stickerPage = 1;
loadStickerSquare().catch((er) => toast(er.message, true));
}
});
let _stPubB64 = null;
let _stPubMime = null;
$("stPubFile")?.addEventListener("change", () => {
const f = $("stPubFile").files?.[0];
if (!f) {
_stPubB64 = null;
_stPubMime = null;
return;
}
if (f.size > 2.5 * 1024 * 1024) {
toast("图片过大(≤2MB", true);
$("stPubFile").value = "";
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = String(reader.result || "");
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!m) {
toast("读取失败", true);
return;
}
_stPubMime = m[1];
_stPubB64 = m[2];
};
reader.readAsDataURL(f);
});
$("stPubSubmit")?.addEventListener("click", async () => {
const displayName = ($("stPubName")?.value || "").trim();
const slug = ($("stPubSlug")?.value || "").trim().toLowerCase();
const description = ($("stPubDesc")?.value || "").trim();
const tags = ($("stPubTags")?.value || "")
.split(/[,]/)
.map((t) => t.trim())
.filter(Boolean);
const visibility =
document.querySelector('input[name="stPubVis"]:checked')?.value ||
"public";
if (!displayName || !_stPubB64) {
toast("请填写名称并选择图片", true);
return;
}
try {
const res = await api("/api/v1/square/stickers", {
method: "POST",
body: {
displayName,
slug: slug || undefined,
description,
tags,
visibility,
mime: _stPubMime,
dataBase64: _stPubB64,
},
});
const st = res.sticker?.reviewStatus || res.sticker?.review_status;
toast(
visibility === "public"
? st === "pending"
? "已提交,等待管理员审核"
: "已提交"
: "私有表情已保存",
);
$("stPubName").value = "";
$("stPubSlug").value = "";
$("stPubDesc").value = "";
$("stPubTags").value = "";
$("stPubFile").value = "";
_stPubB64 = null;
_stPubMime = null;
loadStickerSquare().catch((e) => toast(e.message, true));
} catch (e) {
toast(e.message || "提交失败", true);
}
});
async function loadMine() {
showGridSkeleton("createdGrid", 2);
showGridSkeleton("libGrid", 2);
const data = await api("/api/v1/me/personas");
const created = data.created || [];
const library = data.library || [];
$("createdGrid").innerHTML =
created.map((p) => cardHtml(p, "created")).join("") ||
`<div class="empty compact">
<span class="empty-title">还没有发布人设</span>
在上方填写名称与提示词,一键发布
<br/>
<button class="btn btn-primary btn-sm" type="button"
onclick="document.getElementById('pubName').focus();document.getElementById('personaEditorCard').scrollIntoView({behavior:'smooth',block:'start'})">用上方编辑器创作 <i class="bi bi-arrow-up" aria-hidden="true"></i></button>
</div>`;
$("libGrid").innerHTML =
library.map((p) => cardHtml(p, "lib")).join("") ||
`<div class="empty compact">
<span class="empty-title">库为空</span>
从广场添加后,可分配给机器人用户
<br/>
<button class="btn btn-primary btn-sm" type="button" onclick="switchPane('square')">去人设广场逛逛 <i class="bi bi-arrow-right" aria-hidden="true"></i></button>
</div>`;
document.querySelectorAll("[data-vis]").forEach((btn) => {
btn.onclick = async () => {
const [id, visibility] = btn.dataset.vis.split("|");
try {
await api("/api/v1/square/personas/" + id, {
method: "PUT",
body: { visibility },
});
toast("已更新可见性");
loadMine().catch(onMineError);
loadSquare().catch(onSquareError);
} catch (e) {
toast(e.message, true);
}
};
});
document.querySelectorAll("[data-edit]").forEach((btn) => {
btn.onclick = () => startEditPersona(btn.dataset.edit);
});
document.querySelectorAll("[data-rm-created]").forEach((btn) => {
btn.onclick = async () => {
const ok = await confirmDialog({
title: "删除人设?",
message: "删除后他人无法再使用(软删除,管理员可恢复)。",
okText: "删除",
});
if (!ok) return;
try {
await api("/api/v1/square/personas/" + btn.dataset.rmCreated, {
method: "DELETE",
});
toast("已删除");
if (state.editingPersonaId === btn.dataset.rmCreated) resetEditor();
loadMine().catch(onMineError);
refreshBots().catch(onRefreshError);
} catch (e) {
toast(e.message, true);
}
};
});
document.querySelectorAll("[data-rm-lib]").forEach((btn) => {
btn.onclick = async () => {
try {
await api("/api/v1/me/personas/" + btn.dataset.rmLib, {
method: "DELETE",
});
toast("已从库移除");
loadMine().catch(onMineError);
refreshBots().catch(onRefreshError);
} catch (e) {
toast(e.message, true);
}
};
});
bindDetailButtons($("createdGrid"));
bindDetailButtons($("libGrid"));
bindTryForkButtons($("createdGrid"));
bindTryForkButtons($("libGrid"));
}
function syncChatflowHint() {
const mode = $("pubMode")?.value || "prompt";
const hint = $("pubChatflowHint");
if (hint) hint.style.display = mode === "chatflow" ? "block" : "none";
const link = $("pubChatflowLink");
if (link && state.editingPersonaId) {
link.href = "/chatflow?persona=" + encodeURIComponent(state.editingPersonaId);
} else if (link) {
link.href = "/chatflow";
}
}
async function fillPubLlmSelect(selectedId) {
const sel = $("pubLlmProvider");
if (!sel) return;
try {
const data = await api("/api/v1/me/llm-providers");
llmProvidersCache = data.providers || [];
} catch (_) {
/* keep cache */
}
const cur = selectedId !== undefined ? selectedId : sel.value;
sel.innerHTML =
'<option value="">平台默认(管理员 LLM</option>' +
llmProvidersCache
.filter((p) => p.enabled !== false)
.map(
(p) =>
`<option value="${esc(p.id)}">${esc(p.name)} · ${esc(p.defaultModel || "")}</option>`,
)
.join("");
if (cur && [...sel.options].some((o) => o.value === cur)) {
sel.value = cur;
} else {
sel.value = "";
}
}
function resetEditor() {
state.editingPersonaId = null;
$("pubEditId").value = "";
$("pubName").value = "";
$("pubDesc").value = "";
$("pubTags").value = "";
$("pubPrompt").value = "";
if ($("pubMode")) $("pubMode").value = "prompt";
if ($("pubWebSearch")) $("pubWebSearch").checked = false;
if ($("pubLlmProvider")) $("pubLlmProvider").value = "";
const pubRadio = document.querySelector('input[name="pubVis"][value="public"]');
if (pubRadio) pubRadio.checked = true;
$("editorTitle").textContent = "人设编辑器";
$("pubSubmit").textContent = "发布人设";
$("pubCancelEdit").style.display = "none";
syncChatflowHint();
}
async function startEditPersona(id) {
try {
const data = await api("/api/v1/square/personas/" + id);
const p = data.persona;
state.editingPersonaId = id;
$("pubEditId").value = id;
$("pubName").value = personaName(p);
$("pubDesc").value = p.description || "";
$("pubTags").value = (p.tags || []).join(", ");
$("pubPrompt").value = p.systemPrompt || "";
const vis = p.visibility === "private" ? "private" : "public";
const radio = document.querySelector(`input[name="pubVis"][value="${vis}"]`);
if (radio) radio.checked = true;
if ($("pubMode")) {
$("pubMode").value = p.mode === "chatflow" ? "chatflow" : "prompt";
}
if ($("pubWebSearch")) {
$("pubWebSearch").checked = Boolean(p.webSearchEnabled);
}
await fillPubLlmSelect(p.llmProviderId || "");
$("editorTitle").textContent = "编辑人设";
$("pubSubmit").textContent = "保存修改";
$("pubCancelEdit").style.display = "inline-flex";
syncChatflowHint();
$("personaEditorCard")?.scrollIntoView({ behavior: "smooth", block: "start" });
toast("已载入人设,可插入 {{bot_name}} 变量后保存");
} catch (e) {
toast(e.message, true);
}
}
async function loadLlmProviders() {
const list = $("llmProvList");
const note = $("llmProvGatewayNote");
if (list) list.innerHTML = '<p class="muted">加载中…</p>';
try {
const data = await api("/api/v1/me/llm-providers");
llmProvidersCache = data.providers || [];
if (note) {
if (!data.configured) {
note.textContent =
"服务端未配置 LLM_PROVIDER_SECRET,无法保存自定义密钥。";
} else if (!data.toolsGateway) {
note.textContent =
"未配置 TOOLS_BASE_URL:自定义模型不可用。请先部署 huggingface/wechat-ai-tools。";
} else {
note.textContent =
"工具网关已配置。自定义 API 由 HF 工具服务代发;" +
(data.webSearchEnabled
? "联网搜索已开启。"
: "联网搜索总开关关闭(WEB_SEARCH_ENABLED)。");
}
}
if (!list) return;
if (!llmProvidersCache.length) {
list.innerHTML =
'<div class="empty compact"><span class="empty-title">还没有模型连接</span>填写上方表单添加 OpenAI 兼容 API</div>';
return;
}
list.innerHTML = llmProvidersCache
.map((p) => {
const en = p.enabled !== false;
return `<div class="llm-prov-card" data-prov="${esc(p.id)}">
<div class="row">
<h4>${esc(p.name)}</h4>
<span class="badge ${en ? "ok" : "warn"}">${en ? "启用" : "停用"}</span>
</div>
<div class="meta">${esc(p.baseUrl)} · 模型 ${esc(p.defaultModel || "—")} · Key ${esc(p.apiKeyMasked || "****")}</div>
<div class="row-actions">
<button class="btn btn-secondary btn-sm" type="button" data-prov-toggle="${esc(p.id)}|${en ? "0" : "1"}">${en ? "停用" : "启用"}</button>
<button class="btn btn-danger btn-sm" type="button" data-prov-del="${esc(p.id)}">删除</button>
</div>
</div>`;
})
.join("");
list.querySelectorAll("[data-prov-del]").forEach((btn) => {
btn.onclick = async () => {
const ok = await confirmDialog({
title: "删除模型连接?",
message: "使用该连接的人设将回退到平台默认模型。",
okText: "删除",
});
if (!ok) return;
try {
await api("/api/v1/me/llm-providers/" + btn.dataset.provDel, {
method: "DELETE",
});
toast("已删除");
loadLlmProviders().catch((e) => toast(e.message, true));
fillPubLlmSelect().catch(() => {});
} catch (e) {
toast(e.message, true);
}
};
});
list.querySelectorAll("[data-prov-toggle]").forEach((btn) => {
btn.onclick = async () => {
const [id, en] = (btn.dataset.provToggle || "").split("|");
try {
await api("/api/v1/me/llm-providers/" + id, {
method: "PATCH",
body: { enabled: en === "1" },
});
toast(en === "1" ? "已启用" : "已停用");
loadLlmProviders().catch((e) => toast(e.message, true));
fillPubLlmSelect().catch(() => {});
} catch (e) {
toast(e.message, true);
}
};
});
} catch (e) {
if (list) list.innerHTML = "";
if (note) note.textContent = e.message || "加载失败";
throw e;
}
}
$("pubCancelEdit").onclick = () => {
resetEditor();
toast("已取消编辑");
};
$("squareSearch").onclick = () => {
state.squarePage = 1;
loadSquare().catch(onSquareError);
};
$("squareQ").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
state.squarePage = 1;
loadSquare().catch(onSquareError);
}
});
$("squareQ").addEventListener("input", () => {
clearTimeout(loadSquare._deb);
loadSquare._deb = setTimeout(() => {
state.squarePage = 1;
loadSquare().catch(onSquareError);
}, 350);
});
if ($("squareSort")) {
try {
const saved = localStorage.getItem("wa_app_square_sort");
if (
saved === "use" ||
saved === "recent" ||
saved === "name" ||
saved === "heat"
) {
$("squareSort").value = saved;
} else {
$("squareSort").value = "heat";
}
} catch (_) {}
$("squareSort").onchange = () => {
try {
localStorage.setItem("wa_app_square_sort", getSquareSort());
} catch (_) {}
state.squarePage = 1;
loadSquare().catch(onSquareError);
};
}
$("pubMode")?.addEventListener("change", () => syncChatflowHint());
$("pubSubmit").onclick = async () => {
const displayName = $("pubName").value.trim();
const systemPrompt = $("pubPrompt").value.trim();
if (!displayName || !systemPrompt) {
toast("请填写名称和提示词", true);
return;
}
const visibility =
document.querySelector('input[name="pubVis"]:checked')?.value ||
"public";
const tags = $("pubTags").value
.split(/[,]/)
.map((t) => t.trim())
.filter(Boolean);
const mode = $("pubMode")?.value === "chatflow" ? "chatflow" : "prompt";
const llmProviderId = ($("pubLlmProvider")?.value || "").trim() || null;
const webSearchEnabled = Boolean($("pubWebSearch")?.checked);
const body = {
displayName,
description: $("pubDesc").value.trim(),
systemPrompt,
visibility,
tags,
mode,
llmProviderId,
webSearchEnabled,
};
try {
if (state.editingPersonaId) {
await api("/api/v1/square/personas/" + state.editingPersonaId, {
method: "PUT",
body,
});
toast("人设已保存");
resetEditor();
} else {
await api("/api/v1/square/personas", {
method: "POST",
body,
});
toast("发布成功");
resetEditor();
}
loadMine().catch(onMineError);
if (visibility === "public") loadSquare().catch(onSquareError);
refreshBots().catch(onRefreshError);
} catch (e) {
toast(e.message, true);
}
};
$("llmProvSubmit")?.addEventListener("click", async () => {
const name = ($("llmProvName")?.value || "").trim();
const baseUrl = ($("llmProvBase")?.value || "").trim();
const apiKey = ($("llmProvKey")?.value || "").trim();
const defaultModel = ($("llmProvModel")?.value || "").trim();
if (!name || !baseUrl || !apiKey || !defaultModel) {
toast("请填写名称、Base URL、API Key 与模型", true);
return;
}
try {
await api("/api/v1/me/llm-providers", {
method: "POST",
body: { name, baseUrl, apiKey, defaultModel },
});
toast("已添加模型连接");
if ($("llmProvName")) $("llmProvName").value = "";
if ($("llmProvBase")) $("llmProvBase").value = "";
if ($("llmProvKey")) $("llmProvKey").value = "";
if ($("llmProvModel")) $("llmProvModel").value = "";
loadLlmProviders().catch((e) => toast(e.message, true));
fillPubLlmSelect().catch(() => {});
} catch (e) {
toast(e.message || "添加失败", true);
}
});
$("llmProvRefresh")?.addEventListener("click", () => {
loadLlmProviders().catch((e) => toast(e.message, true));
});
$("reloadPeers").onclick = () => loadPeers().catch((e) => toast(e.message, true));
$("filterBot").onchange = () => loadPeers().catch((e) => toast(e.message, true));
function stopPoll() {
if (state.timer) {
clearInterval(state.timer);
state.timer = null;
}
}
async function startBotLogin(opts) {
const rebindBotId = opts && opts.rebindBotId;
try {
stopPoll();
clearTimeout(showLogin._hideT);
clearTimeout(collapseLoginBox._t);
$("loginBox").style.cssText = "";
$("cancelLogin").disabled = false;
$("loginBox").classList.add("show");
$("qrImg").style.display = "none";
$("qrImg").dataset.src = "";
$("loginLinkWrap").style.display = "none";
$("qrSkel").style.display = "block";
try {
$("loginBox").scrollIntoView({ behavior: "smooth", block: "nearest" });
} catch (_) {}
setLoginStatus(
"wait",
rebindBotId ? "正在发起重新绑定…" : "正在获取二维码…",
);
const data = rebindBotId
? await api("/api/v1/me/bots/" + rebindBotId + "/relogin/start", {
method: "POST",
body: {},
})
: await api("/api/v1/me/bots/login/start", {
method: "POST",
body: { displayName: $("botName").value.trim() || undefined },
});
state.loginSessionId = data.session.sessionId;
state.loginMode = data.session.mode || (rebindBotId ? "rebind" : "create");
showLogin(data.session);
state.pollInFlight = false;
state.timer = setInterval(async () => {
// Don't stack requests on a slow link, and don't keep polling a
// backgrounded tab (server-side session TTL is 10 min, so pausing
// here never discards a still-valid session).
if (state.pollInFlight) return;
if (typeof document !== "undefined" && document.hidden) return;
state.pollInFlight = true;
try {
const r = await api("/api/v1/me/bots/login/" + state.loginSessionId);
showLogin(r.session);
} catch (e) {
toast(e.message, true);
stopPoll();
} finally {
state.pollInFlight = false;
}
}, 1500);
} catch (e) {
toast(e.message, true);
setLoginStatus("err", e.message || "发起失败");
$("qrSkel").style.display = "none";
$("cancelLogin").disabled = true;
}
}
$("addBot").onclick = () => startBotLogin({});
$("botName").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
$("addBot").click();
}
});
async function loadWechatBind() {
const el = $("wechatBindStatus");
const box = $("bindCodeBox");
if (!el) return;
try {
const data = await api("/api/v1/me/wechat-bind");
if (data.enabled === false) {
el.textContent = "用户对话功能已关闭(P2P_ENABLED=false";
if (box) box.style.display = "none";
return;
}
if (!data.bound) {
el.innerHTML =
"状态:<strong>未绑定</strong>。生成绑定码后,用微信给机器人发送 <code>/绑定 验证码</code>。";
} else {
const reach = data.reachable
? "可达(已有会话凭证)"
: "暂不可达——请先用该微信与机器人聊一句,对方才能 @ 到你";
el.innerHTML =
`状态:已绑定为 <strong>@${esc(data.username || "")}</strong><br/>` +
`<span class="muted" style="font-size:13px">Bot <code>${esc(data.botId || "")}</code> · peer <code>${esc(data.peerId || "")}</code> · ${esc(reach)}</span>`;
}
} catch (e) {
el.textContent = "无法加载绑定状态:" + (e.message || "error");
}
}
$("genBindCode").onclick = async () => {
try {
const data = await api("/api/v1/me/wechat-bind/code", {
method: "POST",
body: {},
});
$("bindCodeBox").style.display = "block";
$("bindCodeText").innerHTML =
`绑定码:<strong style="font-size:1.4em;letter-spacing:.12em">${esc(data.code)}</strong>` +
`(约 ${Math.round((data.expiresInSec || 600) / 60)} 分钟有效)`;
$("bindCodeHint").textContent =
data.instruction ||
`在微信中给机器人发送:/绑定 ${data.code}`;
toast("绑定码已生成");
await loadWechatBind();
} catch (e) {
toast(e.message || "生成失败", true);
}
};
$("clearBind").onclick = async () => {
const ok = await confirmDialog({
title: "解除微信绑定",
message: "解绑后无法被他人 @,进行中的用户对话也会结束。确定?",
okText: "解除",
});
if (!ok) return;
try {
await api("/api/v1/me/wechat-bind", { method: "DELETE" });
$("bindCodeBox").style.display = "none";
toast("已解除绑定");
await loadWechatBind();
} catch (e) {
toast(e.message || "解绑失败", true);
}
};
$("refreshBind").onclick = () => {
loadWechatBind().catch((e) => toast(e.message || "刷新失败", true));
};
async function loadBlocks() {
const el = $("blockList");
if (!el) return;
try {
const data = await api("/api/v1/me/blocks");
if (data.enabled === false) {
el.textContent = "用户对话功能已关闭,黑名单不可用。";
return;
}
const blocks = data.blocks || [];
if (!blocks.length) {
el.innerHTML = '<span class="muted">黑名单为空</span>';
return;
}
el.innerHTML =
`<div class="table-wrap"><table><thead><tr><th>用户名</th><th>操作</th></tr></thead><tbody>` +
blocks
.map(
(b) =>
`<tr>
<td><strong>@${esc(b.username || b.userId || "")}</strong>${
b.name && b.name !== b.username
? ` <span class="muted">(${esc(b.name)})</span>`
: ""
}</td>
<td><button class="btn btn-secondary btn-sm" type="button" data-unblock="${esc(
b.userId,
)}">取消拉黑</button></td>
</tr>`,
)
.join("") +
`</tbody></table></div>`;
el.querySelectorAll("[data-unblock]").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.getAttribute("data-unblock");
if (!id) return;
try {
await api("/api/v1/me/blocks/" + encodeURIComponent(id), {
method: "DELETE",
});
toast("已取消拉黑");
await loadBlocks();
} catch (e) {
toast(e.message || "操作失败", true);
}
});
});
} catch (e) {
el.textContent = "无法加载黑名单:" + (e.message || "error");
}
}
$("addBlock").onclick = async () => {
const username = ($("blockUsername").value || "").trim().replace(/^@/, "");
if (!username) {
toast("请输入用户名", true);
return;
}
try {
const r = await api("/api/v1/me/blocks", {
method: "POST",
body: { username },
});
$("blockUsername").value = "";
toast(
r.already
? `@${r.blocked?.username || username} 已在黑名单`
: `已拉黑 @${r.blocked?.username || username}`,
);
await loadBlocks();
} catch (e) {
toast(e.message || "拉黑失败", true);
}
};
$("blockUsername").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
$("addBlock").click();
}
});
$("refreshBlocks").onclick = () => {
loadBlocks().catch((e) => toast(e.message || "刷新失败", true));
};
$("cancelLogin").onclick = async () => {
if (!state.loginSessionId) return;
try {
await api("/api/v1/me/bots/login/" + state.loginSessionId + "/cancel", {
method: "POST",
body: {},
});
} catch (_) { /* ignore */ }
stopPoll();
$("cancelLogin").disabled = true;
$("qrSkel").style.display = "none";
setLoginStatus("warn", "已取消扫码");
toast("已取消扫码");
};
const LOGIN_STATUS_META = {
pending: { cls: "wait", label: "正在准备二维码…" },
wait_scan: { cls: "wait", label: "等待微信扫码…" },
scanned: { cls: "wait", label: "已扫码,请在手机上确认登录" },
confirmed: { cls: "ok", label: "登录成功" },
expired: { cls: "warn", label: "二维码已过期,请重新发起" },
error: { cls: "err", label: "出错了" },
};
function setLoginStatus(cls, text) {
const st = $("loginStatus");
if (!st) return;
// the 1.5s poll calls this repeatedly — only touch the DOM on change,
// otherwise the pulsing dot animation restarts every tick
const next = "qr-status " + cls;
let label = st.querySelector(".qr-label");
if (!label) {
st.textContent = "";
const dot = document.createElement("span");
dot.className = "dot";
dot.setAttribute("aria-hidden", "true");
label = document.createElement("span");
label.className = "qr-label";
st.append(dot, label);
}
if (st.className !== next) st.className = next;
if (label.textContent !== text) label.textContent = text;
}
function collapseLoginBox() {
const box = $("loginBox");
if (!box || !box.classList.contains("show")) return;
const h = box.offsetHeight;
box.style.height = h + "px";
box.style.overflow = "hidden";
requestAnimationFrame(() => {
box.style.transition =
"height 0.28s var(--ease), opacity 0.28s var(--ease), padding 0.28s var(--ease), margin 0.28s var(--ease)";
box.style.height = "0px";
box.style.opacity = "0";
box.style.paddingTop = "0px";
box.style.paddingBottom = "0px";
box.style.marginTop = "0px";
collapseLoginBox._t = setTimeout(() => {
box.classList.remove("show");
box.style.cssText = "";
}, 300);
});
}
function showLogin(s) {
$("loginBox").classList.add("show");
const meta = LOGIN_STATUS_META[s.status] || { cls: "wait", label: s.status || "" };
const modeLabel = s.mode === "rebind" ? "重新绑定 · " : "";
setLoginStatus(meta.cls, modeLabel + (s.message || meta.label));
if (s.openUrl) {
$("loginLinkWrap").style.display = "block";
$("loginLink").href = s.openUrl;
$("loginLink").textContent = s.openUrl;
const img = $("qrImg");
/* 二维码由本站渲染。openUrl 里带着登录 ticket,早先的实现把它当查询参数
发给 api.qrserver.com 换取图片,等于把凭证抄送给第三方。现在只请求本站
同源接口(<img> 自带 cookie),ticket 不出本机与浏览器。 */
const url =
"/api/v1/me/bots/login/" +
encodeURIComponent(s.sessionId) +
"/qr.svg";
// the poll repeats the same session — only (re)load and swap on change,
// and keep the skeleton until the image has actually decoded
if (img.dataset.src !== url) {
img.dataset.src = url;
img.onload = () => {
$("qrSkel").style.display = "none";
img.style.display = "inline-block";
};
img.onerror = () => {
$("qrSkel").style.display = "none";
setLoginStatus("warn", "二维码图片加载失败,请用下方链接打开");
};
img.src = url;
}
}
if (s.status === "confirmed") {
stopPoll();
$("cancelLogin").disabled = true;
$("qrSkel").style.display = "none";
$("qrImg").style.display = "none";
$("loginLinkWrap").style.display = "none";
toast(
s.mode === "rebind"
? "重新绑定成功,好友与人设已保留"
: "机器人添加成功",
);
refreshBots().catch(onRefreshError);
clearTimeout(showLogin._hideT);
showLogin._hideT = setTimeout(collapseLoginBox, 2000);
}
if (s.status === "expired" || s.status === "error") {
stopPoll();
$("cancelLogin").disabled = true;
$("qrSkel").style.display = "none";
toast(s.message || s.status, true);
}
}
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]),
);
}
boot();
/* 只有 Chromium 会真正渲染 backdrop-filter 里的 SVG 滤镜引用;Safari / Firefox
语法能解析但渲染为空,会把整条 backdrop-filter 作废——必须运行时判定而不是 @supports。
navigator.userAgentData 目前仅 Chromium 实现,用作引擎判定。
再要求 pointer:fine:位移滤镜在滚动时每帧重算,中低端手机上代价太高。 */
(function () {
try {
var fine = matchMedia("(pointer: fine)").matches;
var chromium = !!navigator.userAgentData;
if (chromium && fine && CSS.supports("backdrop-filter", "url(#x)")) {
document.documentElement.classList.add("wa-lens");
}
} catch (e) {}
})();
/* 把滚动深度写成 0..1 的进度值,驱动吸顶层的模糊与底色。rAF 节流,passive 监听。 */
(function () {
var root = document.documentElement;
var ticking = false;
function apply() {
ticking = false;
var y = window.scrollY || root.scrollTop || 0;
/* 下界必须夹到 0Safari / iOS 回弹时 scrollY 会是负数,负进度会让
--glass-blur-live 算出负长度,blur(-8.5px) 非法 → 整条 backdrop-filter
作废(模糊直接消失),同时 --glass-a 掉到 0.01 附近近乎全透明。 */
var p = y / 80;
p = p < 0 ? 0 : p > 1 ? 1 : p;
root.style.setProperty("--glass-progress", p.toFixed(3));
}
addEventListener(
"scroll",
function () {
if (!ticking) {
ticking = true;
requestAnimationFrame(apply);
}
},
{ passive: true },
);
apply();
})();
</script>
</body>
</html>