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

2215 lines
81 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="robots" content="noindex" />
<title>Chatflow 编辑器 — WeChat-AI</title>
<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"
/>
<!-- 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>
<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>
<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);
--red: #ff3b30;
--red-soft: rgba(255, 59, 48, 0.12);
--orange: #ff9f0a;
--orange-soft: rgba(255, 159, 10, 0.14);
--purple: #af52de;
--pink: #bf5af2;
--cyan: #32ade6;
--chip: rgba(0, 0, 0, 0.05);
--input-bg: rgba(0, 0, 0, 0.03);
--grid-dot: rgba(0, 0, 0, 0.16);
--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);
--scrim: rgba(0, 0, 0, 0.42);
--radius: 18px;
--radius-sm: 12px;
--radius-xs: 8px;
--radius-lg: 22px;
--radius-pill: 980px;
--control-h: 40px;
--control-h-sm: 34px;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
"Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
--mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
--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;
--bar-h: 52px;
--pal-w: 216px;
--props-w: 320px;
--node-w: 184px;
/* ---- Liquid glass ----
Layered translucency: a tinted base, a blurred backdrop, and a
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);
--glass-tint: transparent;
--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;
/* 本页 body 是 overflow:hidden,没有页面滚动,画布始终垫在顶栏/工具条下面,
所以进度恒为 1(完整玻璃),不接滚动监听。令牌体系与其余各页保持一致。 */
--glass-progress: 1;
--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));
}
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);
--red: #ff453a;
--red-soft: rgba(255, 69, 58, 0.16);
--orange: #ff9f0a;
--orange-soft: rgba(255, 159, 10, 0.16);
--purple: #bf5af2;
--pink: #ff375f;
--cyan: #64d2ff;
--chip: rgba(255, 255, 255, 0.08);
--input-bg: rgba(255, 255, 255, 0.06);
--grid-dot: rgba(255, 255, 255, 0.14);
--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);
--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-width: thin;
scrollbar-color: var(--chip) transparent;
}
html[data-theme="dark"] { color-scheme: dark; }
*::-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; }
body {
margin: 0;
font-family: var(--font);
color: var(--text);
background:
radial-gradient(1000px 500px at 0% 0%, var(--glow-a), transparent 50%),
radial-gradient(800px 400px at 100% 0%, var(--glow-b), transparent 45%),
var(--bg);
line-height: 1.47;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
transition: background 0.25s var(--ease), color 0.2s var(--ease);
}
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%);
}
/* .canvas-hud / .editor-fabs 直接写了 backdrop-filter 而没挂 .glass 类,
这里单独补一条折射覆盖,定位与圆角保持原样不动。 */
html.wa-lens .canvas-hud,
html.wa-lens .editor-fabs {
backdrop-filter: blur(14px) saturate(180%) url(#waGlassLens);
-webkit-backdrop-filter: blur(14px) saturate(180%);
}
/* Floating pills/panels get a full specular rim traced around the shape.
The gradient border is masked to a 1px ring so the fill stays clear. */
.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; }
button, .btn, .theme-toggle, .seg label {
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
-webkit-user-select: none;
user-select: none;
}
.btn:focus-visible,
.theme-toggle:focus-visible,
button:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
input:focus-visible,
textarea:focus-visible {
outline: none;
border-color: var(--focus-border);
box-shadow: 0 0 0 4px var(--blue-soft);
}
/* ---------- Nav ---------- */
.nav {
flex: 0 0 auto;
z-index: 50;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: var(--nav-h);
padding: calc(9px + var(--safe-t)) max(16px, var(--safe-r)) 9px max(16px, var(--safe-l));
/* 材质来自 .glass-dynamic(见 markup 上的 class */
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
font-weight: 600;
font-size: 15px;
letter-spacing: -0.01em;
color: var(--text);
}
.brand:hover { text-decoration: none; }
.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; }
.brand-text { min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.brand-text b { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.brand-text .sub {
font-size: 11.5px;
font-weight: 400;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 48vw;
}
.nav-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.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.12s 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; }
.theme-toggle .bi { font-size: 1.05rem; line-height: 1; }
/* ---------- Buttons ---------- */
.btn {
appearance: none;
border: none;
font-family: inherit;
font-size: 13.5px;
font-weight: 500;
letter-spacing: -0.01em;
border-radius: var(--radius-pill);
padding: 0 15px;
min-height: var(--control-h);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
text-decoration: none;
transition: transform 0.14s var(--ease), background 0.15s, filter 0.15s;
}
.btn:hover { text-decoration: none; }
.btn:active:not(:disabled) { transform: scale(0.97); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn .bi { font-size: 0.98em; line-height: 1; }
.btn-primary { background: var(--blue); color: #fff; box-shadow: 0 1px 2px rgba(0, 113, 227, 0.25); }
.btn-primary:hover:not(:disabled) { background: var(--blue-hover); color: #fff; }
.btn-secondary { background: var(--chip); color: var(--text); }
.btn-secondary:hover:not(:disabled) { filter: brightness(0.96); color: var(--text); }
html[data-theme="dark"] .btn-secondary:hover:not(:disabled) { filter: brightness(1.35); }
.btn-danger { background: var(--red-soft); color: var(--red); }
.btn-danger:hover:not(:disabled) { filter: brightness(0.96); }
.btn-sm { min-height: var(--control-h-sm); font-size: 12.5px; padding: 0 12px; }
/* ---------- Form controls ---------- */
select, input, textarea {
font-family: inherit;
font-size: 13.5px;
color: var(--text);
background: var(--input-bg);
border: 1px solid var(--separator);
border-radius: var(--radius-xs);
padding: 8px 11px;
min-width: 0;
width: 100%;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
select {
min-height: var(--control-h);
appearance: none;
-webkit-appearance: none;
padding-right: 30px;
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%2386868b'%3E%3Cpath d='M4.5 6.5 8 10l3.5-3.5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 9px center;
background-size: 15px;
}
input:disabled, textarea:disabled, select:disabled {
opacity: 0.55;
cursor: not-allowed;
background: var(--chip);
}
textarea { resize: vertical; font-family: var(--mono); font-size: 12.5px; line-height: 1.55; }
/* ---------- Sub toolbar ---------- */
.subbar {
flex: 0 0 auto;
z-index: 40;
min-height: var(--bar-h);
display: flex;
align-items: center;
gap: 8px;
padding: 8px max(16px, var(--safe-r)) 8px max(16px, var(--safe-l));
/* 材质来自 .glass-dynamic(见 markup 上的 class */
}
.subbar > #btnSave { flex: 0 0 auto; }
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
padding-block: 1px;
}
.toolbar::-webkit-scrollbar { display: none; }
.toolbar > * { flex: 0 0 auto; }
.toolbar #personaSelect { flex: 0 1 260px; min-width: 170px; }
.toolbar .spacer { flex: 1 1 auto; min-width: 0; }
.state-pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 10px;
border-radius: var(--radius-pill);
background: var(--chip);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.state-pill.ok { background: var(--green-soft); color: var(--green); }
.state-pill.ro { background: var(--orange-soft); color: var(--orange); }
/* ---------- Layout ---------- */
.layout {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: var(--pal-w) minmax(0, 1fr) var(--props-w);
}
html.is-locked .layout,
html.is-locked .subbar,
html.is-locked .editor-fabs { display: none !important; }
.pane {
background: var(--surface);
padding: 14px;
overflow-y: auto;
overscroll-behavior: contain;
min-width: 0;
-webkit-overflow-scrolling: touch;
}
.pane-palette { border-right: 1px solid var(--separator); }
.pane-props { border-left: 1px solid var(--separator); }
.pane-title {
margin: 0 0 10px;
font-size: 11.5px;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
}
.pane-title:not(:first-child) {
margin-top: 22px;
padding-top: 16px;
border-top: 1px solid var(--separator);
}
/* palette items */
.node-add {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
margin-bottom: 6px;
padding: 9px 11px;
border: 1px solid var(--separator);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font: inherit;
font-size: 13.5px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.12s var(--ease);
}
.node-add:hover:not(:disabled) { background: var(--surface-2); border-color: var(--blue); }
.node-add:active:not(:disabled) { transform: scale(0.985); }
.node-add:disabled { opacity: 0.45; cursor: not-allowed; }
.node-add .dot {
width: 26px;
height: 26px;
flex-shrink: 0;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 13px;
background: var(--tint-soft, var(--chip));
color: var(--tint, var(--text-secondary));
}
.node-add small {
display: block;
font-size: 11px;
font-weight: 400;
color: var(--text-tertiary);
line-height: 1.3;
}
.hint {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.55;
margin: 0;
}
.hint code {
font-family: var(--mono);
font-size: 11px;
background: var(--chip);
padding: 1px 5px;
border-radius: 5px;
}
.tip-box {
margin-top: 14px;
padding: 11px 12px;
border-radius: var(--radius-sm);
background: var(--surface-2);
border: 1px solid var(--separator);
}
/* ---------- Canvas ---------- */
.canvas-wrap {
position: relative;
overflow: hidden;
min-width: 0;
background: var(--surface-2);
touch-action: none;
cursor: grab;
}
.canvas-wrap.panning { cursor: grabbing; }
.canvas-wrap::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background-image: radial-gradient(circle at 1px 1px, var(--grid-dot) 1px, transparent 0);
background-size: var(--grid-size, 22px) var(--grid-size, 22px);
background-position: var(--grid-x, 0px) var(--grid-y, 0px);
opacity: 0.85;
}
.canvas {
position: absolute;
top: 0;
left: 0;
width: 2600px;
height: 1800px;
transform-origin: 0 0;
}
.edge-layer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
}
.edge-label { font-size: 10px; fill: var(--text-tertiary); font-family: var(--font); }
.link-layer { z-index: 3; }
.link-preview {
fill: none;
stroke: var(--blue);
stroke-width: 2;
stroke-linecap: round;
stroke-dasharray: 5 5;
opacity: 0.9;
}
/* while linking, make the drop targets obvious */
.canvas-wrap.linking .node:not([data-type="start"]) { border-color: var(--blue); }
.canvas-wrap.linking .node .port.in { background: var(--blue); transform: translateY(-50%) scale(1.35); }
.node {
position: absolute;
width: var(--node-w);
background: var(--surface);
border: 1px solid var(--separator);
border-radius: 14px;
box-shadow: var(--shadow-md);
cursor: grab;
user-select: none;
-webkit-user-select: none;
touch-action: none;
z-index: 2;
transition: box-shadow 0.18s var(--ease), border-color 0.15s;
}
.node:active { cursor: grabbing; }
.node::after {
content: "";
position: absolute;
left: 0;
top: 10px;
bottom: 10px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--tint, var(--blue));
}
.node.selected {
border-color: var(--blue);
box-shadow: 0 0 0 3px var(--blue-soft), var(--shadow-lg);
}
.node .head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 9px 11px 8px 13px;
font-size: 12.5px;
font-weight: 600;
letter-spacing: -0.01em;
border-bottom: 1px solid var(--separator);
}
.node .head .ttl {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 6px;
}
.node .head .ttl .bi { color: var(--tint, var(--blue)); font-size: 0.95em; flex-shrink: 0; }
.node .head .kind {
flex-shrink: 0;
font-size: 10px;
font-weight: 500;
color: var(--tint, var(--text-tertiary));
background: var(--tint-soft, var(--chip));
border-radius: var(--radius-pill);
padding: 1px 7px;
}
.node .body {
padding: 8px 11px 10px 13px;
font-size: 11.5px;
color: var(--text-secondary);
line-height: 1.45;
min-height: 34px;
overflow-wrap: anywhere;
}
/* One tint scale shared by canvas nodes and palette entries */
[data-type="start"] { --tint: var(--green); --tint-soft: var(--green-soft); }
[data-type="llm"] { --tint: var(--blue); --tint-soft: var(--blue-soft); }
[data-type="answer"] { --tint: var(--purple); --tint-soft: rgba(175, 82, 222, 0.14); }
[data-type="search"] { --tint: var(--orange); --tint-soft: var(--orange-soft); }
[data-type="memory"] { --tint: var(--pink); --tint-soft: rgba(191, 90, 242, 0.14); }
[data-type="if-else"] { --tint: var(--cyan); --tint-soft: rgba(50, 173, 230, 0.14); }
[data-type="http"] { --tint: var(--red); --tint-soft: var(--red-soft); }
.port {
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--blue);
border: 2px solid var(--surface);
position: absolute;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
transition: transform 0.14s var(--ease);
}
.port::after {
/* enlarged hit area for touch without changing visual size */
content: "";
position: absolute;
inset: -9px;
border-radius: 50%;
}
.port.in { left: -6px; top: 50%; transform: translateY(-50%); background: var(--text-tertiary); }
.port.out { right: -6px; top: 50%; transform: translateY(-50%); cursor: crosshair; }
.port.out:hover { transform: translateY(-50%) scale(1.25); }
.port.true { right: -6px; top: 35%; background: var(--green); }
.port.false { right: -6px; top: 65%; background: var(--orange); }
.canvas-hud {
position: absolute;
right: 14px;
bottom: calc(14px + var(--safe-b));
z-index: 6;
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px;
border-radius: 14px;
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(180%);
box-shadow: var(--glass-shadow);
}
.canvas-hud button {
width: 32px;
height: 32px;
border: none;
border-radius: 9px;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 15px;
display: grid;
place-items: center;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.canvas-hud button:hover { background: var(--chip); color: var(--text); }
.canvas-hud .zoom-val {
font-size: 10.5px;
font-variant-numeric: tabular-nums;
color: var(--text-tertiary);
text-align: center;
padding: 2px 0;
user-select: none;
}
.canvas-empty {
position: absolute;
inset: 0;
display: none;
place-items: center;
pointer-events: none;
color: var(--text-tertiary);
font-size: 13px;
text-align: center;
padding: 24px;
}
.canvas-wrap.is-empty .canvas-empty { display: grid; }
/* Placeholder graph shown while the persona's flow is being fetched. */
.canvas-loading {
position: absolute;
inset: 0;
z-index: 5;
display: none;
place-items: center;
background: var(--surface-2);
}
.canvas-wrap.is-loading .canvas-loading { display: grid; }
.canvas-loading .sk-graph {
display: flex;
align-items: center;
gap: 26px;
}
.canvas-loading .sk-node {
width: var(--node-w);
height: 84px;
border-radius: 14px;
}
.canvas-loading .sk-link { width: 40px; height: 2px; border-radius: 2px; }
/* ---------- Props ---------- */
.field {
display: grid;
gap: 5px;
margin-bottom: 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.field textarea { min-height: 96px; }
.edge-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
margin-bottom: 5px;
border-radius: var(--radius-xs);
background: var(--surface-2);
font-size: 12px;
color: var(--text-secondary);
}
.edge-item .ends {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--mono);
font-size: 11.5px;
}
.edge-item .handle-tag {
flex-shrink: 0;
font-size: 10px;
padding: 1px 6px;
border-radius: var(--radius-pill);
background: var(--chip);
}
.edge-item button {
flex-shrink: 0;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
width: 24px;
height: 24px;
border-radius: 6px;
display: grid;
place-items: center;
font-size: 13px;
}
.edge-item button:hover { background: var(--red-soft); color: var(--red); }
/* ---------- Login gate ---------- */
#loginGate {
display: none;
flex: 1 1 auto;
align-items: center;
justify-content: center;
padding: 32px max(20px, var(--safe-l)) calc(48px + var(--safe-b)) max(20px, var(--safe-r));
}
html.is-locked #loginGate { display: flex; }
/* Floats over the page glow, so it takes the glass recipe. The specular
rim replaces the old 1px separator border — keeping both would read as
a doubled edge. */
.gate-card {
width: 100%;
max-width: 400px;
padding: 32px 26px;
border: none;
border-radius: var(--radius-lg);
text-align: center;
}
.gate-card .brand-mark { width: 48px; height: 48px; border-radius: 14px; font-size: 24px; margin: 0 auto 16px; }
.gate-card h2 { margin: 0 0 8px; font-size: 21px; font-weight: 650; letter-spacing: -0.02em; }
.gate-card .hint { margin-bottom: 20px; }
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: calc(24px + var(--safe-b));
transform: translateX(-50%) translateY(10px);
max-width: min(90vw, 420px);
background: var(--toast-bg);
color: #fff;
padding: 11px 20px;
border-radius: var(--radius-pill);
font-size: 13.5px;
font-weight: 500;
text-align: center;
opacity: 0;
pointer-events: none;
z-index: 120;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: var(--shadow-lg);
transition: opacity 0.24s var(--ease), transform 0.24s var(--ease);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.err { background: var(--toast-err); }
/* ---------- Mobile sheets + FABs (hidden on desktop) ---------- */
.editor-fabs { display: none; }
.sheet-backdrop { display: none; }
/* ---------- Loading & motion ---------- */
/* 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%); } }
/* Indeterminate progress bar for in-flight api() calls */
.load-bar {
position: fixed; top: 0; left: 0; right: 0; height: 2px; z-index: 200;
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; } }
@keyframes riseIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
/* backwards 而非 both:延迟期保持 from 态避免闪入,动画结束即释放
opacity/transform;否则填充值会以动画层叠源的优先级永久盖掉
.node-add:active 的按压位移。 */
.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; }
/* The global reduce block zeroes durations but not delays; with fill-mode
"both" that would hide staggered items for up to 0.34s. Drop it entirely. */
@media (prefers-reduced-motion: reduce) {
.stagger > * { animation: none; }
}
.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); } }
/* ================= Responsive ================= */
/* Large desktop: give the canvas more room, widen the inspector */
@media (min-width: 1600px) {
:root { --pal-w: 240px; --props-w: 360px; --node-w: 196px; }
}
/* Small desktop / landscape tablet */
@media (max-width: 1180px) {
:root { --pal-w: 190px; --props-w: 280px; }
}
/* Tablet & phone: single-column canvas, side panes become sheets */
@media (max-width: 900px) {
:root { --nav-h: 52px; --bar-h: 50px; --control-h: 42px; }
body { overflow: hidden; }
/* 16px inputs prevent iOS Safari auto-zoom on focus */
input, select, textarea { font-size: 16px; }
.brand-text .sub { max-width: 40vw; }
.layout { grid-template-columns: minmax(0, 1fr); }
.pane { display: none; }
/* Bottom-sheet presentation for palette + inspector */
.sheet-backdrop {
position: fixed;
inset: 0;
z-index: 90;
display: none;
background: var(--scrim);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: fadeIn 0.2s var(--ease);
}
html.sheet-open .sheet-backdrop { display: block; }
/* The open sheet floats over the canvas, so it takes the glass recipe.
The docked desktop .pane stays solid var(--surface).
The rim lives in the box-shadow (the sheet scrolls, and ::before is
already the grabber handle); the old 1px border is dropped so the top
edge isn't drawn twice. */
html.sheet-open .pane[data-sheet-open] {
display: block;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 95;
max-height: min(76dvh, 620px);
border: none;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: var(--glass-bg-strong);
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 1px 0 0 0 var(--glass-lo),
inset -1px 0 0 0 var(--glass-lo),
var(--glass-shadow);
padding: 8px max(16px, var(--safe-l)) calc(20px + var(--safe-b)) max(16px, var(--safe-r));
animation: sheetIn 0.28s var(--ease);
}
.pane::before {
content: "";
display: none;
width: 36px;
height: 5px;
margin: 0 auto 12px;
border-radius: 99px;
background: var(--separator);
}
html.sheet-open .pane[data-sheet-open]::before { display: block; }
@keyframes sheetIn {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Floating segmented control to summon the sheets */
.editor-fabs {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(16px + var(--safe-b));
z-index: 60;
display: flex;
gap: 4px;
padding: 4px;
border-radius: var(--radius-pill);
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(180%);
box-shadow: var(--glass-shadow);
}
html.sheet-open .editor-fabs { visibility: hidden; }
.editor-fabs button {
display: inline-flex;
align-items: center;
gap: 6px;
height: 38px;
padding: 0 16px;
border: none;
border-radius: var(--radius-pill);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s, transform 0.12s var(--ease);
}
.editor-fabs button:active { transform: scale(0.96); }
.editor-fabs button.on { background: var(--blue-soft); color: var(--blue); }
.editor-fabs button.has-sel { color: var(--text); }
.editor-fabs button.has-sel::after {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--blue);
}
.editor-fabs .count {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 99px;
background: var(--chip);
font-size: 10.5px;
font-weight: 600;
display: inline-grid;
place-items: center;
font-variant-numeric: tabular-nums;
}
/* stack the placeholder graph so three fake nodes can't overflow */
.canvas-loading .sk-graph { flex-direction: column; gap: 18px; }
.canvas-loading .sk-link { width: 2px; height: 24px; }
.canvas-hud { bottom: calc(66px + var(--safe-b)); right: max(12px, var(--safe-r)); }
.toast { bottom: calc(70px + var(--safe-b)); }
.subbar { padding-left: max(12px, var(--safe-l)); padding-right: max(12px, var(--safe-r)); }
.nav { padding-left: max(12px, var(--safe-l)); padding-right: max(12px, var(--safe-r)); gap: 8px; }
.toolbar .btn-label { display: none; }
.toolbar .btn { padding: 0 13px; }
.toolbar #personaSelect { flex: 1 1 170px; }
/* fade the scroller's trailing edge so it reads as scrollable */
.toolbar {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 22px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 22px), transparent);
}
}
@media (max-width: 420px) {
.brand-text b { font-size: 14px; }
.brand-text .sub { max-width: 38vw; }
.editor-fabs button { padding: 0 13px; font-size: 13px; }
.subbar > #btnSave .btn-label { display: none; }
.subbar > #btnSave { padding: 0 13px; }
}
/* Short landscape phones: reclaim vertical space */
@media (max-height: 500px) and (orientation: landscape) {
:root { --nav-h: 44px; --bar-h: 44px; }
.brand-text .sub { display: none; }
html.sheet-open .pane[data-sheet-open] { max-height: 88dvh; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@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;
}
}
@media (prefers-reduced-transparency: reduce) {
:root { --bg-elevated: #f5f5f7; }
html[data-theme="dark"] { --bg-elevated: #1c1c1e; }
.nav, .subbar, .toast, .canvas-hud, .editor-fabs, .sheet-backdrop,
.glass, .glass-dynamic, .gate-card, html.sheet-open .pane[data-sheet-open] {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* 折射同样关掉:位移滤镜是 backdrop-filter 的一部分,必须把 .wa-lens 那两条盖掉 */
html.wa-lens .glass,
html.wa-lens .glass-strong,
html.wa-lens .canvas-hud,
html.wa-lens .editor-fabs {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* opaque fallbacks for every glass surface + drop the specular rim */
.glass-dynamic { background: var(--bg-elevated); }
.nav, .subbar { background: var(--bg-elevated); }
.canvas-hud, .editor-fabs, .glass, .glass-strong { background: var(--surface); }
.gate-card {
background: var(--surface);
border: 1px solid var(--separator);
box-shadow: var(--shadow-lg);
}
html.sheet-open .pane[data-sheet-open] {
background: var(--surface);
box-shadow: var(--shadow-lg);
}
.glass-rim::before { display: none !important; }
}
</style>
</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">
<a class="brand" href="/app#mine">
<span class="brand-mark" aria-hidden="true"><i class="bi bi-diagram-3"></i></span>
<span class="brand-text">
<b>Chatflow 编辑器</b>
<span class="sub" id="headerSub">选择人设后编辑流程</span>
</span>
</a>
<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>
<a class="btn btn-secondary btn-sm" href="/app#mine"><i class="bi bi-arrow-left"></i><span class="btn-label">返回</span></a>
</div>
</header>
<div class="subbar glass-dynamic">
<div class="toolbar">
<select id="personaSelect" title="我的人设" aria-label="选择人设">
<option value="">加载人设…</option>
</select>
<span class="state-pill" id="statePill" hidden></span>
<span class="spacer"></span>
<button type="button" class="btn btn-secondary" id="btnReload" title="重载">
<i class="bi bi-arrow-clockwise"></i><span class="btn-label">重载</span>
</button>
<button type="button" class="btn btn-secondary" id="btnDefault" title="重置为默认图">
<i class="bi bi-magic"></i><span class="btn-label">默认图</span>
</button>
</div>
<!-- outside .toolbar: the primary action must never scroll out of reach -->
<button type="button" class="btn btn-primary" id="btnSave" disabled title="保存并启用 Chatflow">
<i class="bi bi-check-lg"></i><span class="btn-label">保存并启用</span>
</button>
</div>
<div id="loginGate">
<div class="gate-card glass glass-strong glass-rim">
<span class="brand-mark" aria-hidden="true"><i class="bi bi-person-lock"></i></span>
<h2>需要登录</h2>
<p class="hint">请先在用户中心登录,再打开 Chatflow 编辑器。</p>
<a class="btn btn-primary" href="/app"><i class="bi bi-box-arrow-in-right"></i>前往用户中心登录</a>
</div>
</div>
<div class="layout" id="editorLayout">
<aside class="pane pane-palette" id="panePalette">
<h2 class="pane-title">节点</h2>
<!-- wrapper only carries the staggered entrance; [data-add] lookup is unaffected -->
<div class="stagger">
<button type="button" class="node-add" data-add="llm" data-type="llm">
<span class="dot"><i class="bi bi-cpu"></i></span>
<span>LLM<small>调用大模型生成文本</small></span>
</button>
<button type="button" class="node-add" data-add="answer" data-type="answer">
<span class="dot"><i class="bi bi-chat-left-text"></i></span>
<span>回复 Answer<small>输出最终回复内容</small></span>
</button>
<button type="button" class="node-add" data-add="search" data-type="search">
<span class="dot"><i class="bi bi-search"></i></span>
<span>联网搜索<small>检索外部资料</small></span>
</button>
<button type="button" class="node-add" data-add="memory" data-type="memory">
<span class="dot"><i class="bi bi-memory"></i></span>
<span>记忆<small>读取长期记忆</small></span>
</button>
<button type="button" class="node-add" data-add="if-else" data-type="if-else">
<span class="dot"><i class="bi bi-signpost-split"></i></span>
<span>条件分支<small>按条件走 true / false</small></span>
</button>
<button type="button" class="node-add" data-add="http" data-type="http">
<span class="dot"><i class="bi bi-globe2"></i></span>
<span>HTTP<small>仅允许工具域出站</small></span>
</button>
</div>
<div class="tip-box">
<p class="hint">
拖拽节点移动画布上的位置,从右侧圆点拖到另一节点左侧即可连线;双击连线可删除。
空白处拖动可平移画布,<code>Ctrl</code> + 滚轮缩放。
</p>
</div>
<div class="tip-box">
<p class="hint">
LLM / 搜索只经 HF 工具服务出站;HTTP 默认仅允许 <code>TOOLS</code> 主机。
</p>
</div>
</aside>
<div class="canvas-wrap" id="canvasWrap">
<div class="canvas" id="canvas">
<svg class="edge-layer" id="edgeLayer" aria-hidden="true"></svg>
<!-- separate layer so the in-flight link isn't wiped by edge re-renders -->
<svg class="edge-layer link-layer" id="linkLayer" aria-hidden="true"></svg>
</div>
<div class="canvas-empty"><span>选择一个人设以载入流程图</span></div>
<div class="canvas-loading" aria-hidden="true">
<div class="sk-graph">
<span class="skeleton sk-node"></span>
<span class="skeleton sk-link"></span>
<span class="skeleton sk-node"></span>
<span class="skeleton sk-link"></span>
<span class="skeleton sk-node"></span>
</div>
</div>
<div class="canvas-hud glass-rim" role="group" aria-label="画布缩放">
<button type="button" id="btnZoomIn" title="放大" aria-label="放大"><i class="bi bi-plus-lg"></i></button>
<div class="zoom-val" id="zoomVal">100%</div>
<button type="button" id="btnZoomOut" title="缩小" aria-label="缩小"><i class="bi bi-dash-lg"></i></button>
<button type="button" id="btnZoomFit" title="适应画布" aria-label="适应画布"><i class="bi bi-arrows-angle-contract"></i></button>
</div>
</div>
<aside class="pane pane-props" id="paneProps">
<h2 class="pane-title">属性</h2>
<p id="propsEmpty" class="hint">选中一个节点以编辑其属性。</p>
<div id="propsForm" style="display:none"></div>
<h2 class="pane-title">连线</h2>
<div id="edgeList"><p class="hint"></p></div>
</aside>
</div>
<nav class="editor-fabs glass-rim" id="editorFabs" aria-label="面板切换">
<button type="button" id="fabPalette"><i class="bi bi-plus-square-dotted"></i>节点<span class="count" id="fabNodeCount">0</span></button>
<button type="button" id="fabProps"><i class="bi bi-sliders"></i>属性</button>
</nav>
<div class="sheet-backdrop" id="sheetBackdrop"></div>
<div class="toast glass-rim" id="toast" role="status" aria-live="polite"></div>
<script>
const $ = (id) => document.getElementById(id);
const qs = new URLSearchParams(location.search);
let personaId = qs.get("persona") || "";
let editable = false;
let graph = null;
let selectedId = null;
let drag = null;
let linkFrom = null;
/* ---------- theme ---------- */
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");
syncThemeTitle(pref);
if (graph) drawEdges();
}
function syncThemeTitle(pref) {
const btn = $("themeToggle");
if (!btn) return;
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", () => {
let stored = null;
try { stored = localStorage.getItem(THEME_KEY); } catch (_) {}
if (stored === "system" || !stored) applyTheme(stored || "system");
});
} catch (_) {}
(function () {
const meta = $("themeColor");
if (meta) meta.setAttribute("content", currentTheme() === "dark" ? "#000000" : "#f5f5f7");
syncThemeTitle(themePref());
})();
const TYPE_META = {
start: { label: "开始", icon: "bi-play-circle" },
llm: { label: "LLM", icon: "bi-cpu" },
answer: { label: "回复", icon: "bi-chat-left-text" },
search: { label: "搜索", icon: "bi-search" },
memory: { label: "记忆", icon: "bi-memory" },
"if-else": { label: "条件", icon: "bi-signpost-split" },
http: { label: "HTTP", icon: "bi-globe2" },
};
function toast(msg, err) {
const el = $("toast");
el.textContent = msg;
// keep glass-rim: a bare className assignment would strip the specular
// rim off the markup on the very first toast, permanently.
el.className = "toast glass-rim show" + (err ? " err" : "");
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove("show"), 2800);
}
/* Top progress bar: reference-counted so parallel calls don't clear it early. */
let apiInFlight = 0;
function bumpLoadBar(delta) {
apiInFlight = Math.max(0, apiInFlight + delta);
const bar = $("loadBar");
if (bar) bar.classList.toggle("on", apiInFlight > 0);
}
async function api(path, opts = {}) {
bumpLoadBar(1);
try {
const res = await fetch(path, {
credentials: "same-origin",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
method: opts.method || "GET",
body: opts.body != null ? JSON.stringify(opts.body) : undefined,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
if (!res.ok) {
const msg = data?.error || data?.message || res.statusText || "请求失败";
throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
}
return data;
} finally {
bumpLoadBar(-1);
}
}
function defaultGraph() {
return {
version: 1,
nodes: [
{ id: "start", type: "start", label: "开始", data: {}, position: { x: 80, y: 200 } },
{
id: "llm",
type: "llm",
label: "LLM",
data: {
system: "{{system_prompt}}",
prompt: "对话历史:\n{{history}}\n\n相关记忆:\n{{memories}}\n\n用户:{{query}}",
temperature: 0.8,
},
position: { x: 320, y: 180 },
},
{
id: "answer",
type: "answer",
label: "回复",
data: { answer: "{{llm.text}}" },
position: { x: 560, y: 200 },
},
],
edges: [
{ id: "e1", source: "start", target: "llm" },
{ id: "e2", source: "llm", target: "answer" },
],
};
}
function ensurePositions(g) {
let i = 0;
for (const n of g.nodes) {
if (!n.position) {
n.position = { x: 80 + (i % 4) * 240, y: 80 + Math.floor(i / 4) * 160 };
}
if (!n.data) n.data = {};
i++;
}
return g;
}
function uid(prefix) {
return prefix + "_" + Math.random().toString(36).slice(2, 8);
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/* ---------- viewport (pan + zoom) ---------- */
const view = { x: 24, y: 24, scale: 1 };
const MIN_SCALE = 0.35;
const MAX_SCALE = 2.2;
const GRID = 22;
function applyView() {
const canvas = $("canvas");
canvas.style.transform =
`translate(${view.x}px, ${view.y}px) scale(${view.scale})`;
const wrap = $("canvasWrap");
wrap.style.setProperty("--grid-size", GRID * view.scale + "px");
wrap.style.setProperty("--grid-x", (view.x % (GRID * view.scale)) + "px");
wrap.style.setProperty("--grid-y", (view.y % (GRID * view.scale)) + "px");
$("zoomVal").textContent = Math.round(view.scale * 100) + "%";
}
/** Zoom around a point given in canvas-wrap client coordinates. */
function zoomAt(clientX, clientY, nextScale) {
const rect = $("canvasWrap").getBoundingClientRect();
const px = clientX - rect.left;
const py = clientY - rect.top;
const s = Math.min(MAX_SCALE, Math.max(MIN_SCALE, nextScale));
if (s === view.scale) return;
view.x = px - ((px - view.x) / view.scale) * s;
view.y = py - ((py - view.y) / view.scale) * s;
view.scale = s;
applyView();
}
function zoomCenter(factor) {
const rect = $("canvasWrap").getBoundingClientRect();
zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, view.scale * factor);
}
function zoomToFit() {
if (!graph || !graph.nodes.length) return;
const wrap = $("canvasWrap");
const rect = wrap.getBoundingClientRect();
if (!rect.width || !rect.height) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of graph.nodes) {
const el = nodeEl(n.id);
const w = el ? el.offsetWidth : 184;
const h = el ? el.offsetHeight : 84;
const x = n.position?.x || 0;
const y = n.position?.y || 0;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + w);
maxY = Math.max(maxY, y + h);
}
const pad = isCompact() ? 20 : 48;
const gw = Math.max(1, maxX - minX);
const gh = Math.max(1, maxY - minY);
// Never shrink past FIT_MIN — an unreadable "everything fits" view is worse
// than a legible one the user pans around.
const FIT_MIN = 0.62;
const s = Math.min(
MAX_SCALE,
Math.max(FIT_MIN, Math.min((rect.width - pad * 2) / gw, (rect.height - pad * 2) / gh)),
);
view.scale = s;
view.x = (rect.width - gw * s) / 2 - minX * s;
view.y = (rect.height - gh * s) / 2 - minY * s;
applyView();
}
$("btnZoomIn").onclick = () => zoomCenter(1.2);
$("btnZoomOut").onclick = () => zoomCenter(1 / 1.2);
$("btnZoomFit").onclick = () => zoomToFit();
/* Wheel: ctrl/⌘ + wheel (or trackpad pinch) zooms, plain wheel pans. */
$("canvasWrap").addEventListener(
"wheel",
(ev) => {
ev.preventDefault();
if (ev.ctrlKey || ev.metaKey) {
zoomAt(ev.clientX, ev.clientY, view.scale * Math.exp(-ev.deltaY * 0.0022));
} else {
view.x -= ev.deltaX;
view.y -= ev.deltaY;
applyView();
}
},
{ passive: false },
);
/* Pan by dragging empty canvas; pinch-zoom with two pointers. */
const pointers = new Map();
let pan = null;
let pinch = null;
$("canvasWrap").addEventListener("pointerdown", (ev) => {
pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
if (pointers.size === 2) {
pan = null;
const [a, b] = [...pointers.values()];
pinch = {
dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
scale: view.scale,
cx: (a.x + b.x) / 2,
cy: (a.y + b.y) / 2,
};
return;
}
if (ev.target.closest(".node") || ev.target.closest(".canvas-hud")) return;
pan = { ox: ev.clientX, oy: ev.clientY, sx: view.x, sy: view.y };
$("canvasWrap").classList.add("panning");
$("canvasWrap").setPointerCapture?.(ev.pointerId);
if (selectedId) {
selectedId = null;
applySelection();
}
});
$("canvasWrap").addEventListener("pointermove", (ev) => {
if (pointers.has(ev.pointerId)) pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
if (pinch && pointers.size === 2) {
const [a, b] = [...pointers.values()];
const d = Math.hypot(a.x - b.x, a.y - b.y) || 1;
zoomAt(pinch.cx, pinch.cy, pinch.scale * (d / pinch.dist));
return;
}
if (!pan) return;
view.x = pan.sx + (ev.clientX - pan.ox);
view.y = pan.sy + (ev.clientY - pan.oy);
applyView();
});
function endPointer(ev) {
pointers.delete(ev.pointerId);
if (pointers.size < 2) pinch = null;
if (pan) {
pan = null;
$("canvasWrap").classList.remove("panning");
}
}
$("canvasWrap").addEventListener("pointerup", endPointer);
$("canvasWrap").addEventListener("pointercancel", endPointer);
/* ---------- render ---------- */
function nodeEl(id) {
return $("canvas").querySelector(`.node[data-id="${CSS.escape(String(id))}"]`);
}
/** Edge anchor in canvas coordinates; measured from the live element. */
function anchor(id, side, handle) {
const n = graph.nodes.find((x) => x.id === id);
if (!n) return { x: 0, y: 0 };
const el = nodeEl(id);
const w = el ? el.offsetWidth : 184;
const h = el ? el.offsetHeight : 84;
let ratio = 0.5;
if (side === "out" && handle === "true") ratio = 0.35;
if (side === "out" && handle === "false") ratio = 0.65;
return {
x: (n.position?.x || 0) + (side === "in" ? 0 : w),
y: (n.position?.y || 0) + h * ratio,
};
}
function render() {
if (!graph) return;
const canvas = $("canvas");
canvas.querySelectorAll(".node").forEach((el) => el.remove());
for (const n of graph.nodes) {
const el = document.createElement("div");
el.className = "node" + (n.id === selectedId ? " selected" : "");
el.dataset.id = n.id;
el.dataset.type = n.type;
el.style.left = (n.position?.x || 0) + "px";
el.style.top = (n.position?.y || 0) + "px";
const meta = TYPE_META[n.type] || { label: n.type, icon: "bi-box" };
const preview =
n.type === "llm"
? (n.data?.prompt || "").slice(0, 48)
: n.type === "answer"
? (n.data?.answer || "").slice(0, 48)
: n.type === "search"
? (n.data?.query || "{{query}}")
: n.type === "if-else"
? (n.data?.condition || "")
: n.type === "http"
? (n.data?.url || "")
: n.id;
el.innerHTML =
`<div class="head"><span class="ttl"><i class="bi ${meta.icon}" aria-hidden="true"></i>${esc(n.label || meta.label)}</span><span class="kind">${esc(n.type)}</span></div>` +
`<div class="body">${esc(preview || "—")}</div>` +
(n.type !== "start" ? `<span class="port in" data-port="in"></span>` : "") +
(n.type === "answer"
? ""
: n.type === "if-else"
? `<span class="port out true" data-port="true" title="true"></span><span class="port out false" data-port="false" title="false"></span>`
: `<span class="port out" data-port="out"></span>`);
el.addEventListener("pointerdown", onNodePointerDown);
canvas.appendChild(el);
}
drawEdges();
renderProps();
renderEdgeList();
$("canvasWrap").classList.toggle("is-empty", !graph.nodes.length);
$("fabNodeCount").textContent = String(graph.nodes.length);
// mark the inspector when a node is selected so the FAB hints at content
$("fabProps").classList.toggle("has-sel", Boolean(selectedId));
}
/**
* Selection-only update. A full render() would replace the node elements —
* including the one that just took pointer capture — which breaks dragging.
*/
function applySelection() {
$("canvas")
.querySelectorAll(".node")
.forEach((el) => el.classList.toggle("selected", el.dataset.id === selectedId));
renderProps();
$("fabProps").classList.toggle("has-sel", Boolean(selectedId));
}
function drawEdges() {
if (!graph) return;
const svg = $("edgeLayer");
const parts = [];
for (const e of graph.edges) {
const a = anchor(e.source, "out", e.sourceHandle);
const b = anchor(e.target, "in");
const mx = (a.x + b.x) / 2;
const path = `M ${a.x} ${a.y} C ${mx} ${a.y}, ${mx} ${b.y}, ${b.x} ${b.y}`;
const label = e.sourceHandle || "";
const stroke =
label === "true" ? "var(--green)" : label === "false" ? "var(--orange)" : "var(--blue)";
parts.push(
`<path d="${path}" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="round" opacity=".8" data-edge="${esc(e.id)}" style="pointer-events:stroke;cursor:pointer" />`,
);
if (label) {
parts.push(
`<text class="edge-label" x="${mx}" y="${(a.y + b.y) / 2 - 6}" text-anchor="middle">${esc(label)}</text>`,
);
}
}
svg.innerHTML = parts.join("");
svg.querySelectorAll("path[data-edge]").forEach((p) => {
p.addEventListener("dblclick", () => {
if (!editable) return;
const id = p.getAttribute("data-edge");
graph.edges = graph.edges.filter((e) => e.id !== id);
render();
toast("已删除连线");
});
});
}
function onNodePointerDown(ev) {
const el = ev.currentTarget;
const id = el.dataset.id;
selectedId = id;
if (ev.target.classList.contains("port") && ev.target.dataset.port !== "in") {
if (!editable) return;
ev.stopPropagation();
linkFrom = { id, handle: ev.target.dataset.port === "out" ? null : ev.target.dataset.port };
$("canvasWrap").classList.add("linking");
el.setPointerCapture?.(ev.pointerId);
return;
}
if (!editable) {
applySelection();
return;
}
ev.stopPropagation();
const n = graph.nodes.find((x) => x.id === id);
drag = { id, ox: ev.clientX, oy: ev.clientY, sx: n.position.x, sy: n.position.y };
el.setPointerCapture?.(ev.pointerId);
applySelection();
}
/** Client coords -> canvas coords (undo pan + zoom). */
function toCanvas(clientX, clientY) {
const r = $("canvasWrap").getBoundingClientRect();
return {
x: (clientX - r.left - view.x) / view.scale,
y: (clientY - r.top - view.y) / view.scale,
};
}
function drawLinkPreview(clientX, clientY) {
if (!linkFrom) return;
const a = anchor(linkFrom.id, "out", linkFrom.handle);
const b = toCanvas(clientX, clientY);
const mx = (a.x + b.x) / 2;
$("linkLayer").innerHTML =
`<path class="link-preview" d="M ${a.x} ${a.y} C ${mx} ${a.y}, ${mx} ${b.y}, ${b.x} ${b.y}" />`;
}
function clearLinkPreview() {
$("linkLayer").innerHTML = "";
$("canvasWrap").classList.remove("linking");
}
window.addEventListener("pointermove", (ev) => {
if (linkFrom) {
drawLinkPreview(ev.clientX, ev.clientY);
return;
}
if (!drag || !graph) return;
const n = graph.nodes.find((x) => x.id === drag.id);
if (!n) return;
// client-space delta -> canvas space (undo the viewport scale)
n.position.x = Math.max(0, drag.sx + (ev.clientX - drag.ox) / view.scale);
n.position.y = Math.max(0, drag.sy + (ev.clientY - drag.oy) / view.scale);
const el = nodeEl(drag.id);
if (el) {
el.style.left = n.position.x + "px";
el.style.top = n.position.y + "px";
}
drawEdges();
});
window.addEventListener("pointerup", (ev) => {
if (linkFrom) {
clearLinkPreview();
const t = document.elementFromPoint(ev.clientX, ev.clientY);
const el = t?.closest?.(".node");
if (el && el.dataset.id !== linkFrom.id) {
const target = el.dataset.id;
if (graph.nodes.find((n) => n.id === target)?.type === "start") {
toast("不能连到 start", true);
} else {
graph.edges.push({
id: uid("e"),
source: linkFrom.id,
target,
sourceHandle: linkFrom.handle,
});
toast("已连接");
}
}
linkFrom = null;
render();
}
drag = null;
});
window.addEventListener("pointercancel", () => {
if (linkFrom) {
clearLinkPreview();
linkFrom = null;
render();
}
drag = null;
});
/* ---------- props ---------- */
function renderProps() {
const empty = $("propsEmpty");
const form = $("propsForm");
const n = selectedId && graph ? graph.nodes.find((x) => x.id === selectedId) : null;
if (!n) {
empty.style.display = "block";
form.style.display = "none";
return;
}
empty.style.display = "none";
form.style.display = "block";
const disabled = editable ? "" : "disabled";
let fields = `
<div class="field">ID<input value="${esc(n.id)}" disabled /></div>
<div class="field">类型<input value="${esc(n.type)}" disabled /></div>
<div class="field">标签<input id="pLabel" value="${esc(n.label || "")}" ${disabled} /></div>`;
if (n.type === "llm") {
fields += `
<div class="field">System<textarea id="pSystem" ${disabled}>${esc(n.data.system || "")}</textarea></div>
<div class="field">User prompt<textarea id="pPrompt" ${disabled}>${esc(n.data.prompt || "")}</textarea></div>`;
} else if (n.type === "answer") {
fields += `<div class="field">Answer 模板<textarea id="pAnswer" ${disabled}>${esc(n.data.answer || "")}</textarea></div>`;
} else if (n.type === "search") {
fields += `<div class="field">Query 模板<input id="pQuery" value="${esc(n.data.query || "{{query}}")}" ${disabled} /></div>`;
} else if (n.type === "if-else") {
fields += `<div class="field">条件(如 query contains 天气)<input id="pCond" value="${esc(n.data.condition || "")}" ${disabled} /></div>`;
} else if (n.type === "http") {
fields += `
<div class="field">URL(须在 allowlist<input id="pUrl" value="${esc(n.data.url || "")}" ${disabled} /></div>
<div class="field">Method
<select id="pMethod" ${disabled}>
${["GET", "POST", "PUT"].map((m) => `<option ${((n.data.method || "POST") === m) ? "selected" : ""}>${m}</option>`).join("")}
</select>
</div>
<div class="field">Body 模板<textarea id="pBody" ${disabled}>${esc(n.data.body || "")}</textarea></div>`;
}
if (editable && n.type !== "start") {
fields += `<button type="button" class="btn btn-danger btn-sm" id="pDel" style="margin-top:4px"><i class="bi bi-trash3"></i>删除节点</button>`;
}
form.innerHTML = fields;
const bind = (id, fn) => {
const el = document.getElementById(id);
if (el) el.onchange = el.oninput = fn;
};
bind("pLabel", () => {
n.label = $("pLabel")?.value || "";
render();
selectedId = n.id;
});
bind("pSystem", () => { n.data.system = $("pSystem").value; });
bind("pPrompt", () => { n.data.prompt = $("pPrompt").value; });
bind("pAnswer", () => { n.data.answer = $("pAnswer").value; });
bind("pQuery", () => { n.data.query = $("pQuery").value; });
bind("pCond", () => { n.data.condition = $("pCond").value; });
bind("pUrl", () => { n.data.url = $("pUrl").value; });
bind("pMethod", () => { n.data.method = $("pMethod").value; });
bind("pBody", () => { n.data.body = $("pBody").value; });
const del = $("pDel");
if (del) {
del.onclick = () => {
if (n.type === "start") return;
graph.nodes = graph.nodes.filter((x) => x.id !== n.id);
graph.edges = graph.edges.filter((e) => e.source !== n.id && e.target !== n.id);
selectedId = null;
render();
};
}
}
function renderEdgeList() {
const el = $("edgeList");
if (!graph?.edges?.length) {
el.innerHTML = `<p class="hint">还没有连线。</p>`;
return;
}
el.innerHTML = graph.edges
.map(
(e) =>
`<div class="edge-item">` +
`<span class="ends">${esc(e.source)}${esc(e.target)}</span>` +
(e.sourceHandle ? `<span class="handle-tag">${esc(e.sourceHandle)}</span>` : "") +
(editable
? `<button type="button" data-rm-edge="${esc(e.id)}" title="删除连线" aria-label="删除连线"><i class="bi bi-x-lg"></i></button>`
: "") +
`</div>`,
)
.join("");
el.querySelectorAll("[data-rm-edge]").forEach((btn) => {
btn.onclick = () => {
graph.edges = graph.edges.filter((e) => e.id !== btn.dataset.rmEdge);
render();
};
});
}
/* ---------- add nodes ---------- */
function addNode(type) {
if (!editable || !graph) {
toast(personaId ? "无编辑权限" : "请先选择人设", true);
return;
}
if (type === "start" && graph.nodes.some((n) => n.type === "start")) {
toast("只能有一个 start", true);
return;
}
const id = uid(type.replace("-", ""));
const defaults = {
llm: { system: "{{system_prompt}}", prompt: "{{query}}" },
answer: { answer: "{{llm_text}}" },
search: { query: "{{query}}" },
memory: {},
"if-else": { condition: 'query contains ""' },
http: { url: "", method: "POST", body: '{"query":"{{query}}"}' },
};
graph.nodes.push({
id,
type,
label: (TYPE_META[type] || {}).label || type,
data: { ...(defaults[type] || {}) },
position: { x: 120 + graph.nodes.length * 24, y: 120 + graph.nodes.length * 16 },
});
selectedId = id;
render();
if (isCompact()) openSheet("props");
}
document.querySelectorAll("[data-add]").forEach((btn) => {
btn.onclick = () => addNode(btn.dataset.add);
});
/* ---------- mobile sheets ---------- */
const compactMQ = matchMedia("(max-width: 900px)");
const isCompact = () => compactMQ.matches;
let openedSheet = null;
function openSheet(which) {
closeSheet();
const pane = which === "palette" ? $("panePalette") : $("paneProps");
pane.setAttribute("data-sheet-open", "");
document.documentElement.classList.add("sheet-open");
openedSheet = which;
(which === "palette" ? $("fabPalette") : $("fabProps")).classList.add("on");
}
function closeSheet() {
document.documentElement.classList.remove("sheet-open");
$("panePalette").removeAttribute("data-sheet-open");
$("paneProps").removeAttribute("data-sheet-open");
$("fabPalette").classList.remove("on");
$("fabProps").classList.remove("on");
openedSheet = null;
}
function toggleSheet(which) {
if (openedSheet === which) closeSheet();
else openSheet(which);
}
$("fabPalette").onclick = () => toggleSheet("palette");
$("fabProps").onclick = () => toggleSheet("props");
$("sheetBackdrop").onclick = closeSheet;
compactMQ.addEventListener("change", (e) => { if (!e.matches) closeSheet(); });
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && openedSheet) closeSheet();
});
/* ---------- data ---------- */
async function loadPersonas() {
const data = await api("/api/v1/me/personas");
const created = data.created || [];
const sel = $("personaSelect");
sel.innerHTML =
`<option value="">选择人设…</option>` +
created
.map(
(p) =>
`<option value="${esc(p.id)}">${esc(p.displayName || p.display_name || p.slug)} (${esc(p.mode || "prompt")})</option>`,
)
.join("");
if (personaId) sel.value = personaId;
}
function setState(text, kind) {
$("headerSub").textContent = text;
const pill = $("statePill");
if (!kind) {
pill.hidden = true;
return;
}
pill.hidden = false;
pill.className = "state-pill " + kind;
pill.innerHTML =
kind === "ok"
? `<i class="bi bi-pencil-square"></i>可编辑`
: `<i class="bi bi-eye"></i>只读`;
}
async function loadGraph() {
if (!personaId) {
graph = ensurePositions(defaultGraph());
editable = false;
$("btnSave").disabled = true;
setState("请选择人设", null);
selectedId = null;
render();
zoomToFit();
return;
}
// skeleton graph while the fetch is in flight
const wrap = $("canvasWrap");
let data;
wrap.classList.add("is-loading");
try {
data = await api("/api/v1/square/personas/" + encodeURIComponent(personaId) + "/graph");
} finally {
wrap.classList.remove("is-loading");
}
graph = ensurePositions(data.graph || defaultGraph());
editable = Boolean(data.editable);
$("btnSave").disabled = !editable;
setState(
(data.mode || "chatflow") + (data.isDefault ? " · 默认图(未保存)" : ""),
editable ? "ok" : "ro",
);
selectedId = null;
render();
zoomToFit();
}
$("personaSelect").onchange = async () => {
personaId = $("personaSelect").value;
const url = new URL(location.href);
if (personaId) url.searchParams.set("persona", personaId);
else url.searchParams.delete("persona");
history.replaceState(null, "", url);
try {
await loadGraph();
} catch (e) {
toast(e.message, true);
}
};
$("btnReload").onclick = () => loadGraph().catch((e) => toast(e.message, true));
$("btnDefault").onclick = () => {
if (!editable) {
toast("无编辑权限", true);
return;
}
graph = ensurePositions(defaultGraph());
selectedId = null;
render();
zoomToFit();
toast("已重置为默认 start→llm→answer");
};
$("btnSave").onclick = async () => {
if (!personaId || !editable) return;
const btn = $("btnSave");
btn.disabled = true;
btn.classList.add("is-busy");
try {
// strip UI-only noise? keep position for round-trip
const payload = {
version: 1,
nodes: graph.nodes.map((n) => ({
id: n.id,
type: n.type,
label: n.label,
data: n.data || {},
position: n.position,
})),
edges: graph.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle || null,
})),
};
await api("/api/v1/square/personas/" + encodeURIComponent(personaId) + "/graph", {
method: "PUT",
body: { graph: payload, mode: "chatflow" },
});
toast("已保存,人设已切换为 chatflow");
await loadPersonas();
$("personaSelect").value = personaId;
await loadGraph();
} catch (e) {
toast(e.message, true);
} finally {
btn.classList.remove("is-busy");
btn.disabled = !editable;
}
};
// Re-fit when the viewport changes shape (rotation, window resize, sheet open).
let fitTimer = null;
window.addEventListener("resize", () => {
clearTimeout(fitTimer);
fitTimer = setTimeout(applyView, 120);
});
applyView();
(async () => {
try {
// Independent endpoints — one wave instead of two hops on first paint
await Promise.all([loadPersonas(), loadGraph()]);
} catch (e) {
const msg = e.message || "";
if (/login|session|401|unauthorized/i.test(msg) || msg === "login_required" || msg === "session_expired") {
document.documentElement.classList.add("is-locked");
$("editorLayout").style.display = "none";
return;
}
toast(msg, true);
}
})();
/* 只有 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) {}
})();
</script>
</body>
</html>