@things-factory/board-ai 10.1.24 → 10.1.26

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 (107) hide show
  1. package/PLAN-styling-tools.md +1 -1
  2. package/dist-client/server/service/assistant.d.ts +5 -4
  3. package/dist-client/server/service/assistant.js +35 -35
  4. package/dist-client/server/service/assistant.js.map +1 -1
  5. package/dist-client/server/service/styling/clear-tools.d.ts +2 -2
  6. package/dist-client/server/service/styling/clear-tools.js.map +1 -1
  7. package/dist-client/server/service/styling/copy-tools.d.ts +3 -3
  8. package/dist-client/server/service/styling/copy-tools.js +1 -1
  9. package/dist-client/server/service/styling/copy-tools.js.map +1 -1
  10. package/dist-client/server/service/styling/effect-tools.d.ts +2 -2
  11. package/dist-client/server/service/styling/effect-tools.js.map +1 -1
  12. package/dist-client/server/service/styling/fill-tools.d.ts +3 -3
  13. package/dist-client/server/service/styling/fill-tools.js +1 -1
  14. package/dist-client/server/service/styling/fill-tools.js.map +1 -1
  15. package/dist-client/server/service/styling/material-tools.d.ts +2 -2
  16. package/dist-client/server/service/styling/material-tools.js.map +1 -1
  17. package/dist-client/server/service/styling/registry.d.ts +6 -6
  18. package/dist-client/server/service/styling/registry.js +2 -2
  19. package/dist-client/server/service/styling/registry.js.map +1 -1
  20. package/dist-client/server/service/styling/stroke-tools.d.ts +2 -2
  21. package/dist-client/server/service/styling/stroke-tools.js.map +1 -1
  22. package/dist-client/server/service/styling/text-tools.d.ts +2 -2
  23. package/dist-client/server/service/styling/text-tools.js.map +1 -1
  24. package/dist-client/server/service/types.d.ts +6 -148
  25. package/dist-client/server/service/types.js.map +1 -1
  26. package/dist-client/server/service/validation/board-model-schema.d.ts +262 -262
  27. package/dist-client/server/service/validation/board-model-schema.js +4 -4
  28. package/dist-client/server/service/validation/board-model-schema.js.map +1 -1
  29. package/dist-client/server/service/validation/tool-validation.js +5 -5
  30. package/dist-client/server/service/validation/tool-validation.js.map +1 -1
  31. package/dist-client/tsconfig.tsbuildinfo +1 -1
  32. package/dist-server/index.d.ts +2 -2
  33. package/dist-server/index.js +2 -2
  34. package/dist-server/index.js.map +1 -1
  35. package/dist-server/service/assistant.d.ts +5 -4
  36. package/dist-server/service/assistant.js +37 -37
  37. package/dist-server/service/assistant.js.map +1 -1
  38. package/dist-server/service/board-ai-resolver.js +2 -2
  39. package/dist-server/service/board-ai-resolver.js.map +1 -1
  40. package/dist-server/service/index.d.ts +0 -1
  41. package/dist-server/service/index.js +0 -1
  42. package/dist-server/service/index.js.map +1 -1
  43. package/dist-server/service/patch-entry/board-patch-subscription.js +1 -1
  44. package/dist-server/service/patch-entry/board-patch-subscription.js.map +1 -1
  45. package/dist-server/service/patch-entry/patch-entry.d.ts +1 -1
  46. package/dist-server/service/patch-entry/patch-entry.js +1 -1
  47. package/dist-server/service/patch-entry/patch-entry.js.map +1 -1
  48. package/dist-server/service/styling/clear-tools.d.ts +2 -2
  49. package/dist-server/service/styling/clear-tools.js.map +1 -1
  50. package/dist-server/service/styling/copy-tools.d.ts +3 -3
  51. package/dist-server/service/styling/copy-tools.js +1 -1
  52. package/dist-server/service/styling/copy-tools.js.map +1 -1
  53. package/dist-server/service/styling/effect-tools.d.ts +2 -2
  54. package/dist-server/service/styling/effect-tools.js.map +1 -1
  55. package/dist-server/service/styling/fill-tools.d.ts +3 -3
  56. package/dist-server/service/styling/fill-tools.js +1 -1
  57. package/dist-server/service/styling/fill-tools.js.map +1 -1
  58. package/dist-server/service/styling/material-tools.d.ts +2 -2
  59. package/dist-server/service/styling/material-tools.js.map +1 -1
  60. package/dist-server/service/styling/registry.d.ts +6 -6
  61. package/dist-server/service/styling/registry.js +2 -2
  62. package/dist-server/service/styling/registry.js.map +1 -1
  63. package/dist-server/service/styling/stroke-tools.d.ts +2 -2
  64. package/dist-server/service/styling/stroke-tools.js.map +1 -1
  65. package/dist-server/service/styling/text-tools.d.ts +2 -2
  66. package/dist-server/service/styling/text-tools.js.map +1 -1
  67. package/dist-server/service/types.d.ts +6 -148
  68. package/dist-server/service/types.js.map +1 -1
  69. package/dist-server/service/validation/board-model-schema.d.ts +262 -262
  70. package/dist-server/service/validation/board-model-schema.js +4 -4
  71. package/dist-server/service/validation/board-model-schema.js.map +1 -1
  72. package/dist-server/service/validation/tool-validation.js +5 -5
  73. package/dist-server/service/validation/tool-validation.js.map +1 -1
  74. package/dist-server/tsconfig.tsbuildinfo +1 -1
  75. package/logs/.5e5d741d8b7784a2fbad65eedc0fd46946aaf6f2-audit.json +5 -0
  76. package/logs/connections-2026-09-17-16.log +0 -0
  77. package/package.json +8 -7
  78. package/server/index.ts +2 -2
  79. package/server/service/assistant-integration.test.ts +44 -44
  80. package/server/service/assistant-llm-smoke.test.ts +5 -5
  81. package/server/service/assistant.test.ts +86 -86
  82. package/server/service/assistant.ts +41 -43
  83. package/server/service/board-ai-resolver.ts +2 -2
  84. package/server/service/index.ts +0 -1
  85. package/server/service/patch-entry/board-patch-subscription.ts +1 -1
  86. package/server/service/patch-entry/patch-entry.ts +2 -2
  87. package/server/service/styling/clear-tools.ts +2 -2
  88. package/server/service/styling/copy-tools.ts +3 -3
  89. package/server/service/styling/effect-tools.ts +2 -2
  90. package/server/service/styling/fill-tools.test.ts +1 -1
  91. package/server/service/styling/fill-tools.ts +4 -4
  92. package/server/service/styling/material-tools.ts +2 -2
  93. package/server/service/styling/registry.ts +6 -6
  94. package/server/service/styling/stroke-tools.test.ts +1 -1
  95. package/server/service/styling/stroke-tools.ts +3 -3
  96. package/server/service/styling/text-tools.ts +2 -2
  97. package/server/service/tool-exposure.test.ts +7 -7
  98. package/server/service/types.ts +9 -110
  99. package/server/service/validation/board-model-schema.test.ts +3 -3
  100. package/server/service/validation/board-model-schema.ts +4 -4
  101. package/server/service/validation/tool-validation.ts +5 -5
  102. package/dist-server/service/apply-patch.d.ts +0 -47
  103. package/dist-server/service/apply-patch.js +0 -369
  104. package/dist-server/service/apply-patch.js.map +0 -1
  105. package/server/service/apply-patch-drift.test.ts +0 -831
  106. package/server/service/apply-patch.test.ts +0 -757
  107. package/server/service/apply-patch.ts +0 -411
@@ -10,6 +10,11 @@
10
10
  import type { BoardComponent, BoardModel } from '@things-factory/board-import'
11
11
 
12
12
  /* 대화 한 줄은 도메인을 모른다 — 정의는 `@things-factory/ai-assistant` 에 있다(2026-09-10). */
13
+ /*
14
+ * The edit vocabulary is not this package's. A person dragging a box produces the same
15
+ * operations, so they live in @operato/scene-ops with the two appliers that carry them out.
16
+ */
17
+ import type { SceneActionOp, SceneEditPatch } from '@operato/scene-ops'
13
18
  import type { LLMMessage } from '@things-factory/ai-assistant'
14
19
  export type { LLMMessage }
15
20
 
@@ -121,7 +126,7 @@ import type { AIAnalysisClaim, GroundedAnalysisContract } from '@things-factory/
121
126
  export type { ToolUsage }
122
127
 
123
128
  /** Board-specific extension of the shared evidence/approval analysis boundary. */
124
- export interface BoardGroundedAnalysis extends GroundedAnalysisContract<any, AIAnalysisClaim, BoardEditPatch | BoardActionOp[]> {
129
+ export interface BoardGroundedAnalysis extends GroundedAnalysisContract<any, AIAnalysisClaim, SceneEditPatch | SceneActionOp[]> {
125
130
  kind: 'board.analysis.v1'
126
131
  }
127
132
 
@@ -130,12 +135,12 @@ export interface ChatResponse {
130
135
  /** 사용자에게 보여줄 텍스트 응답 */
131
136
  reply: string
132
137
  /** 보드 변경이 있으면 patch */
133
- patch?: BoardEditPatch
138
+ patch?: SceneEditPatch
134
139
  /**
135
140
  * Scene 조작 action (ephemeral — 모델 변경 없음 / undo 영향 없음).
136
141
  * 시간순 시퀀스로 호스트가 things-scene API 직접 실행.
137
142
  */
138
- actions?: BoardActionOp[]
143
+ actions?: SceneActionOp[]
139
144
  /** 모호한 입력 시 명확화 질문 (patch 없음) */
140
145
  followUp?: string
141
146
  /** AI 가 응답 만드는 과정에서 호출한 도구들 (시간순). UI fold-able 박스용. */
@@ -158,119 +163,13 @@ export interface ChatResponse {
158
163
  analysis?: BoardGroundedAnalysis
159
164
  }
160
165
 
161
- export interface BoardEditPatch {
162
- ops: BoardEditOp[]
163
- /** 사용자 검수용 1-2 문장 요약 */
164
- summary: string
165
- /** 0..1 신뢰도 */
166
- confidence: number
167
- }
168
-
169
- /**
170
- * 보드 변경 op.
171
- *
172
- * 식별자 정책 — 기존 컴포넌트 타깃팅은 `refid` (number) 만 사용.
173
- * things-scene 의 모든 컴포넌트는 `refid` 를 자동 발급받는다 (universal).
174
- * `model.id` 는 optional metadata 일 뿐 — 항상 존재하지 않으므로 targeting 에는
175
- * 부적합. 별개 개념이므로 BoardEditOp / tool 인자에서도 별개 식별자로 분리하지
176
- * 않고 refid 단일 채널로 일원화.
177
- *
178
- * 계층 — 보드는 **최상위 부모** (things-scene 의 model-layer) 이고 그 자체로 자기
179
- * 속성을 갖는다 (fillStyle, width, height, fitMode, translate, scale, sky, skyColor,
180
- * exposure, hemi/dirLight 계열, camera 계열). 자식 컴포넌트와 별개. 보드 속성 변경은
181
- * `modifyBoard`, 자식 변경은 `modify` (refid 기반).
182
- *
183
- * 주의 — `name` 은 보드 *엔티티* (DB row) 의 컬럼이지 scene MODEL 의 필드가 아니다.
184
- * things-scene 의 model-layer 가 인식 안 함. modifyBoard 로 name 을 보내면 model
185
- * JSON 에 죽은 필드로 박힐 뿐 렌더링/동작에 영향 없음. 보드 라벨 변경은 GraphQL
186
- * boardPatch (BoardPatch input 의 name 필드) 영역.
187
- *
188
- * style 변경 / 이동 / 크기 변경 등 자식 컴포넌트의 변경은 모두 `modify.patch` 안에
189
- * 흡수. 보드 root 속성 변경은 `modifyBoard.patch` 로.
190
- */
191
- /**
192
- * Phase 2 — Scene 조작 op 들. 모델 차원 (좌표 / 부모-자식 관계 / z-order) 변경이지만
193
- * things-scene 의 자체 API (align/distribute/group/ungroup/zorder) 가 일관된 결과를
194
- * 보장하므로 호스트가 직접 호출. 모델 차원 시뮬레이션 (apply-patch) 은 단순화 —
195
- * scene 호출 결과가 정본.
196
- */
197
- export type AlignDirection =
198
- | 'left'
199
- | 'right'
200
- | 'center'
201
- | 'top'
202
- | 'middle'
203
- | 'bottom'
204
-
205
- export type DistributeAxis = 'horizontal' | 'vertical'
206
-
207
- export type ZorderDirection = 'front' | 'back' | 'forward' | 'backward'
208
-
209
- /**
210
- * Sugar layout — `arrange` op 의 layout 종류.
211
- *
212
- * 의도: align/distribute 위에 얹는 high-level 의도 표현. AI 가 "3x2 그리드로",
213
- * "한 줄로", "세로로 일렬" 같은 자연어를 픽셀 노가다 없이 단일 op 로 표현.
214
- *
215
- * left/top 만 변경 — width/height 는 유지. AI 가 사이즈도 바꾸려면 별도 modify.
216
- *
217
- * 위치 계산은 호스트 (things-scene 측) 가 담당 — 각 컴포넌트의 현재 width/height 를
218
- * 정확히 알아야 하므로 model 차원 시뮬레이션은 SCENE_ONLY (apply-patch noop).
219
- */
220
- export type ArrangeLayout =
221
- | { type: 'grid'; cols: number; gap?: number; anchor?: { left: number; top: number } }
222
- | {
223
- type: 'row'
224
- gap?: number
225
- anchor?: { left: number; top: number }
226
- align?: 'start' | 'center' | 'end'
227
- }
228
- | {
229
- type: 'column'
230
- gap?: number
231
- anchor?: { left: number; top: number }
232
- align?: 'start' | 'center' | 'end'
233
- }
234
-
235
- /**
236
- * C-1 — Scene 조작 action 들 (ephemeral). 모델 변경 X, 따라서 BoardEditOp 와 별개
237
- * 채널 (board-action-execute 이벤트). undo 히스토리 미영향, dirty flag 미영향.
238
- *
239
- * 종류:
240
- * - selectComponents: scene.selected 직접 set (사용자 선택 변경)
241
- * - centerToComponent: 특정 컴포넌트로 view 이동
242
- * - fitToView: 보드 전체가 보이도록 fit
243
- * - setSceneMode: edit / view 모드 전환
244
- */
245
- export type BoardActionOp =
246
- | { action: 'selectComponents'; refids: number[] }
247
- | { action: 'centerToComponent'; refid: number; animated?: boolean }
248
- | { action: 'fitToView'; mode?: 'fit' | 'ratio' | 'width' | 'height' }
249
- | { action: 'setSceneMode'; mode: 'edit' | 'view' }
250
- /** 다중 컴포넌트 outline highlight — search/finder UX 의 "이게 모두 매칭이다" 시각화.
251
- * things-scene 의 highlightSearchResults API 위임 (2D/3D 모두 지원). */
252
- | { action: 'highlightComponents'; refids: number[] }
253
-
254
- export type BoardEditOp =
255
- | { op: 'add'; component: BoardComponent }
256
- | { op: 'remove'; refid: number }
257
- | { op: 'modify'; refid: number; patch: Partial<BoardComponent> }
258
- | { op: 'modifyBoard'; patch: Partial<BoardModel> }
259
- | { op: 'replace'; board: BoardModel }
260
- | { op: 'align'; refids: number[]; direction: AlignDirection }
261
- | { op: 'distribute'; refids: number[]; axis: DistributeAxis }
262
- | { op: 'group'; refids: number[] }
263
- | { op: 'ungroup'; refid: number }
264
- | { op: 'zorder'; refid: number; direction: ZorderDirection }
265
- | { op: 'arrange'; refids: number[]; layout: ArrangeLayout }
266
-
267
166
  /**
268
167
  * BoardAIAssistant — 자연어 채팅으로 보드를 다루는 단일 인터페이스.
269
168
  *
270
169
  * 사용:
271
170
  * const ai = new DefaultBoardAIAssistant(baseClient, { scopes: ['fmsim'] })
272
171
  * const r = await ai.chat([{ role: 'user', content: 'AGV 3대 추가' }], currentBoard)
273
- * if (r.patch) currentBoard = applyBoardEditPatch(currentBoard, r.patch)
172
+ * if (r.patch) currentBoard = applyScenePatch(currentBoard, r.patch)
274
173
  */
275
174
  export interface BoardAIAssistant {
276
175
  readonly id: string
@@ -622,7 +622,7 @@ describe('실 사용자 발견 시나리오 (회귀 방지)', () => {
622
622
  })
623
623
  })
624
624
 
625
- describe('BoardPatchSchema — modifyBoard 의 patch 검증', () => {
625
+ describe('BoardPatchSchema — modifyScene 의 patch 검증', () => {
626
626
  test('빈 patch 통과 (모든 필드 optional)', () => {
627
627
  expect(BoardPatchSchema.safeParse({}).success).toBe(true)
628
628
  })
@@ -650,7 +650,7 @@ describe('BoardPatchSchema — modifyBoard 의 patch 검증', () => {
650
650
  })
651
651
 
652
652
  test('★ fillStyle 안 gradientStops (stale 이름) — 거부 (FillStyleSchema strict)', () => {
653
- // 본 사고의 핵심 회귀 방지 — modifyBoard 로 gradientStops 흘러들어오면 catch
653
+ // 본 사고의 핵심 회귀 방지 — modifyScene 로 gradientStops 흘러들어오면 catch
654
654
  const r = BoardPatchSchema.safeParse({
655
655
  fillStyle: {
656
656
  type: 'gradient',
@@ -682,7 +682,7 @@ describe('BoardPatchSchema — modifyBoard 의 patch 검증', () => {
682
682
  })
683
683
 
684
684
  test('shadow nested unknown key — 거부 (ShadowSchema strict)', () => {
685
- // 다른 alias leak 시나리오 — setShadow 의 옛 이름 'blur' 가 modifyBoard 를 통해
685
+ // 다른 alias leak 시나리오 — setShadow 의 옛 이름 'blur' 가 modifyScene 를 통해
686
686
  // shadow 안에 박히려 할 때 (실제로는 root 에 shadow 가 없지만 검증 흐름 보존)
687
687
  const r = BoardPatchSchema.safeParse({
688
688
  shadow: { blur: 8, offsetX: 4 } as any
@@ -336,7 +336,7 @@ export const ComponentSchema = ComponentBaseSchema.superRefine((component, ctx)
336
336
  * • 식별: id, refid, type
337
337
  *
338
338
  * ⚠ `name` 은 things-scene model-layer 가 인식 안 함 — Board *엔티티* 의 컬럼이지
339
- * scene MODEL 의 필드가 아님. AI 가 modifyBoard 로 name 을 보내도 죽은 필드. 검증
339
+ * scene MODEL 의 필드가 아님. AI 가 modifyScene 로 name 을 보내도 죽은 필드. 검증
340
340
  * 시에도 제거 / 무시 정책.
341
341
  *
342
342
  * `passthrough` 인 이유 — 3D 광원/카메라/sky 등 키가 너무 많아 strict 로는 회귀 위험
@@ -384,14 +384,14 @@ export const ComponentPatchSchema = z
384
384
  .passthrough() // 위치 / 크기 / 컴포넌트 specific 통과
385
385
 
386
386
  /**
387
- * modifyBoard op 의 patch — 보드 root partial.
387
+ * modifyScene op 의 patch — 보드 root partial.
388
388
  *
389
389
  * 모든 root 필드 optional (BoardModelSchema 와 달리 width/height 도 optional).
390
390
  * passthrough 로 알려지지 않은 root 키 (3D atmosphere, sky, camera 등) 통과.
391
391
  * 다만 *알려진 nested object* (fillStyle 등) 는 strict schema 재사용 — 그 안에
392
392
  * unknown key 가 들어오면 거부 (예: fillStyle 안 gradientStops 같은 stale 이름).
393
393
  *
394
- * AI 가 setFill 등에서 학습한 vocabulary 를 modifyBoard 로 echo 시킬 때 stale
394
+ * AI 가 setFill 등에서 학습한 vocabulary 를 modifyScene 로 echo 시킬 때 stale
395
395
  * key 가 fillStyle / shadow 같은 sub-object 안에 끼면 본 schema 가 catch.
396
396
  */
397
397
  export const BoardPatchSchema = z
@@ -438,7 +438,7 @@ export function validateComponent(component: unknown): ValidationResult {
438
438
  return { valid: false, errors: formatIssues(r.error) }
439
439
  }
440
440
 
441
- /** modifyBoard op 의 patch 검증. 보드 root partial. */
441
+ /** modifyScene op 의 patch 검증. 보드 root partial. */
442
442
  export function validateBoardPatch(patch: unknown): ValidationResult {
443
443
  const r = BoardPatchSchema.safeParse(patch)
444
444
  if (r.success) return { valid: true }
@@ -147,7 +147,7 @@ export function validateWriteToolCall(
147
147
  }
148
148
  return { valid: true }
149
149
  }
150
- case 'modifyBoard': {
150
+ case 'modifyScene': {
151
151
  // 보드 root partial 검증 — fillStyle/shadow 같은 nested 의 strict schema 가
152
152
  // unknown key (e.g. fillStyle.gradientStops 같은 stale 이름) 를 거부.
153
153
  // 이게 마지막 방어선 — 다른 styling tool 에서 학습된 vocabulary 가 leak 되면 catch.
@@ -159,7 +159,7 @@ export function validateWriteToolCall(
159
159
  return {
160
160
  valid: false,
161
161
  errors: formatValidationErrors(v),
162
- suggestion: modifyBoardSuggestion()
162
+ suggestion: modifySceneSuggestion()
163
163
  }
164
164
  }
165
165
  return { valid: true }
@@ -174,7 +174,7 @@ export function validateWriteToolCall(
174
174
  return {
175
175
  valid: false,
176
176
  errors: formatValidationErrors(v),
177
- suggestion: modifyBoardSuggestion()
177
+ suggestion: modifySceneSuggestion()
178
178
  }
179
179
  }
180
180
  return { valid: true }
@@ -186,9 +186,9 @@ export function validateWriteToolCall(
186
186
  }
187
187
  }
188
188
 
189
- function modifyBoardSuggestion(): string {
189
+ function modifySceneSuggestion(): string {
190
190
  return (
191
- 'modifyBoard 의 patch 는 things-scene 의 model-layer 가 인식하는 root key 만 사용. ' +
191
+ 'modifyScene 의 patch 는 things-scene 의 model-layer 가 인식하는 root key 만 사용. ' +
192
192
  'fillStyle / strokeStyle / shadow 같은 nested object 의 안쪽 필드는 canonical 이름 (colorStops 등) 정확히 사용. ' +
193
193
  'rect 등 자식 컴포넌트 변경은 modifyComponentByRefid 로 분리.'
194
194
  )
@@ -1,47 +0,0 @@
1
- /**
2
- * BoardEditPatch 를 BoardModel 에 적용하는 표준 helper.
3
- *
4
- * 호출자가 patch 응답을 받아 보드에 반영할 때 사용.
5
- * Pure function — 입력을 mutate 하지 않음.
6
- */
7
- import type { BoardComponent, BoardModel } from '@things-factory/board-import';
8
- import type { BoardEditOp, BoardEditPatch } from './types.js';
9
- export interface PatchApplyReport {
10
- /** 패치 적용 후 보드. 모든 op 가 noop 이어도 입력 그대로 반환. */
11
- board: BoardModel;
12
- /** 실제로 보드를 바꾼 op 들. */
13
- applied: BoardEditOp[];
14
- /** id 매칭 실패 등으로 noop 이 된 op 들 — 호출자가 사용자에게 알릴 단서. */
15
- missed: BoardEditOp[];
16
- }
17
- export declare function applyBoardEditPatch(board: BoardModel | undefined, patch: BoardEditPatch): BoardModel;
18
- /**
19
- * Verbose 변형 — 각 op 의 적용 여부를 보고.
20
- *
21
- * `modify` 와 `remove` 는 id 가 보드에 없으면 silent no-op 이 된다 (patch 함수의
22
- * 의도적 단순화). LLM 이 잘못된 id 를 만들어 보내면 사용자에게 "수정했습니다"
23
- * 라고 답하지만 실제로는 아무 변화도 없는 상황이 발생 — 호스트가 missed 를
24
- * 보고 사용자에게 경고할 수 있도록 별도 entry point 제공.
25
- */
26
- export declare function applyBoardEditPatchVerbose(board: BoardModel | undefined, patch: BoardEditPatch): PatchApplyReport;
27
- /**
28
- * 주어진 board 상태에서 op 의 inverse 를 계산.
29
- *
30
- * Revert 기능의 코어 — patch 적용 직전 board 와 op 만 알면 그 op 의 역연산을
31
- * 만들 수 있으므로, 호스트가 in-place 적용하면서 함께 누적해 두면 나중에
32
- * 역순 실행만으로 복원 가능.
33
- *
34
- * 반환:
35
- * - inverse op 또는 null (unsupported / 데이터 부족)
36
- * - add 의 inverse 는 add 후 발급되는 refid 가 필요해서 모델 단계에서 계산 불가 →
37
- * 호스트가 scene.add 직후 refid 를 캡처해 직접 만들 것 (이 함수는 pre-applied
38
- * board 만 보고 만들 수 있는 종류만 처리: remove / modify / modifyBoard / replace)
39
- */
40
- export declare function computeInverseOp(board: BoardModel | undefined, op: BoardEditOp): BoardEditOp | null;
41
- export declare function applyOp(board: BoardModel, op: BoardEditOp): BoardModel;
42
- /**
43
- * 컴포넌트에 부분 patch 를 적용.
44
- * threeD 등 nested object 는 deep merge — 호출자가 색만 바꾸려고 했을 때 geometry 까지 사라지지 않도록.
45
- * patchVal === null 이면 해당 키를 제거한다 (위 시맨틱 참조).
46
- */
47
- export declare function mergeComponent(base: BoardComponent, patch: Partial<BoardComponent>): BoardComponent;
@@ -1,369 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.applyBoardEditPatch = applyBoardEditPatch;
4
- exports.applyBoardEditPatchVerbose = applyBoardEditPatchVerbose;
5
- exports.computeInverseOp = computeInverseOp;
6
- exports.applyOp = applyOp;
7
- exports.mergeComponent = mergeComponent;
8
- const EMPTY_BOARD = {
9
- width: 1000,
10
- height: 600,
11
- components: []
12
- };
13
- function applyBoardEditPatch(board, patch) {
14
- return applyBoardEditPatchVerbose(board, patch).board;
15
- }
16
- /**
17
- * Verbose 변형 — 각 op 의 적용 여부를 보고.
18
- *
19
- * `modify` 와 `remove` 는 id 가 보드에 없으면 silent no-op 이 된다 (patch 함수의
20
- * 의도적 단순화). LLM 이 잘못된 id 를 만들어 보내면 사용자에게 "수정했습니다"
21
- * 라고 답하지만 실제로는 아무 변화도 없는 상황이 발생 — 호스트가 missed 를
22
- * 보고 사용자에게 경고할 수 있도록 별도 entry point 제공.
23
- */
24
- function applyBoardEditPatchVerbose(board, patch) {
25
- let result = board ?? EMPTY_BOARD;
26
- const applied = [];
27
- const missed = [];
28
- for (const op of patch.ops) {
29
- if (SCENE_ONLY_OPS.has(op.op)) {
30
- // model 차원 noop 이지만 scene 차원 실제 적용 — applied 로 분류.
31
- applied.push(op);
32
- continue;
33
- }
34
- const next = applyOp(result, op);
35
- // componentsUnchanged 는 components 배열 동일 + width/height/fillStyle 동일 만 본다.
36
- // modify / remove 의 silent no-op (ghost refid) 검출 전용 — modifyBoard 가 sky
37
- // 등 다른 root 키만 바꿀 때 false negative 로 missed 분류되는 회귀 방지.
38
- const componentMutating = op.op === 'modify' || op.op === 'remove';
39
- if (next === result || (componentMutating && componentsUnchanged(result, next))) {
40
- missed.push(op);
41
- }
42
- else {
43
- applied.push(op);
44
- result = next;
45
- }
46
- }
47
- return { board: result, applied, missed };
48
- }
49
- function componentsUnchanged(prev, next) {
50
- // applyOp 는 항상 새 객체를 만든다 (`{ ...board, components: ... }`). 따라서 reference
51
- // 비교가 안 되고 내용 비교가 필요. components 는 map/filter 결과 reference 도 다를 수
52
- // 있으므로 length + JSON 깊이 비교.
53
- const a = prev.components ?? [];
54
- const b = next.components ?? [];
55
- if (a.length !== b.length)
56
- return false;
57
- for (let i = 0; i < a.length; i++) {
58
- if (a[i] !== b[i])
59
- return false;
60
- }
61
- // root meta (width/height/...) 비교
62
- return prev.width === next.width && prev.height === next.height && prev.fillStyle === next.fillStyle;
63
- }
64
- /**
65
- * 주어진 board 상태에서 op 의 inverse 를 계산.
66
- *
67
- * Revert 기능의 코어 — patch 적용 직전 board 와 op 만 알면 그 op 의 역연산을
68
- * 만들 수 있으므로, 호스트가 in-place 적용하면서 함께 누적해 두면 나중에
69
- * 역순 실행만으로 복원 가능.
70
- *
71
- * 반환:
72
- * - inverse op 또는 null (unsupported / 데이터 부족)
73
- * - add 의 inverse 는 add 후 발급되는 refid 가 필요해서 모델 단계에서 계산 불가 →
74
- * 호스트가 scene.add 직후 refid 를 캡처해 직접 만들 것 (이 함수는 pre-applied
75
- * board 만 보고 만들 수 있는 종류만 처리: remove / modify / modifyBoard / replace)
76
- */
77
- function computeInverseOp(board, op) {
78
- if (!board)
79
- return null;
80
- const components = board.components ?? [];
81
- switch (op.op) {
82
- case 'add':
83
- // add 의 inverse 는 새로 발급될 refid 를 알아야 → 호스트 측에서 처리
84
- return null;
85
- case 'remove': {
86
- // 자식 refid 도 매칭 (deep search).
87
- const target = findComponentDeep(components, op.refid);
88
- if (!target)
89
- return null; // 매칭 실패 — silent no-op 이라 inverse 도 없음
90
- return { op: 'add', component: JSON.parse(JSON.stringify(target)) };
91
- }
92
- case 'modify': {
93
- // 자식 refid 도 매칭 (deep search).
94
- const target = findComponentDeep(components, op.refid);
95
- if (!target)
96
- return null;
97
- // patch 가 건드린 키만 보관. nested 는 통째로.
98
- const oldValues = {};
99
- for (const k of Object.keys(op.patch || {})) {
100
- const v = target[k];
101
- oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v));
102
- }
103
- return { op: 'modify', refid: op.refid, patch: oldValues };
104
- }
105
- case 'modifyBoard': {
106
- const oldValues = {};
107
- const patch = op.patch || {};
108
- for (const k of Object.keys(patch)) {
109
- if (k === 'components')
110
- continue;
111
- const v = board[k];
112
- oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v));
113
- }
114
- return { op: 'modifyBoard', patch: oldValues };
115
- }
116
- case 'replace':
117
- // 이전 보드 통째로 보관 — replace 의 자연스러운 inverse 는 또 다른 replace
118
- return { op: 'replace', board: JSON.parse(JSON.stringify(board)) };
119
- case 'zorder': {
120
- // forward ↔ backward / front ↔ back — 단순 반전. front/back 의 inverse 는
121
- // 정확하지 않을 수 있음 (front 했다가 back 하면 원래 위치 보장 X), 호스트가
122
- // 정확한 inverse 를 만들려면 zorder 직전 index 를 캡처해서 명시 modify 시퀀스로.
123
- // 여기서는 best-effort 만.
124
- const opp = {
125
- forward: 'backward',
126
- backward: 'forward',
127
- front: 'back',
128
- back: 'front'
129
- };
130
- const dir = opp[op.direction];
131
- if (!dir)
132
- return null;
133
- return { op: 'zorder', refid: op.refid, direction: dir };
134
- }
135
- case 'align':
136
- case 'distribute':
137
- case 'group':
138
- case 'ungroup':
139
- case 'arrange':
140
- // 좌표 변경 / 부모 재구성 / 새 group refid 발급 등은 scene 호출 후에야
141
- // 알 수 있음. 호스트가 직접 inverse 만든다 (변경 컴포넌트 좌표 백업 →
142
- // modify 시퀀스, group 의 새 refid 캡처 → ungroup 등).
143
- return null;
144
- default:
145
- return null;
146
- }
147
- }
148
- /**
149
- * Scene 조작 op 들 — model 차원 시뮬레이션이 things-scene 의 결과와 일치 보장
150
- * 어려움 (things-scene 의 정확한 좌표 계산 / refid 발급 / 부모 재구성 로직). 따라서
151
- * apply-patch 에서는 noop. 호스트가 things-scene 의 정본 API (scene.align,
152
- * scene.distribute, scene.group/ungroup, scene.zorder) 를 직접 호출.
153
- *
154
- * verbose 분류에서는 무조건 applied 로 — model 변화가 안 보여도 scene 차원 실제 적용됨.
155
- */
156
- const SCENE_ONLY_OPS = new Set([
157
- 'align',
158
- 'distribute',
159
- 'group',
160
- 'ungroup',
161
- 'zorder',
162
- 'arrange'
163
- ]);
164
- function applyOp(board, op) {
165
- const components = board.components ?? [];
166
- switch (op.op) {
167
- case 'replace':
168
- return op.board;
169
- case 'add':
170
- // top-level 추가 — 본 op 는 자식 컴포넌트로의 추가는 지원 안 함 (별도 op 필요)
171
- return { ...board, components: [...components, op.component] };
172
- case 'remove':
173
- // 자식 (group/container 안) refid 도 매칭 — deep search.
174
- return { ...board, components: removeComponentDeep(components, op.refid) };
175
- case 'modify':
176
- // 자식 refid 도 매칭 — group/container 안의 컴포넌트 수정 가능.
177
- return { ...board, components: modifyComponentDeep(components, op.refid, op.patch) };
178
- case 'modifyBoard': {
179
- // 루트 속성 (fillStyle / width / height / name 등) 만 deep merge.
180
- // components 키는 무시 — 자식 변경은 add/remove/modify 별도 op 로.
181
- const patch = { ...op.patch };
182
- delete patch.components;
183
- return mergeBoardRoot(board, patch);
184
- }
185
- case 'align':
186
- case 'distribute':
187
- case 'group':
188
- case 'ungroup':
189
- case 'zorder':
190
- case 'arrange':
191
- // scene-only — model 차원 noop. 정본은 things-scene API 가.
192
- return board;
193
- default:
194
- return board;
195
- }
196
- }
197
- /**
198
- * patch 내 `null` 의 시맨틱 — **"이 키를 제거하라"**.
199
- *
200
- * 이유:
201
- * 1. fillStyle/strokeStyle 처럼 `string | object | undefined` 인 필드는
202
- * null 로 set 하면 typeof null === 'object' 함정에 빠져 다운스트림이 크래시.
203
- * 2. computeInverseOp 가 base 에 키가 없던 (`undefined`) 경우 inverse 를 null
204
- * 로 발급 — "키 제거" 의도와 정확히 일치. 새 시맨틱 하에서 inverse 가
205
- * 자연스럽게 올바로 동작.
206
- * 3. mode 전환 시 stale 필드 정리도 새 patch 에 `oldKey: null` 만 끼워서 처리 가능.
207
- *
208
- * 호출자가 명시적으로 "null 값 자체를 set" 하고 싶으면 → 별도 op 를 도입해야 하나,
209
- * 현재 보드 모델에서 의미 있는 사용처가 없음.
210
- */
211
- function mergeBoardRoot(board, patch) {
212
- const out = { ...board };
213
- for (const key of Object.keys(patch)) {
214
- const bv = board[key];
215
- const pv = patch[key];
216
- if (pv === null) {
217
- delete out[key];
218
- }
219
- else if (bv !== null &&
220
- typeof bv === 'object' &&
221
- typeof pv === 'object' &&
222
- !Array.isArray(bv) &&
223
- !Array.isArray(pv)) {
224
- out[key] = deepMergeRoot(bv, pv);
225
- }
226
- else {
227
- out[key] = pv;
228
- }
229
- }
230
- return out;
231
- }
232
- function deepMergeRoot(a, b) {
233
- const out = { ...a };
234
- for (const key of Object.keys(b)) {
235
- const av = a[key];
236
- const bv = b[key];
237
- if (bv === null) {
238
- delete out[key];
239
- }
240
- else if (av !== null &&
241
- typeof av === 'object' &&
242
- typeof bv === 'object' &&
243
- !Array.isArray(av) &&
244
- !Array.isArray(bv)) {
245
- out[key] = deepMergeRoot(av, bv);
246
- }
247
- else {
248
- out[key] = bv;
249
- }
250
- }
251
- return out;
252
- }
253
- /**
254
- * 컴포넌트에 부분 patch 를 적용.
255
- * threeD 등 nested object 는 deep merge — 호출자가 색만 바꾸려고 했을 때 geometry 까지 사라지지 않도록.
256
- * patchVal === null 이면 해당 키를 제거한다 (위 시맨틱 참조).
257
- */
258
- function mergeComponent(base, patch) {
259
- const out = { ...base };
260
- for (const key of Object.keys(patch)) {
261
- const baseVal = base[key];
262
- const patchVal = patch[key];
263
- if (patchVal === null) {
264
- delete out[key];
265
- }
266
- else if (isPlainObject(baseVal) && isPlainObject(patchVal)) {
267
- out[key] = deepMerge(baseVal, patchVal);
268
- }
269
- else {
270
- out[key] = patchVal;
271
- }
272
- }
273
- return out;
274
- }
275
- function deepMerge(a, b) {
276
- const out = { ...a };
277
- for (const key of Object.keys(b)) {
278
- const av = a[key];
279
- const bv = b[key];
280
- if (bv === null) {
281
- delete out[key];
282
- }
283
- else if (isPlainObject(av) && isPlainObject(bv)) {
284
- out[key] = deepMerge(av, bv);
285
- }
286
- else {
287
- out[key] = bv;
288
- }
289
- }
290
- return out;
291
- }
292
- function isPlainObject(v) {
293
- return v !== null && typeof v === 'object' && !Array.isArray(v);
294
- }
295
- // ── 자식 컴포넌트 (group/container 안) 까지 매칭 — deep search ─
296
- /**
297
- * 보드 트리 안에서 refid 로 컴포넌트 찾기 — 깊은 search.
298
- * top-level 만 보던 옛 동작 → group/container 자식도 매칭.
299
- */
300
- function findComponentDeep(components, refid) {
301
- for (const c of components) {
302
- if (!c)
303
- continue;
304
- if (c.refid === refid)
305
- return c;
306
- const children = c.components;
307
- if (Array.isArray(children) && children.length > 0) {
308
- const sub = findComponentDeep(children, refid);
309
- if (sub)
310
- return sub;
311
- }
312
- }
313
- return undefined;
314
- }
315
- /**
316
- * modify op 의 deep 적용 — 자식 트리까지 walk 하며 매칭되는 컴포넌트만 mergeComponent.
317
- * Pure function — 변경 없는 가지는 같은 reference 반환 (structural sharing).
318
- */
319
- function modifyComponentDeep(components, refid, patch) {
320
- let changed = false;
321
- const out = components.map(c => {
322
- if (!c)
323
- return c;
324
- if (c.refid === refid) {
325
- changed = true;
326
- return mergeComponent(c, patch);
327
- }
328
- const children = c.components;
329
- if (Array.isArray(children) && children.length > 0) {
330
- const updated = modifyComponentDeep(children, refid, patch);
331
- if (updated !== children) {
332
- changed = true;
333
- return { ...c, components: updated };
334
- }
335
- }
336
- return c;
337
- });
338
- return changed ? out : components;
339
- }
340
- /**
341
- * remove op 의 deep 적용 — 자식 트리까지 walk 하며 해당 refid 의 컴포넌트 제거.
342
- * 부모는 보존, 빈 group 도 보존 (group 이 비더라도 사용자가 의도적으로 만든 컨테이너일 수 있음).
343
- */
344
- function removeComponentDeep(components, refid) {
345
- let changed = false;
346
- const out = [];
347
- for (const c of components) {
348
- if (!c) {
349
- out.push(c);
350
- continue;
351
- }
352
- if (c.refid === refid) {
353
- changed = true;
354
- continue; // 이 컴포넌트 자체를 제거
355
- }
356
- const children = c.components;
357
- if (Array.isArray(children) && children.length > 0) {
358
- const updated = removeComponentDeep(children, refid);
359
- if (updated !== children) {
360
- changed = true;
361
- out.push({ ...c, components: updated });
362
- continue;
363
- }
364
- }
365
- out.push(c);
366
- }
367
- return changed ? out : components;
368
- }
369
- //# sourceMappingURL=apply-patch.js.map