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