同步 enhance/bridge/smoke/retire 模块、vendor 3.3.34、增强面板与 PRD 文档, Hub 接入 hub-routes-prd,含安装/经验/模块说明与 co-integration 运维脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
630 lines
32 KiB
HTML
Executable File
630 lines
32 KiB
HTML
Executable File
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Persistent Chat · 本地面板</title>
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<meta name="pchat-panel-version" content="20260629-v4" />
|
||
<style>
|
||
:root {
|
||
--bg: #0f1115; --panel: #161a22; --panel2: #1c2230; --line: #262d3d;
|
||
--fg: #e7ecf3; --fg2: #9aa3b2; --accent: #5cc8ff; --ok: #59d39c; --warn: #ffb547; --err: #ff6b6b;
|
||
}
|
||
@media (prefers-color-scheme: light) {
|
||
:root { --bg:#fafbff; --panel:#fff; --panel2:#f3f5fb; --line:#e3e7f1; --fg:#1b1f2a; --fg2:#5a6373; --accent:#1c84ff; --ok:#16a86a; --warn:#c87f00; --err:#d4423a; }
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html,body { margin:0; padding:0; background:var(--bg); color:var(--fg); font:14px/1.5 -apple-system,BlinkMacSystemFont,'PingFang SC','Helvetica Neue',sans-serif; height:100%; }
|
||
body { display:flex; flex-direction:column; height:100vh; }
|
||
header { padding:12px 16px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; background:var(--panel); flex-wrap:wrap; }
|
||
header h1 { margin:0; font-size:14px; font-weight:600; }
|
||
header .ws { color:var(--fg2); font-size:12px; word-break:break-all; }
|
||
header .right { margin-left:auto; display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
|
||
button { background:var(--accent); color:white; border:0; border-radius:6px; padding:6px 12px; font-size:13px; cursor:pointer; }
|
||
button.ghost { background:transparent; color:var(--fg2); border:1px solid var(--line); }
|
||
button:hover { filter:brightness(1.08); }
|
||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||
.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 { display:flex; flex:1; min-height:0; }
|
||
aside { width:300px; border-right:1px solid var(--line); background:var(--panel); overflow:auto; }
|
||
aside .sess { padding:10px 12px; border-bottom:1px solid var(--line); cursor:pointer; position:relative; }
|
||
aside .sess:hover { background:var(--panel2); }
|
||
aside .sess.active { background:var(--panel2); border-left:3px solid var(--accent); }
|
||
aside .sess.waiting { background:linear-gradient(90deg,rgba(255,138,0,.12),transparent); }
|
||
aside .sess.waiting.active { background:linear-gradient(90deg,rgba(255,138,0,.22),var(--panel2)); }
|
||
aside .sess .title { font-weight:600; font-size:13px; }
|
||
aside .sess .meta { font-size:11px; color:var(--fg2); margin-top:2px; display:flex; gap:8px; align-items:center; }
|
||
.badge { padding:1px 6px; border-radius:10px; font-size:10px; font-weight:600; }
|
||
.badge.waiting { background:rgba(255,138,0,.2); color:#ff8a00; }
|
||
.badge.idle { background:rgba(154,163,178,.15); color:var(--fg2); }
|
||
section.detail { flex:1; display:flex; flex-direction:column; min-width:0; background:var(--bg); }
|
||
.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:12px 16px; border-bottom:1px solid var(--line); background:var(--panel); }
|
||
.header2 .title { font-weight:600; }
|
||
.header2 .sub { color:var(--fg2); font-size:12px; margin-top:2px; }
|
||
.msg { flex:1; overflow:auto; padding:16px; }
|
||
.bubble { background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:12px 14px; margin-bottom:12px; white-space:pre-wrap; word-break:break-word; font-size:13px; line-height:1.6; }
|
||
.bubble.assistant { border-left:3px solid var(--accent); }
|
||
.bubble.user { border-left:3px solid var(--ok); }
|
||
.composer { border-top:1px solid var(--line); padding:12px 16px; background:var(--panel); }
|
||
textarea { width:100%; min-height:90px; max-height:240px; background:var(--bg); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:10px; font:13px/1.5 -apple-system,BlinkMacSystemFont,'PingFang SC',monospace; resize:vertical; }
|
||
.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); }
|
||
/* PRD 进度条 */
|
||
.prd-bar { flex-shrink:0; padding:10px 16px; background:var(--panel); border-bottom:1px solid var(--line); }
|
||
.prd-bar .top { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
|
||
.prd-bar .pct { font-size:22px; font-weight:700; color:var(--accent); min-width:52px; }
|
||
.prd-bar .track { flex:1; height:8px; background:var(--panel2); border-radius:4px; overflow:hidden; border:1px solid var(--line); }
|
||
.prd-bar .fill { height:100%; background:linear-gradient(90deg,var(--accent),var(--ok)); transition:width .4s ease; }
|
||
.prd-bar .meta { font-size:11px; color:var(--fg2); }
|
||
.prd-checklist { display:flex; flex-wrap:wrap; gap:6px; max-height:72px; overflow:auto; }
|
||
.prd-item { font-size:11px; padding:3px 8px; border-radius:10px; border:1px solid var(--line); color:var(--fg2); background:var(--panel2); }
|
||
.prd-item.done { color:var(--ok); border-color:rgba(89,211,156,.35); background:rgba(89,211,156,.1); }
|
||
.prd-item.done::before { content:'✓ '; }
|
||
/* 增强设置 */
|
||
.tabs { display:flex; gap:4px; margin-left:8px; }
|
||
.tabs button { padding:4px 10px; font-size:12px; min-height:auto; }
|
||
.tabs button.active { box-shadow:inset 0 0 0 2px rgba(255,255,255,.3); }
|
||
.enhance-view { flex:1; overflow:auto; padding:16px; display:none; }
|
||
.enhance-view.active { display:block; }
|
||
.gate-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:10px; margin:12px 0; }
|
||
.gate { padding:12px; border-radius:8px; border:1px solid var(--line); background:var(--panel2); }
|
||
.gate.ok { border-color:rgba(89,211,156,.4); }
|
||
.gate.bad { border-color:rgba(255,107,107,.4); }
|
||
.gate.pending { border-color:rgba(255,181,71,.55); }
|
||
.gate .label { font-size:11px; color:var(--fg2); }
|
||
.gate .val { font-size:15px; font-weight:600; margin-top:4px; }
|
||
.wizard-btns { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; }
|
||
.section-title { font-weight:600; margin:16px 0 8px; font-size:13px; }
|
||
.log-box { background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:10px; font-size:11px; max-height:160px; overflow:auto; white-space:pre-wrap; }
|
||
.chat-view { display:flex; flex:1; min-height:0; }
|
||
.chat-view.hidden { display:none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<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>
|
||
<h1>Persistent Chat · 本地版</h1>
|
||
<div class="tabs">
|
||
<button class="ghost active" id="tabChat" type="button">💬 对话</button>
|
||
<button class="ghost" id="tabEnhance" type="button">⚡ 增强</button>
|
||
</div>
|
||
<span class="ws" id="ws"></span>
|
||
<div class="right">
|
||
<span id="conn"><span class="dot"></span> 已连接</span>
|
||
<button id="copyPrompt" class="copy-btn">📋 复制启动提示词</button>
|
||
<button class="ghost" id="newBtn">+ 新建</button>
|
||
<button class="ghost" id="refresh">刷新</button>
|
||
</div>
|
||
</header>
|
||
<div class="prd-bar" id="prdBar">
|
||
<div class="top">
|
||
<span class="pct" id="prdPct">0%</span>
|
||
<div class="track"><div class="fill" id="prdFill" style="width:0%"></div></div>
|
||
<span class="meta" id="prdMeta">PRD 加载中…</span>
|
||
</div>
|
||
<div class="prd-checklist" id="prdList"></div>
|
||
</div>
|
||
<main>
|
||
<div class="chat-view" id="chatView">
|
||
<aside id="sessList"></aside>
|
||
<section class="detail" id="detail">
|
||
<div class="empty">
|
||
<div class="big">还没开始?</div>
|
||
点 <button class="copy-btn" style="display:inline-block" onclick="copyPrompt()">📋 复制启动提示词</button> 粘贴到 Cursor Chat(Agent 模式)作为第一条消息。<br><br>
|
||
Agent 会自动调用 <code>init_conversation</code> 创建会话,然后通过 <code>wait_for_user_input</code> 挂起等你回复。<br>
|
||
之后你的每条回复都从这个面板发,Cursor 那边任务永不中断。
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<div class="enhance-view" id="enhanceView">
|
||
<div class="section-title">核心增强包(无 CO 卡密)</div>
|
||
<label style="display:flex;align-items:center;gap:8px;font-size:13px">
|
||
<input type="checkbox" id="enhToggle" /> 开启核心增强包
|
||
</label>
|
||
<div class="gate-grid" id="gateGrid"></div>
|
||
<div class="wizard-btns">
|
||
<button type="button" id="btnDiagnose">① 诊断</button>
|
||
<button type="button" id="btnPermFix">② W2 一键修权限</button>
|
||
<button type="button" class="ghost" id="btnOpenW2Term">打开 Terminal</button>
|
||
<button type="button" class="ghost" id="btnFixPrefs">W6 修 Cursor 前置</button>
|
||
<button type="button" id="btnApply">③ 应用补丁 W7</button>
|
||
<button type="button" id="btnRepair">修复增强</button>
|
||
</div>
|
||
<div class="section-title">无额度重试(FR-ENH-08)</div>
|
||
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;font-size:13px">
|
||
<label>速率 <select id="nqSpeed">
|
||
<option value="extreme">extreme</option>
|
||
<option value="fast">fast</option>
|
||
<option value="balanced">balanced</option>
|
||
<option value="conservative">conservative</option>
|
||
</select></label>
|
||
<button type="button" class="ghost" id="btnSaveNq">保存</button>
|
||
</div>
|
||
<details style="margin-top:10px;font-size:12px;color:var(--fg2)">
|
||
<summary>高级参数</summary>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
|
||
<label>delayMs <input id="nqDelay" type="number" style="width:80px" /></label>
|
||
<label>debounceMs <input id="nqDebounce" type="number" style="width:80px" /></label>
|
||
<label>queueMax <input id="nqQueue" type="number" style="width:80px" /></label>
|
||
<label>rate/s <input id="nqRate" type="number" style="width:80px" /></label>
|
||
</div>
|
||
</details>
|
||
<div class="section-title">双 Chat 模板(FR-TPL)</div>
|
||
<div id="modeConflict" class="gate bad" style="display:none;margin-bottom:10px;padding:10px;border-radius:8px">
|
||
<div class="label">规则互斥</div>
|
||
<div class="val" id="modeConflictMsg"></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||
<button type="button" class="ghost" id="btnTplA">📋 A·续跑 模板</button>
|
||
<button type="button" class="ghost" id="btnTplB">📋 B·增强 模板</button>
|
||
<button type="button" class="ghost" id="btnModeCheck">检测互斥</button>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--fg2)" id="tplHint">模式 A → persistent-chat.mdc · 模式 B → pchat-composer.mdc</div>
|
||
<div class="section-title">Phase 3 验收</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||
<button type="button" id="btnSmoke">▶ 运行冒烟测试</button>
|
||
<button type="button" class="ghost" id="btnRetireCheck">检查退役前置</button>
|
||
</div>
|
||
<div class="section-title">操作日志</div>
|
||
<div class="log-box" id="enhLog">等待操作…</div>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
const STARTUP_PROMPT = `请使用 persistent-chat MCP 工具持久运行:
|
||
1. 现在立刻调用 persistent-chat 的 select_conversation 看是否有活跃会话;若没有就调 init_conversation 创建一个,记下返回的 pchat-token。
|
||
2. 在完成每一轮工作(包括复盘)之后,必须调用 wait_for_user_input(pchat-token=<上面那个>, message=本轮回复用 \`\`\`text 包裹) 挂起等我回复。
|
||
3. 我从 http://127.0.0.1:13458 的面板回复后,把回复当成我的下一条消息继续推进;然后再次调用 wait_for_user_input 挂起。如此无限循环,直到我明确说「结束」。
|
||
4. 中途不要因为「任务已完成」「避免循环」「分析已充分」等任何理由停止调用 wait_for_user_input。
|
||
|
||
第一项任务:`;
|
||
|
||
let state = { sessions: [], workspace: '' };
|
||
let selected = null;
|
||
let autoSelectedToken = null; // 记录我们自动选中过的 waiting token,避免重复抢焦点
|
||
let notifiedTokens = new Set();
|
||
|
||
async function fetchState(){
|
||
try {
|
||
const r = await fetch('/api/state', { cache: 'no-store' });
|
||
state = await r.json();
|
||
document.getElementById('ws').textContent = state.workspace || '';
|
||
document.getElementById('conn').innerHTML = '<span class="dot"></span> 已连接';
|
||
|
||
// 自动选中第一个 waiting 会话(如果用户没主动选过别的)
|
||
const waiting = state.sessions.filter(s => s.status === 'waiting');
|
||
if (waiting.length > 0) {
|
||
const first = waiting[0];
|
||
if (!selected || selected === autoSelectedToken) {
|
||
selected = first.token;
|
||
autoSelectedToken = first.token;
|
||
}
|
||
// 桌面通知
|
||
for (const w of waiting) {
|
||
if (!notifiedTokens.has(w.token + ':' + w.pendingSince)) {
|
||
notifiedTokens.add(w.token + ':' + w.pendingSince);
|
||
notify(w.title, '等待你回复');
|
||
}
|
||
}
|
||
// 顶部提示条
|
||
document.getElementById('alertBar').style.display = 'flex';
|
||
document.getElementById('alertText').textContent = `有 ${waiting.length} 个会话正在等待你回复`;
|
||
} else {
|
||
document.getElementById('alertBar').style.display = 'none';
|
||
}
|
||
render();
|
||
} catch (e) {
|
||
document.getElementById('conn').innerHTML = '<span class="dot warn"></span> 离线';
|
||
}
|
||
}
|
||
|
||
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.sessions.find(s => s.status === 'waiting');
|
||
if (w) { selected = w.token; render(); }
|
||
}
|
||
|
||
function render(){
|
||
const list = document.getElementById('sessList');
|
||
list.innerHTML = '';
|
||
if (state.sessions.length === 0) {
|
||
list.innerHTML = '<div style="padding:20px;color:var(--fg2);font-size:12px;text-align:center;line-height:1.7">尚无会话。<br>点顶部「📋 复制启动提示词」<br>粘贴到 Cursor Chat (Agent 模式) <br>作为第一条消息发送。</div>';
|
||
}
|
||
for (const s of state.sessions) {
|
||
const div = document.createElement('div');
|
||
const cls = ['sess'];
|
||
if (selected === s.token) cls.push('active');
|
||
if (s.status === 'waiting') cls.push('waiting');
|
||
div.className = cls.join(' ');
|
||
div.innerHTML = `
|
||
<div class="title">${esc(s.title)}</div>
|
||
<div class="meta">
|
||
<span class="badge idle">${esc(s.modeBadge || 'A·续跑')}</span>
|
||
<span class="badge ${s.status}">${s.status === 'waiting' ? '⏳ 等待中' : s.status}</span>
|
||
<span>${s.msgCount} 条</span>
|
||
<span>${s.token}</span>
|
||
</div>`;
|
||
div.onclick = () => { selected = s.token; render(); };
|
||
list.appendChild(div);
|
||
}
|
||
renderDetail();
|
||
}
|
||
|
||
function renderDetail(){
|
||
const det = document.getElementById('detail');
|
||
const s = state.sessions.find(x => x.token === selected);
|
||
if (!s) {
|
||
det.innerHTML = `<div class="empty">
|
||
<div class="big">还没开始?</div>
|
||
点 <button class="copy-btn" style="display:inline-block" onclick="copyPrompt()">📋 复制启动提示词</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';
|
||
det.innerHTML = `
|
||
<div class="header2">
|
||
<div class="title">${esc(s.title)} ${waiting ? '<span class="badge waiting" style="margin-left:8px">⏳ 等待你的回复</span>' : ''}</div>
|
||
<div class="sub">${s.token} · ${s.msgCount} 条 · ${esc(s.modeBadge || 'A·续跑')} · 工作区: ${esc(s.workspace || '')}</div>
|
||
</div>
|
||
<div class="msg" id="msgBox">
|
||
<div class="quick" style="margin-bottom:12px">
|
||
<strong>FR-BRIDGE</strong>:将 Composer/增强结论同步到本 ct_,触发卡若复盘续跑。
|
||
<textarea id="bridgeBox" placeholder="粘贴 Composer 结论…" style="width:100%;min-height:60px;margin-top:8px;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:12px"></textarea>
|
||
<button type="button" id="bridgeBtn" style="margin-top:8px">同步增强结论 → 本 ct_</button>
|
||
<span id="bridgeHint" style="margin-left:8px;font-size:11px;color:var(--fg2)"></span>
|
||
</div>
|
||
${waiting ? `
|
||
<div class="quick">💡 Agent 已经挂起在等你。在下方输入回复,按 <code>Cmd+Enter</code> 发送,Cursor 那边的任务会自动从上次的位置继续,不会重启 / 不会断 / 不会消耗新对话额度。</div>
|
||
<div class="bubble assistant">
|
||
<strong>🤖 AI 当前回复:</strong>
|
||
<pre class="text">${esc(stripCodeFence(s.pendingMessage || ''))}</pre>
|
||
${s.pendingPrompt ? `<div style="color:var(--fg2);font-size:12px;margin-top:8px">📝 提示:${esc(s.pendingPrompt)}</div>` : ''}
|
||
</div>
|
||
` : '<div class="empty" style="margin:40px auto">😴 当前会话没在等待。等 AI 完成下一轮任务并调用 <code>wait_for_user_input</code>,这里会自动出现回复入口。</div>'}
|
||
</div>
|
||
<div class="composer">
|
||
<textarea id="replyBox" placeholder="${waiting ? '输入你的回复(Cmd+Enter 发送)...' : '该会话当前不在等待状态'}" ${waiting?'':'disabled'}></textarea>
|
||
<div class="toolbar">
|
||
<span class="hint">${waiting ? '✅ 回复后 Agent 会立刻从上次的位置继续推进任务' : ''}</span>
|
||
<button id="sendBtn" ${waiting?'':'disabled'}>发送回复 →</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
if (waiting) {
|
||
const ta = document.getElementById('replyBox');
|
||
ta.focus();
|
||
ta.addEventListener('keydown', (e) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); send(); }
|
||
});
|
||
document.getElementById('sendBtn').onclick = send;
|
||
}
|
||
const bridgeBtn = document.getElementById('bridgeBtn');
|
||
if (bridgeBtn) {
|
||
bridgeBtn.onclick = async () => {
|
||
const content = document.getElementById('bridgeBox').value;
|
||
if (!content.trim()) return;
|
||
bridgeBtn.disabled = true;
|
||
try {
|
||
const r = await fetch('/api/bridge/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token: selected, content, source: 'composer' }),
|
||
});
|
||
const j = await r.json();
|
||
document.getElementById('bridgeHint').textContent = j.ok
|
||
? (j.wakeWaiting ? '已唤醒 wait 续跑' : '已写入会话队列')
|
||
: (j.error || '失败');
|
||
if (j.ok) { document.getElementById('bridgeBox').value = ''; fetchState(); fetchPrdProgress(); }
|
||
} finally { bridgeBtn.disabled = false; }
|
||
};
|
||
}
|
||
}
|
||
|
||
function stripCodeFence(s){
|
||
const m = String(s).match(/^```text\n([\s\S]*)\n```\s*$/);
|
||
return m ? m[1] : s;
|
||
}
|
||
|
||
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()) return;
|
||
const btn = document.getElementById('sendBtn');
|
||
btn.disabled = true; btn.textContent = '发送中…';
|
||
try {
|
||
const r = await fetch('/api/reply', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ token: selected, reply: text }),
|
||
});
|
||
const j = await r.json();
|
||
if (!j.ok) throw new Error(j.error || 'fail');
|
||
ta.value = '';
|
||
await fetchState();
|
||
} catch (e) {
|
||
alert('发送失败:' + e.message);
|
||
btn.disabled = false; btn.textContent = '发送回复 →';
|
||
}
|
||
}
|
||
|
||
async function copyPrompt(){
|
||
try {
|
||
await navigator.clipboard.writeText(STARTUP_PROMPT);
|
||
const btns = document.querySelectorAll('.copy-btn');
|
||
btns.forEach(b => { const o = b.textContent; b.textContent = '✓ 已复制,去 Cursor Chat 粘贴'; b.classList.add('copied'); setTimeout(() => { b.textContent = o; b.classList.remove('copied'); }, 2500); });
|
||
} catch (e) {
|
||
// 降级:选中文本
|
||
const ta = document.createElement('textarea'); ta.value = STARTUP_PROMPT; document.body.appendChild(ta); ta.select();
|
||
try { document.execCommand('copy'); } catch {}
|
||
document.body.removeChild(ta);
|
||
alert('已复制到剪贴板(降级方式)');
|
||
}
|
||
}
|
||
|
||
document.getElementById('copyPrompt').onclick = copyPrompt;
|
||
document.getElementById('refresh').onclick = fetchState;
|
||
document.getElementById('newBtn').onclick = async () => {
|
||
const title = prompt('新会话标题', '新对话');
|
||
if (!title) return;
|
||
const r = await fetch('/api/new', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title})});
|
||
const j = await r.json();
|
||
if (j.ok) { selected = j.token; await fetchState(); }
|
||
};
|
||
|
||
// 启动时请求通知权限
|
||
if ('Notification' in window && Notification.permission === 'default') {
|
||
setTimeout(() => Notification.requestPermission().catch(()=>{}), 1000);
|
||
}
|
||
|
||
setInterval(fetchState, 1500);
|
||
fetchState();
|
||
|
||
// ── PRD 进度条 ──
|
||
async function fetchPrdProgress(){
|
||
try {
|
||
const r = await fetch('/api/prd/progress', { cache: 'no-store' });
|
||
const p = await r.json();
|
||
document.getElementById('prdPct').textContent = p.percent + '%';
|
||
document.getElementById('prdFill').style.width = p.percent + '%';
|
||
document.getElementById('prdMeta').textContent = `PRD v1.4 · ${p.done}/${p.total} 项完成`;
|
||
const list = document.getElementById('prdList');
|
||
list.innerHTML = '';
|
||
for (const item of (p.items || [])) {
|
||
const span = document.createElement('span');
|
||
span.className = 'prd-item' + (item.done ? ' done' : '');
|
||
span.textContent = item.label;
|
||
span.title = item.phase + ' · ' + item.id;
|
||
list.appendChild(span);
|
||
}
|
||
} catch {}
|
||
}
|
||
setInterval(fetchPrdProgress, 2000);
|
||
fetchPrdProgress();
|
||
|
||
// ── 增强 Tab ──
|
||
let enhState = null;
|
||
function enhLog(msg){ const el = document.getElementById('enhLog'); el.textContent = (new Date().toLocaleTimeString()) + ' ' + msg + '\n' + el.textContent.slice(0,4000); }
|
||
|
||
function renderGates(state){
|
||
if (!state) return;
|
||
const grid = document.getElementById('gateGrid');
|
||
const w8ok = !!(state.patchOk || state.patchNativeOk);
|
||
const w8pending = !w8ok && state.patchPending;
|
||
const gates = [
|
||
{ k: 'permOk', label: 'W2 permOk' },
|
||
{ k: 'cursorPrefOk', label: 'W6 cursorPref' },
|
||
{ k: 'integrityOk', label: 'vendor SHA' },
|
||
];
|
||
let html = gates.map(g => {
|
||
const ok = !!state[g.k];
|
||
const cls = ok ? 'ok' : 'bad';
|
||
const val = ok ? '✅ OK' : '❌ 未通过';
|
||
return `<div class="gate ${cls}"><div class="label">${g.label}</div><div class="val">${val}</div></div>`;
|
||
}).join('');
|
||
const w8cls = w8ok ? 'ok' : (w8pending ? 'pending' : 'bad');
|
||
const w8val = state.patchOk ? '✅ OK' : (state.patchNativeOk ? '✅ 原生OK' : (w8pending ? '⏳ 待重启' : '❌ 未通过'));
|
||
html += `<div class="gate ${w8cls}"><div class="label">W8 patchOk</div><div class="val">${w8val}</div></div>`;
|
||
grid.innerHTML = html;
|
||
document.getElementById('enhToggle').checked = !!state.enhanceEnabled;
|
||
if (state.noQuotaRetrySpeed) document.getElementById('nqSpeed').value = state.noQuotaRetrySpeed;
|
||
if (state.noQuotaRetryDelayMs) document.getElementById('nqDelay').value = state.noQuotaRetryDelayMs;
|
||
if (state.noQuotaSameComposerDebounceMs) document.getElementById('nqDebounce').value = state.noQuotaSameComposerDebounceMs;
|
||
if (state.noQuotaQueueMaxPerWindow) document.getElementById('nqQueue').value = state.noQuotaQueueMaxPerWindow;
|
||
if (state.noQuotaRateLimitPerSecond) document.getElementById('nqRate').value = state.noQuotaRateLimitPerSecond;
|
||
}
|
||
|
||
async function fetchEnhanceStatus(){
|
||
try {
|
||
const r = await fetch('/api/enhance/status', { cache: 'no-store' });
|
||
const j = await r.json();
|
||
enhState = j.state;
|
||
renderGates(enhState);
|
||
} catch {}
|
||
}
|
||
|
||
document.getElementById('tabChat').onclick = () => {
|
||
document.getElementById('tabChat').classList.add('active');
|
||
document.getElementById('tabEnhance').classList.remove('active');
|
||
document.getElementById('chatView').classList.remove('hidden');
|
||
document.getElementById('enhanceView').classList.remove('active');
|
||
};
|
||
document.getElementById('tabEnhance').onclick = () => {
|
||
document.getElementById('tabEnhance').classList.add('active');
|
||
document.getElementById('tabChat').classList.remove('active');
|
||
document.getElementById('chatView').classList.add('hidden');
|
||
document.getElementById('enhanceView').classList.add('active');
|
||
fetchEnhanceStatus();
|
||
};
|
||
|
||
document.getElementById('btnDiagnose').onclick = async () => {
|
||
enhLog('诊断中…');
|
||
const r = await fetch('/api/enhance/diagnose');
|
||
const j = await r.json();
|
||
enhState = j.report.state;
|
||
renderGates(enhState);
|
||
enhLog(j.report.hint || (j.report.gatesPass ? '三门禁通过 ✅' : '诊断完成'));
|
||
fetchPrdProgress();
|
||
};
|
||
document.getElementById('btnPermFix').onclick = async () => {
|
||
enhLog('W2:弹出 macOS 管理员授权…');
|
||
document.getElementById('btnPermFix').disabled = true;
|
||
try {
|
||
const r = await fetch('/api/enhance/fix-perm', { method: 'POST' });
|
||
const j = await r.json();
|
||
if (j.state) { enhState = j.state; renderGates(enhState); }
|
||
enhLog(j.ok ? 'W2 permOk ✅ 请 Cmd+Q 重启 Cursor 后点「应用补丁 W7」' : ('W2 失败: ' + (j.result?.stderr || j.result?.probe?.reason || '用户取消授权')));
|
||
fetchPrdProgress();
|
||
} finally { document.getElementById('btnPermFix').disabled = false; }
|
||
};
|
||
document.getElementById('btnOpenW2Term').onclick = async () => {
|
||
const r = await fetch('/api/enhance/open-w2-terminal', { method: 'POST' });
|
||
const j = await r.json();
|
||
enhLog(j.message || '已打开 Terminal');
|
||
};
|
||
document.getElementById('btnFixPrefs').onclick = async () => {
|
||
const r = await fetch('/api/enhance/fix-prefs', { method: 'POST' });
|
||
const j = await r.json();
|
||
if (j.state) { enhState = j.state; renderGates(enhState); }
|
||
enhLog(j.prefs?.cursorPrefOk ? 'W6 cursorPref OK' : ('W6: ' + (j.prefs?.hints || []).join('; ')));
|
||
fetchPrdProgress();
|
||
};
|
||
document.getElementById('btnApply').onclick = async () => {
|
||
enhLog('应用补丁 W7…(不杀 Cursor;完成后请 Cmd+Q 重启 Cursor)');
|
||
document.getElementById('btnApply').disabled = true;
|
||
try {
|
||
const r = await fetch('/api/enhance/apply', { method: 'POST' });
|
||
const j = await r.json();
|
||
if (j.state) { enhState = j.state; renderGates(enhState); }
|
||
const pr = j.patchResult || {};
|
||
const isQueued = !!(j.queued || j.step === 'W7-queued' || pr.queued);
|
||
if (isQueued || (j.ok && !j.patchOk && j.state?.patchPending)) {
|
||
enhLog('✅ W7 已排队 + pchat 配置已迁移 — 增强包可先开;Cmd+Q 重开 Cursor 后 W8 全绿');
|
||
} else if (j.patchOk || (j.ok && j.state?.patchOk)) {
|
||
enhLog('W8 补丁 OK — 请重新打开 Cursor');
|
||
} else {
|
||
enhLog('W7: ' + (j.hint || j.error || pr.hint || pr.error || j.step || '见诊断'));
|
||
}
|
||
if (pr.stdout) enhLog(String(pr.stdout).slice(-600));
|
||
fetchPrdProgress();
|
||
} finally { document.getElementById('btnApply').disabled = false; }
|
||
};
|
||
document.getElementById('btnRepair').onclick = async () => {
|
||
enhLog('修复增强…(permOk 已绿则跳过 sudo)');
|
||
const r = await fetch('/api/enhance/repair', { method: 'POST' });
|
||
const j = await r.json();
|
||
if (j.apply && j.apply.state) { enhState = j.apply.state; renderGates(enhState); }
|
||
enhLog(JSON.stringify(j, null, 2).slice(0, 800));
|
||
fetchPrdProgress();
|
||
};
|
||
document.getElementById('enhToggle').onchange = async (e) => {
|
||
const want = e.target.checked;
|
||
const r = await fetch('/api/enhance/toggle', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ enabled: want }) });
|
||
const j = await r.json();
|
||
if (!j.ok) {
|
||
e.target.checked = !want;
|
||
alert(j.error || '无法切换');
|
||
return;
|
||
}
|
||
if (j.state) { enhState = j.state; renderGates(enhState); }
|
||
if (want && j.patchPending) {
|
||
enhLog('增强包 ON(pchat 原生)— W8 待 Cmd+Q 重开;Hub 无额度参数已可用');
|
||
} else {
|
||
enhLog(want ? '增强包 ON' : '增强包 OFF');
|
||
}
|
||
fetchPrdProgress();
|
||
};
|
||
document.getElementById('btnSaveNq').onclick = async () => {
|
||
const body = {
|
||
noQuotaRetrySpeed: document.getElementById('nqSpeed').value,
|
||
noQuotaRetryDelayMs: parseInt(document.getElementById('nqDelay').value, 10) || 200,
|
||
noQuotaSameComposerDebounceMs: parseInt(document.getElementById('nqDebounce').value, 10) || 2000,
|
||
noQuotaQueueMaxPerWindow: parseInt(document.getElementById('nqQueue').value, 10) || 2,
|
||
noQuotaRateLimitPerSecond: parseInt(document.getElementById('nqRate').value, 10) || 1,
|
||
};
|
||
const r = await fetch('/api/enhance/settings', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||
const j = await r.json();
|
||
if (j.state) renderGates(j.state);
|
||
enhLog('无额度参数已保存');
|
||
fetchPrdProgress();
|
||
};
|
||
async function fetchModeCheck(){
|
||
try {
|
||
const r = await fetch('/api/mode/check', { cache: 'no-store' });
|
||
const j = await r.json();
|
||
const box = document.getElementById('modeConflict');
|
||
if (j.conflict) {
|
||
box.style.display = 'block';
|
||
document.getElementById('modeConflictMsg').textContent = j.message || 'co-chat + persistent-chat 同窗口冲突';
|
||
} else {
|
||
box.style.display = 'none';
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
async function copyTemplate(id){
|
||
const r = await fetch('/api/templates');
|
||
const j = await r.json();
|
||
const t = (j.templates || []).find(x => x.id === id);
|
||
if (!t) return alert('模板未找到');
|
||
await navigator.clipboard.writeText(t.prompt);
|
||
enhLog('已复制 ' + t.label + ' → 新建 Cursor Chat 粘贴');
|
||
fetchPrdProgress();
|
||
}
|
||
|
||
document.getElementById('btnTplA').onclick = () => copyTemplate('mode-a-persistent');
|
||
document.getElementById('btnTplB').onclick = () => copyTemplate('mode-b-composer');
|
||
document.getElementById('btnModeCheck').onclick = async () => {
|
||
await fetchModeCheck();
|
||
enhLog('互斥检测完成');
|
||
fetchPrdProgress();
|
||
};
|
||
document.getElementById('btnSmoke').onclick = async () => {
|
||
enhLog('冒烟测试运行中…');
|
||
document.getElementById('btnSmoke').disabled = true;
|
||
try {
|
||
const r = await fetch('/api/smoke/run', { method: 'POST' });
|
||
const j = await r.json();
|
||
enhLog(j.ok ? ('冒烟全绿 fail=0 · ' + (j.results||[]).length + ' 项') : ('冒烟 fail=' + j.fail));
|
||
fetchPrdProgress();
|
||
} finally { document.getElementById('btnSmoke').disabled = false; }
|
||
};
|
||
document.getElementById('btnRetireCheck').onclick = async () => {
|
||
const r = await fetch('/api/retire/check');
|
||
const j = await r.json();
|
||
enhLog(j.canExecute ? '可退役 co-chat' : j.message);
|
||
fetchPrdProgress();
|
||
};
|
||
|
||
setInterval(fetchEnhanceStatus, 5000);
|
||
fetchEnhanceStatus();
|
||
fetchModeCheck();
|
||
</script>
|
||
</body>
|
||
</html>
|