mirror of
https://github.com/SMNETSTUDIO/WeChat-AI.git
synced 2026-08-12 22:23:42 +08:00
2215 lines
81 KiB
HTML
2215 lines
81 KiB
HTML
<!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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
/* ---------- 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>
|