feat(app-instance): 添加Outlook MCP调用超时配置选项

新增OUTLOOK_MCP_CALL_TIMEOUT_SECONDS环境变量,默认值为60秒,
用于控制后端等待Outlook MCP调用的超时时间。

在create-instance.sh脚本中添加了相应的命令行参数解析和处理逻辑,
同时更新了deploy-control组件的相关配置和测试用例。

BREAKING CHANGE: 新增配置项可能需要现有部署进行相应调整。
```
This commit is contained in:
2026-06-09 14:23:37 +08:00
parent dc4c6f313d
commit 9cc3334ea7
11 changed files with 2185 additions and 0 deletions

View File

@ -18,6 +18,7 @@ AUTHZ_BASE_URL=""
AUTHZ_INTERNAL_TOKEN=""
AUTHZ_OUTLOOK_MCP_URL=""
OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}"
OUTLOOK_MCP_CALL_TIMEOUT_SECONDS="${OUTLOOK_MCP_CALL_TIMEOUT_SECONDS:-60}"
USER_FILES_MAX_UPLOAD_BYTES="${USER_FILES_MAX_UPLOAD_BYTES:-}"
EXTERNAL_CONNECTOR_BASE_URL="${EXTERNAL_CONNECTOR_BASE_URL:-http://external-connector:8787}"
EXTERNAL_CONNECTOR_TOKEN="${EXTERNAL_CONNECTOR_TOKEN:-}"
@ -76,6 +77,8 @@ Optional:
Managed Outlook MCP URL for AuthZ mode.
--outlook-mcp-server-id <id>
Default Outlook MCP server id. Default: outlook_mcp
--outlook-mcp-call-timeout-seconds <seconds>
Backend wait timeout for Outlook MCP calls. Default: 60
--user-files-max-upload-bytes <bytes>
Optional max upload size for the user file system.
--external-connector-base-url <url>
@ -557,6 +560,10 @@ while [[ $# -gt 0 ]]; do
OUTLOOK_MCP_SERVER_ID="${2:-}"
shift 2
;;
--outlook-mcp-call-timeout-seconds)
OUTLOOK_MCP_CALL_TIMEOUT_SECONDS="${2:-}"
shift 2
;;
--user-files-max-upload-bytes)
USER_FILES_MAX_UPLOAD_BYTES="${2:-}"
shift 2
@ -774,6 +781,7 @@ RUN_ARGS=(
-e "APP_BACKEND_PORT=18080"
-e "BEAVER_ENABLE_SELF_RESTART=1"
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
-e "BEAVER_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS=${OUTLOOK_MCP_CALL_TIMEOUT_SECONDS}"
-e "EXTERNAL_CONNECTOR_BASE_URL=${EXTERNAL_CONNECTOR_BASE_URL}"
--label "beaver.instance.id=${INSTANCE_ID}"
--label "beaver.instance.slug=${INSTANCE_SLUG}"

View File

@ -16,6 +16,7 @@ APP_INSTANCE_API_BASE=
DEFAULT_AUTHZ_BASE_URL=http://beaver-authz-service:19090
DEFAULT_AUTHZ_OUTLOOK_MCP_URL=
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
DEFAULT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS=60
DEFAULT_USER_FILES_MAX_UPLOAD_BYTES=5368709120
DEFAULT_EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787
DEFAULT_EXTERNAL_CONNECTOR_TOKEN=

View File

@ -20,6 +20,7 @@
- `DEFAULT_AUTHZ_BASE_URL`
- `DEFAULT_AUTHZ_OUTLOOK_MCP_URL`
- `DEFAULT_OUTLOOK_MCP_SERVER_ID`
- `DEFAULT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS`
- `DEPLOY_PUBLIC_BASE_DOMAIN`
- `DEPLOY_PUBLIC_PORT`
- `DEPLOY_PUBLIC_SCHEME`
@ -42,6 +43,7 @@ http://<instance-slug>.localhost:8088
```bash
DEFAULT_AUTHZ_OUTLOOK_MCP_URL=http://10.6.80.29:8000/mcp
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
DEFAULT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS=60
```
这样 `deploy-control` 创建的新实例会自动写入一条默认 MCP server 配置,并默认使用 `oauth_backend_token` + `mcp:<server_id>` 的 audience。

View File

@ -41,6 +41,9 @@ DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip()
DEFAULT_AUTHZ_INTERNAL_TOKEN = os.environ.get("DEFAULT_AUTHZ_INTERNAL_TOKEN", "").strip()
DEFAULT_AUTHZ_OUTLOOK_MCP_URL = os.environ.get("DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "").strip()
DEFAULT_OUTLOOK_MCP_SERVER_ID = os.environ.get("DEFAULT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp").strip() or "outlook_mcp"
DEFAULT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS = (
os.environ.get("DEFAULT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "60").strip() or "60"
)
DEFAULT_USER_FILES_MAX_UPLOAD_BYTES = os.environ.get("DEFAULT_USER_FILES_MAX_UPLOAD_BYTES", "").strip()
DEFAULT_EXTERNAL_CONNECTOR_BASE_URL = os.environ.get(
"DEFAULT_EXTERNAL_CONNECTOR_BASE_URL",
@ -279,6 +282,7 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
if authz_outlook_mcp_url:
command.extend(["--authz-outlook-mcp-url", authz_outlook_mcp_url])
command.extend(["--outlook-mcp-server-id", DEFAULT_OUTLOOK_MCP_SERVER_ID])
command.extend(["--outlook-mcp-call-timeout-seconds", DEFAULT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS])
if DEFAULT_USER_FILES_MAX_UPLOAD_BYTES:
command.extend(["--user-files-max-upload-bytes", DEFAULT_USER_FILES_MAX_UPLOAD_BYTES])
if DEFAULT_EXTERNAL_CONNECTOR_BASE_URL:

View File

@ -35,6 +35,8 @@ def test_new_instance_receives_external_connector_configuration(monkeypatch) ->
monkeypatch.setattr(server, "DEFAULT_EXTERNAL_CONNECTOR_TOKEN", "connector-token")
monkeypatch.setattr(server, "DEFAULT_BEAVER_BRIDGE_TOKEN", "bridge-token")
monkeypatch.setattr(server, "DEFAULT_INITIAL_SKILLS_DIR", "/srv/beaver/skills")
monkeypatch.setattr(server, "DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "http://bw-outlook-mcp:8000/mcp")
monkeypatch.setattr(server, "DEFAULT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "60")
def capture_command(args: list[str], **_kwargs: Any) -> str:
commands.append(args)
@ -55,4 +57,5 @@ def test_new_instance_receives_external_connector_configuration(monkeypatch) ->
assert command[command.index("--external-connector-token") + 1] == "connector-token"
assert command[command.index("--bridge-token") + 1] == "bridge-token"
assert command[command.index("--initial-skills-dir") + 1] == "/srv/beaver/skills"
assert command[command.index("--outlook-mcp-call-timeout-seconds") + 1] == "60"
assert result["created"] is True

View File

@ -0,0 +1,138 @@
/* html-ppt :: animations.css
* Apply by adding class="anim-<name>" or data-anim="<name>".
* Durations are deliberately snappy; tweak --anim-dur per element.
*/
:root{--anim-dur:.7s;--anim-ease:cubic-bezier(.4,0,.2,1)}
/* ---------- FADE DIRECTIONALS ---------- */
@keyframes kf-fade-up{from{opacity:0;transform:translateY(32px)}to{opacity:1;transform:none}}
@keyframes kf-fade-down{from{opacity:0;transform:translateY(-32px)}to{opacity:1;transform:none}}
@keyframes kf-fade-left{from{opacity:0;transform:translateX(-40px)}to{opacity:1;transform:none}}
@keyframes kf-fade-right{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:none}}
.anim-fade-up{animation:kf-fade-up var(--anim-dur) var(--anim-ease) both}
.anim-fade-down{animation:kf-fade-down var(--anim-dur) var(--anim-ease) both}
.anim-fade-left{animation:kf-fade-left var(--anim-dur) var(--anim-ease) both}
.anim-fade-right{animation:kf-fade-right var(--anim-dur) var(--anim-ease) both}
/* ---------- RISE / DROP / ZOOM / BLUR / GLITCH ---------- */
@keyframes kf-rise{from{opacity:0;transform:translateY(60px) scale(.97);filter:blur(6px)}to{opacity:1;transform:none;filter:none}}
@keyframes kf-drop{from{opacity:0;transform:translateY(-60px) scale(.97)}to{opacity:1;transform:none}}
@keyframes kf-zoom{0%{opacity:0;transform:scale(.6)}60%{transform:scale(1.04)}100%{opacity:1;transform:scale(1)}}
@keyframes kf-blur{from{opacity:0;filter:blur(18px)}to{opacity:1;filter:none}}
@keyframes kf-glitch{0%{opacity:0;transform:translateX(0);clip-path:inset(0 0 0 0)}
20%{opacity:1;transform:translateX(-6px);clip-path:inset(20% 0 30% 0)}
40%{transform:translateX(4px);clip-path:inset(50% 0 10% 0)}
60%{transform:translateX(-3px);clip-path:inset(10% 0 60% 0)}
80%{transform:translateX(2px);clip-path:inset(0 0 0 0)}
100%{opacity:1;transform:none}}
.anim-rise-in{animation:kf-rise .9s var(--anim-ease) both}
.anim-drop-in{animation:kf-drop .8s var(--anim-ease) both}
.anim-zoom-pop{animation:kf-zoom .7s cubic-bezier(.22,1.3,.36,1) both}
.anim-blur-in{animation:kf-blur .8s var(--anim-ease) both}
.anim-glitch-in{animation:kf-glitch .8s steps(5,end) both}
/* ---------- TYPEWRITER ---------- */
.anim-typewriter{display:inline-block;overflow:hidden;white-space:nowrap;border-right:2px solid currentColor;
width:0;animation:kf-type 2.4s steps(40,end) forwards, kf-caret 1s step-end infinite}
@keyframes kf-type{to{width:100%}}
@keyframes kf-caret{50%{border-color:transparent}}
/* ---------- GLOW / SHIMMER / GRADIENT-FLOW ---------- */
@keyframes kf-neon{0%,100%{text-shadow:0 0 8px var(--accent),0 0 20px var(--accent)}
50%{text-shadow:0 0 16px var(--accent),0 0 40px var(--accent),0 0 80px var(--accent)}}
.anim-neon-glow{animation:kf-neon 2s ease-in-out infinite}
.anim-shimmer-sweep{position:relative;overflow:hidden}
.anim-shimmer-sweep::after{content:"";position:absolute;inset:0;
background:linear-gradient(110deg,transparent 40%,rgba(255,255,255,.55) 50%,transparent 60%);
transform:translateX(-100%);animation:kf-shimmer 2.4s var(--anim-ease) infinite}
@keyframes kf-shimmer{to{transform:translateX(100%)}}
.anim-gradient-flow{background:linear-gradient(90deg,var(--accent),var(--accent-2,var(--accent)),var(--accent-3,var(--accent)),var(--accent));
background-size:300% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent;
animation:kf-gradflow 4s linear infinite}
@keyframes kf-gradflow{to{background-position:300% 0}}
/* ---------- STAGGER LIST ---------- */
.anim-stagger-list > *{opacity:0;animation:kf-rise .65s var(--anim-ease) both}
.anim-stagger-list > *:nth-child(1){animation-delay:.05s}
.anim-stagger-list > *:nth-child(2){animation-delay:.15s}
.anim-stagger-list > *:nth-child(3){animation-delay:.25s}
.anim-stagger-list > *:nth-child(4){animation-delay:.35s}
.anim-stagger-list > *:nth-child(5){animation-delay:.45s}
.anim-stagger-list > *:nth-child(6){animation-delay:.55s}
.anim-stagger-list > *:nth-child(7){animation-delay:.65s}
.anim-stagger-list > *:nth-child(8){animation-delay:.75s}
.anim-stagger-list > *:nth-child(n+9){animation-delay:.85s}
/* ---------- COUNTER-UP (JS-driven, marker class only) ---------- */
.counter{font-variant-numeric:tabular-nums}
/* ---------- SVG PATH DRAW ---------- */
.anim-path-draw path,.anim-path-draw line,.anim-path-draw polyline,.anim-path-draw circle,.anim-path-draw rect{
stroke-dasharray:1000;stroke-dashoffset:1000;animation:kf-draw 2s var(--anim-ease) forwards}
@keyframes kf-draw{to{stroke-dashoffset:0}}
/* ---------- PARALLAX TILT (hover) ---------- */
.anim-parallax-tilt{transform-style:preserve-3d;transition:transform .4s var(--anim-ease)}
.anim-parallax-tilt:hover{transform:perspective(900px) rotateX(6deg) rotateY(-8deg) translateZ(10px)}
/* ---------- CARD FLIP 3D ---------- */
@keyframes kf-flip{from{transform:perspective(1200px) rotateY(-90deg);opacity:0}
to{transform:perspective(1200px) rotateY(0);opacity:1}}
.anim-card-flip-3d{animation:kf-flip .9s var(--anim-ease) both;transform-style:preserve-3d;backface-visibility:hidden}
/* ---------- CUBE ROTATE 3D ---------- */
@keyframes kf-cube{from{transform:perspective(1200px) rotateX(20deg) rotateY(-90deg) translateZ(-200px);opacity:0}
to{transform:perspective(1200px) rotateX(0) rotateY(0) translateZ(0);opacity:1}}
.anim-cube-rotate-3d{animation:kf-cube 1s var(--anim-ease) both}
/* ---------- PAGE TURN 3D ---------- */
@keyframes kf-pageturn{from{transform:perspective(1600px) rotateY(-85deg);transform-origin:left center;opacity:0}
to{transform:perspective(1600px) rotateY(0);opacity:1}}
.anim-page-turn-3d{animation:kf-pageturn 1s var(--anim-ease) both;transform-origin:left center}
/* ---------- PERSPECTIVE ZOOM ---------- */
@keyframes kf-pzoom{from{opacity:0;transform:perspective(1400px) translateZ(-400px) rotateX(12deg)}
to{opacity:1;transform:none}}
.anim-perspective-zoom{animation:kf-pzoom 1s var(--anim-ease) both}
/* ---------- MARQUEE SCROLL ---------- */
.anim-marquee-scroll{display:flex;gap:48px;white-space:nowrap;animation:kf-marquee 20s linear infinite}
@keyframes kf-marquee{from{transform:translateX(0)}to{transform:translateX(-50%)}}
/* ---------- KEN BURNS ---------- */
@keyframes kf-kenburns{0%{transform:scale(1) translate(0,0)}100%{transform:scale(1.15) translate(-2%,-1%)}}
.anim-kenburns{animation:kf-kenburns 14s ease-in-out infinite alternate}
/* ---------- CONFETTI BURST (pseudo — pure CSS sparkles) ---------- */
.anim-confetti-burst{position:relative}
.anim-confetti-burst::before,.anim-confetti-burst::after{
content:"";position:absolute;top:50%;left:50%;width:8px;height:8px;border-radius:50%;
background:var(--accent);box-shadow:
20px -30px 0 var(--accent-2,var(--accent)),-25px -20px 0 var(--accent-3,var(--accent)),
30px 20px 0 var(--good,#1aaf6c),-30px 25px 0 var(--warn,#f5a524),
40px -10px 0 var(--bad,#e0445a),-45px 0 0 var(--accent),
10px 40px 0 var(--accent-2,var(--accent)),-15px -40px 0 var(--accent-3,var(--accent));
opacity:0;animation:kf-confetti 1.2s var(--anim-ease) forwards}
.anim-confetti-burst::after{animation-delay:.15s;transform:rotate(45deg)}
@keyframes kf-confetti{0%{opacity:0;transform:scale(.2)}30%{opacity:1}100%{opacity:0;transform:scale(2.2)}}
/* ---------- SPOTLIGHT ---------- */
@keyframes kf-spot{0%{clip-path:circle(0% at 50% 50%)}100%{clip-path:circle(140% at 50% 50%)}}
.anim-spotlight{animation:kf-spot 1.1s var(--anim-ease) both}
/* ---------- MORPH SHAPE (SVG) ---------- */
.anim-morph-shape path{animation:kf-morph 6s ease-in-out infinite alternate}
@keyframes kf-morph{0%{d:path("M60,120 Q120,20 180,120 T300,120")}
100%{d:path("M60,120 Q120,220 180,120 T300,120")}}
/* ---------- RIPPLE REVEAL ---------- */
@keyframes kf-ripple{0%{clip-path:circle(0% at 20% 80%);opacity:.4}
100%{clip-path:circle(160% at 20% 80%);opacity:1}}
.anim-ripple-reveal{animation:kf-ripple 1.2s var(--anim-ease) both}
/* reduced motion */
@media (prefers-reduced-motion: reduce){
[class*="anim-"]{animation:none!important;transition:none!important}
}

View File

@ -0,0 +1,150 @@
/* html-ppt :: base.css — reset + shared tokens + layout primitives */
/* Default tokens. Themes in assets/themes/*.css override the :root block. */
:root {
--bg: #ffffff;
--bg-soft: #f7f7f8;
--surface: #ffffff;
--surface-2: #f2f2f4;
--border: rgba(0,0,0,.08);
--border-strong: rgba(0,0,0,.16);
--text-1: #111216;
--text-2: #55596a;
--text-3: #8a8f9e;
--accent: #3b6cff;
--accent-2: #7a5cff;
--accent-3: #ff5c8a;
--good: #1aaf6c;
--warn: #f5a524;
--bad: #e0445a;
--grad: linear-gradient(135deg,#3b6cff,#7a5cff 55%,#ff5c8a);
--grad-soft: linear-gradient(135deg,#eef2ff,#f5ecff 55%,#ffeef5);
--radius: 18px;
--radius-sm: 12px;
--radius-lg: 26px;
--shadow: 0 10px 30px rgba(18,24,40,.08), 0 2px 6px rgba(18,24,40,.04);
--shadow-lg: 0 24px 60px rgba(18,24,40,.14), 0 6px 16px rgba(18,24,40,.06);
--font-sans: 'Inter','Noto Sans SC',-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;
--font-serif: 'Playfair Display','Noto Serif SC',Georgia,serif;
--font-mono: 'JetBrains Mono','IBM Plex Mono',SFMono-Regular,Menlo,monospace;
--font-display: var(--font-sans);
--letter-tight: -.03em;
--letter-normal: -.01em;
--ease: cubic-bezier(.4,0,.2,1);
}
*,*::before,*::after{box-sizing:border-box}
html,body{margin:0;padding:0;background:var(--bg);color:var(--text-1);
font-family:var(--font-sans);font-weight:400;line-height:1.6;
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
letter-spacing:var(--letter-normal)}
img,svg,video{max-width:100%;display:block}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
code,kbd,pre,samp{font-family:var(--font-mono)}
/* ================= SLIDE SYSTEM ================= */
.deck{position:relative;width:100vw;height:100vh;overflow:hidden;background:var(--bg)}
.slide{
position:absolute;inset:0;
display:flex;flex-direction:column;justify-content:center;
padding:72px 96px;
box-sizing:border-box;
opacity:0;pointer-events:none;
transition:opacity .5s var(--ease), transform .5s var(--ease);
transform:translateX(30px);
overflow:hidden;
}
.slide.is-active{opacity:1;pointer-events:auto;transform:translateX(0);z-index:2}
.slide.is-prev{transform:translateX(-30px)}
/* single-page standalone (used when a layout file is opened directly) */
body.single .slide{position:relative;width:100vw;height:100vh;opacity:1;transform:none;pointer-events:auto}
/* ================= TYPOGRAPHY ================= */
.eyebrow{font-size:13px;font-weight:500;letter-spacing:.16em;text-transform:uppercase;color:var(--text-3)}
.kicker{font-size:14px;font-weight:600;color:var(--accent);letter-spacing:.08em;text-transform:uppercase}
h1.title,.h1{font-family:var(--font-display);font-size:72px;line-height:1.05;font-weight:800;letter-spacing:var(--letter-tight);margin:0 0 18px;color:var(--text-1)}
h2.title,.h2{font-family:var(--font-display);font-size:54px;line-height:1.1;font-weight:700;letter-spacing:var(--letter-tight);margin:0 0 14px}
h3,.h3{font-size:32px;line-height:1.2;font-weight:600;letter-spacing:var(--letter-normal);margin:0 0 10px}
h4,.h4{font-size:22px;line-height:1.3;font-weight:600;margin:0 0 8px}
.lede{font-size:22px;line-height:1.55;color:var(--text-2);font-weight:300;max-width:62ch}
.dim{color:var(--text-2)}
.dim2{color:var(--text-3)}
.mono{font-family:var(--font-mono)}
.serif{font-family:var(--font-serif)}
.gradient-text{background:var(--grad);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent}
/* ================= LAYOUT PRIMITIVES ================= */
.stack>*+*{margin-top:14px}
.row{display:flex;gap:24px;align-items:center}
.row.wrap{flex-wrap:wrap}
.grid{display:grid;gap:24px}
.g2{grid-template-columns:repeat(2,1fr)}
.g3{grid-template-columns:repeat(3,1fr)}
.g4{grid-template-columns:repeat(4,1fr)}
.center{display:flex;align-items:center;justify-content:center;text-align:center}
.fill{flex:1}
.sp-t{padding-top:24px}.sp-b{padding-bottom:24px}
.mt-s{margin-top:8px}.mt-m{margin-top:18px}.mt-l{margin-top:32px}
.mb-s{margin-bottom:8px}.mb-m{margin-bottom:18px}.mb-l{margin-bottom:32px}
/* ================= CARDS ================= */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
padding:26px 28px;box-shadow:var(--shadow);position:relative;overflow:hidden}
.card-soft{background:var(--surface-2);border:1px solid var(--border)}
.card-outline{background:transparent;border:1.5px solid var(--border-strong);box-shadow:none}
.card-accent{background:var(--surface);border-top:3px solid var(--accent)}
.card-hover{transition:transform .3s var(--ease),box-shadow .3s var(--ease)}
.card-hover:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg)}
.pill{display:inline-block;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:500;
background:var(--surface-2);color:var(--text-2);border:1px solid var(--border)}
.pill-accent{background:color-mix(in srgb,var(--accent) 12%,transparent);color:var(--accent);border-color:color-mix(in srgb,var(--accent) 28%,transparent)}
/* ================= BARS / DIVIDERS ================= */
.divider{height:1px;background:var(--border);width:100%}
.divider-accent{height:3px;width:72px;background:var(--accent);border-radius:2px}
/* ================= CHROME (header/footer/progress) ================= */
.deck-header{position:absolute;top:24px;left:40px;right:40px;display:flex;align-items:center;justify-content:space-between;
font-size:12px;color:var(--text-3);letter-spacing:.12em;text-transform:uppercase;z-index:10;pointer-events:none}
.deck-footer{position:absolute;bottom:24px;left:40px;right:40px;display:flex;align-items:center;justify-content:space-between;
font-size:12px;color:var(--text-3);z-index:10;pointer-events:none}
.slide-number::before{content:attr(data-current)}
.slide-number::after{content:" / " attr(data-total)}
.progress-bar{position:fixed;left:0;right:0;bottom:0;height:3px;background:transparent;z-index:20}
.progress-bar > span{display:block;height:100%;width:0;background:var(--accent);transition:width .3s var(--ease)}
/* ================= PRESENTER / OVERVIEW ================= */
.notes{display:none!important}
.notes-overlay{position:fixed;inset:auto 0 0 0;max-height:42vh;background:rgba(20,22,30,.95);color:#e8ebf4;
padding:20px 32px;font-size:16px;line-height:1.6;border-top:1px solid rgba(255,255,255,.1);transform:translateY(100%);
transition:transform .3s var(--ease);z-index:40;overflow:auto;font-family:var(--font-sans)}
.notes-overlay.open{transform:translateY(0)}
.overview{position:fixed;inset:0;background:rgba(10,12,18,.92);backdrop-filter:blur(12px);z-index:50;
display:none;padding:40px;overflow:auto}
.overview.open{display:grid;grid-template-columns:repeat(4,1fr);gap:20px;align-content:start}
.overview .thumb{background:var(--surface);border:1px solid var(--border);border-radius:12px;
aspect-ratio:16/9;overflow:hidden;cursor:pointer;position:relative;color:var(--text-1);padding:16px;
font-size:11px;transition:transform .2s var(--ease)}
.overview .thumb:hover{transform:scale(1.04)}
.overview .thumb .n{position:absolute;top:8px;left:10px;font-weight:700;font-size:14px;color:var(--text-3)}
.overview .thumb .t{position:absolute;bottom:10px;left:14px;right:14px;font-weight:600;color:var(--text-1)}
/* ================= PRESENTER VIEW ================= */
/* Presenter view opens in a separate popup window (S key).
* All presenter styles are self-contained in the popup HTML generated by runtime.js.
* The audience window (this file) is NOT affected — it stays as normal deck view.
* Only the .notes class below is needed to hide speaker notes from audience. */
/* ================= UTILITY ================= */
.hidden{display:none!important}
.nowrap{white-space:nowrap}
.tr{text-align:right}.tc{text-align:center}.tl{text-align:left}
.uppercase{text-transform:uppercase;letter-spacing:.12em}
/* ================= PRINT ================= */
@media print{
.slide{position:relative;opacity:1!important;transform:none!important;page-break-after:always;height:100vh}
.deck-header,.deck-footer,.progress-bar,.notes-overlay,.overview{display:none!important}
}

View File

@ -0,0 +1,9 @@
/* html-ppt :: shared webfonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@200;300;400;500;600;700;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;400;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,800;1,400&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&display=swap');

View File

@ -0,0 +1,960 @@
/* html-ppt :: runtime.js
* Keyboard-driven deck runtime. Zero dependencies.
*
* Features:
* ← → / space / PgUp PgDn / Home End navigation
* F fullscreen
* S presenter mode (opens a NEW WINDOW with current/next slide preview + notes + timer)
* The original window stays as audience view, synced via BroadcastChannel.
* Slide previews use CSS transform:scale() at design resolution for pixel-perfect layout.
* N quick notes overlay (bottom drawer)
* O slide overview grid
* T cycle themes (reads data-themes on <html> or <body>)
* A cycle demo animation on current slide
* URL hash #/N deep-link to slide N (1-based)
* Progress bar auto-managed
*/
(function () {
'use strict';
const ANIMS = ['fade-up','fade-down','fade-left','fade-right','rise-in','drop-in',
'zoom-pop','blur-in','glitch-in','typewriter','neon-glow','shimmer-sweep',
'gradient-flow','stagger-list','counter-up','path-draw','parallax-tilt',
'card-flip-3d','cube-rotate-3d','page-turn-3d','perspective-zoom',
'marquee-scroll','kenburns','confetti-burst','spotlight','morph-shape','ripple-reveal'];
function ready(fn){ if(document.readyState!='loading')fn(); else document.addEventListener('DOMContentLoaded',fn);}
/* ========== Parse URL for preview-only mode ==========
* When loaded as iframe.src = "index.html?preview=3", runtime enters a
* locked single-slide mode: only slide N is visible, no chrome, no keys,
* no hash updates. This is how the presenter window shows pixel-perfect
* previews — by loading the actual deck file in an iframe and telling it
* to display only a specific slide.
*/
function getPreviewIdx() {
const m = /[?&]preview=(\d+)/.exec(location.search || '');
return m ? parseInt(m[1], 10) - 1 : -1;
}
ready(function () {
const deck = document.querySelector('.deck');
if (!deck) return;
const slides = Array.from(deck.querySelectorAll('.slide'));
if (!slides.length) return;
const previewOnlyIdx = getPreviewIdx();
const isPreviewMode = previewOnlyIdx >= 0 && previewOnlyIdx < slides.length;
/* ===== Preview-only mode: show one slide, hide everything else ===== */
if (isPreviewMode) {
function showSlide(i) {
slides.forEach((s, j) => {
const active = (j === i);
s.classList.toggle('is-active', active);
s.style.display = active ? '' : 'none';
if (active) {
s.style.opacity = '1';
s.style.transform = 'none';
s.style.pointerEvents = 'auto';
}
});
}
showSlide(previewOnlyIdx);
/* Hide chrome that the presenter shouldn't see in preview */
const hideSel = '.progress-bar, .notes-overlay, .overview, .notes, aside.notes, .speaker-notes';
document.querySelectorAll(hideSel).forEach(el => { el.style.display = 'none'; });
document.documentElement.setAttribute('data-preview', '1');
document.body.setAttribute('data-preview', '1');
/* Auto-detect theme base path for theme switching in preview mode */
function getPreviewThemeBase() {
const base = document.documentElement.getAttribute('data-theme-base');
if (base) return base;
const tl = document.getElementById('theme-link');
if (tl) {
const raw = tl.getAttribute('href') || '';
const ls = raw.lastIndexOf('/');
if (ls >= 0) return raw.substring(0, ls + 1);
}
return 'assets/themes/';
}
const previewThemeBase = getPreviewThemeBase();
/* Listen for postMessage from parent presenter window:
* - preview-goto: switch visible slide WITHOUT reloading
* - preview-theme: switch theme CSS link to match audience window */
window.addEventListener('message', function(e) {
if (!e.data) return;
if (e.data.type === 'preview-goto') {
const n = parseInt(e.data.idx, 10);
if (n >= 0 && n < slides.length) showSlide(n);
} else if (e.data.type === 'preview-theme' && e.data.name) {
let link = document.getElementById('theme-link');
if (!link) {
link = document.createElement('link');
link.rel = 'stylesheet';
link.id = 'theme-link';
document.head.appendChild(link);
}
link.href = previewThemeBase + e.data.name + '.css';
document.documentElement.setAttribute('data-theme', e.data.name);
}
});
/* Signal to parent that preview iframe is ready */
try { window.parent && window.parent.postMessage({ type: 'preview-ready' }, '*'); } catch(e) {}
return;
}
let idx = 0;
const total = slides.length;
/* ===== BroadcastChannel for presenter sync ===== */
const CHANNEL_NAME = 'html-ppt-presenter-' + location.pathname;
let bc;
try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) { bc = null; }
// Are we running inside the presenter popup? (legacy flag, now unused)
const isPresenterWindow = false;
/* ===== progress bar ===== */
let bar = document.querySelector('.progress-bar');
if (!bar) {
bar = document.createElement('div');
bar.className = 'progress-bar';
bar.innerHTML = '<span></span>';
document.body.appendChild(bar);
}
const barFill = bar.querySelector('span');
/* ===== notes overlay (N key) ===== */
let notes = document.querySelector('.notes-overlay');
if (!notes) {
notes = document.createElement('div');
notes.className = 'notes-overlay';
document.body.appendChild(notes);
}
/* ===== overview grid (O key) ===== */
let overview = document.querySelector('.overview');
if (!overview) {
overview = document.createElement('div');
overview.className = 'overview';
slides.forEach((s, i) => {
const t = document.createElement('div');
t.className = 'thumb';
// Force 16:9 aspect ratio robustly
t.style.padding = '0 0 56.25% 0';
t.style.height = '0';
t.style.position = 'relative';
t.style.overflow = 'hidden';
const title = s.getAttribute('data-title') ||
(s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1));
// Create a container for the mini-slide
const mini = document.createElement('div');
mini.className = 'mini-slide';
mini.style.position = 'absolute';
mini.style.top = '0';
mini.style.left = '0';
mini.style.width = '1920px';
mini.style.height = '1080px';
mini.style.transformOrigin = 'top left';
mini.style.pointerEvents = 'none';
mini.style.background = 'var(--bg)';
// Clone the slide content
const clone = s.cloneNode(true);
clone.className = 'slide is-active'; // force active styles
clone.style.position = 'absolute';
clone.style.inset = '0';
clone.style.transform = 'none';
clone.style.opacity = '1';
clone.style.padding = '72px 96px'; // ensure padding is kept
mini.appendChild(clone);
t.appendChild(mini);
// Add the number and title overlay
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.inset = '0';
overlay.style.background = 'linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.8) 100%)';
overlay.style.color = '#fff';
overlay.style.zIndex = '10';
overlay.style.pointerEvents = 'none';
const n = document.createElement('div');
n.className = 'n';
n.textContent = i + 1;
n.style.position = 'absolute';
n.style.top = '12px';
n.style.left = '16px';
n.style.fontWeight = '700';
n.style.fontSize = '16px';
n.style.color = '#fff';
n.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
const text = document.createElement('div');
text.className = 't';
text.textContent = title.trim().slice(0,80);
text.style.position = 'absolute';
text.style.bottom = '12px';
text.style.left = '16px';
text.style.right = '16px';
text.style.fontWeight = '600';
text.style.fontSize = '14px';
text.style.color = '#fff';
text.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
overlay.appendChild(n);
overlay.appendChild(text);
t.appendChild(overlay);
t.addEventListener('click', () => { go(i); toggleOverview(false); });
overview.appendChild(t);
});
document.body.appendChild(overview);
}
/* ===== navigation ===== */
function go(n, fromRemote){
n = Math.max(0, Math.min(total-1, n));
slides.forEach((s,i) => {
s.classList.toggle('is-active', i===n);
s.classList.toggle('is-prev', i<n);
});
idx = n;
barFill.style.width = ((n+1)/total*100)+'%';
const numEl = document.querySelector('.slide-number');
if (numEl) { numEl.setAttribute('data-current', n+1); numEl.setAttribute('data-total', total); }
// notes (bottom overlay)
const note = slides[n].querySelector('.notes, aside.notes, .speaker-notes');
notes.innerHTML = note ? note.innerHTML : '';
// hash
const hashTarget = '#/'+(n+1);
if (location.hash !== hashTarget && !isPresenterWindow) {
history.replaceState(null,'', hashTarget);
}
// re-trigger entry animations
slides[n].querySelectorAll('[data-anim]').forEach(el => {
const a = el.getAttribute('data-anim');
el.classList.remove('anim-'+a);
void el.offsetWidth;
el.classList.add('anim-'+a);
});
// counter-up
slides[n].querySelectorAll('.counter').forEach(el => {
const target = parseFloat(el.getAttribute('data-to')||el.textContent);
const dur = parseInt(el.getAttribute('data-dur')||'1200',10);
const start = performance.now();
const from = 0;
function tick(now){
const t = Math.min(1,(now-start)/dur);
const v = from + (target-from)*(1-Math.pow(1-t,3));
el.textContent = (target % 1 === 0) ? Math.round(v) : v.toFixed(1);
if (t<1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
// Broadcast to other window (audience ↔ presenter)
if (!fromRemote && bc) {
bc.postMessage({ type: 'go', idx: n });
}
}
/* ===== listen for remote navigation / theme changes ===== */
if (bc) {
bc.onmessage = function(e) {
if (!e.data) return;
if (e.data.type === 'go' && typeof e.data.idx === 'number') {
go(e.data.idx, true);
} else if (e.data.type === 'theme' && e.data.name) {
/* Sync theme across windows */
const i = themes.indexOf(e.data.name);
if (i >= 0) themeIdx = i;
applyTheme(e.data.name);
}
};
}
function toggleNotes(force){ notes.classList.toggle('open', force!==undefined?force:!notes.classList.contains('open')); }
function toggleOverview(force){
const isOpen = force!==undefined ? force : !overview.classList.contains('open');
overview.classList.toggle('open', isOpen);
if (isOpen) {
requestAnimationFrame(() => {
const thumbs = overview.querySelectorAll('.thumb');
if (thumbs.length) {
const scale = thumbs[0].clientWidth / 1920;
overview.querySelectorAll('.mini-slide').forEach(m => {
m.style.transform = 'scale(' + scale + ')';
});
}
});
}
}
/* ========== PRESENTER MODE — Magnetic-card popup window ========== */
/* Opens a new window with 4 draggable, resizable cards:
* CURRENT — iframe(?preview=N) pixel-perfect preview of current slide
* NEXT — iframe(?preview=N+1) pixel-perfect preview of next slide
* SCRIPT — large speaker notes (逐字稿)
* TIMER — elapsed timer + page counter + controls
* Cards remember position/size in localStorage.
* Two windows sync via BroadcastChannel.
*/
let presenterWin = null;
function openPresenterWindow() {
if (presenterWin && !presenterWin.closed) {
presenterWin.focus();
return;
}
// Build absolute URL of THIS deck file (without hash/query)
const deckUrl = location.protocol + '//' + location.host + location.pathname;
// Collect slide titles + notes (HTML strings)
const slideMeta = slides.map((s, i) => {
const note = s.querySelector('.notes, aside.notes, .speaker-notes');
return {
title: s.getAttribute('data-title') ||
(s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)),
notes: note ? note.innerHTML : ''
};
});
/* Capture current theme so presenter previews match the audience */
const currentTheme = root.getAttribute('data-theme') || (themes[themeIdx] || '');
const presenterHTML = buildPresenterHTML(deckUrl, slideMeta, total, idx, CHANNEL_NAME, currentTheme);
presenterWin = window.open('', 'html-ppt-presenter', 'width=1280,height=820,menubar=no,toolbar=no');
if (!presenterWin) {
alert('请允许弹出窗口以使用演讲者视图');
return;
}
presenterWin.document.open();
presenterWin.document.write(presenterHTML);
presenterWin.document.close();
}
function buildPresenterHTML(deckUrl, slideMeta, total, startIdx, channelName, currentTheme) {
const metaJSON = JSON.stringify(slideMeta);
const deckUrlJSON = JSON.stringify(deckUrl);
const channelJSON = JSON.stringify(channelName);
const themeJSON = JSON.stringify(currentTheme || '');
const storageKey = 'html-ppt-presenter:' + location.pathname;
// Build the document as a single template string for clarity
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Presenter View</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #1a1d24;
background-image:
radial-gradient(circle at 20% 30%, rgba(88,166,255,.04), transparent 50%),
radial-gradient(circle at 80% 70%, rgba(188,140,255,.04), transparent 50%);
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
}
/* Stage: positioned area where cards live */
#stage { position: absolute; inset: 0; overflow: hidden; }
/* Magnetic card */
.pcard {
position: absolute;
background: #0d1117;
border: 1px solid rgba(255,255,255,.1);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.02);
display: flex; flex-direction: column;
overflow: hidden;
min-width: 180px; min-height: 100px;
transition: box-shadow .2s, border-color .2s;
}
.pcard.dragging { box-shadow: 0 16px 48px rgba(0,0,0,.6), 0 0 0 2px rgba(88,166,255,.5); border-color: #58a6ff; transition: none; z-index: 9999; }
.pcard.resizing { box-shadow: 0 16px 48px rgba(0,0,0,.6), 0 0 0 2px rgba(63,185,80,.5); border-color: #3fb950; transition: none; z-index: 9999; }
.pcard:hover { border-color: rgba(88,166,255,.3); }
/* Card header (drag handle) */
.pcard-head {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: rgba(255,255,255,.04);
border-bottom: 1px solid rgba(255,255,255,.06);
cursor: move;
user-select: none;
flex-shrink: 0;
}
.pcard-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dot-color, #58a6ff); flex-shrink: 0; }
.pcard-title {
font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
font-weight: 700; color: #8b949e; flex: 1;
}
.pcard-meta { font-size: 11px; color: #6e7681; }
/* Card body */
.pcard-body { flex: 1; position: relative; overflow: hidden; min-height: 0; }
/* Preview cards (CURRENT/NEXT) — iframe-based pixel-perfect render */
.pcard-preview .pcard-body { background: #000; }
.pcard-preview iframe {
position: absolute; top: 0; left: 0;
width: 1920px; height: 1080px;
border: none;
transform-origin: top left;
pointer-events: none;
background: transparent;
}
.pcard-preview .preview-end {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #484f58; font-size: 14px; letter-spacing: .12em;
}
/* Notes card */
.pcard-notes .pcard-body {
padding: 14px 18px;
overflow-y: auto;
font-size: 18px; line-height: 1.75;
color: #d0d7de;
font-family: "Noto Sans SC", -apple-system, sans-serif;
}
.pcard-notes .pcard-body p { margin: 0 0 .7em 0; }
.pcard-notes .pcard-body strong { color: #f0883e; }
.pcard-notes .pcard-body em { color: #58a6ff; font-style: normal; }
.pcard-notes .pcard-body code {
font-family: "SF Mono", monospace; font-size: .9em;
background: rgba(255,255,255,.08); padding: 1px 6px; border-radius: 4px;
}
.pcard-notes .empty { color: #484f58; font-style: italic; }
/* Timer card */
.pcard-timer .pcard-body {
display: flex; flex-direction: column; gap: 14px;
padding: 18px 20px; justify-content: center;
}
.timer-display {
font-family: "SF Mono", "JetBrains Mono", monospace;
font-size: 42px; font-weight: 700;
color: #3fb950;
letter-spacing: .04em;
line-height: 1;
}
.timer-row {
display: flex; align-items: center; gap: 12px;
font-size: 14px; color: #8b949e;
}
.timer-row .label { font-size: 10px; letter-spacing: .15em; text-transform: uppercase; color: #6e7681; }
.timer-row .val { color: #e6edf3; font-weight: 600; font-family: "SF Mono", monospace; }
.timer-controls { display: flex; gap: 8px; flex-wrap: wrap; }
.timer-btn {
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.1);
color: #e6edf3;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
}
.timer-btn:hover { background: rgba(88,166,255,.15); border-color: #58a6ff; }
.timer-btn:active { transform: translateY(1px); }
/* Resize handle */
.pcard-resize {
position: absolute; right: 0; bottom: 0;
width: 18px; height: 18px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,.25) 50%, rgba(255,255,255,.25) 60%, transparent 60%, transparent 70%, rgba(255,255,255,.25) 70%, rgba(255,255,255,.25) 80%, transparent 80%);
z-index: 5;
}
.pcard-resize:hover { background: linear-gradient(135deg, transparent 50%, #58a6ff 50%, #58a6ff 60%, transparent 60%, transparent 70%, #58a6ff 70%, #58a6ff 80%, transparent 80%); }
/* Bottom hint bar */
.hint-bar {
position: fixed; bottom: 0; left: 0; right: 0;
background: rgba(0,0,0,.6);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(255,255,255,.08);
padding: 6px 16px;
font-size: 11px; color: #8b949e;
display: flex; gap: 18px; align-items: center;
z-index: 1000;
}
.hint-bar kbd {
background: rgba(255,255,255,.08);
padding: 1px 6px; border-radius: 3px;
font-family: "SF Mono", monospace;
font-size: 10px;
border: 1px solid rgba(255,255,255,.1);
color: #e6edf3;
}
.hint-bar .reset-layout {
margin-left: auto;
background: transparent; border: 1px solid rgba(255,255,255,.15);
color: #8b949e; padding: 3px 10px; border-radius: 4px;
font-size: 11px; cursor: pointer; font-family: inherit;
}
.hint-bar .reset-layout:hover { background: rgba(248,81,73,.15); border-color: #f85149; color: #f85149; }
body.is-dragging-card * { user-select: none !important; }
body.is-dragging-card iframe { pointer-events: none !important; }
</style>
</head>
<body>
<div id="stage">
<div class="pcard pcard-preview" id="card-cur" style="--dot-color:#58a6ff">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">CURRENT</span>
<span class="pcard-meta" id="cur-meta">—</span>
</div>
<div class="pcard-body"><iframe id="iframe-cur"></iframe></div>
<div class="pcard-resize" data-resize></div>
</div>
<div class="pcard pcard-preview" id="card-nxt" style="--dot-color:#bc8cff">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">NEXT</span>
<span class="pcard-meta" id="nxt-meta">—</span>
</div>
<div class="pcard-body"><iframe id="iframe-nxt"></iframe></div>
<div class="pcard-resize" data-resize></div>
</div>
<div class="pcard pcard-notes" id="card-notes" style="--dot-color:#f0883e">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">SPEAKER SCRIPT · 逐字稿</span>
</div>
<div class="pcard-body" id="notes-body"></div>
<div class="pcard-resize" data-resize></div>
</div>
<div class="pcard pcard-timer" id="card-timer" style="--dot-color:#3fb950">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">TIMER</span>
</div>
<div class="pcard-body">
<div class="timer-display" id="timer-display">00:00</div>
<div class="timer-row">
<span class="label">Slide</span>
<span class="val" id="timer-count">1 / ${total}</span>
</div>
<div class="timer-controls">
<button class="timer-btn" id="btn-prev">← Prev</button>
<button class="timer-btn" id="btn-next">Next →</button>
<button class="timer-btn" id="btn-reset">⏱ Reset</button>
</div>
</div>
<div class="pcard-resize" data-resize></div>
</div>
</div>
<div class="hint-bar">
<span><kbd>← →</kbd> 翻页</span>
<span><kbd>R</kbd> 重置计时</span>
<span><kbd>Esc</kbd> 关闭</span>
<span style="color:#6e7681">拖动卡片头部移动 · 拖动右下角调整大小</span>
<button class="reset-layout" id="reset-layout">重置布局</button>
</div>
<script>
(function(){
var slideMeta = ${metaJSON};
var total = ${total};
var idx = ${startIdx};
var deckUrl = ${deckUrlJSON};
var STORAGE_KEY = ${JSON.stringify(storageKey)};
var bc;
try { bc = new BroadcastChannel(${channelJSON}); } catch(e) {}
var iframeCur = document.getElementById('iframe-cur');
var iframeNxt = document.getElementById('iframe-nxt');
var notesBody = document.getElementById('notes-body');
var curMeta = document.getElementById('cur-meta');
var nxtMeta = document.getElementById('nxt-meta');
var timerDisplay = document.getElementById('timer-display');
var timerCount = document.getElementById('timer-count');
/* ===== Default card layout ===== */
function defaultLayout() {
var w = window.innerWidth;
var h = window.innerHeight - 36; /* leave room for hint bar */
return {
'card-cur': { x: 16, y: 16, w: Math.round(w*0.55) - 24, h: Math.round(h*0.62) - 16 },
'card-nxt': { x: Math.round(w*0.55) + 8, y: 16, w: w - Math.round(w*0.55) - 24, h: Math.round(h*0.42) - 16 },
'card-notes': { x: Math.round(w*0.55) + 8, y: Math.round(h*0.42) + 8, w: w - Math.round(w*0.55) - 24, h: h - Math.round(h*0.42) - 16 },
'card-timer': { x: 16, y: Math.round(h*0.62) + 8, w: Math.round(w*0.55) - 24, h: h - Math.round(h*0.62) - 16 }
};
}
/* ===== Apply / save / restore layout ===== */
function applyLayout(layout) {
Object.keys(layout).forEach(function(id){
var el = document.getElementById(id);
var l = layout[id];
if (el && l) {
el.style.left = l.x + 'px';
el.style.top = l.y + 'px';
el.style.width = l.w + 'px';
el.style.height = l.h + 'px';
}
});
rescaleAll();
}
function readLayout() {
try {
var saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved);
} catch(e) {}
return defaultLayout();
}
function saveLayout() {
var layout = {};
['card-cur','card-nxt','card-notes','card-timer'].forEach(function(id){
var el = document.getElementById(id);
if (el) {
layout[id] = {
x: parseInt(el.style.left,10) || 0,
y: parseInt(el.style.top,10) || 0,
w: parseInt(el.style.width,10) || 300,
h: parseInt(el.style.height,10) || 200
};
}
});
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); } catch(e) {}
}
/* ===== iframe rescale to fit card body ===== */
function rescaleIframe(iframe) {
if (!iframe || iframe.style.display === 'none') return;
var body = iframe.parentElement;
var cw = body.clientWidth, ch = body.clientHeight;
if (!cw || !ch) return;
var s = Math.min(cw / 1920, ch / 1080);
iframe.style.transform = 'scale(' + s + ')';
/* Center the scaled iframe in the body */
var sw = 1920 * s, sh = 1080 * s;
iframe.style.left = Math.max(0, (cw - sw) / 2) + 'px';
iframe.style.top = Math.max(0, (ch - sh) / 2) + 'px';
}
function rescaleAll() {
rescaleIframe(iframeCur);
rescaleIframe(iframeNxt);
}
window.addEventListener('resize', rescaleAll);
/* ===== Drag (move card by header) ===== */
document.querySelectorAll('[data-drag]').forEach(function(handle){
handle.addEventListener('mousedown', function(e){
if (e.button !== 0) return;
var card = handle.closest('.pcard');
if (!card) return;
e.preventDefault();
card.classList.add('dragging');
document.body.classList.add('is-dragging-card');
var startX = e.clientX, startY = e.clientY;
var startL = parseInt(card.style.left,10) || 0;
var startT = parseInt(card.style.top,10) || 0;
function onMove(ev){
var nx = Math.max(0, Math.min(window.innerWidth - 100, startL + ev.clientX - startX));
var ny = Math.max(0, Math.min(window.innerHeight - 50, startT + ev.clientY - startY));
card.style.left = nx + 'px';
card.style.top = ny + 'px';
}
function onUp(){
card.classList.remove('dragging');
document.body.classList.remove('is-dragging-card');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveLayout();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
/* ===== Resize (drag bottom-right corner) ===== */
document.querySelectorAll('[data-resize]').forEach(function(handle){
handle.addEventListener('mousedown', function(e){
if (e.button !== 0) return;
var card = handle.closest('.pcard');
if (!card) return;
e.preventDefault(); e.stopPropagation();
card.classList.add('resizing');
document.body.classList.add('is-dragging-card');
var startX = e.clientX, startY = e.clientY;
var startW = parseInt(card.style.width,10) || card.offsetWidth;
var startH = parseInt(card.style.height,10) || card.offsetHeight;
function onMove(ev){
var nw = Math.max(180, startW + ev.clientX - startX);
var nh = Math.max(100, startH + ev.clientY - startY);
card.style.width = nw + 'px';
card.style.height = nh + 'px';
if (card.querySelector('iframe')) rescaleIframe(card.querySelector('iframe'));
}
function onUp(){
card.classList.remove('resizing');
document.body.classList.remove('is-dragging-card');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
rescaleAll();
saveLayout();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
/* ===== Preview iframe ready tracking =====
* Each iframe loads the deck ONCE with ?preview=1 on init. Subsequent
* slide changes are sent via postMessage('preview-goto') so the iframe
* just toggles visibility of a different .slide — no reload, no flicker.
*/
var iframeReady = { cur: false, nxt: false };
var currentTheme = ${themeJSON};
window.addEventListener('message', function(e) {
if (!e.data || e.data.type !== 'preview-ready') return;
var iframe = null;
if (e.source === iframeCur.contentWindow) {
iframeReady.cur = true;
iframe = iframeCur;
postPreviewGoto(iframeCur, idx);
} else if (e.source === iframeNxt.contentWindow) {
iframeReady.nxt = true;
iframe = iframeNxt;
postPreviewGoto(iframeNxt, idx + 1 < total ? idx + 1 : idx);
}
/* Sync current theme to the iframe */
if (iframe && currentTheme) {
try { iframe.contentWindow.postMessage({ type: 'preview-theme', name: currentTheme }, '*'); } catch(err) {}
}
if (iframe) rescaleIframe(iframe);
});
function postPreviewGoto(iframe, n) {
try {
iframe.contentWindow.postMessage({ type: 'preview-goto', idx: n }, '*');
} catch(e) {}
}
/* ===== Update content =====
* Smooth (no-reload) navigation: send postMessage to iframes instead of
* resetting src. Iframes stay loaded, just switch visible .slide.
*/
function update(n) {
n = Math.max(0, Math.min(total - 1, n));
idx = n;
/* Current preview — postMessage (smooth) */
if (iframeReady.cur) postPreviewGoto(iframeCur, n);
curMeta.textContent = (n + 1) + '/' + total;
/* Next preview */
if (n + 1 < total) {
iframeNxt.style.display = '';
var endEl = document.querySelector('#card-nxt .preview-end');
if (endEl) endEl.remove();
if (iframeReady.nxt) postPreviewGoto(iframeNxt, n + 1);
nxtMeta.textContent = (n + 2) + '/' + total;
} else {
iframeNxt.style.display = 'none';
var body = document.querySelector('#card-nxt .pcard-body');
if (body && !body.querySelector('.preview-end')) {
var end = document.createElement('div');
end.className = 'preview-end';
end.textContent = '— END OF DECK —';
body.appendChild(end);
}
nxtMeta.textContent = 'END';
}
/* Notes */
var note = slideMeta[n].notes;
notesBody.innerHTML = note || '<span class="empty">(这一页还没有逐字稿)</span>';
/* Timer count */
timerCount.textContent = (n + 1) + ' / ' + total;
}
/* ===== Timer ===== */
var tStart = Date.now();
setInterval(function(){
var s = Math.floor((Date.now() - tStart) / 1000);
var mm = String(Math.floor(s/60)).padStart(2,'0');
var ss = String(s%60).padStart(2,'0');
timerDisplay.textContent = mm + ':' + ss;
}, 1000);
function resetTimer(){ tStart = Date.now(); timerDisplay.textContent = '00:00'; }
/* ===== BroadcastChannel sync ===== */
if (bc) {
bc.onmessage = function(e){
if (!e.data) return;
if (e.data.type === 'go') update(e.data.idx);
else if (e.data.type === 'theme' && e.data.name) {
currentTheme = e.data.name;
/* Forward theme change to preview iframes */
[iframeCur, iframeNxt].forEach(function(iframe){
try {
iframe.contentWindow.postMessage({ type: 'preview-theme', name: e.data.name }, '*');
} catch(err) {}
});
}
};
}
function go(n) {
update(n);
if (bc) bc.postMessage({ type: 'go', idx: idx });
}
/* ===== Buttons ===== */
document.getElementById('btn-prev').addEventListener('click', function(){ go(idx - 1); });
document.getElementById('btn-next').addEventListener('click', function(){ go(idx + 1); });
document.getElementById('btn-reset').addEventListener('click', resetTimer);
document.getElementById('reset-layout').addEventListener('click', function(){
if (confirm('恢复默认卡片布局?')) {
try { localStorage.removeItem(STORAGE_KEY); } catch(e){}
applyLayout(defaultLayout());
}
});
/* ===== Keyboard ===== */
document.addEventListener('keydown', function(e){
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch(e.key) {
case 'ArrowRight': case ' ': case 'PageDown': go(idx + 1); e.preventDefault(); break;
case 'ArrowLeft': case 'PageUp': go(idx - 1); e.preventDefault(); break;
case 'Home': go(0); break;
case 'End': go(total - 1); break;
case 'r': case 'R': resetTimer(); break;
case 'Escape': window.close(); break;
}
});
/* ===== Iframe load → rescale (catches initial size) ===== */
iframeCur.addEventListener('load', function(){ rescaleIframe(iframeCur); });
iframeNxt.addEventListener('load', function(){ rescaleIframe(iframeNxt); });
/* ===== Init =====
* Load each iframe ONCE with the deck file. After they post
* 'preview-ready', all subsequent navigation is via postMessage
* (smooth, no reload, no flicker).
*/
applyLayout(readLayout());
iframeCur.src = deckUrl + '?preview=' + (idx + 1);
if (idx + 1 < total) iframeNxt.src = deckUrl + '?preview=' + (idx + 2);
/* Initialize notes/timer/count without touching iframes */
notesBody.innerHTML = slideMeta[idx].notes || '<span class="empty">(这一页还没有逐字稿)</span>';
curMeta.textContent = (idx + 1) + '/' + total;
nxtMeta.textContent = (idx + 2) + '/' + total;
timerCount.textContent = (idx + 1) + ' / ' + total;
})();
</` + `script>
</body></html>`;
}
function fullscreen(){ const el=document.documentElement;
if (!document.fullscreenElement) el.requestFullscreen&&el.requestFullscreen();
else document.exitFullscreen&&document.exitFullscreen();
}
// theme cycling
const root = document.documentElement;
const themesAttr = root.getAttribute('data-themes') || document.body.getAttribute('data-themes');
const themes = themesAttr ? themesAttr.split(',').map(s=>s.trim()).filter(Boolean) : [];
let themeIdx = 0;
// Auto-detect theme base path from existing <link id="theme-link">
let themeBase = root.getAttribute('data-theme-base');
if (!themeBase) {
const existingLink = document.getElementById('theme-link');
if (existingLink) {
// el.getAttribute('href') gives the raw relative path written in HTML
const rawHref = existingLink.getAttribute('href') || '';
const lastSlash = rawHref.lastIndexOf('/');
themeBase = lastSlash >= 0 ? rawHref.substring(0, lastSlash + 1) : 'assets/themes/';
} else {
themeBase = 'assets/themes/';
}
}
function applyTheme(name) {
let link = document.getElementById('theme-link');
if (!link) {
link = document.createElement('link');
link.rel = 'stylesheet';
link.id = 'theme-link';
document.head.appendChild(link);
}
link.href = themeBase + name + '.css';
root.setAttribute('data-theme', name);
const ind = document.querySelector('.theme-indicator');
if (ind) ind.textContent = name;
}
function cycleTheme(fromRemote){
if (!themes.length) return;
themeIdx = (themeIdx+1) % themes.length;
const name = themes[themeIdx];
applyTheme(name);
/* Broadcast to other window (audience ↔ presenter) */
if (!fromRemote && bc) bc.postMessage({ type: 'theme', name: name });
}
// animation cycling on current slide
let animIdx = 0;
function cycleAnim(){
animIdx = (animIdx+1) % ANIMS.length;
const a = ANIMS[animIdx];
const target = slides[idx].querySelector('[data-anim-target]') || slides[idx];
ANIMS.forEach(x => target.classList.remove('anim-'+x));
void target.offsetWidth;
target.classList.add('anim-'+a);
target.setAttribute('data-anim', a);
const ind = document.querySelector('.anim-indicator');
if (ind) ind.textContent = a;
}
document.addEventListener('keydown', function (e) {
if (e.metaKey||e.ctrlKey||e.altKey) return;
switch (e.key) {
case 'ArrowRight': case ' ': case 'PageDown': case 'Enter': go(idx+1); e.preventDefault(); break;
case 'ArrowLeft': case 'PageUp': case 'Backspace': go(idx-1); e.preventDefault(); break;
case 'Home': go(0); break;
case 'End': go(total-1); break;
case 'f': case 'F': fullscreen(); break;
case 's': case 'S': openPresenterWindow(); break;
case 'n': case 'N': toggleNotes(); break;
case 'o': case 'O': toggleOverview(); break;
case 't': case 'T': cycleTheme(); break;
case 'a': case 'A': cycleAnim(); break;
case 'Escape': toggleOverview(false); toggleNotes(false); break;
}
});
// hash deep-link
function fromHash(){
const m = /^#\/(\d+)/.exec(location.hash||'');
if (m) go(Math.max(0, parseInt(m[1],10)-1));
}
window.addEventListener('hashchange', fromHash);
fromHash();
go(idx);
});
})();

View File

@ -0,0 +1,399 @@
<!DOCTYPE html>
<html lang="zh-CN" class="replay-root">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Beaver Skill Replay Eval · 技术方案介绍</title>
<link rel="stylesheet" href="assets/fonts.css">
<link rel="stylesheet" href="assets/base.css">
<link rel="stylesheet" href="assets/animations/animations.css">
<link rel="stylesheet" href="style.css">
</head>
<body class="tpl-beaver-replay">
<div class="deck">
<section class="slide" data-title="Cover">
<p class="kicker">Beaver Project / Skill Learning</p>
<h1 class="h1">Skill Replay Eval<br><span class="gradient-text">从文本评分到行为证据</span></h1>
<p class="lede">让技能草稿评估真正覆盖任务执行、工具调用、副作用和草稿保真,而不是只看生成文本是否“像一个好技能”。</p>
<div class="speaker">
<div class="av"></div>
<div><b>Beaver 技能学习评估方案</b><span>技术分享 · 架构设计 · UI 与测试路线</span></div>
</div>
<div class="deck-footer"><span>skill-replay-eval-design.md</span><span class="slide-number" data-current="1" data-total="13"></span></div>
<aside class="notes">
这一页先建立主题。我们不是在做一个更复杂的“打分器”,而是在把技能评估从静态文本判断,推进到真实任务行为判断。核心信息有三层:第一,技能草稿要在历史任务中复跑;第二,所有工具都要被纳入覆盖,只是安全工具真实执行,危险工具用替身判断;第三,修改已有技能时必须保留原有关键内容,不能因为重新生成而把重要说明丢掉。
</aside>
</section>
<section class="slide" data-title="Project Context">
<p class="kicker">system context</p>
<h2 class="h2">Beaver 是多实例 AI 工作台Skill Replay Eval 位于单实例技能学习链路。</h2>
<div class="flow mt-l">
<div class="flow-step"><span class="n">01</span><h4>auth-portal</h4><p>用户注册、登录和模型配置引导入口。</p></div>
<div class="flow-step"><span class="n">02</span><h4>authz-service</h4><p>账号、后端身份和内部授权编排。</p></div>
<div class="flow-step"><span class="n">03</span><h4>deploy-control</h4><p>创建、配置和管理独立 app-instance 容器。</p></div>
<div class="flow-step"><span class="n">04</span><h4>router-proxy</h4><p>按实例域名把外部流量转发到对应容器。</p></div>
<div class="flow-step card-accent"><span class="n">05</span><h4>app-instance</h4><p>单用户工作台包含前端、后端、workspace 和 skills。</p></div>
</div>
<div class="grid g3 mt-l">
<div class="card card-accent"><h4>技能页</h4><p class="dim">处理 published skills、candidates、draft review。</p></div>
<div class="card card-accent"><h4>学习管线</h4><p class="dim">从历史任务发现候选,合成草稿,再进行安全和评估门禁。</p></div>
<div class="card card-accent"><h4>本方案</h4><p class="dim">增强草稿评估,给发布前审查提供可追溯证据。</p></div>
</div>
<div class="deck-footer"><span>project boundary: app-instance/backend + app-instance/frontend</span><span class="slide-number" data-current="2" data-total="13"></span></div>
<aside class="notes">
先把项目放在整体架构里。Beaver 的外层是多实例部署系统,用户最终进入自己的 app-instance。技能学习、草稿评审、发布门禁都发生在 app-instance 内。也就是说 Replay Eval 不是登录系统或部署控制面功能,它服务的是单用户实例里的技能生命周期:发现候选,生成草稿,评估草稿,人工审核,然后发布为新的技能版本。
</aside>
</section>
<section class="slide" data-title="Current Gap">
<p class="kicker">current_state.py</p>
<h2 class="h2">旧评估的问题:它在评“草稿文本”,不是评“任务结果”。</h2>
<div class="compare mt-l">
<div class="side">
<span class="tag bad">heuristic-only</span>
<h3 class="mt-m">现状</h3>
<ul class="clean mt-m">
<li>只从 candidate.source_run_ids 构造轻量报告。</li>
<li>历史 run 使用 validation_result.score 或 success fallback。</li>
<li>候选得分主要估算自 draft text。</li>
<li>不复跑任务,不执行工具,不比较旧技能和新草稿行为。</li>
</ul>
</div>
<div class="side candidate">
<span class="tag good">behavior evidence</span>
<h3 class="mt-m">目标</h3>
<ul class="clean mt-m">
<li>用历史任务作为 eval cases运行 baseline 与 candidate 两个 arm。</li>
<li>安全工具真实执行,危险或不可用工具进入 surrogate 判断。</li>
<li>报告分开披露执行覆盖、替身覆盖、阻塞覆盖和置信度。</li>
<li>修订和合并草稿必须证明没有无理由丢失原技能内容。</li>
</ul>
</div>
</div>
<div class="deck-footer"><span>risk: high-looking score can still hide tool regressions</span><span class="slide-number" data-current="3" data-total="13"></span></div>
<aside class="notes">
这一页强调为什么要改。旧逻辑并非没有价值,它能快速给一个草稿粗略打分。但它无法回答发布前最关键的问题:这个技能真的会让任务做得更好吗?会不会漏掉工具调用?会不会多做一次危险操作?会不会把原技能里的安全说明删了?所以新方案的目标不是替换所有历史字段,而是在兼容旧 UI 的基础上新增 replay 证据。
</aside>
</section>
<section class="slide" data-title="Eval Model">
<p class="kicker">evaluation_model</p>
<h2 class="h2">核心模型:历史 case 选择 + 双臂 replay。</h2>
<div class="split mt-l">
<div class="card">
<h3>Case selection</h3>
<div class="matrix mt-m">
<div class="head">候选类型</div><div class="head">选择来源</div><div class="head">优先级</div><div class="head">规模</div>
<div class="rowhead">new_skill</div><div>source runs + 相似主题 accepted runs</div><div>相似任务主题</div><div>最多 10 个</div>
<div class="rowhead">revise_skill</div><div>激活目标 skill/version 的 accepted runs</div><div>近期优先,再按任务分散</div><div>最多 10 个</div>
<div class="rowhead">merge_skills</div><div>相关 skills 共同激活的 accepted runs</div><div>共同出现证据</div><div>最多 10 个</div>
</div>
</div>
<div class="card">
<h3>Two arms</h3>
<div class="pipeline mt-m">
<div class="phase"><span class="tag">same task</span><h3>输入一致</h3><p class="dim">同一任务文本、同一历史上下文、同一模型设置、同一最大工具迭代数。</p></div>
<div class="phase"><span class="tag warn">baseline</span><h3>旧行为</h3><p class="dim">new_skill 无技能revise_skill 用旧技能merge_skills 用旧相关技能。</p></div>
<div class="phase"><span class="tag good">candidate</span><h3>新草稿</h3><p class="dim">将 draft skill 作为 pinned draft guidance 注入,并记录行为差异。</p></div>
</div>
</div>
</div>
<div class="deck-footer"><span>same inputs, different skill guidance, comparable outputs</span><span class="slide-number" data-current="4" data-total="13"></span></div>
<aside class="notes">
新评估的基本单元是 historical case。每个 case 都跑两个 armbaseline 代表当前已有能力candidate 代表注入草稿后的能力。为了让对比有意义,两个 arm 除了技能引导不同以外,其他条件都尽量一致。这样我们评估的不是模型随机性,也不是不同上下文造成的差异,而是草稿技能本身对任务行为的影响。
</aside>
</section>
<section class="slide" data-title="Tool Modes">
<p class="kicker">tool_policy</p>
<h2 class="h2">所有工具都被覆盖,只是进入不同执行模式。</h2>
<div class="metric-grid mt-l">
<div class="metric"><span>mode</span><b>executed</b><p class="dim">能安全隔离的工具,在 replay 环境中真实执行。</p></div>
<div class="metric"><span>mode</span><b>surrogate</b><p class="dim">不能安全执行,但记录 intended call由 LLM 替身判断效果。</p></div>
<div class="metric"><span>mode</span><b>blocked</b><p class="dim">既不能执行,也无法可靠判断,明确降低置信度。</p></div>
<div class="metric"><span>report</span><b>coverage</b><p class="dim">执行、替身、阻塞覆盖率分开披露。</p></div>
</div>
<div class="matrix mt-l">
<div class="head">工具类型</div><div class="head">默认模式</div><div class="head">原因</div><div class="head">例子</div>
<div class="rowhead">workspace read/write</div><div><span class="tag good">executed</span></div><div>可在临时 workspace clone 中执行</div><div>读写代码、生成文件、测试 artifact</div>
<div class="rowhead">web/search read</div><div><span class="tag good">executed</span></div><div>只读且可缓存输出</div><div>搜索、打开网页、读取公开文档</div>
<div class="rowhead">external write</div><div><span class="tag warn">surrogate</span></div><div>默认不能写生产第三方系统</div><div>邮件、日历、消息发送</div>
<div class="rowhead">destructive action</div><div><span class="tag bad">surrogate / blocked</span></div><div>删除、支付、权限变更不可自动执行</div><div>不可逆外部写入</div>
</div>
<div class="deck-footer"><span>principle: include third-party tools without production side effects</span><span class="slide-number" data-current="5" data-total="13"></span></div>
<aside class="notes">
一个重要原则是“不要忽略工具”。如果只评估安全工具,那第三方连接器、发送类工具和破坏性工具就会从报告里消失,风险反而被掩盖。这里的做法是:能隔离的就真实执行;不能隔离的就记录意图,并用替身评估它是否正确、完整、必要、有无风险;如果替身也无法判断,就明确标记 blocked让报告和发布门禁知道置信度不足。
</aside>
</section>
<section class="slide" data-title="Replay Environment">
<p class="kicker">replay_environment</p>
<h2 class="h2">隔离 replay 环境:让任务复跑有证据,但不污染真实世界。</h2>
<div class="split mt-l">
<div>
<p class="lede">每个 case、每个 arm 都创建独立状态。runner 负责执行、拦截、记录,评估器在 runner 外部读取 artifacts 和 side effects。</p>
<div class="grid g2 mt-l">
<div class="panel"><span class="tag">session</span><h4 class="mt-s">Temporary session id</h4><p class="dim">避免污染真实会话状态。</p></div>
<div class="panel"><span class="tag">workspace</span><h4 class="mt-s">Temporary workspace root</h4><p class="dim">文件读写落在临时 clone。</p></div>
<div class="panel"><span class="tag">trace</span><h4 class="mt-s">Tool call trace</h4><p class="dim">记录调用、参数、模式和理由。</p></div>
<div class="panel"><span class="tag">journal</span><h4 class="mt-s">Side-effect journal</h4><p class="dim">外部写入不落生产,只留证据。</p></div>
</div>
</div>
<div class="terminal">
<div class="bar"><span class="dot"></span><span class="dot"></span><span class="dot"></span><span>replay_case.json</span></div>
<pre>{
<span class="str">"case_id"</span>: <span class="str">"run-accepted-042"</span>,
<span class="str">"arm"</span>: <span class="str">"candidate"</span>,
<span class="str">"workspace_root"</span>: <span class="str">"/tmp/beaver-replay/..."</span>,
<span class="str">"tool_calls"</span>: [
{ <span class="str">"tool"</span>: <span class="str">"filesystem.write"</span>,
<span class="str">"mode"</span>: <span class="str">"executed"</span> },
{ <span class="str">"tool"</span>: <span class="str">"outlook.send_mail"</span>,
<span class="str">"mode"</span>: <span class="str">"surrogate"</span> }
],
<span class="str">"artifacts"</span>: [<span class="str">"draft.md"</span>],
<span class="str">"side_effects"</span>: [<span class="str">"intended_email"</span>]
}</pre>
</div>
</div>
<div class="deck-footer"><span>pattern: OfficeBench-style isolated testbed, adapted to Beaver skills</span><span class="slide-number" data-current="6" data-total="13"></span></div>
<aside class="notes">
这里可以用一句话概括runner 是沙盒执行器evaluator 是证据裁判。我们不让 replay 直接写用户 workspace、用户文件、长期 memory、第三方账号或外部系统。每个 arm 有自己的临时 workspace、工具 trace、输出 artifacts 和 side-effect journal。这个形状借鉴了 OfficeBench MCP 的思路,但不绑定固定 benchmark 函数,而是服务 Beaver 的历史任务。
</aside>
</section>
<section class="slide" data-title="Surrogate Evaluation">
<p class="kicker">surrogate_evaluator</p>
<h2 class="h2">替身评估不是跳过工具,而是评估 intended effect。</h2>
<div class="split mt-l">
<div class="card">
<h3>输入 payload</h3>
<ul class="clean mt-m">
<li>工具名称和 schema。</li>
<li>候选 arm 计划调用的 arguments。</li>
<li>工具被分类为 surrogate 的原因。</li>
<li>历史 accepted evidence 和任务预期副作用。</li>
<li>assistant 在调用前后的 rationale。</li>
</ul>
</div>
<div class="card card-accent">
<h3>判断维度</h3>
<ul class="clean mt-m">
<li>调用是否满足任务目标。</li>
<li>参数是否完整、正确、可执行。</li>
<li>是否遗漏、重复或不必要。</li>
<li>是否有越权、危险或不可逆风险。</li>
<li>candidate 相比 baseline 是否真实改善。</li>
</ul>
</div>
</div>
<div class="terminal mt-l">
<div class="bar"><span class="dot"></span><span class="dot"></span><span class="dot"></span><span>surrogate_score.py</span></div>
<pre>candidate_score = quality(intended_effect, arguments, evidence)
risk_penalty = risk(missing_args, duplicated_calls, unsafe_scope)
confidence = lower_than_real_execution
case_score = candidate_score - risk_penalty</pre>
</div>
<div class="deck-footer"><span>surrogate contributes to score, but lowers confidence</span><span class="slide-number" data-current="7" data-total="13"></span></div>
<aside class="notes">
替身评估要避免两个误解。第一,它不是把危险工具当作成功,而是把“模型打算怎么调用工具”拿出来审。第二,它的置信度天然低于真实执行。比如发送邮件不能真的发,但我们可以判断收件人、主题、正文、附件、发送时机是否符合任务,也能判断是否重复发送、是否包含敏感信息、是否越权。这些判断会进入分数,但会降低 confidence。
</aside>
</section>
<section class="slide" data-title="Scoring And Gates">
<p class="kicker">report_and_gates</p>
<h2 class="h2">报告兼容旧 UI同时新增 replay 证据和发布门禁信号。</h2>
<div class="metric-grid mt-l">
<div class="metric"><span>legacy</span><b>passed</b><p class="dim">保留 passed、score_delta、cases、status 等旧字段。</p></div>
<div class="metric"><span>coverage</span><b>3 modes</b><p class="dim">execution、surrogate、blocked coverage。</p></div>
<div class="metric"><span>quality</span><b>delta</b><p class="dim">baseline mean、candidate mean、improved/regression count。</p></div>
<div class="metric"><span>trust</span><b>confidence</b><p class="dim">低置信度不能自动等同可发布。</p></div>
</div>
<div class="split mt-l">
<div class="terminal">
<div class="bar"><span class="dot"></span><span class="dot"></span><span class="dot"></span><span>SkillDraftEvalReport</span></div>
<pre>{
<span class="str">"eval_version"</span>: <span class="str">"replay-v1"</span>,
<span class="str">"mode"</span>: <span class="str">"replay"</span>,
<span class="str">"execution_coverage"</span>: <span class="num">0.58</span>,
<span class="str">"surrogate_coverage"</span>: <span class="num">0.33</span>,
<span class="str">"blocked_coverage"</span>: <span class="num">0.09</span>,
<span class="str">"confidence"</span>: <span class="str">"medium"</span>,
<span class="str">"case_reports"</span>: [...],
<span class="str">"tool_mode_summary"</span>: {...}
}</pre>
</div>
<div class="card card-accent">
<h3>Publish gate logic</h3>
<ul class="clean mt-m">
<li>高分但低置信度,进入更强人工 review。</li>
<li>重要工具全部 blocked不允许自动 pass。</li>
<li>部分 case 失败status = partial降低 confidence。</li>
<li>replay 基础设施失败status = replay_error。</li>
<li>provider 不可用时保留 skipped-provider 行为,但标明没有 replay evidence。</li>
</ul>
</div>
</div>
<div class="deck-footer"><span>score answers quality, confidence answers how much we can trust it</span><span class="slide-number" data-current="8" data-total="13"></span></div>
<aside class="notes">
报告设计的关键是兼容而不是推倒重来。旧字段继续保留,这样 UI 和存储不会被迫一次性迁移。新字段回答三个问题:我们执行了多少工具?多少工具只能替身判断?多少工具被阻塞?最后 confidence 会参与发布门禁。也就是说,一个草稿即使分数看起来不错,如果证据主要来自替身,或者关键工具全 blocked也不应该自动放行。
</aside>
</section>
<section class="slide" data-title="Draft Preservation">
<p class="kicker">preservation_contract</p>
<h2 class="h2">修订草稿必须保留原技能内容,除非明确说明改变理由。</h2>
<div class="split mt-l">
<div class="card">
<h3>为什么需要</h3>
<p class="lede mt-m">现有 synthesizer 对 revision 和 merge 只拿到候选理由、相关技能名、工具名、任务摘要和 session excerpts没有完整 base skill body。</p>
<div class="panel mt-m"><span class="tag bad">failure mode</span><p class="dim mt-s">重新生成看起来更整洁,但可能丢掉原技能里的安全边界、工具约束、工作流顺序和异常处理。</p></div>
</div>
<div class="card card-accent">
<h3>新合同</h3>
<ul class="clean mt-m">
<li>向 synthesis prompt 传入 base skill name、version、完整 frontmatter 和完整 content。</li>
<li>要求模型默认保留现有指令,只有在明确理由下才改变。</li>
<li>输出 preserved_sections、changed_sections、dropped_sections、change_reason。</li>
<li>preservation checker 比较 base 和 draft未解释的关键丢失会标记风险。</li>
</ul>
</div>
</div>
<div class="deck-footer"><span>revision is an edit contract, not a fresh rewrite</span><span class="slide-number" data-current="9" data-total="13"></span></div>
<aside class="notes">
这一页是第二条主线草稿保真。Replay 评估解决“行为是否更好”preservation 解决“原来重要的东西有没有丢”。对于修订和合并技能,模型必须看到完整 base skill不能只看摘要。生成结果也不只是一个新 body还要解释保留了什么、改了什么、删了什么以及为什么删。这样评审者能判断这是合理修订还是无意丢失。
</aside>
</section>
<section class="slide" data-title="Implementation">
<p class="kicker">implementation_plan</p>
<h2 class="h2">实现路径:扩展现有技能学习管线,新增小模块而不是重写系统。</h2>
<div class="pipeline mt-l">
<div class="phase card-accent">
<span class="tag">backend model</span>
<h3>数据层兼容</h3>
<ul class="clean">
<li>扩展 SkillDraftEvalReport。</li>
<li>新增 replay 字段默认值。</li>
<li>保留旧 payload 读取能力。</li>
</ul>
</div>
<div class="phase card-accent">
<span class="tag">learning helpers</span>
<h3>评估能力拆分</h3>
<ul class="clean">
<li>case_selection.py</li>
<li>preservation.py</li>
<li>replay.py</li>
<li>surrogate.py</li>
</ul>
</div>
<div class="phase card-accent">
<span class="tag">pipeline + ui</span>
<h3>接入和展示</h3>
<ul class="clean">
<li>eval.py 编排 replay。</li>
<li>pipeline.py 更新发布门禁。</li>
<li>loop.py 支持 replay executor override。</li>
<li>Skills UI 展示 coverage、case 和 preservation。</li>
</ul>
</div>
</div>
<div class="terminal mt-l">
<div class="bar"><span class="dot"></span><span class="dot"></span><span class="dot"></span><span>target files</span></div>
<pre>app-instance/backend/beaver/skills/learning/
case_selection.py
preservation.py
replay.py
surrogate.py
eval.py
synthesizer.py
pipeline.py
app-instance/frontend/app/(app)/skills/page.tsx
app-instance/frontend/types/index.ts</pre>
</div>
<div class="deck-footer"><span>architecture: focused helpers under existing SkillLearningPipelineService.evaluate_draft()</span><span class="slide-number" data-current="10" data-total="13"></span></div>
<aside class="notes">
实现策略很保守:扩展现有技能学习管线,而不是引入一套平行系统。数据模型先兼容扩展,然后把 case selection、preservation、replay、surrogate 拆成小模块。eval.py 负责 orchestratepipeline.py 负责发布门禁loop.py 给 replay executor 一个注入点。前端只是在草稿评审页增加证据展示,并保留已有摘要入口。
</aside>
</section>
<section class="slide" data-title="UI">
<p class="kicker">skills_review_ui</p>
<h2 class="h2">评审页先给结论,再让审核者展开证据。</h2>
<div class="split mt-l">
<div class="card card-accent">
<h3>Concise summary</h3>
<div class="metric-grid mt-m" style="grid-template-columns: repeat(2, 1fr);">
<div class="metric"><span>pass</span><b>failed</b><p class="dim">发布门禁结论。</p></div>
<div class="metric"><span>delta</span><b>+0.18</b><p class="dim">candidate - baseline。</p></div>
<div class="metric"><span>exec</span><b>58%</b><p class="dim">真实执行覆盖。</p></div>
<div class="metric"><span>conf</span><b>medium</b><p class="dim">证据可信度。</p></div>
</div>
</div>
<div class="card">
<h3>Detailed evidence</h3>
<ul class="clean mt-m">
<li>Replay cases每个历史任务的 baseline/candidate 分数。</li>
<li>Tool calls by modeexecuted、surrogate、blocked 分类和理由。</li>
<li>Artifacts and side effects产物和副作用 journal。</li>
<li>Preservation report修订草稿的保留、变更、删除风险。</li>
<li>Raw eval payload保留完整 JSON 供深度排查。</li>
</ul>
</div>
</div>
<div class="panel mt-l">
<span class="tag good">UX principle</span>
<p class="lede mt-s">用户不需要预先配置每个工具的策略。系统在报告里解释覆盖、阻塞和不确定性,让审核者知道该相信多少。</p>
</div>
<div class="deck-footer"><span>page: app-instance/frontend/app/(app)/skills/page.tsx</span><span class="slide-number" data-current="11" data-total="13"></span></div>
<aside class="notes">
UI 的目标不是把评审页变复杂,而是让复杂证据有层次。最上面仍然是 pass/fail、baseline mean、candidate mean、delta、coverage、confidence。审核者需要细看时才展开 replay cases、工具模式、阻塞理由、artifacts、副作用和 preservation report。普通使用者不需要理解每个工具策略系统在评估后解释不确定性。
</aside>
</section>
<section class="slide" data-title="Testing And Next Steps">
<p class="kicker">testing_strategy</p>
<h2 class="h2">交付标准:覆盖决策逻辑与真实工具形态。</h2>
<div class="roadmap mt-l">
<div class="item"><span>01</span><b>模型兼容</b><p>旧 payload 可读,新 replay 字段可写UI 不被旧数据破坏。</p></div>
<div class="item"><span>02</span><b>核心逻辑</b><p>case selection、arm construction、tool mode aggregation、surrogate payload。</p></div>
<div class="item"><span>03</span><b>保真检查</b><p>base section 保留、删除风险、publish gate 对 preservation risk 的处理。</p></div>
<div class="item"><span>04</span><b>混合工具</b><p>安全文件写真实执行,外部写入被拦截为 surrogate生成可审计报告。</p></div>
</div>
<div class="split mt-l">
<div class="card card-accent"><h3>Unit tests</h3><p class="dim">历史 case 选择、双臂构造、工具模式分类、替身评分 payload、保真检查、低置信发布门禁。</p></div>
<div class="card card-accent"><h3>Integration-style tests</h3><p class="dim">stub filesystem write、stub external write、candidate 同时改善真实 artifact 和 surrogate side effect。</p></div>
</div>
<div class="deck-footer"><span>next: implement tasks 1-12 from the current plan</span><span class="slide-number" data-current="12" data-total="13"></span></div>
<aside class="notes">
这一页收束到交付标准。测试不只测 happy path而是围绕风险边界模型兼容、case 选择、双臂构造、工具模式聚合、替身 payload、保真检查、发布门禁以及混合工具场景。我们需要证明这套评估不仅能生成报告而且能正确处理安全工具、外部写入和低置信度边界。
</aside>
</section>
<section class="slide center tc" data-title="Questions">
<div>
<div class="center-mark">Q</div>
<h2 class="h2 mt-m">Questions</h2>
<p class="lede" style="margin-left:auto;margin-right:auto;">技能发布前,不只要看草稿写得好不好,还要看它在历史任务里做了什么、没做什么、敢不敢相信。</p>
<div class="row mt-l" style="justify-content:center">
<span class="tag good">behavior evidence</span>
<span class="tag">tool coverage</span>
<span class="tag warn">confidence gates</span>
<span class="tag">draft preservation</span>
</div>
</div>
<div class="deck-footer"><span>Beaver Skill Replay Eval</span><span class="slide-number" data-current="13" data-total="13"></span></div>
<aside class="notes">
最后一页只留核心判断方便收尾和进入问答。可以用一句话结束Replay Eval 让技能发布从“相信生成结果”变成“审查行为证据”。然后邀请大家针对工具策略、隔离环境、替身判断、发布门禁或 UI 展示提问。
</aside>
</section>
</div>
<script src="assets/runtime.js"></script>
</body>
</html>

View File

@ -0,0 +1,511 @@
/* Beaver Skill Replay Eval deck, based on html-ppt tech-sharing template. */
.replay-root {
background: #08111d;
}
.tpl-beaver-replay {
--bg: #08111d;
--bg-soft: #0d1726;
--surface: #101b2c;
--surface-2: #132235;
--border: rgba(147, 197, 253, .18);
--border-strong: rgba(147, 197, 253, .34);
--text-1: #eef6ff;
--text-2: #a9bfd7;
--text-3: #6f879f;
--accent: #64e3a1;
--accent-2: #7cc7ff;
--accent-3: #d9a6ff;
--good: #64e3a1;
--warn: #ffd166;
--bad: #ff7b7b;
--grad: linear-gradient(120deg, #64e3a1 0%, #7cc7ff 52%, #d9a6ff 100%);
--radius: 8px;
--radius-sm: 6px;
--radius-lg: 8px;
--shadow: 0 20px 60px rgba(0, 0, 0, .38);
--letter-tight: 0;
--letter-normal: 0;
font-family: "Inter", "Noto Sans SC", sans-serif;
background: var(--bg);
color: var(--text-1);
}
.tpl-beaver-replay .slide {
padding: 50px 72px;
background:
linear-gradient(rgba(124, 199, 255, .04) 1px, transparent 1px),
linear-gradient(90deg, rgba(124, 199, 255, .04) 1px, transparent 1px),
linear-gradient(135deg, #08111d 0%, #0b1524 54%, #101426 100%);
background-size: 40px 40px, 40px 40px, auto;
color: var(--text-1);
}
.tpl-beaver-replay .slide::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(100, 227, 161, .1), transparent 22%, transparent 78%, rgba(124, 199, 255, .08)),
linear-gradient(180deg, rgba(217, 166, 255, .07), transparent 30%, transparent 82%, rgba(100, 227, 161, .05));
opacity: .7;
pointer-events: none;
z-index: 0;
}
.tpl-beaver-replay .slide > * {
position: relative;
z-index: 1;
}
.tpl-beaver-replay .h1 {
font-size: 62px;
line-height: 1.06;
font-weight: 800;
letter-spacing: 0;
margin-bottom: 20px;
color: #fff;
}
.tpl-beaver-replay .h2 {
font-size: 40px;
line-height: 1.12;
font-weight: 760;
letter-spacing: 0;
color: #fff;
margin-bottom: 16px;
}
.tpl-beaver-replay h3,
.tpl-beaver-replay h4 {
color: #fff;
letter-spacing: 0;
}
.tpl-beaver-replay .lede {
font-size: 19px;
line-height: 1.55;
color: var(--text-2);
max-width: 66ch;
}
.tpl-beaver-replay .kicker {
color: var(--accent);
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
font-size: 13px;
font-weight: 700;
letter-spacing: 0;
text-transform: none;
}
.tpl-beaver-replay .kicker::before {
content: "> ";
}
.tpl-beaver-replay .mono {
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
}
.tpl-beaver-replay .gradient-text {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.tpl-beaver-replay .deck-footer {
position: absolute;
left: 40px;
right: 40px;
bottom: 20px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-3);
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
letter-spacing: 0;
}
.tpl-beaver-replay .card {
background: rgba(16, 27, 44, .92);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: none;
}
.tpl-beaver-replay .card-accent {
border-top: 3px solid var(--accent);
}
.tpl-beaver-replay .panel {
padding: 20px 22px;
background: rgba(8, 17, 29, .68);
border: 1px solid var(--border);
border-radius: 8px;
}
.tpl-beaver-replay .tag,
.tpl-beaver-replay .pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 4px 10px;
border-radius: 6px;
background: rgba(19, 34, 53, .9);
border: 1px solid var(--border);
color: var(--text-2);
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
font-size: 12px;
letter-spacing: 0;
}
.tpl-beaver-replay .tag.good {
color: var(--good);
border-color: rgba(100, 227, 161, .34);
background: rgba(100, 227, 161, .1);
}
.tpl-beaver-replay .tag.warn {
color: var(--warn);
border-color: rgba(255, 209, 102, .34);
background: rgba(255, 209, 102, .1);
}
.tpl-beaver-replay .tag.bad {
color: var(--bad);
border-color: rgba(255, 123, 123, .34);
background: rgba(255, 123, 123, .1);
}
.tpl-beaver-replay .terminal {
background: #050a12;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
box-shadow: var(--shadow);
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
font-size: 14px;
line-height: 1.62;
}
.tpl-beaver-replay .terminal .bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: #0d1726;
border-bottom: 1px solid var(--border);
font-size: 12px;
color: var(--text-3);
}
.tpl-beaver-replay .terminal .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ff7b7b;
}
.tpl-beaver-replay .terminal .dot:nth-child(2) {
background: #ffd166;
}
.tpl-beaver-replay .terminal .dot:nth-child(3) {
background: #64e3a1;
}
.tpl-beaver-replay .terminal pre {
margin: 0;
padding: 20px 22px;
color: #dbeafe;
overflow: auto;
max-height: 420px;
}
.tpl-beaver-replay .kw { color: #ff9f9f; }
.tpl-beaver-replay .fn { color: #d9a6ff; }
.tpl-beaver-replay .str { color: #9fe6ff; }
.tpl-beaver-replay .num { color: #7cc7ff; }
.tpl-beaver-replay .cmt { color: #6f879f; }
.tpl-beaver-replay .speaker {
display: flex;
align-items: center;
gap: 14px;
margin-top: 22px;
}
.tpl-beaver-replay .speaker .av {
width: 54px;
height: 54px;
border-radius: 50%;
background: var(--grad);
}
.tpl-beaver-replay .speaker b {
display: block;
color: #fff;
font-size: 17px;
}
.tpl-beaver-replay .speaker span {
color: var(--text-3);
font-size: 13px;
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
}
.tpl-beaver-replay .agenda-row {
display: grid;
grid-template-columns: 58px 1fr 190px;
gap: 22px;
align-items: baseline;
padding: 15px 0;
border-bottom: 1px dashed var(--border);
}
.tpl-beaver-replay .agenda-row .num {
color: var(--accent);
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
}
.tpl-beaver-replay .agenda-row .t {
color: #fff;
font-size: 22px;
font-weight: 700;
}
.tpl-beaver-replay .agenda-row .d {
color: var(--text-3);
font-size: 13px;
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
}
.tpl-beaver-replay .flow {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
align-items: stretch;
}
.tpl-beaver-replay .flow-step {
min-height: 120px;
padding: 16px;
border-radius: 8px;
background: rgba(16, 27, 44, .94);
border: 1px solid var(--border);
}
.tpl-beaver-replay .flow-step .n {
display: inline-block;
margin-bottom: 10px;
color: var(--accent);
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
font-size: 13px;
}
.tpl-beaver-replay .flow-step h4 {
margin: 0 0 8px;
font-size: 18px;
}
.tpl-beaver-replay .flow-step p {
margin: 0;
color: var(--text-2);
font-size: 14px;
line-height: 1.5;
}
.tpl-beaver-replay .split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 26px;
align-items: start;
}
.tpl-beaver-replay .compare {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.tpl-beaver-replay .compare .side {
min-height: 330px;
padding: 22px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(8, 17, 29, .72);
}
.tpl-beaver-replay .compare .side.candidate {
border-color: rgba(100, 227, 161, .4);
background: rgba(100, 227, 161, .08);
}
.tpl-beaver-replay .metric-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.tpl-beaver-replay .metric {
padding: 18px;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(16, 27, 44, .9);
min-height: 104px;
}
.tpl-beaver-replay .metric b {
display: block;
font-size: 23px;
line-height: 1.1;
color: #fff;
}
.tpl-beaver-replay .metric span {
color: var(--text-3);
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
font-size: 12px;
}
.tpl-beaver-replay .matrix {
display: grid;
grid-template-columns: 190px repeat(3, 1fr);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.tpl-beaver-replay .matrix > div {
min-height: 76px;
padding: 14px;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: rgba(16, 27, 44, .78);
font-size: 14px;
}
.tpl-beaver-replay .matrix > div:nth-child(4n) {
border-right: 0;
}
.tpl-beaver-replay .matrix .head {
min-height: 48px;
color: var(--accent-2);
background: rgba(124, 199, 255, .08);
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
font-size: 12px;
}
.tpl-beaver-replay .matrix .rowhead {
color: #fff;
font-weight: 700;
}
.tpl-beaver-replay .pipeline {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 18px;
}
.tpl-beaver-replay .pipeline .phase {
min-height: 250px;
padding: 22px;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(16, 27, 44, .88);
}
.tpl-beaver-replay .phase h3 {
font-size: 22px;
margin-bottom: 12px;
}
.tpl-beaver-replay ul.clean {
list-style: none;
padding: 0;
margin: 0;
}
.tpl-beaver-replay ul.clean li {
position: relative;
padding-left: 18px;
margin: 10px 0;
color: var(--text-2);
font-size: 15px;
line-height: 1.45;
}
.tpl-beaver-replay ul.clean li::before {
content: "";
position: absolute;
left: 0;
top: .65em;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
}
.tpl-beaver-replay .large-number {
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
font-size: 96px;
line-height: .9;
font-weight: 800;
color: var(--accent);
}
.tpl-beaver-replay .source-line {
color: var(--text-3);
font-size: 12px;
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
}
.tpl-beaver-replay .roadmap {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.tpl-beaver-replay .roadmap .item {
min-height: 150px;
padding: 18px;
background: rgba(16, 27, 44, .9);
border: 1px solid var(--border);
border-radius: 8px;
}
.tpl-beaver-replay .roadmap .item b {
display: block;
margin-bottom: 10px;
color: #fff;
font-size: 18px;
}
.tpl-beaver-replay .roadmap .item span {
color: var(--accent);
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
font-size: 12px;
}
.tpl-beaver-replay .roadmap .item p {
color: var(--text-2);
font-size: 14px;
line-height: 1.5;
}
.tpl-beaver-replay .center-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 112px;
height: 112px;
border-radius: 50%;
border: 1px solid rgba(100, 227, 161, .38);
background: rgba(100, 227, 161, .08);
color: var(--accent);
font-family: "JetBrains Mono", "IBM Plex Mono", monospace;
font-size: 46px;
font-weight: 800;
}