Files
persistent-chat-plugin/web/panel.html
卡若AI ba786b3700 feat: Persistent Chat v1.1 CO-Chat 整合上传 Gitea
同步 enhance/bridge/smoke/retire 模块、vendor 3.3.34、增强面板与 PRD 文档,
Hub 接入 hub-routes-prd,含安装/经验/模块说明与 co-integration 运维脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:42:16 +08:00

630 lines
32 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

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

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>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 ChatAgent 模式)作为第一条消息。<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 ChatAgent 模式)作为第一条消息。<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 => ({'&':'&amp;','<':'&lt;','>':'&gt;'})[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('增强包 ONpchat 原生)— 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>