- 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 插件,可同时跑
1744 lines
82 KiB
HTML
Executable File
1744 lines
82 KiB
HTML
Executable File
<!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>
|
||
</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 Chat(Agent 模式),或点左侧 <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_conversation(cursorTitle=当前标签名)。
|
||
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 已 wait(Hub 有 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 已 wait,Hub 自动续跑中' };
|
||
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 Chat(Agent 模式)作为第一条消息。<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 => ({'&':'&','<':'<','>':'>'})[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>
|