Files
persistent-chat-plugin/web/panel.html

1841 lines
87 KiB
HTML
Executable File
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.abandoned { opacity:.62; }
aside .sess.abandoned .badge { color:var(--fg2); }
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>
</head>
<body>
<div class="app-shell">
<div id="vsixBanner" class="alert-bar" style="display:flex;background:linear-gradient(90deg,#2563eb,#7c3aed);cursor:default">
<span>🔌 <strong>VSIX 4.6.0 官方</strong> · 主界面=<strong>Cursor 侧栏</strong>(开 MCP + 持续对话)· Hub <code>http://localhost:13458</code> 排队续跑</span>
</div>
<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 · VSIX 4.6.0 Hub <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> 挂起;之后从本面板回复即可。<br>
<small style="color:var(--fg2)">MCP 4 工具init · select · wait · merge · 与 Cursor 侧栏 4tools 一致</small>
</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.70';
const MCP_4TOOLS = ['init_conversation', 'select_conversation', 'wait_for_user_input', 'merge_conversation'];
let hubMeta = { vsixCompat: '4.6.0', vsixVariant: '4tools', mcpToolCount: 4, panelVersion: PANEL_VERSION };
function isVsix4Tools() {
return hubMeta.vsixVariant === '4tools' || (Number(hubMeta.mcpToolCount) === 4 && hubMeta.vsixVariant !== 'standard');
}
function fetchWithTimeout(url, ms, init = {}) {
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
return fetch(url, { ...init, signal: AbortSignal.timeout(ms) });
}
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), ms);
return fetch(url, { ...init, signal: ac.signal }).finally(() => clearTimeout(timer));
}
const SIDEBAR_ACTIVE_MS = 2 * 60 * 60 * 1000;
const PIN_STORAGE_KEY = 'pchat_pinned_token';
const PINNED_LIST_KEY = 'pchat_pinned_tokens';
/** v1.6.68/?token=ct_xxx 深链选中并聚焦回复框 */
let deepLinkToken = null;
try {
const _dl = new URLSearchParams(location.search);
deepLinkToken = _dl.get('token') || _dl.get('ct');
} catch {}
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 MCPVSIX 4.6.0-4tools · 仅 4 工具):
1. select_conversation / init_conversationcursorTitle=当前标签名)。
2. **新计划必须 newPlan**:无关新任务 → init_conversation(newPlan:true, planLabel=计划名, cursorTitle=标签名)。
3. 每轮:执行 → **[卡若复盘]** 🎯📌💡📝▶ → wait_for_user_input 长挂起;**每轮只 wait 一次**。
4. **[RENEWAL] 零工**:同 turn 立刻 renewalOnly 再 wait禁止 RENEWAL 之间夹 Read/Shell。
5. 续跑在 **13458 面板** 或 **侧栏** 发下一条;勿在 Cursor 聊天打「继续」。
6. 「结束持久对话」才停循环。
MCP 4 工具init_conversation · select_conversation · wait_for_user_input · merge_conversation
第一项任务:`;
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 || s.isAbandoned || s.status === 'abandoned') return false;
return isAgentSuspended(s) || !!(s.autoRunning || s.isActiveWait || s.connectionHeld || s.isHung || s.status === 'holding');
}
/** 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) {
if (isVsix4Tools()) return s.sessionAutoContinue === true;
return s.sessionAutoContinue !== false;
}
function sessionTimedOn(s) {
if (isVsix4Tools()) return s.sessionTimedReply === true;
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;
});
}
function setConnected(ok, meta) {
const connEl = document.getElementById('conn');
if (!connEl) return;
if (ok) {
const variant = (meta && meta.vsixVariant) || hubMeta.vsixVariant || '4tools';
const tools = (meta && meta.mcpToolCount) || hubMeta.mcpToolCount || 4;
connEl.innerHTML = `<span class="dot"></span> 已连接 · ${variant} · MCP ${tools}`;
connEl.className = 'conn-pill ok';
} else {
connEl.innerHTML = '<span class="dot warn"></span> 离线';
connEl.className = 'conn-pill';
}
}
async function pingHealth() {
try {
const r = await fetchWithTimeout('/api/health', 8000, { cache: 'no-store' });
if (!r.ok) throw new Error('health ' + r.status);
const d = await r.json();
hubMeta = { ...hubMeta, ...d };
setConnected(true, d);
const ver = document.getElementById('panelVer');
if (ver) ver.textContent = `v${d.panelVersion || PANEL_VERSION} · MCP ${d.mcpToolCount || 4}`;
return true;
} catch (e) {
console.warn('pingHealth failed', e);
setConnected(false);
return false;
}
}
async function fetchState(){
if (isTyping() && Date.now() < pollPauseUntil + 120000) return;
try {
const r = await fetchWithTimeout('/api/state?lite=1', 45000, { cache: 'no-store' });
if (!r.ok) throw new Error('state ' + r.status);
state = await r.json();
if (!Array.isArray(state.sessions)) state.sessions = [];
if (state.vsixVariant) hubMeta.vsixVariant = state.vsixVariant;
if (state.mcpToolCount) hubMeta.mcpToolCount = state.mcpToolCount;
setConnected(true, hubMeta);
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();
applyDeepLinkToken();
} catch (e) {
console.error('fetchState failed', e);
setConnected(false);
}
}
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 applyDeepLinkToken() {
if (!deepLinkToken) return;
const tok = deepLinkToken;
deepLinkToken = null;
const hit = state.sessions.find((s) => s.token === tok);
if (!hit) return;
selectSession(tok);
userPinnedSelection = true;
const ta = document.getElementById('replyBox');
if (ta) {
ta.focus();
ta.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
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: '待wait', cls: 'suspend-auto', title: '仅保活连接Cursor 尚未调用 wait_for_user_input' };
return null;
}
function sessionBadge(s) {
if (s.isAbandoned || s.status === 'abandoned') {
return { text: '废弃', cls: 'idle' };
}
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');
if (s.isAbandoned || s.status === 'abandoned') cls.push('abandoned');
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) {
const sub = (s.panelSubTask || review.nextStep || '').trim();
if (sub && !review.isIdleNext) {
banner += `<div class="status-banner ok" style="border-color:rgba(89,211,156,.5)">📋 <strong>子任务已挂起</strong> · 请到本面板点「▶ 点击继续开发」分配执行<br/><span style="opacity:.9;font-size:13px">${esc(sub)}</span><br/><small style="opacity:.75">勿在 Cursor 对话框输入「继续」</small></div>`;
} else {
banner += `<div class="status-banner ok">⏸ <strong>已挂起</strong> · 请在本面板点「继续开发」或发消息续跑</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);
const autoTimedControls = isVsix4Tools()
? `<span class="chip on" title="4tools仅面板/侧栏手动发令Hub 不自动续跑">4tools · 手动发令</span>
<span class="chip" title="MCP 4 工具与 Cursor 侧栏一致">MCP 4 · init/select/wait/merge</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>`;
const timedPresetControls = isVsix4Tools()
? ''
: `${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>`;
const composerLegend = isVsix4Tools()
? '4tools本面板输入 → 排队 → Agent wait 首 poll 送达 Cursor → 自动续跑 ▶(勿开 turbo 自动续跑)'
: '定时:自动=10秒保活 · 10秒/30秒/4分/9分 · 排队方向可单删';
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>
${autoTimedControls}
<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>
${timedPresetControls}
</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">${composerLegend}</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 将自动续跑stop hook', 'ok', 5500);
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);
pingHealth().finally(() => {
fetchState().catch((e) => {
console.error('fetchState failed', e);
showToast('面板加载失败,请 Cmd+Shift+R 硬刷新', 'err', 6000);
});
});
</script>
</body>
</html>