@ppdocs/mcp 3.2.3 → 3.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web/ui.js CHANGED
@@ -3,472 +3,486 @@
3
3
  * 页面: 项目列表 / 添加项目 / 项目详情 (标签页: 概览/MCP/提示词)
4
4
  */
5
5
  export function getAgentHtml() {
6
- return `<!DOCTYPE html>
7
- <html lang="zh-CN">
8
- <head>
9
- <meta charset="UTF-8">
10
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
11
- <title>PPDocs Agent</title>
12
- <style>
13
- *{margin:0;padding:0;box-sizing:border-box}
14
- body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;background:#0a0e1a;color:#e0e6ed;min-height:100vh}
15
- .app{max-width:700px;margin:0 auto;padding:32px 20px}
16
- h1{font-size:24px;font-weight:700;background:linear-gradient(135deg,#60a5fa,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
17
- .header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}
18
- .host-badge{font-size:12px;padding:4px 12px;border-radius:20px;background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3)}
19
- .host-badge.off{background:rgba(239,68,68,.15);color:#ef4444;border-color:rgba(239,68,68,.3)}
20
- .card{background:rgba(30,41,59,.7);backdrop-filter:blur(12px);border:1px solid rgba(100,116,139,.2);border-radius:14px;padding:20px;margin-bottom:12px;cursor:pointer;transition:all .2s}
21
- .card:hover{border-color:rgba(96,165,250,.4);transform:translateY(-1px)}
22
- .card.no-hover{cursor:default;transform:none}
23
- .card h3{font-size:15px;font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:8px}
24
- .card .meta{font-size:12px;color:#64748b;margin-top:4px}
25
- .card .stat{display:flex;gap:16px;margin-top:8px;font-size:13px}
26
- .card .stat span{color:#94a3b8}
27
- .sync-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
28
- .sync-dot.on{background:#22c55e;box-shadow:0 0 6px #22c55e}
29
- .sync-dot.off{background:#64748b}
30
- .btn{padding:8px 16px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:5px}
31
- .btn-p{background:linear-gradient(135deg,#3b82f6,#6366f1);color:#fff}
32
- .btn-p:hover{box-shadow:0 4px 12px rgba(59,130,246,.4)}
33
- .btn-s{background:rgba(51,65,85,.8);color:#94a3b8}
34
- .btn-s:hover{background:rgba(71,85,105,.8)}
35
- .btn-d{background:rgba(239,68,68,.15);color:#ef4444;border:1px solid rgba(239,68,68,.3)}
36
- .btn-d:hover{background:rgba(239,68,68,.25)}
37
- .btn-row{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
38
- input,select{background:rgba(15,23,42,.8);border:1px solid rgba(100,116,139,.3);border-radius:8px;padding:10px 14px;color:#e0e6ed;font-size:14px;outline:none;width:100%;transition:border-color .2s}
39
- input:focus{border-color:#60a5fa}
40
- label{font-size:12px;color:#64748b;font-weight:500;display:block;margin-bottom:4px}
41
- .fg{margin-bottom:14px}
42
- .section{margin-bottom:20px}
43
- .section h2{font-size:14px;font-weight:600;color:#94a3b8;margin-bottom:12px;display:flex;align-items:center;gap:6px}
44
- .tree-box{background:rgba(15,23,42,.8);border:1px solid rgba(100,116,139,.2);border-radius:10px;padding:14px;font-family:'Cascadia Code','Fira Code',monospace;font-size:12px;color:#94a3b8;max-height:280px;overflow-y:auto;line-height:1.7;white-space:pre}
45
- .platform-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
46
- .platform-card{padding:14px;border-radius:10px;border:1px solid rgba(100,116,139,.2);text-align:center;transition:all .2s}
47
- .platform-card:hover{border-color:#a78bfa;background:rgba(167,139,250,.08)}
48
- .platform-card .icon{font-size:22px;margin-bottom:4px}
49
- .platform-card .name{font-size:13px;font-weight:600}
50
- .modal-bg{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);display:flex;justify-content:center;align-items:center;z-index:50;backdrop-filter:blur(4px)}
51
- .modal{background:#1e293b;border:1px solid rgba(100,116,139,.3);border-radius:16px;padding:24px;width:90%;max-width:500px;max-height:80vh;overflow-y:auto}
52
- .modal h3{font-size:16px;margin-bottom:16px}
53
- .cmd-box{background:rgba(15,23,42,.9);border:1px solid rgba(100,116,139,.2);border-radius:8px;padding:12px;font-family:monospace;font-size:12px;color:#94a3b8;word-break:break-all;margin:10px 0;white-space:pre-wrap}
54
- .toast{position:fixed;top:20px;right:20px;padding:10px 18px;border-radius:8px;font-size:13px;font-weight:500;opacity:0;transform:translateY(-10px);transition:all .3s;z-index:99}
55
- .toast.show{opacity:1;transform:translateY(0)}
56
- .toast.ok{background:rgba(34,197,94,.2);border:1px solid #22c55e;color:#22c55e}
57
- .toast.err{background:rgba(239,68,68,.2);border:1px solid #ef4444;color:#ef4444}
58
- .empty{text-align:center;padding:40px;color:#475569;font-size:14px}
59
- .back{font-size:13px;color:#64748b;cursor:pointer;margin-bottom:16px;display:inline-block}
60
- .back:hover{color:#94a3b8}
61
- .row{display:flex;gap:12px}
62
- .row>*{flex:1}
63
- .drop-zone{border:2px dashed rgba(100,116,139,.3);border-radius:14px;padding:40px;text-align:center;color:#475569;font-size:14px;transition:all .3s;cursor:pointer;margin-top:12px}
64
- .drop-zone.over{border-color:#60a5fa;background:rgba(96,165,250,.06);color:#60a5fa}
65
- .auth-badge{font-size:12px;padding:3px 10px;border-radius:12px;font-weight:600;display:inline-flex;align-items:center;gap:4px}
66
- .auth-badge.ok{background:rgba(34,197,94,.15);color:#22c55e}
67
- .auth-badge.no{background:rgba(245,158,11,.15);color:#f59e0b}
68
- .prompt-editor{width:100%;min-height:200px;background:rgba(15,23,42,.9);border:1px solid rgba(100,116,139,.2);border-radius:8px;padding:12px;font-family:'Cascadia Code','Fira Code',monospace;font-size:12px;color:#94a3b8;resize:vertical}
69
- .mcp-list-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-radius:8px;border:1px solid rgba(100,116,139,.15);margin-bottom:4px;font-size:13px}
70
- .mcp-list-item .badges{display:flex;gap:4px}
71
- .mcp-list-item .badges span{font-size:10px;padding:2px 6px;border-radius:8px;background:rgba(96,165,250,.15);color:#60a5fa}
72
- .tab-row{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(100,116,139,.2)}
73
- .tab{padding:8px 16px;font-size:13px;font-weight:600;color:#64748b;cursor:pointer;border-bottom:2px solid transparent;transition:all .2s}
74
- .tab.active{color:#60a5fa;border-color:#60a5fa}
75
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
76
- </style>
77
- </head>
78
- <body>
79
- <div class="app" id="app"></div>
80
- <div class="toast" id="toast"></div>
81
- <script>
82
- // ============ State ============
83
- var S={host:'',port:20001,hostOk:false,projects:[],page:'list',addStep:1,addDir:'',detailIdx:-1,detailTab:'overview',mcpStatus:{},mcpAll:[],prompts:[],authRequestId:null,authStartTime:0,authStatus:'',authResult:null,modal:null};
84
-
85
- // ============ Router ============
86
- function go(page,data){Object.assign(S,data||{});S.page=page;_lastDetailRid=null;cancelAuth();render()}
87
-
88
- // ============ Toast ============
89
- function toast(m,t){t=t||'ok';var e=document.getElementById('toast');e.textContent=m;e.className='toast '+t+' show';setTimeout(function(){e.className='toast'},3000)}
90
-
91
- // ============ API ============
92
- function api(path,opts){return fetch(path,opts).then(function(r){return r.json()})}
93
-
94
- // ============ Init ============
95
- function init(){
96
- api('/api/status').then(function(st){
97
- S.host=st.host||'';S.port=st.port||20001;S.hostOk=st.hostConnected;S.projects=st.projects||[];
98
- render();
99
- }).catch(function(){render()});
100
- // Drag-drop
101
- document.body.addEventListener('dragover',function(e){e.preventDefault();e.dataTransfer.dropEffect='copy';var dz=document.querySelector('.drop-zone');if(dz)dz.classList.add('over')});
102
- document.body.addEventListener('dragleave',function(){var dz=document.querySelector('.drop-zone');if(dz)dz.classList.remove('over')});
103
- document.body.addEventListener('drop',function(e){
104
- e.preventDefault();
105
- var dz=document.querySelector('.drop-zone');if(dz)dz.classList.remove('over');
106
- var items=e.dataTransfer.items;
107
- for(var i=0;i<items.length;i++){
108
- var entry=items[i].webkitGetAsEntry&&items[i].webkitGetAsEntry();
109
- if(entry&&entry.isDirectory){
110
- go('add',{addStep:1,addDir:entry.name});
111
- toast('请补全绝对路径,例如 D:/projects/'+entry.name);
112
- return;
113
- }
114
- }
115
- });
116
- }
117
-
118
- // ============ Render ============
119
- function render(){
120
- var app=document.getElementById('app');
121
- if(!S.host&&S.page!=='setup'){S.page='setup'}
122
- switch(S.page){
123
- case 'setup':app.innerHTML=renderSetup();break;
124
- case 'list':app.innerHTML=renderList();break;
125
- case 'add':app.innerHTML=renderAdd();break;
126
- case 'detail':app.innerHTML=renderDetail();break;
127
- }
128
- }
129
-
130
- // ============ Setup ============
131
- function renderSetup(){
132
- return '<div class="header"><h1>📡 PPDocs Agent</h1></div>'+
133
- '<div class="card no-hover"><div class="section"><h2>🏠 主机连接</h2>'+
134
- '<div class="row"><div class="fg"><label>主机 IP</label><input id="iHost" value="'+S.host+'" placeholder="10.0.0.176"></div>'+
135
- '<div class="fg" style="max-width:120px"><label>端口</label><input id="iPort" type="number" value="'+S.port+'" placeholder="20001"></div></div>'+
136
- '<div class="btn-row"><button class="btn btn-p" onclick="doTestHost()">🔍 测试连接</button>'+
137
- '<span id="hostResult" style="font-size:13px;line-height:32px;margin-left:8px"></span></div></div></div>';
138
- }
139
-
140
- function doTestHost(){
141
- var host=document.getElementById('iHost').value.trim();
142
- var port=parseInt(document.getElementById('iPort').value)||20001;
143
- var el=document.getElementById('hostResult');
144
- el.textContent='连接中...';
145
- api('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({host:host,port:port})}).then(function(r){
146
- if(r.ok){
147
- el.innerHTML='<span style="color:#22c55e">✅ 连接成功</span>';
148
- api('/api/host',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({host:host,port:port})});
149
- S.host=host;S.port=port;S.hostOk=true;
150
- setTimeout(function(){go('list')},800);
151
- }else{
152
- el.innerHTML='<span style="color:#ef4444">❌ '+(r.error||'连接失败')+'</span>';
153
- }
154
- });
155
- }
156
-
157
- // ============ Project List ============
158
- function renderList(){
159
- var badge=S.hostOk?'<span class="host-badge">🟢 '+S.host+':'+S.port+'</span>':'<span class="host-badge off">🔴 未连接</span>';
160
- var cards='';
161
- if(S.projects.length===0){
162
- cards='<div class="empty">暂无项目,点击上方按钮添加</div>';
163
- }else{
164
- cards=S.projects.map(function(p,i){
165
- var dot=p.connected?'<span class="sync-dot on"></span>':'<span class="sync-dot off"></span>';
166
- var sync=p.lastSync?'同步: '+p.lastSync:p.syncStatus||'—';
167
- return '<div class="card" onclick="go(\\x27detail\\x27,{detailIdx:'+i+',detailTab:\\x27overview\\x27})">'+
168
- '<h3>'+dot+' 📂 '+(p.localName||p.remoteName)+'</h3>'+
169
- '<div class="meta">'+(p.localDir||'')+'</div>'+
170
- '<div class="stat"><span>远程: '+p.remoteName+' ('+p.remoteId+')</span><span>'+(p.docCount?p.docCount+' 文档':'')+'</span></div>'+
171
- '<div class="meta">'+sync+'</div></div>';
172
- }).join('');
173
- }
174
- return '<div class="header"><h1>📡 PPDocs Agent</h1>'+badge+'</div>'+
175
- '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">'+
176
- '<span style="font-size:15px;font-weight:600">我的项目 ('+S.projects.length+')</span>'+
177
- '<div class="btn-row" style="margin:0"><button class="btn btn-p" onclick="go(\\x27add\\x27,{addStep:1,addDir:\\x27\\x27})">➕ 添加项目</button>'+
178
- '<button class="btn btn-s" onclick="go(\\x27setup\\x27)">⚙️</button></div></div>'+
179
- cards+
180
- '<div class="drop-zone">📂 拖拽项目文件夹到此处快速添加</div>';
181
- }
182
-
183
- // ============ Add Project ============
184
- function renderAdd(){
185
- if(S.addStep===1) return renderAddStep1();
186
- return renderAddStep2();
187
- }
188
-
189
- function renderAddStep1(){
190
- var dirName=S.addDir?S.addDir.replace(/\\\\/g,'/').split('/').filter(Boolean).pop():'';
191
- var namePreview=dirName?'<div style="font-size:12px;color:#60a5fa;margin:-8px 0 12px">📂 项目名: <strong>'+dirName+'</strong></div>':'';
192
- return '<span class="back" onclick="cancelAuth();go(\\x27list\\x27)">← 返回项目列表</span>'+
193
- '<div class="card no-hover"><div class="section"><h2>➕ 添加项目 — Step 1/2</h2>'+
194
- '<p style="font-size:13px;color:#64748b;margin-bottom:12px">指定本地项目目录,以目录名作为项目名称</p>'+
195
- '<div class="fg"><label>本地项目目录 (绝对路径)</label><input id="iDir" value="'+S.addDir+'" placeholder="例: D:/projects/my-app" oninput="S.addDir=this.value;_render()"></div>'+
196
- namePreview+
197
- '<div class="btn-row"><button class="btn btn-p" onclick="doAuthStart()">📡 发送授权请求</button></div>'+
198
- '</div></div>';
199
- }
200
-
201
- function doAuthStart(){
202
- var dir=document.getElementById('iDir').value.trim();
203
- if(!dir){toast('请填写目录路径','err');return}
204
- if(!/^[A-Za-z]:[\\\\\\/]|^\\//.test(dir)){toast('请输入绝对路径','err');return}
205
- S.addDir=dir;
206
- api('/api/auth/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({localDir:dir})}).then(function(r){
207
- if(!r.ok){toast(r.error||'授权请求失败','err');return}
208
- S.authRequestId=r.requestId;S.authStartTime=Date.now();S.authStatus='pending';S.addStep=2;
209
- render();startAuthPoll();
210
- });
211
- }
212
-
213
- var authPollTimer=null,authUiTimer=null;
214
- function startAuthPoll(){
215
- cancelAuth();
216
- authPollTimer=setInterval(function(){
217
- if(!S.authRequestId){cancelAuth();return}
218
- var elapsed=Math.floor((Date.now()-S.authStartTime)/1000);
219
- if(elapsed>=300){S.authStatus='expired';cancelAuth();_render();return}
220
- api('/api/auth/status/'+S.authRequestId).then(function(r){
221
- S.authStatus=r.status;
222
- if(r.status==='approved'){
223
- cancelAuth();
224
- api('/api/bind',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
225
- localDir:S.addDir,remoteId:r.projectId,remoteName:r.projectName,password:r.password
226
- })}).then(function(br){
227
- if(br.ok){
228
- S.authResult={name:r.projectName,id:r.projectId};_render();
229
- toast('✅ 授权成功,已绑定 '+r.projectName);
230
- setTimeout(function(){init();go('list')},2000);
231
- }else{toast(br.error||'绑定失败','err')}
232
- });
233
- }else if(r.status==='rejected'){cancelAuth();_render()}
234
- else{_render()}
235
- }).catch(function(){});
236
- },2000);
237
- authUiTimer=setInterval(function(){if(S.page==='add'&&S.addStep===2&&S.authStatus==='pending')_render()},1000);
238
- }
239
- function cancelAuth(){if(authPollTimer){clearInterval(authPollTimer);authPollTimer=null}if(authUiTimer){clearInterval(authUiTimer);authUiTimer=null}}
240
-
241
- function renderAddStep2(){
242
- var elapsed=Math.floor((Date.now()-(S.authStartTime||Date.now()))/1000);
243
- var pct=Math.min(100,Math.round(elapsed/300*100));
244
- if(S.authStatus==='approved'&&S.authResult){
245
- return '<div class="card no-hover" style="text-align:center;padding:40px">'+
246
- '<div style="font-size:40px;margin-bottom:12px">✅</div>'+
247
- '<h2 style="color:#22c55e;font-size:18px;margin-bottom:8px">授权成功!</h2>'+
248
- '<p style="color:#94a3b8;font-size:14px">项目: '+S.authResult.name+' ('+S.authResult.id+')</p>'+
249
- '<p style="color:#64748b;font-size:13px;margin-top:4px">权限: 读写 | 2秒后跳转...</p></div>';
250
- }
251
- if(S.authStatus==='rejected'){
252
- return '<span class="back" onclick="go(\\x27list\\x27)">← 返回</span>'+
253
- '<div class="card no-hover" style="text-align:center;padding:40px">'+
254
- '<div style="font-size:40px;margin-bottom:12px">❌</div>'+
255
- '<h2 style="color:#ef4444;font-size:18px;margin-bottom:8px">授权被拒绝</h2>'+
256
- '<div class="btn-row" style="justify-content:center;margin-top:16px"><button class="btn btn-p" onclick="S.addStep=1;render()">重试</button></div></div>';
257
- }
258
- if(S.authStatus==='expired'){
259
- return '<span class="back" onclick="go(\\x27list\\x27)">← 返回</span>'+
260
- '<div class="card no-hover" style="text-align:center;padding:40px">'+
261
- '<div style="font-size:40px;margin-bottom:12px">⏰</div>'+
262
- '<h2 style="color:#f59e0b;font-size:18px;margin-bottom:8px">授权已超时</h2>'+
263
- '<div class="btn-row" style="justify-content:center;margin-top:16px"><button class="btn btn-p" onclick="S.addStep=1;render()">重试</button></div></div>';
264
- }
265
- return '<span class="back" onclick="cancelAuth();S.addStep=1;render()">← 返回</span>'+
266
- '<div class="card no-hover"><div class="section"><h2>➕ 添加项目 — Step 2/2</h2>'+
267
- '<div style="text-align:center;padding:20px 0">'+
268
- '<div style="font-size:36px;margin-bottom:10px;animation:pulse 2s infinite">📡</div>'+
269
- '<p style="font-size:15px;font-weight:600;color:#60a5fa;margin-bottom:6px">等待主机端授权...</p>'+
270
- '<p style="font-size:12px;color:#64748b;margin-bottom:4px">已向 '+S.host+':'+S.port+' 发送授权请求</p>'+
271
- '<p style="font-size:12px;color:#475569">请在主机端 PPDocs 桌面应用中选择项目并批准</p>'+
272
- '<div style="margin:16px auto;width:80%;height:4px;background:rgba(100,116,139,.2);border-radius:2px;overflow:hidden">'+
273
- '<div style="width:'+pct+'%;height:100%;background:linear-gradient(90deg,#3b82f6,#6366f1);border-radius:2px;transition:width .5s"></div></div>'+
274
- '<p style="font-size:11px;color:#475569">'+elapsed+'s / 300s</p>'+
275
- '</div><div class="btn-row" style="justify-content:center"><button class="btn btn-s" onclick="cancelAuth();go(\\x27list\\x27)">取消</button></div></div></div>';
276
- }
277
-
278
- // ============ Project Detail ============
279
- function renderDetail(){
280
- var p=S.projects[S.detailIdx];
281
- if(!p) return '<span class="back" onclick="go(\\x27list\\x27)">← 项目不存在</span>';
282
- var tab=S.detailTab||'overview';
283
- var tabs=[{key:'overview',label:'📊 概览'},{key:'mcp',label:'🤖 MCP'},{key:'prompts',label:'📝 提示词'}];
284
- var tabHtml=tabs.map(function(t){return '<div class="tab '+(tab===t.key?'active':'')+'" onclick="S.detailTab=\\x27'+t.key+'\\x27;_lastDetailRid=null;render()">'+t.label+'</div>'}).join('');
285
- var tabContent='';
286
- if(tab==='overview') tabContent=renderOverview(p);
287
- else if(tab==='mcp') tabContent=renderMcpTab(p);
288
- else if(tab==='prompts') tabContent=renderPromptsTab(p);
289
- return '<span class="back" onclick="go(\\x27list\\x27)">← 返回项目列表</span>'+
290
- '<div class="header"><h1>📂 '+(p.localName||p.remoteName)+'</h1></div>'+
291
- '<div class="tab-row">'+tabHtml+'</div>'+tabContent+
292
- '<div class="card no-hover" style="border-color:rgba(239,68,68,.2)"><div class="section"><h2>⚠️ 危险区域</h2>'+
293
- '<button class="btn btn-d" onclick="doUnbind(\\x27'+p.remoteId+'\\x27)">🗑️ 解除绑定</button></div></div>';
294
- }
295
-
296
- function renderOverview(p){
297
- var authBadge=p.hasPassword?'<span class="auth-badge ok">✅ 已授权 (读写)</span>':'<span class="auth-badge no">⚠️ 未授权 (只读)</span>';
298
- var authBtn=p.hasPassword?'':'<button class="btn btn-p" style="font-size:12px;padding:5px 12px;margin-left:8px" onclick="doReAuth(\\x27'+p.remoteId+'\\x27,\\x27'+(p.localDir||'').replace(/\\\\/g,'/')+'\\x27)">📡 申请读写授权</button>';
299
- return '<div class="card no-hover"><div class="section"><h2>📊 概览</h2>'+
300
- '<div class="stat"><span>本地: '+(p.localDir||'<em style="color:#ef4444">未指定</em>')+'</span></div>'+
301
- '<div class="stat"><span>远程: '+p.remoteName+' ('+p.remoteId+')</span></div>'+
302
- '<div class="stat"><span>授权: '+authBadge+authBtn+'</span></div>'+
303
- '<div class="stat"><span>文档: '+(p.docCount||0)+' 个</span></div>'+
304
- '<div class="stat"><span>同步: '+(p.connected?'🟢 运行中':'🔴 '+p.syncStatus)+' '+(p.lastSync?'('+p.lastSync+')':'')+'</span></div>'+
305
- '</div></div>'+
306
- '<div class="card no-hover"><div class="section"><h2>📚 知识图谱</h2>'+
307
- '<div class="tree-box" id="treeBox">加载中...</div></div></div>';
308
- }
309
-
310
- function renderMcpTab(p){
311
- var mcpState=S.mcpStatus||{};
312
- var platforms=[
313
- {key:'cursor',icon:'🟣',name:'Cursor'},
314
- {key:'antigravity',icon:'🟢',name:'Antigravity'},
315
- {key:'vscode',icon:'🔵',name:'OpenCode'},
316
- {key:'claude',icon:'🟠',name:'Claude'},
317
- {key:'lobechat',icon:'🦞',name:'LobeChat'}
318
- ];
319
- var platformCards=platforms.map(function(pl){
320
- var isManual=pl.key==='lobechat';
321
- var installed=mcpState[pl.key];
322
- var badge='',btn='';
323
- if(isManual){
324
- badge='<span style="font-size:11px;color:#94a3b8;display:block;margin:4px 0">手动配置</span>';
325
- btn='<button class="btn btn-p" style="font-size:11px;padding:4px 10px;background:rgba(59,130,246,.5)" onclick="event.stopPropagation();doMcpInstall(\\x27'+p.remoteId+'\\x27,\\x27'+pl.key+'\\x27)">获取配置</button>';
326
- }else{
327
- badge=installed?'<span style="font-size:11px;color:#22c55e;display:block;margin:4px 0">✅ 已配置</span>':'<span style="font-size:11px;color:#64748b;display:block;margin:4px 0">未配置</span>';
328
- btn=installed?'<button class="btn btn-d" style="font-size:11px;padding:4px 10px" onclick="event.stopPropagation();doMcpUninstall(\\x27'+p.remoteId+'\\x27,\\x27'+pl.key+'\\x27)">卸载</button>':'<button class="btn btn-p" style="font-size:11px;padding:4px 10px" onclick="event.stopPropagation();doMcpInstall(\\x27'+p.remoteId+'\\x27,\\x27'+pl.key+'\\x27)">安装/更新</button>';
329
- }
330
- return '<div class="platform-card"><div class="icon">'+pl.icon+'</div><div class="name" style="white-space:nowrap">'+pl.name+'</div>'+badge+btn+'</div>';
331
- }).join('');
332
- var noDir=!p.localDir;
333
- var mcpListHtml='';
334
- if(S.mcpAll&&S.mcpAll.length>0){
335
- mcpListHtml=S.mcpAll.map(function(s){
336
- var badges=s.platforms.map(function(pl){return '<span>'+pl+'</span>'}).join('');
337
- return '<div class="mcp-list-item"><span>📦 '+s.name+'</span><div class="badges">'+badges+'</div></div>';
338
- }).join('');
339
- }else{
340
- mcpListHtml='<div style="font-size:12px;color:#475569;padding:8px 0">未检测到任何 MCP 服务</div>';
341
- }
342
- return '<div class="card no-hover"><div class="section"><h2>🤖 PPDocs MCP 安装</h2>'+
343
- (noDir?'<div style="font-size:13px;color:#f59e0b;margin-bottom:8px">⚠️ 未指定本地目录</div>':'')+
344
- '<div class="platform-grid">'+platformCards+'</div></div></div>'+
345
- '<div class="card no-hover"><div class="section"><h2>📦 项目内全部 MCP ('+(S.mcpAll?S.mcpAll.length:0)+')</h2>'+
346
- mcpListHtml+'</div></div>';
347
- }
348
-
349
- function renderPromptsTab(p){
350
- if(!p.localDir) return '<div class="card no-hover"><div class="section"><h2>📝 提示词</h2><div style="font-size:13px;color:#f59e0b">⚠️ 未指定本地目录</div></div></div>';
351
- var files=S.prompts||[];
352
- var cards=files.map(function(f){
353
- var statusBadge=f.exists?'<span style="font-size:11px;color:#22c55e">'+f.lines+' 行</span>':'<span style="font-size:11px;color:#64748b">不存在</span>';
354
- var btn=f.exists
355
- ?'<button class="btn btn-s" style="font-size:11px;padding:3px 8px" onclick="doViewPrompt(\\x27'+p.remoteId+'\\x27,\\x27'+f.name+'\\x27)">查看/编辑</button>'
356
- :'<button class="btn btn-p" style="font-size:11px;padding:3px 8px" onclick="doCreatePrompt(\\x27'+p.remoteId+'\\x27,\\x27'+f.name+'\\x27)">创建</button>';
357
- return '<div class="mcp-list-item"><span>📄 '+f.name+'</span><div style="display:flex;gap:8px;align-items:center">'+statusBadge+btn+'</div></div>';
358
- }).join('');
359
- return '<div class="card no-hover"><div class="section"><h2>📝 系统提示词</h2>'+(cards||'<div style="font-size:12px;color:#475569">加载中...</div>')+'</div></div>';
360
- }
361
-
362
- // ============ Data Loading ============
363
- function loadTree(rid){
364
- var box=document.getElementById('treeBox');if(!box)return Promise.resolve();
365
- return api('/api/bind/'+rid+'/tree').then(function(r){
366
- var prefix=r.readOnly?'🔒 只读模式\\n\\n':'';
367
- box.textContent=r.tree?(prefix+(r.docCount>0?'共 '+r.docCount+' 文档, '+r.dirCount+' 目录\\n\\n':'')+r.tree):'知识库为空';
368
- }).catch(function(e){box.textContent='加载失败: '+e});
369
- }
370
- function loadMcpStatus(rid){return api('/api/bind/'+rid+'/mcp').then(function(r){S.mcpStatus=r.platforms||{}}).catch(function(){S.mcpStatus={}})}
371
- function loadMcpAll(rid){return api('/api/bind/'+rid+'/mcp/all').then(function(r){S.mcpAll=r.servers||[]}).catch(function(){S.mcpAll=[]})}
372
- function loadPrompts(rid){return api('/api/bind/'+rid+'/prompts').then(function(r){S.prompts=r.files||[]}).catch(function(){S.prompts=[]})}
373
-
374
- // ============ Auth from Detail ============
375
- function doReAuth(remoteId,localDir){
376
- cancelAuth();
377
- api('/api/auth/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({localDir:localDir})}).then(function(r){
378
- if(!r.ok){toast(r.error||'授权请求失败','err');return}
379
- toast('📡 已发送授权请求,请在主机端批准');
380
- S.authRequestId=r.requestId;S.authStartTime=Date.now();
381
- authPollTimer=setInterval(function(){
382
- var elapsed=Math.floor((Date.now()-S.authStartTime)/1000);
383
- if(elapsed>=300){cancelAuth();toast('⏰ 授权已超时','err');return}
384
- api('/api/auth/status/'+S.authRequestId).then(function(sr){
385
- if(sr.status==='approved'){cancelAuth();
386
- api('/api/bind/'+remoteId,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:sr.password,remoteName:sr.projectName})}).then(function(){
387
- toast('✅ 授权成功!');_lastDetailRid=null;init();
388
- });
389
- }else if(sr.status==='rejected'){cancelAuth();toast(' 授权被拒绝','err')}
390
- else if(sr.status==='expired'){cancelAuth();toast('⏰ 授权已超时','err')}
391
- }).catch(function(){});
392
- },2000);
393
- });
394
- }
395
-
396
- // ============ MCP ============
397
- function doMcpInstall(remoteId,platform){
398
- api('/api/bind/'+remoteId+'/mcp',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({platform:platform})}).then(function(r){
399
- if(r.ok){
400
- if(r.action==='copy'){S.modal={title:platform.toUpperCase()+' MCP 配置',content:r.config};renderModal()}
401
- else{toast(r.message||'已安装/更新');loadMcpStatus(remoteId).then(function(){return loadMcpAll(remoteId)}).then(function(){_render()})}
402
- }else{toast(r.error||'操作失败','err')}
403
- });
404
- }
405
- function renderModal(){
406
- if(!S.modal)return;var div=document.createElement('div');div.className='modal-bg';
407
- div.innerHTML='<div class="modal"><h3>'+S.modal.title+'</h3><div class="cmd-box" id="mcpCopyBox">'+S.modal.content+'</div>'+
408
- '<div class="btn-row"><button class="btn btn-p" onclick="copyMcpConfig()">📋 复制</button>'+
409
- '<button class="btn btn-s" onclick="closeModal()">关闭</button></div></div>';
410
- document.body.appendChild(div);div.addEventListener('click',function(e){if(e.target===div)div.remove()});
411
- }
412
- function closeModal(){var bg=document.querySelector('.modal-bg');if(bg)bg.remove();S.modal=null}
413
- function copyMcpConfig(){var el=document.getElementById('mcpCopyBox');if(el){navigator.clipboard.writeText(el.textContent);toast('已复制')}}
414
- function doMcpUninstall(remoteId,platform){
415
- if(!confirm('确定卸载 '+platform+' 的 MCP 配置?'))return;
416
- api('/api/bind/'+remoteId+'/mcp',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({platform:platform})}).then(function(r){
417
- if(r.ok){toast(r.message||'已卸载');loadMcpStatus(remoteId).then(function(){return loadMcpAll(remoteId)}).then(function(){_render()})}
418
- else{toast(r.error||'卸载失败','err')}
419
- });
420
- }
421
-
422
- // ============ Prompts ============
423
- function doViewPrompt(remoteId,name){
424
- api('/api/bind/'+remoteId+'/prompts/'+encodeURIComponent(name)).then(function(r){
425
- if(!r.ok){toast(r.error||'读取失败','err');return}
426
- S.modal={title:'📝 '+name,content:r.content,remoteId:remoteId,fileName:name};renderPromptEditor();
427
- });
428
- }
429
- function doCreatePrompt(remoteId,name){S.modal={title:'📝 创建 '+name,content:'',remoteId:remoteId,fileName:name};renderPromptEditor()}
430
- function renderPromptEditor(){
431
- if(!S.modal)return;var div=document.createElement('div');div.className='modal-bg';
432
- var escaped=(S.modal.content||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
433
- div.innerHTML='<div class="modal" style="max-width:650px"><h3>'+S.modal.title+'</h3>'+
434
- '<textarea class="prompt-editor" id="promptEditor">'+escaped+'</textarea>'+
435
- '<div class="btn-row"><button class="btn btn-p" onclick="doSavePrompt()">💾 保存</button>'+
436
- '<button class="btn btn-s" onclick="closeModal()">关闭</button></div></div>';
437
- document.body.appendChild(div);div.addEventListener('click',function(e){if(e.target===div)div.remove()});
438
- }
439
- function doSavePrompt(){
440
- var content=document.getElementById('promptEditor').value;
441
- api('/api/bind/'+S.modal.remoteId+'/prompts/'+encodeURIComponent(S.modal.fileName),{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:content})}).then(function(r){
442
- if(r.ok){toast('✅ 已保存');var rid=S.modal.remoteId;closeModal();loadPrompts(rid).then(function(){_render()})}
443
- else{toast(r.error||'保存失败','err')}
444
- });
445
- }
446
-
447
- // ============ Unbind ============
448
- function doUnbind(remoteId){
449
- if(!confirm('确定解除绑定?'))return;
450
- api('/api/bind/'+remoteId,{method:'DELETE'}).then(function(r){if(r.ok){toast('已解绑');init();go('list')}else{toast('解绑失败','err')}});
451
- }
452
-
453
- // ============ Auto-refresh ============
454
- var _detailLoading=false,_lastDetailRid=null;
455
- var _render=render;
456
- render=function(){
457
- _render();
458
- var _rid=S.projects[S.detailIdx]?S.projects[S.detailIdx].remoteId:null;
459
- if(S.page==='detail'&&_rid&&!_detailLoading&&_lastDetailRid!==_rid){
460
- _detailLoading=true;_lastDetailRid=_rid;
461
- Promise.all([loadTree(_rid),loadMcpStatus(_rid),loadMcpAll(_rid),loadPrompts(_rid)]).then(function(){_detailLoading=false;_render()}).catch(function(){_detailLoading=false});
462
- }
463
- };
464
- setInterval(function(){
465
- if(S.page==='list'){
466
- api('/api/status').then(function(st){S.projects=st.projects||[];S.hostOk=st.hostConnected;_render()}).catch(function(){});
467
- }
468
- },10000);
469
-
470
- init();
471
- </script>
472
- </body>
6
+ return `<!DOCTYPE html>
7
+ <html lang="zh-CN">
8
+ <head>
9
+ <meta charset="UTF-8">
10
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
11
+ <title>PPDocs Agent</title>
12
+ <style>
13
+ *{margin:0;padding:0;box-sizing:border-box}
14
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;background:#0a0e1a;color:#e0e6ed;min-height:100vh}
15
+ .app{max-width:700px;margin:0 auto;padding:32px 20px}
16
+ h1{font-size:24px;font-weight:700;background:linear-gradient(135deg,#60a5fa,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
17
+ .header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}
18
+ .host-badge{font-size:12px;padding:4px 12px;border-radius:20px;background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3)}
19
+ .host-badge.off{background:rgba(239,68,68,.15);color:#ef4444;border-color:rgba(239,68,68,.3)}
20
+ .card{background:rgba(30,41,59,.7);backdrop-filter:blur(12px);border:1px solid rgba(100,116,139,.2);border-radius:14px;padding:20px;margin-bottom:12px;cursor:pointer;transition:all .2s}
21
+ .card:hover{border-color:rgba(96,165,250,.4);transform:translateY(-1px)}
22
+ .card.no-hover{cursor:default;transform:none}
23
+ .card h3{font-size:15px;font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:8px}
24
+ .card .meta{font-size:12px;color:#64748b;margin-top:4px}
25
+ .card .stat{display:flex;gap:16px;margin-top:8px;font-size:13px}
26
+ .card .stat span{color:#94a3b8}
27
+ .sync-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
28
+ .sync-dot.on{background:#22c55e;box-shadow:0 0 6px #22c55e}
29
+ .sync-dot.off{background:#64748b}
30
+ .btn{padding:8px 16px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:5px}
31
+ .btn-p{background:linear-gradient(135deg,#3b82f6,#6366f1);color:#fff}
32
+ .btn-p:hover{box-shadow:0 4px 12px rgba(59,130,246,.4)}
33
+ .btn-s{background:rgba(51,65,85,.8);color:#94a3b8}
34
+ .btn-s:hover{background:rgba(71,85,105,.8)}
35
+ .btn-d{background:rgba(239,68,68,.15);color:#ef4444;border:1px solid rgba(239,68,68,.3)}
36
+ .btn-d:hover{background:rgba(239,68,68,.25)}
37
+ .btn-row{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
38
+ input,select{background:rgba(15,23,42,.8);border:1px solid rgba(100,116,139,.3);border-radius:8px;padding:10px 14px;color:#e0e6ed;font-size:14px;outline:none;width:100%;transition:border-color .2s}
39
+ input:focus{border-color:#60a5fa}
40
+ label{font-size:12px;color:#64748b;font-weight:500;display:block;margin-bottom:4px}
41
+ .fg{margin-bottom:14px}
42
+ .section{margin-bottom:20px}
43
+ .section h2{font-size:14px;font-weight:600;color:#94a3b8;margin-bottom:12px;display:flex;align-items:center;gap:6px}
44
+ .tree-box{background:rgba(15,23,42,.8);border:1px solid rgba(100,116,139,.2);border-radius:10px;padding:14px;font-family:'Cascadia Code','Fira Code',monospace;font-size:12px;color:#94a3b8;max-height:280px;overflow-y:auto;line-height:1.7;white-space:pre}
45
+ .platform-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
46
+ .platform-card{padding:14px;border-radius:10px;border:1px solid rgba(100,116,139,.2);text-align:center;transition:all .2s}
47
+ .platform-card:hover{border-color:#a78bfa;background:rgba(167,139,250,.08)}
48
+ .platform-card .icon{font-size:22px;margin-bottom:4px}
49
+ .platform-card .name{font-size:13px;font-weight:600}
50
+ .modal-bg{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);display:flex;justify-content:center;align-items:center;z-index:50;backdrop-filter:blur(4px)}
51
+ .modal{background:#1e293b;border:1px solid rgba(100,116,139,.3);border-radius:16px;padding:24px;width:90%;max-width:500px;max-height:80vh;overflow-y:auto}
52
+ .modal h3{font-size:16px;margin-bottom:16px}
53
+ .cmd-box{background:rgba(15,23,42,.9);border:1px solid rgba(100,116,139,.2);border-radius:8px;padding:12px;font-family:monospace;font-size:12px;color:#94a3b8;word-break:break-all;margin:10px 0;white-space:pre-wrap}
54
+ .toast{position:fixed;top:20px;right:20px;padding:10px 18px;border-radius:8px;font-size:13px;font-weight:500;opacity:0;transform:translateY(-10px);transition:all .3s;z-index:99}
55
+ .toast.show{opacity:1;transform:translateY(0)}
56
+ .toast.ok{background:rgba(34,197,94,.2);border:1px solid #22c55e;color:#22c55e}
57
+ .toast.err{background:rgba(239,68,68,.2);border:1px solid #ef4444;color:#ef4444}
58
+ .empty{text-align:center;padding:40px;color:#475569;font-size:14px}
59
+ .back{font-size:13px;color:#64748b;cursor:pointer;margin-bottom:16px;display:inline-block}
60
+ .back:hover{color:#94a3b8}
61
+ .row{display:flex;gap:12px}
62
+ .row>*{flex:1}
63
+ .drop-zone{border:2px dashed rgba(100,116,139,.3);border-radius:14px;padding:40px;text-align:center;color:#475569;font-size:14px;transition:all .3s;cursor:pointer;margin-top:12px}
64
+ .drop-zone.over{border-color:#60a5fa;background:rgba(96,165,250,.06);color:#60a5fa}
65
+ .auth-badge{font-size:12px;padding:3px 10px;border-radius:12px;font-weight:600;display:inline-flex;align-items:center;gap:4px}
66
+ .auth-badge.ok{background:rgba(34,197,94,.15);color:#22c55e}
67
+ .auth-badge.no{background:rgba(245,158,11,.15);color:#f59e0b}
68
+ .prompt-editor{width:100%;min-height:200px;background:rgba(15,23,42,.9);border:1px solid rgba(100,116,139,.2);border-radius:8px;padding:12px;font-family:'Cascadia Code','Fira Code',monospace;font-size:12px;color:#94a3b8;resize:vertical}
69
+ .mcp-list-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-radius:8px;border:1px solid rgba(100,116,139,.15);margin-bottom:4px;font-size:13px}
70
+ .mcp-list-item .badges{display:flex;gap:4px}
71
+ .mcp-list-item .badges span{font-size:10px;padding:2px 6px;border-radius:8px;background:rgba(96,165,250,.15);color:#60a5fa}
72
+ .tab-row{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(100,116,139,.2)}
73
+ .tab{padding:8px 16px;font-size:13px;font-weight:600;color:#64748b;cursor:pointer;border-bottom:2px solid transparent;transition:all .2s}
74
+ .tab.active{color:#60a5fa;border-color:#60a5fa}
75
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
76
+ </style>
77
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
78
+ <meta http-equiv="Pragma" content="no-cache">
79
+ <meta http-equiv="Expires" content="0">
80
+ </head>
81
+ <body>
82
+ <div class="app" id="app"></div>
83
+ <div class="toast" id="toast"></div>
84
+ <script>
85
+ // ============ State ============
86
+ var S={host:'',port:20001,hostOk:false,projects:[],page:'list',addStep:1,addDir:'',detailIdx:-1,detailTab:'overview',mcpStatus:{},mcpAll:[],prompts:[],authRequestId:null,authStartTime:0,authStatus:'',authResult:null,modal:null};
87
+
88
+ // ============ Router ============
89
+ function go(page,data){Object.assign(S,data||{});S.page=page;_lastDetailRid=null;cancelAuth();render()}
90
+
91
+ // ============ Toast ============
92
+ function toast(m,t){t=t||'ok';var e=document.getElementById('toast');e.textContent=m;e.className='toast '+t+' show';setTimeout(function(){e.className='toast'},3000)}
93
+
94
+ // ============ API ============
95
+ function api(path,opts){return fetch(path,opts).then(function(r){return r.json()}).then(function(d){console.log('[api]',path,d);return d})}
96
+
97
+ // ============ Init ============
98
+ function init(){
99
+ api('/api/status').then(function(st){
100
+ S.host=st.host||'';S.port=st.port||20001;S.hostOk=st.hostConnected;S.projects=st.projects||[];
101
+ render();
102
+ }).catch(function(){render()});
103
+ // Drag-drop
104
+ document.body.addEventListener('dragover',function(e){e.preventDefault();e.dataTransfer.dropEffect='copy';var dz=document.querySelector('.drop-zone');if(dz)dz.classList.add('over')});
105
+ document.body.addEventListener('dragleave',function(){var dz=document.querySelector('.drop-zone');if(dz)dz.classList.remove('over')});
106
+ document.body.addEventListener('drop',function(e){
107
+ e.preventDefault();
108
+ var dz=document.querySelector('.drop-zone');if(dz)dz.classList.remove('over');
109
+ var items=e.dataTransfer.items;
110
+ for(var i=0;i<items.length;i++){
111
+ var entry=items[i].webkitGetAsEntry&&items[i].webkitGetAsEntry();
112
+ if(entry&&entry.isDirectory){
113
+ go('add',{addStep:1,addDir:entry.name});
114
+ toast('📂 检测到 '+entry.name+',可直接发送授权或补全路径');
115
+ return;
116
+ }
117
+ }
118
+ });
119
+ }
120
+
121
+ // ============ Render ============
122
+ function render(){
123
+ var app=document.getElementById('app');
124
+ if(!S.host&&S.page!=='setup'){S.page='setup'}
125
+ switch(S.page){
126
+ case 'setup':app.innerHTML=renderSetup();break;
127
+ case 'list':app.innerHTML=renderList();break;
128
+ case 'add':app.innerHTML=renderAdd();break;
129
+ case 'detail':app.innerHTML=renderDetail();break;
130
+ }
131
+ }
132
+
133
+ // ============ Setup ============
134
+ function renderSetup(){
135
+ return '<div class="header"><h1>📡 PPDocs Agent</h1></div>'+
136
+ '<div class="card no-hover"><div class="section"><h2>🏠 主机连接</h2>'+
137
+ '<div class="row"><div class="fg"><label>主机 IP</label><input id="iHost" value="'+S.host+'" placeholder="10.0.0.176"></div>'+
138
+ '<div class="fg" style="max-width:120px"><label>端口</label><input id="iPort" type="number" value="'+S.port+'" placeholder="20001"></div></div>'+
139
+ '<div class="btn-row"><button class="btn btn-p" onclick="doTestHost()">🔍 测试连接</button>'+
140
+ '<span id="hostResult" style="font-size:13px;line-height:32px;margin-left:8px"></span></div></div></div>';
141
+ }
142
+
143
+ function doTestHost(){
144
+ var host=document.getElementById('iHost').value.trim();
145
+ var port=parseInt(document.getElementById('iPort').value)||20001;
146
+ var el=document.getElementById('hostResult');
147
+ el.textContent='连接中...';
148
+ api('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({host:host,port:port})}).then(function(r){
149
+ if(r.ok){
150
+ el.innerHTML='<span style="color:#22c55e">✅ 连接成功</span>';
151
+ api('/api/host',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({host:host,port:port})});
152
+ S.host=host;S.port=port;S.hostOk=true;
153
+ setTimeout(function(){go('list')},800);
154
+ }else{
155
+ el.innerHTML='<span style="color:#ef4444">❌ '+(r.error||'连接失败')+'</span>';
156
+ }
157
+ });
158
+ }
159
+
160
+ // ============ Project List ============
161
+ function renderList(){
162
+ var badge=S.hostOk?'<span class="host-badge">🟢 '+S.host+':'+S.port+'</span>':'<span class="host-badge off">🔴 未连接</span>';
163
+ var cards='';
164
+ if(S.projects.length===0){
165
+ cards='<div class="empty">暂无项目,点击上方按钮添加</div>';
166
+ }else{
167
+ cards=S.projects.map(function(p,i){
168
+ var dot=p.connected?'<span class="sync-dot on"></span>':'<span class="sync-dot off"></span>';
169
+ var sync=p.lastSync?'同步: '+p.lastSync:p.syncStatus||'';
170
+ return '<div class="card" onclick="go(\\x27detail\\x27,{detailIdx:'+i+',detailTab:\\x27overview\\x27})">'+
171
+ '<h3>'+dot+' 📂 '+(p.localName||p.remoteName)+'</h3>'+
172
+ '<div class="meta">'+(p.localDir||'')+'</div>'+
173
+ '<div class="stat"><span>远程: '+p.remoteName+' ('+p.remoteId+')</span><span>'+(p.docCount?p.docCount+' 文档':'')+'</span></div>'+
174
+ '<div class="meta">'+sync+'</div></div>';
175
+ }).join('');
176
+ }
177
+ return '<div class="header"><h1>📡 PPDocs Agent</h1>'+badge+'</div>'+
178
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">'+
179
+ '<span style="font-size:15px;font-weight:600">我的项目 ('+S.projects.length+')</span>'+
180
+ '<div class="btn-row" style="margin:0"><button class="btn btn-p" onclick="go(\\x27add\\x27,{addStep:1,addDir:\\x27\\x27})">➕ 添加项目</button>'+
181
+ '<button class="btn btn-s" onclick="go(\\x27setup\\x27)">⚙️</button></div></div>'+
182
+ cards+
183
+ '<div class="drop-zone">📂 拖拽项目文件夹到此处快速添加</div>';
184
+ }
185
+
186
+ // ============ Add Project ============
187
+ function renderAdd(){
188
+ if(S.addStep===1) return renderAddStep1();
189
+ return renderAddStep2();
190
+ }
191
+
192
+ function renderAddStep1(){
193
+ return '<span class="back" onclick="cancelAuth();go(\\x27list\\x27)">← 返回项目列表</span>'+
194
+ '<div class="card no-hover"><div class="section"><h2>➕ 添加项目</h2>'+
195
+ '<p style="font-size:13px;color:#64748b;margin-bottom:12px">点击发送授权,在主机端 PPDocs 应用中选择项目并批准</p>'+
196
+ '<div class="fg"><label>本地项目目录 (可选,MCP安装时需要)</label><input id="iDir" value="'+S.addDir+'" placeholder="(可选) D:/projects/my-app" oninput="S.addDir=this.value"></div>'+
197
+ '<div class="btn-row"><button class="btn btn-p" onclick="doAuthStart()">📡 发送授权请求</button></div>'+
198
+ '</div></div>';
199
+ }
200
+
201
+ function doAuthStart(){
202
+ var dir=(document.getElementById('iDir')?document.getElementById('iDir').value:'').trim();
203
+ if(dir&&!/^[A-Za-z]:[\\\\\\/]|^\\//.test(dir)){toast('路径需为绝对路径或留空','err');return}
204
+ S.addDir=dir;
205
+ api('/api/auth/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({localDir:dir||''})}).then(function(r){
206
+ if(!r.ok){toast(r.error||'授权请求失败','err');return}
207
+ S.authRequestId=r.requestId;S.authStartTime=Date.now();S.authStatus='pending';S.addStep=2;
208
+ render();startAuthPoll();
209
+ });
210
+ }
211
+
212
+ var authPollTimer=null,authUiTimer=null;
213
+ function startAuthPoll(){
214
+ cancelAuth();
215
+ authPollTimer=setInterval(function(){
216
+ if(!S.authRequestId){cancelAuth();return}
217
+ var elapsed=Math.floor((Date.now()-S.authStartTime)/1000);
218
+ if(elapsed>=300){S.authStatus='expired';cancelAuth();_render();return}
219
+ api('/api/auth/status/'+S.authRequestId).then(function(r){
220
+ S.authStatus=r.status;
221
+ if(r.status==='approved'){
222
+ cancelAuth();
223
+ api('/api/bind',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
224
+ localDir:S.addDir,remoteId:r.projectId,remoteName:r.projectName,password:r.password
225
+ })}).then(function(br){
226
+ if(br.ok){
227
+ S.authResult={name:r.projectName,id:r.projectId};_render();
228
+ toast('✅ 授权成功,已绑定 '+r.projectName);
229
+ setTimeout(function(){init();go('list')},2000);
230
+ }else{toast(br.error||'绑定失败','err')}
231
+ });
232
+ }else if(r.status==='rejected'){cancelAuth();_render()}
233
+ else{_render()}
234
+ }).catch(function(){});
235
+ },2000);
236
+ authUiTimer=setInterval(function(){if(S.page==='add'&&S.addStep===2&&S.authStatus==='pending')_render()},1000);
237
+ }
238
+ function cancelAuth(){if(authPollTimer){clearInterval(authPollTimer);authPollTimer=null}if(authUiTimer){clearInterval(authUiTimer);authUiTimer=null}}
239
+
240
+ function renderAddStep2(){
241
+ var elapsed=Math.floor((Date.now()-(S.authStartTime||Date.now()))/1000);
242
+ var pct=Math.min(100,Math.round(elapsed/300*100));
243
+ if(S.authStatus==='approved'&&S.authResult){
244
+ return '<div class="card no-hover" style="text-align:center;padding:40px">'+
245
+ '<div style="font-size:40px;margin-bottom:12px">✅</div>'+
246
+ '<h2 style="color:#22c55e;font-size:18px;margin-bottom:8px">授权成功!</h2>'+
247
+ '<p style="color:#94a3b8;font-size:14px">项目: '+S.authResult.name+' ('+S.authResult.id+')</p>'+
248
+ '<p style="color:#64748b;font-size:13px;margin-top:4px">权限: 读写 | 2秒后跳转...</p></div>';
249
+ }
250
+ if(S.authStatus==='rejected'){
251
+ return '<span class="back" onclick="go(\\x27list\\x27)">← 返回</span>'+
252
+ '<div class="card no-hover" style="text-align:center;padding:40px">'+
253
+ '<div style="font-size:40px;margin-bottom:12px">❌</div>'+
254
+ '<h2 style="color:#ef4444;font-size:18px;margin-bottom:8px">授权被拒绝</h2>'+
255
+ '<div class="btn-row" style="justify-content:center;margin-top:16px"><button class="btn btn-p" onclick="S.addStep=1;render()">重试</button></div></div>';
256
+ }
257
+ if(S.authStatus==='expired'){
258
+ return '<span class="back" onclick="go(\\x27list\\x27)">← 返回</span>'+
259
+ '<div class="card no-hover" style="text-align:center;padding:40px">'+
260
+ '<div style="font-size:40px;margin-bottom:12px">⏰</div>'+
261
+ '<h2 style="color:#f59e0b;font-size:18px;margin-bottom:8px">授权已超时</h2>'+
262
+ '<div class="btn-row" style="justify-content:center;margin-top:16px"><button class="btn btn-p" onclick="S.addStep=1;render()">重试</button></div></div>';
263
+ }
264
+ return '<span class="back" onclick="cancelAuth();S.addStep=1;render()">← 返回</span>'+
265
+ '<div class="card no-hover"><div class="section"><h2>➕ 添加项目 — Step 2/2</h2>'+
266
+ '<div style="text-align:center;padding:20px 0">'+
267
+ '<div style="font-size:36px;margin-bottom:10px;animation:pulse 2s infinite">📡</div>'+
268
+ '<p style="font-size:15px;font-weight:600;color:#60a5fa;margin-bottom:6px">等待主机端授权...</p>'+
269
+ '<p style="font-size:12px;color:#64748b;margin-bottom:4px">已向 '+S.host+':'+S.port+' 发送授权请求</p>'+
270
+ '<p style="font-size:12px;color:#475569">请在主机端 PPDocs 桌面应用中选择项目并批准</p>'+
271
+ '<div style="margin:16px auto;width:80%;height:4px;background:rgba(100,116,139,.2);border-radius:2px;overflow:hidden">'+
272
+ '<div style="width:'+pct+'%;height:100%;background:linear-gradient(90deg,#3b82f6,#6366f1);border-radius:2px;transition:width .5s"></div></div>'+
273
+ '<p style="font-size:11px;color:#475569">'+elapsed+'s / 300s</p>'+
274
+ '</div><div class="btn-row" style="justify-content:center"><button class="btn btn-s" onclick="cancelAuth();go(\\x27list\\x27)">取消</button></div></div></div>';
275
+ }
276
+
277
+ // ============ Project Detail ============
278
+ function renderDetail(){
279
+ var p=S.projects[S.detailIdx];
280
+ if(!p) return '<span class="back" onclick="go(\\x27list\\x27)">← 项目不存在</span>';
281
+ var tab=S.detailTab||'overview';
282
+ var tabs=[{key:'overview',label:'📊 概览'},{key:'mcp',label:'🤖 MCP'},{key:'prompts',label:'📝 提示词'}];
283
+ var tabHtml=tabs.map(function(t){return '<div class="tab '+(tab===t.key?'active':'')+'" onclick="S.detailTab=\\x27'+t.key+'\\x27;_lastDetailRid=null;render()">'+t.label+'</div>'}).join('');
284
+ var tabContent='';
285
+ if(tab==='overview') tabContent=renderOverview(p);
286
+ else if(tab==='mcp') tabContent=renderMcpTab(p);
287
+ else if(tab==='prompts') tabContent=renderPromptsTab(p);
288
+ return '<span class="back" onclick="go(\\x27list\\x27)">← 返回项目列表</span>'+
289
+ '<div class="header"><h1>📂 '+(p.localName||p.remoteName)+'</h1></div>'+
290
+ '<div class="tab-row">'+tabHtml+'</div>'+tabContent+
291
+ '<div class="card no-hover" style="border-color:rgba(239,68,68,.2)"><div class="section"><h2>⚠️ 危险区域</h2>'+
292
+ '<button class="btn btn-d" onclick="doUnbind(\\x27'+p.remoteId+'\\x27)">🗑️ 解除绑定</button></div></div>';
293
+ }
294
+
295
+ function renderOverview(p){
296
+ var authBadge=p.hasPassword?'<span class="auth-badge ok">✅ 已授权 (读写)</span>':'<span class="auth-badge no">⚠️ 未授权 (只读)</span>';
297
+ var authBtn=p.hasPassword?'':'<button class="btn btn-p" style="font-size:12px;padding:5px 12px;margin-left:8px" onclick="doReAuth(\\x27'+p.remoteId+'\\x27,\\x27'+(p.localDir||'').replace(/\\\\/g,'/')+'\\x27)">📡 申请读写授权</button>';
298
+ return '<div class="card no-hover"><div class="section"><h2>📊 概览</h2>'+
299
+ '<div class="stat"><span>本地: '+(p.localDir||'<em style="color:#ef4444">未指定</em>')+'</span></div>'+
300
+ '<div class="stat"><span>远程: '+p.remoteName+' ('+p.remoteId+')</span></div>'+
301
+ '<div class="stat"><span>授权: '+authBadge+authBtn+'</span></div>'+
302
+ '<div class="stat"><span>文档: '+(p.docCount||0)+' 个</span></div>'+
303
+ '<div class="stat"><span>同步: '+(p.connected?'🟢 运行中':'🔴 '+p.syncStatus)+' '+(p.lastSync?'('+p.lastSync+')':'')+'</span></div>'+
304
+ '</div></div>'+
305
+ '<div class="card no-hover"><div class="section"><h2>📚 知识图谱</h2>'+
306
+ '<div class="tree-box" id="treeBox">加载中 ...</div></div></div>';
307
+ }
308
+
309
+ function renderMcpTab(p){
310
+ var mcpState=S.mcpStatus||{};
311
+ var platforms=[
312
+ {key:'cursor',icon:'🟣',name:'Cursor'},
313
+ {key:'antigravity',icon:'🟢',name:'Antigravity'},
314
+ {key:'vscode',icon:'🔵',name:'OpenCode'},
315
+ {key:'claude',icon:'🟠',name:'Claude'},
316
+ {key:'lobechat',icon:'🦞',name:'LobeChat'}
317
+ ];
318
+ var platformCards=platforms.map(function(pl){
319
+ var isManual=pl.key==='lobechat';
320
+ var installed=mcpState[pl.key];
321
+ var badge='',btn='';
322
+ if(isManual){
323
+ badge='<span style="font-size:11px;color:#94a3b8;display:block;margin:4px 0">手动配置</span>';
324
+ btn='<button class="btn btn-p" style="font-size:11px;padding:4px 10px;background:rgba(59,130,246,.5)" onclick="event.stopPropagation();doMcpInstall(\\x27'+p.remoteId+'\\x27,\\x27'+pl.key+'\\x27)">获取配置</button>';
325
+ }else{
326
+ badge=installed?'<span style="font-size:11px;color:#22c55e;display:block;margin:4px 0">✅ 已配置</span>':'<span style="font-size:11px;color:#64748b;display:block;margin:4px 0">未配置</span>';
327
+ btn=installed?'<button class="btn btn-d" style="font-size:11px;padding:4px 10px" onclick="event.stopPropagation();doMcpUninstall(\\x27'+p.remoteId+'\\x27,\\x27'+pl.key+'\\x27)">卸载</button>':'<button class="btn btn-p" style="font-size:11px;padding:4px 10px" onclick="event.stopPropagation();doMcpInstall(\\x27'+p.remoteId+'\\x27,\\x27'+pl.key+'\\x27)">安装/更新</button>';
328
+ }
329
+ return '<div class="platform-card"><div class="icon">'+pl.icon+'</div><div class="name" style="white-space:nowrap">'+pl.name+'</div>'+badge+btn+'</div>';
330
+ }).join('');
331
+ var noDir=!p.localDir;
332
+ var mcpListHtml='';
333
+ if(S.mcpAll&&S.mcpAll.length>0){
334
+ mcpListHtml=S.mcpAll.map(function(s){
335
+ var badges=s.platforms.map(function(pl){return '<span>'+pl+'</span>'}).join('');
336
+ return '<div class="mcp-list-item"><span>📦 '+s.name+'</span><div class="badges">'+badges+'</div></div>';
337
+ }).join('');
338
+ }else{
339
+ mcpListHtml='<div style="font-size:12px;color:#475569;padding:8px 0">未检测到任何 MCP 服务</div>';
340
+ }
341
+ return '<div class="card no-hover"><div class="section"><h2>🤖 PPDocs MCP 安装</h2>'+
342
+ (noDir?'<div style="font-size:13px;color:#f59e0b;margin-bottom:8px">⚠️ 未指定本地目录</div>':'')+
343
+ '<div class="platform-grid">'+platformCards+'</div></div></div>'+
344
+ '<div class="card no-hover"><div class="section"><h2>📦 项目内全部 MCP ('+(S.mcpAll?S.mcpAll.length:0)+')</h2>'+
345
+ mcpListHtml+'</div></div>';
346
+ }
347
+
348
+ function renderPromptsTab(p){
349
+ if(!p.localDir) return '<div class="card no-hover"><div class="section"><h2>📝 提示词</h2><div style="font-size:13px;color:#f59e0b">⚠️ 未指定本地目录</div></div></div>';
350
+ var files=S.prompts||[];
351
+ var cards=files.map(function(f){
352
+ var statusBadge=f.exists?'<span style="font-size:11px;color:#22c55e">'+f.lines+' 行</span>':'<span style="font-size:11px;color:#64748b">不存在</span>';
353
+ var btn=f.exists
354
+ ?'<button class="btn btn-s" style="font-size:11px;padding:3px 8px" onclick="doViewPrompt(\\x27'+p.remoteId+'\\x27,\\x27'+f.name+'\\x27)">查看/编辑</button>'
355
+ :'<button class="btn btn-p" style="font-size:11px;padding:3px 8px" onclick="doCreatePrompt(\\x27'+p.remoteId+'\\x27,\\x27'+f.name+'\\x27)">创建</button>';
356
+ return '<div class="mcp-list-item"><span>📄 '+f.name+'</span><div style="display:flex;gap:8px;align-items:center">'+statusBadge+btn+'</div></div>';
357
+ }).join('');
358
+ return '<div class="card no-hover"><div class="section"><h2>📝 系统提示词</h2>'+(cards||'<div style="font-size:12px;color:#475569">加载中...</div>')+'</div></div>';
359
+ }
360
+
361
+ // ============ Data Loading ============
362
+ function loadTree(rid){
363
+ var box=document.getElementById('treeBox');
364
+ if(!box) return;
365
+ var xhr=new XMLHttpRequest();
366
+ xhr.open('GET','/api/bind/'+rid+'/tree?_='+Date.now(),true);
367
+ xhr.onload=function(){
368
+ try{
369
+ var r=JSON.parse(xhr.responseText);
370
+ var txt=r.tree||'知识库为空';
371
+ var NL=String.fromCharCode(10);
372
+ if(r.docCount>0) txt=''+r.docCount+' 文档, '+r.dirCount+' 目录'+NL+NL+txt;
373
+ if(r.readOnly) txt='🔒 只读模式'+NL+NL+txt;
374
+ box.textContent=txt;
375
+ }catch(e){box.textContent='解析失败: '+e}
376
+ };
377
+ xhr.onerror=function(){box.textContent='请求失败'};
378
+ xhr.send();
379
+ }
380
+ function loadMcpStatus(rid){return api('/api/bind/'+rid+'/mcp').then(function(r){S.mcpStatus=r.platforms||{}}).catch(function(){S.mcpStatus={}})}
381
+ function loadMcpAll(rid){return api('/api/bind/'+rid+'/mcp/all').then(function(r){S.mcpAll=r.servers||[]}).catch(function(){S.mcpAll=[]})}
382
+ function loadPrompts(rid){return api('/api/bind/'+rid+'/prompts').then(function(r){S.prompts=r.files||[]}).catch(function(){S.prompts=[]})}
383
+
384
+ // ============ Auth from Detail ============
385
+ function doReAuth(remoteId,localDir){
386
+ cancelAuth();
387
+ api('/api/auth/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({localDir:localDir})}).then(function(r){
388
+ if(!r.ok){toast(r.error||'授权请求失败','err');return}
389
+ toast('📡 已发送授权请求,请在主机端批准');
390
+ S.authRequestId=r.requestId;S.authStartTime=Date.now();
391
+ authPollTimer=setInterval(function(){
392
+ var elapsed=Math.floor((Date.now()-S.authStartTime)/1000);
393
+ if(elapsed>=300){cancelAuth();toast('⏰ 授权已超时','err');return}
394
+ api('/api/auth/status/'+S.authRequestId).then(function(sr){
395
+ if(sr.status==='approved'){cancelAuth();
396
+ api('/api/bind/'+remoteId,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:sr.password,remoteName:sr.projectName})}).then(function(){
397
+ toast('✅ 授权成功!');_lastDetailRid=null;init();
398
+ });
399
+ }else if(sr.status==='rejected'){cancelAuth();toast('❌ 授权被拒绝','err')}
400
+ else if(sr.status==='expired'){cancelAuth();toast(' 授权已超时','err')}
401
+ }).catch(function(){});
402
+ },2000);
403
+ });
404
+ }
405
+
406
+ // ============ MCP ============
407
+ function doMcpInstall(remoteId,platform){
408
+ api('/api/bind/'+remoteId+'/mcp',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({platform:platform})}).then(function(r){
409
+ if(r.ok){
410
+ if(r.action==='copy'){S.modal={title:platform.toUpperCase()+' MCP 配置',content:r.config};renderModal()}
411
+ else{toast(r.message||'已安装/更新');loadMcpStatus(remoteId).then(function(){return loadMcpAll(remoteId)}).then(function(){_render()})}
412
+ }else{toast(r.error||'操作失败','err')}
413
+ });
414
+ }
415
+ function renderModal(){
416
+ if(!S.modal)return;var div=document.createElement('div');div.className='modal-bg';
417
+ div.innerHTML='<div class="modal"><h3>'+S.modal.title+'</h3><div class="cmd-box" id="mcpCopyBox">'+S.modal.content+'</div>'+
418
+ '<div class="btn-row"><button class="btn btn-p" onclick="copyMcpConfig()">📋 复制</button>'+
419
+ '<button class="btn btn-s" onclick="closeModal()">关闭</button></div></div>';
420
+ document.body.appendChild(div);div.addEventListener('click',function(e){if(e.target===div)div.remove()});
421
+ }
422
+ function closeModal(){var bg=document.querySelector('.modal-bg');if(bg)bg.remove();S.modal=null}
423
+ function copyMcpConfig(){var el=document.getElementById('mcpCopyBox');if(el){navigator.clipboard.writeText(el.textContent);toast('已复制')}}
424
+ function doMcpUninstall(remoteId,platform){
425
+ if(!confirm('确定卸载 '+platform+' 的 MCP 配置?'))return;
426
+ api('/api/bind/'+remoteId+'/mcp',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({platform:platform})}).then(function(r){
427
+ if(r.ok){toast(r.message||'已卸载');loadMcpStatus(remoteId).then(function(){return loadMcpAll(remoteId)}).then(function(){_render()})}
428
+ else{toast(r.error||'卸载失败','err')}
429
+ });
430
+ }
431
+
432
+ // ============ Prompts ============
433
+ function doViewPrompt(remoteId,name){
434
+ api('/api/bind/'+remoteId+'/prompts/'+encodeURIComponent(name)).then(function(r){
435
+ if(!r.ok){toast(r.error||'读取失败','err');return}
436
+ S.modal={title:'📝 '+name,content:r.content,remoteId:remoteId,fileName:name};renderPromptEditor();
437
+ });
438
+ }
439
+ function doCreatePrompt(remoteId,name){S.modal={title:'📝 创建 '+name,content:'',remoteId:remoteId,fileName:name};renderPromptEditor()}
440
+ function renderPromptEditor(){
441
+ if(!S.modal)return;var div=document.createElement('div');div.className='modal-bg';
442
+ var escaped=(S.modal.content||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
443
+ div.innerHTML='<div class="modal" style="max-width:650px"><h3>'+S.modal.title+'</h3>'+
444
+ '<textarea class="prompt-editor" id="promptEditor">'+escaped+'</textarea>'+
445
+ '<div class="btn-row"><button class="btn btn-p" onclick="doSavePrompt()">💾 保存</button>'+
446
+ '<button class="btn btn-s" onclick="closeModal()">关闭</button></div></div>';
447
+ document.body.appendChild(div);div.addEventListener('click',function(e){if(e.target===div)div.remove()});
448
+ }
449
+ function doSavePrompt(){
450
+ var content=document.getElementById('promptEditor').value;
451
+ api('/api/bind/'+S.modal.remoteId+'/prompts/'+encodeURIComponent(S.modal.fileName),{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:content})}).then(function(r){
452
+ if(r.ok){toast('✅ 已保存');var rid=S.modal.remoteId;closeModal();loadPrompts(rid).then(function(){_render()})}
453
+ else{toast(r.error||'保存失败','err')}
454
+ });
455
+ }
456
+
457
+ // ============ Unbind ============
458
+ function doUnbind(remoteId){
459
+ if(!confirm('确定解除绑定?'))return;
460
+ api('/api/bind/'+remoteId,{method:'DELETE'}).then(function(r){if(r.ok){toast('已解绑');init();go('list')}else{toast('解绑失败','err')}});
461
+ }
462
+
463
+ // ============ Auto-refresh ============
464
+ var _detailLoading=false,_lastDetailRid=null;
465
+ var _render=render;
466
+ render=function(){
467
+ _render();
468
+ var _rid=S.projects[S.detailIdx]?S.projects[S.detailIdx].remoteId:null;
469
+ if(S.page==='detail'&&_rid&&!_detailLoading&&_lastDetailRid!==_rid){
470
+ _detailLoading=true;_lastDetailRid=_rid;
471
+ loadMcpStatus(_rid);loadMcpAll(_rid);loadPrompts(_rid);
472
+ setTimeout(function(){
473
+ _detailLoading=false;
474
+ loadTree(_rid);
475
+ },100);
476
+ }
477
+ };
478
+ setInterval(function(){
479
+ if(S.page==='list'){
480
+ api('/api/status').then(function(st){S.projects=st.projects||[];S.hostOk=st.hostConnected;_render()}).catch(function(){});
481
+ }
482
+ },10000);
483
+
484
+ init();
485
+ </script>
486
+ </body>
473
487
  </html>`;
474
488
  }