@things-factory/board-ai 10.0.0-beta.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/client/components/board-ai-chat.test.ts +120 -0
  2. package/client/components/board-ai-chat.ts +1502 -0
  3. package/client/components/chat-input-builder.ts +40 -0
  4. package/client/components/markdown.test.ts +220 -0
  5. package/client/components/markdown.ts +184 -0
  6. package/client/index.ts +11 -0
  7. package/client/tsconfig.json +13 -0
  8. package/client/utils/board-edit-patch.ts +200 -0
  9. package/config/config.development.js +43 -0
  10. package/config/config.production.js +15 -0
  11. package/dist-client/components/board-ai-chat.d.ts +127 -0
  12. package/dist-client/components/board-ai-chat.js +1455 -0
  13. package/dist-client/components/board-ai-chat.js.map +1 -0
  14. package/dist-client/components/board-ai-chat.test.d.ts +1 -0
  15. package/dist-client/components/board-ai-chat.test.js +112 -0
  16. package/dist-client/components/board-ai-chat.test.js.map +1 -0
  17. package/dist-client/components/chat-input-builder.d.ts +30 -0
  18. package/dist-client/components/chat-input-builder.js +25 -0
  19. package/dist-client/components/chat-input-builder.js.map +1 -0
  20. package/dist-client/components/markdown.d.ts +16 -0
  21. package/dist-client/components/markdown.js +167 -0
  22. package/dist-client/components/markdown.js.map +1 -0
  23. package/dist-client/components/markdown.test.d.ts +1 -0
  24. package/dist-client/components/markdown.test.js +187 -0
  25. package/dist-client/components/markdown.test.js.map +1 -0
  26. package/dist-client/index.d.ts +11 -0
  27. package/dist-client/index.js +12 -0
  28. package/dist-client/index.js.map +1 -0
  29. package/dist-client/tsconfig.tsbuildinfo +1 -0
  30. package/dist-client/utils/board-edit-patch.d.ts +73 -0
  31. package/dist-client/utils/board-edit-patch.js +159 -0
  32. package/dist-client/utils/board-edit-patch.js.map +1 -0
  33. package/dist-server/index.d.ts +21 -0
  34. package/dist-server/index.js +25 -0
  35. package/dist-server/index.js.map +1 -0
  36. package/dist-server/service/apply-patch.d.ts +46 -0
  37. package/dist-server/service/apply-patch.js +211 -0
  38. package/dist-server/service/apply-patch.js.map +1 -0
  39. package/dist-server/service/assistant.d.ts +75 -0
  40. package/dist-server/service/assistant.js +1298 -0
  41. package/dist-server/service/assistant.js.map +1 -0
  42. package/dist-server/service/board-ai-resolver.d.ts +40 -0
  43. package/dist-server/service/board-ai-resolver.js +260 -0
  44. package/dist-server/service/board-ai-resolver.js.map +1 -0
  45. package/dist-server/service/chat-message/chat-message.d.ts +24 -0
  46. package/dist-server/service/chat-message/chat-message.js +108 -0
  47. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  48. package/dist-server/service/chat-message/index.d.ts +3 -0
  49. package/dist-server/service/chat-message/index.js +7 -0
  50. package/dist-server/service/chat-message/index.js.map +1 -0
  51. package/dist-server/service/chat-session/chat-session.d.ts +22 -0
  52. package/dist-server/service/chat-session/chat-session.js +109 -0
  53. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  54. package/dist-server/service/chat-session/index.d.ts +3 -0
  55. package/dist-server/service/chat-session/index.js +7 -0
  56. package/dist-server/service/chat-session/index.js.map +1 -0
  57. package/dist-server/service/chat-session-resolver.d.ts +13 -0
  58. package/dist-server/service/chat-session-resolver.js +178 -0
  59. package/dist-server/service/chat-session-resolver.js.map +1 -0
  60. package/dist-server/service/index.d.ts +14 -0
  61. package/dist-server/service/index.js +26 -0
  62. package/dist-server/service/index.js.map +1 -0
  63. package/dist-server/service/patch-entry/index.d.ts +3 -0
  64. package/dist-server/service/patch-entry/index.js +7 -0
  65. package/dist-server/service/patch-entry/index.js.map +1 -0
  66. package/dist-server/service/patch-entry/patch-entry.d.ts +16 -0
  67. package/dist-server/service/patch-entry/patch-entry.js +96 -0
  68. package/dist-server/service/patch-entry/patch-entry.js.map +1 -0
  69. package/dist-server/service/types.d.ts +137 -0
  70. package/dist-server/service/types.js +3 -0
  71. package/dist-server/service/types.js.map +1 -0
  72. package/dist-server/tsconfig.tsbuildinfo +1 -0
  73. package/package.json +47 -0
  74. package/server/index.ts +21 -0
  75. package/server/service/apply-patch.test.ts +640 -0
  76. package/server/service/apply-patch.ts +250 -0
  77. package/server/service/assistant.test.ts +1317 -0
  78. package/server/service/assistant.ts +1431 -0
  79. package/server/service/board-ai-resolver.ts +239 -0
  80. package/server/service/chat-message/chat-message.ts +110 -0
  81. package/server/service/chat-message/index.ts +5 -0
  82. package/server/service/chat-session/chat-session.ts +103 -0
  83. package/server/service/chat-session/index.ts +5 -0
  84. package/server/service/chat-session-resolver.ts +154 -0
  85. package/server/service/index.ts +24 -0
  86. package/server/service/patch-entry/index.ts +5 -0
  87. package/server/service/patch-entry/patch-entry.ts +89 -0
  88. package/server/service/types.ts +138 -0
  89. package/things-factory.config.js +1 -0
  90. package/translations/en.json +39 -0
  91. package/translations/ja.json +39 -0
  92. package/translations/ko.json +40 -0
  93. package/translations/ms.json +39 -0
  94. package/translations/zh.json +39 -0
  95. package/tsconfig.json +9 -0
@@ -0,0 +1,1431 @@
1
+ /**
2
+ * DefaultBoardAIAssistant — base AIClient 위에 LLM 프롬프트로 chat() 구현.
3
+ */
4
+ import type {
5
+ AIClient,
6
+ AIMessage,
7
+ AITool,
8
+ AIToolCall,
9
+ AIToolChatResult
10
+ } from '@things-factory/ai-client-base'
11
+ import {
12
+ getImportRules,
13
+ type BoardComponent,
14
+ type BoardModel,
15
+ type ComponentCategory
16
+ } from '@things-factory/board-import'
17
+ import type {
18
+ BoardAIAssistant,
19
+ BoardEditOp,
20
+ BoardEditPatch,
21
+ ChatOptions,
22
+ ChatResponse,
23
+ ComponentSchema,
24
+ LLMMessage,
25
+ ToolUsage
26
+ } from './types.js'
27
+
28
+ const ALL_CATEGORIES: ComponentCategory[] = [
29
+ 'container',
30
+ 'vehicle',
31
+ 'path',
32
+ 'region',
33
+ 'equipment',
34
+ 'io',
35
+ 'marker',
36
+ 'structure',
37
+ 'unknown'
38
+ ]
39
+
40
+ const MAX_BOARD_SUMMARY_COMPONENTS = 80
41
+
42
+ export interface BoardAIAssistantOptions {
43
+ /** 기본 scope (chat 호출 시 옵션이 없으면 사용) */
44
+ scopes?: string[]
45
+ knownTypes?: string[]
46
+ categories?: ComponentCategory[]
47
+ }
48
+
49
+ export class DefaultBoardAIAssistant implements BoardAIAssistant {
50
+ readonly id: string
51
+ constructor(private base: AIClient, private options: BoardAIAssistantOptions = {}) {
52
+ this.id = base.id
53
+ }
54
+
55
+ async chat(
56
+ messages: LLMMessage[],
57
+ currentBoard: BoardModel | undefined,
58
+ options: ChatOptions = {}
59
+ ): Promise<ChatResponse> {
60
+ if (messages.length === 0) {
61
+ throw new Error('chat() requires at least one message')
62
+ }
63
+
64
+ const { knownTypes, categories } = this.resolveTypesAndCategories(options)
65
+ const schemas = options.componentSchemas ?? []
66
+
67
+ // Tool calling 가능하면 그 경로 (정확성 ★) — provider 가 schema 강제, invalid type / parse error 봉쇄
68
+ if (typeof this.base.chatWithTools === 'function') {
69
+ return await this.chatViaTools(messages, currentBoard, options, knownTypes, categories, schemas)
70
+ }
71
+
72
+ // Fallback — 기존 generateJSON 경로
73
+ return await this.chatViaJSON(messages, currentBoard, options, knownTypes, categories, schemas)
74
+ }
75
+
76
+ // ── Tool calling 경로 (agentic multi-turn) ─────────────────────────
77
+
78
+ private async chatViaTools(
79
+ messages: LLMMessage[],
80
+ currentBoard: BoardModel | undefined,
81
+ options: ChatOptions,
82
+ knownTypes: string[],
83
+ categories: ComponentCategory[],
84
+ schemas: ComponentSchema[]
85
+ ): Promise<ChatResponse> {
86
+ const tools = buildBoardEditTools(knownTypes)
87
+ const systemPrompt = this.buildSystemPromptForTools(knownTypes, categories, schemas)
88
+
89
+ const selectedRefids = (options.selectedRefids ?? []).filter(
90
+ (n): n is number => typeof n === 'number' && Number.isFinite(n)
91
+ )
92
+ // selection 정보는 항상 포함 (비어 있어도) — LLM 이 "선택된 게 뭐냐" 류 질문에
93
+ // getSelection 을 호출할지 판단하는 데 필요.
94
+ const selectionContext = selectedRefids.length > 0
95
+ ? `\n\nUser-selected component refids: ${JSON.stringify(selectedRefids)}\n` +
96
+ `When the user says "selected" / "선택한" / "this" / "그것" / "these" etc. ` +
97
+ `WITHOUT specifying targets, operate ONLY on these refids. ` +
98
+ `Do not modify other components unless explicitly asked. ` +
99
+ `For inspecting selected components, prefer calling \`getSelection\` over guessing.`
100
+ : `\n\nNo component is currently selected. If the user asks about "selected" / "this", ` +
101
+ `clarify or call \`getSelection\` to confirm.`
102
+
103
+ const boardContext = currentBoard
104
+ ? `Current board state (JSON, summarized; full details via \`getComponent\` / \`findComponents\`):\n` +
105
+ `${JSON.stringify(summarizeBoard(currentBoard), null, 2)}` + selectionContext
106
+ : 'No current board (empty start).' + selectionContext
107
+
108
+ let conversation: AIMessage[] = [
109
+ { role: 'user', content: boardContext },
110
+ ...messages.map<AIMessage>(m => ({
111
+ role: m.role === 'assistant' ? 'assistant' : 'user',
112
+ content: m.content
113
+ }))
114
+ ]
115
+
116
+ // multi-turn loop — LLM 이 read tool 을 호출하면 서버에서 즉시 실행 → 결과 회신 →
117
+ // LLM 이 다시 추론. write tool (add/remove/modify/replace) 은 누적해서 client 에 patch
118
+ // 로 전달. 무한 루프 방지를 위해 iteration cap.
119
+ const MAX_ITERATIONS = 8
120
+ const accumulatedWriteCalls: AIToolCall[] = []
121
+ const toolUsages: ToolUsage[] = []
122
+ let lastText: string | undefined
123
+ let stopReason: string = 'end_turn'
124
+
125
+ for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
126
+ const result: AIToolChatResult = await this.base.chatWithTools!(conversation, tools, {
127
+ systemPrompt,
128
+ model: options.model,
129
+ temperature: options.temperature,
130
+ maxTokens: options.maxTokens,
131
+ toolChoice: 'auto',
132
+ allowParallelToolCalls: true
133
+ })
134
+
135
+ lastText = result.text || lastText
136
+ stopReason = result.stopReason
137
+
138
+ if (result.toolCalls.length === 0) break
139
+
140
+ // tool 결과 빌드 — read tool 은 서버 즉시 실행, write tool 은 누적 + LLM 에게 "queued"
141
+ const toolResultParts: any[] = []
142
+ for (const tc of result.toolCalls) {
143
+ if (isReadTool(tc.name)) {
144
+ const value = executeReadTool(tc, currentBoard, selectedRefids)
145
+ toolResultParts.push({
146
+ type: 'tool_result',
147
+ toolUseId: tc.id,
148
+ content: JSON.stringify(value)
149
+ })
150
+ toolUsages.push({
151
+ name: tc.name,
152
+ arguments: tc.arguments ?? {},
153
+ result: summarizeToolResult(value),
154
+ kind: 'read'
155
+ })
156
+ } else if (isWriteTool(tc.name)) {
157
+ accumulatedWriteCalls.push(tc)
158
+ const queuedResult = { queued: true, note: 'Will be applied on the client.' }
159
+ toolResultParts.push({
160
+ type: 'tool_result',
161
+ toolUseId: tc.id,
162
+ content: JSON.stringify(queuedResult)
163
+ })
164
+ toolUsages.push({
165
+ name: tc.name,
166
+ arguments: tc.arguments ?? {},
167
+ result: queuedResult,
168
+ kind: 'write'
169
+ })
170
+ } else {
171
+ const errResult = { error: `Unknown tool: ${tc.name}` }
172
+ toolResultParts.push({
173
+ type: 'tool_result',
174
+ toolUseId: tc.id,
175
+ content: JSON.stringify(errResult),
176
+ isError: true
177
+ })
178
+ toolUsages.push({
179
+ name: tc.name,
180
+ arguments: tc.arguments ?? {},
181
+ result: errResult,
182
+ kind: 'unknown'
183
+ })
184
+ }
185
+ }
186
+
187
+ // assistant 의 tool_use turn + user 의 tool_result turn 을 conversation 에 누적
188
+ const assistantContent: any[] = []
189
+ if (result.text) assistantContent.push({ type: 'text', text: result.text })
190
+ for (const tc of result.toolCalls) {
191
+ // providerMeta (Gemini thoughtSignature 등) 를 그대로 round-trip — 안 보내면 400.
192
+ assistantContent.push({
193
+ type: 'tool_use',
194
+ id: tc.id,
195
+ name: tc.name,
196
+ arguments: tc.arguments,
197
+ ...(tc.providerMeta && { providerMeta: tc.providerMeta })
198
+ })
199
+ }
200
+
201
+ conversation = [
202
+ ...conversation,
203
+ { role: 'assistant', content: assistantContent },
204
+ { role: 'user', content: toolResultParts }
205
+ ]
206
+
207
+ // write 만 있고 LLM 이 추가 추론 의도 없으면 다음 turn 으로 → 보통 final reply 1 턴이면 끝.
208
+ // read 도 없으면 굳이 더 돌릴 필요 없지만 일단 LLM 이 마무리 답변 줄 수 있도록 한 번 더 호출.
209
+ }
210
+
211
+ // accumulated write calls → BoardEditPatch
212
+ const ops = accumulatedWriteCalls
213
+ .map(toolCallToBoardEditOp)
214
+ .filter((op): op is BoardEditOp => op !== null)
215
+
216
+ const reply = lastText || (ops.length > 0 ? '변경했습니다.' : '')
217
+ let patch: BoardEditPatch | undefined
218
+ if (ops.length > 0) {
219
+ patch = {
220
+ ops,
221
+ summary: lastText ?? `${ops.length} edit op(s) applied`,
222
+ confidence: 0.9
223
+ }
224
+ }
225
+
226
+ return {
227
+ reply,
228
+ patch,
229
+ followUp: undefined,
230
+ toolUsages: toolUsages.length > 0 ? toolUsages : undefined
231
+ }
232
+ }
233
+
234
+ // ── Fallback (generateJSON) 경로 ──────────────────────────────────
235
+
236
+ private async chatViaJSON(
237
+ messages: LLMMessage[],
238
+ currentBoard: BoardModel | undefined,
239
+ options: ChatOptions,
240
+ knownTypes: string[],
241
+ categories: ComponentCategory[],
242
+ schemas: ComponentSchema[]
243
+ ): Promise<ChatResponse> {
244
+ const systemPrompt = this.buildSystemPrompt(knownTypes, categories, schemas)
245
+
246
+ const userPayload = {
247
+ currentBoard: currentBoard ? summarizeBoard(currentBoard) : null,
248
+ conversation: messages
249
+ }
250
+
251
+ const raw = await this.base.generateJSON<RawChatResponse>(
252
+ `User payload (JSON):\n${JSON.stringify(userPayload, null, 2)}`,
253
+ {
254
+ systemPrompt,
255
+ model: options.model,
256
+ temperature: options.temperature,
257
+ maxTokens: options.maxTokens
258
+ }
259
+ )
260
+
261
+ return normalizeChatResponse(raw)
262
+ }
263
+
264
+ private resolveTypesAndCategories(options: ChatOptions): {
265
+ knownTypes: string[]
266
+ categories: ComponentCategory[]
267
+ } {
268
+ const explicitTypes = options.knownTypes ?? this.options.knownTypes
269
+ const explicitCats = (options.categories ?? this.options.categories) as ComponentCategory[] | undefined
270
+ if (explicitTypes && explicitCats) {
271
+ return { knownTypes: explicitTypes, categories: explicitCats }
272
+ }
273
+
274
+ const scopes = options.scopes ?? this.options.scopes
275
+ if (scopes && scopes.length > 0) {
276
+ const rules = getImportRules(scopes)
277
+ const types = explicitTypes ?? Array.from(new Set(rules.map(r => r.componentType))).filter(Boolean)
278
+ const cats =
279
+ explicitCats ??
280
+ (Array.from(new Set(rules.map(r => r.category).filter(Boolean))) as ComponentCategory[])
281
+ return {
282
+ knownTypes: types,
283
+ categories: cats.length > 0 ? cats : ALL_CATEGORIES
284
+ }
285
+ }
286
+
287
+ return {
288
+ knownTypes: explicitTypes ?? [],
289
+ categories: explicitCats ?? ALL_CATEGORIES
290
+ }
291
+ }
292
+
293
+ private buildSystemPrompt(
294
+ knownTypes: string[],
295
+ categories: ComponentCategory[],
296
+ schemas: ComponentSchema[]
297
+ ): string {
298
+ const hasTypes = knownTypes.length > 0
299
+ const typeLine = hasTypes ? knownTypes.join(', ') : '(none registered)'
300
+ const catLine = categories.join(', ')
301
+ const schemaSection = formatSchemas(schemas, knownTypes)
302
+
303
+ return `You are a board modeling assistant. The user describes board components in natural language and you respond with:
304
+ 1. A short conversational reply in the same language as the user.
305
+ 2. Optionally, a board edit patch (4 op kinds: add, remove, modify, replace).
306
+
307
+ ==== AVAILABLE COMPONENT TYPES (STRICT WHITELIST) ====
308
+ ${typeLine}
309
+
310
+ ==== AVAILABLE GROUPS / CATEGORIES ====
311
+ ${catLine}
312
+
313
+ ==== COMPONENT SCHEMAS (type-specific geometry + properties) ====
314
+ ${schemaSection}
315
+
316
+ CRITICAL — geometry discipline:
317
+ - Each type has its OWN geometry keys, listed under "geometry:" above.
318
+ - Use EXACTLY those keys for that type. Do NOT default to {left, top, width, height} for every type.
319
+ - Examples:
320
+ rect/image/text: left, top, width, height
321
+ circle/ellipse: cx, cy, rx (or radius), ry
322
+ line/polyline: points or path or vertices array
323
+ gauge-* may use left, top, width, height OR cx, cy depending on the variant — follow the listed geometry keys.
324
+ - For each component you add, include ALL its geometry keys with sensible numeric values (no undefined).
325
+ - For type-specific REQUIRED properties (e.g., gauge needs value/min/max, chart needs data, text needs text), include them explicitly — do not omit.
326
+
327
+
328
+ CRITICAL — type discipline:
329
+ - Component "type" MUST be a value from the AVAILABLE COMPONENT TYPES list above. Exact string match.
330
+ - DO NOT invent type names. DO NOT guess. DO NOT use domain names ("Stocker", "AGV", etc.) unless they appear verbatim in the list.
331
+ - If the user describes a domain concept (e.g., "AGV", "스토커") that is NOT in the list:
332
+ a) Pick the closest registered type from the list, OR
333
+ b) Use a generic shape from the list (e.g. 'rect', 'ellipse', 'text') with descriptive fillStyle/strokeStyle and a "name" or "label" field that captures the user's intent.
334
+ c) NEVER fabricate a new type string.
335
+ - If the AVAILABLE list is empty, you must respond with "followUp" asking the user to register components first; do NOT generate a patch.
336
+
337
+ Respond ONLY with this JSON shape:
338
+ {
339
+ "reply": "<short conversational text>",
340
+ "patch": {
341
+ "ops": [<op>...],
342
+ "summary": "<1-2 sentence summary>",
343
+ "confidence": <0..1>
344
+ } | null,
345
+ "followUp": "<clarifying question>" | null
346
+ }
347
+
348
+ Op shapes:
349
+ - {"op":"add","component":{"type":"<from list>","id":"<unique>","left":<num>,"top":<num>,"width":<num>,"height":<num>,"rotation":<num,optional>,"fillStyle":"<css color, optional>","strokeStyle":"<css color, optional>","name":"<optional label>","threeD":{"geometry":{"kind":"box|cylinder|extrude|gltf",...},"material":{"color":"...","roughness":<0..1>,"metalness":<0..1>,"opacity":<0..1>}}}}
350
+ - {"op":"remove","id":"<existing id>"}
351
+ - {"op":"modify","id":"<existing id>","patch":{<partial fields including style/position/3D material>}}
352
+ - {"op":"replace","board":{"width":<num>,"height":<num>,"components":[<component...>]}}
353
+
354
+ Style edits (color, roughness, opacity, dash, 3D material) → use "modify" op with the changed style fields inside "patch". The "modify" op covers position/size/style/3D material — do NOT invent additional op kinds.
355
+
356
+ Rules:
357
+ - If user is asking a question rather than commanding, set "patch" to null and answer in "reply".
358
+ - If user request is ambiguous, set "followUp" to a clarifying question and "patch" to null.
359
+ - When the user starts from no current board, use "replace" op with a fresh board (or sensible default size: 1000x600).
360
+ - Coordinates are in scene units (1 unit = 1 mm by convention); place components without overlap.
361
+ - Output JSON only, no markdown fences, no commentary outside JSON.`
362
+ }
363
+
364
+ // ── Tool calling 용 system prompt ──────────────────────────────────
365
+
366
+ private buildSystemPromptForTools(
367
+ knownTypes: string[],
368
+ categories: ComponentCategory[],
369
+ schemas: ComponentSchema[]
370
+ ): string {
371
+ const typeLine = knownTypes.length > 0 ? knownTypes.join(', ') : '(none registered)'
372
+ const catLine = categories.join(', ')
373
+ const schemaSection = formatSchemas(schemas, knownTypes)
374
+
375
+ return `You are a board modeling assistant. Edit the board by calling the provided tools.
376
+
377
+ ==== BOARD HIERARCHY (CRITICAL) ====
378
+ The BOARD itself is the TOPMOST PARENT. It is NOT a component — it has its own
379
+ root-level properties separate from any child:
380
+
381
+ • Root attributes (board-level): \`fillStyle\` (background color), \`width\`,
382
+ \`height\` (dimensions), \`name\` (label), and possibly other metadata.
383
+ • Children: an array of components inside the board (rect / circle / text /
384
+ gauge / etc.).
385
+
386
+ Rules:
387
+ - To change a board-level attribute (background color, board size, board name)
388
+ → call \`modifyBoard({ patch: { fillStyle: ..., width: ..., ... } })\`.
389
+ - To change a CHILD COMPONENT's attribute → call \`modifyComponentByRefid\`.
390
+ - DO NOT use modifyBoard to change a child. DO NOT use modifyComponentByRefid
391
+ to change board background.
392
+ - "보드 배경색", "board background", "테두리/캔버스 크기" 같은 요청은 root
393
+ attribute 변경 → \`modifyBoard\`.
394
+
395
+ ==== IDENTIFIER POLICY (CRITICAL) ====
396
+ Each CHILD component has TWO distinct identifier-like fields. Do NOT confuse them:
397
+
398
+ • \`refid\` (number) — UNIVERSAL HANDLE. things-scene auto-assigns to every
399
+ component. Always present. Unique per component within
400
+ a board. The ONLY identifier used to target a component
401
+ for inspection / modification / deletion.
402
+
403
+ • \`id\` (string) — DATA BINDING NAME. Optional. Multiple components MAY share
404
+ the same id (e.g., several gauges all bound to the same
405
+ data feed). NOT unique. Treat as metadata only.
406
+
407
+ Rules:
408
+ - To inspect / modify / remove a child component → use refid.
409
+ - When you see "id: 'motor-1'" in board summary, it is a data-binding label,
410
+ not a unique handle. Two components with id='motor-1' may both exist.
411
+ - findComponents may return many matches when filtered by id (data binding
412
+ group); that is correct.
413
+ - Selection from the user is always communicated as a list of refids.
414
+ - The BOARD itself does NOT have refid (only children do). Use \`modifyBoard\`
415
+ for board-level changes.
416
+
417
+ ==== AVAILABLE TOOLS ====
418
+ Board-level (root attributes):
419
+ - modifyBoard: change board's own fillStyle / width / height / name AND 3D atmosphere keys (sky / exposure / hemi* / dirLight* / floorMaterial3d / ...)
420
+ - replaceBoard: wholesale board reset (heavy — prefer modifyBoard for most changes)
421
+ Child components:
422
+ - addComponent: add a new component (parallel calls for multiple adds)
423
+ - removeComponentByRefid: remove a child by refid
424
+ - modifyComponentByRefid: edit a child's properties by refid
425
+ - getComponentByRefid: inspect full data of one child by refid
426
+ - getSelection: return full data of components currently selected by the user
427
+ - findComponents: search children by type/id/name/region — returns matches with refid
428
+ Knowledge:
429
+ - getAtmosphereGuide: 3D atmosphere synthesis primer — parameter physics, ranges, color theory, archetype recipes (time-of-day / indoor / surreal). Call BEFORE applying atmospheric/styling modifyBoard for any natural-language mood request.
430
+
431
+ ==== 3D ATMOSPHERE SYNTHESIS ====
432
+ 3D scene atmosphere = combination of (1) sky preset / color (2) exposure (3) hemisphere ambient (4) directional sun/key (5) floor material. ALL of these are root-level board attributes — applied via a SINGLE modifyBoard call.
433
+
434
+ For ANY natural-language atmospheric request — "공장 분위기로", "심해 컬러", "황혼", "사이버펑크", "안개 자욱한 새벽", "1980 우주정거장", "따뜻하게", "차갑고 극적으로" — follow this workflow:
435
+
436
+ 1. Call getAtmosphereGuide first.
437
+ 2. From the guide's "archetypes" section, pick the closest base recipe (time-of-day / indoor / surreal). Interpolate if user combines multiple cues.
438
+ 3. Apply user's nuance via the guide's "modifiers" (warm/cool, drama, saturation, contrast).
439
+ 4. Sanity-check: avoid washout (all params maxed) or mud (all minned). Confirm hemi sky/ground colors and dir color are consistent with the mood.
440
+ 5. Apply ALL keys in ONE modifyBoard({patch}) call (sky, skyColor?, exposure, hemiIntensity, hemiSkyColor, hemiGroundColor, dirLightEnabled, dirLightColor, dirLightIntensity, dirLightFollowCamera=false, dirLightAzimuth, dirLightElevation, fillStyle?). ONE patch — not multiple modifyBoard calls.
441
+ 6. In the reply, briefly state which archetype + modifier you composed.
442
+
443
+ Avoid:
444
+ - Calling modifyBoard with only "fillStyle" for atmospheric requests — that only changes 2D background and floor tint, NOT the 3D scene mood.
445
+ - Mixing a sky preset with explicit skyColor (preset wins, skyColor ignored).
446
+ - Setting dirLightAzimuth/Elevation while leaving dirLightFollowCamera=true (no effect — set follow=false first).
447
+ - Using only canned presets when the user describes a custom mood — the guide enables synthesis beyond presets.
448
+
449
+ ==== AVAILABLE COMPONENT TYPES (enum-enforced by tool schema) ====
450
+ ${typeLine}
451
+
452
+ ==== AVAILABLE GROUPS / CATEGORIES ====
453
+ ${catLine}
454
+
455
+ ==== COMPONENT SCHEMAS (type-specific geometry + properties) ====
456
+ ${schemaSection}
457
+
458
+ CRITICAL — geometry discipline:
459
+ - Each type has its OWN geometry keys, listed under "geometry:" above.
460
+ - Use EXACTLY those keys for that type. Do NOT default to {left, top, width, height} for every type.
461
+ Examples:
462
+ rect/image/text: left, top, width, height
463
+ circle/ellipse: cx, cy, rx (or radius), ry
464
+ line/polyline: points or path or vertices array
465
+ gauge-* may use left, top, width, height OR cx, cy depending on the variant.
466
+ - Include ALL the geometry keys for the type with sensible numeric values (no undefined).
467
+ - Include type-specific REQUIRED properties (gauge needs value/min/max, chart needs data, text needs text).
468
+
469
+ CRITICAL — type discipline:
470
+ - The "type" parameter is enum-restricted by the tool schema. Use ONLY values from the registered list.
471
+ - If a domain concept (e.g., "AGV", "Stocker") is NOT in the list, pick the closest registered type or use a generic shape ('rect', 'ellipse', 'text') with descriptive fillStyle and a "name" field.
472
+
473
+ Behavioral rules:
474
+ - If the user is asking a question rather than commanding, REPLY in TEXT WITHOUT calling any tool.
475
+ - If the user's request is ambiguous, REPLY in TEXT (asking for clarification) WITHOUT calling any tool.
476
+ - For multiple components, call addComponent multiple times in parallel.
477
+ - The current board JSON is provided as the first user message in the conversation.
478
+
479
+ Reply formatting rules (chat surface):
480
+ - Reply ONCE with ONE form only. NEVER restate the same answer in a second form (e.g. do NOT add a prose summary after a bullet list, or vice versa).
481
+ - Keep replies concise — chat-style, not document-style. Prefer 1–4 sentences or a short bullet list.
482
+ - Use markdown for emphasis (**bold**, *italic*) and short lists. Avoid headings (#) and long structured documents.
483
+ - Use the same language as the user's last message.`
484
+ }
485
+ }
486
+
487
+ // ── Tool 정의 ──────────────────────────────────────────────────────
488
+
489
+ /**
490
+ * 4 op 직교 set 을 4 개의 도구로 정의.
491
+ *
492
+ * 핵심: addComponent 의 `type` 파라미터가 knownTypes 의 enum 으로 강제 →
493
+ * LLM 이 등록 안 된 type 을 만들 수 없음 (provider 단계에서 차단).
494
+ *
495
+ * 좌표/크기 키들은 모두 optional 로 허용 (type 별로 사용 키 다름).
496
+ * type-specific 추가 속성은 additionalProperties 로 허용.
497
+ */
498
+ export function buildBoardEditTools(knownTypes: string[]): AITool[] {
499
+ const typeProp: Record<string, any> = {
500
+ type: 'string',
501
+ description: 'Registered component type (must be from the enum).'
502
+ }
503
+ if (knownTypes.length > 0) {
504
+ typeProp.enum = knownTypes
505
+ }
506
+
507
+ const componentSchema: Record<string, any> = {
508
+ type: 'object',
509
+ description: 'Component fields (geometry + style + type-specific properties).',
510
+ properties: {
511
+ type: typeProp,
512
+ id: { type: 'string', description: 'Unique id within the board.' },
513
+ // 좌상단 기반 박스 좌표
514
+ left: { type: 'number' },
515
+ top: { type: 'number' },
516
+ width: { type: 'number' },
517
+ height: { type: 'number' },
518
+ rotation: { type: 'number' },
519
+ // 중심 기반 좌표 (circle/ellipse 등)
520
+ cx: { type: 'number' },
521
+ cy: { type: 'number' },
522
+ radius: { type: 'number' },
523
+ rx: { type: 'number' },
524
+ ry: { type: 'number' },
525
+ // 경로 (line/polyline 등)
526
+ points: { type: 'array', items: { type: 'object' } },
527
+ // 일반 스타일
528
+ fillStyle: { type: 'string', description: 'CSS color or pattern.' },
529
+ strokeStyle: { type: 'string' },
530
+ strokeWidth: { type: 'number' },
531
+ strokeDashArray: { type: 'string' },
532
+ opacity: { type: 'number' },
533
+ name: { type: 'string', description: 'Optional human label.' }
534
+ },
535
+ required: ['type'],
536
+ additionalProperties: true
537
+ }
538
+
539
+ return [
540
+ {
541
+ name: 'addComponent',
542
+ description:
543
+ 'Add a new component to the board. Call this multiple times in parallel for multiple adds. Use the type-specific geometry keys (left/top vs cx/cy etc.) and include any required type-specific properties.',
544
+ parameters: componentSchema
545
+ },
546
+ {
547
+ name: 'removeComponentByRefid',
548
+ description:
549
+ 'Remove an existing component by its `refid` — the universal numeric handle that things-scene auto-assigns to every component (visible in board summary as the `refid` field of each component).',
550
+ parameters: {
551
+ type: 'object',
552
+ properties: {
553
+ refid: {
554
+ type: 'number',
555
+ description: 'things-scene auto-assigned numeric handle. Always present.'
556
+ }
557
+ },
558
+ required: ['refid']
559
+ }
560
+ },
561
+ {
562
+ name: 'modifyComponentByRefid',
563
+ description:
564
+ 'Modify properties of an existing component by its `refid`. The patch is deep-merged with the existing component (style/position/size/3D material all in one).',
565
+ parameters: {
566
+ type: 'object',
567
+ properties: {
568
+ refid: {
569
+ type: 'number',
570
+ description: 'things-scene auto-assigned numeric handle.'
571
+ },
572
+ patch: {
573
+ type: 'object',
574
+ description: 'Partial fields to merge.',
575
+ additionalProperties: true
576
+ }
577
+ },
578
+ required: ['refid', 'patch']
579
+ }
580
+ },
581
+ {
582
+ name: 'modifyBoard',
583
+ description:
584
+ "Modify the BOARD's OWN root-level properties — the board itself is the topmost parent and has its own attributes (NOT child components). Common keys: `fillStyle` (background color), `width`, `height`, `name`. Use this to change the board background, size, or label. Do NOT use for child component edits (use modifyComponentByRefid for those). Do NOT pass a `components` array here — children are managed by add/remove/modify ops separately.",
585
+ parameters: {
586
+ type: 'object',
587
+ properties: {
588
+ patch: {
589
+ type: 'object',
590
+ description:
591
+ 'Root-level fields to merge into the board. Examples: { fillStyle: "#0a1929" } for dark navy background; { width: 1920, height: 1080 } for resize; { name: "Production Floor" } for label.',
592
+ additionalProperties: true
593
+ }
594
+ },
595
+ required: ['patch']
596
+ }
597
+ },
598
+ {
599
+ name: 'replaceBoard',
600
+ description:
601
+ 'Replace the entire board (use ONLY for fresh start or wholesale redesign — heavy operation that resets undo history). For just changing background or size, prefer modifyBoard. Provide width/height and the full components array.',
602
+ parameters: {
603
+ type: 'object',
604
+ properties: {
605
+ width: { type: 'number' },
606
+ height: { type: 'number' },
607
+ fillStyle: { type: 'string' },
608
+ components: {
609
+ type: 'array',
610
+ items: { type: 'object', additionalProperties: true }
611
+ }
612
+ },
613
+ required: ['components']
614
+ }
615
+ },
616
+ // ── Read tools (서버측 즉시 실행, 결과를 LLM 에 회신) ─────────
617
+ {
618
+ name: 'getSelection',
619
+ description:
620
+ 'Get full details of components currently selected by the user in the modeller. Call this when the user asks "what is selected?" / "선택된 게 뭐냐?" / refers to "the selected component" without specifying ids. Returns array of full component objects (no truncation).',
621
+ parameters: { type: 'object', properties: {} }
622
+ },
623
+ {
624
+ name: 'getComponentByRefid',
625
+ description:
626
+ 'Get full details of a single component by its `refid` (the things-scene-assigned universal numeric handle). Use this to inspect properties not visible in the board summary.',
627
+ parameters: {
628
+ type: 'object',
629
+ properties: {
630
+ refid: {
631
+ type: 'number',
632
+ description: 'things-scene auto-assigned numeric handle. Universal.'
633
+ }
634
+ },
635
+ required: ['refid']
636
+ }
637
+ },
638
+ {
639
+ name: 'findComponents',
640
+ description:
641
+ 'Search components by criteria. All filters are optional and AND-combined. Returns matching components (full data, capped at 50). Useful for grouping by data-binding `id` (multiple components may share an id).',
642
+ parameters: {
643
+ type: 'object',
644
+ properties: {
645
+ type: { type: 'string', description: 'Match by exact type.' },
646
+ id: {
647
+ type: 'string',
648
+ description:
649
+ 'Match by exact data-binding id. Note: id is NOT unique — multiple components may share an id. Returns all matches.'
650
+ },
651
+ namePattern: {
652
+ type: 'string',
653
+ description: 'Case-insensitive substring match on the `name` field.'
654
+ },
655
+ region: {
656
+ type: 'object',
657
+ description:
658
+ 'Bounding box. Components whose left/top fall within are returned. Provide all 4 fields when used.',
659
+ properties: {
660
+ left: { type: 'number' },
661
+ top: { type: 'number' },
662
+ width: { type: 'number' },
663
+ height: { type: 'number' }
664
+ }
665
+ }
666
+ }
667
+ }
668
+ },
669
+ {
670
+ name: 'getAtmosphereGuide',
671
+ description:
672
+ "Get a comprehensive 3D atmosphere synthesis guide — parameter physics (sky / exposure / hemisphere / directional / shadow / floor / camera), sane ranges, color theory, time-of-day archetypes, and indoor/surreal mood recipes. Call this BEFORE applying atmospheric/styling changes via modifyBoard whenever the user describes a mood / scene / time-of-day in natural language (e.g. '심해 컬러', '황혼', '공장 분위기', '사이버펑크', '안개 자욱한 새벽'). The guide enables you to synthesize concrete parameter values yourself, beyond canned presets.",
673
+ parameters: { type: 'object', properties: {} }
674
+ }
675
+ ]
676
+ }
677
+
678
+ // ── Read tool 분류 + 실행기 ────────────────────────────────────────
679
+
680
+ const READ_TOOL_NAMES = new Set([
681
+ 'getSelection',
682
+ 'getComponentByRefid',
683
+ 'findComponents',
684
+ 'getAtmosphereGuide'
685
+ ])
686
+ const WRITE_TOOL_NAMES = new Set([
687
+ 'addComponent',
688
+ 'removeComponentByRefid',
689
+ 'modifyComponentByRefid',
690
+ 'modifyBoard',
691
+ 'replaceBoard'
692
+ ])
693
+
694
+ const FIND_COMPONENTS_CAP = 50
695
+
696
+ /**
697
+ * Read tool 의 서버측 실행기 — currentBoard / selectedRefids 에 대해 즉시 실행해 결과 반환.
698
+ * LLM 의 다음 turn 에 tool_result 로 회신된다.
699
+ *
700
+ * 식별자 정책:
701
+ * - 컴포넌트 targeting 은 항상 refid (universal numeric handle).
702
+ * - id 는 데이터 바인딩 이름이며 unique 가 아니다 — filter 용도로만 사용 (findComponents).
703
+ */
704
+ export function executeReadTool(
705
+ call: AIToolCall,
706
+ currentBoard: BoardModel | null | undefined,
707
+ selectedRefids: number[]
708
+ ): any {
709
+ const board = currentBoard
710
+ const components = board?.components ?? []
711
+
712
+ switch (call.name) {
713
+ case 'getSelection': {
714
+ if (selectedRefids.length === 0) {
715
+ return { selected: [], message: 'No component is currently selected.' }
716
+ }
717
+ const matched = components.filter(
718
+ c => c && typeof (c as any).refid === 'number' && selectedRefids.includes((c as any).refid)
719
+ )
720
+ return { selected: matched, count: matched.length, selectedRefids }
721
+ }
722
+ case 'getComponentByRefid': {
723
+ const refid = call.arguments?.refid
724
+ if (typeof refid !== 'number') return { error: 'refid (number) is required' }
725
+ const found = components.find(c => c && (c as any).refid === refid)
726
+ if (!found) return { error: `Component with refid ${refid} not found.` }
727
+ return { component: found }
728
+ }
729
+ case 'findComponents': {
730
+ const args = call.arguments ?? {}
731
+ const type = typeof args.type === 'string' ? args.type : undefined
732
+ const namePattern =
733
+ typeof args.namePattern === 'string' && args.namePattern.length > 0
734
+ ? args.namePattern.toLowerCase()
735
+ : undefined
736
+ const idFilter = typeof args.id === 'string' && args.id.length > 0 ? args.id : undefined
737
+ const region =
738
+ args.region &&
739
+ typeof args.region === 'object' &&
740
+ typeof args.region.left === 'number' &&
741
+ typeof args.region.top === 'number' &&
742
+ typeof args.region.width === 'number' &&
743
+ typeof args.region.height === 'number'
744
+ ? args.region
745
+ : undefined
746
+
747
+ const matched = components.filter((c: any) => {
748
+ if (!c) return false
749
+ if (type && c.type !== type) return false
750
+ if (idFilter && c.id !== idFilter) return false
751
+ if (namePattern) {
752
+ const name = typeof c.name === 'string' ? c.name.toLowerCase() : ''
753
+ if (!name.includes(namePattern)) return false
754
+ }
755
+ if (region) {
756
+ const left = typeof c.left === 'number' ? c.left : c.cx
757
+ const top = typeof c.top === 'number' ? c.top : c.cy
758
+ if (typeof left !== 'number' || typeof top !== 'number') return false
759
+ if (
760
+ left < region.left ||
761
+ left > region.left + region.width ||
762
+ top < region.top ||
763
+ top > region.top + region.height
764
+ ) {
765
+ return false
766
+ }
767
+ }
768
+ return true
769
+ })
770
+
771
+ return {
772
+ components: matched.slice(0, FIND_COMPONENTS_CAP),
773
+ totalMatched: matched.length,
774
+ truncated: matched.length > FIND_COMPONENTS_CAP
775
+ }
776
+ }
777
+ case 'getAtmosphereGuide':
778
+ return ATMOSPHERE_GUIDE
779
+ default:
780
+ return { error: `Unknown read tool: ${call.name}` }
781
+ }
782
+ }
783
+
784
+ /**
785
+ * 3D 분위기 합성 지식 primer.
786
+ *
787
+ * 의도 — preset 한 단어로 끝나지 않는 사용자 요청 ("심해", "황혼", "1980 우주정거장",
788
+ * "안개 새벽") 에 AI 가 구체적 파라미터 값을 직접 합성하도록 물리/색채/미학 지식
789
+ * 동시 제공. AI 는 이 가이드를 받은 뒤 modifyBoard 로 한 번에 적용.
790
+ *
791
+ * things-scene 의 board model 에 들어가는 root level 키만 다룸 (modifyBoard 로
792
+ * 적용 가능한 것). camera position 같은 ephemeral 한 것은 별도 영역.
793
+ */
794
+ const ATMOSPHERE_GUIDE = {
795
+ overview:
796
+ '3D 보드 분위기는 (1) sky preset 또는 단색 (2) tone mapping exposure (3) hemisphere ambient (4) directional sun/key (5) floor 의 조합. 모두 board root model 의 속성이라 modifyBoard 단일 op 로 한 번에 적용. 자연어 요청은 archetype 레시피를 base 로 잡고 modifier 로 fine-tune.',
797
+
798
+ parameters: {
799
+ sky: {
800
+ description:
801
+ '3D 배경 + IBL (image-based lighting) 의 source. preset / "color" / "" 셋 중 하나.',
802
+ presets: {
803
+ studio: 'Neutral white studio with soft IBL — product-photo / showroom 분위기',
804
+ warehouse: 'Indoor industrial space — moderate ambient, warm-tinted',
805
+ factory: 'Bright factory floor — high ceiling lights, ~6000K cool',
806
+ office: 'Clean office interior — neutral fluorescent feel',
807
+ home: 'Warm domestic interior',
808
+ sunny: 'Outdoor clear day — Three.js Sky shader, strong directional sun',
809
+ cloudy: 'Outdoor overcast — soft diffuse, low contrast',
810
+ rainy: 'Outdoor rainy — dim, cool, very diffuse'
811
+ },
812
+ special: {
813
+ color: '단색 배경 (skyColor 같이 지정). IBL 없음 — hemi/dir 만 유효.',
814
+ '""': 'no background, no IBL. 거의 안 씀.'
815
+ },
816
+ tip: '커스텀 분위기는 sky="color" + skyColor + hemi/dir 수동 튜닝 조합. preset 위에 hemi 등을 덮어쓰면 IBL 과 ambient 가 섞여 의도 흐려질 수 있다.'
817
+ },
818
+ skyColor: {
819
+ description: 'sky="color" 일 때 단색 배경. hex string.',
820
+ examples: {
821
+ '#0a1929': 'deep navy — 심해, 우주, 어두운 SF',
822
+ '#1a2540': 'midnight blue — 밤, 차가운 미래',
823
+ '#2c1b3d': 'deep violet — magical, mystical',
824
+ '#3a4a3a': 'forest moss — 자연, 안개숲',
825
+ '#f5e6d0': 'cream warm — 빈티지, 70년대',
826
+ '#0a0a0a': 'pure dark — 드라마, 미니멀'
827
+ }
828
+ },
829
+ exposure: {
830
+ description: 'tone mapping exposure (ACESFilmic). 카메라 노출과 동일 개념.',
831
+ range: '0.4 ~ 2.5 (sane), default ~1.2',
832
+ bands: {
833
+ '< 0.7': 'dim — 동굴, 심해, 음울, 야간',
834
+ '0.7 ~ 1.0': 'subdued — 황혼, 안개, 침착',
835
+ '1.0 ~ 1.4': 'balanced — 일반 실내/실외',
836
+ '1.4 ~ 2.0': 'bright — 쇼룸, 한낮, 클린룸',
837
+ '> 2.0': 'over — 매우 주의 (washout)'
838
+ },
839
+ pitfall: 'exposure 만 올리고 hemi/dir 도 동시에 올리면 wash 되어 평면화. 한쪽만 손대는 게 깔끔.'
840
+ },
841
+ hemisphere: {
842
+ description:
843
+ 'Hemisphere light = 위(sky)/아래(ground) 두 색을 보간한 ambient. 환경의 base 색조 결정.',
844
+ keys: {
845
+ hemiIntensity: '0 ~ 10, default 4. 높을수록 wash, 낮을수록 contrast.',
846
+ hemiSkyColor: 'hex. 위에서 내려오는 빛 색조 (sky 의 색).',
847
+ hemiGroundColor: 'hex. 아래에서 올라오는 반사 색 (floor / ground 의 bounce).'
848
+ },
849
+ tip: 'sky/ground 색 차이가 크면 environmental tint 강조 (예: sky=warm orange, ground=cool blue → 일출 분위기). 비슷하면 평탄한 ambient.'
850
+ },
851
+ directional: {
852
+ description: 'Directional light = 태양/key light. 하나만 존재. 그림자 source.',
853
+ keys: {
854
+ dirLightEnabled: 'boolean. 태양 on/off. 야간 / 동굴 / 우주 등은 false.',
855
+ dirLightColor:
856
+ 'hex. 색온도 — 0xfff0d0 따뜻 (~3000K), 0xffffff 중성, 0xd0e0ff 차가움 (~7000K).',
857
+ dirLightIntensity: '0 ~ 5, default 2. dramatic 은 높이고 hemi 낮춤.',
858
+ dirLightAzimuth:
859
+ '도. 0=북, 90=동, 180=남, 270=서. dirLightFollowCamera=false 일 때만 유효.',
860
+ dirLightElevation: '도. 0=수평선, 90=정수리. 일출/석양 5–15, 한낮 70–90.',
861
+ dirLightFollowCamera: 'true 면 카메라에 묶임 (편집 편의), false 면 world-fixed (sim 자연).'
862
+ },
863
+ sunPhysics: {
864
+ dawn: 'elevation 10–20, color #ffb070 (warm orange), intensity 1.0–1.5',
865
+ goldenHour: 'elevation 15–25, color #ffaf60 (saturated warm), intensity 2.0–2.5',
866
+ noon: 'elevation 80–90, color #ffffff (neutral), intensity 2.8–3.5',
867
+ sunset: 'elevation 5–15, color #ff7050 (red-orange), intensity 1.5–2.0',
868
+ moonlight: 'elevation 60–80, color #a0b0d0 (cool blue), intensity 0.4–0.7'
869
+ }
870
+ },
871
+ shadow: {
872
+ keys: {
873
+ dirShadowEnabled: 'boolean. drama 위해 on, exposure < 0.7 이면 의미 약함.',
874
+ dirShadowBias: 'small negative (-0.0005 default). artifact 시 -0.001 정도 시도.'
875
+ }
876
+ },
877
+ floor: {
878
+ keys: {
879
+ floorMaterial3d: '바닥 재질 preset (rubber/concrete/wood/metal/... — 도메인 별 등록).',
880
+ fillStyle:
881
+ '보드 배경 색 + preset 없을 때 floor tint. 3D 에서는 ground color 와 시각적으로 묶임.'
882
+ }
883
+ },
884
+ camera: {
885
+ keys: {
886
+ fov: '30 ~ 75, default ~45. 낮으면 telephoto/compressed (드라마틱), 높으면 wide-angle.',
887
+ near: '클리핑 near plane.',
888
+ far: '클리핑 far plane (큰 보드면 5000+).'
889
+ }
890
+ }
891
+ },
892
+
893
+ archetypes: {
894
+ timeOfDay: {
895
+ dawn: {
896
+ description: '해 뜨기 직전 — 차분, 기대감, 청량 + 약한 따뜻함',
897
+ patch: {
898
+ sky: 'color',
899
+ skyColor: '#a0b8d0',
900
+ exposure: 0.85,
901
+ hemiIntensity: 3,
902
+ hemiSkyColor: '#ffd0a0',
903
+ hemiGroundColor: '#3a3040',
904
+ dirLightEnabled: true,
905
+ dirLightColor: '#ffb070',
906
+ dirLightIntensity: 1.2,
907
+ dirLightFollowCamera: false,
908
+ dirLightElevation: 12,
909
+ dirLightAzimuth: 80
910
+ }
911
+ },
912
+ goldenHour: {
913
+ description: '황금시간 — 따뜻, 풍부, 영화적',
914
+ patch: {
915
+ sky: 'sunny',
916
+ exposure: 1.3,
917
+ hemiIntensity: 4,
918
+ hemiSkyColor: '#ffd5a0',
919
+ hemiGroundColor: '#5a4030',
920
+ dirLightEnabled: true,
921
+ dirLightColor: '#ffaf60',
922
+ dirLightIntensity: 2.2,
923
+ dirLightFollowCamera: false,
924
+ dirLightElevation: 20,
925
+ dirLightAzimuth: 250
926
+ }
927
+ },
928
+ noon: {
929
+ description: '한낮 — 밝고 또렷, 사실적',
930
+ patch: {
931
+ sky: 'sunny',
932
+ exposure: 1.4,
933
+ hemiIntensity: 5,
934
+ hemiSkyColor: '#cfe7ff',
935
+ hemiGroundColor: '#5a5550',
936
+ dirLightEnabled: true,
937
+ dirLightColor: '#ffffff',
938
+ dirLightIntensity: 3.0,
939
+ dirLightFollowCamera: false,
940
+ dirLightElevation: 85,
941
+ dirLightAzimuth: 180
942
+ }
943
+ },
944
+ dusk: {
945
+ description: '황혼 — melancholy, 따뜻한 노을',
946
+ patch: {
947
+ sky: 'color',
948
+ skyColor: '#3a4a6a',
949
+ exposure: 0.95,
950
+ hemiIntensity: 2.5,
951
+ hemiSkyColor: '#a070a0',
952
+ hemiGroundColor: '#2a2030',
953
+ dirLightEnabled: true,
954
+ dirLightColor: '#ff7050',
955
+ dirLightIntensity: 1.6,
956
+ dirLightFollowCamera: false,
957
+ dirLightElevation: 8,
958
+ dirLightAzimuth: 270
959
+ }
960
+ },
961
+ night: {
962
+ description: '밤 — 정적, 차가움, 달빛',
963
+ patch: {
964
+ sky: 'color',
965
+ skyColor: '#0a0e1a',
966
+ exposure: 0.7,
967
+ hemiIntensity: 1.5,
968
+ hemiSkyColor: '#3a4060',
969
+ hemiGroundColor: '#0a0a14',
970
+ dirLightEnabled: true,
971
+ dirLightColor: '#a0b0d0',
972
+ dirLightIntensity: 0.5,
973
+ dirLightFollowCamera: false,
974
+ dirLightElevation: 70,
975
+ dirLightAzimuth: 200
976
+ }
977
+ }
978
+ },
979
+
980
+ indoor: {
981
+ studioShowroom: {
982
+ description: '스튜디오 / 매장 — 깨끗, 균일, 제품-photo',
983
+ patch: {
984
+ sky: 'studio',
985
+ exposure: 1.3,
986
+ hemiIntensity: 4.5,
987
+ hemiSkyColor: '#ffffff',
988
+ hemiGroundColor: '#a0a0a0',
989
+ dirLightEnabled: true,
990
+ dirLightColor: '#ffffff',
991
+ dirLightIntensity: 2.0,
992
+ dirLightFollowCamera: false,
993
+ dirLightElevation: 70,
994
+ dirLightAzimuth: 135
995
+ }
996
+ },
997
+ industrialFactory: {
998
+ description: '공장 — 기능적, 밝음, 약간 차가움',
999
+ patch: {
1000
+ sky: 'factory',
1001
+ exposure: 1.2,
1002
+ hemiIntensity: 4.5,
1003
+ hemiSkyColor: '#e8eef5',
1004
+ hemiGroundColor: '#404040',
1005
+ dirLightEnabled: true,
1006
+ dirLightColor: '#f0f5ff',
1007
+ dirLightIntensity: 2.5,
1008
+ dirLightFollowCamera: false,
1009
+ dirLightElevation: 80,
1010
+ dirLightAzimuth: 90
1011
+ }
1012
+ },
1013
+ hospitalCleanroom: {
1014
+ description: '병원/클린룸 — 매우 밝음, 차갑고 균일',
1015
+ patch: {
1016
+ sky: 'studio',
1017
+ exposure: 1.5,
1018
+ hemiIntensity: 6,
1019
+ hemiSkyColor: '#ffffff',
1020
+ hemiGroundColor: '#d8e0e8',
1021
+ dirLightEnabled: true,
1022
+ dirLightColor: '#ffffff',
1023
+ dirLightIntensity: 1.5,
1024
+ dirLightFollowCamera: false,
1025
+ dirLightElevation: 90,
1026
+ dirLightAzimuth: 0
1027
+ }
1028
+ },
1029
+ cyberpunkLab: {
1030
+ description: '사이버펑크 실험실 — 네온, 마젠타+시안, 어두움',
1031
+ patch: {
1032
+ sky: 'color',
1033
+ skyColor: '#0a0a1a',
1034
+ exposure: 1.0,
1035
+ hemiIntensity: 3,
1036
+ hemiSkyColor: '#5040a0',
1037
+ hemiGroundColor: '#003a4a',
1038
+ dirLightEnabled: true,
1039
+ dirLightColor: '#a040c0',
1040
+ dirLightIntensity: 1.8,
1041
+ dirLightFollowCamera: false,
1042
+ dirLightElevation: 50,
1043
+ dirLightAzimuth: 135
1044
+ }
1045
+ }
1046
+ },
1047
+
1048
+ surreal: {
1049
+ deepOcean: {
1050
+ description: '심해 — 차가운 청록, 위에서 약하게 비치는 빛',
1051
+ patch: {
1052
+ sky: 'color',
1053
+ skyColor: '#0a1929',
1054
+ exposure: 0.7,
1055
+ hemiIntensity: 2,
1056
+ hemiSkyColor: '#1a4060',
1057
+ hemiGroundColor: '#050a14',
1058
+ dirLightEnabled: true,
1059
+ dirLightColor: '#5080a0',
1060
+ dirLightIntensity: 0.8,
1061
+ dirLightFollowCamera: false,
1062
+ dirLightElevation: 75,
1063
+ dirLightAzimuth: 90,
1064
+ fillStyle: '#0a1929'
1065
+ }
1066
+ },
1067
+ mistyForest: {
1068
+ description: '안개 자욱한 숲 — 매우 diffuse, 채도 낮음',
1069
+ patch: {
1070
+ sky: 'cloudy',
1071
+ exposure: 0.95,
1072
+ hemiIntensity: 4,
1073
+ hemiSkyColor: '#c0d0c8',
1074
+ hemiGroundColor: '#3a4a3a',
1075
+ dirLightEnabled: true,
1076
+ dirLightColor: '#e0e0d0',
1077
+ dirLightIntensity: 0.8,
1078
+ dirLightFollowCamera: false,
1079
+ dirLightElevation: 35,
1080
+ dirLightAzimuth: 100
1081
+ }
1082
+ },
1083
+ deepSpace: {
1084
+ description: '우주 / SF — 검은 배경, 차갑고 강한 key',
1085
+ patch: {
1086
+ sky: 'color',
1087
+ skyColor: '#020205',
1088
+ exposure: 0.95,
1089
+ hemiIntensity: 0.5,
1090
+ hemiSkyColor: '#1a2040',
1091
+ hemiGroundColor: '#000005',
1092
+ dirLightEnabled: true,
1093
+ dirLightColor: '#ffffff',
1094
+ dirLightIntensity: 3,
1095
+ dirLightFollowCamera: false,
1096
+ dirLightElevation: 30,
1097
+ dirLightAzimuth: 70
1098
+ }
1099
+ },
1100
+ vintage70s: {
1101
+ description: '70년대 빈티지 — 따뜻한 베이지, 부드러운 그림자',
1102
+ patch: {
1103
+ sky: 'color',
1104
+ skyColor: '#d8b890',
1105
+ exposure: 1.15,
1106
+ hemiIntensity: 3.5,
1107
+ hemiSkyColor: '#f5d8a8',
1108
+ hemiGroundColor: '#705038',
1109
+ dirLightEnabled: true,
1110
+ dirLightColor: '#ffd098',
1111
+ dirLightIntensity: 1.8,
1112
+ dirLightFollowCamera: false,
1113
+ dirLightElevation: 45,
1114
+ dirLightAzimuth: 135
1115
+ }
1116
+ }
1117
+ }
1118
+ },
1119
+
1120
+ modifiers: {
1121
+ warmCool:
1122
+ 'warm 으로 가려면 hemiSkyColor / dirLightColor 를 +30° hue red 방향. cool 은 반대.',
1123
+ drama:
1124
+ 'drama 강조 — hemiIntensity 낮추고 dirLightIntensity 올림. 그림자 강해짐.',
1125
+ saturation:
1126
+ 'vivid — 색채 그대로 유지하고 hemi 약간 낮춰 dir 가 punch 하게. muted — hemi 올리고 채도 낮은 색.',
1127
+ contrast:
1128
+ 'high — exposure 1.0 이하 + dir 강하게. low — exposure 1.3 이상 + hemi 강하게.'
1129
+ },
1130
+
1131
+ workflow: [
1132
+ '1. 사용자 요청에서 archetype 키워드 식별 (시간대 / 실내 / 초현실 카테고리).',
1133
+ '2. archetypes 에서 가장 가까운 base 레시피 선택.',
1134
+ '3. modifiers 로 사용자 요청 뉘앙스 fine-tune (warm/cool/drama/...).',
1135
+ '4. sanity check — exposure × hemi × dir 어느 한쪽 극단 아닌지, hemi sky/ground 색 모순 없는지.',
1136
+ "5. modifyBoard({ patch: { ...combined keys... } }) 한 번에 적용. 사용자에게 어떤 archetype + modifier 조합으로 갔는지 한 줄로 설명."
1137
+ ],
1138
+
1139
+ pitfalls: [
1140
+ 'sky preset 위에 skyColor 같이 지정하면 preset 이 우선 (skyColor 무시).',
1141
+ 'exposure 와 hemi/dir 모두 max 면 washout. 한 쪽만 강하게.',
1142
+ 'dirLightEnabled=false 인데 dirLight 색/강도 설정해도 효과 없음.',
1143
+ 'dirLightFollowCamera=true 일 땐 azimuth/elevation 무의미 — false 로 바꿔야 적용.',
1144
+ "fillStyle 만 바꾸면 floor tint 도 같이 바뀜 (floor preset 없을 때). 의도 없으면 floorMaterial3d 같이 점검."
1145
+ ]
1146
+ }
1147
+
1148
+ export function isReadTool(name: string): boolean {
1149
+ return READ_TOOL_NAMES.has(name)
1150
+ }
1151
+
1152
+ /**
1153
+ * tool 결과를 UI fold-able 박스에 보여줄 만한 크기로 요약.
1154
+ *
1155
+ * 원본 그대로면 findComponents 50개 / getAtmosphereGuide 같은 큰 객체가 ChatMessage
1156
+ * 영속까지 부담 — 핵심 모양만 유지하고 나머지는 marker.
1157
+ */
1158
+ export function summarizeToolResult(value: any, depth = 0): any {
1159
+ if (value === null || value === undefined) return value
1160
+ if (typeof value === 'string') {
1161
+ return value.length > 200 ? `${value.slice(0, 200)}…[+${value.length - 200} chars]` : value
1162
+ }
1163
+ if (typeof value === 'number' || typeof value === 'boolean') return value
1164
+ if (Array.isArray(value)) {
1165
+ if (depth >= 2) return `[Array(${value.length})]`
1166
+ const out = value.slice(0, 5).map(v => summarizeToolResult(v, depth + 1))
1167
+ if (value.length > 5) out.push(`…[+${value.length - 5} items]`)
1168
+ return out
1169
+ }
1170
+ if (typeof value === 'object') {
1171
+ if (depth >= 3) {
1172
+ const keys = Object.keys(value).slice(0, 6).join(',')
1173
+ return `{${keys}${Object.keys(value).length > 6 ? ',…' : ''}}`
1174
+ }
1175
+ const out: any = {}
1176
+ for (const key of Object.keys(value)) {
1177
+ out[key] = summarizeToolResult(value[key], depth + 1)
1178
+ }
1179
+ return out
1180
+ }
1181
+ return undefined
1182
+ }
1183
+
1184
+ export function isWriteTool(name: string): boolean {
1185
+ return WRITE_TOOL_NAMES.has(name)
1186
+ }
1187
+
1188
+ // ── Tool call → BoardEditOp 변환 ───────────────────────────────────
1189
+
1190
+ /**
1191
+ * AIToolChatResult → ChatResponse.
1192
+ *
1193
+ * 정책 — followUp 항상 undefined: reply 와 동일 텍스트를 followUp 에 복제하면 클라이언트
1194
+ * 에서 .md-body 와 .followup 두 박스에 같은 내용이 표시되어 사용자에게 "동일 응답이
1195
+ * 두 번 반복" 으로 보인다. 명확화 질문이 필요한 경우 LLM 이 reply 자체에 포함시키도록
1196
+ * 설계.
1197
+ */
1198
+ export function toolResultToChatResponse(result: AIToolChatResult): ChatResponse {
1199
+ const ops = result.toolCalls
1200
+ .map(toolCallToBoardEditOp)
1201
+ .filter((op): op is BoardEditOp => op !== null)
1202
+
1203
+ const reply = result.text || (ops.length > 0 ? '변경했습니다.' : '')
1204
+
1205
+ let patch: BoardEditPatch | undefined
1206
+ if (ops.length > 0) {
1207
+ patch = {
1208
+ ops,
1209
+ summary: result.text ?? `${ops.length} edit op(s) applied`,
1210
+ confidence: 0.9
1211
+ }
1212
+ }
1213
+
1214
+ return { reply, patch, followUp: undefined }
1215
+ }
1216
+
1217
+ export function toolCallToBoardEditOp(tc: AIToolCall): BoardEditOp | null {
1218
+ const args = tc.arguments ?? {}
1219
+ switch (tc.name) {
1220
+ case 'addComponent': {
1221
+ if (typeof args.type !== 'string') return null
1222
+ return { op: 'add', component: args as BoardComponent }
1223
+ }
1224
+ case 'removeComponentByRefid': {
1225
+ if (typeof args.refid !== 'number') return null
1226
+ return { op: 'remove', refid: args.refid }
1227
+ }
1228
+ case 'modifyComponentByRefid': {
1229
+ if (typeof args.refid !== 'number' || !args.patch || typeof args.patch !== 'object') return null
1230
+ return { op: 'modify', refid: args.refid, patch: args.patch as Partial<BoardComponent> }
1231
+ }
1232
+ case 'modifyBoard': {
1233
+ if (!args.patch || typeof args.patch !== 'object' || Array.isArray(args.patch)) return null
1234
+ return { op: 'modifyBoard', patch: args.patch as Partial<BoardModel> }
1235
+ }
1236
+ case 'replaceBoard': {
1237
+ if (!Array.isArray(args.components)) return null
1238
+ return {
1239
+ op: 'replace',
1240
+ board: {
1241
+ width: typeof args.width === 'number' ? args.width : undefined,
1242
+ height: typeof args.height === 'number' ? args.height : undefined,
1243
+ fillStyle: typeof args.fillStyle === 'string' ? args.fillStyle : undefined,
1244
+ components: args.components as BoardComponent[]
1245
+ } as BoardModel
1246
+ }
1247
+ }
1248
+ default:
1249
+ return null
1250
+ }
1251
+ }
1252
+
1253
+ // ── 스킴 포맷 ──────────────────────────────────────────────────────
1254
+
1255
+ /**
1256
+ * ComponentSchema[] 를 LLM 친화적 형태로 압축. 토큰 절감을 위해:
1257
+ * - knownTypes 에 포함된 schema 만
1258
+ * - properties 의 nested object 는 키 목록만
1259
+ */
1260
+ function formatSchemas(schemas: ComponentSchema[], knownTypes: string[]): string {
1261
+ if (!schemas || schemas.length === 0) return '(none provided)'
1262
+ const allowed = new Set(knownTypes)
1263
+ const filtered = schemas.filter(s => s && allowed.has(s.type))
1264
+ if (filtered.length === 0) return '(none provided)'
1265
+
1266
+ const lines: string[] = []
1267
+ for (const s of filtered) {
1268
+ const desc = s.description ? ` — ${s.description}` : ''
1269
+ const grp = s.group ? ` [${s.group}]` : ''
1270
+ const geom =
1271
+ s.geometryKeys && s.geometryKeys.length > 0
1272
+ ? `\n geometry: ${s.geometryKeys.join(', ')}`
1273
+ : ''
1274
+ const propKeys = s.properties ? formatProperties(s.properties) : ''
1275
+ const propLine = propKeys ? `\n properties: ${propKeys}` : ''
1276
+ lines.push(`- ${s.type}${grp}${desc}${geom}${propLine}`)
1277
+ }
1278
+ return lines.join('\n')
1279
+ }
1280
+
1281
+ function formatProperties(props: Record<string, any>): string {
1282
+ const items: string[] = []
1283
+ for (const [k, v] of Object.entries(props)) {
1284
+ if (v === null || v === undefined) {
1285
+ items.push(k)
1286
+ } else if (typeof v === 'object' && !Array.isArray(v)) {
1287
+ const subKeys = Object.keys(v).slice(0, 6).join(',')
1288
+ items.push(`${k}:{${subKeys}}`)
1289
+ } else if (Array.isArray(v)) {
1290
+ items.push(`${k}:[]`)
1291
+ } else if (typeof v === 'string' && v.length > 24) {
1292
+ items.push(`${k}:string`)
1293
+ } else {
1294
+ items.push(`${k}=${JSON.stringify(v)}`)
1295
+ }
1296
+ }
1297
+ return items.join(', ')
1298
+ }
1299
+
1300
+ // ── 응답 정규화 ─────────────────────────────────────────────────────
1301
+
1302
+ interface RawChatResponse {
1303
+ reply?: string
1304
+ patch?: { ops?: any[]; summary?: string; confidence?: number } | null
1305
+ followUp?: string | null
1306
+ }
1307
+
1308
+ export function normalizeChatResponse(raw: RawChatResponse): ChatResponse {
1309
+ const reply = typeof raw.reply === 'string' ? raw.reply : ''
1310
+ const followUp = typeof raw.followUp === 'string' && raw.followUp.length > 0 ? raw.followUp : undefined
1311
+
1312
+ let patch: BoardEditPatch | undefined
1313
+ if (raw.patch && Array.isArray(raw.patch.ops) && raw.patch.ops.length > 0) {
1314
+ const ops = raw.patch.ops.filter(isValidOp)
1315
+ if (ops.length > 0) {
1316
+ patch = {
1317
+ ops,
1318
+ summary: raw.patch.summary ?? '',
1319
+ confidence: typeof raw.patch.confidence === 'number' ? raw.patch.confidence : 0.5
1320
+ }
1321
+ }
1322
+ }
1323
+
1324
+ return { reply, patch, followUp }
1325
+ }
1326
+
1327
+ export function isValidOp(op: any): boolean {
1328
+ if (!op || typeof op.op !== 'string') return false
1329
+ switch (op.op) {
1330
+ case 'add':
1331
+ return !!op.component && typeof op.component.type === 'string'
1332
+ case 'remove':
1333
+ return typeof op.refid === 'number'
1334
+ case 'modify':
1335
+ return typeof op.refid === 'number' && !!op.patch && typeof op.patch === 'object'
1336
+ case 'modifyBoard':
1337
+ return !!op.patch && typeof op.patch === 'object' && !Array.isArray(op.patch)
1338
+ case 'replace':
1339
+ return !!op.board && Array.isArray(op.board.components)
1340
+ default:
1341
+ return false
1342
+ }
1343
+ }
1344
+
1345
+ // ── 보드 요약 (LLM 토큰 절감) ──────────────────────────────────────
1346
+
1347
+ export function summarizeBoard(board: BoardModel | null | undefined): any {
1348
+ if (!board || typeof board !== 'object') return null
1349
+ const components = board.components ?? []
1350
+
1351
+ // 루트(보드 자체) 의 모든 비-internal 속성을 그대로 노출.
1352
+ // 단순 whitelist (width/height/fillStyle) 로 좁히면 AI 가 보드 자체의 다른 속성
1353
+ // (name 등) 을 모르고 또한 "보드는 단순 컨테이너" 로 오해할 수 있다. 보드도
1354
+ // 자체 속성을 가진 부모 노드임을 분명히 보이도록.
1355
+ const root: any = {}
1356
+ for (const key of Object.keys(board)) {
1357
+ if (key === 'components') continue // children 은 별도 처리
1358
+ if (key.startsWith('_')) continue
1359
+ root[key] = summarizeValue((board as any)[key], 0)
1360
+ }
1361
+
1362
+ return {
1363
+ // 'model' 은 things-scene 보드 root 의 type — 보드가 컴포넌트 같은 대상임을 명시.
1364
+ boardRoot: { type: 'model', ...root },
1365
+ componentCount: components.length,
1366
+ components: components.slice(0, MAX_BOARD_SUMMARY_COMPONENTS).map(summarizeComponent),
1367
+ truncated: components.length > MAX_BOARD_SUMMARY_COMPONENTS
1368
+ }
1369
+ }
1370
+
1371
+ // 요약에서 제외할 키 — things-scene 의 내부/transient 상태 (사용자가 편집 대상으로 삼지 않음)
1372
+ const COMPONENT_OMIT_KEYS = new Set<string>([
1373
+ 'components', // group/container 의 children — 별도 처리
1374
+ '_state',
1375
+ '_cache',
1376
+ '_layout',
1377
+ '_dirty',
1378
+ '_internal'
1379
+ ])
1380
+
1381
+ const MAX_STRING_LEN = 200
1382
+ const MAX_ARRAY_LEN = 50
1383
+
1384
+ /**
1385
+ * 컴포넌트 요약 — type-specific 속성 (text, value, cx/cy/radius, points, ...) 까지
1386
+ * 포함해 LLM 이 정확한 modify 대상을 판단할 수 있도록.
1387
+ *
1388
+ * 토큰 절감을 위해:
1389
+ * - 긴 문자열/배열은 길이 제한 (말미 truncated 마커)
1390
+ * - things-scene 내부 키 (_state 등) 는 제외
1391
+ * - nested object 는 1단계까지 그대로, 그 이상은 키 목록만
1392
+ */
1393
+ function summarizeComponent(c: BoardComponent): any {
1394
+ if (!c || typeof c !== 'object') return c
1395
+ const out: any = {}
1396
+ for (const key of Object.keys(c)) {
1397
+ if (COMPONENT_OMIT_KEYS.has(key)) continue
1398
+ if (key.startsWith('_')) continue
1399
+ out[key] = summarizeValue((c as any)[key], 0)
1400
+ }
1401
+ return out
1402
+ }
1403
+
1404
+ function summarizeValue(v: any, depth: number): any {
1405
+ if (v === null || v === undefined) return v
1406
+ const t = typeof v
1407
+ if (t === 'string') {
1408
+ return v.length > MAX_STRING_LEN ? `${v.slice(0, MAX_STRING_LEN)}…[+${v.length - MAX_STRING_LEN} chars]` : v
1409
+ }
1410
+ if (t === 'number' || t === 'boolean') return v
1411
+ if (Array.isArray(v)) {
1412
+ if (v.length === 0) return []
1413
+ if (depth >= 2) return `[Array(${v.length})]`
1414
+ const out = v.slice(0, MAX_ARRAY_LEN).map(x => summarizeValue(x, depth + 1))
1415
+ if (v.length > MAX_ARRAY_LEN) out.push(`…[+${v.length - MAX_ARRAY_LEN} items]`)
1416
+ return out
1417
+ }
1418
+ if (t === 'object') {
1419
+ if (depth >= 2) {
1420
+ const keys = Object.keys(v).slice(0, 8)
1421
+ return `{${keys.join(',')}${Object.keys(v).length > 8 ? ',…' : ''}}`
1422
+ }
1423
+ const out: any = {}
1424
+ for (const key of Object.keys(v)) {
1425
+ if (key.startsWith('_')) continue
1426
+ out[key] = summarizeValue(v[key], depth + 1)
1427
+ }
1428
+ return out
1429
+ }
1430
+ return undefined
1431
+ }