命名结构(品牌=市场,产品=插件):市场名保持 deta;插件 deta → studio(displayName DETA Studio),新增 writer(DETA Writer)。安装标识 studio@deta / writer@deta;MCP 服务名 studio_mcp / writer_mcp,可同时安装。plugins/deta → plugins/studio(git mv 保留历史),新增 plugins/writer。 连接形态统一为单一本机 stdio 管道:.mcp.json 与 cordis.patch.yml 均声明 stdio → bin/deta-mcp.sh --profile <产品>,并按官方插件写法补 cwd: "."(缺它时 Cwd=- ,相对命令按宿主进程 cwd 解析会静默起不来);启动器新增 --profile 档位(工作区 ~/.deta/<产品>/workspace、工具面缺省 full),运行时按 DETA_SERVER_ROOT → ~/.deta/runtime → 开发检出解析,入口优先打包产物;DETA_TOKEN 可选注入(缺省时云类工具返回 auth_required)。原远程端点声明(含 OAuth/401 段落)从插件移除。 文档与治理:根 README 与两个插件 README 改为单一管道口径;技能 aml-design 对齐本机全量面;新增 scripts/check-plugin-contract.mjs(E1 禁工具清单 / E2 必须给出 list_capabilities / E3 禁解释内部实现 / E4 市场条目一致性 / W1 开发检出依赖)与 .gitea/workflows/ci.yml。版本:studio 0.3.1、writer 0.1.0。
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "studio",
|
||||
"version": "0.3.1",
|
||||
"description": "DETA Studio 插件:以 AML 为唯一事实源编辑建筑模型,编译为建筑模型并执行规则校验。",
|
||||
"author": {
|
||||
"name": "DETA",
|
||||
"url": "https://deta.city"
|
||||
},
|
||||
"homepage": "https://deta.city",
|
||||
"license": "Proprietary",
|
||||
"keywords": [
|
||||
"architecture",
|
||||
"aml",
|
||||
"building-performance",
|
||||
"energy",
|
||||
"bim",
|
||||
"design"
|
||||
],
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "DETA Studio",
|
||||
"shortDescription": "AML 建筑模型读写、编译与校验",
|
||||
"longDescription": "在编码代理中直接操作建筑模型:读取与编辑 AML 设计源码,经编译器编译为建筑模型,执行语法与规则校验、围护结构统计与空间邻接查询。AML 是建筑语义的唯一事实源,所有几何编辑均落到 AML 文本。",
|
||||
"developerName": "DETA",
|
||||
"category": "Education & Research",
|
||||
"capabilities": [
|
||||
"Interactive",
|
||||
"Read",
|
||||
"Write"
|
||||
],
|
||||
"brandColor": "#2F6F4E",
|
||||
"websiteURL": "https://deta.city",
|
||||
"defaultPrompt": [
|
||||
"读取当前 AML 设计源码并说明建筑构成。",
|
||||
"在 AML 中修改指定空间的层高,然后重新编译校验。",
|
||||
"统计当前模型的围护结构面积与外窗面积。"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"studio_mcp": {
|
||||
"type": "stdio",
|
||||
"command": "./bin/deta-mcp.sh",
|
||||
"cwd": ".",
|
||||
"args": ["--profile", "studio"],
|
||||
"default_tools_approval_mode": "approve",
|
||||
"omit_tools_from": ["deferred"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
# DETA Studio 插件(Codex 插件 / dsh 组合包)
|
||||
|
||||
DETA 领域能力(AML 建筑模型读写、编译校验、围护结构统计、空间邻接查询)
|
||||
以**本机 MCP 服务**形态挂进 Agent harness:同一个目录既是 Codex 插件根,
|
||||
也是 dsh 组合包根,两端共享 `skills/` 与**同一个启动器**。
|
||||
|
||||
- 发行标识:**`studio@deta`**(插件 `studio` + 市场 `deta`)
|
||||
- MCP 服务名:**`studio_mcp`**(工具前缀 `mcp__studio_mcp__…`)
|
||||
- 产品档位:启动器 `--profile studio`(工作区缺省 `~/.deta/studio/workspace`)
|
||||
|
||||
## 连接形态与凭据(唯一管道:本机 stdio)
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| 形态 | **本机 stdio**:宿主拉起 `bin/deta-mcp.sh --profile studio`,由它解析领域服务运行时与工作目录 |
|
||||
| MCP 服务名 | `studio_mcp`(工具前缀 `mcp__studio_mcp__…`);其他产品用各自服务名,可同时安装 |
|
||||
| 宿主声明 | Codex 读 `.mcp.json`;dsh 读 `cordis.patch.yml`——两边都只声明"调用同一个脚本、带哪个档位" |
|
||||
| 工作目录 | `DETA_WORKSPACE` → `ANT_HEADLESS_WORKSPACE` → 缺省 `~/.deta/studio/workspace`(启动器创建并在 stderr 回显) |
|
||||
| 运行时 | `DETA_SERVER_ROOT` → `~/.deta/runtime` → 本机开发检出(回落到开发检出时启动器打印提示) |
|
||||
| 云侧能力(可选) | `DETA_TOKEN`(`node bin/deta-login.mjs` 获取):项目/楼栋列表、`push_project` / `pull_project` |
|
||||
| Studio 执行器中继(可选) | `ANT_WORKSPACE_BRIDGE_URL` + 项目 ID(三要素齐备才启用;供模拟/画布类工具) |
|
||||
|
||||
**本机形态不做 MCP 鉴权**(进程由宿主或用户在本机拉起,没有 OAuth、没有 401、没有令牌续期)。
|
||||
未注入 `DETA_TOKEN` 时,云类工具返回 `auth_required`,其余工具(编辑、编译、校验、统计、
|
||||
脚本、模拟)照常工作——这是与远程形态**同一份工具实现**的不同输入,不是两套代码。
|
||||
|
||||
## 工具面:编辑归宿主,MCP 只做引擎与同步
|
||||
|
||||
AML 是纯文本 DSL,读写、搜索、重构一律由**宿主原生的文件工具**完成
|
||||
(Codex 的 read / patch / grep / shell;dsh 的同名能力),MCP 不是编辑入口:
|
||||
|
||||
| 分工 | 谁做 |
|
||||
|------|------|
|
||||
| 读取 / 修改 / 搜索 `aml.xml`、`bss.md`、`rules.yml`、`behaviors.yml`、`design-brief.md` | 宿主原生工具(本地项目目录里的文件) |
|
||||
| 编译、规范校验、规则检查、围护结构统计、空间邻接、领域脚本 | MCP 引擎工具(编译 / 校验 / 统计 / 邻接 / 脚本等) |
|
||||
| 改动送进引擎工作区、取回工作区 | MCP 同步工具 |
|
||||
|
||||
本机形态为**全量工具面**(含工作区文件类工具)。**当前可用的完整工具名以运行时
|
||||
`list_capabilities` 为准**,本仓文档不列举工具清单。
|
||||
|
||||
## 本地项目目录约定
|
||||
|
||||
一个 DETA 项目就是一个普通目录;引擎工具读的是**引擎工作区**,因此改动要先同步:
|
||||
|
||||
```
|
||||
my-project/
|
||||
├── deta.json # 可选:项目绑定({ "projectId": "...", "name": "..." })
|
||||
├── aml.xml # 必需:建筑模型唯一事实源
|
||||
├── bss.md # 可选:样式/构造附属文档
|
||||
├── rules.yml # 可选:校验规则
|
||||
├── behaviors.yml # 可选:声明式行为规则
|
||||
└── design-brief.md # 可选:设计任务书
|
||||
```
|
||||
|
||||
- 想让引擎直接以项目目录为工作区:启动宿主前设 `DETA_WORKSPACE=<项目目录>`。
|
||||
- `deta.json` 里的 `projectId` 是 deta.city 云项目 ID;带上它的同步调用会把同一个 ID
|
||||
绑定到引擎工作区,之后生成/拉取会带同一个 Studio 深链 `https://deta.city/design/<projectId>`。
|
||||
- 文件名沿用引擎侧约定(`aml` ↔ `aml.xml`、`brief` ↔ `design-brief.md` 等),
|
||||
本地目录与引擎工作区结构一致,拉取结果可直接落盘回本地目录。
|
||||
|
||||
## 端到端工作流:本地编辑 → 同步 → 校验 → Studio 查看
|
||||
|
||||
```text
|
||||
1. 本地编辑 read/patch/grep 直接改 aml.xml(MCP 不参与编辑)
|
||||
2. 同步 把改动送进引擎工作区(带 projectId 时同时绑定云项目)
|
||||
→ { ok, files, projectId, studioUrl: "https://deta.city/design/<ID>" }
|
||||
3. 引擎校验 编译 AML 为建筑模型(工作区最新版本)→ 语法/几何/规则校验逐条修复
|
||||
围护结构统计与空间邻接查询按需调用
|
||||
4. Studio 查看 在宿主内置浏览器面板打开 studioUrl;已有相同 URL 的标签优先复用,
|
||||
不为同一项目重复开新标签,也不要 goto 重载已有标签
|
||||
5. 跑模拟 在面板里的 Studio 界面运行——模拟由 Studio 执行,本插件 MCP 不替代它
|
||||
6. 反向核对 从引擎工作区拉回文件(工作区为空时返回空集)
|
||||
```
|
||||
|
||||
路径安全:同步只接受工作区相对路径,绝对路径与 `..` 穿越会被拒绝,单次载荷上限 1 MiB。
|
||||
|
||||
## 安装(Codex)
|
||||
|
||||
```bash
|
||||
# 1. 添加市场并安装插件
|
||||
codex plugin marketplace add https://git.deta.city/deta/deta-agent-plugins.git
|
||||
codex plugin add studio@deta
|
||||
|
||||
# 2. 准备领域服务运行时(二选一)
|
||||
# a) 本机引擎包已安装 → 启动器自动找到(~/.deta/runtime)
|
||||
# b) 从源码构建:在领域服务仓库执行 npm run build:mcp,并让 DETA_SERVER_ROOT 指向它
|
||||
#
|
||||
# 冒烟(应等待 stdin/stdout):
|
||||
bash <插件目录>/bin/deta-mcp.sh --profile studio
|
||||
```
|
||||
|
||||
装完即用,**没有 OAuth 登录步骤**。需要云侧能力(项目列表 / 同步)时再注入令牌:
|
||||
|
||||
```bash
|
||||
node bin/deta-login.mjs # 登录 deta.city,写 ~/.deta/credentials.json 与 ~/.deta/env
|
||||
source ~/.deta/env # 让 DETA_TOKEN 进入宿主进程环境
|
||||
```
|
||||
|
||||
### 登录助手(云侧能力用)
|
||||
|
||||
```bash
|
||||
node bin/deta-login.mjs # 交互式:读标识与密码,写 ~/.deta/*
|
||||
node bin/deta-login.mjs -u <用户名|已验证邮箱|已验证手机号> # 免交互输入标识
|
||||
node bin/deta-login.mjs --email-code <邮箱> # 邮箱验证码登录(未设密码的账号用)
|
||||
node bin/deta-login.mjs --base-url https://<host> # 自建/预发环境
|
||||
```
|
||||
|
||||
**标识(identifier)填门户登录页上用的那一个**:用户名、已验证邮箱、已验证手机号,服务端都会解析到同一账号。
|
||||
助手先匿名调用 `/login/check` 预检标识是否已注册,再走 `/login` 做密码校验(不走门户的「登录即注册」端点,
|
||||
不会误建账号)。失败时按服务端提示给出下一步:
|
||||
|
||||
| 服务端提示 | 含义与处理 |
|
||||
|------------|------------|
|
||||
| `该标识在 <host> 未注册` | 标识不是门户上用的那一个;邮箱验证码注册的账号标识填邮箱 |
|
||||
| `用户不存在/密码错误` | 服务端不区分「账号不存在」与「密码不匹配」;已确认标识存在,即密码不符,或改用 `--email-code` |
|
||||
| `该邮箱/手机号尚未验证…` | 先用验证码方式登录一次门户完成验证 |
|
||||
| `该登录方式未开放` | 用 `GET <base>/prod-api/login/methods` 查当前启用项 |
|
||||
|
||||
## 安装(dsh)
|
||||
|
||||
```bash
|
||||
export DETA_PLUGIN_ROOT=<本包所在目录> # 配置层按它定位启动器
|
||||
export DETA_WORKSPACE=<你的项目目录> # 可选;缺省 ~/.deta/workspace
|
||||
dsh plugin --profile <name> add <本包>
|
||||
```
|
||||
|
||||
`cordis.patch.yml` 声明本机 stdio,经 `bash -lc` 调用同一个 `bin/deta-mcp.sh`;
|
||||
未设置 `DETA_PLUGIN_ROOT` 时启动器明确报错退出,不会静默失败。
|
||||
|
||||
## 文件
|
||||
|
||||
```
|
||||
.codex-plugin/plugin.json Codex 插件清单
|
||||
.mcp.json Codex MCP 声明(stdio → ./bin/deta-mcp.sh)
|
||||
package.json dsh 组合包声明(dsh.bundle.patch)
|
||||
cordis.patch.yml dsh 配置层(同一启动器,stdio)
|
||||
bin/deta-login.mjs 登录助手(写 ~/.deta/credentials.json + ~/.deta/env,供云侧能力用)
|
||||
bin/deta-mcp.sh 本机 stdio 启动器(两宿主共用;解析运行时与工作目录)
|
||||
README.md 本文件(安装 / 连接 / 排障)
|
||||
skills/aml-design/SKILL.md 宿主侧技能(工具使用规则;不含工具清单)
|
||||
```
|
||||
|
||||
## 已知缺陷(实测,2026-09-10)
|
||||
|
||||
**上游:编码代理 × 模型服务 命名空间重复**
|
||||
|
||||
会话累积较多本插件工具调用后,代理会在请求里重复声明 MCP 命名空间,模型服务的
|
||||
Responses 兼容层拒绝请求:
|
||||
|
||||
```
|
||||
Duplicate namespace name 'deta'; Namespace names must be unique.
|
||||
```
|
||||
|
||||
复现记录(同一插件、不同会话长度):
|
||||
|
||||
| 代理版本 | 会话规模 | 触发位置 |
|
||||
|---------|---------|---------|
|
||||
| 0.144.4 | 一步并发 2 次调用 | input[10]、input[18] |
|
||||
| 0.144.4 | 串行 7–8 次调用 | input[37]、input[47] |
|
||||
| 0.154.0 | 短会话 5 工具 | 未触发 |
|
||||
| 0.154.0 | 长会话(≈15+ 工具) | input[260] |
|
||||
|
||||
已排除/无效的手段:`wire_api = "chat"`(该版本已移除,仅支持 responses)、
|
||||
`features.non_prefixed_mcp_tool_names`(仅去掉命名空间前缀,重复依旧)、
|
||||
`supports_parallel_tool_calls = false`(削弱并发触发面,长会话仍复现)。
|
||||
|
||||
**应对**:完成一个任务后另起会话;撞到该错误不要重试,重开会话即可(工作区文件保留)。
|
||||
|
||||
## 已修缺陷(实测)
|
||||
|
||||
| 现象 | 根因 | 修复 |
|
||||
|------|------|------|
|
||||
| 写类工具返回 `user cancelled MCP tool call` | MCP 工具默认需审批,非交互模式下无法弹窗 | `.mcp.json` 声明 `default_tools_approval_mode: "approve"`(实测 `auto`/`writes` 都会被取消,只有 `approve` 放行) |
|
||||
| 本机 stdio 形态起不来:`ERR_UNSUPPORTED_DIR_IMPORT` | 启动器指向未打包的产物,依赖包的目录 import 在 Node ESM 下非法 | 领域服务提供打包入口;启动器优先取打包产物,回落未打包产物时打印警告 |
|
||||
| 一个插件两种形态并存(远程 URL + 本机 stdio) | 历史遗留:远程端点声明与本地启动器同时存在,语义冲突 | 统一为**本机 stdio 单一管道**,远程端点声明从插件移除 |
|
||||
Executable
+368
@@ -0,0 +1,368 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* DETA 登录助手(Node ESM,零第三方依赖)
|
||||
*
|
||||
* 用 deta.city 账号换取 API 令牌,供 MCP 远程端点鉴权使用:
|
||||
*
|
||||
* node bin/deta-login.mjs [--identifier <用户名|邮箱|手机号>] [--base-url https://deta.city]
|
||||
* node bin/deta-login.mjs --email-code <邮箱> # 邮箱验证码登录(无密码账号用)
|
||||
*
|
||||
* 标识(identifier)就是你在门户登录页填的那一个:
|
||||
* 用户名 / 已验证邮箱 / 已验证手机号,服务端都会解析到同一账号。
|
||||
* 注意 deta.city 门户的密码登录端点与 RuoYi 内置的 /login 不是同一个,
|
||||
* 本助手统一走 /login(已存在账号的密码校验),不会触发「登录即注册」。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 读入标识;先匿名调用 /login/check 预检该标识是否已注册;
|
||||
* 2. 读入密码(不回显);POST <base>/prod-api/login { username: 标识, password };
|
||||
* 3. 成功 → 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600);
|
||||
* 4. 失败 → 打印服务端 msg 与针对性提示,退出码非 0。
|
||||
*
|
||||
* 凭据纪律:token 只写在本次 stdout 与上述两个 0600 文件里,
|
||||
* 不写日志、不写 stderr、不写其它位置。
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile, chmod } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://deta.city';
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[deta-login] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const USAGE = [
|
||||
'用法:node bin/deta-login.mjs [选项]',
|
||||
' -u, --identifier <值> 登录标识(用户名/已验证邮箱/已验证手机号);省略则交互式输入',
|
||||
' --email-code <邮箱> 用邮箱验证码登录(门户只用验证码登录、未设密码时用)',
|
||||
' --base-url <url> 服务地址,缺省 https://deta.city',
|
||||
' -h, --help 显示本帮助',
|
||||
].join('\n');
|
||||
|
||||
/** 解析命令行;缺省 baseUrl 取 DETA_BASE_URL 或 https://deta.city,末尾斜杠去掉 */
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
baseUrl: process.env.DETA_BASE_URL || DEFAULT_BASE_URL,
|
||||
identifier: '',
|
||||
emailCode: '',
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const takeValue = (name) => {
|
||||
const next = argv[i + 1];
|
||||
if (!next) fail(`${name} 缺少取值`);
|
||||
i++;
|
||||
return next;
|
||||
};
|
||||
if (arg === '-u' || arg === '--identifier') {
|
||||
options.identifier = takeValue(arg);
|
||||
} else if (arg.startsWith('--identifier=')) {
|
||||
options.identifier = arg.slice('--identifier='.length);
|
||||
} else if (arg === '--email-code') {
|
||||
options.emailCode = takeValue(arg);
|
||||
} else if (arg.startsWith('--email-code=')) {
|
||||
options.emailCode = arg.slice('--email-code='.length);
|
||||
} else if (arg === '--base-url') {
|
||||
options.baseUrl = takeValue(arg);
|
||||
} else if (arg.startsWith('--base-url=')) {
|
||||
options.baseUrl = arg.slice('--base-url='.length);
|
||||
} else if (arg === '-h' || arg === '--help') {
|
||||
console.log(USAGE);
|
||||
process.exit(0);
|
||||
} else {
|
||||
fail(`未知参数:${arg}\n${USAGE}`);
|
||||
}
|
||||
}
|
||||
options.baseUrl = options.baseUrl.trim().replace(/\/+$/, '');
|
||||
options.identifier = options.identifier.trim();
|
||||
options.emailCode = options.emailCode.trim();
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读一行输入。hidden=true 时不回显(密码);
|
||||
* 非 TTY(管道/CI)时同样按行读取,便于自动化。
|
||||
*/
|
||||
let pendingInput = '';
|
||||
|
||||
/**
|
||||
* 读一行输入。hidden=true 时不回显(密码);非 TTY(管道/CI)时同样按行读取。
|
||||
*
|
||||
* 跨读取缓冲:`pendingInput` 保存上一次读取切剩的数据。TTY 下在 raw mode 逐字符处理,
|
||||
* 遇到换行即切行并把该 chunk 换行之后的字符存回 `pendingInput`——因此
|
||||
* 一次粘贴「标识 + 密码」两行不会丢第二行。
|
||||
*/
|
||||
function readLine({ hidden }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdin = process.stdin;
|
||||
const isTTY = Boolean(stdin.isTTY);
|
||||
let buffer = pendingInput;
|
||||
pendingInput = '';
|
||||
let settled = false;
|
||||
|
||||
/** 从 buffer 切出一整行(含行尾判定),并把剩余数据留到 pendingInput;无完整行返回 null */
|
||||
const splitLine = () => {
|
||||
const index = buffer.search(/[\r\n]/);
|
||||
if (index < 0) return null;
|
||||
const line = buffer.slice(0, index);
|
||||
const skip = buffer[index] === '\r' && buffer[index + 1] === '\n' ? 2 : 1;
|
||||
pendingInput = buffer.slice(index + skip);
|
||||
buffer = '';
|
||||
return line;
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
stdin.removeListener('data', onData);
|
||||
stdin.removeListener('end', onEnd);
|
||||
stdin.removeListener('error', onError);
|
||||
if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(false);
|
||||
stdin.pause();
|
||||
};
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
// TTY 下 raw mode 不回显回车,这里补一个换行;非 TTY(管道)不额外输出
|
||||
if (isTTY) process.stdout.write('\n');
|
||||
resolve(value);
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
||||
if (!isTTY) {
|
||||
// 非交互:不回显,按行切分,剩余数据留给下一次读取
|
||||
buffer += text;
|
||||
const line = splitLine();
|
||||
if (line !== null) finish(line);
|
||||
return;
|
||||
}
|
||||
const chars = [...text];
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const ch = chars[i];
|
||||
if (ch === '\n' || ch === '\r') {
|
||||
// 本 chunk 换行符之后的字符属于后续读取(一次粘贴多行):存入 pendingInput 后立即返回
|
||||
let next = i + 1;
|
||||
if (ch === '\r' && chars[next] === '\n') next++; // CRLF 视作一个行尾
|
||||
const rest = chars.slice(next).join('');
|
||||
if (rest) pendingInput = rest + pendingInput;
|
||||
return finish(buffer);
|
||||
}
|
||||
if (ch === '\u0003') {
|
||||
// Ctrl-C
|
||||
process.stdout.write('\n');
|
||||
process.exit(130);
|
||||
}
|
||||
if (ch === '\u0004') return finish(buffer); // Ctrl-D
|
||||
if (ch === '\u007f' || ch === '\b') {
|
||||
if (buffer && !hidden) process.stdout.write('\b \b');
|
||||
buffer = buffer.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
buffer += ch;
|
||||
if (!hidden) process.stdout.write(ch);
|
||||
}
|
||||
};
|
||||
const onEnd = () => finish(buffer);
|
||||
const onError = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
// 缓冲里已有完整行(上一次读取的遗留)→ 立即返回,不等待输入
|
||||
const buffered = splitLine();
|
||||
if (buffered !== null) {
|
||||
if (isTTY) process.stdout.write('\n');
|
||||
resolve(buffered);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
|
||||
stdin.resume();
|
||||
stdin.on('data', onData);
|
||||
stdin.on('end', onEnd);
|
||||
stdin.on('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
/** 单引号安全转义,供 `export DETA_TOKEN='...'` 使用 */
|
||||
function shellSingleQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
async function writePrivateFile(filePath, content) {
|
||||
await writeFile(filePath, content, { mode: 0o600 });
|
||||
// 已存在的文件不被 mode 改写,显式收紧
|
||||
await chmod(filePath, 0o600).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** 登录标识类型判定(与服务端 LoginIdentifierResolver 的规则一致) */
|
||||
const PHONE_REGEX = /^1[3-9]\d{9}$/;
|
||||
const EMAIL_REGEX = /^[\w.+-]+@[\w-]+(\.[\w-]+)+$/;
|
||||
|
||||
function identifierField(identifier) {
|
||||
if (EMAIL_REGEX.test(identifier)) return 'email';
|
||||
if (PHONE_REGEX.test(identifier)) return 'phone';
|
||||
return 'username';
|
||||
}
|
||||
|
||||
/** POST JSON,返回 { response, payload }(响应体非 JSON 时 payload 为 null) */
|
||||
async function postJson(baseUrl, path, body) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
fail(`无法连接 ${baseUrl}${path}(${err instanceof Error ? err.message : String(err)})`);
|
||||
}
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
return { response, payload };
|
||||
}
|
||||
|
||||
function serverMessage(payload, response) {
|
||||
return (payload && typeof payload.msg === 'string' && payload.msg) || `请求失败(HTTP ${response.status})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 匿名查询标识是否已注册;接口异常返回 null(不据此阻断登录)。
|
||||
* 只填标识所属类型的那一个字段:服务端按 phone → email → username 取第一个非空字段。
|
||||
*/
|
||||
async function accountRegistered(baseUrl, identifier) {
|
||||
const { response, payload } = await postJson(baseUrl, '/prod-api/login/check', {
|
||||
[identifierField(identifier)]: identifier,
|
||||
});
|
||||
if (!response.ok || !payload || payload.code !== 200) return null;
|
||||
return payload.registered === true;
|
||||
}
|
||||
|
||||
/** 服务端提示 → 可执行的下一步 */
|
||||
function loginHint(message, identifier) {
|
||||
if (message.includes('用户不存在/密码错误')) {
|
||||
return [
|
||||
`该提示不区分「账号不存在」与「密码不匹配」(已确认 ${identifier} 是已注册标识,故为密码不符或标识与账号不对应)。`,
|
||||
'请确认:',
|
||||
' 1) 标识与门户登录页填写的一致(用户名 / 已验证邮箱 / 已验证手机号);',
|
||||
' 2) 密码与门户一致(区分大小写,注意首尾空格);',
|
||||
' 3) 若门户只用邮箱验证码登录、从未设过密码,改用 --email-code <邮箱>。',
|
||||
].join('\n ');
|
||||
}
|
||||
if (message.includes('尚未验证')) {
|
||||
return '该邮箱/手机号在账号里尚未标记为已验证:请先用验证码方式登录一次门户完成验证,再重试。';
|
||||
}
|
||||
if (message.includes('停用')) return '账号已被停用,请联系管理员。';
|
||||
if (message.includes('未开放')) {
|
||||
return '该登录方式未开放:改用已启用的方式(GET /prod-api/login/methods 可查当前启用项)。';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 邮箱验证码登录:发码 → 读入验证码 → 校验 */
|
||||
async function loginByEmailCode(baseUrl, email) {
|
||||
const send = await postJson(baseUrl, '/prod-api/login/email/send-code', { email });
|
||||
if (!send.response.ok || send.payload?.code !== 200) {
|
||||
fail(`发送验证码失败:${serverMessage(send.payload, send.response)}`);
|
||||
}
|
||||
process.stdout.write('邮箱验证码:');
|
||||
const code = (await readLine({ hidden: false })).trim();
|
||||
if (!code) fail('验证码不能为空');
|
||||
const { response, payload } = await postJson(baseUrl, '/prod-api/login/email', { email, code });
|
||||
const token = typeof payload?.token === 'string' ? payload.token : '';
|
||||
if (!response.ok || !token || (typeof payload?.code === 'number' && payload.code !== 200)) {
|
||||
fail(`${serverMessage(payload, response)}\n ${loginHint(serverMessage(payload, response), email)}`.trimEnd());
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600);token 只在 stdout 出现一次 */
|
||||
async function writeCredentials(baseUrl, identifier, payload) {
|
||||
const token = payload.token;
|
||||
const loginUsername =
|
||||
typeof payload.username === 'string' && payload.username ? payload.username : identifier;
|
||||
const dir = join(homedir(), '.deta');
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
await chmod(dir, 0o700).catch(() => undefined);
|
||||
|
||||
const credentials = {
|
||||
token,
|
||||
username: loginUsername,
|
||||
baseUrl,
|
||||
loginAt: new Date().toISOString(),
|
||||
};
|
||||
const credentialsPath = join(dir, 'credentials.json');
|
||||
const envPath = join(dir, 'env');
|
||||
await writePrivateFile(credentialsPath, `${JSON.stringify(credentials, null, 2)}\n`);
|
||||
await writePrivateFile(
|
||||
envPath,
|
||||
`# DETA MCP 凭据(由 bin/deta-login.mjs 生成,勿提交版本库)\n` +
|
||||
`# 用法:source ~/.deta/env\n` +
|
||||
`export DETA_TOKEN=${shellSingleQuote(token)}\n`,
|
||||
);
|
||||
|
||||
console.log(`DETA_TOKEN=${token}`);
|
||||
console.log(`✅ 登录成功:${loginUsername} @ ${baseUrl}`);
|
||||
console.log(`凭据已写入 ${credentialsPath}(0600),环境变量已写入 ${envPath}(0600)`);
|
||||
console.log(
|
||||
`使用:source ~/.deta/env 后再启动 harness(DETA_TOKEN 随环境传给 https://deta.city/mcp-api/mcp)`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { baseUrl, identifier: presetIdentifier, emailCode } = parseArgs(process.argv.slice(2));
|
||||
|
||||
process.stdout.write(`DETA 登录(${baseUrl})\n`);
|
||||
|
||||
if (emailCode) {
|
||||
process.stdout.write(`邮箱验证码登录:${emailCode}\n`);
|
||||
return writeCredentials(baseUrl, emailCode, await loginByEmailCode(baseUrl, emailCode));
|
||||
}
|
||||
|
||||
let identifier = presetIdentifier;
|
||||
if (identifier) {
|
||||
process.stdout.write(`登录标识:${identifier}\n`);
|
||||
} else {
|
||||
process.stdout.write('登录标识(用户名/已验证邮箱/已验证手机号):');
|
||||
identifier = (await readLine({ hidden: false })).trim();
|
||||
}
|
||||
if (!identifier) fail('登录标识不能为空');
|
||||
|
||||
// 匿名预检:区分「标识未注册」与「密码不匹配」,避免密码错时给出误导性提示
|
||||
if ((await accountRegistered(baseUrl, identifier)) === false) {
|
||||
fail(
|
||||
`该标识在 ${baseUrl} 未注册:${identifier}\n` +
|
||||
' 请填门户登录页上实际使用的标识(用户名 / 已验证邮箱 / 已验证手机号)。\n' +
|
||||
' 邮箱验证码注册的账号,标识请填邮箱。',
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write('密码:');
|
||||
const password = await readLine({ hidden: true });
|
||||
if (!password) fail('密码不能为空');
|
||||
|
||||
const { response, payload } = await postJson(baseUrl, '/prod-api/login', {
|
||||
username: identifier,
|
||||
password,
|
||||
});
|
||||
const token = typeof payload?.token === 'string' ? payload.token : '';
|
||||
if (!response.ok || !token || (typeof payload?.code === 'number' && payload.code !== 200)) {
|
||||
const message = serverMessage(payload, response);
|
||||
const hint = loginHint(message, identifier);
|
||||
fail(hint ? `${message}\n ${hint}` : message);
|
||||
}
|
||||
return writeCredentials(baseUrl, identifier, payload);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# DETA 产品插件的本机 MCP stdio 启动器(唯一入口,多产品共用)
|
||||
#
|
||||
# 两个宿主(Codex 插件 / dsh 组合包)都通过本脚本拉起同一个领域服务;
|
||||
# 产品差异只由 --profile 决定(工作区默认位置、工具面),宿主侧只声明"调哪个脚本、带哪个档位"。
|
||||
#
|
||||
# 用法:deta-mcp.sh [--profile <studio|writer>]
|
||||
#
|
||||
# 依赖:node + 已构建的领域服务运行时
|
||||
#
|
||||
# 环境变量:
|
||||
# DETA_SERVER_ROOT 领域服务运行时根目录(未设时自动查找,见下)
|
||||
# DETA_WORKSPACE AML 工作目录(引擎侧工作区真值;缺省 ~/.deta/<profile>/workspace)
|
||||
# ANT_HEADLESS_WORKSPACE 同上(兼容旧名,DETA_WORKSPACE 优先)
|
||||
# DETA_TOKEN deta.city 门户令牌(可选;不注入则云类工具返回 auth_required)
|
||||
# ANT_WORKSPACE_BRIDGE_URL 网关基址(可选;与项目 ID 一起启用 Studio 执行器中继)
|
||||
# DETA_SURFACE 工具面覆盖(缺省 full)
|
||||
set -euo pipefail
|
||||
|
||||
# ── 产品档位:宿主在声明里传 --profile;缺省 studio ──
|
||||
PROFILE="${DETA_PROFILE:-studio}"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--profile) PROFILE="${2:?--profile 需要一个值}"; shift 2 ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── 运行时解析:显式 DETA_SERVER_ROOT → 本机引擎包安装位置 → 本机开发检出(打印提示)──
|
||||
ROOT="${DETA_SERVER_ROOT:-}"
|
||||
if [ -z "$ROOT" ]; then
|
||||
for cand in "$HOME/.deta/runtime" "$HOME/Dev/ant/ant-copilot"; do
|
||||
if [ -f "$cand/dist/deta-mcp.mjs" ] || [ -f "$cand/dist/src/headless/mcp-server.js" ]; then ROOT="$cand"; break; fi
|
||||
done
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "[deta] 未找到 node,请先安装 Node.js" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 入口优先取 esbuild 打包形态:tsc 产物在 Node ESM 下会因依赖包的目录 import 直接失败
|
||||
ENTRY=""
|
||||
if [ -n "$ROOT" ]; then
|
||||
if [ -f "$ROOT/dist/deta-mcp.mjs" ]; then
|
||||
ENTRY="$ROOT/dist/deta-mcp.mjs"
|
||||
elif [ -f "$ROOT/dist/src/headless/mcp-server.js" ]; then
|
||||
ENTRY="$ROOT/dist/src/headless/mcp-server.js"
|
||||
echo "[deta] 警告:回落到未打包产物,请先在该运行时目录执行 npm run build:mcp" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$ENTRY" ]; then
|
||||
echo "[deta] 未找到领域服务运行时(已尝试:DETA_SERVER_ROOT、\$HOME/.deta/runtime、\$HOME/Dev/ant/ant-copilot)" >&2
|
||||
echo "[deta] 请安装本机引擎包,或设置 DETA_SERVER_ROOT 指向已构建的领域服务仓库" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$ROOT" = "$HOME/Dev/ant/ant-copilot" ]; then
|
||||
echo "[deta] 提示:使用本机开发检出 ${ROOT}(非打包运行时)" >&2
|
||||
fi
|
||||
|
||||
# ── 工作目录:宿主不会传项目目录(cwd 通常是插件根),按产品档位定稳定位置 ──
|
||||
WS="${DETA_WORKSPACE:-${ANT_HEADLESS_WORKSPACE:-$HOME/.deta/$PROFILE/workspace}}"
|
||||
mkdir -p "$WS"
|
||||
export ANT_HEADLESS_WORKSPACE="$WS"
|
||||
|
||||
# ── 工具面:产品档位缺省全量面;DETA_SURFACE 可覆盖(如 engine)──
|
||||
export DETA_TOOL_SURFACE="${DETA_SURFACE:-full}"
|
||||
|
||||
echo "[deta] 档位=${PROFILE} 运行时=${ROOT} 工作区=${WS} 工具面=${DETA_TOOL_SURFACE} 云令牌=$([ -n "${DETA_TOKEN:-}" ] && echo 已注入 || echo 未注入)" >&2
|
||||
|
||||
exec node "$ENTRY" "$@"
|
||||
@@ -0,0 +1,33 @@
|
||||
# DETA 组合包(dsh bundle)配置层
|
||||
#
|
||||
# 挂载方式:dsh plugin --profile <name> add <本包>
|
||||
# 本层只做一件事:把 DETA 领域能力作为 MCP 服务挂进 harness。
|
||||
# 工具以 mcp__deta__<tool> 暴露(与 Codex 侧同名)。
|
||||
#
|
||||
# 形态:本机 stdio(唯一管道)——与 Codex 插件共用同一个启动器 bin/deta-mcp.sh,
|
||||
# 由它解析领域服务运行时(DETA_SERVER_ROOT → ~/.deta/runtime → 本机开发检出)。
|
||||
# 不校验 deta.city 账号、不隔离工作区;工作目录由 DETA_WORKSPACE 指定
|
||||
# (缺省 ~/.deta/studio/workspace,实际值见启动器的 stderr 输出)。
|
||||
#
|
||||
# 需要云侧能力(项目/楼栋列表、push/pull 同步、Studio 执行器中继)时注入:
|
||||
# DETA_TOKEN deta.city 门户令牌(node bin/deta-login.mjs 获取)
|
||||
# ANT_WORKSPACE_BRIDGE_URL 网关基址(与项目 ID 一起构成 host-call 中继三要素)
|
||||
#
|
||||
# 前置:设置 DETA_PLUGIN_ROOT 指向本包所在目录(未设置时启动器明确报错退出)。
|
||||
#
|
||||
- insert:
|
||||
- id: deta-mcp
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: studio_mcp
|
||||
transport: stdio
|
||||
command: bash
|
||||
args:
|
||||
- -lc
|
||||
- 'exec "${DETA_PLUGIN_ROOT:?请设置 DETA_PLUGIN_ROOT 指向 DETA 插件目录}/bin/deta-mcp.sh" --profile studio'
|
||||
env:
|
||||
DETA_PLUGIN_ROOT: !!js process.env.DETA_PLUGIN_ROOT ?? ''
|
||||
DETA_TOKEN: !!js process.env.DETA_TOKEN ?? ''
|
||||
DETA_WORKSPACE: !!js process.env.DETA_WORKSPACE ?? ''
|
||||
toolCallTimeoutMs: 600000
|
||||
failOnStartupError: true
|
||||
Generated
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "deta-studio-plugin",
|
||||
"version": "0.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "deta-studio-plugin",
|
||||
"version": "0.3.1",
|
||||
"license": "Proprietary"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "deta-studio-plugin",
|
||||
"version": "0.3.1",
|
||||
"description": "DETA Studio:AML 建筑模型读写与编译校验(Codex 插件 / dsh 组合包 双壳)",
|
||||
"type": "module",
|
||||
"license": "Proprietary",
|
||||
"files": [
|
||||
"cordis.patch.yml",
|
||||
"skills",
|
||||
".codex-plugin",
|
||||
".mcp.json",
|
||||
"bin",
|
||||
"README.md"
|
||||
],
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
name: aml-design
|
||||
description: 以 AML 为唯一事实源读写建筑模型并编译校验。当用户要求创建/修改建筑方案、房间布局、层高、开洞、围护结构或做建筑性能相关的模型准备时使用。
|
||||
---
|
||||
|
||||
# DETA 建筑性能设计(AML)
|
||||
|
||||
## ★ 先读:工具怎么找、模型怎么改(任意工作目录都适用)
|
||||
|
||||
**工具发现规则(重要)**:宿主对插件类 MCP 工具做**懒加载检索**,一次检索只返回少量结果
|
||||
(实测 `Only 10 shown of 23`)。**搜不到不等于不存在**——不确定有哪些工具时,先调
|
||||
**`list_capabilities`**(能力目录)拿到当前形态的**完整工具名清单**,再按名字**直接调用**。
|
||||
|
||||
**改建筑模型只有一个标准动作**(能力目录里有 `build_model` 时):
|
||||
|
||||
`build_model({ aml, projectId })` —— 写服务端工作区 → 编译成建筑模型 → 规范校验 → 写进云项目,
|
||||
一次调用完成;返回 `gate`(校验是否通过)、编译摘要、校验明细、云工作区 `headSha`。
|
||||
|
||||
- **不要**把 AML 拷进 Studio 的代码编辑器手工点编译(那是没有闭环工具时的兜底,不是正常路径);
|
||||
- **不要**用脚本自绘模型图代替 Studio;
|
||||
- 校验不通过(`gate: failed`)就按 `validation` 明细改 AML 后重跑,直到通过;
|
||||
- 冲突(`version_conflict`)说明云工作区被其它会话改过:确认要覆盖才传 `force: true`。
|
||||
|
||||
**看模型 / 打开 Studio**:`open_studio` 拿一次性免密链接,再用宿主内置浏览器(`iab`)打开——
|
||||
不要手工在面板登录,也不要用 playwright 逐步点击。
|
||||
|
||||
**执行器在线是模拟类工具的前置条件**:`simulate` / `studio_command` / `query_simulation_db` /
|
||||
`view_canvas` 的算力在 ant-model 浏览器里的执行器,经网关 host-call 中继干活;项目标签页一关
|
||||
就离线,工具会 fail fast 报「执行器离线」。`open_studio` 会回报 `executor_online`,遇到离线提示
|
||||
就再调一次它,不要改用手工登录。
|
||||
|
||||
## 何时使用
|
||||
|
||||
用户提到建筑方案、房间/空间布局、层高、墙体开洞(门窗)、围护结构、体形系数、
|
||||
建筑模型校验,或需要为后续能耗/日照/CFD 模拟准备模型时使用本技能。
|
||||
|
||||
## 编辑与校验分工(先搞清"哪份文件是真值")
|
||||
|
||||
本插件挂的是**本机 MCP**:引擎工作区才是编译/校验/模拟读的真值(启动器 stderr 会回显路径)。
|
||||
两种用法:
|
||||
|
||||
- **工作区 = 项目目录**(启动宿主前设 `DETA_WORKSPACE=<项目目录>`):宿主原生文件工具与 MCP
|
||||
工作区工具看的是同一份文件,直接改完就能编译,不需要同步步骤。
|
||||
- **工作区 ≠ 项目目录**(缺省 `~/.deta/workspace`):本地改动要先同步进引擎工作区,
|
||||
引擎工具读到的才是最新版本。
|
||||
|
||||
编辑手段按场景选:纯文本改动优先用宿主原生文件工具(read / patch / grep);需要按元素定向
|
||||
读写 AML(按空间 ID 改属性、只取某实体块原文)时用 MCP 的工作区工具。编译、规范校验、
|
||||
规则检查、围护结构统计、空间邻接一律走引擎工具。
|
||||
当前形态到底有哪些工具、叫什么名字,以运行时调 `list_capabilities` 的结果为准。
|
||||
|
||||
| 要做的事 | 用什么 |
|
||||
|----------|--------|
|
||||
| 读取 / 修改 / 搜索 AML 与其它设计源文件(`aml.xml`、`bss.md`、`rules.yml`、`behaviors.yml`、`design-brief.md`) | **宿主原生工具**:read / patch / grep / shell(直接改文件,不走 MCP 编辑工具) |
|
||||
| 云端编译(AML → building.json) | MCP `apply_design` |
|
||||
| **改模型并让它出现在云项目/Studio(闭环单入口)** | **MCP `build_model`**(写工作区 → 编译 → 校验 → 云同步,一次调用完成) |
|
||||
| 语法与几何校验 / 规则检查 | MCP `validate_model`(`checks` 一次要齐几何 + issues + rules,三类共用同一次编译) |
|
||||
| 围护结构统计 | MCP `envelope_stats` |
|
||||
| 空间邻接、交通空间合并 | MCP `query_spatial_graph`、`apply_design(merge_traffic_spaces=…)` |
|
||||
| 领域脚本(面积预算 / 轴网 / 布局建议 / 校核) | MCP `run_script`、`run_scripts` |
|
||||
| 把本地改动送上云端、取回云端工作区 | MCP `push_project`、`pull_project` |
|
||||
| 语法规则与生成上下文 | MCP `get_aml_schema`、`generate_aml` |
|
||||
|
||||
固定顺序(工具面里有 `build_model` 时):**本地写 AML → `build_model` → 看 gate →(Studio 里看效果)**。
|
||||
`build_model` 一次完成「写服务端工作区 → 编译 → 校验 → 写进云项目工作区」,Studio 里立即可见。
|
||||
工具面里没有 `build_model`(部分部署形态会裁掉它)时才退化为:本地编辑 → `push_project` → `apply_design` →
|
||||
`validate_model` → Studio 查看。引擎工具读的是**服务端工作区**的文件,没推送过就读不到你的最新改动。
|
||||
|
||||
**工具检索结果条数有限**:一次搜索没看到某个工具,不代表它不存在——先调
|
||||
`list_capabilities` 拿当前形态的完整工具名,再按名字直接调用。
|
||||
|
||||
### 闭环单入口 build_model
|
||||
|
||||
```
|
||||
build_model({ aml?: "<AML 全文>", files?: { "aml.xml": "…" }, projectId?: "24", force?: false })
|
||||
```
|
||||
|
||||
- `aml` 给出则先写入引擎工作区 `aml.xml`;`files` 可一并写其它工作区文件(路径规则同 `push_project`)。
|
||||
- `projectId` 缺省取工作区 `deta.json` 绑定的云项目;没有绑定就先
|
||||
`push_project({ files, projectId })` 绑一次。
|
||||
- 返回:`gate`(`passed` / `failed`)、编译摘要、校验明细、`cloud.headSha`、`studioUrl`。
|
||||
- `gate: failed` → 按 warning/error 改 AML,再调一次 `build_model`,直到 `passed`。
|
||||
- `status: conflict`(`version_conflict`)→ 云项目工作区被其它会话改过(例如 Studio 面板里编辑过),
|
||||
确认要覆盖再传 `force: true` 重跑一次。
|
||||
- `status: unavailable` → 当前没有云同步通道(未注入云令牌,或该部署形态未接云),改用
|
||||
`apply_design` + `validate_model`(编译校验不依赖云)。
|
||||
- **禁止**把 AML 拷进 Studio 的代码编辑器手工点编译:那是没有闭环工具时的兜底,不是正常路径。
|
||||
|
||||
### push 与 Studio 深链
|
||||
|
||||
- 需要云端校验、或想在 Studio 里看效果时,先
|
||||
`push_project({ files: { "aml.xml": "<全文>", "rules.yml": "…" }, projectId?: "…" })`。
|
||||
`files` 至少含 `aml.xml`;路径必须是工作区相对路径(绝对路径、`..` 穿越会被拒绝);
|
||||
单次载荷上限 1 MiB。传 `projectId` 会同时把本项目绑定到云项目
|
||||
(写入服务端工作区 `deta.json`,之后 `generate_aml` / `pull_project` 也带同一深链)。
|
||||
- 返回的 `studioUrl`(`https://deta.city/design/<projectId>`,例:云项目 11 → `https://deta.city/design/11`):
|
||||
**在宿主右侧的内置浏览器面板打开**(做法见下一节);已有相同 URL 的标签优先复用,不要重复开新标签。
|
||||
- 反向核对 / 拉回云端工作区用 `pull_project`(返回 `{ files, projectId?, studioUrl? }`,
|
||||
工作区为空时 `files` 为 `{}`)。
|
||||
|
||||
## 在右侧面板打开 Studio,并在其中跑模拟
|
||||
|
||||
Studio 就是deta.city 上的建筑性能应用。在 Codex 右侧内置浏览器面板打开它之后,
|
||||
看模型、跑模拟、看结果图层都在面板里完成;本插件的 MCP 只负责把本地改动推上去,不执行模拟。
|
||||
|
||||
打开步骤(用宿主内置浏览器 = browser 插件的 `iab`):
|
||||
|
||||
1. **先调 `open_studio` 拿链接并确认执行器上线**(免手工登录)。返回 `url`
|
||||
(`https://deta.city/mcp-api/portal-session/landing?ticket=…&next=/design/<id>`)、
|
||||
`studioUrl`、`executor_online`、`expiresInSeconds`(180)。该链接在面板内会自动完成
|
||||
deta.city 登录并跳到 Studio。
|
||||
- 典型节奏:`open_studio()` 拿 `url` → 面板打开 → `open_studio({ waitSeconds: 20 })`
|
||||
确认 `executor_online: true` → 再调 `simulate` / `studio_command` 等执行器侧工具。
|
||||
- 若返回 `auth_required` → 先让用户完成 MCP 授权(`codex mcp login deta_mcp`)再重试;
|
||||
若 `url` 报「票据无效或已过期」→ 重新调一次,不要改成手工登录。
|
||||
2. 按 browser 插件技能(`control-in-app-browser`)的 Bootstrap 初始化,再取内置浏览器句柄:
|
||||
`const iab = await agent.browsers.get("iab");`,并先读一次 `await iab.documentation()`。
|
||||
3. 先 `await iab.tabs.list()` 看是否已有该 URL 的标签:**有就复用,不要调 `goto` 重载**
|
||||
(重载会丢面板里正在进行的操作);没有才 `const tab = await iab.tabs.new(); await tab.goto(url);`
|
||||
4. 打开后告诉用户「Studio 已在右侧面板打开(项目名/ID)」,并说明模拟在面板里运行。
|
||||
|
||||
旧路径(工具面里没有 `open_studio` 时):自己拼 `studioUrl`
|
||||
(本机项目目录 `deta.json` 的 `projectId`,或 `push_project(…, projectId)` 的返回值,
|
||||
形如 `https://deta.city/design/<projectId>`),按上面第 2–4 步打开——但需面板已登录。
|
||||
|
||||
在面板里跑模拟:
|
||||
|
||||
- 模拟入口在 Studio 界面内(分析/模拟面板、GH 模板等)。**默认交给用户在面板里操作**——
|
||||
模拟参数与结果解读属设计决策,不要代替用户点。
|
||||
- 用户明确要你代跑时:先 `await tab.ax.write()` 取当前界面元素,再 `await tab.ax.click(<元素序号>)`
|
||||
逐步操作;每一步后重新取一次状态确认,**不要凭记忆点坐标**。
|
||||
- 结果出来后用 `await tab.ax.write("both")` 或 `await tab.screenshot()` 确认图层与数值,再向用户汇报。
|
||||
|
||||
前置与故障:
|
||||
|
||||
- 面板未登录 deta.city:用 `open_studio` 的一次性链接即可免手工登录(见上);只有拿不到
|
||||
`open_studio` 时才退化为手工:在面板里打开 `https://deta.city/login` 完成一次登录
|
||||
(登录态写入该浏览器的 `ant_token` cookie 与 `ant-model-auth` localStorage,之后长期有效),
|
||||
登录完成后重新打开 `studioUrl` 即自动加载项目。
|
||||
- **宿主把 MCP 授权页开在哪个浏览器由宿主决定**(实测 Codex Desktop 走系统默认浏览器),
|
||||
且**那一页的登录态不会进入面板**——所以别把"MCP 已授权"当成"面板已登录",两者要分别确认:
|
||||
MCP 授权只解决 Codex↔服务端;面板登录解决 Studio 在面板里的会话(`open_studio` 正是两者的桥)。
|
||||
实测口径:授权页在系统浏览器完成换码后停留在宿主的回调页("Authentication complete. You may close
|
||||
this window."),该页不归我们控制;授权页自身已改为**在隐藏框架内完成换码**并给出「打开 deta.city」
|
||||
与「关闭本页」,不再顶层跳走。
|
||||
- 没有 `projectId`:Studio 打开的是空项目。先在门户创建云项目拿到 ID,再 `push_project(…, projectId)` 绑定。
|
||||
- 宿主没有内置浏览器(CLI 等形态):直接把 `studioUrl` 给用户,让他自己打开,不要假装已经打开。
|
||||
|
||||
## 铁律
|
||||
|
||||
1. **AML 是建筑语义的唯一事实源**。任何几何或属性变更都必须落到 AML 文本,
|
||||
不得用中间 JSON 表达修改意图。
|
||||
2. **禁止凭记忆编写 AML 结构**。动手前必须先取真实语法:
|
||||
- 已有模型:读工作区里的 `aml.xml`(宿主原生 read 工具,或 MCP 的 `read_source`)
|
||||
- 语法规则:`get_aml_schema`(从编译服务实时获取,唯一权威)
|
||||
3. **属性知识不硬编码**:房间用途、构造材料、洞口参数等可选值从工具返回中获取,
|
||||
不要凭经验列举。
|
||||
4. **局部修改优于整体重写**:改单个空间属性时只改该空间,不重写整个模型。
|
||||
5. **控制单会话工具调用量**。编码代理在会话累积较多本插件工具调用后,会在请求里重复
|
||||
声明 `deta` 命名空间,上游模型服务(Responses 兼容层)直接拒绝:
|
||||
`Duplicate namespace name 'deta'; Namespace names must be unique`。
|
||||
实测触发点随会话长度前移(并发调用会显著提前触发)。规程:
|
||||
- 同一轮只调用一个本插件工具,不要并发;
|
||||
- 单会话保持轻量,完成任务后另起会话继续;
|
||||
- 一旦出现该错误,**停止重试并告知用户重开会话**(工作区文件不会丢失)。
|
||||
6. **复杂形体(`<solid script="geometry/xxx.py">`)的脚本属于工作区文件,不是宿主本地文件**。
|
||||
编译服务没有工作区访问权:编译前引擎会按 AML 声明的相对路径把脚本内容**随请求携带**
|
||||
(键名必须与 AML 一字不差,含 `geometry/` 前缀)。因此:
|
||||
- 脚本必须真实存在于**工作区**(用工作区文件工具读写,如 `geometry/shapes.py`);
|
||||
- 脚本取不到时编译会**显式失败并列出原因**(而不是"编译通过但形体丢失");
|
||||
- 改动脚本内容后无需重命名或改 AML;若 AML 里写了 `version="sha256:…"`,脚本内容变了
|
||||
必须同步更新该版本号,否则编译报「几何脚本版本不匹配」。
|
||||
|
||||
## 标准工作流
|
||||
|
||||
1. **读**:用宿主原生工具读本地项目里的设计源码(`aml.xml` 等)。模型为空时先用
|
||||
`generate_aml` 取生成上下文,再用 `get_aml_schema` 核对语法,然后在本地写出 AML。
|
||||
2. **改**:在本地用 read / patch / grep 直接改 AML 文本(局部修改优于整体重写)。
|
||||
3. **推**:`push_project({ files: { "aml.xml": … } })` 把改好的源码送到服务端工作区
|
||||
(要绑云项目就带上 `projectId`)。
|
||||
4. **编**:`apply_design` 提交编译 → 编译为建筑模型,产物落盘 `building.json`。
|
||||
5. **校**:`validate_model` 做语法与规则校验,逐条读取错误码并修复,不得跳过;
|
||||
规则类问题用 `validate_model({ checks: ['issues','rules'] })` 一次查齐(info 级提示只在 issues 视图可见)。
|
||||
6. **看**:需要几何指标用 `envelope_stats`(围护结构/外窗面积)或 `query_spatial_graph`
|
||||
(空间邻接与查询);要看模型、**跑模拟**、看结果图层,就在右侧面板打开 `studioUrl`
|
||||
(见「在右侧面板打开 Studio,并在其中跑模拟」一节),模拟在 Studio 界面里运行。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 编译服务未在线时,编译/校验类工具会返回明确错误文本。此时**不要伪造结果**,
|
||||
直接报告「编译服务未接入」,改用宿主原生文件工具(read / patch)核对与编辑本地 AML。
|
||||
- 编译报错必须逐条修复后重新 `apply_design`,禁止在报错状态下声称完成。
|
||||
|
||||
## 工具名从哪来
|
||||
|
||||
本技能**不列工具清单**:工具面随部署形态(引擎形态 / 全量形态)裁剪,任何手写清单都会过期。
|
||||
|
||||
不确定有哪些工具时,调 `list_capabilities` 拿当前形态的**完整工具名**,再按名字直接调用;
|
||||
每个工具能做什么、参数怎么给,以它自身的 description 与返回为准。
|
||||
Reference in New Issue
Block a user