feat: co-located module spec prototype (EasyTier/RDP)

- specs/easytier-rdp.json: 双层文档 — human_help(人看) + ai_spec(AI看)
- ai_spec 包含: apis, dependencies, architecture, constraints, must_not, tests, related_files
- dashboard.py: 新增 /api/module-spec/<module> 和 /api/module-specs endpoints
- dashboard.html: EasyTier/RDP 标题旁加 ? (人看) 和 § (AI看) 按钮
- 点击 ? 显示使用说明(人话), 点击 § 显示 AI spec(机器可读)
- CSS: .help-btn 圆形按钮, .spec-modal 全屏弹窗
This commit is contained in:
hmo
2026-07-15 10:47:39 +08:00
parent b11b88887c
commit ef34e6bd1a
3 changed files with 170 additions and 2 deletions
+37
View File
@@ -1032,6 +1032,43 @@ def api_rdp_toggle():
return jsonify({"ok": False, "error": str(e)})
# ════════════════════════════════════════════════════════════
# Module Spec — 功能模块 co-located 文档
# ════════════════════════════════════════════════════════════
_SPECS_DIR = _SCRIPT_DIR / "specs"
@app.route("/api/module-spec/<module>")
def api_module_spec(module):
"""返回指定模块的 co-located spec JSON(人看 + AI 看)"""
spec_file = _SPECS_DIR / f"{module}.json"
if not spec_file.exists():
return jsonify({"ok": False, "error": f"spec not found: {module}"})
try:
with open(spec_file, "r", encoding="utf-8") as f:
return jsonify(json.load(f))
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
@app.route("/api/module-specs")
def api_module_specs():
"""列出所有可用的 module spec"""
specs = []
if _SPECS_DIR.exists():
for p in sorted(_SPECS_DIR.glob("*.json")):
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
specs.append({
"module": data.get("module", p.stem),
"purpose": data.get("purpose", ""),
"version": data.get("version", ""),
})
except:
pass
return jsonify({"ok": True, "specs": specs})
# ════════════════════════════════════════════════════════════
# Spec 文档展示
# ════════════════════════════════════════════════════════════
+81
View File
@@ -0,0 +1,81 @@
{
"module": "easytier-rdp",
"version": "1.0",
"purpose": "EasyTier VPN 网络开关 + RDP 远程桌面隧道开关。允许从外网通过 ags.yoin.fun 控制内网 EasyTier 和 RDP 访问。",
"ui_location": "I Tab (Infrastructure) → EasyTier VPN section + RDP Remote Desktop section",
"human_help": {
"title": "EasyTier VPN + RDP 远程桌面",
"description": [
"EasyTier 是一个 P2P VPN 组网工具,用于连接 Windows(192.168.1.16) 和 Linux 246(192.168.1.246)。",
"Turn On/Off 按钮控制两台机器上的 EasyTier 进程。",
"RDP Enable/Disable 按钮控制 SSH 反向隧道,开启后可通过 47.115.32.206:8080 从外网远程桌面到 Windows。"
],
"usage": [
"1. 点击 Turn On 启动 EasyTier VPN(两台机器都启动)",
"2. 点击 Turn Off 关闭 EasyTier VPN",
"3. 点击 Enable 启动 RDP 隧道(需先确保 EasyTier 已连接,否则 SSH 可能不通)",
"4. 点击 Disable 关闭 RDP 隧道",
"5. RDP 连接方式:mstsc /v:47.115.32.206:8080"
],
"troubleshooting": [
"如果 Turn On 返回失败:检查 Windows xmpp_bot (5802) 是否运行",
"如果 RDP Enable 返回失败:检查 SSH 到 246 是否可用(EasyTier VPN 需先连接)",
"如果隧道状态一直 pending:检查 246 的 SSH GatewayPortsyes 是否配置"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/easytier", "returns": "status{windows,246} + virtual_ips{windows,246}", "proxied_to": "xmpp_bot /easytier action=status"},
{"method": "POST", "path": "/api/easytier/toggle", "body": "{action: start|stop}", "returns": "{ok, message}", "proxied_to": "xmpp_bot /easytier action=start|stop"},
{"method": "GET", "path": "/api/rdp", "returns": "rdp_enabled + tunnel_running + public_endpoint", "proxied_to": "xmpp_bot /rdp action=status"},
{"method": "POST", "path": "/api/rdp/toggle", "body": "{action: start|stop}", "returns": "{ok, message}", "proxied_to": "xmpp_bot /rdp action=start|stop"}
],
"dependencies": [
"xmpp_bot on Windows 192.168.1.16:5802 (HTTP bridge with /easytier and /rdp endpoints)",
"XMPP_BRIDGE_URL env var (default: http://192.168.1.16:5802) — set in systemd service",
"_bridge_post() helper in dashboard.py — proxies POST to xmpp_bot with X-Api-Key header",
"_BRIDGE_KEY = 'xxm_bridge_8f3a2c' — API key for xmpp_bot HTTP bridge",
"port_open() helper in dashboard.py — socket-based port check for tunnel status"
],
"architecture": {
"flow": "Dashboard(246:5803) → _bridge_post() → xmpp_bot(Windows:5802) → subprocess/system",
"easytier_mechanism": "xmpp_bot receives POST /easytier {action} → starts/stops easytier-core process on respective machine",
"rdp_mechanism": "xmpp_bot receives POST /rdp {action} → starts/stops SSH reverse tunnel: ssh -R 0.0.0.0:8080:localhost:3389 root@47.115.32.206"
},
"constraints": [
"VPN IP: Windows=10.144.144.3, 246=10.144.144.1 (NOT .2 — .2 was wrong in old code)",
"RDP SSH reverse tunnel: ssh -R 0.0.0.0:8080:localhost:3389 root@47.115.32.206",
"GatewayPorts yes required in /etc/ssh/sshd_config on 47.115.32.206 (Aliyun)",
"xmpp_bot must be running on Windows with /rdp and /easytier HTTP endpoints (API key: xxm_bridge_8f3a2c)",
"dashboard.py runs on 246 (Linux), xmpp_bot runs on Windows — they communicate over LAN HTTP"
],
"tests": [
{"id": "ET01", "name": "EasyTier toggle start returns ok", "endpoint": "POST /api/easytier/toggle {action:start}"},
{"id": "ET02", "name": "EasyTier toggle stop returns ok", "endpoint": "POST /api/easytier/toggle {action:stop}"},
{"id": "RDP01", "name": "RDP toggle start returns ok", "endpoint": "POST /api/rdp/toggle {action:start}"},
{"id": "RDP02", "name": "RDP toggle stop returns ok", "endpoint": "POST /api/rdp/toggle {action:stop}"},
{"id": "ET-RDP01", "name": "EasyTier status returns virtual_ips", "endpoint": "GET /api/easytier"},
{"id": "ET-RDP02", "name": "RDP status returns public_endpoint 47.115.32.206:8080", "endpoint": "GET /api/rdp"}
],
"must_not": [
"不要把 proxy 调用改成 local subprocessdashboard 在 LinuxEasyTier/RDP 在 Windows",
"不要重写整个 fI() 函数来改 EasyTier/RDP 部分 — 用 create-once/update-state pattern",
"不要把 VPN IP 从 .3 改成 .2 — .3 是正确的",
"不要删除 _bridge_post() helper 或 _BRIDGE_KEY — 它们是 proxy 的核心",
"不要在 fI() 里用 fill('infra',h) — 这会清空整个 ct-infra 导致闪烁"
],
"known_issues": [
"如果 xmpp_bot (5802) 停了,所有 EasyTier/RDP 操作都会失败 — 检查 Windows pythonw.exe 进程",
"如果 246 上 SSH 到 Windows 不通,RDP 隧道无法启动 — 先确保 EasyTier VPN 连接",
"EasyTier status 246 字段可能显示 unknown — 这是正常的,因为 246 的 EasyTier 状态不一定通过 xmpp_bot 返回"
],
"related_files": [
"gateway/scripts/dashboard.py — /api/easytier*, /api/rdp*, _bridge_post(), port_open()",
"gateway/scripts/templates/dashboard.html — fI() function creates ET/RDP sections",
"xmpp_agent_core.py — /easytier and /rdp HTTP endpoints (lines ~780, ~794)",
"/etc/systemd/system/agentsmeeting-dashboard.service — XMPP_BRIDGE_URL env var"
]
}
}
+52 -2
View File
@@ -42,6 +42,21 @@ h1{font-size:32px;font-weight:600;color:var(--accent);margin-bottom:4px}.subtitl
.lp{display:none;border-top:1px solid var(--border);padding:12px 16px;background:#0a0e14}
.lp.open{display:block}.lh{font-size:18px;color:var(--dim);margin-bottom:8px;display:flex;justify-content:space-between}
.lc{font:11px/1.6 "Cascadia Code",Consolas,monospace;color:var(--dim);max-height:300px;overflow-y:auto;background:#06080c;border:1px solid var(--border);border-radius:6px;padding:10px;white-space:pre-wrap}
.help-btn{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;border:1px solid var(--border);background:var(--card);color:var(--dim);cursor:pointer;font-size:14px;line-height:1;margin-left:8px;flex-shrink:0}
.help-btn:hover{border-color:var(--accent);color:var(--accent)}
.help-btn.ai{color:var(--yellow);border-color:var(--yellow)}
.help-btn.ai:hover{background:var(--yellow);color:#000}
.spec-modal{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.7);z-index:1000;display:none;align-items:center;justify-content:center}
.spec-modal.show{display:flex}
.spec-modal .modal-box{background:var(--card);border:1px solid var(--border);border-radius:12px;max-width:700px;max-height:80vh;overflow-y:auto;padding:28px;margin:20px}
.spec-modal .modal-box h3{font-size:22px;color:var(--accent);margin-bottom:12px}
.spec-modal .modal-box .close{float:right;cursor:pointer;color:var(--dim);font-size:22px}
.spec-modal .modal-box .close:hover{color:var(--text)}
.spec-modal .modal-box h4{font-size:19px;color:var(--accent);margin:16px 0 6px}
.spec-modal .modal-box p{font-size:18px;color:var(--text);margin:4px 0}
.spec-modal .modal-box ul{margin:4px 0 4px 20px}
.spec-modal .modal-box li{font-size:18px;color:var(--text);margin:2px 0}
.spec-modal .modal-box pre{background:var(--bg);padding:12px;border-radius:6px;font-size:15px;overflow-x:auto;margin:8px 0}
.toast{position:fixed;top:16px;right:16px;padding:10px 16px;border-radius:6px;font-size:19px;z-index:999;opacity:0;pointer-events:none}
.toast.show{opacity:1}.toast.ok{background:#238636;color:#fff}.toast.err{background:#da3633;color:#fff}
.ps{margin-top:24px}.ps h2{font-size:18px;color:var(--dim);margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
@@ -66,12 +81,47 @@ th{color:var(--dim);font-weight:600;font-size:19px}
</style></head><body>
<h1>AgentsMeeting</h1><div class="subtitle" id="subtitle">Dashboard</div>
<div class="tabs" id="tabs"></div><div id="panes"></div><div id="toast" class="toast"></div>
<div id="spec-modal" class="spec-modal"><div class="modal-box" id="spec-modal-box"></div></div>
<script>
var T=[{"id": "agents", "label": "Agents"}, {"id": "kanban", "label": "Kanban"}, {"id": "infra", "label": "Infrastructure"}, {"id": "principles", "label": "开发原则", "children": [{"id": "git", "label": "A 源码"}, {"id": "svc", "label": "B 服务"}, {"id": "mon", "label": "C 监控"}, {"id": "todos", "label": "D 修复"}, {"id": "meta", "label": "E 成长"}, {"id": "exp", "label": "F 期望"},{"id":"spec","label":"G 规范"},{"id":"prd","label":"H 需求"},{"id":"tree","label":"I 功能树"},{"id":"topo","label":"J 拓扑"},{"id":"tests","label":"K 测试"}]}];
var at=localStorage.getItem('t')||'agents',as=localStorage.getItem('s')||'git';
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'<').replace(/>/g,'>')}
function qs(id){return document.getElementById(id)}
function toast(m,t){var e=qs('toast');e.textContent=m;e.className='toast show '+(t||'ok');setTimeout(function(){e.className='toast'},2500)}
var _specCache={};
function showModuleHelp(module,type){
var modal=qs('spec-modal');var box=qs('spec-modal-box');
if(_specCache[module]){
_renderSpecModal(_specCache[module],type);modal.classList.add('show');return;
}
fetch('/api/module-spec/'+module).then(function(r){return r.json()}).then(function(spec){
_specCache[module]=spec;_renderSpecModal(spec,type);modal.classList.add('show');
}).catch(function(){
box.innerHTML='<span class="close" onclick="qs(\'spec-modal\').classList.remove(\'show\')">&times;</span><h3>Spec not found</h3><p>Module: '+esc(module)+'</p>';modal.classList.add('show');
});
}
function _renderSpecModal(spec,type){
var box=qs('spec-modal-box');var h='';
h+='<span class="close" onclick="qs(\'spec-modal\').classList.remove(\'show\')">&times;</span>';
if(type==='human'){
var hh=spec.human_help||{};
h+='<h3>'+esc(hh.title||spec.module||'Help')+'</h3>';
if(hh.description){h+='<h4>说明</h4><ul>';hh.description.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>';}
if(hh.usage){h+='<h4>使用方法</h4><ul>';hh.usage.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>';}
if(hh.troubleshooting){h+='<h4>常见问题</h4><ul>';hh.troubleshooting.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>';}
}else{
var ai=spec.ai_spec||{};
h+='<h3>§ AI Spec — '+esc(spec.module||'')+'</h3>';
h+='<p style="color:var(--dim)">'+esc(spec.purpose||'')+'</p>';
if(ai.apis){h+='<h4>API 端点</h4><ul>';ai.apis.forEach(function(a){h+='<li><code>'+esc(a.method)+' '+esc(a.path)+'</code> — '+esc(a.returns||'')+(a.proxied_to?' <span style=color:var(--dim)>→ '+esc(a.proxied_to)+'</span>':'')+'</li>'});h+='</ul>';}
if(ai.dependencies){h+='<h4>依赖</h4><ul>';ai.dependencies.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>';}
if(ai.constraints){h+='<h4>关键约束</h4><ul>';ai.constraints.forEach(function(d){h+='<li style="color:var(--yellow)">'+esc(d)+'</li>'});h+='</ul>';}
if(ai.must_not){h+='<h4>禁止行为</h4><ul>';ai.must_not.forEach(function(d){h+='<li style="color:var(--red)">'+esc(d)+'</li>'});h+='</ul>';}
if(ai.tests){h+='<h4>测试用例</h4><ul>';ai.tests.forEach(function(t){h+='<li><code>'+esc(t.id)+'</code> '+esc(t.name)+'</li>'});h+='</ul>';}
if(ai.related_files){h+='<h4>相关文件</h4><ul>';ai.related_files.forEach(function(f){h+='<li><code>'+esc(f)+'</code></li>'});h+='</ul>';}
}
box.innerHTML=h;
}
function init(){var b='',p='';for(var i=0;i<T.length;i++){var t=T[i],a=t.id===at||(t.children&&t.children.some(function(c){return c.id===at}));
b+='<button class="tab-btn'+(a?' active':'')+'" onclick="sw(\''+t.id+'\')">'+t.label+'</button>';
if(t.children){p+='<div class="tab-pane'+(a?' active':'')+'" id="pn-'+t.id+'"><div class="sub-bar">';
@@ -141,7 +191,7 @@ async function fI(){
var et=document.getElementById('easytier-section');
if(!et){
et=document.createElement('div');et.id='easytier-section';et.className='ps';et.style.marginTop='16px';
et.innerHTML='<h2>EasyTier VPN</h2><div class=psv><div class=i><span class="d" id=et-w></span>Windows <span id=et-w-ip></span></div><div class=i><span class="d" id=et-246></span>246 <span id=et-246-ip></span></div></div><div style="margin-top:8px;display:flex;gap:8px;align-items:center"><button class="btn s" id=btn-et-on>Turn On</button><button class="btn x" id=btn-et-off>Turn Off</button><span id=et-status style="font-size:12px;color:var(--dim)">-</span></div>';
et.innerHTML='<h2>EasyTier VPN<span class="help-btn" onclick="showModuleHelp(\'easytier-rdp\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'easytier-rdp\',\'ai\')" title="AI Spec">§</span></h2><div class=psv><div class=i><span class="d" id=et-w></span>Windows <span id=et-w-ip></span></div><div class=i><span class="d" id=et-246></span>246 <span id=et-246-ip></span></div></div><div style="margin-top:8px;display:flex;gap:8px;align-items:center"><button class="btn s" id=btn-et-on>Turn On</button><button class="btn x" id=btn-et-off>Turn Off</button><span id=et-status style="font-size:12px;color:var(--dim)">-</span></div>';
ci.appendChild(et);
document.getElementById('btn-et-on').onclick=function(){fetch('/api/easytier/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'start'})}).then(function(){fI()}).catch(function(){toast('ET Fail','err')})};
document.getElementById('btn-et-off').onclick=function(){fetch('/api/easytier/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'stop'})}).then(function(){fI()}).catch(function(){toast('ET Fail','err')})};
@@ -160,7 +210,7 @@ async function fI(){
var rd=document.getElementById('rdp-section');
if(!rd){
rd=document.createElement('div');rd.id='rdp-section';rd.className='ps';rd.style.marginTop='16px';
rd.innerHTML='<h2>RDP Remote Desktop</h2><div class=psv><div class=i><span class="d" id=rdp-st></span>RDP <span id=rdp-info></span></div><div class=i><span class="d" id=rdp-tun></span>Tunnel</div></div><div style="margin-top:8px;display:flex;gap:8px;align-items:center"><button class="btn s" id=btn-rdp-on>Enable</button><button class="btn x" id=btn-rdp-off>Disable</button><span id=rdp-status-txt style="font-size:12px;color:var(--dim)">-</span></div>';
rd.innerHTML='<h2>RDP Remote Desktop<span class="help-btn" onclick="showModuleHelp(\'easytier-rdp\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'easytier-rdp\',\'ai\')" title="AI Spec">§</span></h2><div class=psv><div class=i><span class="d" id=rdp-st></span>RDP <span id=rdp-info></span></div><div class=i><span class="d" id=rdp-tun></span>Tunnel</div></div><div style="margin-top:8px;display:flex;gap:8px;align-items:center"><button class="btn s" id=btn-rdp-on>Enable</button><button class="btn x" id=btn-rdp-off>Disable</button><span id=rdp-status-txt style="font-size:12px;color:var(--dim)">-</span></div>';
ci.appendChild(rd);
document.getElementById('btn-rdp-on').onclick=function(){fetch('/api/rdp/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'start'})}).then(function(){fI()}).catch(function(){toast('RDP Fail','err')})};
document.getElementById('btn-rdp-off').onclick=function(){fetch('/api/rdp/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'stop'})}).then(function(){fI()}).catch(function(){toast('RDP Fail','err')})};