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:
@@ -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 文档展示
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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 subprocess(dashboard 在 Linux,EasyTier/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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,'&').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\')">×</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\')">×</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')})};
|
||||
|
||||
Reference in New Issue
Block a user