@cniot/mdd-editor 0.3.0 → 0.3.3

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.
@@ -8,10 +8,15 @@ import {
8
8
  openPageWorkspace,
9
9
  pullPage,
10
10
  pushPage,
11
+ shouldUseBridgeRelay,
11
12
  } from './bridgeClient';
12
13
  import { buildPageIR, getPageCode } from './pageIR';
13
14
  import { EVENT_KEY } from '$src/common/const';
14
15
 
16
+ const MIN_BRIDGE_VERSION = '0.1.4';
17
+ const START_COMMAND = `npm i -g @cniot/mdd-ai-bridge
18
+ mdd-ai-bridge`;
19
+
15
20
  const parseSchema = (value) => {
16
21
  if (!value) return null;
17
22
  if (typeof value === 'string') {
@@ -33,14 +38,134 @@ const applySchemaToEditor = (schema, schemaJson) => {
33
38
  schema.emit(EVENT_KEY.SCHEMA_UPDATE_FORCE, schemaJson);
34
39
  };
35
40
 
41
+ const compareVersion = (a = '', b = '') => {
42
+ const left = String(a).split('.').map((item) => Number(item) || 0);
43
+ const right = String(b).split('.').map((item) => Number(item) || 0);
44
+ const max = Math.max(left.length, right.length);
45
+ for (let i = 0; i < max; i += 1) {
46
+ if ((left[i] || 0) > (right[i] || 0)) return 1;
47
+ if ((left[i] || 0) < (right[i] || 0)) return -1;
48
+ }
49
+ return 0;
50
+ };
51
+
52
+ const isBridgeVersionReady = (version) => Boolean(version) && compareVersion(version, MIN_BRIDGE_VERSION) >= 0;
53
+
54
+ const copyText = async (text) => {
55
+ if (typeof navigator !== 'undefined' && navigator?.clipboard?.writeText) {
56
+ await navigator.clipboard.writeText(text);
57
+ return;
58
+ }
59
+ const textarea = document.createElement('textarea');
60
+ textarea.value = text;
61
+ textarea.setAttribute('readonly', 'readonly');
62
+ textarea.style.position = 'fixed';
63
+ textarea.style.left = '-9999px';
64
+ document.body.appendChild(textarea);
65
+ textarea.select();
66
+ document.execCommand('copy');
67
+ document.body.removeChild(textarea);
68
+ };
69
+
70
+ const getJsonErrorDetail = (error) => {
71
+ const jsonError = error?.data?.jsonError;
72
+ if (jsonError) {
73
+ return {
74
+ title: `${jsonError.file || 'JSON'} 解析失败${
75
+ jsonError.line ? `:第 ${jsonError.line} 行${jsonError.column ? `,第 ${jsonError.column} 列` : ''}` : ''
76
+ }`,
77
+ message: jsonError.message || error.message || '',
78
+ snippet: jsonError.snippet || '',
79
+ };
80
+ }
81
+ return {
82
+ title: '同步失败',
83
+ message: error?.message || '同步本地 AI 修改失败',
84
+ snippet: '',
85
+ };
86
+ };
87
+
88
+ const getChangeTypeText = (type) => {
89
+ switch (type) {
90
+ case 'added':
91
+ return '新增';
92
+ case 'removed':
93
+ return '删除';
94
+ case 'type':
95
+ return '类型变化';
96
+ case 'changed':
97
+ return '修改';
98
+ default:
99
+ return type || '修改';
100
+ }
101
+ };
102
+
103
+ function PullDiffDetail({ diffSummary }) {
104
+ if (!diffSummary?.hasBaseline) return null;
105
+ const schemaChanges = diffSummary.schema?.changes || diffSummary.schema?.paths || [];
106
+ const diffText = diffSummary.diffText || '';
107
+ const hasDetail = schemaChanges.length > 0 || diffSummary.script?.changed || diffSummary.style?.changed || diffText;
108
+ if (!hasDetail) return null;
109
+
110
+ return (
111
+ <details className="mdd-local-ai-diff">
112
+ <summary>查看具体改动与 diff</summary>
113
+ <div className="mdd-local-ai-diff-content">
114
+ {schemaChanges.length > 0 ? (
115
+ <div className="mdd-local-ai-diff-section">
116
+ <div className="mdd-local-ai-diff-title">Schema 改动位置</div>
117
+ <ul className="mdd-local-ai-diff-list">
118
+ {schemaChanges.map((item, index) => {
119
+ const path = typeof item === 'string' ? item : item.path;
120
+ const type = typeof item === 'string' ? 'changed' : item.type;
121
+ return (
122
+ <li key={`${path}-${index}`}>
123
+ <code>{path}</code>
124
+ <span>{getChangeTypeText(type)}</span>
125
+ </li>
126
+ );
127
+ })}
128
+ </ul>
129
+ </div>
130
+ ) : null}
131
+
132
+ {diffSummary.script?.changed ? (
133
+ <div className="mdd-local-ai-diff-section">
134
+ <div className="mdd-local-ai-diff-title">Script 改动</div>
135
+ <div>{diffSummary.script.message}</div>
136
+ {diffSummary.script.addedFunctions?.length ? (
137
+ <div>新增:{diffSummary.script.addedFunctions.join('、')}</div>
138
+ ) : null}
139
+ {diffSummary.script.removedFunctions?.length ? (
140
+ <div>移除:{diffSummary.script.removedFunctions.join('、')}</div>
141
+ ) : null}
142
+ </div>
143
+ ) : null}
144
+
145
+ {diffSummary.style?.changed ? (
146
+ <div className="mdd-local-ai-diff-section">
147
+ <div className="mdd-local-ai-diff-title">Style 改动</div>
148
+ <div>{diffSummary.style.message}</div>
149
+ </div>
150
+ ) : null}
151
+
152
+ {diffText ? <pre className="mdd-local-ai-diff-pre">{diffText}</pre> : null}
153
+ </div>
154
+ </details>
155
+ );
156
+ }
157
+
36
158
  export default function LocalAIDrawer(props) {
37
159
  const { schema, scriptInfo, pageMeta = {}, bridgeConfig, onApply } = props;
38
160
  const config = normalizeBridgeConfig(bridgeConfig);
39
161
  const [baseURL, setBaseURL] = React.useState(config.baseURL || DEFAULT_BRIDGE_URL);
40
162
  const [status, setStatus] = React.useState('未连接');
41
163
  const [workspacePath, setWorkspacePath] = React.useState('');
164
+ const [bridgeInfo, setBridgeInfo] = React.useState(null);
42
165
  const [loadingAction, setLoadingAction] = React.useState('');
43
166
  const [lastSummary, setLastSummary] = React.useState('');
167
+ const [pullError, setPullError] = React.useState(null);
168
+ const [lastDiffSummary, setLastDiffSummary] = React.useState(null);
44
169
 
45
170
  const schemaJson = schema?.getAllJSON?.() || {};
46
171
  const pageCode = getPageCode(schemaJson, pageMeta);
@@ -68,13 +193,54 @@ export default function LocalAIDrawer(props) {
68
193
  };
69
194
  }, [pageCode, pageMeta, schema, scriptInfo]);
70
195
 
196
+ const expectedWorkspacePath = React.useMemo(() => {
197
+ if (workspacePath) return workspacePath;
198
+ const root = bridgeInfo?.workspaceRoot || '~/.mdd-ai-workspace';
199
+ return `${root.replace(/\/$/, '')}/pages/${pageCode}`;
200
+ }, [bridgeInfo, pageCode, workspacePath]);
201
+
202
+ const aiPrompt = React.useMemo(
203
+ () =>
204
+ `请帮我修改这个 MDD 页面:
205
+
206
+ 工作区:${expectedWorkspacePath}
207
+ 页面 Code:${pageCode}
208
+
209
+ 请先阅读同目录下的 AGENTS.md 和 PROMPT.md,再按需求修改:
210
+ - mdd.schema.json:页面 schema
211
+ - mdd.script.jsx:页面脚本
212
+ - mdd.style.less:页面样式
213
+
214
+ 注意:
215
+ 1. 不要修改 page.ir.json / page.meta.json 作为最终结果。
216
+ 2. JSON 必须保持合法,尽量只改和需求相关的位置。
217
+ 3. MDD hook / engine API 不确定时,先读 context 目录里的 api-cheatsheet.md、engine-runtime.md、hooks-cookbook.md。
218
+
219
+ 我的需求是:`,
220
+ [expectedWorkspacePath, pageCode],
221
+ );
222
+
223
+ const handleCopy = React.useCallback(async (text, successText) => {
224
+ try {
225
+ await copyText(text);
226
+ Message.success(successText);
227
+ } catch (e) {
228
+ Message.error('复制失败,请手动选择复制');
229
+ }
230
+ }, []);
231
+
71
232
  const checkHealth = React.useCallback(async () => {
72
233
  setLoadingAction('health');
73
234
  try {
74
235
  const res = await health(baseURL);
75
- setStatus(res?.ok ? '已连接' : '连接异常');
76
- if (res?.workspaceRoot) {
77
- setWorkspacePath(res.workspaceRoot);
236
+ setBridgeInfo(res || null);
237
+ if (res?.ok && isBridgeVersionReady(res.version)) {
238
+ setStatus(`已连接(Bridge v${res.version})`);
239
+ } else if (res?.ok) {
240
+ setStatus(`Bridge 版本偏旧${res?.version ? `(v${res.version})` : ''}`);
241
+ setLastSummary(`建议升级本地 bridge 到 ${MIN_BRIDGE_VERSION} 或更高版本:npm i -g @cniot/mdd-ai-bridge@latest`);
242
+ } else {
243
+ setStatus('连接异常');
78
244
  }
79
245
  } catch (e) {
80
246
  setStatus('未连接');
@@ -86,9 +252,15 @@ export default function LocalAIDrawer(props) {
86
252
 
87
253
  const handlePush = async () => {
88
254
  setLoadingAction('push');
255
+ setPullError(null);
256
+ setLastDiffSummary(null);
89
257
  try {
90
258
  const res = await pushPage(baseURL, pageCode, getPayload());
91
259
  setWorkspacePath(res?.dir || '');
260
+ setBridgeInfo((prev) => ({
261
+ ...(prev || {}),
262
+ workspaceRoot: res?.context?.workspaceRoot || prev?.workspaceRoot,
263
+ }));
92
264
  setStatus('已同步到本地');
93
265
  setLastSummary(`已写入本地工作区: ${res?.dir || pageCode}`);
94
266
  Message.success('已发送到本地 AI 工作区');
@@ -101,6 +273,7 @@ export default function LocalAIDrawer(props) {
101
273
 
102
274
  const handlePull = async () => {
103
275
  setLoadingAction('pull');
276
+ setPullError(null);
104
277
  try {
105
278
  const res = await pullPage(baseURL, pageCode);
106
279
  const nextSchema = parseSchema(res?.schemaInfo);
@@ -116,9 +289,13 @@ export default function LocalAIDrawer(props) {
116
289
  raw: res,
117
290
  });
118
291
  setLastSummary(res?.summary || '已从本地工作区同步修改');
292
+ setLastDiffSummary(res?.diffSummary || null);
119
293
  Message.success('已同步本地 AI 修改');
120
294
  } catch (e) {
121
- Message.error(e.message || '同步本地 AI 修改失败');
295
+ const detail = getJsonErrorDetail(e);
296
+ setPullError(detail);
297
+ setLastDiffSummary(null);
298
+ Message.error(detail.title || '同步本地 AI 修改失败');
122
299
  } finally {
123
300
  setLoadingAction('');
124
301
  }
@@ -129,6 +306,7 @@ export default function LocalAIDrawer(props) {
129
306
  try {
130
307
  const res = await getPageStatus(baseURL, pageCode);
131
308
  setWorkspacePath(res?.dir || '');
309
+ setLastDiffSummary(null);
132
310
  setLastSummary(res?.exists ? `本地工作区已存在: ${res.dir}` : '本地还没有这个页面的工作区');
133
311
  Message.success(res?.exists ? '本地工作区已存在' : '本地工作区不存在');
134
312
  } catch (e) {
@@ -152,6 +330,10 @@ export default function LocalAIDrawer(props) {
152
330
  };
153
331
 
154
332
  React.useEffect(() => {
333
+ if (shouldUseBridgeRelay(baseURL)) {
334
+ setStatus('点击按钮后通过本地 relay 连接');
335
+ return;
336
+ }
155
337
  checkHealth();
156
338
  }, []);
157
339
 
@@ -173,6 +355,7 @@ export default function LocalAIDrawer(props) {
173
355
  </div>
174
356
  <div className="mdd-local-ai-meta">
175
357
  <div>状态:{status}</div>
358
+ {bridgeInfo?.version ? <div>Bridge 版本:{bridgeInfo.version}</div> : null}
176
359
  <div>页面:{pageCode}</div>
177
360
  {workspacePath ? <div>目录:{workspacePath}</div> : null}
178
361
  </div>
@@ -193,8 +376,30 @@ export default function LocalAIDrawer(props) {
193
376
  </CnButton>
194
377
  </div>
195
378
 
379
+ <div className="mdd-local-ai-copy-actions">
380
+ <CnButton size="small" onClick={() => handleCopy(START_COMMAND, '已复制启动命令')}>
381
+ 复制启动命令
382
+ </CnButton>
383
+ <CnButton size="small" onClick={() => handleCopy(expectedWorkspacePath, '已复制工作区路径')}>
384
+ 复制工作区路径
385
+ </CnButton>
386
+ <CnButton size="small" onClick={() => handleCopy(aiPrompt, '已复制 AI 提示词')}>
387
+ 复制 AI 提示词
388
+ </CnButton>
389
+ </div>
390
+
196
391
  {lastSummary ? <div className="mdd-local-ai-summary">{lastSummary}</div> : null}
197
392
 
393
+ <PullDiffDetail diffSummary={lastDiffSummary} />
394
+
395
+ {pullError ? (
396
+ <div className="mdd-local-ai-error">
397
+ <div className="mdd-local-ai-error-title">{pullError.title}</div>
398
+ <div className="mdd-local-ai-error-message">{pullError.message}</div>
399
+ {pullError.snippet ? <pre className="mdd-local-ai-error-snippet">{pullError.snippet}</pre> : null}
400
+ </div>
401
+ ) : null}
402
+
198
403
  <CnCard className="mdd-local-ai-card">
199
404
  <div className="mdd-local-ai-title">使用方式</div>
200
405
  <div className="mdd-local-ai-tips">
@@ -203,6 +408,7 @@ export default function LocalAIDrawer(props) {
203
408
  <div>3. 点击“发送到本地 AI”,当前页面会写入本地工作区。</div>
204
409
  <div>4. 用 Cursor、Qoder、Codex CLI 等工具打开目录并修改文件。</div>
205
410
  <div>5. 点击“同步本地修改”,确认预览后继续使用原保存按钮。</div>
411
+ <div>线上 HTTP 页面会自动打开本地 Bridge relay 窗口,请允许浏览器弹窗。</div>
206
412
  <div>本地工作区默认在 ~/.mdd-ai-workspace。</div>
207
413
  </div>
208
414
  </CnCard>
@@ -1,8 +1,33 @@
1
1
  export const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:17678';
2
2
 
3
3
  const trimEndSlash = (value = '') => value.replace(/\/+$/, '');
4
+ const RELAY_CHANNEL = 'mdd-ai-bridge';
5
+ const relayCacheMap = new Map();
6
+
7
+ const isLoopbackURL = (value = '') => /^http:\/\/(127(?:\.\d{1,3}){3}|localhost)(?::\d+)?/i.test(value);
8
+
9
+ export class BridgeRequestError extends Error {
10
+ constructor(message, options = {}) {
11
+ super(message);
12
+ this.name = 'BridgeRequestError';
13
+ this.status = options.status || 0;
14
+ this.data = options.data || null;
15
+ }
16
+ }
17
+
18
+ export function shouldUseBridgeRelay(baseURL) {
19
+ return typeof window !== 'undefined' && !window.isSecureContext && isLoopbackURL(baseURL);
20
+ }
21
+
22
+ function createRequestId() {
23
+ return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
24
+ }
4
25
 
5
26
  async function request(baseURL, path, options = {}) {
27
+ if (shouldUseBridgeRelay(baseURL)) {
28
+ return requestViaRelay(baseURL, path, options);
29
+ }
30
+
6
31
  const res = await fetch(`${trimEndSlash(baseURL)}${path}`, {
7
32
  ...options,
8
33
  headers: {
@@ -20,11 +45,130 @@ async function request(baseURL, path, options = {}) {
20
45
  }
21
46
 
22
47
  if (!res.ok) {
23
- throw new Error(data?.message || `请求本地 AI Bridge 失败: ${res.status}`);
48
+ throw new BridgeRequestError(data?.message || `请求本地 AI Bridge 失败: ${res.status}`, {
49
+ status: res.status,
50
+ data,
51
+ });
24
52
  }
25
53
  return data;
26
54
  }
27
55
 
56
+ function getRelayCache(baseURL) {
57
+ const relayOrigin = trimEndSlash(baseURL);
58
+ const relayURL = `${relayOrigin}/relay`;
59
+ const cached = relayCacheMap.get(relayOrigin);
60
+ if (cached?.window && !cached.window.closed) {
61
+ return cached;
62
+ }
63
+
64
+ const relayWindow = window.open(
65
+ relayURL,
66
+ 'mdd-ai-bridge-relay',
67
+ 'width=520,height=320,menubar=no,toolbar=no,location=no,status=no',
68
+ );
69
+
70
+ if (!relayWindow) {
71
+ throw new Error('浏览器拦截了本地 Bridge 窗口,请允许弹窗后重试');
72
+ }
73
+
74
+ const next = {
75
+ origin: relayOrigin,
76
+ window: relayWindow,
77
+ ready: false,
78
+ };
79
+ relayCacheMap.set(relayOrigin, next);
80
+ return next;
81
+ }
82
+
83
+ function requestViaRelay(baseURL, path, options = {}) {
84
+ const relayCache = getRelayCache(baseURL);
85
+ const requestId = createRequestId();
86
+
87
+ return new Promise((resolve, reject) => {
88
+ let settled = false;
89
+ let fallbackPosted = false;
90
+ let readyPosted = false;
91
+
92
+ const cleanup = () => {
93
+ window.removeEventListener('message', handleMessage);
94
+ clearTimeout(timeoutTimer);
95
+ clearTimeout(fallbackTimer);
96
+ };
97
+
98
+ const postRequest = (source = 'ready') => {
99
+ if (source === 'ready') {
100
+ if (readyPosted) return;
101
+ readyPosted = true;
102
+ } else {
103
+ if (fallbackPosted) return;
104
+ fallbackPosted = true;
105
+ }
106
+ if (!relayCache.window || relayCache.window.closed) {
107
+ finish(reject, new Error('本地 Bridge relay 窗口已关闭,请重试'));
108
+ return;
109
+ }
110
+ relayCache.window.postMessage(
111
+ {
112
+ channel: RELAY_CHANNEL,
113
+ type: 'request',
114
+ id: requestId,
115
+ path,
116
+ options,
117
+ },
118
+ relayCache.origin,
119
+ );
120
+ };
121
+
122
+ const finish = (callback, value) => {
123
+ if (settled) return;
124
+ settled = true;
125
+ cleanup();
126
+ callback(value);
127
+ };
128
+
129
+ const handleMessage = (event) => {
130
+ if (event.origin !== relayCache.origin) return;
131
+ if (event.source !== relayCache.window) return;
132
+ const message = event.data || {};
133
+ if (message.channel !== RELAY_CHANNEL) return;
134
+
135
+ if (message.type === 'ready') {
136
+ relayCache.ready = true;
137
+ postRequest('ready');
138
+ return;
139
+ }
140
+
141
+ if (message.type !== 'response' || message.id !== requestId) return;
142
+ if (!message.ok) {
143
+ finish(
144
+ reject,
145
+ new BridgeRequestError(
146
+ message.data?.message || `请求本地 AI Bridge 失败: ${message.status || 0}`,
147
+ {
148
+ status: message.status || 0,
149
+ data: message.data,
150
+ },
151
+ ),
152
+ );
153
+ return;
154
+ }
155
+ finish(resolve, message.data);
156
+ };
157
+
158
+ const timeoutTimer = setTimeout(() => {
159
+ finish(reject, new Error('本地 AI Bridge relay 响应超时,请确认服务已启动'));
160
+ }, 30000);
161
+ const fallbackTimer = setTimeout(() => {
162
+ if (!relayCache.ready) postRequest('fallback');
163
+ }, 500);
164
+
165
+ window.addEventListener('message', handleMessage);
166
+ if (relayCache.ready) {
167
+ setTimeout(() => postRequest('ready'), 0);
168
+ }
169
+ });
170
+ }
171
+
28
172
  export function normalizeBridgeConfig(config) {
29
173
  if (config === false) {
30
174
  return {