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

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.
@@ -7,17 +7,20 @@ import gql from 'graphql-tag';
7
7
  import { css, html, nothing } from 'lit';
8
8
  import { customElement, property, query, state } from 'lit/decorators.js';
9
9
  import { BoardModeller } from '@operato/board/ox-board-modeller.js';
10
- import { LoadTracker } from '@hatiolab/things-scene';
10
+ import { LoadTracker, SceneSearchEngine } from '@hatiolab/things-scene';
11
11
  import { OxPropertyEditor } from '@operato/property-editor';
12
12
  import { PageView, FontController } from '@operato/shell';
13
13
  import { hasPrivilege } from '@things-factory/auth-base/dist-client/index.js';
14
- import { applyBoardEditPatchVerbose, mergeComponent } from '@things-factory/board-ai';
14
+ import { applyBoardEditPatchVerbose } from '@things-factory/board-ai';
15
15
  import { client, gqlContext } from '@operato/graphql';
16
16
  import { i18next } from '@operato/i18n';
17
17
  import { OxPrompt } from '@operato/popup/ox-prompt.js';
18
18
  import { provider } from '../board-provider.js';
19
19
  import components from './things-scene-components-with-tools.import';
20
20
  import { notify, notifyError } from '../utils/notify-helper.js';
21
+ import { findSceneComponent, dispatchBoardAction } from './board-action-dispatch.js';
22
+ import { dispatchBoardEditOp } from './board-edit-dispatch.js';
23
+ export { findSceneComponent };
21
24
  const NOOP = () => { };
22
25
  /** 서버 PatchEntry.reverted=true 플래그 영속용 mutation. */
23
26
  const REVERT_PATCH_MUTATION = gql `
@@ -25,27 +28,6 @@ const REVERT_PATCH_MUTATION = gql `
25
28
  revertPatch(patchId: $patchId)
26
29
  }
27
30
  `;
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
31
  /**
50
32
  * default 위에 override 를 깊게 merge.
51
33
  * - override 의 키 값이 undefined 가 아닌 경우 우선 적용
@@ -90,6 +72,8 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
90
72
  this.overlay = null;
91
73
  this.scene = null;
92
74
  this.componentGroupList = BoardModeller.groups;
75
+ /** 보드의 모든 ChatSession (탭으로 표시). */
76
+ this.chatSessions = [];
93
77
  /** AI 채팅 패널 열림 상태 (기본 닫힘) */
94
78
  this.aiPanelOpen = false;
95
79
  /**
@@ -102,6 +86,108 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
102
86
  this.patchInverses = new Map();
103
87
  this.board = null;
104
88
  this._fontCtrl = new FontController(this);
89
+ /** `@` mention popup 의 도메인 사용자 후보 — chat 의 userProvider prop 으로 wire.
90
+ * query 변할 때마다 호출되므로 server query (`domainUsersForMention`) 를 그대로 위임. */
91
+ this.fetchDomainUsersForMention = async (query) => {
92
+ try {
93
+ const result = await client.query({
94
+ query: gql `
95
+ query DomainUsersForMention($query: String, $limit: Int) {
96
+ domainUsersForMention(query: $query, limit: $limit) {
97
+ id
98
+ name
99
+ email
100
+ }
101
+ }
102
+ `,
103
+ variables: { query: query || null, limit: 50 },
104
+ context: gqlContext(),
105
+ fetchPolicy: 'cache-first'
106
+ });
107
+ return result.data?.domainUsersForMention ?? [];
108
+ }
109
+ catch (ex) {
110
+ // 조용히 — chat panel 이 빈 결과로 처리
111
+ console.warn('[board-modeller-page] fetchDomainUsersForMention failed:', ex);
112
+ return [];
113
+ }
114
+ };
115
+ /** 채팅 패널의 탭 클릭 — 활성 세션 전환. */
116
+ this.onChatSessionSwitch = (e) => {
117
+ const newId = e.detail?.sessionId;
118
+ if (typeof newId === 'string' && newId !== this.chatSessionId) {
119
+ this.chatSessionId = newId;
120
+ }
121
+ };
122
+ /** 채팅 패널의 "+" 버튼 — 새 세션 생성 후 활성으로 전환. */
123
+ this.onChatSessionCreate = async () => {
124
+ if (!this.boardId)
125
+ return;
126
+ try {
127
+ const created = await client.mutate({
128
+ mutation: gql `
129
+ mutation CreateChatSession($boardId: String!, $name: String) {
130
+ createChatSession(boardId: $boardId, name: $name) {
131
+ id
132
+ name
133
+ createdAt
134
+ }
135
+ }
136
+ `,
137
+ variables: { boardId: this.boardId, name: null },
138
+ context: gqlContext()
139
+ });
140
+ const s = created.data?.createChatSession;
141
+ if (s) {
142
+ this.chatSessions = [...this.chatSessions, s];
143
+ this.chatSessionId = s.id;
144
+ }
145
+ }
146
+ catch (ex) {
147
+ notifyError(ex);
148
+ }
149
+ };
150
+ /**
151
+ * AI 채팅 mention popup (`#`) 검색 위임 — things-scene SceneSearchEngine 으로 wrap.
152
+ *
153
+ * F-key Finder 와 동일한 검색 룰 (id → name → tag → type → class → text → data → property)
154
+ * 을 그대로 mention popup 에 재사용. 컴포넌트 트리를 직접 순회하므로 model JSON 으로
155
+ * 변환할 필요 없음 — 일관성 + 성능 이점.
156
+ *
157
+ * 반환 구조 (FilterResult) 는 chat 의 mention-popup 표시 모델. host 는 things-scene
158
+ * Component 에서 refid/id/type/tag/class/text 를 뽑아 candidate 로 채워 넘긴다.
159
+ */
160
+ this.searchSceneForMention = (() => {
161
+ const engine = new SceneSearchEngine();
162
+ return (query) => {
163
+ const root = this.scene?.root;
164
+ if (!root)
165
+ return [];
166
+ const results = engine.search(root, query);
167
+ return results.map(r => {
168
+ const c = r.component;
169
+ const refid = typeof c.get === 'function' ? c.get('refid') : undefined;
170
+ const id = typeof c.get === 'function' ? c.get('id') : undefined;
171
+ const type = typeof c.get === 'function' ? c.get('type') : undefined;
172
+ const tag = typeof c.get === 'function' ? c.get('tag') : undefined;
173
+ const cls = typeof c.get === 'function' ? c.get('class') : undefined;
174
+ const text = typeof c.get === 'function' ? c.get('text') : undefined;
175
+ return {
176
+ candidate: {
177
+ refid: typeof refid === 'number' ? refid : undefined,
178
+ label: id || type || 'unknown',
179
+ type: typeof type === 'string' ? type : 'unknown',
180
+ id: typeof id === 'string' && id ? id : undefined,
181
+ tag: typeof tag === 'string' && tag ? tag : undefined,
182
+ class: typeof cls === 'string' && cls ? cls : undefined,
183
+ text: typeof text === 'string' && text ? text : undefined
184
+ },
185
+ matchType: r.matchType,
186
+ matchValue: r.matchValue
187
+ };
188
+ });
189
+ };
190
+ })();
105
191
  /**
106
192
  * 채팅에서 patch 도착 — things-scene 에 in-place 적용.
107
193
  *
@@ -128,84 +214,18 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
128
214
  return;
129
215
  }
130
216
  try {
131
- const applied = [];
132
217
  const missed = [];
133
- // 각 op 의 inverse — patch 적용 직전/직후 scene 상태 기준으로 계산해 누적.
134
- // 나중에 onBoardEditRevert 가 역순 in-place 적용으로 복원.
218
+ // 각 op 의 inverse — dispatcher 적용 직전/직후 scene 상태로 계산해 반환.
219
+ // 누적했다가 onBoardEditRevert 가 역순 in-place 적용으로 복원.
135
220
  const inverseOps = [];
221
+ const normalize = (c) => this.normalizeComponent(c);
136
222
  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' 는 위에서 분기됨 — 여기 도달 안 함
223
+ const r = dispatchBoardEditOp(scene, op, { normalize });
224
+ if (r.applied) {
225
+ inverseOps.push(...r.inverseOps);
226
+ }
227
+ else {
228
+ missed.push(op);
209
229
  }
210
230
  }
211
231
  // patch 단위로 inverse 보관 — Revert 시 역순 적용
@@ -276,6 +296,34 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
276
296
  notifyError(ex);
277
297
  }
278
298
  };
299
+ /**
300
+ * AI 가 ephemeral scene action (selection / view / mode) 을 실행하라고 요청.
301
+ *
302
+ * board-edit-patch 와 별개 채널 — 모델 변경 X, undo 영향 X, dirty flag 무관.
303
+ * things-scene API 직접 호출. 잘못된 action / 누락된 컴포넌트는 console.warn 만.
304
+ */
305
+ this.onBoardActionExecute = (e) => {
306
+ const { actions } = e.detail || {};
307
+ if (!Array.isArray(actions) || actions.length === 0)
308
+ return;
309
+ const scene = this.scene;
310
+ if (!scene)
311
+ return;
312
+ let modeChanged = false;
313
+ for (const action of actions) {
314
+ try {
315
+ dispatchBoardAction(scene, action);
316
+ if (action?.action === 'setSceneMode')
317
+ modeChanged = true;
318
+ }
319
+ catch (ex) {
320
+ console.warn('[board-modeller] action execute failed:', action, ex?.message ?? ex);
321
+ }
322
+ }
323
+ // setSceneMode 가 있었으면 page 의 reactive mode 도 동기 (Lit re-render 트리거)
324
+ if (modeChanged)
325
+ this.mode = scene.mode;
326
+ };
279
327
  components.forEach(({ templates = [], groups = [] }) => {
280
328
  groups.forEach(group => BoardModeller.registerGroup(group));
281
329
  BoardModeller.registerTemplate(templates);
@@ -335,7 +383,7 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
335
383
  .ai-toggle.open:hover { background: #334155; }
336
384
 
337
385
  .ai-panel {
338
- flex: 0 0 380px;
386
+ flex: 0 0 320px;
339
387
  display: flex;
340
388
  border-left: 1px solid #e2e8f0;
341
389
  background: #ffffff;
@@ -515,8 +563,14 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
515
563
  this.model = {
516
564
  ...this.board.model
517
565
  };
518
- // AI 협력 세션 — 패널이 열릴 lazy 로드 (보드 단순 열기로는 세션 자동 생성 X)
566
+ // AI 협력 세션 — 보드 전환 이전 보드의 세션 / 세션 리스트 정리 (chat panel 의
567
+ // sessionId/sessions 변화 감지 → history 비우고 새로 로드). 패널 열린 채 보드 옮겼다면
568
+ // 새 보드의 세션 리스트를 즉시 ensure (fire-and-forget — 에러는 ensureChatSession 처리).
519
569
  this.chatSessionId = undefined;
570
+ this.chatSessions = [];
571
+ if (this.aiPanelOpen && this.boardId) {
572
+ this.ensureChatSession();
573
+ }
520
574
  }
521
575
  catch (ex) {
522
576
  notifyError(ex);
@@ -526,43 +580,61 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
526
580
  this.updateContext();
527
581
  }
528
582
  }
529
- /** 패널 토글 시 호출 — 열 때만 chatSession 보장 */
583
+ /** 패널 토글 시 호출 — 열 때만 chatSession 보장 (세션 리스트 로드 + 활성 세션 결정) */
530
584
  async toggleAIPanel() {
531
585
  const willOpen = !this.aiPanelOpen;
532
- if (willOpen && this.boardId && !this.chatSessionId) {
586
+ if (willOpen && this.boardId) {
533
587
  await this.ensureChatSession();
534
588
  }
535
589
  this.aiPanelOpen = willOpen;
536
590
  }
537
- /** boardId ChatSession 조회 또는 생성 (idempotent). */
591
+ /** 보드의 모든 ChatSession 목록을 로드 + 활성 세션 보장.
592
+ * - 세션이 0 개면 startBoardAISession 으로 첫 세션 생성 (단일 세션 UX 호환)
593
+ * - 활성 세션 미설정 시 첫 번째 세션을 활성으로
594
+ * - 이미 활성 세션 set 돼있고 list 에 포함되면 유지 */
538
595
  async ensureChatSession() {
539
- if (!this.boardId || this.chatSessionId)
596
+ if (!this.boardId)
540
597
  return;
541
598
  try {
542
- const found = await client.query({
599
+ const result = await client.query({
543
600
  query: gql `
544
- query ChatSessionByBoard($boardId: String!) {
545
- chatSessionByBoard(boardId: $boardId) { id }
601
+ query ChatSessionsByBoard($boardId: String!) {
602
+ chatSessionsByBoard(boardId: $boardId) {
603
+ id
604
+ name
605
+ createdAt
606
+ }
546
607
  }
547
608
  `,
548
609
  variables: { boardId: this.boardId },
549
610
  context: gqlContext(),
550
611
  fetchPolicy: 'network-only'
551
612
  });
552
- if (found.data?.chatSessionByBoard?.id) {
553
- this.chatSessionId = found.data.chatSessionByBoard.id;
554
- return;
613
+ let sessions = result.data?.chatSessionsByBoard ?? [];
614
+ if (sessions.length === 0) {
615
+ // 첫 세션 ensure (idempotent)
616
+ const created = await client.mutate({
617
+ mutation: gql `
618
+ mutation StartBoardAISession($boardId: String!) {
619
+ startBoardAISession(boardId: $boardId) {
620
+ id
621
+ name
622
+ createdAt
623
+ }
624
+ }
625
+ `,
626
+ variables: { boardId: this.boardId },
627
+ context: gqlContext()
628
+ });
629
+ const s = created.data?.startBoardAISession;
630
+ if (s)
631
+ sessions = [s];
632
+ }
633
+ this.chatSessions = sessions;
634
+ // 활성 세션 결정 — 기존 active 가 list 에 있으면 유지, 아니면 첫 번째
635
+ if (!this.chatSessionId || !sessions.some(s => s.id === this.chatSessionId)) {
636
+ this.chatSessionId = sessions[0]?.id;
555
637
  }
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
638
  }
567
639
  catch (ex) {
568
640
  notifyError(ex);
@@ -639,76 +711,14 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
639
711
  notifyError(ex);
640
712
  }
641
713
  }
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
714
  /**
668
715
  * inverse op 시퀀스를 things-scene 위에 in-place 적용 (snapshot/undo 보존).
669
- * onBoardEditPatchadd/remove/modify/modifyBoard 분기와 동일 패턴이지만
670
- * inverse 누적은 하지 않음 (revert 자체가 revert 가능하지 않은 단방향 동작).
716
+ * dispatcher 재사용 — inverse 누적은 무시 (revert revert redo, 별개 기능).
671
717
  */
672
718
  applyInverseOpsInPlace(scene, ops) {
719
+ const normalize = (c) => this.normalizeComponent(c);
673
720
  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
- }
721
+ dispatchBoardEditOp(scene, op, { normalize });
712
722
  }
713
723
  }
714
724
  /** 서버 PatchEntry.reverted=true 영속 — fail 해도 UI 는 이미 되돌렸으니 warn 만. */
@@ -824,14 +834,20 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
824
834
  <div class="ai-panel">
825
835
  <ox-board-ai-chat
826
836
  .sessionId=${this.chatSessionId}
837
+ .sessions=${this.chatSessions}
827
838
  .currentBoard=${this.model}
828
839
  .boardProvider=${() => this.getLiveBoard()}
840
+ .searchProvider=${this.searchSceneForMention}
841
+ .userProvider=${this.fetchDomainUsersForMention}
829
842
  .knownTypes=${this.knownTypes}
830
843
  .categories=${this.knownCategories}
831
844
  .componentSchemas=${this.componentSchemas}
832
845
  .selectedRefids=${this.extractSelectedRefids()}
846
+ @session-switch=${this.onChatSessionSwitch}
847
+ @session-create=${this.onChatSessionCreate}
833
848
  @board-edit-patch=${this.onBoardEditPatch}
834
- @board-edit-revert=${this.onBoardEditRevert}>
849
+ @board-edit-revert=${this.onBoardEditRevert}
850
+ @board-action-execute=${this.onBoardActionExecute}>
835
851
  </ox-board-ai-chat>
836
852
  </div>
837
853
  `
@@ -931,6 +947,10 @@ __decorate([
931
947
  state(),
932
948
  __metadata("design:type", String)
933
949
  ], BoardModellerPage.prototype, "chatSessionId", void 0);
950
+ __decorate([
951
+ state(),
952
+ __metadata("design:type", Array)
953
+ ], BoardModellerPage.prototype, "chatSessions", void 0);
934
954
  __decorate([
935
955
  state(),
936
956
  __metadata("design:type", Object)