@things-factory/ai-assistant 10.1.6

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 (164) hide show
  1. package/client/components/board-ai-chat.test.ts +149 -0
  2. package/client/components/board-ai-chat.ts +3228 -0
  3. package/client/components/chat-defaults.ts +27 -0
  4. package/client/components/chat-echo-dedup.test.ts +230 -0
  5. package/client/components/chat-echo-dedup.ts +155 -0
  6. package/client/components/chat-input-builder.ts +69 -0
  7. package/client/components/markdown.test.ts +518 -0
  8. package/client/components/markdown.ts +508 -0
  9. package/client/components/mention-popup-helpers.ts +433 -0
  10. package/client/components/mention-popup.test.ts +588 -0
  11. package/client/components/mention-popup.ts +238 -0
  12. package/client/index.ts +14 -0
  13. package/client/tsconfig.json +13 -0
  14. package/client/utils/board-edit-patch.ts +374 -0
  15. package/dist-client/components/board-ai-chat.d.ts +423 -0
  16. package/dist-client/components/board-ai-chat.js +3034 -0
  17. package/dist-client/components/board-ai-chat.js.map +1 -0
  18. package/dist-client/components/board-ai-chat.test.d.ts +1 -0
  19. package/dist-client/components/board-ai-chat.test.js +138 -0
  20. package/dist-client/components/board-ai-chat.test.js.map +1 -0
  21. package/dist-client/components/chat-defaults.d.ts +8 -0
  22. package/dist-client/components/chat-defaults.js +22 -0
  23. package/dist-client/components/chat-defaults.js.map +1 -0
  24. package/dist-client/components/chat-echo-dedup.d.ts +54 -0
  25. package/dist-client/components/chat-echo-dedup.js +115 -0
  26. package/dist-client/components/chat-echo-dedup.js.map +1 -0
  27. package/dist-client/components/chat-echo-dedup.test.d.ts +1 -0
  28. package/dist-client/components/chat-echo-dedup.test.js +203 -0
  29. package/dist-client/components/chat-echo-dedup.test.js.map +1 -0
  30. package/dist-client/components/chat-input-builder.d.ts +55 -0
  31. package/dist-client/components/chat-input-builder.js +32 -0
  32. package/dist-client/components/chat-input-builder.js.map +1 -0
  33. package/dist-client/components/markdown.d.ts +60 -0
  34. package/dist-client/components/markdown.js +422 -0
  35. package/dist-client/components/markdown.js.map +1 -0
  36. package/dist-client/components/markdown.test.d.ts +1 -0
  37. package/dist-client/components/markdown.test.js +418 -0
  38. package/dist-client/components/markdown.test.js.map +1 -0
  39. package/dist-client/components/mention-popup-helpers.d.ts +165 -0
  40. package/dist-client/components/mention-popup-helpers.js +330 -0
  41. package/dist-client/components/mention-popup-helpers.js.map +1 -0
  42. package/dist-client/components/mention-popup.d.ts +35 -0
  43. package/dist-client/components/mention-popup.js +223 -0
  44. package/dist-client/components/mention-popup.js.map +1 -0
  45. package/dist-client/components/mention-popup.test.d.ts +1 -0
  46. package/dist-client/components/mention-popup.test.js +474 -0
  47. package/dist-client/components/mention-popup.test.js.map +1 -0
  48. package/dist-client/index.d.ts +14 -0
  49. package/dist-client/index.js +15 -0
  50. package/dist-client/index.js.map +1 -0
  51. package/dist-client/tsconfig.tsbuildinfo +1 -0
  52. package/dist-client/utils/board-edit-patch.d.ts +157 -0
  53. package/dist-client/utils/board-edit-patch.js +284 -0
  54. package/dist-client/utils/board-edit-patch.js.map +1 -0
  55. package/dist-server/index.d.ts +28 -0
  56. package/dist-server/index.js +32 -0
  57. package/dist-server/index.js.map +1 -0
  58. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
  59. package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
  60. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
  61. package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
  62. package/dist-server/service/chat-message/chat-message-publish.js +27 -0
  63. package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
  64. package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
  65. package/dist-server/service/chat-message/chat-message-subscription.js +57 -0
  66. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
  67. package/dist-server/service/chat-message/chat-message.d.ts +68 -0
  68. package/dist-server/service/chat-message/chat-message.js +172 -0
  69. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  70. package/dist-server/service/chat-message/fold-history.d.ts +43 -0
  71. package/dist-server/service/chat-message/fold-history.js +33 -0
  72. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  73. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  74. package/dist-server/service/chat-message/history-summary.js +77 -0
  75. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  76. package/dist-server/service/chat-message/index.d.ts +5 -0
  77. package/dist-server/service/chat-message/index.js +12 -0
  78. package/dist-server/service/chat-message/index.js.map +1 -0
  79. package/dist-server/service/chat-message/llm-history.d.ts +46 -0
  80. package/dist-server/service/chat-message/llm-history.js +87 -0
  81. package/dist-server/service/chat-message/llm-history.js.map +1 -0
  82. package/dist-server/service/chat-session/chat-session.d.ts +60 -0
  83. package/dist-server/service/chat-session/chat-session.js +159 -0
  84. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  85. package/dist-server/service/chat-session/index.d.ts +3 -0
  86. package/dist-server/service/chat-session/index.js +7 -0
  87. package/dist-server/service/chat-session/index.js.map +1 -0
  88. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +30 -0
  89. package/dist-server/service/chat-session/session-activity-broadcast.js +34 -0
  90. package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -0
  91. package/dist-server/service/chat-session/session-activity-publish.d.ts +12 -0
  92. package/dist-server/service/chat-session/session-activity-publish.js +37 -0
  93. package/dist-server/service/chat-session/session-activity-publish.js.map +1 -0
  94. package/dist-server/service/chat-session/session-activity-subscription.d.ts +14 -0
  95. package/dist-server/service/chat-session/session-activity-subscription.js +87 -0
  96. package/dist-server/service/chat-session/session-activity-subscription.js.map +1 -0
  97. package/dist-server/service/chat-session/session-anchor.d.ts +26 -0
  98. package/dist-server/service/chat-session/session-anchor.js +100 -0
  99. package/dist-server/service/chat-session/session-anchor.js.map +1 -0
  100. package/dist-server/service/chat-session/session-inbox.d.ts +80 -0
  101. package/dist-server/service/chat-session/session-inbox.js +133 -0
  102. package/dist-server/service/chat-session/session-inbox.js.map +1 -0
  103. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +49 -0
  104. package/dist-server/service/chat-session-participant/chat-session-participant.js +141 -0
  105. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
  106. package/dist-server/service/chat-session-participant/index.d.ts +4 -0
  107. package/dist-server/service/chat-session-participant/index.js +10 -0
  108. package/dist-server/service/chat-session-participant/index.js.map +1 -0
  109. package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
  110. package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
  112. package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
  113. package/dist-server/service/chat-session-participant/presence.js +22 -0
  114. package/dist-server/service/chat-session-participant/presence.js.map +1 -0
  115. package/dist-server/service/chat-session-resolver.d.ts +93 -0
  116. package/dist-server/service/chat-session-resolver.js +706 -0
  117. package/dist-server/service/chat-session-resolver.js.map +1 -0
  118. package/dist-server/service/index.d.ts +24 -0
  119. package/dist-server/service/index.js +39 -0
  120. package/dist-server/service/index.js.map +1 -0
  121. package/dist-server/service/subscribe-auth.d.ts +14 -0
  122. package/dist-server/service/subscribe-auth.js +33 -0
  123. package/dist-server/service/subscribe-auth.js.map +1 -0
  124. package/dist-server/service/types.d.ts +5 -0
  125. package/dist-server/service/types.js +12 -0
  126. package/dist-server/service/types.js.map +1 -0
  127. package/dist-server/tsconfig.tsbuildinfo +1 -0
  128. package/package.json +48 -0
  129. package/server/index.ts +28 -0
  130. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
  131. package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
  132. package/server/service/chat-message/chat-message-broadcast.ts +27 -0
  133. package/server/service/chat-message/chat-message-publish.ts +22 -0
  134. package/server/service/chat-message/chat-message-subscription.ts +45 -0
  135. package/server/service/chat-message/chat-message.ts +195 -0
  136. package/server/service/chat-message/fold-history.test.ts +98 -0
  137. package/server/service/chat-message/fold-history.ts +77 -0
  138. package/server/service/chat-message/history-summary.test.ts +127 -0
  139. package/server/service/chat-message/history-summary.ts +100 -0
  140. package/server/service/chat-message/index.ts +12 -0
  141. package/server/service/chat-message/llm-history.test.ts +194 -0
  142. package/server/service/chat-message/llm-history.ts +163 -0
  143. package/server/service/chat-session/chat-session.ts +193 -0
  144. package/server/service/chat-session/index.ts +5 -0
  145. package/server/service/chat-session/session-activity-broadcast.ts +57 -0
  146. package/server/service/chat-session/session-activity-publish.ts +44 -0
  147. package/server/service/chat-session/session-activity-subscription.ts +67 -0
  148. package/server/service/chat-session/session-anchor.ts +106 -0
  149. package/server/service/chat-session/session-inbox.test.ts +185 -0
  150. package/server/service/chat-session/session-inbox.ts +161 -0
  151. package/server/service/chat-session-participant/chat-session-participant.ts +135 -0
  152. package/server/service/chat-session-participant/index.ts +6 -0
  153. package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
  154. package/server/service/chat-session-participant/invite-notification.ts +48 -0
  155. package/server/service/chat-session-participant/presence.test.ts +39 -0
  156. package/server/service/chat-session-participant/presence.ts +21 -0
  157. package/server/service/chat-session-resolver.ts +644 -0
  158. package/server/service/index.ts +37 -0
  159. package/server/service/session-anchor.test.ts +133 -0
  160. package/server/service/subscribe-auth.test.ts +57 -0
  161. package/server/service/subscribe-auth.ts +34 -0
  162. package/server/service/types.ts +15 -0
  163. package/things-factory.config.js +1 -0
  164. package/tsconfig.json +9 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * <ox-mention-popup> — Mention 후보 리스트 popup overlay.
3
+ *
4
+ * board-ai-chat 의 textarea 위에 absolute 로 띄움. plain `<textarea>` + popup 분리 패턴
5
+ * (한국어 IME 함정 회피 — contenteditable 사용 안 함).
6
+ *
7
+ * 키보드 네비게이션은 호스트 (board-ai-chat) 가 textarea keydown 으로 처리하고
8
+ * activeIndex 만 동기화. 마우스 클릭은 popup 자체에서 처리.
9
+ *
10
+ * Pure helper (extractMentionContext / filterMentionCandidates 등) 는
11
+ * `mention-popup-helpers.ts` 에 분리 — 단위 테스트 가능.
12
+ */
13
+ import { LitElement, css, html } from 'lit'
14
+ import { customElement, property } from 'lit/decorators.js'
15
+ import type { FilterResult, MentionCandidate } from './mention-popup-helpers.js'
16
+
17
+ export type {
18
+ MentionCandidate,
19
+ FilterResult
20
+ } from './mention-popup-helpers.js'
21
+ export {
22
+ buildMentionCandidates,
23
+ buildCatalogCandidates,
24
+ buildSlashCandidates,
25
+ buildUserCandidates,
26
+ extractMentionContext,
27
+ filterMentionCandidates,
28
+ applyMentionSelection,
29
+ resolveMentionToken,
30
+ type CatalogEntry,
31
+ type SlashTemplate,
32
+ type ResolveMentionTokenOptions
33
+ } from './mention-popup-helpers.js'
34
+
35
+ /** match type 라벨 — things-scene SearchOverlay 의 MATCH_TYPE_LABELS 와 동일. */
36
+ const MATCH_TYPE_LABEL: Record<string, string> = {
37
+ id: 'ID',
38
+ name: 'Name',
39
+ tag: 'Tag',
40
+ type: 'Type',
41
+ class: 'Class',
42
+ text: 'Text',
43
+ data: 'Data',
44
+ property: 'Prop',
45
+ description: 'Desc'
46
+ }
47
+
48
+ @customElement('ox-mention-popup')
49
+ export class OxMentionPopup extends LitElement {
50
+ @property({ type: Array }) results: FilterResult[] = []
51
+ @property({ type: Number }) activeIndex = 0
52
+ /** filter cap 후 표시 안 된 추가 매칭 수 ("...and N more" 표기용) */
53
+ @property({ type: Number }) overflow = 0
54
+
55
+ static styles = css`
56
+ :host {
57
+ display: block;
58
+ position: absolute;
59
+ z-index: 1000;
60
+ background: var(--md-sys-color-surface, #ffffff);
61
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
62
+ border-radius: 6px;
63
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.10);
64
+ max-height: 220px;
65
+ overflow-y: auto;
66
+ /* 폭은 호스트가 left/right 로 결정. */
67
+ }
68
+ .empty {
69
+ padding: 8px 10px;
70
+ color: var(--md-sys-color-outline, #94a3b8);
71
+ font-size: 11px;
72
+ }
73
+ /* dense 한 단일 행 — scene Finder 톤. label / hint / type-chip inline. */
74
+ .item {
75
+ display: flex;
76
+ align-items: center;
77
+ gap: 6px;
78
+ padding: 4px 8px;
79
+ cursor: pointer;
80
+ font-size: 11.5px;
81
+ line-height: 1.4;
82
+ color: var(--md-sys-color-on-surface, #0f172a);
83
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, #f1f5f9);
84
+ }
85
+ .item:last-child {
86
+ border-bottom: 0;
87
+ }
88
+ .item:hover {
89
+ background: var(--md-sys-color-surface-container, #f8fafc);
90
+ }
91
+ .item.active {
92
+ background: var(--md-sys-color-primary-container, rgba(37, 99, 235, 0.10));
93
+ }
94
+ .match-badge {
95
+ flex-shrink: 0;
96
+ background: var(--md-sys-color-primary-container, rgba(37, 99, 235, 0.15));
97
+ color: var(--md-sys-color-on-primary-container, #1e40af);
98
+ padding: 0 5px;
99
+ border-radius: 3px;
100
+ font-size: 9px;
101
+ font-weight: 600;
102
+ letter-spacing: 0.04em;
103
+ min-width: 32px;
104
+ text-align: center;
105
+ }
106
+ /* badge 가 비어있을 때 (matchType=label) — 자리는 reserve 해서 모든 행 정렬 일치 */
107
+ .match-badge.invisible {
108
+ visibility: hidden;
109
+ }
110
+ .label {
111
+ flex: 1;
112
+ min-width: 0;
113
+ font-weight: 500;
114
+ overflow: hidden;
115
+ text-overflow: ellipsis;
116
+ white-space: nowrap;
117
+ color: var(--md-sys-color-on-surface, #0f172a);
118
+ }
119
+ .hint {
120
+ font-size: 10px;
121
+ color: var(--md-sys-color-outline, #94a3b8);
122
+ flex-shrink: 0;
123
+ white-space: nowrap;
124
+ max-width: 45%;
125
+ overflow: hidden;
126
+ text-overflow: ellipsis;
127
+ }
128
+ .type {
129
+ flex-shrink: 0;
130
+ background: var(--md-sys-color-surface-container, #f1f5f9);
131
+ padding: 0 5px;
132
+ border-radius: 3px;
133
+ font-size: 9.5px;
134
+ color: var(--md-sys-color-outline, #94a3b8);
135
+ letter-spacing: 0.02em;
136
+ }
137
+ /* 상태 footer — 'N matches' 카운터 + cap 초과 안내 */
138
+ .status {
139
+ padding: 3px 8px;
140
+ font-size: 9.5px;
141
+ color: var(--md-sys-color-outline, #94a3b8);
142
+ border-top: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
143
+ background: var(--md-sys-color-surface-container-low, #fafbfc);
144
+ display: flex;
145
+ justify-content: space-between;
146
+ gap: 8px;
147
+ }
148
+ .more {
149
+ color: var(--md-sys-color-outline, #94a3b8);
150
+ }
151
+ `
152
+
153
+ render() {
154
+ if (this.results.length === 0) {
155
+ return html`<div class="empty">매칭 없음</div>`
156
+ }
157
+ const totalShown = this.results.length
158
+ const totalAll = totalShown + this.overflow
159
+ return html`
160
+ ${this.results.map((r, i) => {
161
+ // 행 layout:
162
+ // [matchType-badge] [type-chip] [label] [secondary hint when 의미 있을 때]
163
+ // - label = 항상 사람-친화적 1차 식별 (component=id, user=name, catalog=name 등)
164
+ // - secondary hint = matchValue 가 label 과 다른 경우 (예: tag/text 매칭) 또는
165
+ // user 의 email 같은 부가 정보. 없으면 생략.
166
+ const matchType = r.matchType
167
+ const badgeText = matchType ? MATCH_TYPE_LABEL[matchType] ?? '' : ''
168
+ const label = r.candidate.label
169
+ // matchValue 가 label 과 같으면 redundant 라 hint 안 함
170
+ const matchHint = r.matchValue && r.matchValue !== label ? r.matchValue : ''
171
+ // user 후보이고 hint 가 비어있으면 email 을 hint 로 (보조 식별)
172
+ const fallbackHint =
173
+ !matchHint && r.candidate.type === 'user' && r.candidate.email !== label
174
+ ? r.candidate.email ?? ''
175
+ : ''
176
+ const hint = matchHint || fallbackHint
177
+ return html`
178
+ <div
179
+ class="item ${i === this.activeIndex ? 'active' : ''}"
180
+ @mouseenter=${() => this._setActive(i)}
181
+ @mousedown=${(e: MouseEvent) => this._select(e, r.candidate)}>
182
+ <span class="match-badge ${badgeText ? '' : 'invisible'}"
183
+ >${badgeText || '·'}</span
184
+ >
185
+ <span class="type">${r.candidate.type}</span>
186
+ <span class="label">${label}</span>
187
+ ${hint ? html`<span class="hint">${hint}</span>` : ''}
188
+ </div>
189
+ `
190
+ })}
191
+ <div class="status">
192
+ <span>${totalShown} / ${totalAll} matches</span>
193
+ ${this.overflow > 0
194
+ ? html`<span class="more">+${this.overflow} more — narrow query</span>`
195
+ : ''}
196
+ </div>
197
+ `
198
+ }
199
+
200
+ private _select(e: MouseEvent, candidate: MentionCandidate) {
201
+ e.preventDefault() // textarea blur 방지
202
+ this.dispatchEvent(
203
+ new CustomEvent('mention-select', {
204
+ detail: { candidate },
205
+ bubbles: true,
206
+ composed: true
207
+ })
208
+ )
209
+ }
210
+
211
+ /** 마우스 hover 로 active 이동 — host 가 받아 keyboard nav 와 동일하게 처리. */
212
+ private _setActive(index: number) {
213
+ if (index === this.activeIndex) return
214
+ this.dispatchEvent(
215
+ new CustomEvent('mention-active-change', {
216
+ detail: { index },
217
+ bubbles: true,
218
+ composed: true
219
+ })
220
+ )
221
+ }
222
+
223
+ /** activeIndex 가 바뀌면 해당 항목을 view 로 스크롤 — popup 의 overflow-y:auto
224
+ * 영역에서 키보드 네비 시 active 가 가려지지 않도록. */
225
+ protected updated(changed: Map<string, unknown>) {
226
+ if (changed.has('activeIndex') || changed.has('results')) {
227
+ const items = this.renderRoot.querySelectorAll('.item')
228
+ const active = items[this.activeIndex] as HTMLElement | undefined
229
+ active?.scrollIntoView({ block: 'nearest' })
230
+ }
231
+ }
232
+ }
233
+
234
+ declare global {
235
+ interface HTMLElementTagNameMap {
236
+ 'ox-mention-popup': OxMentionPopup
237
+ }
238
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@things-factory/ai-assistant` — 대화면.
3
+ *
4
+ * `<ox-board-ai-chat>` 은 **이름을 안 바꿨다.** 커스텀 엘리먼트 이름은 소비처 여덟 곳의 템플릿에
5
+ * 박혀 있고, 그것을 같은 커밋에서 함께 바꾸면 무엇이 깨졌는지 못 가린다. 이름은 별도로 옮긴다.
6
+ */
7
+ export * from './components/board-ai-chat.js'
8
+ export * from './components/chat-defaults.js'
9
+ export * from './components/markdown.js'
10
+ export * from './components/chat-echo-dedup.js'
11
+ export * from './components/chat-input-builder.js'
12
+ export * from './components/mention-popup.js'
13
+ export * from './components/mention-popup-helpers.js'
14
+ export * from './utils/board-edit-patch.js'
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig-base.json",
3
+ "compilerOptions": {
4
+ "experimentalDecorators": true,
5
+ "skipLibCheck": true,
6
+ "strict": true,
7
+ "declaration": true,
8
+ "module": "esnext",
9
+ "outDir": "../dist-client",
10
+ "baseUrl": "./"
11
+ },
12
+ "include": ["./**/*"]
13
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Client-side BoardEditPatch types + applier.
3
+ *
4
+ * NOTE: 동일 정의가 server/service/types.ts + apply-patch.ts 에 있음.
5
+ * client 번들이 server 코드를 import 하지 않도록 분리. 변경 시 양쪽 동기화 필요.
6
+ */
7
+
8
+ /**
9
+ * 컴포넌트 타깃팅은 항상 `refid` (things-scene universal numeric handle).
10
+ * `id` 는 데이터 바인딩 이름이며 unique 가 아니므로 targeting 에 사용하지 않는다.
11
+ *
12
+ * 보드는 최상위 부모 — 자체 속성 (fillStyle / width / height / name 등) 을 갖고,
13
+ * `modifyBoard` 로 변경. 자식 컴포넌트 변경 (`modify`) 와 분리.
14
+ *
15
+ * Phase 2 — Scene 조작 op (align/distribute/group/ungroup/zorder). model 차원
16
+ * 시뮬레이션은 noop 이고 things-scene API 가 정본.
17
+ */
18
+ export type AlignDirection =
19
+ | 'left'
20
+ | 'right'
21
+ | 'center'
22
+ | 'top'
23
+ | 'middle'
24
+ | 'bottom'
25
+
26
+ export type DistributeAxis = 'horizontal' | 'vertical'
27
+
28
+ export type ZorderDirection = 'front' | 'back' | 'forward' | 'backward'
29
+
30
+ /**
31
+ * Sugar layout — `arrange` op 의 layout 종류.
32
+ * Server-side types.ts 의 ArrangeLayout 와 일치 — drift test 가 검증.
33
+ */
34
+ export type ArrangeLayout =
35
+ | { type: 'grid'; cols: number; gap?: number; anchor?: { left: number; top: number } }
36
+ | {
37
+ type: 'row'
38
+ gap?: number
39
+ anchor?: { left: number; top: number }
40
+ align?: 'start' | 'center' | 'end'
41
+ }
42
+ | {
43
+ type: 'column'
44
+ gap?: number
45
+ anchor?: { left: number; top: number }
46
+ align?: 'start' | 'center' | 'end'
47
+ }
48
+
49
+ export type BoardEditOp =
50
+ | { op: 'add'; component: any }
51
+ | { op: 'remove'; refid: number }
52
+ | { op: 'modify'; refid: number; patch: any }
53
+ | { op: 'modifyBoard'; patch: any }
54
+ | { op: 'replace'; board: any }
55
+ | { op: 'align'; refids: number[]; direction: AlignDirection }
56
+ | { op: 'distribute'; refids: number[]; axis: DistributeAxis }
57
+ | { op: 'group'; refids: number[] }
58
+ | { op: 'ungroup'; refid: number }
59
+ | { op: 'zorder'; refid: number; direction: ZorderDirection }
60
+ | { op: 'arrange'; refids: number[]; layout: ArrangeLayout }
61
+
62
+ /**
63
+ * C-1 — Scene 조작 action (ephemeral). 모델 변경 X, undo 영향 X.
64
+ * 호스트가 board-action-execute 이벤트로 받아서 things-scene API 직접 호출.
65
+ */
66
+ export type BoardActionOp =
67
+ | { action: 'selectComponents'; refids: number[] }
68
+ | { action: 'centerToComponent'; refid: number; animated?: boolean }
69
+ | { action: 'fitToView'; mode?: 'fit' | 'ratio' | 'width' | 'height' }
70
+ | { action: 'setSceneMode'; mode: 'edit' | 'view' }
71
+ | { action: 'highlightComponents'; refids: number[] }
72
+
73
+ export interface BoardEditPatch {
74
+ ops: BoardEditOp[]
75
+ summary: string
76
+ confidence: number
77
+ }
78
+
79
+ export interface PatchApplyReport {
80
+ /** 패치 적용 후 보드. 모든 op 가 noop 이어도 입력 그대로 반환. */
81
+ board: any
82
+ /** 실제로 보드를 바꾼 op 들. */
83
+ applied: BoardEditOp[]
84
+ /** id 매칭 실패 등으로 noop 이 된 op 들 — 호출자가 사용자에게 알릴 단서. */
85
+ missed: BoardEditOp[]
86
+ }
87
+
88
+ const EMPTY_BOARD = { width: 1000, height: 600, components: [] as any[] }
89
+
90
+ /**
91
+ * Patch 를 BoardModel 에 적용 (pure, board 를 mutate 하지 않음).
92
+ */
93
+ export function applyBoardEditPatch(board: any | undefined, patch: BoardEditPatch): any {
94
+ return applyBoardEditPatchVerbose(board, patch).board
95
+ }
96
+
97
+ /**
98
+ * Verbose 변형 — 각 op 의 적용 여부를 보고.
99
+ *
100
+ * `modify` 와 `remove` 는 id 가 보드에 없으면 silent no-op 이 된다. LLM 이 잘못된
101
+ * id 를 만들어 보내면 사용자에게 "수정했습니다" 라고 답하지만 실제로는 아무 변화도
102
+ * 없는 상황이 발생 — 호스트가 missed 를 보고 사용자에게 경고할 수 있도록 별도
103
+ * entry point 제공.
104
+ */
105
+ export function applyBoardEditPatchVerbose(
106
+ board: any | undefined,
107
+ patch: BoardEditPatch
108
+ ): PatchApplyReport {
109
+ let result: any = board ?? EMPTY_BOARD
110
+ const applied: BoardEditOp[] = []
111
+ const missed: BoardEditOp[] = []
112
+
113
+ for (const op of patch.ops) {
114
+ if (SCENE_ONLY_OPS.has(op.op)) {
115
+ // scene-only — model 차원 noop, scene 차원 적용. applied 분류.
116
+ applied.push(op)
117
+ continue
118
+ }
119
+ const next = applyOp(result, op)
120
+ // componentsUnchanged 는 components 배열 동일 + width/height/fillStyle 동일 만 본다.
121
+ // modify / remove 의 silent no-op (ghost refid) 검출 전용 — modifyBoard 가 sky
122
+ // 등 다른 root 키만 바꿀 때 false negative 로 missed 분류되는 회귀 방지.
123
+ const componentMutating = op.op === 'modify' || op.op === 'remove'
124
+ if (next === result || (componentMutating && componentsUnchanged(result, next))) {
125
+ missed.push(op)
126
+ } else {
127
+ applied.push(op)
128
+ result = next
129
+ }
130
+ }
131
+
132
+ return { board: result, applied, missed }
133
+ }
134
+
135
+ function componentsUnchanged(prev: any, next: any): boolean {
136
+ // applyOp 는 항상 새 객체를 만든다. 따라서 reference 비교가 안 되고 내용 비교 필요.
137
+ // components 는 map/filter 결과 reference 도 다를 수 있으므로 length + 요소 ref 비교.
138
+ const a = prev.components ?? []
139
+ const b = next.components ?? []
140
+ if (a.length !== b.length) return false
141
+ for (let i = 0; i < a.length; i++) {
142
+ if (a[i] !== b[i]) return false
143
+ }
144
+ return prev.width === next.width && prev.height === next.height && prev.fillStyle === next.fillStyle
145
+ }
146
+
147
+ const SCENE_ONLY_OPS = new Set<BoardEditOp['op']>([
148
+ 'align',
149
+ 'distribute',
150
+ 'group',
151
+ 'ungroup',
152
+ 'zorder',
153
+ 'arrange'
154
+ ])
155
+
156
+ function applyOp(board: any, op: BoardEditOp): any {
157
+ switch (op.op) {
158
+ case 'replace':
159
+ return op.board
160
+ case 'add':
161
+ return { ...board, components: [...(board.components || []), op.component] }
162
+ case 'remove':
163
+ // 자식 (group/container 안) refid 도 매칭 — deep search.
164
+ return {
165
+ ...board,
166
+ components: removeComponentDeep(board.components || [], op.refid)
167
+ }
168
+ case 'modify':
169
+ // 자식 refid 도 매칭 — group/container 안의 컴포넌트 수정 가능.
170
+ return {
171
+ ...board,
172
+ components: modifyComponentDeep(board.components || [], op.refid, op.patch)
173
+ }
174
+ case 'modifyBoard': {
175
+ const patch = { ...(op.patch || {}) }
176
+ delete patch.components // 자식 변경은 별도 op
177
+ return mergeComponent(board, patch) // 최상위 board 자체에 deep-merge
178
+ }
179
+ case 'align':
180
+ case 'distribute':
181
+ case 'group':
182
+ case 'ungroup':
183
+ case 'zorder':
184
+ case 'arrange':
185
+ // scene-only — model 차원 noop. things-scene API 가 정본.
186
+ return board
187
+ default:
188
+ return board
189
+ }
190
+ }
191
+
192
+ /**
193
+ * 주어진 board 상태에서 op 의 inverse 를 계산.
194
+ *
195
+ * Revert 기능 — patch 적용 직전 board + op 로 역연산을 만든다. 호스트가
196
+ * 누적해두면 나중에 역순 실행만으로 복원.
197
+ *
198
+ * add 의 inverse 는 새로 발급될 refid 를 알아야 → 모델 단계에서 계산 불가.
199
+ * 호스트가 scene.add 직후 refid 를 캡처해 직접 만들 것.
200
+ */
201
+ export function computeInverseOp(board: any, op: BoardEditOp): BoardEditOp | null {
202
+ if (!board) return null
203
+ const components = board.components ?? []
204
+
205
+ switch (op.op) {
206
+ case 'add':
207
+ return null
208
+
209
+ case 'remove': {
210
+ // 자식 refid 도 매칭 (deep search)
211
+ const target = findComponentDeep(components, op.refid)
212
+ if (!target) return null
213
+ return { op: 'add', component: JSON.parse(JSON.stringify(target)) }
214
+ }
215
+
216
+ case 'modify': {
217
+ // 자식 refid 도 매칭 (deep search)
218
+ const target = findComponentDeep(components, op.refid)
219
+ if (!target) return null
220
+ const oldValues: any = {}
221
+ for (const k of Object.keys(op.patch || {})) {
222
+ const v = (target as any)[k]
223
+ oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v))
224
+ }
225
+ return { op: 'modify', refid: op.refid, patch: oldValues }
226
+ }
227
+
228
+ case 'modifyBoard': {
229
+ const oldValues: any = {}
230
+ const patch = op.patch || {}
231
+ for (const k of Object.keys(patch)) {
232
+ if (k === 'components') continue
233
+ const v = (board as any)[k]
234
+ oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v))
235
+ }
236
+ return { op: 'modifyBoard', patch: oldValues }
237
+ }
238
+
239
+ case 'replace':
240
+ return { op: 'replace', board: JSON.parse(JSON.stringify(board)) }
241
+
242
+ case 'zorder': {
243
+ // forward ↔ backward / front ↔ back. front/back 의 inverse 는 정확하지
244
+ // 않을 수 있음 (best-effort) — 정확 inverse 는 호스트가 zorder 직전 index
245
+ // 를 캡처해서 명시 modify 시퀀스로 만들어야.
246
+ const opp: Record<string, 'front' | 'back' | 'forward' | 'backward'> = {
247
+ forward: 'backward',
248
+ backward: 'forward',
249
+ front: 'back',
250
+ back: 'front'
251
+ }
252
+ const dir = opp[op.direction]
253
+ if (!dir) return null
254
+ return { op: 'zorder', refid: op.refid, direction: dir }
255
+ }
256
+
257
+ case 'align':
258
+ case 'distribute':
259
+ case 'group':
260
+ case 'ungroup':
261
+ case 'arrange':
262
+ // scene 호출 후에야 변경 결과 알 수 있음. 호스트가 직접 inverse 만든다.
263
+ return null
264
+
265
+ default:
266
+ return null
267
+ }
268
+ }
269
+
270
+ /**
271
+ * 컴포넌트에 부분 patch 를 적용 (deep merge).
272
+ * threeD 등 nested object 는 deep merge — 색만 바꾸려고 했을 때 geometry 까지 사라지지 않도록.
273
+ *
274
+ * patch 내 `null` 의 시맨틱 — **"이 키를 제거하라"**:
275
+ * - fillStyle/strokeStyle 처럼 `string | object | undefined` 인 필드는 null 로
276
+ * set 하면 typeof null === 'object' 함정에 빠져 다운스트림 (3D 텍스처 등) 이 크래시.
277
+ * - computeInverseOp 가 base 에 키가 없던 (`undefined`) 경우 inverse 를 null 로
278
+ * 발급하므로, 이 시맨틱과 정확히 일치 — inverse 가 자연스럽게 올바로 동작.
279
+ *
280
+ * host (board-modeller-page) 에서 things-scene 의 component.set(merged) 호출 전에 사용.
281
+ */
282
+ export function mergeComponent(base: any, patch: any): any {
283
+ const out: any = { ...base }
284
+ for (const key of Object.keys(patch)) {
285
+ const patchVal = patch[key]
286
+ if (patchVal === null) {
287
+ delete out[key]
288
+ } else if (isPlainObject(base[key]) && isPlainObject(patchVal)) {
289
+ out[key] = deepMerge(base[key], patchVal)
290
+ } else {
291
+ out[key] = patchVal
292
+ }
293
+ }
294
+ return out
295
+ }
296
+
297
+ function deepMerge(a: any, b: any): any {
298
+ const out: any = { ...a }
299
+ for (const key of Object.keys(b)) {
300
+ const bv = b[key]
301
+ if (bv === null) {
302
+ delete out[key]
303
+ } else if (isPlainObject(a[key]) && isPlainObject(bv)) {
304
+ out[key] = deepMerge(a[key], bv)
305
+ } else {
306
+ out[key] = bv
307
+ }
308
+ }
309
+ return out
310
+ }
311
+
312
+ function isPlainObject(v: any): boolean {
313
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
314
+ }
315
+
316
+ // ── 자식 컴포넌트 (group/container 안) 까지 매칭 — deep search ─
317
+ // server/service/apply-patch.ts 와 동일 로직. drift test 가 동등성 보장.
318
+
319
+ function findComponentDeep(components: any[], refid: number): any | undefined {
320
+ for (const c of components) {
321
+ if (!c) continue
322
+ if (c.refid === refid) return c
323
+ if (Array.isArray(c.components) && c.components.length > 0) {
324
+ const sub = findComponentDeep(c.components, refid)
325
+ if (sub) return sub
326
+ }
327
+ }
328
+ return undefined
329
+ }
330
+
331
+ function modifyComponentDeep(components: any[], refid: number, patch: any): any[] {
332
+ let changed = false
333
+ const out = components.map(c => {
334
+ if (!c) return c
335
+ if (c.refid === refid) {
336
+ changed = true
337
+ return mergeComponent(c, patch)
338
+ }
339
+ if (Array.isArray(c.components) && c.components.length > 0) {
340
+ const updated = modifyComponentDeep(c.components, refid, patch)
341
+ if (updated !== c.components) {
342
+ changed = true
343
+ return { ...c, components: updated }
344
+ }
345
+ }
346
+ return c
347
+ })
348
+ return changed ? out : components
349
+ }
350
+
351
+ function removeComponentDeep(components: any[], refid: number): any[] {
352
+ let changed = false
353
+ const out: any[] = []
354
+ for (const c of components) {
355
+ if (!c) {
356
+ out.push(c)
357
+ continue
358
+ }
359
+ if (c.refid === refid) {
360
+ changed = true
361
+ continue
362
+ }
363
+ if (Array.isArray(c.components) && c.components.length > 0) {
364
+ const updated = removeComponentDeep(c.components, refid)
365
+ if (updated !== c.components) {
366
+ changed = true
367
+ out.push({ ...c, components: updated })
368
+ continue
369
+ }
370
+ }
371
+ out.push(c)
372
+ }
373
+ return changed ? out : components
374
+ }