@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.
- package/dist-client/pages/board-action-dispatch.d.ts +31 -0
- package/dist-client/pages/board-action-dispatch.js +80 -0
- package/dist-client/pages/board-action-dispatch.js.map +1 -0
- package/dist-client/pages/board-action-dispatch.test.d.ts +1 -0
- package/dist-client/pages/board-action-dispatch.test.js +235 -0
- package/dist-client/pages/board-action-dispatch.test.js.map +1 -0
- package/dist-client/pages/board-edit-dispatch.d.ts +74 -0
- package/dist-client/pages/board-edit-dispatch.js +299 -0
- package/dist-client/pages/board-edit-dispatch.js.map +1 -0
- package/dist-client/pages/board-edit-dispatch.test.d.ts +1 -0
- package/dist-client/pages/board-edit-dispatch.test.js +858 -0
- package/dist-client/pages/board-edit-dispatch.test.js.map +1 -0
- package/dist-client/pages/board-modeller-page.d.ts +40 -28
- package/dist-client/pages/board-modeller-page.js +206 -186
- package/dist-client/pages/board-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
|
@@ -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
|
|
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 —
|
|
134
|
-
//
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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 협력 세션 —
|
|
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
|
|
586
|
+
if (willOpen && this.boardId) {
|
|
533
587
|
await this.ensureChatSession();
|
|
534
588
|
}
|
|
535
589
|
this.aiPanelOpen = willOpen;
|
|
536
590
|
}
|
|
537
|
-
/**
|
|
591
|
+
/** 보드의 모든 ChatSession 목록을 로드 + 활성 세션 보장.
|
|
592
|
+
* - 세션이 0 개면 startBoardAISession 으로 첫 세션 생성 (단일 세션 UX 호환)
|
|
593
|
+
* - 활성 세션 미설정 시 첫 번째 세션을 활성으로
|
|
594
|
+
* - 이미 활성 세션 set 돼있고 list 에 포함되면 유지 */
|
|
538
595
|
async ensureChatSession() {
|
|
539
|
-
if (!this.boardId
|
|
596
|
+
if (!this.boardId)
|
|
540
597
|
return;
|
|
541
598
|
try {
|
|
542
|
-
const
|
|
599
|
+
const result = await client.query({
|
|
543
600
|
query: gql `
|
|
544
|
-
query
|
|
545
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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)
|