Files
persistent-chat-plugin/web/panel.remote.html
卡若AI 62577508c9 feat: 持久对话 MCP 插件 v1.6.37 · Hub + Panel + MCP 三形态部署
- src/: server.js (MCP 4 工具) + hub.js (HTTP Hub) + cursor-title.js
- web/: panel.html (主面板) + panel.remote.html (远端 1637)
- scripts/: ensure-hub.sh (stdio→http 桥) + mongo_sync.py
- mcp-config/: trae.mcp.json + cursor.mcp.json
- docs/: INSTALL / DEPLOY / MCP_SETUP / ARCHITECTURE / QUICKSTART
- ZIP 原始安装包备查

关键机制:
- 4 MCP 工具:init / select / wait / merge
- 垂直绑定:workspace::cursorTitle@hostIp 三件指纹
- 真挂起:关闭 timedReply 后 wait 真正阻塞,agent turn 不结束
- 双 Hub 部署:远端 NAS 24×7 + 本地 launchd 守护备份
- 三形态组合:远端 + 本地 + Trae 插件,可同时跑
2026-06-26 13:01:08 +08:00

1745 lines
93 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>卡若AI · Persistent Chat</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
/* 卡若AI Glass UI Kit · kir-* 令牌见火炬_全栈消息/私域银行统一前端模块) */
:root {
--kir-brand: #2563eb;
--kir-brand-2: #7c3aed;
--kir-glass-bg: rgba(255,255,255,.78);
--kir-glass-bg-solid: rgba(255,255,255,.94);
--kir-glass-border: rgba(255,255,255,.45);
--kir-glass-blur: 16px;
--kir-radius-lg: 16px;
--kir-radius-md: 12px;
--kir-radius-pill: 999px;
--bg: #f0f4ff;
--bg-grad: linear-gradient(135deg,#f8fafc 0%,#eef2ff 42%,#faf5ff 100%);
--panel: var(--kir-glass-bg);
--panel2: rgba(255,255,255,.55);
--line: rgba(148,163,184,.28);
--fg: #0f172a;
--fg2: #64748b;
--accent: var(--kir-brand);
--ok: #16a34a;
--warn: #d97706;
--err: #dc2626;
--shadow: 0 8px 32px rgba(31,38,135,.12);
--shadow-hover: 0 12px 40px rgba(37,99,235,.18);
}
@media (prefers-color-scheme: dark) {
:root {
--kir-glass-bg: rgba(30,41,59,.72);
--kir-glass-bg-solid: rgba(15,23,42,.94);
--kir-glass-border: rgba(255,255,255,.12);
--bg: #0b1220;
--bg-grad: linear-gradient(135deg,#0f172a 0%,#1e1b4b 50%,#0f172a 100%);
--panel2: rgba(15,23,42,.65);
--line: rgba(148,163,184,.18);
--fg: #f1f5f9;
--fg2: #94a3b8;
--shadow: 0 8px 32px rgba(0,0,0,.35);
}
}
.kir-glass { backdrop-filter:blur(var(--kir-glass-blur)) saturate(180%); -webkit-backdrop-filter:blur(var(--kir-glass-blur)) saturate(180%); background:var(--kir-glass-bg); border:1px solid var(--kir-glass-border); box-shadow:var(--shadow); }
.kir-glass-solid { backdrop-filter:blur(20px) saturate(180%); -webkit-backdrop-filter:blur(20px) saturate(180%); background:var(--kir-glass-bg-solid, rgba(255,255,255,.92)); border:1px solid var(--kir-glass-border); }
* { box-sizing: border-box; }
html,body { margin:0; padding:0; background:var(--bg); background-image:var(--bg-grad); background-attachment:fixed; color:var(--fg); font:14px/1.5 -apple-system,BlinkMacSystemFont,'PingFang SC','Helvetica Neue',sans-serif; height:100%; }
body { min-height:100vh; overflow:hidden; }
.app-shell { display:flex; flex-direction:column; height:100vh; padding:12px; gap:10px; overflow:hidden; }
header.app-bar { flex-shrink:0; position:relative; z-index:300; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; padding:12px 16px; border-radius:var(--kir-radius-lg); min-height:52px; }
header.app-bar h1 { margin:0; font-size:15px; font-weight:700; letter-spacing:-.02em; background:linear-gradient(90deg,var(--kir-brand),var(--kir-brand-2)); -webkit-background-clip:text; background-clip:text; color:transparent; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
header .right { display:flex; gap:8px; align-items:center; flex-shrink:0; }
.conn-pill { display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:var(--kir-radius-pill); font-size:12px; font-weight:600; color:var(--fg2); background:rgba(255,255,255,.55); border:1px solid var(--line); white-space:nowrap; }
.conn-pill.ok { color:var(--ok); border-color:rgba(22,163,74,.28); background:rgba(22,163,74,.08); }
button { background:linear-gradient(135deg,var(--kir-brand),var(--kir-brand-2)); color:#fff; border:0; border-radius:var(--kir-radius-md); padding:7px 14px; font-size:13px; font-weight:600; cursor:pointer; box-shadow:0 4px 14px rgba(37,99,235,.25); transition:transform .15s ease,box-shadow .15s ease; min-height:36px; }
button.ghost { background:var(--panel2); color:var(--fg2); border:1px solid var(--line); box-shadow:none; font-weight:500; }
button:hover { transform:translateY(-1px); box-shadow:var(--shadow-hover); }
button.ghost:hover { transform:none; box-shadow:none; background:var(--panel); }
button:disabled { opacity:.5; cursor:not-allowed; }
.steer-queue { margin:10px 0; padding:10px 12px; border-radius:10px; background:rgba(89,211,156,.08); border:1px solid rgba(89,211,156,.25); }
.steer-head { display:flex; justify-content:space-between; align-items:center; font-size:12px; margin-bottom:8px; color:var(--ok); }
.steer-item { display:flex; justify-content:space-between; gap:10px; padding:8px 0; border-top:1px solid rgba(255,255,255,.08); font-size:12px; }
.steer-text { flex:1; white-space:pre-wrap; word-break:break-word; color:var(--fg); }
.steer-meta { display:flex; flex-direction:column; align-items:flex-end; gap:4px; flex-shrink:0; }
.timed-custom input { width:42px; border:none; background:transparent; color:inherit; font-size:11px; text-align:center; }
.timed-custom.on { border-color:var(--accent); background:rgba(89,211,156,.12); }
.alert-bar { background:linear-gradient(90deg,#ff8a00,#ff5e62); color:white; padding:10px 16px; display:flex; align-items:center; gap:12px; font-size:13px; font-weight:600; cursor:pointer; }
.alert-bar:hover { filter:brightness(1.08); }
.alert-bar .pulse { width:10px; height:10px; border-radius:50%; background:white; animation:pulse 1s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity:1; transform:scale(1); } 50% { opacity:.5; transform:scale(1.4); } }
main.workspace { flex:1; min-height:0; display:flex; gap:10px; overflow:hidden; position:relative; z-index:1; }
aside.sidebar-wrap { width:268px; flex-shrink:0; display:flex; flex-direction:column; border-radius:var(--kir-radius-lg); overflow:hidden; background:rgba(255,255,255,.42); border:1px solid var(--line); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); }
aside .sidebar-head { flex-shrink:0; padding:8px 8px 6px; border-bottom:1px solid var(--line); display:flex; gap:6px; }
aside .sidebar-head button { flex:1; font-size:12px; padding:6px 8px; min-height:32px; }
aside .sidebar-list { flex:1; overflow:auto; padding:8px 6px; min-height:0; }
aside .sess { padding:11px 12px; cursor:pointer; position:relative; margin:4px 2px; border-radius:var(--kir-radius-md); border:1px solid transparent; transition:background .12s ease,border-color .12s ease; }
aside .sess:hover { background:var(--panel2); border-color:var(--line); }
aside .sess.active { background:rgba(37,99,235,.08); border-color:rgba(37,99,235,.28); box-shadow:inset 3px 0 0 var(--kir-brand); }
aside .sess.waiting { background:linear-gradient(90deg,rgba(255,138,0,.1),transparent); }
aside .sess.waiting.active { background:linear-gradient(90deg,rgba(255,138,0,.16),rgba(37,99,235,.06)); }
aside .sess .title { font-weight:600; font-size:13px; line-height:1.35; }
aside .sess .meta { font-size:11px; color:var(--fg2); margin-top:4px; display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
.badge { padding:2px 8px; border-radius:var(--kir-radius-pill); font-size:10px; font-weight:600; letter-spacing:.02em; }
.badge.waiting { background:rgba(255,138,0,.14); color:#c2410c; }
.badge.suspend-waiting { background:rgba(255,138,0,.16); color:#c2410c; border:1px solid rgba(255,138,0,.35); font-weight:700; }
.badge.suspend-auto { background:rgba(89,211,156,.12); color:var(--ok); border:1px solid rgba(22,163,74,.28); }
.badge.idle { background:rgba(148,163,184,.14); color:var(--fg2); }
section.detail { flex:1; display:flex; flex-direction:column; min-width:0; border-radius:var(--kir-radius-lg); overflow:hidden; background:rgba(255,255,255,.58); border:1px solid var(--line); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); box-shadow:var(--shadow); }
.empty { margin:auto; color:var(--fg2); text-align:center; padding:40px; max-width:520px; line-height:1.7; }
.empty .big { font-size:18px; color:var(--fg); margin-bottom:8px; }
.empty code { background:var(--panel2); padding:2px 6px; border-radius:4px; font-size:12px; }
.header2 { padding:16px 18px 12px; border-bottom:1px solid var(--line); background:rgba(255,255,255,.35); flex-shrink:0; }
.header2 .title { font-weight:700; font-size:15px; letter-spacing:-.01em; }
.header2 .sub { color:var(--fg2); font-size:12px; margin-top:4px; }
.msg { flex:1; overflow:auto; padding:16px 18px 8px; scroll-behavior:smooth; }
.bubble { background:rgba(255,255,255,.72); backdrop-filter:blur(10px); border:1px solid rgba(255,255,255,.55); border-radius:var(--kir-radius-lg); padding:14px 16px; margin-bottom:14px; white-space:pre-wrap; word-break:break-word; font-size:13px; line-height:1.7; box-shadow:0 4px 18px rgba(15,23,42,.06); }
.bubble.assistant { border-left:4px solid var(--kir-brand); }
.bubble.user { border-left:4px solid var(--ok); background:rgba(240,253,244,.75); }
.composer { border-top:1px solid var(--line); padding:14px 18px 16px; background:rgba(255,255,255,.42); flex-shrink:0; }
textarea { width:100%; min-height:88px; max-height:220px; background:rgba(255,255,255,.82); color:var(--fg); border:1px solid var(--line); border-radius:var(--kir-radius-md); padding:12px 14px; font:13px/1.55 -apple-system,BlinkMacSystemFont,'PingFang SC',monospace; resize:vertical; transition:border-color .15s ease,box-shadow .15s ease; }
textarea:focus { outline:none; border-color:rgba(37,99,235,.45); box-shadow:0 0 0 3px rgba(37,99,235,.12); }
.toolbar { display:flex; gap:8px; align-items:center; margin-top:8px; }
.toolbar .hint { color:var(--fg2); font-size:11px; flex:1; }
pre.text { background:var(--panel2); padding:10px; border-radius:6px; overflow:auto; margin:6px 0 0; max-height:280px; font-size:12px; line-height:1.55; }
.dot { width:8px; height:8px; border-radius:50%; display:inline-block; background:var(--ok); }
.dot.warn { background:var(--warn); }
.quick { background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:12px; margin-bottom:12px; font-size:12px; color:var(--fg2); }
.quick code { background:var(--bg); padding:2px 6px; border-radius:4px; color:var(--fg); }
.copy-btn { background:var(--ok); color:white; border:0; padding:6px 10px; border-radius:6px; font-size:12px; cursor:pointer; margin-left:6px; }
.copy-btn.copied { background:var(--fg2); }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); padding:10px 18px; border-radius:8px; font-size:13px; font-weight:600; z-index:9999; box-shadow:0 8px 24px rgba(0,0,0,.35); max-width:min(92vw,520px); text-align:center; }
.toast.ok { background:var(--ok); color:#fff; }
.toast.err { background:var(--err); color:#fff; }
.toast.info { background:var(--panel2); color:var(--fg); border:1px solid var(--line); }
.status-banner { border-radius:var(--kir-radius-md); padding:11px 13px; margin-bottom:12px; font-size:12px; line-height:1.65; backdrop-filter:blur(8px); }
.status-banner.ok { background:rgba(22,163,74,.08); border:1px solid rgba(22,163,74,.22); color:#15803d; }
.status-banner.proc { background:rgba(37,99,235,.08); border:1px solid rgba(37,99,235,.18); color:var(--kir-brand); }
.status-banner.err { background:rgba(220,38,38,.08); border:1px solid rgba(220,38,38,.2); color:var(--err); }
.legend { font-size:11px; color:var(--fg2); padding:8px 0 0; line-height:1.7; }
.auto-toggle { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--fg2); user-select:none; cursor:pointer; padding:4px 8px; border-radius:6px; border:1px solid var(--line); }
.auto-toggle.on { color:var(--ok); border-color:rgba(89,211,156,.45); background:rgba(89,211,156,.08); }
.auto-toggle input { accent-color: var(--ok); }
.sess-del { position:absolute; right:8px; top:8px; width:22px; height:22px; border:0; border-radius:4px; background:transparent; color:var(--fg2); font-size:14px; line-height:1; padding:0; cursor:pointer; opacity:0; }
aside .sess:hover .sess-del { opacity:1; }
.sess-del:hover { background:rgba(255,107,107,.15); color:var(--err); }
.badge.auto { background:rgba(89,211,156,.15); color:var(--ok); }
.copy-modal { position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:10000; display:flex; align-items:center; justify-content:center; padding:20px; }
.copy-modal .box { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px; max-width:560px; width:100%; max-height:80vh; overflow:auto; }
.copy-modal textarea { width:100%; min-height:160px; margin:10px 0; }
.hist { margin-top:8px; }
.hist .bubble { font-size:12px; padding:8px 10px; margin-bottom:8px; }
.hist .bubble.user { opacity:.92; }
.composer.minimal textarea { min-height: 44px; max-height: 44px; opacity: .75; }
.composer.minimal .hint { color: var(--ok); }
.composer-expand { font-size: 12px; color: var(--accent); cursor: pointer; margin-left: 8px; }
.rhythm-rail { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.rhythm-step { flex:1; min-width:120px; padding:8px 10px; border-radius:8px; border:1px solid var(--line); background:var(--panel2); font-size:11px; }
.rhythm-step.done { border-color:rgba(89,211,156,.45); color:var(--ok); }
.rhythm-step.active { border-color:rgba(92,200,255,.55); color:var(--accent); }
.rhythm-step.pending { opacity:.65; }
.review-card { background:rgba(255,255,255,.72); border:1px solid var(--line); border-radius:var(--kir-radius-lg); padding:13px 15px; margin-bottom:12px; font-size:12px; line-height:1.7; box-shadow:0 4px 16px rgba(15,23,42,.05); }
.review-card .pct { font-size:18px; font-weight:700; color:var(--ok); }
.quick-btns { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0 12px; }
.quick-btns button { font-size:12px; padding:6px 10px; }
.quick-btns button.secondary { background:var(--panel2); color:var(--fg); border:1px solid var(--line); }
.mode-switch { display:inline-flex; border:1px solid var(--line); border-radius:var(--kir-radius-md); overflow:hidden; background:rgba(255,255,255,.45); }
.mode-switch .mode-btn { background:transparent; color:var(--fg2); border:0; border-radius:0; padding:6px 11px; font-size:12px; cursor:pointer; min-height:32px; box-shadow:none; }
.mode-switch .mode-btn.active { background:linear-gradient(135deg,var(--kir-brand),var(--kir-brand-2)); color:#fff; font-weight:600; transform:none; }
.mode-switch .mode-btn.optimize.active { background:linear-gradient(135deg,#7c3aed,#9333ea); }
.session-config { display:flex; flex-wrap:wrap; gap:10px; align-items:center; padding:12px 0 0; margin-top:10px; border-top:1px dashed var(--line); }
.session-config .cfg-label { font-size:11px; color:var(--fg2); margin-right:4px; }
.mode-hint { font-size:11px; color:var(--fg2); flex:1; min-width:200px; line-height:1.5; }
.btn-continue-big { background:var(--ok); font-size:14px; font-weight:600; padding:10px 18px; }
.btn-continue-big:disabled { opacity:.45; }
.more-menu { position:relative; z-index:400; }
.more-dropdown { display:none; position:absolute; right:0; top:calc(100% + 8px); min-width:180px; background:rgba(255,255,255,.96); border:1px solid var(--line); border-radius:var(--kir-radius-md); box-shadow:0 16px 40px rgba(15,23,42,.14); z-index:500; padding:6px; backdrop-filter:blur(16px); }
.more-dropdown.open { display:block; }
.more-dropdown button { display:block; width:100%; text-align:left; background:transparent; color:var(--fg); border:0; border-radius:6px; padding:8px 10px; font-size:12px; }
.more-dropdown button:hover { background:var(--panel2); }
.control-bar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:10px 0 0; margin-top:8px; border-top:1px dashed var(--line); }
.control-bar .chip { display:inline-flex; align-items:center; gap:5px; font-size:12px; color:var(--fg2); padding:5px 10px; border-radius:var(--kir-radius-pill); border:1px solid var(--line); background:rgba(255,255,255,.5); cursor:pointer; user-select:none; }
.control-bar .chip.on { color:var(--ok); border-color:rgba(22,163,74,.35); background:rgba(22,163,74,.08); }
.control-bar .timed-preset { font-weight:600; }
.control-bar .timed-preset.on { color:var(--ok); border-color:rgba(22,163,74,.45); background:rgba(22,163,74,.12); }
.control-bar .chip-label { font-size:11px; color:var(--fg2); margin-right:2px; }
.status-strip { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:10px 12px; border-radius:var(--kir-radius-md); background:rgba(37,99,235,.06); border:1px solid rgba(37,99,235,.15); font-size:12px; margin-bottom:12px; }
.status-strip .dot-live { width:8px; height:8px; border-radius:50%; background:var(--ok); animation:pulse 1.4s ease-in-out infinite; }
.status-strip code { font-size:11px; background:rgba(255,255,255,.6); padding:2px 6px; border-radius:4px; }
aside .sess.pinned { border-color:rgba(37,99,235,.22); background:rgba(37,99,235,.04); }
.sess-ctx-menu { display:none; position:fixed; z-index:10001; min-width:168px; background:rgba(255,255,255,.97); border:1px solid var(--line); border-radius:var(--kir-radius-md); box-shadow:0 12px 36px rgba(15,23,42,.18); padding:4px; backdrop-filter:blur(16px); }
.sess-ctx-menu.open { display:block; }
.sess-ctx-menu button { display:block; width:100%; text-align:left; background:transparent; color:var(--fg); border:0; border-radius:6px; padding:8px 12px; font-size:12px; cursor:pointer; min-height:auto; box-shadow:none; font-weight:500; }
.sess-ctx-menu button:hover { background:var(--panel2); transform:none; }
.sess-ctx-menu button.danger { color:var(--err); }
.sidebar-fold { margin:6px 4px 2px; border-radius:var(--kir-radius-md); border:1px dashed var(--line); background:rgba(148,163,184,.08); overflow:hidden; }
.sidebar-fold-head { display:flex; align-items:center; justify-content:space-between; gap:6px; padding:8px 10px; font-size:11px; color:var(--fg2); cursor:pointer; user-select:none; }
.sidebar-fold-head:hover { background:rgba(255,255,255,.35); color:var(--fg); }
.sidebar-fold-head .count { font-weight:700; color:var(--warn); }
.sidebar-fold-body { display:none; padding:2px 4px 6px; max-height:220px; overflow:auto; }
.sidebar-fold.open .sidebar-fold-body { display:block; }
.sidebar-fold .sess { opacity:.88; }
.sidebar-section-label { font-size:10px; color:var(--fg2); padding:4px 10px 2px; letter-spacing:.02em; }
#sendBtn { min-width:108px; }
footer.task-footer { flex-shrink:0; display:flex; align-items:center; gap:10px; padding:8px 14px; border-radius:var(--kir-radius-lg); background:rgba(255,255,255,.55); border:1px solid var(--line); font-size:12px; }
footer.task-footer .task-progress { flex:1; min-width:0; color:var(--fg2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
@media (max-width: 860px) {
.app-shell { padding:8px; }
aside.sidebar-wrap { width:220px; }
header.app-bar h1 { font-size:14px; }
}
</style>
<script>window.__PCHAT_BOOT__={"sessions":[{"token":"ct_3d48135b","title":"UU远程","displayTitle":"","status":"completed","workspace":"/Users/karuo/Documents/个人","createdAt":1782271296804,"lastActiveAt":1782315971710,"msgCount":6,"pendingMessage":null,"pendingPrompt":null,"pendingTitle":null,"pendingSince":null,"silent":false,"autoRunning":false,"hasPending":false,"agentWaitActive":false,"waitingForUser":false,"connectionHeld":false,"isHung":false,"lastKeepAliveAt":1782285390833,"autoStreak":3,"cursorTitle":"UU远程","bindingKey":"/Users/karuo/Documents/个人::UU远程@192.168.3.1","hostIp":"192.168.3.1","hub":"192.168.110.101:13458","bindingCheckCount":10,"isActiveWait":false,"projectGoal":"","completionPct":100,"lastReview":{"hasReview":true,"completionPct":100,"nextStep":"重启 Trae CN 使 VSIX 完全生效(可选),或继续当前对话。","isIdleNext":false,"needsZhuiwen":true,"at":1782272035138},"phase":"pre_zhuiwen","planLabel":"UU远程","preRun":{"nextStep":"重启 Trae CN 使 VSIX 完全生效(可选),或继续当前对话。","stage":"zhuiwen_wait","zhuiwenSent":true,"searchSent":false},"optimizeRun":null,"continueMode":"standard","sessionAutoContinue":false,"continueModeLabel":"复盘","timedReplyEnabled":false,"sessionTimedReply":false,"timedReplyAuto":false,"timedReplyIntervalMs":10000,"sessionTimedIntervalMs":10000,"effectiveTimedIntervalMs":10000,"timedReplyFireAt":null,"registryToken":"ct_3d48135b","bindingOk":true,"restoredFromBinding":false,"steerQueueCount":0,"steerQueuePreview":[],"recentMessages":[{"role":"assistant","preview":"## [卡若复盘]2026-06-24 10:05\n\n### 🎯 目标·结果·达成率\n修复 Cursor Codex `tls handshake eof` / `正在重新连接 5/5` 错误。**达成率 100%** — 根因定位(代理节点死亡)→ 切换代理 → 验证 Codex API 端点恢复连通。\n\n### 📌 过程\n1. 初步误判为 MCP→Hub 连接问题,修改了 mcp.json NO_PROXY、server.js agent:false、ensure-hub.sh --noproxy\n2. 用户反馈未解决,截图暴露真实 URL`https://chatgpt.com/backend-api/codex/responses` — **是 Cursor→OpenAI 的链路断,不是 MCP**\n3. 诊断发现 **Clash Verge Rev 当前节点「新加坡(T","at":1782271344938,"auto":false,"timed":false},{"role":"user","preview":"【保活续跑】继续执行▶下一步:等待验证 Cursor Codex 对话是否恢复正常;若复发需在 ClashX Pro 内手动切换到「美国 AI」或「香港」节点。。保持连接勿停。","at":1782271354979,"auto":true,"timed":false},{"role":"assistant","preview":"## [卡若复盘]2026-06-24 10:08\n\n### 🎯 目标·结果·达成率\n修复 Trae CN 的 mcp.json 配置,添加代理绕过环境变量。**达成率 100%**\n\n### 📌 过程\n1. 用户指出 Trae CN mcp.json 内容\"为空\"\n2. 实际文件有完整 MCP 配置,但缺少 NO_PROXY 环境变量(之前只修改了 Trae 英文版)\n3. 添加 NO_PROXY/no_proxy 防止代理干扰远程 Hub 连接\n\n### 💡 反思\n用户同时使用 Trae CN 和 Trae 英文版,两套 IDE 有独立的 mcp.json需要同步修改。\n\n### 📝 总结\n- ✅ Trae CN mcp.json 已添加 NO_PROXY\n- ✅ Trae 英文版 mcp.json 已添加 NO_PROXY\n- ✅ server.js / ensure-","at":1782271639019,"auto":false,"timed":false},{"role":"user","preview":"【保活续跑】继续执行▶下一步:重启 Trae CN 使 MCP 配置生效,或等待新指令。。保持连接勿停。","at":1782271649040,"auto":true,"timed":false},{"role":"assistant","preview":"## [卡若复盘]2026-06-24 10:15\n\n### 🎯 目标·结果·达成率\n重新安装旧版本 MCP 插件 (4.6.0-4tools) 到 Trae CN确保可用。**达成率 100%**\n\n### 📌 过程\n1. 找到两个旧版本 VSIXpersistent-chat-4.6.0-4tools.vsix 和 persistent-chat-4.6.0.vsix\n2. 同步 Trae CN 和 Trae 英文版 mcp.json添加 NO_PROXY 环境变量\n3. 使用 Trae CN CLI 安装 VSIX → 成功\n4. 验证VSIX 已安装、Hub 健康(ok:true)、Node v22.22.0 正常\n\n### 💡 反思\nTrae 英文版无独立 IDE只有 Trae Proxy.app 轻量代理),用户主要使用 Trae CN只需维护一套配置。\n","at":1782272035138,"auto":false,"timed":false},{"role":"user","preview":"【保活续跑】继续执行▶下一步:重启 Trae CN 使 VSIX 完全生效(可选),或继续当前对话。。保持连接勿停。","at":1782272045200,"auto":true,"timed":false}]},{"token":"ct_9b5a9ac9","title":"测试计划","displayTitle":"","status":"completed","workspace":"/Users/karuo/Documents/个人","createdAt":1782254531255,"lastActiveAt":1782265519609,"msgCount":8,"pendingMessage":null,"pendingPrompt":null,"pendingTitle":null,"pendingSince":null,"silent":false,"autoRunning":false,"hasPending":false,"agentWaitActive":false,"waitingForUser":false,"connectionHeld":false,"isHung":false,"lastKeepAliveAt":1782285390833,"autoStreak":4,"cursorTitle":"TRAE持久对话测试","bindingKey":"/Users/karuo/Documents/个人::TRAE持久对话测试@192.168.3.1","hostIp":"192.168.3.1","hub":"192.168.110.101:13458","bindingCheckCount":12,"isActiveWait":false,"projectGoal":"","completionPct":null,"lastReview":{"hasReview":false,"completionPct":null,"nextStep":"","isIdleNext":true,"needsZhuiwen":false,"at":1782265509606},"phase":"running","planLabel":"测试计划","preRun":null,"optimizeRun":null,"continueMode":"standard","sessionAutoContinue":false,"continueModeLabel":"复盘","timedReplyEnabled":false,"sessionTimedReply":false,"timedReplyAuto":false,"timedReplyIntervalMs":10000,"sessionTimedIntervalMs":10000,"effectiveTimedIntervalMs":10000,"timedReplyFireAt":null,"registryToken":"ct_9b5a9ac9","bindingOk":true,"restoredFromBinding":false,"steerQueueCount":0,"steerQueuePreview":[],"recentMessages":[{"role":"assistant","preview":"测试可持续对话功能,已成功挂起,请在浏览器面板 http://192.168.110.101:13458/ 中输入回复。","at":1782254538559,"auto":false,"timed":false},{"role":"user","preview":"【保活续跑】保持持久对话连接,继续执行当前任务与学习清单,按复盘▶推进,勿停。","at":1782254548617,"auto":true,"timed":false},{"role":"assistant","preview":"已进入持续运行模式,保持对话开放状态。你可以随时通过浏览器面板 http://192.168.110.101:13458/ 发送消息,或直接在此输入任务指令。","at":1782254592061,"auto":false,"timed":false},{"role":"user","preview":"【保活续跑】保持持久对话连接,继续执行当前任务与学习清单,按复盘▶推进,勿停。","at":1782254602063,"auto":true,"timed":false},{"role":"assistant","preview":"v1.1版本已成功安装并连接到远程Hub保持持续运行模式等待用户通过浏览器面板 http://192.168.110.101:13458/ 发送消息。","at":1782265486897,"auto":false,"timed":false},{"role":"user","preview":"【保活续跑】保持持久对话连接,继续执行当前任务与学习清单,按复盘▶推进,勿停。","at":1782265496956,"auto":true,"timed":false},{"role":"assistant","preview":"持续运行模式已激活,等待用户输入任务或通过浏览器面板回复。","at":1782265509606,"auto":false,"timed":false},{"role":"user","preview":"【保活续跑】保持持久对话连接,继续执行当前任务与学习清单,按复盘▶推进,勿停。","at":1782265519609,"auto":true,"timed":false}]},{"token":"ct_787975ab","title":"111","displayTitle":"","status":"completed","workspace":"","createdAt":1782250975188,"lastActiveAt":1782250975188,"msgCount":0,"pendingMessage":null,"pendingPrompt":null,"pendingTitle":null,"pendingSince":null,"silent":false,"autoRunning":false,"hasPending":false,"agentWaitActive":false,"waitingForUser":false,"connectionHeld":false,"isHung":false,"lastKeepAliveAt":1782285390833,"autoStreak":0,"cursorTitle":"111","bindingKey":"(unknown)::111@192.168.110.101","hostIp":"192.168.110.101","hub":"127.0.0.1:13458","bindingCheckCount":1,"isActiveWait":false,"projectGoal":"","completionPct":null,"lastReview":null,"phase":"running","planLabel":"111","preRun":null,"optimizeRun":null,"continueMode":"standard","sessionAutoContinue":false,"continueModeLabel":"复盘","timedReplyEnabled":false,"sessionTimedReply":false,"timedReplyAuto":false,"timedReplyIntervalMs":10000,"sessionTimedIntervalMs":10000,"effectiveTimedIntervalMs":10000,"timedReplyFireAt":null,"registryToken":"ct_787975ab","bindingOk":true,"restoredFromBinding":false,"steerQueueCount":0,"steerQueuePreview":[],"recentMessages":[]},{"token":"ct_7a559f3c","title":"111","displayTitle":"","status":"completed","workspace":"","createdAt":1782160172726,"lastActiveAt":1782160183729,"msgCount":0,"pendingMessage":null,"pendingPrompt":null,"pendingTitle":null,"pendingSince":null,"silent":false,"autoRunning":false,"hasPending":false,"agentWaitActive":false,"waitingForUser":false,"connectionHeld":false,"isHung":false,"lastKeepAliveAt":1782184207900,"autoStreak":0,"cursorTitle":"111","bindingKey":"(unknown)::111@192.168.110.101","hostIp":"192.168.110.101","hub":"127.0.0.1:13458","bindingCheckCount":0,"isActiveWait":false,"projectGoal":"","completionPct":null,"lastReview":null,"phase":"running","planLabel":"111","preRun":null,"optimizeRun":null,"continueMode":"standard","sessionAutoContinue":false,"continueModeLabel":"复盘","timedReplyEnabled":false,"sessionTimedReply":false,"timedReplyAuto":false,"timedReplyIntervalMs":10000,"sessionTimedIntervalMs":10000,"effectiveTimedIntervalMs":10000,"timedReplyFireAt":null,"registryToken":"ct_787975ab","bindingOk":false,"restoredFromBinding":false,"steerQueueCount":0,"steerQueuePreview":[],"recentMessages":[]}],"settings":{"autoContinue":true,"autoContinueMode":"standard","defaultSessionAutoContinue":true,"defaultContinueMode":"standard","autoContinueDelayMs":10000,"autoContinueText":"继续","turboContinueText":"【直通续跑】继续执行当前计划,直推开发直到目标完成,无需等复盘/追问/检索确认。","autoContinueMaxStreak":0,"timedReplyEnabled":true,"defaultTimedReplyEnabled":true,"timedReplyIntervalMs":10000,"connectionKeepAliveMs":10000,"timedReplyPresets":[0,10000,30000,60000,120000,240000,540000,900000,1800000],"timedReplyText":"【定时回复】保持持久对话连接,若仍有任务请继续执行。","timedReplyTurboText":"【定时续跑】保持连接,继续执行当前计划,直推开发直到目标完成。","alwaysKeepAlive":true,"keepAliveText":"【保活续跑】保持持久对话连接,继续执行当前任务与学习清单,按复盘▶推进,勿停。","keepAliveTurboText":"【保活续跑】保持连接,继续执行当前计划与学习清单,直推开发直到目标完成。","maxSessions":30,"maxIdleAgeDays":14},"panelVersion":"1.6.37","activeWaitToken":null};</script>
</head>
<body>
<div class="app-shell">
<div id="alertBar" class="alert-bar" style="display:none" onclick="jumpToWaiting()">
<span class="pulse"></span>
<span id="alertText">有 1 个会话正在等待你回复</span>
<span style="margin-left:auto;font-size:12px;opacity:.85">点击跳转 →</span>
</div>
<header class="app-bar kir-glass-solid">
<h1>卡若AI · 持久对话 <span id="panelVer" style="font-size:11px;color:var(--fg2);font-weight:500;-webkit-text-fill-color:var(--fg2)"></span></h1>
<div class="right">
<span id="conn" class="conn-pill"><span class="dot warn"></span> 连接中…</span>
<div class="more-menu">
<button class="ghost" id="moreBtn" title="更多操作" style="min-width:40px;padding:6px 10px"></button>
<div class="more-dropdown" id="moreMenu">
<button type="button" id="newPlanBtn">🆕 新建任务</button>
<button type="button" id="pruneBtn">🗑 清理旧会话</button>
<button type="button" id="copyPrompt">📋 复制启动提示词</button>
</div>
</div>
</div>
</header>
<main class="workspace">
<aside class="sidebar-wrap">
<div class="sidebar-head">
<button type="button" class="ghost" id="sidebarNewTask" title="新建任务newPlan">+ 新建任务</button>
<button type="button" class="ghost" id="sidebarCopyPrompt" title="一键复制启动提示词">📋 提示词</button>
</div>
<div id="sessList" class="sidebar-list"></div>
</aside>
<section class="detail" id="detail">
<div class="empty">
<div class="big">还没开始?</div>
<button type="button" class="copy-btn" data-copy-prompt style="display:inline-block">📋 复制启动提示词</button> 粘贴到 Cursor ChatAgent 模式),或点左侧 <strong>+ 新建任务</strong><br><br>
Agent 会 <code>init_conversation</code> 创建会话,再 <code>wait_for_user_input</code> 挂起;之后从本面板回复即可。
</div>
</section>
</main>
<footer class="task-footer kir-glass-solid">
<span class="task-progress" id="taskProgress">未选择会话</span>
</footer>
<div id="sessCtxMenu" class="sess-ctx-menu" role="menu">
<button type="button" data-ctx-pin role="menuitem">置顶 📌</button>
<button type="button" data-ctx-rename role="menuitem">修改标题 ✏️</button>
<button type="button" data-ctx-open role="menuitem">打开对话</button>
<button type="button" data-ctx-del class="danger" role="menuitem">删除会话</button>
</div>
</div>
<script>
const PANEL_VERSION = '1.6.37';
const SIDEBAR_ACTIVE_MS = 2 * 60 * 60 * 1000;
const PIN_STORAGE_KEY = 'pchat_pinned_token';
const PINNED_LIST_KEY = 'pchat_pinned_tokens';
const MODE_LABELS = { standard: '复盘', turbo: '直通', optimize: '优化' };
const TIMED_REPLY_FIXED = [
{ label: '自动', ms: 0 },
{ label: '10秒', ms: 10000 },
{ label: '30秒', ms: 30000 },
{ label: '4分', ms: 240000 },
{ label: '9分', ms: 540000 },
];
const TIMED_REPLY_PRESETS = TIMED_REPLY_FIXED;
const MODE_HINTS = {
standard: '挂起后走追问→检索→执行,需完整复盘块',
turbo: '不等复盘Hub 直推开发直到目标完成',
optimize: '自动总结→全网/GitHub检索→补开发文档→再开发',
};
function sessionDisplayTitle(s) {
return `${shortSessionTitle(s)} · ${s.token || ''}`;
}
function shortSessionTitle(s) {
if (!s) return '';
const custom = String(s.displayTitle || '').trim();
if (custom) return custom;
const tab = s.cursorTitle || s.title || '';
const plan = String(s.planLabel || '').trim();
let head = tab || plan || String(s.token || '');
if (plan && plan !== tab && !String(tab).includes(plan)) head = `${tab || '任务'} · ${plan}`;
return head;
}
function loadPinnedTokens() {
try {
const raw = sessionStorage.getItem(PINNED_LIST_KEY);
const arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr.filter(Boolean) : [];
} catch { return []; }
}
function savePinnedTokens(arr) {
try {
sessionStorage.setItem(PINNED_LIST_KEY, JSON.stringify([...new Set(arr.filter(Boolean))]));
} catch {}
}
function isTokenPinned(token) {
return !!token && loadPinnedTokens().includes(token);
}
function togglePinToken(token) {
if (!token) return;
let arr = loadPinnedTokens();
if (arr.includes(token)) arr = arr.filter((t) => t !== token);
else arr = [token, ...arr];
savePinnedTokens(arr);
showToast(arr.includes(token) ? '已置顶' : '已取消置顶', 'ok', 2200);
lastSidebarKey = '';
renderSidebar();
updateTaskFooter();
}
const STARTUP_PROMPT = `请使用 persistent-chat MCP卡若复盘 + 续跑三段式):
1. select_conversation / init_conversationcursorTitle=当前标签名)。
2. **新计划必须 newPlan**:若与旧任务无关的新任务 → init_conversation(newPlan:true, planLabel=计划名, cursorTitle=标签名),禁止挂旧线程。
3. 每轮:执行 → **[卡若复盘]** 🎯📌💡📝▶ → wait\\\`\\\`\\\`text 包裹)。
4. 挂起后续跑①追问→「追问完成」→②WebSearch+GitHub→「检索完成」→③执行▶。
5. 「结束持久对话」才停循环。
第一项任务:`;
let state = { sessions: [], primaryWaitingToken: null, hubPid: null, settings: { autoContinue: true, autoContinueMode: 'standard', autoContinueMaxStreak: 0 } };
let selected = null;
let autoSelectedToken = null;
/** 用户手动点选左侧会话后为 true轮询不再自动切到 active/waiting */
let userPinnedSelection = false;
let lastSidebarKey = '';
let lastDetailKey = '';
let msgStickToBottom = true;
let notifiedTokens = new Set();
let lastSent = {};
let lastAutoSent = {};
let pollPaused = false;
let pollPauseUntil = 0;
let savedDraft = '';
let composerExpanded = false;
/** 用户点选定时/控制栏后冻结重绘,避免轮询把选中态打回 */
let controlBarFreezeUntil = 0;
/** 超过2小时未活跃会话是否展开 */
let sidebarStaleExpanded = false;
function getTimedMs(s) {
if (!s) return 0;
if (s.sessionTimedIntervalMs != null) return Number(s.sessionTimedIntervalMs) || 0;
if (s.timedReplyIntervalMs != null) return Number(s.timedReplyIntervalMs) || 0;
return 0;
}
function freezeControlBar(ms = 12000) {
controlBarFreezeUntil = Date.now() + ms;
pollPaused = true;
pollPauseUntil = controlBarFreezeUntil;
}
function sessionLastActiveAt(s) {
const t = Number(s.lastActiveAt || s.createdAt || 0);
return Number.isFinite(t) ? t : 0;
}
/** 真正保持连接:挂起 / 续跑 / 活跃 wait */
function isSessionConnected(s) {
if (!s) return false;
return isAgentSuspended(s) || !!(s.autoRunning || s.isActiveWait);
}
/** 2 小时内有实质对话(有消息且最近活跃) */
function hasRecentDialogue(s) {
if (!s) return false;
const at = sessionLastActiveAt(s);
return (Number(s.msgCount) || 0) > 0 && at > 0 && (Date.now() - at) < SIDEBAR_ACTIVE_MS;
}
/** 主列表:保持连接 · 或 2h 内有对话 · 或 置顶/当前选中 */
function isSessionSidebarActive(s) {
if (!s) return false;
if (isTokenPinned(s.token)) return true;
if (selected === s.token) return true;
if (isSessionConnected(s)) return true;
return hasRecentDialogue(s);
}
function partitionSidebarSessions(sessions) {
const active = [];
const stale = [];
for (const s of stableSessions(sessions || [])) {
if (isSessionSidebarActive(s)) active.push(s);
else stale.push(s);
}
const pins = loadPinnedTokens();
active.sort((a, b) => {
const ap = pins.indexOf(a.token);
const bp = pins.indexOf(b.token);
if (ap >= 0 && bp < 0) return -1;
if (bp >= 0 && ap < 0) return 1;
if (ap >= 0 && bp >= 0) return ap - bp;
return sessionLastActiveAt(b) - sessionLastActiveAt(a);
});
stale.sort((a, b) => sessionLastActiveAt(b) - sessionLastActiveAt(a));
return { active, stale };
}
function sidebarRenderKey() {
const { active, stale } = partitionSidebarSessions(state.sessions);
const pinKey = loadPinnedTokens().join(',');
const activeKey = active.map((x) =>
[x.token, x.displayTitle, x.title, x.cursorTitle, x.status, x.msgCount, x.autoRunning, x.isActiveWait,
x.continueMode, x.hasPending, x.connectionHeld, x.steerQueueCount || 0, sessionLastActiveAt(x)].join(':')
).join('|');
const staleKey = stale.map((x) => x.token + ':' + sessionLastActiveAt(x)).join('|');
return activeKey + '||stale:' + staleKey + '::' + selected + '::fold:' + (sidebarStaleExpanded ? '1' : '0') + '::pins:' + pinKey;
}
function detailRenderKey() {
const s = state.sessions.find((x) => x.token === selected);
if (!s) return 'empty::' + selected;
const pre = s.preRun || {};
return [
selected,
s.status,
s.msgCount,
s.autoRunning,
s.hasPending,
s.connectionHeld,
s.steerQueueCount || 0,
s.phase,
pre.stage,
composerExpanded,
s.continueMode,
s.sessionTimedReply,
getTimedMs(s),
s.sessionAutoContinue,
s.displayTitle || '',
].join('::');
}
function stableSessions(list) {
return [...(list || [])].sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
}
function loadPinnedToken() {
try { return sessionStorage.getItem(PIN_STORAGE_KEY) || ''; } catch { return ''; }
}
function savePinnedToken(token) {
try {
if (token) sessionStorage.setItem(PIN_STORAGE_KEY, token);
else sessionStorage.removeItem(PIN_STORAGE_KEY);
} catch {}
}
function updateTaskFooter() {
const el = document.getElementById('taskProgress');
if (!el) return;
const s = state.sessions.find((x) => x.token === selected);
if (!s) {
el.textContent = `${(state.sessions || []).length} 个会话 · 未选择`;
return;
}
const pct = typeof s.completionPct === 'number' ? ` · 达成 ${s.completionPct}%` : '';
const plan = s.planLabel ? ` · ${s.planLabel}` : '';
el.textContent = `${sessionDisplayTitle(s)}${plan}${pct} · ${s.token}`;
}
function isNearBottom(el, threshold = 96) {
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= threshold;
}
function pickDefaultSelection() {
const pinned = loadPinnedToken();
if (pinned && state.sessions.find((s) => s.token === pinned)) return pinned;
return state.sessions[0] ? state.sessions[0].token : null;
}
function maxAutoStreakLimit() {
const n = Number(state.settings && state.settings.autoContinueMaxStreak);
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.floor(n);
}
function isAutoPaused(s) {
const lim = maxAutoStreakLimit();
return lim > 0 && (s.autoStreak || 0) >= lim;
}
function isTyping() {
const ta = document.getElementById('replyBox');
return pollPaused || (ta && (document.activeElement === ta || ta.value.trim().length > 0));
}
function showToast(text, kind = 'info', ms = 3200) {
const old = document.getElementById('toast');
if (old) old.remove();
const el = document.createElement('div');
el.id = 'toast';
el.className = 'toast ' + kind;
el.textContent = text;
document.body.appendChild(el);
setTimeout(() => { el.remove(); }, ms);
}
function errHint(code, data) {
if (data && data.primaryWaitingToken && data.primaryWaitingToken !== selected) {
return `❌ 发错会话了Cursor 在等 ${data.primaryWaitingToken}。已自动跳转。`;
}
const map = {
'no pending wait': '❌ Agent 还没挂起,或你选错了左侧会话。点最新「⏳ 等待中」再发。',
'bad request': '❌ 内容为空或会话无效。',
'NOT_ACTIVE_WAIT_TOKEN': '❌ ct_ 与 Cursor 活跃挂起不一致,已自动跳转正确会话。',
};
return map[code] || ('❌ 发送失败:' + code);
}
function sessionMode(s) {
return s.continueMode || 'standard';
}
function sessionAutoOn(s) {
return s.sessionAutoContinue !== false;
}
function sessionTimedOn(s) {
if (typeof s.sessionTimedReply === 'boolean') return s.sessionTimedReply;
return s.timedReplyEnabled !== false;
}
function timedLabel(ms) {
const hit = TIMED_REPLY_FIXED.find((p) => p.ms === ms);
return hit ? hit.label : (ms >= 60000 ? Math.round(ms / 60000) + '分' : Math.round(ms / 1000) + '秒');
}
function continueDelayLabel(s) {
if (s.continueDelayMs) return timedLabel(s.continueDelayMs);
const ms = getTimedMs(s);
return ms > 0 ? timedLabel(ms) : '自动(10秒)';
}
async function setSessionConfig(token, patch) {
if (!token) return;
const payload = { token, ...patch };
if (typeof patch.sessionTimedReply === 'boolean') {
payload.timedReplyEnabled = patch.sessionTimedReply;
}
if (patch.sessionTimedIntervalMs != null) {
payload.timedReplyIntervalMs = patch.sessionTimedIntervalMs;
}
try {
const r = await fetch('/api/session/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const j = await r.json();
if (j.ok) {
const idx = state.sessions.findIndex((x) => x.token === token);
if (idx >= 0) {
if (typeof patch.sessionAutoContinue === 'boolean') {
state.sessions[idx].sessionAutoContinue = patch.sessionAutoContinue;
}
if (patch.continueMode) state.sessions[idx].continueMode = patch.continueMode;
if (typeof patch.sessionTimedReply === 'boolean') {
state.sessions[idx].sessionTimedReply = patch.sessionTimedReply;
state.sessions[idx].timedReplyEnabled = patch.sessionTimedReply;
}
if (patch.sessionTimedIntervalMs != null) {
const ms = Number(patch.sessionTimedIntervalMs) || 0;
state.sessions[idx].sessionTimedIntervalMs = ms;
state.sessions[idx].timedReplyIntervalMs = ms;
}
}
if (patch.sessionTimedIntervalMs != null || typeof patch.sessionTimedReply === 'boolean') {
freezeControlBar();
const cur = idx >= 0 ? state.sessions[idx] : null;
patchTimedPresetUi(cur ? getTimedMs(cur) : 0);
if (cur) showToast(`定时已设为 ${continueDelayLabel(cur)}`, 'ok', 2500);
} else {
lastSidebarKey = '';
lastDetailKey = '';
renderSidebar();
renderDetail();
}
if (patch.continueMode) {
showToast(`本对话已切到「${MODE_LABELS[patch.continueMode] || patch.continueMode}」模式`, 'ok', 3500);
}
} else {
showToast('配置失败', 'err');
}
} catch (e) {
showToast('配置失败', 'err');
}
}
function patchTimedPresetUi(timedMs) {
document.querySelectorAll('.timed-preset').forEach((btn) => {
const ms = Number(btn.getAttribute('data-ms') || 0);
btn.classList.toggle('on', ms === timedMs);
});
const custom = document.querySelector('.timed-custom');
const customOn = timedMs > 0 && !TIMED_REPLY_FIXED.some((p) => p.ms === timedMs);
if (custom) custom.classList.toggle('on', customOn);
const sub = document.querySelector('.header2 .sub');
if (sub && selected) {
const s = state.sessions.find((x) => x.token === selected);
if (s) {
const mode = sessionMode(s);
sub.innerHTML = `${s.msgCount} 条消息 · 间隔 ${esc(continueDelayLabel(s))} · ${esc(MODE_LABELS[mode])}模式
<button class="ghost" style="margin-left:8px;padding:2px 8px;font-size:11px" onclick="deleteSession('${s.token}', ${!!s.hasPending})">删除</button>`;
}
}
}
function detHasControlBar() {
const det = document.getElementById('detail');
return !!(det && det.dataset.selectedToken === selected && det.querySelector('.control-bar'));
}
/** 轮询时只更新消息区/状态条,不重绘控制栏与输入框,避免界面跳动 */
function patchDetailDynamic() {
const s = state.sessions.find((x) => x.token === selected);
const det = document.getElementById('detail');
if (!s || !det || det.dataset.selectedToken !== selected) {
renderDetail();
return;
}
const msgBox = det.querySelector('#msgBox');
if (!msgBox) {
renderDetail();
return;
}
const stick = isNearBottom(msgBox);
const scrollTop = msgBox.scrollTop;
const waiting = s.status === 'waiting';
const autoRunning = s.autoRunning;
const canSend = waiting || (autoRunning && s.hasPending) || !!s.hasPending;
const pendingRaw = s.ownPendingMessage || s.pendingMessage || (s.recentMessages || []).filter(m => m.role === 'assistant').slice(-1)[0]?.preview || '';
const review = parseKaruoReviewClient(pendingRaw);
const reviewHtml = renderReviewCard(review, s);
const suspended = isAgentSuspended(s);
const connOnly = isConnectionOnly(s);
const live = autoRunning || waiting || suspended || s.connectionHeld || (s.steerQueueCount || 0) > 0;
let banner = `<div class="status-strip">
${live ? '<span class="dot-live"></span>' : ''}
<strong>${suspended ? '⏸ Agent 已挂起' : (connOnly ? '🔗 计划保活中' : (live ? '运行中' : '待命'))}</strong>
<code>${esc(s.token)}</code>
<span>↔ ${esc(s.cursorTitle || s.title || '—')}</span>
</div>`;
if ((s.steerQueueCount || 0) > 0) {
const steerItems = (s.steerQueuePreview || []).map((item) =>
`<div class="steer-item"><div class="steer-text">${esc(item.text)}</div><div class="steer-meta"><small>${item.at ? new Date(item.at).toLocaleTimeString() : ''}</small><button type="button" class="ghost steer-del" data-steer-index="${item.index}">删除</button></div></div>`
).join('');
banner += `<div class="steer-queue"><div class="steer-head">📥 排队方向(${s.steerQueueCount})· wait 后送达 · 可单删 <button type="button" class="ghost" id="steerClearAll">清空</button></div>${steerItems}</div>`;
}
if (suspended) {
banner += `<div class="status-banner ok">⏸ <strong>已挂起</strong> · 可从本面板回复</div>`;
} else if (connOnly) {
banner += `<div class="status-banner proc">🔗 <strong>保活未挂起</strong> · Cursor 尚未 wait</div>`;
}
const pendingBlock = (waiting || autoRunning || suspended) ? `
<div class="bubble assistant">
<strong>🤖 当前挂起回复</strong>
<pre class="text">${esc(stripCodeFence(s.ownPendingMessage || s.pendingMessage || (s.recentMessages || []).filter(m => m.role === 'assistant').slice(-1)[0]?.preview || ''))}</pre>
${s.pendingPrompt ? `<div style="color:var(--fg2);font-size:12px;margin-top:8px">${esc(s.pendingPrompt)}</div>` : ''}
</div>
` : '<div class="empty" style="margin:24px auto;font-size:13px">在 Cursor 发任务后,此处显示 Agent 挂起回复。</div>';
msgBox.innerHTML = banner + reviewHtml + pendingBlock + renderHistory(s);
det.querySelectorAll('[data-steer-index]').forEach((btn) => {
btn.onclick = (e) => { e.stopPropagation(); deleteSteerItem(Number(btn.getAttribute('data-steer-index'))); };
});
const steerClear = det.querySelector('#steerClearAll');
if (steerClear) steerClear.onclick = (e) => { e.stopPropagation(); clearSteerQueue(true); };
patchTimedPresetUi(getTimedMs(s));
requestAnimationFrame(() => {
if (stick) msgBox.scrollTop = msgBox.scrollHeight;
else msgBox.scrollTop = scrollTop;
});
}
async function fetchState(){
if (isTyping() && Date.now() < pollPauseUntil + 120000) return;
try {
const r = await fetch('/api/state', { cache: 'no-store' });
state = await r.json();
const connEl = document.getElementById('conn');
if (connEl && !connEl.classList.contains('ok')) {
connEl.innerHTML = '<span class="dot"></span> 已连接';
connEl.className = 'conn-pill ok';
}
const waiting = state.sessions.filter(s => s.status === 'waiting' || s.hasPending);
const active = state.activeWaitToken;
const selectedExists = selected && state.sessions.find((s) => s.token === selected);
const selSess = selectedExists ? state.sessions.find((x) => x.token === selected) : null;
const autoOn = selSess ? sessionAutoOn(selSess) : true;
if (!selectedExists) {
const pinned = loadPinnedToken();
const pinnedExists = pinned && state.sessions.find((s) => s.token === pinned);
if (pinnedExists) {
selected = pinned;
userPinnedSelection = true;
} else {
userPinnedSelection = false;
selected = pickDefaultSelection();
}
autoSelectedToken = selected;
}
/* 用户已点选 ct_ 后:轮询只刷新数据,绝不自动切到 active/primary */
if (waiting.length > 0 && !autoOn && !userPinnedSelection) {
for (const w of waiting) {
const key = w.token + ':' + w.pendingSince;
if (!notifiedTokens.has(key)) {
notifiedTokens.add(key);
notify(w.title, '等待你回复');
}
}
document.getElementById('alertBar').style.display = 'flex';
document.getElementById('alertText').textContent = `${waiting.length} 个会话正在等待你回复`;
} else {
document.getElementById('alertBar').style.display = 'none';
}
if (autoOn && active) {
document.getElementById('alertBar').style.display = 'none';
}
const sidebarKey = sidebarRenderKey();
const detailKey = detailRenderKey();
if (sidebarKey !== lastSidebarKey) {
lastSidebarKey = sidebarKey;
renderSidebar();
}
if (detailKey !== lastDetailKey) {
lastDetailKey = detailKey;
if (Date.now() < controlBarFreezeUntil && detHasControlBar()) {
patchDetailDynamic();
} else {
renderDetail();
}
} else if (Date.now() < controlBarFreezeUntil) {
patchTimedPresetUi(getTimedMs(selSess || {}));
}
updateTaskFooter();
} catch (e) {
const connEl = document.getElementById('conn');
if (connEl) {
connEl.innerHTML = '<span class="dot warn"></span> 离线';
connEl.className = 'conn-pill';
}
}
}
function notify(title, body){
if (!('Notification' in window)) return;
if (Notification.permission === 'granted') new Notification(title, { body, silent: false });
else if (Notification.permission !== 'denied') Notification.requestPermission();
}
function jumpToWaiting(){
const w = state.primaryWaitingToken
? state.sessions.find(s => s.token === state.primaryWaitingToken)
: state.sessions.find(s => s.status === 'waiting');
if (w) {
selected = w.token;
userPinnedSelection = true;
lastSidebarKey = '';
lastDetailKey = '';
renderSidebar();
renderDetail();
}
}
function selectSession(token) {
selected = token;
userPinnedSelection = true;
savePinnedToken(token);
msgStickToBottom = true;
const s = state.sessions.find((x) => x.token === token);
if (s && !isSessionSidebarActive(s)) sidebarStaleExpanded = true;
lastSidebarKey = '';
lastDetailKey = '';
renderSidebar();
renderDetail();
updateTaskFooter();
}
/** Agent 已 waitHub 有 pending */
function isAgentSuspended(s) {
if (!s) return false;
return !!(s.hasPending || s.agentWaitActive || s.waitingForUser || s.status === 'waiting');
}
/** 仅 Hub 保活、尚未 wait */
function isConnectionOnly(s) {
if (!s) return false;
return !!(s.connectionHeld || s.isHung) && !isAgentSuspended(s);
}
function sessionLiveBadge(s) {
if (isAgentSuspended(s)) {
if (s.autoRunning) return { text: '挂起·续跑', cls: 'suspend-waiting', title: 'Agent 已 waitHub 自动续跑中' };
return { text: '挂起', cls: 'suspend-waiting', title: 'Agent 已 wait_for_user_input等待你回复' };
}
if (s.autoRunning || s.isActiveWait) return { text: '续跑', cls: 'suspend-auto', title: '自动续跑中' };
if (isConnectionOnly(s)) return { text: '保活', cls: 'suspend-auto', title: '计划保活中Cursor 尚未调用 wait' };
return null;
}
function sessionBadge(s) {
const live = sessionLiveBadge(s);
if (live) {
const sideCls = live.cls === 'suspend-waiting' ? 'waiting' : (live.text === '续跑' ? 'auto' : 'waiting');
return { text: live.text, cls: sideCls };
}
return { text: s.status === 'completed' ? '完成' : '空闲', cls: 'idle' };
}
function pendingPreview(s) {
const raw = s.ownPendingMessage || s.pendingMessage || '';
if (!raw) return '';
const t = String(raw).replace(/```text|```/g, '').trim();
return t.slice(0, 48) + (t.length > 48 ? '…' : '');
}
let ctxMenuToken = null;
function showSessContextMenu(e, token) {
e.preventDefault();
e.stopPropagation();
ctxMenuToken = token;
const menu = document.getElementById('sessCtxMenu');
if (!menu) return;
const pinBtn = menu.querySelector('[data-ctx-pin]');
if (pinBtn) pinBtn.textContent = isTokenPinned(token) ? '取消置顶 📌' : '置顶 📌';
const x = Math.min(e.clientX, window.innerWidth - 180);
const y = Math.min(e.clientY, window.innerHeight - 168);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.classList.add('open');
}
function hideSessContextMenu() {
document.getElementById('sessCtxMenu')?.classList.remove('open');
ctxMenuToken = null;
}
async function renameSessionTitle(token) {
hideSessContextMenu();
const s = state.sessions.find((x) => x.token === token);
if (!s) return;
const cur = String(s.displayTitle || s.cursorTitle || s.title || '').trim();
const next = prompt('修改面板显示标题(仅本面板,不影响 Cursor 标签名)', cur);
if (next === null) return;
try {
const r = await fetch('/api/session/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, displayTitle: next.trim() }),
});
const j = await r.json();
if (!j.ok) { showToast('改名失败', 'err'); return; }
const idx = state.sessions.findIndex((x) => x.token === token);
if (idx >= 0) {
if (next.trim()) state.sessions[idx].displayTitle = next.trim();
else delete state.sessions[idx].displayTitle;
}
lastSidebarKey = '';
lastDetailKey = '';
renderSidebar();
renderDetail();
updateTaskFooter();
showToast('标题已更新', 'ok', 2500);
} catch {
showToast('改名失败', 'err');
}
}
function patchSessionItem(div, s) {
const cls = ['sess'];
if (selected === s.token) cls.push('active');
if (isTokenPinned(s.token)) cls.push('pinned');
if (s.hasPending || s.status === 'waiting' || s.connectionHeld || s.isHung) cls.push('waiting');
div.className = cls.join(' ');
div.oncontextmenu = (e) => showSessContextMenu(e, s.token);
const badge = sessionBadge(s);
const bindOk = !s.bindingMismatch && (!s.registryToken || s.registryToken === s.token);
const preview = pendingPreview(s);
let titleEl = div.querySelector('.title');
if (!titleEl) {
div.innerHTML = `
<button class="sess-del" title="删除此会话">×</button>
<div class="title"></div>
<div class="sess-bind" style="font-size:10px;color:var(--fg2);margin-top:2px;line-height:1.3"></div>
<div class="sess-pending" style="font-size:10px;color:var(--accent);margin-top:3px;line-height:1.35;opacity:.9"></div>
<div class="meta">
<span class="badge"></span>
<span class="msg-count"></span>
<span class="token-id"></span>
</div>`;
div.querySelector('.sess-del').onclick = (e) => { e.stopPropagation(); deleteSession(s.token, s.hasPending); };
titleEl = div.querySelector('.title');
}
titleEl.textContent = (isTokenPinned(s.token) ? '📌 ' : '') + shortSessionTitle(s);
const bindEl = div.querySelector('.sess-bind');
if (bindEl) {
const bindLine = `${s.token}${s.cursorTitle || s.title || '—'}`;
bindEl.textContent = bindLine;
bindEl.style.color = 'var(--fg2)';
}
const pendEl = div.querySelector('.sess-pending');
if (pendEl) {
pendEl.textContent = preview ? `挂起: ${preview}` : (s.steerQueueCount ? `排队 ${s.steerQueueCount}` : (isAgentSuspended(s) ? '挂起中,可面板回复…' : (isConnectionOnly(s) ? '保活中,等 Agent wait 接入…' : '')));
pendEl.style.display = preview || s.hasPending || s.steerQueueCount || s.connectionHeld ? 'block' : 'none';
}
const badgeEl = div.querySelector('.badge');
badgeEl.className = 'badge ' + badge.cls;
badgeEl.textContent = badge.text;
div.querySelector('.msg-count').textContent = `${s.msgCount}`;
div.querySelector('.token-id').textContent = s.token;
let modeEl = div.querySelector('.mode-tag');
if (!modeEl) {
modeEl = document.createElement('span');
modeEl.className = 'mode-tag badge idle';
div.querySelector('.meta').appendChild(modeEl);
}
modeEl.textContent = MODE_LABELS[sessionMode(s)] || '复盘';
}
function ensureSessionRow(list, parent, s) {
let div = parent.querySelector(`[data-token="${s.token}"]`);
if (!div) {
div = document.createElement('div');
div.className = 'sess';
div.dataset.token = s.token;
div.onclick = () => selectSession(s.token);
parent.appendChild(div);
}
patchSessionItem(div, s);
return div;
}
function renderSidebar(){
const list = document.getElementById('sessList');
const listScrollTop = list.scrollTop;
if (state.sessions.length === 0) {
list.innerHTML = '<div class="empty-hint" style="padding:20px;color:var(--fg2);font-size:12px;text-align:center;line-height:1.7">尚无会话。<br>在 Cursor Agent 发任务即可自动创建。</div>';
return;
}
const { active, stale } = partitionSidebarSessions(state.sessions);
const keep = new Set();
list.innerHTML = '';
if (active.length) {
const label = document.createElement('div');
label.className = 'sidebar-section-label';
label.textContent = `保持连接 / 近2小时有对话${active.length}`;
list.appendChild(label);
const activeWrap = document.createElement('div');
activeWrap.className = 'sidebar-recent';
list.appendChild(activeWrap);
for (const s of active) {
keep.add(s.token);
ensureSessionRow(list, activeWrap, s);
}
} else {
const hint = document.createElement('div');
hint.className = 'empty-hint';
hint.style.cssText = 'padding:12px;color:var(--fg2);font-size:12px;text-align:center;line-height:1.7';
hint.textContent = '暂无活跃会话 · 展开下方归类查看旧对话';
list.appendChild(hint);
}
if (stale.length) {
const fold = document.createElement('div');
fold.className = 'sidebar-fold' + (sidebarStaleExpanded ? ' open' : '');
fold.id = 'sidebarStaleFold';
const head = document.createElement('div');
head.className = 'sidebar-fold-head';
const foldLabel = '▸ 已归类 · 未连接或超过2小时';
head.innerHTML = `<span>${foldLabel}</span><span class="count">${stale.length}</span>`;
head.onclick = (e) => {
e.stopPropagation();
sidebarStaleExpanded = !sidebarStaleExpanded;
fold.classList.toggle('open', sidebarStaleExpanded);
head.querySelector('span').textContent = (sidebarStaleExpanded ? '▾' : '▸') + ' 已归类 · 未连接或超过2小时';
lastSidebarKey = '';
};
const body = document.createElement('div');
body.className = 'sidebar-fold-body';
fold.appendChild(head);
fold.appendChild(body);
list.appendChild(fold);
if (sidebarStaleExpanded) head.querySelector('span').textContent = '▾ 已归类 · 未连接或超过2小时';
for (const s of stale) {
keep.add(s.token);
ensureSessionRow(list, body, s);
}
}
list.querySelectorAll('[data-token]').forEach((el) => {
if (!keep.has(el.dataset.token)) el.remove();
});
list.scrollTop = listScrollTop;
}
function renderDetail(){
const det = document.getElementById('detail');
const msgBoxBefore = det.querySelector('#msgBox');
const sameSession = det.dataset.selectedToken === selected;
if (msgBoxBefore) {
msgStickToBottom = isNearBottom(msgBoxBefore);
}
const msgScrollTop = msgBoxBefore ? msgBoxBefore.scrollTop : 0;
const s = state.sessions.find(x => x.token === selected);
if (!s) {
det.innerHTML = `<div class="empty">
<div class="big">还没开始?</div>
点 <button type="button" class="copy-btn" data-copy-prompt style="display:inline-block">📋 复制启动提示词</button> 粘贴到 Cursor ChatAgent 模式)作为第一条消息。<br><br>
Agent 会自动调用 <code>init_conversation</code> 创建会话,然后通过 <code>wait_for_user_input</code> 挂起等你回复。<br>
之后你的每条回复都从这个面板发Cursor 那边任务永不中断。
</div>`;
return;
}
const waiting = s.status === 'waiting';
const autoRunning = s.autoRunning;
const canSend = waiting || (autoRunning && s.hasPending) || !!s.hasPending;
const canInput = true;
const steerQueued = (s.steerQueueCount || 0) > 0;
const autoOn = sessionAutoOn(s);
const mode = sessionMode(s);
const turboOn = mode === 'turbo';
const optimizeOn = mode === 'optimize';
const standardOn = mode === 'standard';
const primary = state.primaryWaitingToken;
const wrongSession = waiting && primary && selected !== primary;
const sent = lastSent[s.token];
const sentRecent = sent && (Date.now() - sent.at < 120000);
const registryOk = !s.registryToken || s.registryToken === s.token;
const timedOn = sessionTimedOn(s);
const delayLbl = continueDelayLabel(s);
const suspended = isAgentSuspended(s);
const connOnly = isConnectionOnly(s);
const live = autoRunning || waiting || suspended || s.connectionHeld || steerQueued;
const liveBadge = sessionLiveBadge(s);
const titleBadge = liveBadge
? `<span class="badge ${liveBadge.cls}" style="margin-left:8px" title="${esc(liveBadge.title || '')}">${esc(liveBadge.text)}</span>`
: '';
const statusStrip = `<div class="status-strip">
${live ? '<span class="dot-live"></span>' : ''}
<strong>${suspended ? '⏸ Agent 已挂起' : (connOnly ? '🔗 计划保活中' : (live ? '运行中' : '待命'))}</strong>
<code>${esc(s.token)}</code>
<span>↔ ${esc(s.cursorTitle || s.title || '—')}</span>
${s.bindingRenamedFrom ? `<span style="color:var(--warn)">(自「${esc(s.bindingRenamedFrom)}」改名)</span>` : ''}
${!registryOk ? '<span style="color:var(--err)">⚠ 绑定异常</span>' : ''}
${wrongSession ? `<button class="copy-btn" onclick="jumpToWaiting()">切到活跃会话</button>` : ''}
</div>`;
let banner = statusStrip;
if (steerQueued) {
const steerItems = (s.steerQueuePreview || []).map((item) =>
`<div class="steer-item"><div class="steer-text">${esc(item.text)}</div><div class="steer-meta"><small>${item.at ? new Date(item.at).toLocaleTimeString() : ''}</small><button type="button" class="ghost steer-del" data-steer-index="${item.index}">删除</button></div></div>`
).join('');
banner += `<div class="steer-queue"><div class="steer-head">📥 排队方向(${s.steerQueueCount})· wait 后送达 · 可单删 <button type="button" class="ghost" id="steerClearAll">清空</button></div>${steerItems}</div>`;
}
if (suspended) {
banner += `<div class="status-banner ok">⏸ <strong>已挂起</strong> · Hub 已收到 <code>wait_for_user_input</code>,可从本面板回复或点「继续开发」</div>`;
} else if (connOnly) {
banner += `<div class="status-banner proc">🔗 <strong>计划保活中(未挂起)</strong> · Cursor 里该任务若还在跑、未调用 wait面板不会显示挂起请让 Agent 复盘后 <code>wait_for_user_input</code>,或在本面板发消息排队</div>`;
}
const paused = autoRunning && autoOn && isAutoPaused(s);
const minimalComposer = autoOn && autoRunning && !paused && !composerExpanded;
const pendingRaw = s.ownPendingMessage || s.pendingMessage || (s.recentMessages || []).filter(m => m.role === 'assistant').slice(-1)[0]?.preview || '';
const review = parseKaruoReviewClient(pendingRaw);
const reviewHtml = renderReviewCard(review, s);
const canManualContinue = s.hasPending && (s.isActiveWait || selected === state.activeWaitToken);
const quickParts = [];
if (canManualContinue) {
quickParts.push(`<button type="button" class="btn-continue-big" id="manualContinueBtn">▶ 点击继续开发</button>`);
}
if (canSend && review.hasReview && !review.isIdleNext && standardOn) {
const pre = s.preRun || {};
const st = pre.stage || '';
if (st === 'zhuiwen_wait' || st === 'pre_zhuiwen') {
quickParts.push(`<button type="button" data-quick-zhuiwen-done="1">✅ 追问完成 → 进入检索</button>`);
}
if (st === 'search_wait' || st === 'pre_search') {
quickParts.push(`<button type="button" data-quick-search-done="1">✅ 检索完成 → 进入执行</button>`);
}
if (st === 'execute' || st === 'pre_execute') {
quickParts.push(`<button type="button" class="secondary" data-quick-next="1">▶ 确认执行下一步</button>`);
}
if (!st || st === 'zhuiwen') {
quickParts.push(`<button type="button" data-quick-zhuiwen="1">🔍 开始续跑前追问</button>`);
}
}
if (canManualContinue || canSend) {
quickParts.push(`<button type="button" class="secondary" data-quick-stop="1">⏹ 结束持久对话</button>`);
}
const quickBtns = quickParts.length
? `<div class="quick-btns">${quickParts.join('')}</div>`
: '';
const composerPh = minimalComposer
? '全自动续跑中 · 只有要插队改方向时才输入…'
: (canSend ? '输入你的回复或新任务Cmd+Enter 发送)...' : '未挂起也可输入 · 调整方向/新任务wait 后自动送达)');
const composerHint = minimalComposer
? '🔄 Hub 自动发「继续」· 一般不用手输 <span class="composer-expand" id="expandComposer">展开输入框</span>'
: (canSend ? '发前确认 token 与 Cursor 一致' : '未挂起时发送会排队 · Agent wait 后送达 Cursor');
const cfgAutoCls = autoOn ? 'on' : '';
const cfgTimedCls = timedOn ? 'on' : '';
const timedMs = getTimedMs(s);
det.innerHTML = `
<div class="header2">
<div class="title">${esc(sessionDisplayTitle(s))} ${titleBadge}</div>
<div class="sub">${s.msgCount} 条消息 · 间隔 ${esc(delayLbl)} · ${esc(MODE_LABELS[mode])}模式
<button class="ghost" style="margin-left:8px;padding:2px 8px;font-size:11px" onclick="deleteSession('${s.token}', ${!!s.hasPending})">删除</button>
</div>
<div class="control-bar">
<span class="chip-label">控制</span>
<label class="chip ${cfgAutoCls}" id="sessAutoToggle" title="自动续跑">
<input type="checkbox" id="sessAutoCb" ${autoOn ? 'checked' : ''} hidden /> 自动续跑
</label>
<label class="chip ${cfgTimedCls}" id="timedReplyToggle" title="定时保活Hub 按间隔发继续)">
<input type="checkbox" id="timedReplyCb" ${timedOn ? 'checked' : ''} hidden /> 自动定时
</label>
<div class="mode-switch" id="sessModeSwitch">
<button type="button" class="mode-btn${standardOn ? ' active' : ''}" data-mode="standard">复盘</button>
<button type="button" class="mode-btn turbo${turboOn ? ' active' : ''}" data-mode="turbo">直通</button>
<button type="button" class="mode-btn optimize${optimizeOn ? ' active' : ''}" data-mode="optimize">优化</button>
</div>
${TIMED_REPLY_FIXED.map((p) =>
`<button type="button" class="chip timed-preset${timedMs === p.ms ? ' on' : ''}" data-ms="${p.ms}">${p.label}</button>`
).join('')}
<label class="chip timed-custom${(timedMs > 0 && !TIMED_REPLY_FIXED.some((p) => p.ms === timedMs)) ? ' on' : ''}" title="自定义分钟">
<input id="timedCustomMin" type="number" min="1" max="999" placeholder="分" value="${(timedMs > 0 && !TIMED_REPLY_FIXED.some((p) => p.ms === timedMs)) ? Math.max(1, Math.round(timedMs / 60000)) : ''}" />
<span>自定义</span>
</label>
</div>
</div>
<div class="msg" id="msgBox">
${banner}
${reviewHtml}
${quickBtns}
${(waiting || autoRunning || s.hasPending) ? `
<div class="bubble assistant">
<strong>🤖 当前挂起回复</strong>
<pre class="text">${esc(stripCodeFence(s.ownPendingMessage || s.pendingMessage || (s.recentMessages || []).filter(m => m.role === 'assistant').slice(-1)[0]?.preview || ''))}</pre>
${s.pendingPrompt ? `<div style="color:var(--fg2);font-size:12px;margin-top:8px">${esc(s.pendingPrompt)}</div>` : ''}
</div>
` : '<div class="empty" style="margin:24px auto;font-size:13px">在 Cursor 发任务后,此处显示 Agent 挂起回复。</div>'}
${renderHistory(s)}
</div>
<div class="composer${minimalComposer ? ' minimal' : ''}">
<textarea id="replyBox" placeholder="${composerPh}">${esc(savedDraft && selected === autoSelectedToken ? savedDraft : '')}</textarea>
<div class="toolbar">
<span class="hint">${composerHint}</span>
<button id="sendBtn">${minimalComposer ? '插队发送 →' : (canSend ? '发送回复 →' : '排队发送 →')}</button>
</div>
<div class="legend">定时:自动=10秒保活 · 10秒/30秒/4分/9分 · 排队方向可单删</div>
</div>
`;
{
const ta = document.getElementById('replyBox');
if (ta) {
ta.addEventListener('focus', () => { pollPaused = true; pollPauseUntil = Date.now(); });
ta.addEventListener('blur', () => { pollPaused = false; savedDraft = ta.value; setTimeout(fetchState, 500); });
ta.addEventListener('input', () => { pollPaused = true; pollPauseUntil = Date.now(); savedDraft = ta.value; });
ta.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); send(); }
});
}
const sendBtn = document.getElementById('sendBtn');
if (sendBtn) sendBtn.onclick = send;
det.querySelectorAll('[data-quick-next]').forEach((btn) => {
btn.onclick = () => sendQuick('继续执行▶下一步:' + review.nextStep);
});
det.querySelectorAll('[data-quick-zhuiwen]').forEach((btn) => {
btn.onclick = () => sendQuick('【追问验收】按 Human 3.0 智能追问 SKILL 开始验收本阶段交付');
});
det.querySelectorAll('[data-quick-zhuiwen-done]').forEach((btn) => {
btn.onclick = () => sendQuick('追问完成');
});
det.querySelectorAll('[data-quick-search-done]').forEach((btn) => {
btn.onclick = () => sendQuick('检索完成');
});
det.querySelectorAll('[data-quick-stop]').forEach((btn) => {
btn.onclick = () => sendQuick('结束持久对话');
});
const mc = document.getElementById('manualContinueBtn');
if (mc) mc.onclick = () => manualContinue();
}
const sessCb = document.getElementById('sessAutoCb');
if (sessCb) {
sessCb.addEventListener('change', (e) => {
setSessionConfig(selected, { sessionAutoContinue: e.target.checked });
});
}
const sessToggle = document.getElementById('sessAutoToggle');
if (sessToggle) {
sessToggle.addEventListener('click', (e) => {
if (e.target.tagName === 'INPUT') return;
const c = document.getElementById('sessAutoCb');
if (!c) return;
c.checked = !c.checked;
setSessionConfig(selected, { sessionAutoContinue: c.checked });
});
}
const timedCb = document.getElementById('timedReplyCb');
if (timedCb) {
timedCb.addEventListener('change', (e) => {
setSessionConfig(selected, { sessionTimedReply: e.target.checked });
});
}
const timedToggle = document.getElementById('timedReplyToggle');
if (timedToggle) {
timedToggle.addEventListener('click', (e) => {
if (e.target.tagName === 'INPUT') return;
const c = document.getElementById('timedReplyCb');
if (!c) return;
c.checked = !c.checked;
setSessionConfig(selected, { sessionTimedReply: c.checked });
});
}
det.querySelectorAll('.timed-preset').forEach((btn) => {
btn.onclick = () => {
freezeControlBar();
setSessionConfig(selected, {
sessionTimedReply: true,
sessionTimedIntervalMs: Number(btn.getAttribute('data-ms') || 0),
});
};
});
const customMin = document.getElementById('timedCustomMin');
if (customMin) {
const applyCustom = () => {
const v = Number(customMin.value);
if (!v || v < 1) return;
freezeControlBar();
setSessionConfig(selected, { sessionTimedReply: true, sessionTimedIntervalMs: Math.round(v * 60000) });
};
customMin.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCustom(); } });
customMin.addEventListener('change', applyCustom);
customMin.closest('.timed-custom')?.addEventListener('click', (e) => { if (e.target !== customMin) applyCustom(); });
}
det.querySelectorAll('[data-steer-index]').forEach((btn) => {
btn.onclick = (e) => { e.stopPropagation(); deleteSteerItem(Number(btn.getAttribute('data-steer-index'))); };
});
const steerClear = document.getElementById('steerClearAll');
if (steerClear) steerClear.onclick = (e) => { e.stopPropagation(); clearSteerQueue(true); };
document.querySelectorAll('#sessModeSwitch .mode-btn').forEach((btn) => {
btn.onclick = () => setSessionConfig(selected, { continueMode: btn.getAttribute('data-mode') });
});
const expand = document.getElementById('expandComposer');
if (expand) expand.onclick = (e) => { e.preventDefault(); composerExpanded = true; lastDetailKey = ''; renderDetail(); };
det.dataset.selectedToken = selected || '';
const msgBoxAfter = document.getElementById('msgBox');
if (msgBoxAfter) {
msgBoxAfter.onscroll = () => { msgStickToBottom = isNearBottom(msgBoxAfter); };
if (msgStickToBottom || !sameSession) {
requestAnimationFrame(() => { msgBoxAfter.scrollTop = msgBoxAfter.scrollHeight; });
} else {
msgBoxAfter.scrollTop = msgScrollTop;
}
}
}
function renderHistory(s) {
const msgs = s.recentMessages || [];
if (!msgs.length) return '';
return `<div class="hist"><div style="color:var(--fg2);font-size:11px;margin:12px 0 6px">最近消息(${s.msgCount} 条)</div>` +
msgs.map(m => `<div class="bubble ${m.role === 'user' ? 'user' : 'assistant'}"><small>${m.role === 'user' ? (m.auto ? '👤 你(自动续跑)' : '👤 你') : '🤖 AI'} · ${m.at ? new Date(m.at).toLocaleTimeString() : ''}</small>${esc(m.preview)}</div>`).join('') +
'</div>';
}
function stripCodeFence(s){
const m = String(s).match(/^```text\n([\s\S]*)\n```\s*$/);
return m ? m[1] : String(s).replace(/```text|```/g, '').trim();
}
function parseKaruoReviewClient(message) {
const raw = stripCodeFence(message || '');
const hasReview = /\[卡若复盘\]|🎯\s*目标/.test(raw);
let completionPct = null;
const goalSection = raw.match(/🎯[\s\S]*?(?=\n\*\*📌|\n\*\*💡|\n\*\*📝|\n\*\*▶|$)/);
const pctMatch = goalSection && goalSection[0].match(/([0-9]{1,3})\s*%/);
if (pctMatch) completionPct = Math.min(100, Math.max(0, parseInt(pctMatch[1], 10)));
let nextStep = '';
const nextBlock = raw.match(/\*\*▶\s*下一步执行\*\*\s*\n([\s\S]*?)(?=\n\*\*[📌💡📝🎯▶]|$)/);
if (nextBlock) nextStep = nextBlock[1].trim().replace(/^[\d]+[.、]\s*/, '');
const isIdleNext = !nextStep || /^(无[。.]?|暂无|待命|等下一)/.test(nextStep);
return { hasReview, completionPct, nextStep, isIdleNext, needsZhuiwen: completionPct === 100 };
}
function renderRhythmRail(review, s) {
const pre = s.preRun || {};
const stage = pre.stage || s.phase || '';
const steps = [
{ id: 'work', label: '① 执行任务', done: true },
{ id: 'review', label: '② 卡若复盘', done: review.hasReview, active: !review.hasReview },
{ id: 'wait', label: '③ 挂起', done: review.hasReview && !review.isIdleNext, active: review.hasReview && review.isIdleNext },
];
if (!review.isIdleNext && review.hasReview) {
steps.push({ id: 'zhuiwen', label: '④ 追问', done: ['search','search_wait','execute','done'].includes(stage), active: ['zhuiwen','zhuiwen_wait','pre_zhuiwen'].includes(stage) });
steps.push({ id: 'search', label: '⑤ 检索', done: ['execute','done'].includes(stage), active: ['search','search_wait','pre_search'].includes(stage) });
steps.push({ id: 'run', label: '⑥ 续跑执行', done: stage === 'done', active: ['execute','pre_execute'].includes(stage) });
}
return `<div class="rhythm-rail">${steps.map(st =>
`<div class="rhythm-step ${st.done ? 'done' : (st.active ? 'active' : 'pending')}">${st.label}${st.done ? ' ✓' : ''}</div>`
).join('')}</div>`;
}
function renderReviewCard(review, s) {
const mode = sessionMode(s);
const turboOn = mode === 'turbo';
const optimizeOn = mode === 'optimize';
if (!review.hasReview) {
if (turboOn) {
return `<div class="review-card" style="border-color:rgba(89,211,156,.35)">⚡ 直通:无复盘块也会自动续跑。</div>`;
}
if (optimizeOn) {
return `<div class="review-card" style="border-color:rgba(155,107,255,.35)">✨ 优化Hub 将自动总结→检索→补文档→开发。</div>`;
}
return `<div class="review-card" style="border-color:rgba(255,107,107,.35)">⚠️ 复盘模式需完整 [卡若复盘] 五块,或切到直通/优化。</div>`;
}
const pct = review.completionPct != null ? `<span class="pct">${review.completionPct}%</span>` : '—';
const next = review.isIdleNext
? '<em style="color:var(--ok)">待命 · 保活续跑中</em>'
: esc(review.nextStep);
const pre = s.preRun || {};
const pipe = pre.stage ? `<div style="margin-top:4px;color:var(--accent)">续跑管道:${esc(pre.stage)}</div>` : '';
return `<div class="review-card">
<div><strong>🎯 达成率</strong> ${pct} · 阶段 ${esc(s.phase || 'running')}</div>
<div style="margin-top:6px"><strong>▶ 下一步</strong> ${next}</div>${pipe}
</div>`;
}
async function manualContinue() {
if (!selected) {
showToast('请先选择左侧会话', 'err');
return;
}
const primary = state.activeWaitToken || state.primaryWaitingToken;
if (primary && selected !== primary && !state.sessions.find(s => s.token === selected)?.connectionHeld) {
selected = primary;
userPinnedSelection = false;
lastSidebarKey = '';
lastDetailKey = '';
showToast('已切到活跃会话 ' + primary, 'info');
renderSidebar();
renderDetail();
return;
}
const btn = document.getElementById('manualContinueBtn');
if (btn) { btn.disabled = true; btn.textContent = '发送中…'; }
try {
const r = await fetch('/api/continue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: selected }),
});
const j = await r.json();
if (!j.ok) {
if (j.expected) {
selected = j.expected;
userPinnedSelection = false;
lastSidebarKey = '';
lastDetailKey = '';
await fetchState();
}
showToast(errHint(j.error || 'fail', j), 'err', 6000);
return;
}
lastSent[selected] = { at: Date.now(), text: (j.text || '继续').slice(0, 80), auto: false };
showToast('▶ 已发送继续 · Cursor 应马上开跑', 'ok', 4500);
lastSidebarKey = '';
lastDetailKey = '';
await fetchState();
} catch (e) {
showToast('继续失败', 'err');
} finally {
if (btn) { btn.disabled = false; btn.textContent = '▶ 点击继续开发'; }
}
}
async function sendQuick(text) {
if (!selected || !text.trim()) return;
const ta = document.getElementById('replyBox');
if (ta) ta.value = text;
await send();
}
function esc(s){ return String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'})[c]); }
async function send(){
const ta = document.getElementById('replyBox');
const text = ta.value;
if (!text.trim() || !selected) return;
const btn = document.getElementById('sendBtn');
btn.disabled = true; btn.textContent = '发送中…';
pollPaused = false;
savedDraft = '';
const s = state.sessions.find(x => x.token === selected);
try {
const r = await fetch('/api/reply', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ token: selected, reply: text, cursorTitle: s ? (s.cursorTitle || s.title || '') : '' }),
});
const j = await r.json();
if (!j.ok) {
const err = new Error(j.error || 'fail');
err.data = j;
throw err;
}
lastSent[selected] = { at: Date.now(), text: text.slice(0, 80), auto: false, queued: !!j.queued };
ta.value = '';
if (j.queued) {
showToast(`📥 已排队(${j.queueLen || 1}条)· Agent wait 后自动送达 Cursor`, 'ok', 5500);
} else {
showToast('✅ 发送成功 · 请看 Cursor Chat 是否继续', 'ok', 5000);
}
await fetchState();
} catch (e) {
showToast(errHint(String(e.message || e), e.data), 'err', 7000);
} finally {
btn.disabled = false;
btn.textContent = s && s.hasPending ? '发送回复 →' : '排队发送 →';
}
}
async function copyPrompt(){
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(STARTUP_PROMPT);
ok = true;
}
} catch (e) {}
if (!ok) {
try {
const ta = document.createElement('textarea');
ta.value = STARTUP_PROMPT;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
ok = document.execCommand('copy');
document.body.removeChild(ta);
} catch (e) {}
}
if (ok) {
showToast('✅ 已复制启动提示词 · 去 Cursor Chat 粘贴Cmd+V', 'ok', 4000);
} else {
showCopyModal();
}
}
function showCopyModal() {
const old = document.getElementById('copyModal');
if (old) old.remove();
const wrap = document.createElement('div');
wrap.id = 'copyModal';
wrap.className = 'copy-modal';
wrap.innerHTML = `<div class="box"><strong>复制启动提示词</strong><p style="color:var(--fg2);font-size:12px;margin:8px 0">自动复制失败,请 Cmd+A 全选后 Cmd+C</p><textarea readonly id="copyModalTa"></textarea><div style="display:flex;gap:8px;justify-content:flex-end"><button class="ghost" type="button" id="copyModalClose">关闭</button></div></div>`;
wrap.querySelector('#copyModalTa').value = STARTUP_PROMPT;
document.body.appendChild(wrap);
const ta = wrap.querySelector('#copyModalTa');
ta.focus();
ta.select();
wrap.querySelector('#copyModalClose').onclick = () => wrap.remove();
wrap.onclick = (e) => { if (e.target === wrap) wrap.remove(); };
}
function showNewTaskDialog() {
return new Promise((resolve) => {
const old = document.getElementById('newTaskModal');
if (old) old.remove();
const wrap = document.createElement('div');
wrap.id = 'newTaskModal';
wrap.className = 'copy-modal';
wrap.innerHTML = `<div class="box"><strong>🆕 新建任务</strong>
<p style="color:var(--fg2);font-size:12px;margin:8px 0">将归档同标签旧 ct_并 init newPlan 新线程</p>
<input id="newTaskName" type="text" placeholder="任务名称(如:工作手机微信控机)" style="width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;font-size:13px" />
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
<button type="button" class="ghost" id="newTaskCancel">取消</button>
<button type="button" id="newTaskOk">创建</button>
</div></div>`;
document.body.appendChild(wrap);
const input = wrap.querySelector('#newTaskName');
input.focus();
wrap.querySelector('#newTaskCancel').onclick = () => { wrap.remove(); resolve(null); };
wrap.querySelector('#newTaskOk').onclick = () => {
resolve((input.value || '').trim() || '新任务');
wrap.remove();
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') wrap.querySelector('#newTaskOk').click();
if (e.key === 'Escape') wrap.querySelector('#newTaskCancel').click();
});
wrap.onclick = (e) => { if (e.target === wrap) { wrap.remove(); resolve(null); } };
});
}
async function deleteSteerItem(index) {
if (!selected || !Number.isInteger(index)) return;
try {
const r = await fetch('/api/steer/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: selected, index }),
});
const j = await r.json();
if (!j.ok) { showToast('删除排队失败', 'err'); return; }
showToast('已删除该条排队', 'ok');
lastDetailKey = '';
await fetchState();
} catch (e) { showToast('删除排队失败', 'err'); }
}
async function clearSteerQueue(needConfirm) {
if (!selected) return;
if (needConfirm && !confirm('清空本 ct_ 全部排队方向?')) return;
try {
const r = await fetch('/api/steer/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: selected, all: true }),
});
const j = await r.json();
if (!j.ok) { showToast('清空失败', 'err'); return; }
showToast('已清空排队', 'ok');
lastDetailKey = '';
await fetchState();
} catch (e) { showToast('清空失败', 'err'); }
}
async function deleteSession(token, hasPending) {
if (!token) return;
let force = false;
if (hasPending) {
if (!confirm('该会话可能仍在挂起,确定强制删除?')) return;
force = true;
} else if (!confirm('确定删除此会话?')) {
return;
}
try {
const r = await fetch('/api/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, force: !!force || !!hasPending }),
});
const j = await r.json();
if (!j.ok) {
showToast(j.error === 'waiting' ? '会话仍在等待,已尝试强制删除' : ('删除失败: ' + (j.error || 'unknown')), 'err');
if (j.error === 'waiting' && !force) {
const r2 = await fetch('/api/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, force: true }) });
const j2 = await r2.json();
if (j2.ok) { showToast('已强制删除', 'ok'); if (selected === token) selected = null; await fetchState(); return; }
}
return;
}
showToast('已删除 ' + token, 'ok');
if (selected === token) selected = null;
await fetchState();
} catch (e) {
showToast('删除失败', 'err');
}
}
async function pruneIdleSessions() {
if (!confirm('清理 72 小时未活动的空闲会话?(挂起中/当前选中/垂直绑定近期会话会保留)')) return;
try {
const r = await fetch('/api/delete-idle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep: selected, maxIdleHours: 72 }),
});
const j = await r.json();
if (j.ok) {
showToast(`已清理 ${j.deleted} 条,剩余 ${j.remain}`, 'ok', 4000);
await fetchState();
}
} catch (e) {
showToast('清理失败', 'err');
}
}
async function createNewSession(force, newPlan, planLabelOverride) {
try {
let planLabel = planLabelOverride;
if (newPlan && planLabel == null) {
planLabel = await showNewTaskDialog();
if (planLabel === null) return;
}
planLabel = String(planLabel || '').trim();
const r = await fetch('/api/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: planLabel || '新对话',
planLabel,
cursorTitle: planLabel || '面板任务',
force: !!(force || newPlan),
newPlan: !!newPlan,
}),
});
const j = await r.json();
if (j.ok) {
userPinnedSelection = true;
selected = j.token;
savePinnedToken(j.token);
lastSidebarKey = '';
lastDetailKey = '';
showToast(newPlan ? `🆕 新建任务 ${j.token}${planLabel ? ' · ' + planLabel : ''}` : ('已新建 ' + j.token), 'ok', 4500);
await fetchState();
return;
}
if (j.error === 'SESSION_LIST_NOT_EMPTY') {
showRestoreModal('已有会话,选一个恢复,或强制新建:');
return;
}
showToast('新建失败: ' + (j.error || 'unknown'), 'err');
} catch (e) {
showToast('新建失败', 'err');
}
}
function showRestoreModal(hint) {
const old = document.getElementById('restoreModal');
if (old) old.remove();
const wrap = document.createElement('div');
wrap.id = 'restoreModal';
wrap.className = 'copy-modal';
const items = (state.sessions || []).map(s =>
`<button type="button" class="ghost" style="display:block;width:100%;text-align:left;margin:6px 0;padding:10px" data-pick="${esc(s.token)}">
<strong>${esc(sessionDisplayTitle(s))}</strong><br><span style="font-size:11px;color:var(--fg2)">${esc(s.token)} · ${s.msgCount} 条 · ${esc(s.status)}</span>
</button>`).join('') || '<p style="color:var(--fg2)">暂无会话,点「+ 新建」</p>';
wrap.innerHTML = `<div class="box"><strong>🔄 恢复对话</strong>
<p style="color:var(--fg2);font-size:12px;margin:8px 0">${esc(hint || '选择要恢复的会话:')}</p>
${items}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
<button type="button" class="ghost" id="restoreForceNew">强制新建</button>
<button type="button" class="ghost" id="restoreClose">关闭</button>
</div></div>`;
document.body.appendChild(wrap);
wrap.querySelectorAll('[data-pick]').forEach(btn => {
btn.onclick = () => { selectSession(btn.getAttribute('data-pick')); wrap.remove(); showToast('已恢复 ' + selected, 'ok'); };
});
const close = wrap.querySelector('#restoreClose');
if (close) close.onclick = () => wrap.remove();
const forceNew = wrap.querySelector('#restoreForceNew');
if (forceNew) forceNew.onclick = async () => { wrap.remove(); await createNewSession(true); };
wrap.onclick = (e) => { if (e.target === wrap) wrap.remove(); };
}
window.copyPrompt = copyPrompt;
window.deleteSession = deleteSession;
window.clearSteerQueue = clearSteerQueue;
window.deleteSteerItem = deleteSteerItem;
window.jumpToWaiting = jumpToWaiting;
function bindUi() {
const ver = document.getElementById('panelVer');
if (ver) ver.textContent = 'v' + PANEL_VERSION;
const cp = document.getElementById('copyPrompt');
if (cp) cp.onclick = (e) => { e.preventDefault(); e.stopPropagation(); copyPrompt(); closeMoreMenu(); };
document.body.addEventListener('click', (e) => {
const btn = e.target.closest('[data-copy-prompt]');
if (btn) { e.preventDefault(); copyPrompt(); }
const menu = document.getElementById('moreMenu');
const moreBtn = document.getElementById('moreBtn');
if (menu && menu.classList.contains('open') && !e.target.closest('.more-menu')) {
menu.classList.remove('open');
}
});
const prune = document.getElementById('pruneBtn');
if (prune) prune.onclick = (e) => { e.stopPropagation(); pruneIdleSessions(); closeMoreMenu(); };
const npb = document.getElementById('newPlanBtn');
if (npb) npb.onclick = (e) => { e.stopPropagation(); createNewSession(true, true); closeMoreMenu(); };
const sidebarNew = document.getElementById('sidebarNewTask');
if (sidebarNew) sidebarNew.onclick = (e) => { e.stopPropagation(); createNewSession(true, true); };
const sidebarCopy = document.getElementById('sidebarCopyPrompt');
if (sidebarCopy) sidebarCopy.onclick = (e) => { e.stopPropagation(); copyPrompt(); };
const moreBtn = document.getElementById('moreBtn');
const moreMenu = document.getElementById('moreMenu');
if (moreBtn && moreMenu) {
moreBtn.onclick = (e) => {
e.stopPropagation();
moreMenu.classList.toggle('open');
};
}
const ctxMenu = document.getElementById('sessCtxMenu');
if (ctxMenu) {
ctxMenu.addEventListener('click', (e) => e.stopPropagation());
ctxMenu.querySelector('[data-ctx-pin]')?.addEventListener('click', () => {
if (ctxMenuToken) togglePinToken(ctxMenuToken);
hideSessContextMenu();
});
ctxMenu.querySelector('[data-ctx-rename]')?.addEventListener('click', () => {
if (ctxMenuToken) renameSessionTitle(ctxMenuToken);
});
ctxMenu.querySelector('[data-ctx-open]')?.addEventListener('click', () => {
if (ctxMenuToken) { selectSession(ctxMenuToken); hideSessContextMenu(); }
});
ctxMenu.querySelector('[data-ctx-del]')?.addEventListener('click', () => {
const t = ctxMenuToken;
hideSessContextMenu();
if (t) deleteSession(t, false);
});
}
document.addEventListener('click', () => hideSessContextMenu());
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideSessContextMenu(); });
}
function closeMoreMenu() {
const menu = document.getElementById('moreMenu');
if (menu) menu.classList.remove('open');
}
bindUi();
if (window.__PCHAT_BOOT__) {
state.sessions = window.__PCHAT_BOOT__.sessions || [];
state.settings = window.__PCHAT_BOOT__.settings || state.settings;
state.activeWaitToken = window.__PCHAT_BOOT__.activeWaitToken || null;
const pinned = loadPinnedToken();
if (pinned && state.sessions.find((s) => s.token === pinned)) {
selected = pinned;
userPinnedSelection = true;
} else {
selected = pickDefaultSelection();
}
renderSidebar();
renderDetail();
updateTaskFooter();
}
if ('Notification' in window && Notification.permission === 'default') {
setTimeout(() => Notification.requestPermission().catch(()=>{}), 1000);
}
setInterval(() => { if (!isTyping()) fetchState(); }, 2000);
fetchState().catch((e) => {
console.error('fetchState failed', e);
showToast('面板加载失败,请 Cmd+Shift+R 硬刷新', 'err', 6000);
});
</script>
</body>
</html>