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

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 (329) hide show
  1. package/PLAN-styling-tools.md +1253 -0
  2. package/client/components/board-ai-chat.test.ts +149 -0
  3. package/client/components/board-ai-chat.ts +2574 -0
  4. package/client/components/chat-echo-dedup.test.ts +116 -0
  5. package/client/components/chat-echo-dedup.ts +89 -0
  6. package/client/components/chat-input-builder.ts +47 -0
  7. package/client/components/default-catalog-entries.test.ts +59 -0
  8. package/client/components/default-catalog-entries.ts +100 -0
  9. package/client/components/default-slash-templates.test.ts +55 -0
  10. package/client/components/default-slash-templates.ts +129 -0
  11. package/client/components/markdown.test.ts +518 -0
  12. package/client/components/markdown.ts +508 -0
  13. package/client/components/mention-popup-helpers.ts +433 -0
  14. package/client/components/mention-popup.test.ts +588 -0
  15. package/client/components/mention-popup.ts +238 -0
  16. package/client/index.ts +11 -0
  17. package/client/tsconfig.json +13 -0
  18. package/client/utils/board-edit-patch.ts +374 -0
  19. package/config/config.development.js +43 -0
  20. package/config/config.production.js +15 -0
  21. package/dist-client/client/components/board-ai-chat.d.ts +277 -0
  22. package/dist-client/client/components/board-ai-chat.js +2444 -0
  23. package/dist-client/client/components/board-ai-chat.js.map +1 -0
  24. package/dist-client/client/components/board-ai-chat.test.d.ts +1 -0
  25. package/dist-client/client/components/board-ai-chat.test.js +138 -0
  26. package/dist-client/client/components/board-ai-chat.test.js.map +1 -0
  27. package/dist-client/client/components/chat-echo-dedup.d.ts +45 -0
  28. package/dist-client/client/components/chat-echo-dedup.js +58 -0
  29. package/dist-client/client/components/chat-echo-dedup.js.map +1 -0
  30. package/dist-client/client/components/chat-echo-dedup.test.d.ts +1 -0
  31. package/dist-client/client/components/chat-echo-dedup.test.js +101 -0
  32. package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -0
  33. package/dist-client/client/components/chat-input-builder.d.ts +39 -0
  34. package/dist-client/client/components/chat-input-builder.js +26 -0
  35. package/dist-client/client/components/chat-input-builder.js.map +1 -0
  36. package/dist-client/client/components/default-catalog-entries.d.ts +15 -0
  37. package/dist-client/client/components/default-catalog-entries.js +86 -0
  38. package/dist-client/client/components/default-catalog-entries.js.map +1 -0
  39. package/dist-client/client/components/default-catalog-entries.test.d.ts +1 -0
  40. package/dist-client/client/components/default-catalog-entries.test.js +52 -0
  41. package/dist-client/client/components/default-catalog-entries.test.js.map +1 -0
  42. package/dist-client/client/components/default-slash-templates.d.ts +17 -0
  43. package/dist-client/client/components/default-slash-templates.js +113 -0
  44. package/dist-client/client/components/default-slash-templates.js.map +1 -0
  45. package/dist-client/client/components/default-slash-templates.test.d.ts +1 -0
  46. package/dist-client/client/components/default-slash-templates.test.js +51 -0
  47. package/dist-client/client/components/default-slash-templates.test.js.map +1 -0
  48. package/dist-client/client/components/markdown.d.ts +60 -0
  49. package/dist-client/client/components/markdown.js +422 -0
  50. package/dist-client/client/components/markdown.js.map +1 -0
  51. package/dist-client/client/components/markdown.test.d.ts +1 -0
  52. package/dist-client/client/components/markdown.test.js +418 -0
  53. package/dist-client/client/components/markdown.test.js.map +1 -0
  54. package/dist-client/client/components/mention-popup-helpers.d.ts +165 -0
  55. package/dist-client/client/components/mention-popup-helpers.js +330 -0
  56. package/dist-client/client/components/mention-popup-helpers.js.map +1 -0
  57. package/dist-client/client/components/mention-popup.d.ts +35 -0
  58. package/dist-client/client/components/mention-popup.js +223 -0
  59. package/dist-client/client/components/mention-popup.js.map +1 -0
  60. package/dist-client/client/components/mention-popup.test.d.ts +1 -0
  61. package/dist-client/client/components/mention-popup.test.js +474 -0
  62. package/dist-client/client/components/mention-popup.test.js.map +1 -0
  63. package/dist-client/client/index.d.ts +11 -0
  64. package/dist-client/client/index.js +12 -0
  65. package/dist-client/client/index.js.map +1 -0
  66. package/dist-client/client/utils/board-edit-patch.d.ts +157 -0
  67. package/dist-client/client/utils/board-edit-patch.js +284 -0
  68. package/dist-client/client/utils/board-edit-patch.js.map +1 -0
  69. package/dist-client/server/service/agentic-loop.d.ts +87 -0
  70. package/dist-client/server/service/agentic-loop.js +276 -0
  71. package/dist-client/server/service/agentic-loop.js.map +1 -0
  72. package/dist-client/server/service/assistant.d.ts +84 -0
  73. package/dist-client/server/service/assistant.js +2220 -0
  74. package/dist-client/server/service/assistant.js.map +1 -0
  75. package/dist-client/server/service/catalog-resolver.d.ts +18 -0
  76. package/dist-client/server/service/catalog-resolver.js +93 -0
  77. package/dist-client/server/service/catalog-resolver.js.map +1 -0
  78. package/dist-client/server/service/component-tree.d.ts +68 -0
  79. package/dist-client/server/service/component-tree.js +68 -0
  80. package/dist-client/server/service/component-tree.js.map +1 -0
  81. package/dist-client/server/service/mention-resolver.d.ts +96 -0
  82. package/dist-client/server/service/mention-resolver.js +172 -0
  83. package/dist-client/server/service/mention-resolver.js.map +1 -0
  84. package/dist-client/server/service/prefab-registry.d.ts +73 -0
  85. package/dist-client/server/service/prefab-registry.js +252 -0
  86. package/dist-client/server/service/prefab-registry.js.map +1 -0
  87. package/dist-client/server/service/styling/clear-tools.d.ts +15 -0
  88. package/dist-client/server/service/styling/clear-tools.js +50 -0
  89. package/dist-client/server/service/styling/clear-tools.js.map +1 -0
  90. package/dist-client/server/service/styling/color-utils.d.ts +43 -0
  91. package/dist-client/server/service/styling/color-utils.js +205 -0
  92. package/dist-client/server/service/styling/color-utils.js.map +1 -0
  93. package/dist-client/server/service/styling/copy-tools.d.ts +27 -0
  94. package/dist-client/server/service/styling/copy-tools.js +78 -0
  95. package/dist-client/server/service/styling/copy-tools.js.map +1 -0
  96. package/dist-client/server/service/styling/effect-tools.d.ts +45 -0
  97. package/dist-client/server/service/styling/effect-tools.js +85 -0
  98. package/dist-client/server/service/styling/effect-tools.js.map +1 -0
  99. package/dist-client/server/service/styling/fill-tools.d.ts +60 -0
  100. package/dist-client/server/service/styling/fill-tools.js +226 -0
  101. package/dist-client/server/service/styling/fill-tools.js.map +1 -0
  102. package/dist-client/server/service/styling/material-tools.d.ts +32 -0
  103. package/dist-client/server/service/styling/material-tools.js +88 -0
  104. package/dist-client/server/service/styling/material-tools.js.map +1 -0
  105. package/dist-client/server/service/styling/read-tools.d.ts +34 -0
  106. package/dist-client/server/service/styling/read-tools.js +42 -0
  107. package/dist-client/server/service/styling/read-tools.js.map +1 -0
  108. package/dist-client/server/service/styling/registry.d.ts +91 -0
  109. package/dist-client/server/service/styling/registry.js +345 -0
  110. package/dist-client/server/service/styling/registry.js.map +1 -0
  111. package/dist-client/server/service/styling/stroke-tools.d.ts +35 -0
  112. package/dist-client/server/service/styling/stroke-tools.js +94 -0
  113. package/dist-client/server/service/styling/stroke-tools.js.map +1 -0
  114. package/dist-client/server/service/styling/text-tools.d.ts +32 -0
  115. package/dist-client/server/service/styling/text-tools.js +101 -0
  116. package/dist-client/server/service/styling/text-tools.js.map +1 -0
  117. package/dist-client/server/service/types.d.ts +265 -0
  118. package/dist-client/server/service/types.js +2 -0
  119. package/dist-client/server/service/types.js.map +1 -0
  120. package/dist-client/server/service/validation/board-model-schema.d.ts +8311 -0
  121. package/dist-client/server/service/validation/board-model-schema.js +402 -0
  122. package/dist-client/server/service/validation/board-model-schema.js.map +1 -0
  123. package/dist-client/server/service/validation/tool-validation.d.ts +31 -0
  124. package/dist-client/server/service/validation/tool-validation.js +193 -0
  125. package/dist-client/server/service/validation/tool-validation.js.map +1 -0
  126. package/dist-client/tsconfig.tsbuildinfo +1 -0
  127. package/dist-server/index.d.ts +23 -0
  128. package/dist-server/index.js +27 -0
  129. package/dist-server/index.js.map +1 -0
  130. package/dist-server/service/agentic-loop.d.ts +87 -0
  131. package/dist-server/service/agentic-loop.js +280 -0
  132. package/dist-server/service/agentic-loop.js.map +1 -0
  133. package/dist-server/service/apply-patch.d.ts +47 -0
  134. package/dist-server/service/apply-patch.js +369 -0
  135. package/dist-server/service/apply-patch.js.map +1 -0
  136. package/dist-server/service/assistant.d.ts +84 -0
  137. package/dist-server/service/assistant.js +2237 -0
  138. package/dist-server/service/assistant.js.map +1 -0
  139. package/dist-server/service/board-ai-resolver.d.ts +54 -0
  140. package/dist-server/service/board-ai-resolver.js +409 -0
  141. package/dist-server/service/board-ai-resolver.js.map +1 -0
  142. package/dist-server/service/catalog-resolver.d.ts +18 -0
  143. package/dist-server/service/catalog-resolver.js +98 -0
  144. package/dist-server/service/catalog-resolver.js.map +1 -0
  145. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
  146. package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
  147. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
  148. package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
  149. package/dist-server/service/chat-message/chat-message-publish.js +27 -0
  150. package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
  151. package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
  152. package/dist-server/service/chat-message/chat-message-subscription.js +53 -0
  153. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
  154. package/dist-server/service/chat-message/chat-message.d.ts +56 -0
  155. package/dist-server/service/chat-message/chat-message.js +149 -0
  156. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  157. package/dist-server/service/chat-message/index.d.ts +5 -0
  158. package/dist-server/service/chat-message/index.js +12 -0
  159. package/dist-server/service/chat-message/index.js.map +1 -0
  160. package/dist-server/service/chat-session/chat-session.d.ts +31 -0
  161. package/dist-server/service/chat-session/chat-session.js +131 -0
  162. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  163. package/dist-server/service/chat-session/index.d.ts +3 -0
  164. package/dist-server/service/chat-session/index.js +7 -0
  165. package/dist-server/service/chat-session/index.js.map +1 -0
  166. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +30 -0
  167. package/dist-server/service/chat-session-participant/chat-session-participant.js +112 -0
  168. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
  169. package/dist-server/service/chat-session-participant/index.d.ts +4 -0
  170. package/dist-server/service/chat-session-participant/index.js +10 -0
  171. package/dist-server/service/chat-session-participant/index.js.map +1 -0
  172. package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
  173. package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
  174. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
  175. package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
  176. package/dist-server/service/chat-session-participant/presence.js +22 -0
  177. package/dist-server/service/chat-session-participant/presence.js.map +1 -0
  178. package/dist-server/service/chat-session-resolver.d.ts +20 -0
  179. package/dist-server/service/chat-session-resolver.js +356 -0
  180. package/dist-server/service/chat-session-resolver.js.map +1 -0
  181. package/dist-server/service/component-tree.d.ts +68 -0
  182. package/dist-server/service/component-tree.js +73 -0
  183. package/dist-server/service/component-tree.js.map +1 -0
  184. package/dist-server/service/index.d.ts +20 -0
  185. package/dist-server/service/index.js +34 -0
  186. package/dist-server/service/index.js.map +1 -0
  187. package/dist-server/service/mention-marker.d.ts +78 -0
  188. package/dist-server/service/mention-marker.js +162 -0
  189. package/dist-server/service/mention-marker.js.map +1 -0
  190. package/dist-server/service/mention-resolver.d.ts +96 -0
  191. package/dist-server/service/mention-resolver.js +177 -0
  192. package/dist-server/service/mention-resolver.js.map +1 -0
  193. package/dist-server/service/patch-entry/board-patch-broadcast.d.ts +13 -0
  194. package/dist-server/service/patch-entry/board-patch-broadcast.js +13 -0
  195. package/dist-server/service/patch-entry/board-patch-broadcast.js.map +1 -0
  196. package/dist-server/service/patch-entry/board-patch-subscription.d.ts +12 -0
  197. package/dist-server/service/patch-entry/board-patch-subscription.js +63 -0
  198. package/dist-server/service/patch-entry/board-patch-subscription.js.map +1 -0
  199. package/dist-server/service/patch-entry/index.d.ts +4 -0
  200. package/dist-server/service/patch-entry/index.js +12 -0
  201. package/dist-server/service/patch-entry/index.js.map +1 -0
  202. package/dist-server/service/patch-entry/patch-entry.d.ts +16 -0
  203. package/dist-server/service/patch-entry/patch-entry.js +96 -0
  204. package/dist-server/service/patch-entry/patch-entry.js.map +1 -0
  205. package/dist-server/service/prefab-registry.d.ts +73 -0
  206. package/dist-server/service/prefab-registry.js +258 -0
  207. package/dist-server/service/prefab-registry.js.map +1 -0
  208. package/dist-server/service/styling/clear-tools.d.ts +15 -0
  209. package/dist-server/service/styling/clear-tools.js +53 -0
  210. package/dist-server/service/styling/clear-tools.js.map +1 -0
  211. package/dist-server/service/styling/color-utils.d.ts +43 -0
  212. package/dist-server/service/styling/color-utils.js +210 -0
  213. package/dist-server/service/styling/color-utils.js.map +1 -0
  214. package/dist-server/service/styling/copy-tools.d.ts +27 -0
  215. package/dist-server/service/styling/copy-tools.js +81 -0
  216. package/dist-server/service/styling/copy-tools.js.map +1 -0
  217. package/dist-server/service/styling/effect-tools.d.ts +45 -0
  218. package/dist-server/service/styling/effect-tools.js +90 -0
  219. package/dist-server/service/styling/effect-tools.js.map +1 -0
  220. package/dist-server/service/styling/fill-tools.d.ts +60 -0
  221. package/dist-server/service/styling/fill-tools.js +229 -0
  222. package/dist-server/service/styling/fill-tools.js.map +1 -0
  223. package/dist-server/service/styling/material-tools.d.ts +32 -0
  224. package/dist-server/service/styling/material-tools.js +92 -0
  225. package/dist-server/service/styling/material-tools.js.map +1 -0
  226. package/dist-server/service/styling/read-tools.d.ts +34 -0
  227. package/dist-server/service/styling/read-tools.js +45 -0
  228. package/dist-server/service/styling/read-tools.js.map +1 -0
  229. package/dist-server/service/styling/registry.d.ts +91 -0
  230. package/dist-server/service/styling/registry.js +352 -0
  231. package/dist-server/service/styling/registry.js.map +1 -0
  232. package/dist-server/service/styling/stroke-tools.d.ts +35 -0
  233. package/dist-server/service/styling/stroke-tools.js +97 -0
  234. package/dist-server/service/styling/stroke-tools.js.map +1 -0
  235. package/dist-server/service/styling/text-tools.d.ts +32 -0
  236. package/dist-server/service/styling/text-tools.js +105 -0
  237. package/dist-server/service/styling/text-tools.js.map +1 -0
  238. package/dist-server/service/subscribe-auth.d.ts +14 -0
  239. package/dist-server/service/subscribe-auth.js +33 -0
  240. package/dist-server/service/subscribe-auth.js.map +1 -0
  241. package/dist-server/service/types.d.ts +265 -0
  242. package/dist-server/service/types.js +3 -0
  243. package/dist-server/service/types.js.map +1 -0
  244. package/dist-server/service/validation/board-model-schema.d.ts +8311 -0
  245. package/dist-server/service/validation/board-model-schema.js +409 -0
  246. package/dist-server/service/validation/board-model-schema.js.map +1 -0
  247. package/dist-server/service/validation/tool-validation.d.ts +31 -0
  248. package/dist-server/service/validation/tool-validation.js +196 -0
  249. package/dist-server/service/validation/tool-validation.js.map +1 -0
  250. package/dist-server/tsconfig.tsbuildinfo +1 -0
  251. package/package.json +48 -0
  252. package/server/index.ts +23 -0
  253. package/server/service/agentic-loop.test.ts +705 -0
  254. package/server/service/agentic-loop.ts +431 -0
  255. package/server/service/apply-patch-drift.test.ts +829 -0
  256. package/server/service/apply-patch.test.ts +757 -0
  257. package/server/service/apply-patch.ts +411 -0
  258. package/server/service/assistant-integration.test.ts +922 -0
  259. package/server/service/assistant-llm-smoke.test.ts +296 -0
  260. package/server/service/assistant.test.ts +2034 -0
  261. package/server/service/assistant.ts +2378 -0
  262. package/server/service/board-ai-resolver.ts +407 -0
  263. package/server/service/catalog-resolver.test.ts +137 -0
  264. package/server/service/catalog-resolver.ts +102 -0
  265. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
  266. package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
  267. package/server/service/chat-message/chat-message-broadcast.ts +27 -0
  268. package/server/service/chat-message/chat-message-publish.ts +22 -0
  269. package/server/service/chat-message/chat-message-subscription.ts +41 -0
  270. package/server/service/chat-message/chat-message.ts +169 -0
  271. package/server/service/chat-message/index.ts +12 -0
  272. package/server/service/chat-session/chat-session.ts +130 -0
  273. package/server/service/chat-session/index.ts +5 -0
  274. package/server/service/chat-session-participant/chat-session-participant.ts +110 -0
  275. package/server/service/chat-session-participant/index.ts +6 -0
  276. package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
  277. package/server/service/chat-session-participant/invite-notification.ts +48 -0
  278. package/server/service/chat-session-participant/presence.test.ts +39 -0
  279. package/server/service/chat-session-participant/presence.ts +21 -0
  280. package/server/service/chat-session-resolver.ts +312 -0
  281. package/server/service/component-tree.test.ts +248 -0
  282. package/server/service/component-tree.ts +135 -0
  283. package/server/service/index.ts +32 -0
  284. package/server/service/mention-marker.extract.test.ts +61 -0
  285. package/server/service/mention-marker.test.ts +228 -0
  286. package/server/service/mention-marker.ts +184 -0
  287. package/server/service/mention-resolver.test.ts +273 -0
  288. package/server/service/mention-resolver.ts +264 -0
  289. package/server/service/patch-entry/board-patch-broadcast.ts +24 -0
  290. package/server/service/patch-entry/board-patch-subscription.test.ts +40 -0
  291. package/server/service/patch-entry/board-patch-subscription.ts +50 -0
  292. package/server/service/patch-entry/index.ts +12 -0
  293. package/server/service/patch-entry/patch-entry.ts +89 -0
  294. package/server/service/prefab-registry.test.ts +172 -0
  295. package/server/service/prefab-registry.ts +332 -0
  296. package/server/service/styling/clear-tools.test.ts +164 -0
  297. package/server/service/styling/clear-tools.ts +69 -0
  298. package/server/service/styling/color-utils.test.ts +130 -0
  299. package/server/service/styling/color-utils.ts +226 -0
  300. package/server/service/styling/copy-tools.test.ts +152 -0
  301. package/server/service/styling/copy-tools.ts +115 -0
  302. package/server/service/styling/effect-tools.test.ts +277 -0
  303. package/server/service/styling/effect-tools.ts +124 -0
  304. package/server/service/styling/fill-tools.test.ts +331 -0
  305. package/server/service/styling/fill-tools.ts +293 -0
  306. package/server/service/styling/material-tools.test.ts +294 -0
  307. package/server/service/styling/material-tools.ts +114 -0
  308. package/server/service/styling/read-tools.test.ts +78 -0
  309. package/server/service/styling/read-tools.ts +67 -0
  310. package/server/service/styling/registry.test.ts +203 -0
  311. package/server/service/styling/registry.ts +423 -0
  312. package/server/service/styling/stroke-tools.test.ts +245 -0
  313. package/server/service/styling/stroke-tools.ts +145 -0
  314. package/server/service/styling/text-tools.test.ts +253 -0
  315. package/server/service/styling/text-tools.ts +131 -0
  316. package/server/service/styling/tier1-tools.test.ts +300 -0
  317. package/server/service/subscribe-auth.test.ts +57 -0
  318. package/server/service/subscribe-auth.ts +34 -0
  319. package/server/service/types.ts +235 -0
  320. package/server/service/validation/board-model-schema.test.ts +778 -0
  321. package/server/service/validation/board-model-schema.ts +446 -0
  322. package/server/service/validation/tool-validation.ts +228 -0
  323. package/things-factory.config.js +1 -0
  324. package/translations/en.json +49 -0
  325. package/translations/ja.json +48 -0
  326. package/translations/ko.json +49 -0
  327. package/translations/ms.json +48 -0
  328. package/translations/zh.json +48 -0
  329. package/tsconfig.json +9 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * GraphQL resolver — AI 주도 보드 모델링 (chat).
3
+ *
4
+ * Mutation: boardAIChat — 자연어 채팅. sessionId 가 있으면 메시지/패치 영속.
5
+ * sessionId 가 없으면 ad-hoc 모드로 단발 호출 (영속 없음).
6
+ */
7
+ import { Resolver, Mutation, Arg, Ctx, Directive, Field, InputType, Int, ObjectType } from 'type-graphql'
8
+ import { GraphQLJSON } from 'graphql-scalars'
9
+ import '@things-factory/auth-base'
10
+
11
+ import { config } from '@things-factory/env'
12
+ import { getRepository } from '@things-factory/shell'
13
+ import { createAIClient, getDefaultAIClient, type AIClient } from '@things-factory/ai-client-base'
14
+ import type { ComponentCategory } from '@things-factory/board-import'
15
+
16
+ import { DefaultBoardAIAssistant } from './assistant.js'
17
+ import type { LLMMessage } from './types.js'
18
+ import { ChatSession } from './chat-session/chat-session.js'
19
+ import { ChatMessage } from './chat-message/chat-message.js'
20
+ import { publishChatMessage } from './chat-message/chat-message-publish.js'
21
+ import { PatchEntry } from './patch-entry/patch-entry.js'
22
+ import { publishBoardPatch } from './patch-entry/board-patch-subscription.js'
23
+ import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
24
+ import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
25
+ import {
26
+ injectMentionRefids,
27
+ injectAiMentionRefids,
28
+ stripMentionRefids,
29
+ extractMentionUserIds,
30
+ newParticipantUserIds
31
+ } from './mention-marker.js'
32
+
33
+ @InputType()
34
+ class BoardAILLMMessageInput {
35
+ @Field({ description: "Role: 'user' or 'assistant'" })
36
+ role: string
37
+
38
+ @Field({ description: 'Message content' })
39
+ content: string
40
+ }
41
+
42
+ @InputType()
43
+ class BoardAIMentionPickInput {
44
+ @Field({ description: 'Token used by user (e.g. "경광등1" — the part after #).' })
45
+ token: string
46
+
47
+ @Field(() => Int, {
48
+ description:
49
+ 'Refid explicitly picked from popup. Server uses this for deterministic resolution — bypasses fallback name/id/type matching for this token.'
50
+ })
51
+ refid: number
52
+ }
53
+
54
+ @InputType()
55
+ class BoardAIChatInput {
56
+ @Field({
57
+ nullable: true,
58
+ description: 'ChatSession id. Omit for ad-hoc (no persistence). When given, messages/patches are persisted.'
59
+ })
60
+ sessionId?: string
61
+
62
+ @Field(() => [BoardAILLMMessageInput], {
63
+ description: 'Conversation history (latest user message at the end). For persisted sessions, only the last user message is appended; older are loaded from DB.'
64
+ })
65
+ messages: BoardAILLMMessageInput[]
66
+
67
+ @Field(() => GraphQLJSON, { nullable: true, description: 'Current BoardModel JSON.' })
68
+ currentBoard?: any
69
+
70
+ @Field(() => [String], {
71
+ nullable: true,
72
+ description:
73
+ "Capability scopes the AI is allowed to use (e.g. 'create', 'edit', 'style'). Filters which tools are exposed to the LLM."
74
+ })
75
+ scopes?: string[]
76
+
77
+ @Field(() => [String], {
78
+ nullable: true,
79
+ description: 'Component types the LLM is allowed to emit (e.g. ["rect", "label"]). Constrains generation to the current solution.'
80
+ })
81
+ knownTypes?: string[]
82
+
83
+ @Field(() => [String], {
84
+ nullable: true,
85
+ description: 'Component categories to surface (e.g. ["3D", "form"]). Subset filter for groupings exposed in component-schemas.'
86
+ })
87
+ categories?: string[]
88
+
89
+ @Field(() => GraphQLJSON, {
90
+ nullable: true,
91
+ description:
92
+ 'Component schemas — array of { type, description?, group?, properties? } so the LLM knows valid props per type.'
93
+ })
94
+ componentSchemas?: any
95
+
96
+ @Field(() => [Int], {
97
+ nullable: true,
98
+ description:
99
+ 'Refids of components currently selected in the modeller (universal numeric handle, things-scene auto-assigned). For "selected" / "선택한" intent. Note: distinct from `id` which is a data-binding name.'
100
+ })
101
+ selectedRefids?: number[]
102
+
103
+ @Field(() => [BoardAIMentionPickInput], {
104
+ nullable: true,
105
+ description:
106
+ 'Explicit #mention picks from popup — {token, refid}. User text stays clean (#token only); these picks let server resolve deterministically without exposing refid in the user-visible message. Tokens not in this list fall back to free name/id/type matching.'
107
+ })
108
+ mentions?: BoardAIMentionPickInput[]
109
+
110
+ @Field({
111
+ nullable: true,
112
+ description: 'AI model identifier override (e.g. "claude-opus-4-7"). Falls back to the configured default when omitted.'
113
+ })
114
+ model?: string
115
+
116
+ @Field({
117
+ nullable: true,
118
+ description: 'Sampling temperature override. Falls back to assistant default when omitted.'
119
+ })
120
+ temperature?: number
121
+
122
+ @Field({
123
+ nullable: true,
124
+ description: 'Max output tokens override per LLM call. Falls back to assistant default when omitted.'
125
+ })
126
+ maxTokens?: number
127
+ }
128
+
129
+ @ObjectType()
130
+ class BoardAIChatOutput {
131
+ @Field({ description: 'Conversational reply.' })
132
+ reply: string
133
+
134
+ @Field(() => GraphQLJSON, { nullable: true, description: 'BoardEditPatch.' })
135
+ patch?: any
136
+
137
+ @Field({ nullable: true, description: 'Clarifying question when input is ambiguous.' })
138
+ followUp?: string
139
+
140
+ @Field({ description: 'AI client identifier (provider:model).' })
141
+ clientId: string
142
+
143
+ @Field({ nullable: true, description: 'Echo of session id (when persisted).' })
144
+ sessionId?: string
145
+
146
+ @Field({ nullable: true, description: 'Persisted ChatMessage id of the user input.' })
147
+ userMessageId?: string
148
+
149
+ @Field({ nullable: true, description: 'Persisted ChatMessage id of the AI reply.' })
150
+ assistantMessageId?: string
151
+
152
+ @Field({ nullable: true, description: 'Persisted PatchEntry id (when patch was generated).' })
153
+ patchId?: string
154
+
155
+ @Field(() => GraphQLJSON, {
156
+ nullable: true,
157
+ description:
158
+ 'Tool usages collected during agentic loop — sequence of {name, arguments, result, kind}. UI fold-able box for transparency / debug.'
159
+ })
160
+ toolUsages?: any
161
+
162
+ @Field(() => GraphQLJSON, {
163
+ nullable: true,
164
+ description:
165
+ 'Ephemeral scene actions (selection/view/mode) — sequence of BoardActionOp. Applied via board-action-execute event on the host. Distinct from `patch` which carries persistent model changes.'
166
+ })
167
+ actions?: any
168
+ }
169
+
170
+ @Resolver()
171
+ export class BoardAIChatResolver {
172
+ /**
173
+ * NOTE: 일부러 @Directive('@transaction') 사용하지 않는다.
174
+ * LLM 호출은 수 초~수십 초 — 트랜잭션 안에서 호출하면 SQLite write lock 을
175
+ * 그 시간 동안 보유 → 다른 작업 (cache 정리 등) 과 SQLITE_BUSY 충돌.
176
+ * 따라서 DB 쓰기를 LLM 호출 전후의 짧은 단위로 나누고 각각 auto-commit.
177
+ * 데이터 일관성: 채팅 이력은 부분 누락보다 부분 보존이 안전 (대화 컨텍스트).
178
+ */
179
+ @Mutation(() => BoardAIChatOutput, {
180
+ description: 'AI 주도 보드 모델링 — 자연어 채팅으로 보드 생성·구조편집·스타일링. sessionId 로 영속 컨텍스트.'
181
+ })
182
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
183
+ async boardAIChat(
184
+ @Arg('input') input: BoardAIChatInput,
185
+ @Ctx() context: ResolverContext
186
+ ): Promise<BoardAIChatOutput> {
187
+ const base = resolveAIClient()
188
+ if (!base) {
189
+ throw new Error(
190
+ 'No AI client configured. Set config.aiClient = { provider: "anthropic" | "gemini", model, apiKey } or call setDefaultAIClient(...).'
191
+ )
192
+ }
193
+
194
+ const { domain, user } = context.state
195
+ const sessionRepo = getRepository(ChatSession)
196
+ const messageRepo = getRepository(ChatMessage)
197
+ const patchRepo = getRepository(PatchEntry)
198
+
199
+ // ── Phase A: LLM 호출 전 짧은 DB 쓰기 ─────────────────────────
200
+ let session: ChatSession | undefined
201
+ if (input.sessionId) {
202
+ const found = await sessionRepo.findOneBy({ id: input.sessionId, domain: { id: domain.id } })
203
+ if (!found) throw new Error(`ChatSession ${input.sessionId} not found`)
204
+ session = found
205
+ }
206
+
207
+ const lastUserMsg = input.messages[input.messages.length - 1]
208
+ let userMessageId: string | undefined
209
+ if (session && lastUserMsg && lastUserMsg.role === 'user') {
210
+ // popup pick 의 refid 를 #token{refid:N} inline marker 로 content 에 박아 저장.
211
+ // 단일 source-of-truth — 별도 컬럼 없이 content 안에서 token-refid 매핑 보존.
212
+ const enriched = injectMentionRefids(lastUserMsg.content, input.mentions)
213
+ // parentMessage = 이 세션의 직전 메시지 (보통 assistant / system / 또는 첫 turn 이면 null).
214
+ // tree 구조 인프라 — branching (regenerate / 사용자별 분기) 는 같은 부모 child 로.
215
+ const previousLast = await messageRepo.findOne({
216
+ where: { session: { id: session.id } as any },
217
+ order: { createdAt: 'DESC' }
218
+ })
219
+ const saved = await messageRepo.save({
220
+ session: { id: session.id } as any,
221
+ role: 'user',
222
+ content: enriched,
223
+ creator: user,
224
+ updater: user,
225
+ parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
226
+ })
227
+ userMessageId = saved.id
228
+
229
+ // C4: 사용자 메시지를 같은 세션 구독자에게 방송 (그룹챗 실시간). best-effort.
230
+ // 자기 발신 에코는 클라이언트가 message.id 로 dedup.
231
+ publishChatMessage({ domainId: domain.id, sessionId: session.id, message: saved })
232
+
233
+ // C4: @user 멘션 → 세션 참여자(member) 자동 등록 (초대). best-effort — 실패해도 chat 흐름 무영향.
234
+ // enriched content 의 {userId:UUID} 마커에서 추출 → 기존 참여자/자기 제외한 신규만 member 로 등록.
235
+ try {
236
+ await this._inviteMentionedUsers(enriched, session.id!, domain.id, user, session.boardId, (context.state as any).notify)
237
+ } catch {
238
+ /* 초대 실패는 대화를 막지 않는다 */
239
+ }
240
+ }
241
+
242
+ // ── Phase B: LLM 호출 (DB 잠금 없음, 오래 걸려도 OK) ───────────
243
+ const assistant = new DefaultBoardAIAssistant(base, {
244
+ scopes: input.scopes,
245
+ knownTypes: input.knownTypes,
246
+ categories: input.categories as ComponentCategory[] | undefined
247
+ })
248
+
249
+ // 영속된 메시지에는 #token{refid:N} inline marker 가 박혀 있을 수 있음. LLM 한테는
250
+ // 깨끗한 텍스트만 보여 주의 분산 방지 + 토큰 낭비 방지.
251
+ const llmMessages: LLMMessage[] = input.messages.map(m => ({
252
+ role: m.role === 'assistant' ? 'assistant' : 'user',
253
+ content: stripMentionRefids(m.content)
254
+ }))
255
+
256
+ const r = await assistant.chat(llmMessages, input.currentBoard ?? undefined, {
257
+ scopes: input.scopes,
258
+ knownTypes: input.knownTypes,
259
+ categories: input.categories as ComponentCategory[] | undefined,
260
+ selectedRefids: input.selectedRefids,
261
+ mentionPicks: input.mentions,
262
+ componentSchemas: Array.isArray(input.componentSchemas) ? input.componentSchemas : undefined,
263
+ model: input.model,
264
+ temperature: input.temperature ?? undefined,
265
+ maxTokens: input.maxTokens ?? undefined,
266
+ // 외부 도메인 (board-import 등) 의 read/external tool builder 가 server-side
267
+ // 의존성 (도메인 / 유저 / 트랜잭션) 을 사용할 수 있도록 ResolverContext.state 전달.
268
+ // board-ai 코어 자체는 이 값을 알지 않고 dispatch 단계에서 closure 로만 흘림.
269
+ toolCallContext: context.state
270
+ })
271
+
272
+ // ── Phase C: LLM 호출 후 짧은 DB 쓰기 ─────────────────────────
273
+ let assistantMessageId: string | undefined
274
+ let patchId: string | undefined
275
+ if (session) {
276
+ let patchEntry: PatchEntry | undefined
277
+ if (r.patch) {
278
+ patchEntry = await patchRepo.save({
279
+ session: { id: session.id } as any,
280
+ source: 'ai',
281
+ ops: JSON.stringify(r.patch.ops),
282
+ summary: r.patch.summary,
283
+ confidence: r.patch.confidence,
284
+ reverted: false
285
+ })
286
+ patchId = patchEntry.id
287
+ // C1: 같은 board 구독자에게 방송 (실시간 공동편집 spine). best-effort.
288
+ publishBoardPatch({ domainId: domain.id, boardId: session.boardId, patch: patchEntry })
289
+ }
290
+
291
+ // AI 응답의 #token 들을 currentBoard 에 대해 resolve 한 뒤 inline marker 로 박음.
292
+ // patch 로 새로 만든 컴포넌트는 input.currentBoard 에 없으므로 marker 없이 저장 →
293
+ // 클릭 시 runtime fallback 으로 해석 (그때는 patch 적용 후라 scene 에 존재).
294
+ const enrichedReply = injectAiMentionRefids(r.reply, input.currentBoard ?? null)
295
+
296
+ const aiMsg = await messageRepo.save({
297
+ session: { id: session.id } as any,
298
+ role: 'assistant',
299
+ content: enrichedReply,
300
+ relatedPatchId: patchEntry?.id,
301
+ toolUsages: r.toolUsages && r.toolUsages.length > 0 ? JSON.stringify(r.toolUsages) : undefined,
302
+ // assistant 메시지의 creator = 이 응답을 트리거한 user (= 직전 user 메시지의 작성자).
303
+ // 같은 turn 으로 묶임 → 향후 사용자별 필터에서 user/assistant 가 함께 따라옴.
304
+ creator: user,
305
+ updater: user,
306
+ // parentMessage = 이 응답이 답하는 user 메시지. regenerate 시 같은 user 메시지를
307
+ // 부모로 하는 형제 assistant 들이 생기면 자연스럽게 branching tree.
308
+ parentMessage: userMessageId ? ({ id: userMessageId } as any) : undefined
309
+ })
310
+ assistantMessageId = aiMsg.id
311
+
312
+ // C4: AI 응답도 같은 세션 구독자에게 방송 (트리거한 본인 포함 — id dedup). best-effort.
313
+ publishChatMessage({ domainId: domain.id, sessionId: session.id, message: aiMsg })
314
+
315
+ await sessionRepo.update(session.id, {
316
+ aiClientId: base.id,
317
+ updater: user
318
+ })
319
+ }
320
+
321
+ return {
322
+ reply: r.reply,
323
+ patch: r.patch ?? null,
324
+ followUp: r.followUp ?? null,
325
+ clientId: base.id,
326
+ sessionId: session?.id,
327
+ userMessageId,
328
+ assistantMessageId,
329
+ patchId,
330
+ toolUsages: r.toolUsages ?? null,
331
+ actions: r.actions && r.actions.length > 0 ? r.actions : null
332
+ }
333
+ }
334
+
335
+ /**
336
+ * C4: 메시지에 @멘션된 사용자를 세션 참여자(member)로 자동 등록 — 초대.
337
+ * enriched content 의 {userId:UUID} 마커에서 추출 → 기존 참여자·자기 제외한 신규만 등록.
338
+ * 소프트삭제(deletedAt)된 과거 참여자는 다시 살린다(재초대). 신규 등록 후 협업 초대 알림 발송(C4b-2, best-effort).
339
+ */
340
+ private async _inviteMentionedUsers(
341
+ content: string,
342
+ sessionId: string,
343
+ domainId: string,
344
+ inviter: any,
345
+ boardId?: string,
346
+ notify?: (arg: any) => Promise<void>
347
+ ): Promise<void> {
348
+ const mentioned = extractMentionUserIds(content)
349
+ if (mentioned.length === 0) return
350
+
351
+ const repo = getRepository(ChatSessionParticipant)
352
+ const existing = await repo.find({
353
+ where: { session: { id: sessionId } as any, domain: { id: domainId } as any },
354
+ withDeleted: true
355
+ })
356
+ const activeIds = existing.filter(p => !p.deletedAt).map(p => p.userId!).filter(Boolean)
357
+ const fresh = newParticipantUserIds(mentioned, activeIds, inviter?.id)
358
+ if (fresh.length === 0) return
359
+
360
+ for (const userId of fresh) {
361
+ // 과거에 나갔던(soft-deleted) 참여자면 되살리고, 아니면 새로 등록.
362
+ const prior = existing.find(p => p.userId === userId && p.deletedAt)
363
+ if (prior) {
364
+ await repo.update(prior.id!, { deletedAt: null as any, role: 'member', updater: inviter } as any)
365
+ } else {
366
+ await repo.save({
367
+ domain: { id: domainId } as any,
368
+ session: { id: sessionId } as any,
369
+ user: { id: userId } as any,
370
+ role: 'member',
371
+ creator: inviter,
372
+ updater: inviter
373
+ } as any)
374
+ }
375
+ }
376
+
377
+ // C4b-2: 초대 알림 — notification 모듈의 기존 발송 메커니즘 재사용(중복 구현 금지).
378
+ // context.state.notify 는 notification-middleware 가 전역 주입(FCM/pubsub). duck-typed →
379
+ // board-ai 에 의존성 추가 없이 런타임 호출. 미로드 환경이면 undefined → graceful skip.
380
+ this._notifyInvited(notify, fresh, boardId, inviter)
381
+ }
382
+
383
+ /** 초대된 사용자들에게 협업 초대 알림 발송 (best-effort, capability 있을 때만). */
384
+ private _notifyInvited(
385
+ notify: ((arg: any) => Promise<void>) | undefined,
386
+ userIds: string[],
387
+ boardId: string | undefined,
388
+ inviter: any
389
+ ): void {
390
+ if (typeof notify !== 'function') return
391
+ const payload = buildInviteNotification(userIds, boardId, inviter)
392
+ if (!payload) return
393
+ Promise.resolve(notify(payload)).catch(() => {
394
+ /* 알림 실패는 초대·대화를 막지 않는다 */
395
+ })
396
+ }
397
+ }
398
+
399
+ function resolveAIClient(): AIClient | undefined {
400
+ const existing = getDefaultAIClient()
401
+ if (existing) return existing
402
+ const cfg = config.get('aiClient', null) as any
403
+ if (cfg && cfg.provider) {
404
+ return createAIClient(cfg)
405
+ }
406
+ return undefined
407
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * catalog-resolver.ts 단위 테스트.
3
+ *
4
+ * 핵심 보호:
5
+ * - @name → 'name (kind)' 정확 변환 (prefab / palette / layout 각각)
6
+ * - 다중 kind 매치 시 'name (prefab and palette)' 형태
7
+ * - 매칭 실패 → 원문 그대로
8
+ * - 멀티 토큰 위치 보존
9
+ * - 단어 중간 @ (email-like 'user@host') 는 어떻게 — 의도적 단순 처리
10
+ */
11
+ import {
12
+ resolveCatalogMentions,
13
+ findCatalogMentions,
14
+ lookupCatalogKinds
15
+ } from './catalog-resolver'
16
+
17
+ describe('lookupCatalogKinds', () => {
18
+ test('default prefab 3종 인식', () => {
19
+ expect(lookupCatalogKinds('monitor-card')).toEqual(['prefab'])
20
+ expect(lookupCatalogKinds('gauge-panel')).toEqual(['prefab'])
21
+ expect(lookupCatalogKinds('stat-tile')).toEqual(['prefab'])
22
+ })
23
+
24
+ test('default palette 7종 인식', () => {
25
+ expect(lookupCatalogKinds('industrial-steel')).toEqual(['palette'])
26
+ expect(lookupCatalogKinds('financial-trader')).toEqual(['palette'])
27
+ expect(lookupCatalogKinds('neon-cyberpunk')).toEqual(['palette'])
28
+ })
29
+
30
+ test('default layout 6종 인식', () => {
31
+ expect(lookupCatalogKinds('kpi-row')).toEqual(['layout'])
32
+ expect(lookupCatalogKinds('3x3-grid')).toEqual(['layout'])
33
+ expect(lookupCatalogKinds('t-shape-command')).toEqual(['layout'])
34
+ })
35
+
36
+ test('알 수 없는 이름 → []', () => {
37
+ expect(lookupCatalogKinds('no-such-thing')).toEqual([])
38
+ })
39
+
40
+ test('대소문자 구분 — 정확 매칭만', () => {
41
+ expect(lookupCatalogKinds('Monitor-Card')).toEqual([])
42
+ expect(lookupCatalogKinds('INDUSTRIAL-STEEL')).toEqual([])
43
+ })
44
+ })
45
+
46
+ describe('resolveCatalogMentions — 단일 매칭', () => {
47
+ test('prefab', () => {
48
+ expect(resolveCatalogMentions('@monitor-card 3개 추가')).toBe(
49
+ 'monitor-card (prefab) 3개 추가'
50
+ )
51
+ })
52
+
53
+ test('palette', () => {
54
+ expect(resolveCatalogMentions('@industrial-steel 팔레트')).toBe(
55
+ 'industrial-steel (palette) 팔레트'
56
+ )
57
+ })
58
+
59
+ test('layout', () => {
60
+ expect(resolveCatalogMentions('@kpi-row 으로 정렬')).toBe(
61
+ 'kpi-row (layout) 으로 정렬'
62
+ )
63
+ })
64
+ })
65
+
66
+ describe('resolveCatalogMentions — 매칭 실패', () => {
67
+ test('알 수 없는 이름 → 원문 보존', () => {
68
+ expect(resolveCatalogMentions('@unknown-thing 어쩌고')).toBe(
69
+ '@unknown-thing 어쩌고'
70
+ )
71
+ })
72
+
73
+ test('빈 텍스트 안전', () => {
74
+ expect(resolveCatalogMentions('')).toBe('')
75
+ })
76
+
77
+ test('@ 만 있고 token 없으면 변환 안 함', () => {
78
+ expect(resolveCatalogMentions('@ alone')).toBe('@ alone')
79
+ })
80
+
81
+ test('email-like (user@host) 도 token 매칭 — 카탈로그에 없으면 그대로', () => {
82
+ // @host 가 카탈로그 매칭이면 변환됨. 이 테스트의 'host' 는 카탈로그에 없음.
83
+ expect(resolveCatalogMentions('contact me at user@host')).toBe(
84
+ 'contact me at user@host'
85
+ )
86
+ })
87
+ })
88
+
89
+ describe('resolveCatalogMentions — 멀티 토큰', () => {
90
+ test('서로 다른 카탈로그의 두 토큰', () => {
91
+ expect(
92
+ resolveCatalogMentions('@monitor-card 를 @industrial-steel 로')
93
+ ).toBe('monitor-card (prefab) 를 industrial-steel (palette) 로')
94
+ })
95
+
96
+ test('일부만 매칭 — 나머지는 원문', () => {
97
+ expect(resolveCatalogMentions('@monitor-card and @no-such')).toBe(
98
+ 'monitor-card (prefab) and @no-such'
99
+ )
100
+ })
101
+
102
+ test('3종 카탈로그 한 메시지에', () => {
103
+ expect(
104
+ resolveCatalogMentions('@kpi-row 에 @monitor-card 3개 + @neon-cyberpunk')
105
+ ).toBe(
106
+ 'kpi-row (layout) 에 monitor-card (prefab) 3개 + neon-cyberpunk (palette)'
107
+ )
108
+ })
109
+ })
110
+
111
+ describe('resolveCatalogMentions — 위치 보존 (뒤에서 앞으로 치환)', () => {
112
+ test('첫 번째 토큰 매칭, 두 번째 매칭 실패 — 인덱스 어긋남 없이 정확', () => {
113
+ const r = resolveCatalogMentions('@stat-tile 와 @notmatch')
114
+ expect(r).toBe('stat-tile (prefab) 와 @notmatch')
115
+ })
116
+
117
+ test('치환 결과가 원본보다 길어져도 후속 토큰 위치 정확', () => {
118
+ // 원본 길이가 변해도 backward iteration 으로 안전
119
+ const r = resolveCatalogMentions('a @stat-tile b @gauge-panel c')
120
+ expect(r).toBe('a stat-tile (prefab) b gauge-panel (prefab) c')
121
+ })
122
+ })
123
+
124
+ describe('findCatalogMentions — UI / 디버그용', () => {
125
+ test('모든 매칭 결과 (성공 + 실패) 반환', () => {
126
+ const r = findCatalogMentions('@monitor-card @no-such @industrial-steel')
127
+ expect(r).toEqual([
128
+ { token: 'monitor-card', kinds: ['prefab'] },
129
+ { token: 'no-such', kinds: [] },
130
+ { token: 'industrial-steel', kinds: ['palette'] }
131
+ ])
132
+ })
133
+
134
+ test('빈 텍스트 → []', () => {
135
+ expect(findCatalogMentions('')).toEqual([])
136
+ })
137
+ })
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `@name` 카탈로그 멘션 → kind disambiguation.
3
+ *
4
+ * Phase 1A 의 mention-resolver (#name → 'name (refid 35)') 와 같은 패턴을
5
+ * 카탈로그 (prefab / color palette / layout template) 식별자에 적용.
6
+ *
7
+ * "@monitor-card 3개 추가"
8
+ * → "monitor-card (prefab) 3개 추가"
9
+ *
10
+ * "@industrial-steel 팔레트로 통일"
11
+ * → "industrial-steel (palette) 팔레트로 통일"
12
+ *
13
+ * 다중 kind 매치 (이름이 prefab + palette 둘 다인 희귀 케이스):
14
+ * → "name (prefab and palette)"
15
+ *
16
+ * 매칭 0 — 원문 보존 (AI 가 자연어로 폴백).
17
+ *
18
+ * Token 정의: `@` 뒤의 공백/`@` 이 아닌 모든 글자. 정규식 /@([^\s@]+)/g.
19
+ *
20
+ * 의도: AI 가 이름을 자유 작곡 / hallucinate 하지 않고 정확한 카탈로그 식별자를
21
+ * 사용하도록 호출자가 명시한 의도를 server-side 에서 보강.
22
+ */
23
+ import { listBoardPrefabs } from './prefab-registry'
24
+ import { listColorPaletteNames, listLayoutTemplateNames } from './assistant'
25
+
26
+ export type CatalogKind = 'prefab' | 'palette' | 'layout'
27
+
28
+ const CATALOG_PATTERN = /@([^\s@]+)/g
29
+
30
+ /**
31
+ * 텍스트의 `@token` 들을 카탈로그와 매칭해 LLM 친화 형태로 변환.
32
+ */
33
+ export function resolveCatalogMentions(text: string): string {
34
+ if (!text || typeof text !== 'string') return text
35
+ const matches = collectCatalogMatches(text)
36
+ if (matches.length === 0) return text
37
+
38
+ let out = text
39
+ for (let i = matches.length - 1; i >= 0; i--) {
40
+ const m = matches[i]
41
+ if (m.kinds.length === 0) continue // 매칭 실패 — 그대로
42
+ const replacement = formatCatalogReplacement(m.token, m.kinds)
43
+ out = out.slice(0, m.start) + replacement + out.slice(m.end)
44
+ }
45
+ return out
46
+ }
47
+
48
+ interface CatalogMatch {
49
+ token: string
50
+ start: number
51
+ end: number
52
+ kinds: CatalogKind[]
53
+ }
54
+
55
+ function collectCatalogMatches(text: string): CatalogMatch[] {
56
+ const matches: CatalogMatch[] = []
57
+ const pattern = new RegExp(CATALOG_PATTERN.source, 'g')
58
+ let m: RegExpExecArray | null
59
+ while ((m = pattern.exec(text)) !== null) {
60
+ const token = m[1]
61
+ const kinds = lookupCatalogKinds(token)
62
+ matches.push({
63
+ token,
64
+ start: m.index,
65
+ end: m.index + m[0].length,
66
+ kinds
67
+ })
68
+ }
69
+ return matches
70
+ }
71
+
72
+ /**
73
+ * Token 의 매칭 kind 목록 (case-sensitive — 카탈로그 식별자는 정확 매칭).
74
+ *
75
+ * 같은 이름이 여러 카탈로그에 존재해도 모두 반환. UI 가 disambiguation 안내.
76
+ */
77
+ export function lookupCatalogKinds(token: string): CatalogKind[] {
78
+ const kinds: CatalogKind[] = []
79
+ const prefabs = listBoardPrefabs()
80
+ if (prefabs.some(p => p.name === token)) kinds.push('prefab')
81
+ if (listColorPaletteNames().includes(token)) kinds.push('palette')
82
+ if (listLayoutTemplateNames().includes(token)) kinds.push('layout')
83
+ return kinds
84
+ }
85
+
86
+ function formatCatalogReplacement(token: string, kinds: CatalogKind[]): string {
87
+ if (kinds.length === 1) return `${token} (${kinds[0]})`
88
+ return `${token} (${kinds.join(' and ')})`
89
+ }
90
+
91
+ /**
92
+ * 텍스트의 모든 `@token` 매칭 결과 — UI / 디버그용.
93
+ */
94
+ export function findCatalogMentions(
95
+ text: string
96
+ ): Array<{ token: string; kinds: CatalogKind[] }> {
97
+ if (!text || typeof text !== 'string') return []
98
+ return collectCatalogMatches(text).map(m => ({
99
+ token: m.token,
100
+ kinds: m.kinds
101
+ }))
102
+ }