@cniot/mdd-editor 0.3.1 → 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.
@@ -13,6 +13,10 @@ import {
13
13
  import { buildPageIR, getPageCode } from './pageIR';
14
14
  import { EVENT_KEY } from '$src/common/const';
15
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
+
16
20
  const parseSchema = (value) => {
17
21
  if (!value) return null;
18
22
  if (typeof value === 'string') {
@@ -34,14 +38,134 @@ const applySchemaToEditor = (schema, schemaJson) => {
34
38
  schema.emit(EVENT_KEY.SCHEMA_UPDATE_FORCE, schemaJson);
35
39
  };
36
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
+
37
158
  export default function LocalAIDrawer(props) {
38
159
  const { schema, scriptInfo, pageMeta = {}, bridgeConfig, onApply } = props;
39
160
  const config = normalizeBridgeConfig(bridgeConfig);
40
161
  const [baseURL, setBaseURL] = React.useState(config.baseURL || DEFAULT_BRIDGE_URL);
41
162
  const [status, setStatus] = React.useState('未连接');
42
163
  const [workspacePath, setWorkspacePath] = React.useState('');
164
+ const [bridgeInfo, setBridgeInfo] = React.useState(null);
43
165
  const [loadingAction, setLoadingAction] = React.useState('');
44
166
  const [lastSummary, setLastSummary] = React.useState('');
167
+ const [pullError, setPullError] = React.useState(null);
168
+ const [lastDiffSummary, setLastDiffSummary] = React.useState(null);
45
169
 
46
170
  const schemaJson = schema?.getAllJSON?.() || {};
47
171
  const pageCode = getPageCode(schemaJson, pageMeta);
@@ -69,13 +193,54 @@ export default function LocalAIDrawer(props) {
69
193
  };
70
194
  }, [pageCode, pageMeta, schema, scriptInfo]);
71
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
+
72
232
  const checkHealth = React.useCallback(async () => {
73
233
  setLoadingAction('health');
74
234
  try {
75
235
  const res = await health(baseURL);
76
- setStatus(res?.ok ? '已连接' : '连接异常');
77
- if (res?.workspaceRoot) {
78
- 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('连接异常');
79
244
  }
80
245
  } catch (e) {
81
246
  setStatus('未连接');
@@ -87,9 +252,15 @@ export default function LocalAIDrawer(props) {
87
252
 
88
253
  const handlePush = async () => {
89
254
  setLoadingAction('push');
255
+ setPullError(null);
256
+ setLastDiffSummary(null);
90
257
  try {
91
258
  const res = await pushPage(baseURL, pageCode, getPayload());
92
259
  setWorkspacePath(res?.dir || '');
260
+ setBridgeInfo((prev) => ({
261
+ ...(prev || {}),
262
+ workspaceRoot: res?.context?.workspaceRoot || prev?.workspaceRoot,
263
+ }));
93
264
  setStatus('已同步到本地');
94
265
  setLastSummary(`已写入本地工作区: ${res?.dir || pageCode}`);
95
266
  Message.success('已发送到本地 AI 工作区');
@@ -102,6 +273,7 @@ export default function LocalAIDrawer(props) {
102
273
 
103
274
  const handlePull = async () => {
104
275
  setLoadingAction('pull');
276
+ setPullError(null);
105
277
  try {
106
278
  const res = await pullPage(baseURL, pageCode);
107
279
  const nextSchema = parseSchema(res?.schemaInfo);
@@ -117,9 +289,13 @@ export default function LocalAIDrawer(props) {
117
289
  raw: res,
118
290
  });
119
291
  setLastSummary(res?.summary || '已从本地工作区同步修改');
292
+ setLastDiffSummary(res?.diffSummary || null);
120
293
  Message.success('已同步本地 AI 修改');
121
294
  } catch (e) {
122
- Message.error(e.message || '同步本地 AI 修改失败');
295
+ const detail = getJsonErrorDetail(e);
296
+ setPullError(detail);
297
+ setLastDiffSummary(null);
298
+ Message.error(detail.title || '同步本地 AI 修改失败');
123
299
  } finally {
124
300
  setLoadingAction('');
125
301
  }
@@ -130,6 +306,7 @@ export default function LocalAIDrawer(props) {
130
306
  try {
131
307
  const res = await getPageStatus(baseURL, pageCode);
132
308
  setWorkspacePath(res?.dir || '');
309
+ setLastDiffSummary(null);
133
310
  setLastSummary(res?.exists ? `本地工作区已存在: ${res.dir}` : '本地还没有这个页面的工作区');
134
311
  Message.success(res?.exists ? '本地工作区已存在' : '本地工作区不存在');
135
312
  } catch (e) {
@@ -178,6 +355,7 @@ export default function LocalAIDrawer(props) {
178
355
  </div>
179
356
  <div className="mdd-local-ai-meta">
180
357
  <div>状态:{status}</div>
358
+ {bridgeInfo?.version ? <div>Bridge 版本:{bridgeInfo.version}</div> : null}
181
359
  <div>页面:{pageCode}</div>
182
360
  {workspacePath ? <div>目录:{workspacePath}</div> : null}
183
361
  </div>
@@ -198,8 +376,30 @@ export default function LocalAIDrawer(props) {
198
376
  </CnButton>
199
377
  </div>
200
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
+
201
391
  {lastSummary ? <div className="mdd-local-ai-summary">{lastSummary}</div> : null}
202
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
+
203
403
  <CnCard className="mdd-local-ai-card">
204
404
  <div className="mdd-local-ai-title">使用方式</div>
205
405
  <div className="mdd-local-ai-tips">
@@ -2,9 +2,19 @@ export const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:17678';
2
2
 
3
3
  const trimEndSlash = (value = '') => value.replace(/\/+$/, '');
4
4
  const RELAY_CHANNEL = 'mdd-ai-bridge';
5
+ const relayCacheMap = new Map();
5
6
 
6
7
  const isLoopbackURL = (value = '') => /^http:\/\/(127(?:\.\d{1,3}){3}|localhost)(?::\d+)?/i.test(value);
7
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
+
8
18
  export function shouldUseBridgeRelay(baseURL) {
9
19
  return typeof window !== 'undefined' && !window.isSecureContext && isLoopbackURL(baseURL);
10
20
  }
@@ -35,14 +45,22 @@ async function request(baseURL, path, options = {}) {
35
45
  }
36
46
 
37
47
  if (!res.ok) {
38
- 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
+ });
39
52
  }
40
53
  return data;
41
54
  }
42
55
 
43
- function requestViaRelay(baseURL, path, options = {}) {
44
- const relayURL = `${trimEndSlash(baseURL)}/relay`;
45
- const requestId = createRequestId();
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
+
46
64
  const relayWindow = window.open(
47
65
  relayURL,
48
66
  'mdd-ai-bridge-relay',
@@ -53,9 +71,23 @@ function requestViaRelay(baseURL, path, options = {}) {
53
71
  throw new Error('浏览器拦截了本地 Bridge 窗口,请允许弹窗后重试');
54
72
  }
55
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
+
56
87
  return new Promise((resolve, reject) => {
57
88
  let settled = false;
58
- let ready = false;
89
+ let fallbackPosted = false;
90
+ let readyPosted = false;
59
91
 
60
92
  const cleanup = () => {
61
93
  window.removeEventListener('message', handleMessage);
@@ -63,8 +95,19 @@ function requestViaRelay(baseURL, path, options = {}) {
63
95
  clearTimeout(fallbackTimer);
64
96
  };
65
97
 
66
- const postRequest = () => {
67
- relayWindow.postMessage(
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(
68
111
  {
69
112
  channel: RELAY_CHANNEL,
70
113
  type: 'request',
@@ -72,7 +115,7 @@ function requestViaRelay(baseURL, path, options = {}) {
72
115
  path,
73
116
  options,
74
117
  },
75
- trimEndSlash(baseURL),
118
+ relayCache.origin,
76
119
  );
77
120
  };
78
121
 
@@ -84,14 +127,14 @@ function requestViaRelay(baseURL, path, options = {}) {
84
127
  };
85
128
 
86
129
  const handleMessage = (event) => {
87
- if (event.origin !== trimEndSlash(baseURL)) return;
88
- if (event.source !== relayWindow) return;
130
+ if (event.origin !== relayCache.origin) return;
131
+ if (event.source !== relayCache.window) return;
89
132
  const message = event.data || {};
90
133
  if (message.channel !== RELAY_CHANNEL) return;
91
134
 
92
- if (message.type === 'ready' && !ready) {
93
- ready = true;
94
- postRequest();
135
+ if (message.type === 'ready') {
136
+ relayCache.ready = true;
137
+ postRequest('ready');
95
138
  return;
96
139
  }
97
140
 
@@ -99,7 +142,13 @@ function requestViaRelay(baseURL, path, options = {}) {
99
142
  if (!message.ok) {
100
143
  finish(
101
144
  reject,
102
- new Error(message.data?.message || `请求本地 AI Bridge 失败: ${message.status || 0}`),
145
+ new BridgeRequestError(
146
+ message.data?.message || `请求本地 AI Bridge 失败: ${message.status || 0}`,
147
+ {
148
+ status: message.status || 0,
149
+ data: message.data,
150
+ },
151
+ ),
103
152
  );
104
153
  return;
105
154
  }
@@ -110,10 +159,13 @@ function requestViaRelay(baseURL, path, options = {}) {
110
159
  finish(reject, new Error('本地 AI Bridge relay 响应超时,请确认服务已启动'));
111
160
  }, 30000);
112
161
  const fallbackTimer = setTimeout(() => {
113
- if (!ready) postRequest();
162
+ if (!relayCache.ready) postRequest('fallback');
114
163
  }, 500);
115
164
 
116
165
  window.addEventListener('message', handleMessage);
166
+ if (relayCache.ready) {
167
+ setTimeout(() => postRequest('ready'), 0);
168
+ }
117
169
  });
118
170
  }
119
171