@things-factory/board-ui 10.0.0-beta.6 → 10.0.0-beta.64

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.
Files changed (81) hide show
  1. package/dist-client/apptools/favorite-tool.js +5 -5
  2. package/dist-client/apptools/favorite-tool.js.map +1 -1
  3. package/dist-client/board-list/board-tile-list.d.ts +6 -1
  4. package/dist-client/board-list/board-tile-list.js +291 -44
  5. package/dist-client/board-list/board-tile-list.js.map +1 -1
  6. package/dist-client/board-list/group-bar.js +3 -3
  7. package/dist-client/board-list/group-bar.js.map +1 -1
  8. package/dist-client/board-list/play-group-bar.d.ts +0 -1
  9. package/dist-client/board-list/play-group-bar.js +3 -6
  10. package/dist-client/board-list/play-group-bar.js.map +1 -1
  11. package/dist-client/board-provider.js +20 -8
  12. package/dist-client/board-provider.js.map +1 -1
  13. package/dist-client/data-grist/board-editor.js +4 -4
  14. package/dist-client/data-grist/board-editor.js.map +1 -1
  15. package/dist-client/data-grist/board-renderer.js +4 -4
  16. package/dist-client/data-grist/board-renderer.js.map +1 -1
  17. package/dist-client/graphql/board-template.js +1 -1
  18. package/dist-client/graphql/board-template.js.map +1 -1
  19. package/dist-client/graphql/board.d.ts +1 -0
  20. package/dist-client/graphql/board.js +28 -2
  21. package/dist-client/graphql/board.js.map +1 -1
  22. package/dist-client/graphql/group.js +1 -1
  23. package/dist-client/graphql/group.js.map +1 -1
  24. package/dist-client/graphql/play-group.js +3 -3
  25. package/dist-client/graphql/play-group.js.map +1 -1
  26. package/dist-client/pages/attachment-list-page.d.ts +16 -0
  27. package/dist-client/pages/attachment-list-page.js +63 -2
  28. package/dist-client/pages/attachment-list-page.js.map +1 -1
  29. package/dist-client/pages/board-list-page.d.ts +23 -3
  30. package/dist-client/pages/board-list-page.js +165 -77
  31. package/dist-client/pages/board-list-page.js.map +1 -1
  32. package/dist-client/pages/board-modeller-page.d.ts +122 -0
  33. package/dist-client/pages/board-modeller-page.js +705 -54
  34. package/dist-client/pages/board-modeller-page.js.map +1 -1
  35. package/dist-client/pages/board-player-by-name-page.js.map +1 -1
  36. package/dist-client/pages/board-player-page.js +14 -26
  37. package/dist-client/pages/board-player-page.js.map +1 -1
  38. package/dist-client/pages/board-viewer-by-name-page.d.ts +8 -1
  39. package/dist-client/pages/board-viewer-by-name-page.js +9 -1
  40. package/dist-client/pages/board-viewer-by-name-page.js.map +1 -1
  41. package/dist-client/pages/board-viewer-page.d.ts +2 -1
  42. package/dist-client/pages/board-viewer-page.js +52 -48
  43. package/dist-client/pages/board-viewer-page.js.map +1 -1
  44. package/dist-client/pages/play-list-page.d.ts +0 -1
  45. package/dist-client/pages/play-list-page.js +26 -33
  46. package/dist-client/pages/play-list-page.js.map +1 -1
  47. package/dist-client/pages/printable-board-viewer-page.js +2 -2
  48. package/dist-client/pages/printable-board-viewer-page.js.map +1 -1
  49. package/dist-client/route.d.ts +1 -1
  50. package/dist-client/route.js.map +1 -1
  51. package/dist-client/setting-let/board-view-setting-let.js +1 -1
  52. package/dist-client/setting-let/board-view-setting-let.js.map +1 -1
  53. package/dist-client/tsconfig.tsbuildinfo +1 -1
  54. package/dist-client/utils/notify-helper.d.ts +7 -0
  55. package/dist-client/utils/notify-helper.js +28 -0
  56. package/dist-client/utils/notify-helper.js.map +1 -0
  57. package/dist-client/utils/query-utils.d.ts +1 -0
  58. package/dist-client/utils/query-utils.js +20 -0
  59. package/dist-client/utils/query-utils.js.map +1 -0
  60. package/dist-client/viewparts/board-basic-info.js +9 -13
  61. package/dist-client/viewparts/board-basic-info.js.map +1 -1
  62. package/dist-client/viewparts/board-template-info.d.ts +0 -1
  63. package/dist-client/viewparts/board-template-info.js +5 -13
  64. package/dist-client/viewparts/board-template-info.js.map +1 -1
  65. package/dist-client/viewparts/board-versions.js +1 -1
  66. package/dist-client/viewparts/board-versions.js.map +1 -1
  67. package/dist-client/viewparts/group-info-basic.js +2 -2
  68. package/dist-client/viewparts/group-info-basic.js.map +1 -1
  69. package/dist-client/viewparts/group-info-import.js +2 -2
  70. package/dist-client/viewparts/group-info-import.js.map +1 -1
  71. package/dist-client/viewparts/link-builder.js +1 -1
  72. package/dist-client/viewparts/link-builder.js.map +1 -1
  73. package/dist-client/viewparts/play-group-info-basic.js +2 -2
  74. package/dist-client/viewparts/play-group-info-basic.js.map +1 -1
  75. package/dist-server/tsconfig.tsbuildinfo +1 -1
  76. package/package.json +5 -4
  77. package/translations/en.json +3 -29
  78. package/translations/ja.json +3 -29
  79. package/translations/ko.json +3 -29
  80. package/translations/ms.json +3 -29
  81. package/translations/zh.json +3 -29
@@ -2,19 +2,83 @@ import { __decorate, __metadata } from "tslib";
2
2
  import './things-scene-components.import';
3
3
  import '@operato/board/ox-board-modeller.js';
4
4
  import '@operato/oops';
5
+ import '@things-factory/board-ai';
5
6
  import gql from 'graphql-tag';
6
- import { css, html } from 'lit';
7
- import { customElement, property, query } from 'lit/decorators.js';
7
+ import { css, html, nothing } from 'lit';
8
+ import { customElement, property, query, state } from 'lit/decorators.js';
8
9
  import { BoardModeller } from '@operato/board/ox-board-modeller.js';
10
+ import { LoadTracker } from '@hatiolab/things-scene';
9
11
  import { OxPropertyEditor } from '@operato/property-editor';
10
12
  import { PageView, FontController } from '@operato/shell';
11
13
  import { hasPrivilege } from '@things-factory/auth-base/dist-client/index.js';
14
+ import { applyBoardEditPatchVerbose, mergeComponent } from '@things-factory/board-ai';
12
15
  import { client, gqlContext } from '@operato/graphql';
13
16
  import { i18next } from '@operato/i18n';
14
17
  import { OxPrompt } from '@operato/popup/ox-prompt.js';
15
18
  import { provider } from '../board-provider.js';
16
19
  import components from './things-scene-components-with-tools.import';
20
+ import { notify, notifyError } from '../utils/notify-helper.js';
17
21
  const NOOP = () => { };
22
+ /** 서버 PatchEntry.reverted=true 플래그 영속용 mutation. */
23
+ const REVERT_PATCH_MUTATION = gql `
24
+ mutation RevertPatch($patchId: String!) {
25
+ revertPatch(patchId: $patchId)
26
+ }
27
+ `;
28
+ /**
29
+ * AI 의 BoardEditOp 가 지정한 대상 (id 또는 refid) 으로 things-scene component 검색.
30
+ *
31
+ * id (사용자/AI 가 명시적으로 설정한 string identifier) 와
32
+ * refid (things-scene 이 모든 컴포넌트에 자동 발급하는 numeric identifier) 는
33
+ * 서로 별개 개념이고 AI 도 둘을 구별해서 보낸다 — 이 함수는 호스트 측에서 둘 중
34
+ * 어느 쪽으로든 lookup 가능하게 통합. refid 가 universal 이므로 우선.
35
+ */
36
+ export function findSceneComponent(scene, target) {
37
+ if (!scene)
38
+ return null;
39
+ if (typeof target.refid === 'number') {
40
+ const byRefid = scene.rootContainer?.refidIndexMap?.get(target.refid);
41
+ if (byRefid)
42
+ return byRefid;
43
+ }
44
+ if (typeof target.id === 'string' && target.id.length > 0) {
45
+ return scene.findById?.(target.id) ?? null;
46
+ }
47
+ return null;
48
+ }
49
+ /**
50
+ * default 위에 override 를 깊게 merge.
51
+ * - override 의 키 값이 undefined 가 아닌 경우 우선 적용
52
+ * - override 의 nested object 는 default 와 deep merge
53
+ * - array / primitive 는 override 가 그대로 우선
54
+ */
55
+ function mergeDefaultsDeep(defaults, override) {
56
+ if (defaults === null || defaults === undefined)
57
+ return override;
58
+ if (override === null || override === undefined)
59
+ return defaults;
60
+ if (typeof defaults !== 'object' || typeof override !== 'object')
61
+ return override;
62
+ if (Array.isArray(defaults) || Array.isArray(override))
63
+ return override;
64
+ const out = { ...defaults };
65
+ for (const key of Object.keys(override)) {
66
+ const dv = defaults[key];
67
+ const ov = override[key];
68
+ if (dv !== null &&
69
+ ov !== null &&
70
+ typeof dv === 'object' &&
71
+ typeof ov === 'object' &&
72
+ !Array.isArray(dv) &&
73
+ !Array.isArray(ov)) {
74
+ out[key] = mergeDefaultsDeep(dv, ov);
75
+ }
76
+ else if (ov !== undefined) {
77
+ out[key] = ov;
78
+ }
79
+ }
80
+ return out;
81
+ }
18
82
  let BoardModellerPage = class BoardModellerPage extends PageView {
19
83
  constructor() {
20
84
  super();
@@ -26,15 +90,199 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
26
90
  this.overlay = null;
27
91
  this.scene = null;
28
92
  this.componentGroupList = BoardModeller.groups;
93
+ /** AI 채팅 패널 열림 상태 (기본 닫힘) */
94
+ this.aiPanelOpen = false;
95
+ /**
96
+ * 적용된 patch 별 inverse op 시퀀스 — Revert 기능용.
97
+ *
98
+ * Map<patchId, inverseOps[]>: 각 patch 적용 시점에 계산해 저장. revert 클릭
99
+ * 시 역순 in-place 적용. 페이지 reload 시 손실 — reload 후 revert 시도는
100
+ * notify 로 안내 (메모리 외 영속은 별개 작업).
101
+ */
102
+ this.patchInverses = new Map();
29
103
  this.board = null;
30
104
  this._fontCtrl = new FontController(this);
105
+ /**
106
+ * 채팅에서 patch 도착 — things-scene 에 in-place 적용.
107
+ *
108
+ * 핵심: `this.model = next` 로 모델을 통째 교체하면 ox-scene-viewer 가 scene 을
109
+ * dispose → 재생성 → undo 히스토리/dirty 플래그가 모두 초기화된다.
110
+ * 따라서 일반 add/modify/remove 는 scene 의 in-place mutation API 로 적용 →
111
+ * commander 가 자동으로 snapshot push → CMD+Z 와 저장 안내 팝업이 정상 동작.
112
+ *
113
+ * 'replace' op (보드 전체 교체) 만은 부득이 wholesale 경로로 빠진다 — 이 경우는
114
+ * 사용자에게 명시적으로 안내 후 진행.
115
+ */
116
+ this.onBoardEditPatch = (e) => {
117
+ const { patch, patchId } = e.detail || {};
118
+ if (!patch)
119
+ return;
120
+ const scene = this.scene;
121
+ if (!scene) {
122
+ notifyError(new Error('Scene not initialized'));
123
+ return;
124
+ }
125
+ const hasReplace = (patch.ops || []).some((op) => op.op === 'replace');
126
+ if (hasReplace) {
127
+ this.applyPatchWholesale(patch, patchId);
128
+ return;
129
+ }
130
+ try {
131
+ const applied = [];
132
+ const missed = [];
133
+ // 각 op 의 inverse — patch 적용 직전/직후 scene 상태 기준으로 계산해 누적.
134
+ // 나중에 onBoardEditRevert 가 역순 in-place 적용으로 복원.
135
+ const inverseOps = [];
136
+ for (const op of patch.ops) {
137
+ switch (op.op) {
138
+ case 'add': {
139
+ // normalize → things-scene 의 default 와 merge (gauge.value 등 누락 보강)
140
+ const normalized = this.normalizeComponent(op.component);
141
+ // add 의 inverse 는 새로 발급되는 refid 를 알아야 함 — scene.add 직후
142
+ // 새 컴포넌트의 refid 를 캡처해 remove inverse 생성.
143
+ const prevRefids = new Set(this.collectAllRefids(scene));
144
+ // scene.add 는 내부적으로 CommandMigrate 를 commander 에 execute → 자동 snapshot
145
+ // 두 번째 인자는 boundsOrOffset — 빈 객체면 model 자체의 좌표 그대로 사용.
146
+ scene.add(normalized, {});
147
+ // 새로 발급된 refid 식별
148
+ const newRefids = this.collectAllRefids(scene).filter(r => !prevRefids.has(r));
149
+ for (const refid of newRefids) {
150
+ inverseOps.push({ op: 'remove', refid });
151
+ }
152
+ applied.push(op);
153
+ break;
154
+ }
155
+ case 'remove': {
156
+ const target = findSceneComponent(scene, { refid: op.refid });
157
+ if (!target || !target.parent) {
158
+ missed.push(op);
159
+ break;
160
+ }
161
+ // 삭제 직전 model 캡처 → inverse 는 add(savedModel)
162
+ const savedModel = JSON.parse(JSON.stringify(target.model));
163
+ // scene.remove() 는 selected 를 사용하므로 일시적으로 대상으로 set.
164
+ const prevSelected = scene.selected ?? [];
165
+ scene.selected = [target];
166
+ scene.remove(); // CommandMigrate 통한 정상 경로 → snapshot 자동
167
+ // 선택 복원 — 삭제 대상은 제외
168
+ scene.selected = prevSelected.filter((c) => c !== target);
169
+ inverseOps.push({ op: 'add', component: savedModel });
170
+ applied.push(op);
171
+ break;
172
+ }
173
+ case 'modify': {
174
+ const target = findSceneComponent(scene, { refid: op.refid });
175
+ if (!target) {
176
+ missed.push(op);
177
+ break;
178
+ }
179
+ // 변경 전 patch 가 건드린 키만 보관 — inverse 는 같은 키들을 원본 값으로 modify
180
+ const oldValues = this.captureOldKeys(target.model, op.patch);
181
+ // op.patch 의 nested 값 보존 위해 model 위에 deep-merge (apply-patch 와 동일 정책)
182
+ const merged = mergeComponent(target.model, op.patch);
183
+ target.set(merged);
184
+ // change 는 자동 snapshot 하지 않으므로 commander.execute 호출 → snapshot push
185
+ scene.commander?.execute(null, false);
186
+ inverseOps.push({ op: 'modify', refid: op.refid, patch: oldValues });
187
+ applied.push(op);
188
+ break;
189
+ }
190
+ case 'modifyBoard': {
191
+ // 보드 root 속성 (fillStyle / width / height / name 등) 변경.
192
+ // scene.root 는 model_layer — 보드 JSON 의 최상위. 자식 components 와 별개.
193
+ const root = scene.root;
194
+ if (!root || typeof root.set !== 'function') {
195
+ missed.push(op);
196
+ break;
197
+ }
198
+ const cleanPatch = { ...(op.patch || {}) };
199
+ delete cleanPatch.components; // 자식 변경은 별도 op
200
+ const oldValues = this.captureOldKeys(root.model, cleanPatch);
201
+ const merged = mergeComponent(root.model, cleanPatch);
202
+ root.set(merged);
203
+ scene.commander?.execute(null, false);
204
+ inverseOps.push({ op: 'modifyBoard', patch: oldValues });
205
+ applied.push(op);
206
+ break;
207
+ }
208
+ // 'replace' 는 위에서 분기됨 — 여기 도달 안 함
209
+ }
210
+ }
211
+ // patch 단위로 inverse 보관 — Revert 시 역순 적용
212
+ if (patchId && inverseOps.length > 0) {
213
+ this.patchInverses.set(patchId, inverseOps);
214
+ }
215
+ if (missed.length > 0) {
216
+ const missedDesc = missed
217
+ .map((op) => {
218
+ if (op.op === 'modify' || op.op === 'remove')
219
+ return `refid=${op.refid}`;
220
+ if (op.op === 'modifyBoard')
221
+ return 'modifyBoard (scene root unavailable)';
222
+ return op.op;
223
+ })
224
+ .join(', ');
225
+ notify('warn', `AI 가 ${missed.length}개의 변경을 시도했지만 적용되지 않았습니다: ${missedDesc}`);
226
+ }
227
+ }
228
+ catch (ex) {
229
+ notifyError(ex);
230
+ }
231
+ };
232
+ /**
233
+ * 채팅에서 board-edit-revert 도착 — 누적된 inverse 를 역순 in-place 적용.
234
+ *
235
+ * - 메모리상 patchInverses Map 만 본다. 페이지 reload 후엔 inverse 가 없어
236
+ * 되돌릴 수 없음 (사용자에게 안내).
237
+ * - 성공 시 서버 revertPatch mutation 호출 → PatchEntry.reverted=true 영속.
238
+ * - in-place 적용이라 undo/dirty/selection 보존 (chatViaTools 의 모든 강점 그대로).
239
+ */
240
+ this.onBoardEditRevert = async (e) => {
241
+ const { patchId } = e.detail || {};
242
+ if (!patchId)
243
+ return;
244
+ const inverses = this.patchInverses.get(patchId);
245
+ if (!inverses || inverses.length === 0) {
246
+ notify('warn', '이 변경은 자동 되돌릴 수 없습니다 (페이지 새로고침 이후 적용된 변경은 메모리에 inverse 가 없습니다).');
247
+ return;
248
+ }
249
+ const scene = this.scene;
250
+ if (!scene) {
251
+ notifyError(new Error('Scene not initialized'));
252
+ return;
253
+ }
254
+ try {
255
+ // 역순 적용 — 마지막 op 부터 되돌림. 단일 patch 안에서도 순서 의미 있음
256
+ // (예: add 후 modify 한 시퀀스를 되돌리려면 modify(원본) 후 remove)
257
+ const reversed = [...inverses].reverse();
258
+ const reverseFakePatch = {
259
+ ops: reversed,
260
+ summary: 'revert',
261
+ confidence: 1
262
+ };
263
+ // hasReplace 면 wholesale, 그렇지 않으면 in-place — 일반 onBoardEditPatch 와 동일
264
+ const hasReplace = reversed.some(op => op.op === 'replace');
265
+ if (hasReplace) {
266
+ this.applyPatchWholesale(reverseFakePatch);
267
+ }
268
+ else {
269
+ // in-place 직접 적용 — 새 inverse 누적은 안 함 (revert 의 revert 는 redo, 별개 기능)
270
+ this.applyInverseOpsInPlace(scene, reversed);
271
+ }
272
+ this.patchInverses.delete(patchId);
273
+ await this.recordRevertOnServer(patchId);
274
+ }
275
+ catch (ex) {
276
+ notifyError(ex);
277
+ }
278
+ };
31
279
  components.forEach(({ templates = [], groups = [] }) => {
32
280
  groups.forEach(group => BoardModeller.registerGroup(group));
33
281
  BoardModeller.registerTemplate(templates);
34
282
  });
35
283
  /* 컴포넌트에서 정의된 에디터들을 MODELLER_EDITORS에 등록 */
36
- var addedEditors = {};
37
- for (let component in components) {
284
+ const addedEditors = {};
285
+ for (const component in components) {
38
286
  let { editors } = components[component];
39
287
  editors &&
40
288
  editors.forEach(editor => {
@@ -48,16 +296,53 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
48
296
  css `
49
297
  :host {
50
298
  display: flex;
51
- flex-direction: column;
299
+ flex-direction: row;
52
300
 
53
301
  overflow: hidden;
54
302
  position: relative;
55
303
  }
56
304
 
305
+ .modeller-area {
306
+ flex: 1;
307
+ min-width: 0;
308
+ display: flex;
309
+ flex-direction: column;
310
+ position: relative;
311
+ }
312
+
57
313
  ox-board-modeller {
58
314
  flex: 1;
59
315
  }
60
316
 
317
+ .ai-toggle {
318
+ position: absolute;
319
+ right: 12px;
320
+ bottom: 12px;
321
+ z-index: 10;
322
+ padding: 8px 14px;
323
+ border: 0;
324
+ border-radius: 24px;
325
+ background: #2563eb;
326
+ color: #fff;
327
+ font: 500 13px/1.4 system-ui, -apple-system, sans-serif;
328
+ cursor: pointer;
329
+ box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35);
330
+ transition: background 0.15s, transform 0.1s;
331
+ }
332
+ .ai-toggle:hover { background: #1d4ed8; }
333
+ .ai-toggle:active { transform: scale(0.97); }
334
+ .ai-toggle.open { background: #475569; }
335
+ .ai-toggle.open:hover { background: #334155; }
336
+
337
+ .ai-panel {
338
+ flex: 0 0 380px;
339
+ display: flex;
340
+ border-left: 1px solid #e2e8f0;
341
+ background: #ffffff;
342
+ }
343
+
344
+ ox-board-ai-chat { flex: 1; }
345
+
61
346
  ox-oops-note {
62
347
  display: block;
63
348
  position: absolute;
@@ -65,11 +350,119 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
65
350
  top: 50%;
66
351
  transform: translate(-50%, -50%);
67
352
  }
353
+
354
+ @media (max-width: 900px) {
355
+ .ai-panel {
356
+ flex-basis: 100%;
357
+ position: absolute;
358
+ inset: 0;
359
+ z-index: 20;
360
+ }
361
+ }
68
362
  `
69
363
  ]; }
364
+ get knownTypes() {
365
+ if (!this._knownTypesCache) {
366
+ const types = new Set();
367
+ for (const group of BoardModeller.groups || []) {
368
+ for (const t of group.templates || []) {
369
+ if (t && typeof t.type === 'string')
370
+ types.add(t.type);
371
+ }
372
+ }
373
+ this._knownTypesCache = Array.from(types).sort();
374
+ }
375
+ return this._knownTypesCache;
376
+ }
377
+ get knownCategories() {
378
+ if (!this._knownCategoriesCache) {
379
+ const cats = new Set();
380
+ for (const group of BoardModeller.groups || []) {
381
+ for (const t of group.templates || []) {
382
+ if (t && typeof t.group === 'string')
383
+ cats.add(t.group);
384
+ }
385
+ }
386
+ this._knownCategoriesCache = Array.from(cats).sort();
387
+ }
388
+ return this._knownCategoriesCache;
389
+ }
390
+ get componentSchemas() {
391
+ if (!this._componentSchemasCache) {
392
+ const out = [];
393
+ const NON_PROPS = new Set(['type', 'id', 'name', 'class']);
394
+ // 좌표/크기/경로 관련 키들 — type 마다 다른 조합으로 사용
395
+ const GEOMETRY_KEYS = new Set([
396
+ 'left',
397
+ 'top',
398
+ 'right',
399
+ 'bottom',
400
+ 'x',
401
+ 'y',
402
+ 'cx',
403
+ 'cy',
404
+ 'width',
405
+ 'height',
406
+ 'rx',
407
+ 'ry',
408
+ 'radius',
409
+ 'rotation',
410
+ 'translate',
411
+ 'scale',
412
+ 'points',
413
+ 'path',
414
+ 'vertices',
415
+ 'verticles',
416
+ 'startX',
417
+ 'startY',
418
+ 'endX',
419
+ 'endY',
420
+ 'startPoint',
421
+ 'endPoint'
422
+ ]);
423
+ for (const group of BoardModeller.groups || []) {
424
+ for (const t of group.templates || []) {
425
+ if (!t || typeof t.type !== 'string')
426
+ continue;
427
+ const properties = {};
428
+ const geometryKeys = [];
429
+ const model = t.model || {};
430
+ for (const key of Object.keys(model)) {
431
+ if (NON_PROPS.has(key))
432
+ continue;
433
+ if (GEOMETRY_KEYS.has(key)) {
434
+ geometryKeys.push(key);
435
+ continue;
436
+ }
437
+ const v = model[key];
438
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
439
+ properties[key] = Object.fromEntries(Object.keys(v)
440
+ .slice(0, 8)
441
+ .map(k => [k, null]));
442
+ }
443
+ else if (Array.isArray(v)) {
444
+ properties[key] = [];
445
+ }
446
+ else {
447
+ properties[key] = v;
448
+ }
449
+ }
450
+ out.push({
451
+ type: t.type,
452
+ description: t.description || undefined,
453
+ group: t.group || undefined,
454
+ geometryKeys: geometryKeys.length > 0 ? geometryKeys : undefined,
455
+ properties: Object.keys(properties).length > 0 ? properties : undefined
456
+ });
457
+ }
458
+ }
459
+ this._componentSchemasCache = out;
460
+ }
461
+ return this._componentSchemasCache;
462
+ }
70
463
  get context() {
71
464
  return {
72
- title: this.board ? this.boardName : this.preparing ? 'Fetching board...' : 'Board Not Found',
465
+ title: this.board ? this.boardName : this.preparing ? 'Fetching board...' : '',
73
466
  help: 'board-modeller/modeller',
74
467
  widebleed: true
75
468
  };
@@ -88,9 +481,14 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
88
481
  return;
89
482
  }
90
483
  try {
484
+ // 이전 보드 완전히 클리어
485
+ this.board = null;
486
+ this.model = null;
487
+ this._loadTracker = new LoadTracker();
488
+ this._loadTracker.setPhase('fetch');
91
489
  this.preparing = true;
92
490
  this.updateContext();
93
- var response = await client.query({
491
+ const response = await client.query({
94
492
  query: gql `
95
493
  query FetchBoardById($id: String!) {
96
494
  board(id: $id) {
@@ -103,10 +501,11 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
103
501
  variables: { id: this.boardId },
104
502
  context: gqlContext()
105
503
  });
106
- var board = response.data.board;
504
+ this._loadTracker?.setPhase('parse');
505
+ const board = response.data.board;
107
506
  if (!board) {
108
507
  this.board = null;
109
- throw 'board not found';
508
+ throw new Error('board not found');
110
509
  }
111
510
  this.board = {
112
511
  ...board,
@@ -116,27 +515,247 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
116
515
  this.model = {
117
516
  ...this.board.model
118
517
  };
518
+ // AI 협력 세션 — 패널이 열릴 때 lazy 로드 (보드 단순 열기로는 세션 자동 생성 X)
519
+ this.chatSessionId = undefined;
119
520
  }
120
521
  catch (ex) {
121
- document.dispatchEvent(new CustomEvent('notify', {
122
- detail: {
123
- level: 'error',
124
- message: ex,
125
- ex
126
- }
127
- }));
522
+ notifyError(ex);
128
523
  }
129
524
  finally {
130
525
  this.preparing = false;
131
526
  this.updateContext();
132
527
  }
133
528
  }
529
+ /** 패널 토글 시 호출 — 열 때만 chatSession 보장 */
530
+ async toggleAIPanel() {
531
+ const willOpen = !this.aiPanelOpen;
532
+ if (willOpen && this.boardId && !this.chatSessionId) {
533
+ await this.ensureChatSession();
534
+ }
535
+ this.aiPanelOpen = willOpen;
536
+ }
537
+ /** boardId 로 ChatSession 조회 또는 생성 (idempotent). */
538
+ async ensureChatSession() {
539
+ if (!this.boardId || this.chatSessionId)
540
+ return;
541
+ try {
542
+ const found = await client.query({
543
+ query: gql `
544
+ query ChatSessionByBoard($boardId: String!) {
545
+ chatSessionByBoard(boardId: $boardId) { id }
546
+ }
547
+ `,
548
+ variables: { boardId: this.boardId },
549
+ context: gqlContext(),
550
+ fetchPolicy: 'network-only'
551
+ });
552
+ if (found.data?.chatSessionByBoard?.id) {
553
+ this.chatSessionId = found.data.chatSessionByBoard.id;
554
+ return;
555
+ }
556
+ const created = await client.mutate({
557
+ mutation: gql `
558
+ mutation StartBoardAISession($boardId: String!) {
559
+ startBoardAISession(boardId: $boardId) { id }
560
+ }
561
+ `,
562
+ variables: { boardId: this.boardId },
563
+ context: gqlContext()
564
+ });
565
+ this.chatSessionId = created.data?.startBoardAISession?.id;
566
+ }
567
+ catch (ex) {
568
+ notifyError(ex);
569
+ }
570
+ }
571
+ /**
572
+ * 모델러의 selected (things-scene Component 인스턴스 배열) 에서 refid 추출.
573
+ *
574
+ * refid 는 things-scene 이 모든 컴포넌트에 자동 발급하는 universal numeric handle.
575
+ * AI 의 selection 표현은 항상 refid 기반 (id 는 데이터 바인딩 이름이며 unique 가
576
+ * 아니라 targeting 에 부적합).
577
+ */
578
+ extractSelectedRefids() {
579
+ const sel = this.selected || [];
580
+ return sel
581
+ .map((c) => {
582
+ if (!c || typeof c.get !== 'function')
583
+ return null;
584
+ const refid = c.get('refid');
585
+ return typeof refid === 'number' && Number.isFinite(refid) ? refid : null;
586
+ })
587
+ .filter((x) => x !== null);
588
+ }
589
+ /**
590
+ * 라이브 보드 모델 — things-scene 캔버스가 들고 있는 가장 최신 상태의 deep clone.
591
+ *
592
+ * 사용자 수작업 편집은 scene.model 에만 반영되고 this.model property 에는 자동
593
+ * 동기화되지 않는다. AI 한테 보드 상태를 넘기거나 patch 를 적용할 때는 반드시
594
+ * 이쪽을 쓴다.
595
+ *
596
+ * id 와 refid 는 별개 필드 — 컴포넌트는 model.id (선택, 사용자/AI 가 명시 설정)
597
+ * 와 model.refid (필수, things-scene 자동 발급) 를 둘 다 가질 수 있고 AI 가
598
+ * 양쪽을 구별해서 다룬다 (tool 인자가 분리됨). 따라서 여기서는 model 을 mutate
599
+ * 하지 않고 raw deep clone 만 반환.
600
+ */
601
+ getLiveBoard() {
602
+ const sceneModel = this.scene?.model;
603
+ if (sceneModel) {
604
+ return JSON.parse(JSON.stringify(sceneModel));
605
+ }
606
+ return this.model;
607
+ }
608
+ /**
609
+ * 'replace' op 적용 — scene 통째로 재생성 (undo 히스토리 초기화).
610
+ *
611
+ * AI 가 보드 전체 재구성을 요청한 경우. 사용자에게 history 손실 안내.
612
+ * board-import 데이터 wholesale 적용 등 드문 케이스에 한정.
613
+ *
614
+ * Revert 지원 — replace 직전 보드 model 을 inverse 로 보관 (또 다른 replace).
615
+ */
616
+ applyPatchWholesale(patch, patchId) {
617
+ try {
618
+ const baseBoard = this.getLiveBoard();
619
+ const inverseOps = baseBoard
620
+ ? [{ op: 'replace', board: JSON.parse(JSON.stringify(baseBoard)) }]
621
+ : [];
622
+ const report = applyBoardEditPatchVerbose(baseBoard, patch);
623
+ const next = report.board;
624
+ if (report.applied.length === 0)
625
+ return;
626
+ const normalized = (next.components || []).map((c) => this.normalizeComponent(c));
627
+ this.model = {
628
+ ...(baseBoard || {}),
629
+ ...(next.width !== undefined && { width: next.width }),
630
+ ...(next.height !== undefined && { height: next.height }),
631
+ components: normalized
632
+ };
633
+ if (patchId && inverseOps.length > 0) {
634
+ this.patchInverses.set(patchId, inverseOps);
635
+ }
636
+ notify('info', '보드 전체가 교체되어 실행 취소(undo) 히스토리가 초기화되었습니다.');
637
+ }
638
+ catch (ex) {
639
+ notifyError(ex);
640
+ }
641
+ }
642
+ /**
643
+ * scene 의 모든 컴포넌트 refid 수집 — add 의 inverse 계산용.
644
+ *
645
+ * scene.add 직전/직후 호출해 차집합으로 새 발급된 refid 식별.
646
+ */
647
+ collectAllRefids(scene) {
648
+ const refids = [];
649
+ const map = scene?.rootContainer?.refidIndexMap;
650
+ if (map && typeof map.forEach === 'function') {
651
+ map.forEach((_, refid) => refids.push(refid));
652
+ }
653
+ return refids;
654
+ }
655
+ /**
656
+ * patch 가 변경하려는 키들에 대해 model 의 현재 값을 deep-clone 으로 캡처.
657
+ * inverse modify / modifyBoard op 의 patch 로 사용.
658
+ */
659
+ captureOldKeys(model, patch) {
660
+ const out = {};
661
+ for (const k of Object.keys(patch || {})) {
662
+ const v = model?.[k];
663
+ out[k] = v === undefined ? null : JSON.parse(JSON.stringify(v));
664
+ }
665
+ return out;
666
+ }
667
+ /**
668
+ * inverse op 시퀀스를 things-scene 위에 in-place 적용 (snapshot/undo 보존).
669
+ * onBoardEditPatch 의 add/remove/modify/modifyBoard 분기와 동일 패턴이지만
670
+ * inverse 누적은 하지 않음 (revert 자체가 revert 가능하지 않은 단방향 동작).
671
+ */
672
+ applyInverseOpsInPlace(scene, ops) {
673
+ for (const op of ops) {
674
+ switch (op.op) {
675
+ case 'add': {
676
+ const normalized = this.normalizeComponent(op.component);
677
+ scene.add(normalized, {});
678
+ break;
679
+ }
680
+ case 'remove': {
681
+ const target = findSceneComponent(scene, { refid: op.refid });
682
+ if (!target || !target.parent)
683
+ break;
684
+ const prevSelected = scene.selected ?? [];
685
+ scene.selected = [target];
686
+ scene.remove();
687
+ scene.selected = prevSelected.filter((c) => c !== target);
688
+ break;
689
+ }
690
+ case 'modify': {
691
+ const target = findSceneComponent(scene, { refid: op.refid });
692
+ if (!target)
693
+ break;
694
+ const merged = mergeComponent(target.model, op.patch);
695
+ target.set(merged);
696
+ scene.commander?.execute(null, false);
697
+ break;
698
+ }
699
+ case 'modifyBoard': {
700
+ const root = scene.root;
701
+ if (!root || typeof root.set !== 'function')
702
+ break;
703
+ const cleanPatch = { ...(op.patch || {}) };
704
+ delete cleanPatch.components;
705
+ const merged = mergeComponent(root.model, cleanPatch);
706
+ root.set(merged);
707
+ scene.commander?.execute(null, false);
708
+ break;
709
+ }
710
+ // 'replace' 는 호출자가 wholesale 분기로 보냄
711
+ }
712
+ }
713
+ }
714
+ /** 서버 PatchEntry.reverted=true 영속 — fail 해도 UI 는 이미 되돌렸으니 warn 만. */
715
+ async recordRevertOnServer(patchId) {
716
+ try {
717
+ await client.mutate({ mutation: REVERT_PATCH_MUTATION, variables: { patchId } });
718
+ }
719
+ catch (e) {
720
+ console.warn('[board-modeller] revertPatch server persist failed:', e?.message ?? e);
721
+ }
722
+ }
723
+ /**
724
+ * AI 가 만든 컴포넌트를 things-scene 이 기대하는 완전한 형태로 normalize.
725
+ * BoardModeller 에 등록된 template.model (default 값) 위에 AI 응답을 덮어쓴다.
726
+ * 이 단계 없이 things-scene 에 넣으면 필수 속성 누락 시 render 에서 crash 가능
727
+ * (예: gauge-circle 의 value 가 undefined → toString() TypeError).
728
+ */
729
+ normalizeComponent(c) {
730
+ if (!c || typeof c !== 'object' || typeof c.type !== 'string')
731
+ return c;
732
+ const template = this.findTemplateByType(c.type);
733
+ if (!template || !template.model)
734
+ return c;
735
+ return mergeDefaultsDeep(template.model, c);
736
+ }
737
+ findTemplateByType(type) {
738
+ for (const group of BoardModeller.groups || []) {
739
+ for (const t of group.templates || []) {
740
+ if (t && t.type === type)
741
+ return t;
742
+ }
743
+ }
744
+ return undefined;
745
+ }
134
746
  async updated(changes) {
135
747
  if (changes.has('boardId')) {
136
- if (await hasPrivilege({ privilege: 'mutation', category: 'board' })) {
137
- this.refresh();
748
+ try {
749
+ if (await hasPrivilege({ privilege: 'mutation', category: 'board' })) {
750
+ this.refresh();
751
+ }
752
+ else {
753
+ this.boardId = null;
754
+ this.model = null;
755
+ this.modeller?.close();
756
+ }
138
757
  }
139
- else {
758
+ catch {
140
759
  this.boardId = null;
141
760
  this.model = null;
142
761
  this.modeller?.close();
@@ -149,46 +768,81 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
149
768
  }
150
769
  else {
151
770
  this.boardId = null;
771
+ this.board = null;
152
772
  this.model = null;
773
+ this.preparing = false;
153
774
  this.modeller?.close();
775
+ this.updateContext();
154
776
  }
155
777
  }
156
778
  render() {
157
- var oops = !this.preparing && !this.model && this.oopsNote;
158
- return oops
159
- ? html ` <ox-oops-note icon=${oops.icon} title=${oops.title} description=${oops.description}></ox-oops-note> `
779
+ const oops = !this.preparing && !this.model && this.oopsNote;
780
+ return html `
781
+ <div class="modeller-area">
782
+ ${oops
783
+ ? html `<ox-oops-note icon=${oops.icon} title=${oops.title} description=${oops.description}></ox-oops-note>`
160
784
  : html `
161
- <ox-board-modeller
162
- .mode=${this.mode}
163
- @mode-changed=${e => {
785
+ <ox-board-modeller
786
+ .mode=${this.mode}
787
+ .loadTracker=${this._loadTracker}
788
+ @mode-changed=${e => {
164
789
  this.mode = e.detail.value;
165
790
  }}
166
- .model=${this.model}
167
- @model-changed=${e => {
791
+ .model=${this.model}
792
+ @model-changed=${e => {
168
793
  this.model = e.detail.value;
169
794
  }}
170
- .scene=${this.scene}
171
- @scene-changed=${e => {
795
+ .scene=${this.scene}
796
+ @scene-changed=${e => {
172
797
  this.scene = e.detail.value;
173
798
  }}
174
- .selected=${this.selected}
175
- @selected-changed=${e => {
799
+ .selected=${this.selected}
800
+ @selected-changed=${e => {
176
801
  this.selected = e.detail.value;
177
802
  }}
178
- .provider=${provider}
179
- @save-model=${e => this.saveBoard()}
180
- .componentGroupList=${this.componentGroupList}
181
- .fonts=${this._fontCtrl.fonts}
182
- .hideProperty=${this.hideProperty}
183
- >
184
- </ox-board-modeller>
185
- `;
803
+ .provider=${provider}
804
+ @save-model=${e => this.saveBoard()}
805
+ .componentGroupList=${this.componentGroupList}
806
+ .fonts=${this._fontCtrl.fonts}
807
+ .hideProperty=${this.hideProperty}
808
+ >
809
+ </ox-board-modeller>
810
+ `}
811
+ ${this.boardId
812
+ ? html `
813
+ <button
814
+ class="ai-toggle ${this.aiPanelOpen ? 'open' : ''}"
815
+ title="${i18next.t('text.ai_assistant') || 'AI Assistant'}"
816
+ @click=${this.toggleAIPanel}>
817
+ ${this.aiPanelOpen ? '✕ AI' : '✨ AI'}
818
+ </button>
819
+ `
820
+ : nothing}
821
+ </div>
822
+ ${this.aiPanelOpen
823
+ ? html `
824
+ <div class="ai-panel">
825
+ <ox-board-ai-chat
826
+ .sessionId=${this.chatSessionId}
827
+ .currentBoard=${this.model}
828
+ .boardProvider=${() => this.getLiveBoard()}
829
+ .knownTypes=${this.knownTypes}
830
+ .categories=${this.knownCategories}
831
+ .componentSchemas=${this.componentSchemas}
832
+ .selectedRefids=${this.extractSelectedRefids()}
833
+ @board-edit-patch=${this.onBoardEditPatch}
834
+ @board-edit-revert=${this.onBoardEditRevert}>
835
+ </ox-board-ai-chat>
836
+ </div>
837
+ `
838
+ : nothing}
839
+ `;
186
840
  }
187
841
  async updateBoard() {
188
842
  try {
189
843
  this.preparing = true;
190
- var { id, name, description, groupId } = this.board;
191
- var model = JSON.stringify(this.scene.model);
844
+ const { id, name, description, groupId } = this.board;
845
+ const model = JSON.stringify(this.scene.model);
192
846
  await client.mutate({
193
847
  mutation: gql `
194
848
  mutation UpdateBoard($id: String!, $patch: BoardPatch!) {
@@ -203,21 +857,10 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
203
857
  },
204
858
  context: gqlContext()
205
859
  });
206
- document.dispatchEvent(new CustomEvent('notify', {
207
- detail: {
208
- level: 'info',
209
- message: i18next.t('text.saved')
210
- }
211
- }));
860
+ notify('info', i18next.t('text.saved'));
212
861
  }
213
862
  catch (ex) {
214
- document.dispatchEvent(new CustomEvent('notify', {
215
- detail: {
216
- level: 'error',
217
- message: ex,
218
- ex: ex
219
- }
220
- }));
863
+ notifyError(ex);
221
864
  }
222
865
  finally {
223
866
  this.preparing = false;
@@ -284,6 +927,14 @@ __decorate([
284
927
  property({ type: Boolean }),
285
928
  __metadata("design:type", Boolean)
286
929
  ], BoardModellerPage.prototype, "preparing", void 0);
930
+ __decorate([
931
+ state(),
932
+ __metadata("design:type", String)
933
+ ], BoardModellerPage.prototype, "chatSessionId", void 0);
934
+ __decorate([
935
+ state(),
936
+ __metadata("design:type", Object)
937
+ ], BoardModellerPage.prototype, "aiPanelOpen", void 0);
287
938
  __decorate([
288
939
  query('ox-board-modeller'),
289
940
  __metadata("design:type", BoardModeller)