@things-factory/ai-assistant 10.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/client/components/board-ai-chat.test.ts +149 -0
  2. package/client/components/board-ai-chat.ts +3228 -0
  3. package/client/components/chat-defaults.ts +27 -0
  4. package/client/components/chat-echo-dedup.test.ts +230 -0
  5. package/client/components/chat-echo-dedup.ts +155 -0
  6. package/client/components/chat-input-builder.ts +69 -0
  7. package/client/components/markdown.test.ts +518 -0
  8. package/client/components/markdown.ts +508 -0
  9. package/client/components/mention-popup-helpers.ts +433 -0
  10. package/client/components/mention-popup.test.ts +588 -0
  11. package/client/components/mention-popup.ts +238 -0
  12. package/client/index.ts +14 -0
  13. package/client/tsconfig.json +13 -0
  14. package/client/utils/board-edit-patch.ts +374 -0
  15. package/dist-client/components/board-ai-chat.d.ts +423 -0
  16. package/dist-client/components/board-ai-chat.js +3034 -0
  17. package/dist-client/components/board-ai-chat.js.map +1 -0
  18. package/dist-client/components/board-ai-chat.test.d.ts +1 -0
  19. package/dist-client/components/board-ai-chat.test.js +138 -0
  20. package/dist-client/components/board-ai-chat.test.js.map +1 -0
  21. package/dist-client/components/chat-defaults.d.ts +8 -0
  22. package/dist-client/components/chat-defaults.js +22 -0
  23. package/dist-client/components/chat-defaults.js.map +1 -0
  24. package/dist-client/components/chat-echo-dedup.d.ts +54 -0
  25. package/dist-client/components/chat-echo-dedup.js +115 -0
  26. package/dist-client/components/chat-echo-dedup.js.map +1 -0
  27. package/dist-client/components/chat-echo-dedup.test.d.ts +1 -0
  28. package/dist-client/components/chat-echo-dedup.test.js +203 -0
  29. package/dist-client/components/chat-echo-dedup.test.js.map +1 -0
  30. package/dist-client/components/chat-input-builder.d.ts +55 -0
  31. package/dist-client/components/chat-input-builder.js +32 -0
  32. package/dist-client/components/chat-input-builder.js.map +1 -0
  33. package/dist-client/components/markdown.d.ts +60 -0
  34. package/dist-client/components/markdown.js +422 -0
  35. package/dist-client/components/markdown.js.map +1 -0
  36. package/dist-client/components/markdown.test.d.ts +1 -0
  37. package/dist-client/components/markdown.test.js +418 -0
  38. package/dist-client/components/markdown.test.js.map +1 -0
  39. package/dist-client/components/mention-popup-helpers.d.ts +165 -0
  40. package/dist-client/components/mention-popup-helpers.js +330 -0
  41. package/dist-client/components/mention-popup-helpers.js.map +1 -0
  42. package/dist-client/components/mention-popup.d.ts +35 -0
  43. package/dist-client/components/mention-popup.js +223 -0
  44. package/dist-client/components/mention-popup.js.map +1 -0
  45. package/dist-client/components/mention-popup.test.d.ts +1 -0
  46. package/dist-client/components/mention-popup.test.js +474 -0
  47. package/dist-client/components/mention-popup.test.js.map +1 -0
  48. package/dist-client/index.d.ts +14 -0
  49. package/dist-client/index.js +15 -0
  50. package/dist-client/index.js.map +1 -0
  51. package/dist-client/tsconfig.tsbuildinfo +1 -0
  52. package/dist-client/utils/board-edit-patch.d.ts +157 -0
  53. package/dist-client/utils/board-edit-patch.js +284 -0
  54. package/dist-client/utils/board-edit-patch.js.map +1 -0
  55. package/dist-server/index.d.ts +28 -0
  56. package/dist-server/index.js +32 -0
  57. package/dist-server/index.js.map +1 -0
  58. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
  59. package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
  60. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
  61. package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
  62. package/dist-server/service/chat-message/chat-message-publish.js +27 -0
  63. package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
  64. package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
  65. package/dist-server/service/chat-message/chat-message-subscription.js +57 -0
  66. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
  67. package/dist-server/service/chat-message/chat-message.d.ts +68 -0
  68. package/dist-server/service/chat-message/chat-message.js +172 -0
  69. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  70. package/dist-server/service/chat-message/fold-history.d.ts +43 -0
  71. package/dist-server/service/chat-message/fold-history.js +33 -0
  72. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  73. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  74. package/dist-server/service/chat-message/history-summary.js +77 -0
  75. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  76. package/dist-server/service/chat-message/index.d.ts +5 -0
  77. package/dist-server/service/chat-message/index.js +12 -0
  78. package/dist-server/service/chat-message/index.js.map +1 -0
  79. package/dist-server/service/chat-message/llm-history.d.ts +46 -0
  80. package/dist-server/service/chat-message/llm-history.js +87 -0
  81. package/dist-server/service/chat-message/llm-history.js.map +1 -0
  82. package/dist-server/service/chat-session/chat-session.d.ts +60 -0
  83. package/dist-server/service/chat-session/chat-session.js +159 -0
  84. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  85. package/dist-server/service/chat-session/index.d.ts +3 -0
  86. package/dist-server/service/chat-session/index.js +7 -0
  87. package/dist-server/service/chat-session/index.js.map +1 -0
  88. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +30 -0
  89. package/dist-server/service/chat-session/session-activity-broadcast.js +34 -0
  90. package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -0
  91. package/dist-server/service/chat-session/session-activity-publish.d.ts +12 -0
  92. package/dist-server/service/chat-session/session-activity-publish.js +37 -0
  93. package/dist-server/service/chat-session/session-activity-publish.js.map +1 -0
  94. package/dist-server/service/chat-session/session-activity-subscription.d.ts +14 -0
  95. package/dist-server/service/chat-session/session-activity-subscription.js +87 -0
  96. package/dist-server/service/chat-session/session-activity-subscription.js.map +1 -0
  97. package/dist-server/service/chat-session/session-anchor.d.ts +26 -0
  98. package/dist-server/service/chat-session/session-anchor.js +100 -0
  99. package/dist-server/service/chat-session/session-anchor.js.map +1 -0
  100. package/dist-server/service/chat-session/session-inbox.d.ts +80 -0
  101. package/dist-server/service/chat-session/session-inbox.js +133 -0
  102. package/dist-server/service/chat-session/session-inbox.js.map +1 -0
  103. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +49 -0
  104. package/dist-server/service/chat-session-participant/chat-session-participant.js +141 -0
  105. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
  106. package/dist-server/service/chat-session-participant/index.d.ts +4 -0
  107. package/dist-server/service/chat-session-participant/index.js +10 -0
  108. package/dist-server/service/chat-session-participant/index.js.map +1 -0
  109. package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
  110. package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
  112. package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
  113. package/dist-server/service/chat-session-participant/presence.js +22 -0
  114. package/dist-server/service/chat-session-participant/presence.js.map +1 -0
  115. package/dist-server/service/chat-session-resolver.d.ts +93 -0
  116. package/dist-server/service/chat-session-resolver.js +706 -0
  117. package/dist-server/service/chat-session-resolver.js.map +1 -0
  118. package/dist-server/service/index.d.ts +24 -0
  119. package/dist-server/service/index.js +39 -0
  120. package/dist-server/service/index.js.map +1 -0
  121. package/dist-server/service/subscribe-auth.d.ts +14 -0
  122. package/dist-server/service/subscribe-auth.js +33 -0
  123. package/dist-server/service/subscribe-auth.js.map +1 -0
  124. package/dist-server/service/types.d.ts +5 -0
  125. package/dist-server/service/types.js +12 -0
  126. package/dist-server/service/types.js.map +1 -0
  127. package/dist-server/tsconfig.tsbuildinfo +1 -0
  128. package/package.json +48 -0
  129. package/server/index.ts +28 -0
  130. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
  131. package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
  132. package/server/service/chat-message/chat-message-broadcast.ts +27 -0
  133. package/server/service/chat-message/chat-message-publish.ts +22 -0
  134. package/server/service/chat-message/chat-message-subscription.ts +45 -0
  135. package/server/service/chat-message/chat-message.ts +195 -0
  136. package/server/service/chat-message/fold-history.test.ts +98 -0
  137. package/server/service/chat-message/fold-history.ts +77 -0
  138. package/server/service/chat-message/history-summary.test.ts +127 -0
  139. package/server/service/chat-message/history-summary.ts +100 -0
  140. package/server/service/chat-message/index.ts +12 -0
  141. package/server/service/chat-message/llm-history.test.ts +194 -0
  142. package/server/service/chat-message/llm-history.ts +163 -0
  143. package/server/service/chat-session/chat-session.ts +193 -0
  144. package/server/service/chat-session/index.ts +5 -0
  145. package/server/service/chat-session/session-activity-broadcast.ts +57 -0
  146. package/server/service/chat-session/session-activity-publish.ts +44 -0
  147. package/server/service/chat-session/session-activity-subscription.ts +67 -0
  148. package/server/service/chat-session/session-anchor.ts +106 -0
  149. package/server/service/chat-session/session-inbox.test.ts +185 -0
  150. package/server/service/chat-session/session-inbox.ts +161 -0
  151. package/server/service/chat-session-participant/chat-session-participant.ts +135 -0
  152. package/server/service/chat-session-participant/index.ts +6 -0
  153. package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
  154. package/server/service/chat-session-participant/invite-notification.ts +48 -0
  155. package/server/service/chat-session-participant/presence.test.ts +39 -0
  156. package/server/service/chat-session-participant/presence.ts +21 -0
  157. package/server/service/chat-session-resolver.ts +644 -0
  158. package/server/service/index.ts +37 -0
  159. package/server/service/session-anchor.test.ts +133 -0
  160. package/server/service/subscribe-auth.test.ts +57 -0
  161. package/server/service/subscribe-auth.ts +34 -0
  162. package/server/service/types.ts +15 -0
  163. package/things-factory.config.js +1 -0
  164. package/tsconfig.json +9 -0
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Mention popup pure helpers — Lit / DOM 의존 없음. 단위 테스트 가능.
3
+ *
4
+ * Lit 엘리먼트 (ox-mention-popup) 와 분리 — jest 의 node 환경에서 'lit' import 가
5
+ * ESM 변환 없이 실패하므로 chat-input-builder 패턴과 동일하게 helper 분리.
6
+ */
7
+
8
+ export interface MentionCandidate {
9
+ /** popup 에 보일 1차 식별 라벨 */
10
+ label: string
11
+ /** 카테고리 — 컴포넌트 type / 카탈로그 kind ('prefab'/'palette'/'layout') / 'template' / 'user' */
12
+ type: string
13
+ /** 컴포넌트 후보 — things-scene refid (popup 표시 안 함, AI 내부 reference) */
14
+ refid?: number
15
+ /** 사용자 멘션 후보 — 도메인 사용자 id (UUID). popup 표시 안 함. */
16
+ userId?: string
17
+ /** 사용자 멘션 후보 — 이메일 (라벨 부가 정보 / 검색 대상) */
18
+ email?: string
19
+ /** 선택 시 trigger+label 대신 삽입할 전체 텍스트 (slash template 용) */
20
+ replacementText?: string
21
+ /** F-Finder 검색 필드 — things-scene 컴포넌트의 실제 키. 'name' 없음. */
22
+ id?: string
23
+ tag?: string
24
+ class?: string
25
+ text?: string
26
+ /** 카탈로그/슬래시 후보용 — description 도 검색 대상 */
27
+ description?: string
28
+ }
29
+
30
+ /** filter 결과 — 매칭됐을 때만 matchType + matchValue 동봉.
31
+ * matchType union 은 things-scene SearchResult 와 동일 (id/name/tag/type/class/
32
+ * text/data/property) + 자체 'description' (catalog/slash). */
33
+ export interface FilterResult {
34
+ candidate: MentionCandidate
35
+ matchType?:
36
+ | 'id'
37
+ | 'name'
38
+ | 'tag'
39
+ | 'type'
40
+ | 'class'
41
+ | 'text'
42
+ | 'data'
43
+ | 'property'
44
+ | 'description'
45
+ matchValue?: string
46
+ }
47
+
48
+ /** 검색 시 시도할 field 순서.
49
+ * - 컴포넌트: id 우선 (F-Finder 와 동일 — id/tag/type/class/text)
50
+ * - user 후보: id 없으니 label/email 로 매칭
51
+ * - catalog/slash: label/description 로 매칭
52
+ *
53
+ * 'label' 이 fallback 으로 들어있어 모든 candidate 가 최소 1개 매칭 가능. */
54
+ const SEARCH_FIELDS: Array<{
55
+ key: keyof MentionCandidate
56
+ type: NonNullable<FilterResult['matchType']>
57
+ }> = [
58
+ { key: 'id', type: 'id' },
59
+ { key: 'label', type: 'name' },
60
+ { key: 'email', type: 'data' },
61
+ { key: 'tag', type: 'tag' },
62
+ { key: 'type', type: 'type' },
63
+ { key: 'class', type: 'class' },
64
+ { key: 'text', type: 'text' },
65
+ { key: 'description', type: 'description' }
66
+ ]
67
+
68
+ /**
69
+ * 보드의 컴포넌트를 popup 후보로 변환. things-scene 컴포넌트 state 키만 사용:
70
+ * id / type / tag / class / text. 'name' 같은 별도 개념 도입 안 함.
71
+ *
72
+ * Label: id 있으면 id, 없으면 type. refid 는 popup 에 표시 안 함 (시스템 핸들).
73
+ */
74
+ export function buildMentionCandidates(components: any): MentionCandidate[] {
75
+ const list = Array.isArray(components) ? components : []
76
+ const out: MentionCandidate[] = []
77
+ for (const c of list) {
78
+ if (!c || typeof (c as any).refid !== 'number') continue
79
+ const refid = (c as any).refid
80
+ const id = strField(c, 'id')
81
+ const type = typeof (c as any).type === 'string' ? (c as any).type : 'unknown'
82
+ const tag = strField(c, 'tag')
83
+ const cls = strField(c, 'class')
84
+ const text = strField(c, 'text')
85
+ const label = id || type
86
+ out.push({ refid, label, type, id, tag, class: cls, text })
87
+ }
88
+ return out
89
+ }
90
+
91
+ function strField(c: any, key: string): string | undefined {
92
+ const v = c[key]
93
+ return typeof v === 'string' && v.length > 0 ? v : undefined
94
+ }
95
+
96
+ // ── User (`@`) 후보 ─────────────────────────────────────────────────
97
+
98
+ /**
99
+ * 도메인 사용자 → MentionCandidate. server `domainUsersForMention` 결과를 popup 표시용
100
+ * 으로 변환.
101
+ * - label : 화면 표시 + textarea 삽입 토큰 (공백 / 비-단어 문자 불가 → 이메일 username 으로
102
+ * fallback 또는 underscore 치환)
103
+ * - userId : 영속용 UUID (inline marker `{userId:UUID}` 로 박힘)
104
+ *
105
+ * mention 토큰 정규식은 word char 만 허용하므로 공백 / 점 / 하이픈 같은 문자 들어간 name 은
106
+ * 그대로 쓰면 토큰이 첫 단어에서 끊김. 안전한 토큰 우선순위:
107
+ * 1. name (공백 / 비-단어 문자 없음)
108
+ * 2. email 의 username (공백 / 비-단어 문자 없음)
109
+ * 3. name 의 공백을 underscore 로 치환
110
+ * 4. id (UUID — 마지막 폴백)
111
+ */
112
+ export function buildUserCandidates(
113
+ users: ReadonlyArray<{ id: string; name?: string | null; email?: string | null }>
114
+ ): MentionCandidate[] {
115
+ const out: MentionCandidate[] = []
116
+ for (const u of users) {
117
+ if (!u || typeof u.id !== 'string') continue
118
+ const name = typeof u.name === 'string' && u.name.trim() ? u.name.trim() : undefined
119
+ const email = typeof u.email === 'string' && u.email.trim() ? u.email.trim() : undefined
120
+ out.push({
121
+ label: deriveUserToken(u.id, name, email),
122
+ type: 'user',
123
+ userId: u.id,
124
+ email
125
+ })
126
+ }
127
+ return out
128
+ }
129
+
130
+ /** 토큰 안전 검사 — mention 정규식 (word char + 한/일/중) 으로만 구성됐는지. */
131
+ function isSafeMentionToken(s: string): boolean {
132
+ return /^[\w\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}-]+$/u.test(s)
133
+ }
134
+
135
+ function deriveUserToken(id: string, name?: string, email?: string): string {
136
+ if (name && isSafeMentionToken(name)) return name
137
+ if (email) {
138
+ const local = email.split('@')[0]
139
+ if (local && isSafeMentionToken(local)) return local
140
+ }
141
+ if (name) {
142
+ // 공백 / 비-단어 문자 underscore 치환 — 시각적 우선순위 유지
143
+ const sanitized = name.replace(/[^\w\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}-]+/gu, '_')
144
+ if (sanitized.length > 0) return sanitized
145
+ }
146
+ return id
147
+ }
148
+
149
+ // ── Catalog (`/` 와 함께) 후보 ──────────────────────────────────────
150
+
151
+ export interface CatalogEntry {
152
+ /** 카탈로그 식별자 — '@token' 으로 사용 + addPrefab/modifyBoard 인자에 전달 */
153
+ name: string
154
+ /** 종류 — UI 표시 + server-side disambiguation 과 일치 */
155
+ kind: 'prefab' | 'palette' | 'layout'
156
+ /** 한 줄 설명 (사용자 popup 에 보임) */
157
+ description?: string
158
+ }
159
+
160
+ /**
161
+ * 카탈로그 항목 묶음 → MentionCandidate[]. 클라이언트가 chat panel mount 시 1회만
162
+ * 빌드 후 캐시. 이름 + kind 가 unique 여야 함 — 동일 name 다른 kind 는 카탈로그
163
+ * resolver 가 disambiguation 처리.
164
+ *
165
+ * 정렬: kind 우선 (prefab → palette → layout), 그 안에서 name asc.
166
+ */
167
+ export function buildCatalogCandidates(entries: CatalogEntry[]): MentionCandidate[] {
168
+ const KIND_ORDER: Record<string, number> = { prefab: 0, palette: 1, layout: 2 }
169
+ const sorted = entries.slice().sort((a, b) => {
170
+ const ka = KIND_ORDER[a.kind] ?? 99
171
+ const kb = KIND_ORDER[b.kind] ?? 99
172
+ if (ka !== kb) return ka - kb
173
+ return a.name.localeCompare(b.name)
174
+ })
175
+ return sorted.map(e => ({
176
+ label: e.name,
177
+ type: e.kind,
178
+ description: e.description
179
+ }))
180
+ }
181
+
182
+ // ── Slash (`/`) 템플릿 ──────────────────────────────────────────────
183
+
184
+ export interface SlashTemplate {
185
+ /** 식별자 — '/grid' 의 'grid'. trigger 빠진 형태 */
186
+ name: string
187
+ /** 사용자에게 보일 한 줄 설명 */
188
+ description?: string
189
+ /** 선택 시 textarea 에 삽입될 전체 자연어 텍스트 (trigger+name 영역 교체) */
190
+ template: string
191
+ }
192
+
193
+ /**
194
+ * Slash template 묶음 → MentionCandidate[] (replacementText 사용).
195
+ *
196
+ * 정렬: name asc.
197
+ *
198
+ * Slash 는 component/catalog 와 달리 LLM 우회 명령이 아니라 사용자에게 미리 작성된
199
+ * 자연어 prompt 를 빠르게 끼워넣는 용도. AI 는 변환 결과를 그대로 자연어로 처리.
200
+ */
201
+ export function buildSlashCandidates(templates: SlashTemplate[]): MentionCandidate[] {
202
+ const sorted = templates.slice().sort((a, b) => a.name.localeCompare(b.name))
203
+ return sorted.map(t => ({
204
+ label: t.name,
205
+ type: 'template',
206
+ description: t.description,
207
+ replacementText: t.template
208
+ }))
209
+ }
210
+
211
+ /**
212
+ * cursor 직전의 trigger char (`#` or `@`) + token 추출. popup 활성 여부 판단.
213
+ *
214
+ * 활성 조건:
215
+ * - cursor 이전에 trigger 가 존재
216
+ * - trigger 와 cursor 사이에 공백 / 줄바꿈 / 동일 trigger 없음
217
+ * - trigger 직전이 문자열 시작 또는 공백/줄바꿈/구두점 (단어 중간 trigger 무시)
218
+ *
219
+ * 반환 — 활성 시 { hashPos, query } (hashPos 는 trigger 위치 — # 또는 @) /
220
+ * 비활성 시 null.
221
+ *
222
+ * trigger default '#' — 기존 호출자 호환.
223
+ */
224
+ export function extractMentionContext(
225
+ text: string,
226
+ cursorPos: number,
227
+ trigger: string = '#'
228
+ ): { hashPos: number; query: string } | null {
229
+ if (!text || cursorPos < 0 || cursorPos > text.length) return null
230
+ if (!trigger || trigger.length !== 1) return null
231
+ // cursor 에서 뒤로 이동하며 trigger 찾기 — 공백/줄바꿈 만나면 중단
232
+ let i = cursorPos - 1
233
+ while (i >= 0) {
234
+ const ch = text.charAt(i)
235
+ if (ch === trigger) break
236
+ if (ch === ' ' || ch === '\n' || ch === '\t') return null
237
+ i--
238
+ }
239
+ if (i < 0) return null
240
+ // i 가 trigger 위치. 직전 글자 검사 (단어 중간 trigger 거부)
241
+ if (i > 0) {
242
+ const prev = text.charAt(i - 1)
243
+ // 알파벳/숫자/한글 같은 word char 직후는 mention 으로 안 봄 (예: 'foo#bar', 'user@host')
244
+ if (!/[\s\n\t.,!?;:()[\]{}'"`]/.test(prev)) return null
245
+ }
246
+ return { hashPos: i, query: text.slice(i + 1, cursorPos) }
247
+ }
248
+
249
+ /**
250
+ * query 로 후보 필터 — F-Finder 스타일 multi-field.
251
+ *
252
+ * 검색 문법:
253
+ * - 빈 query → 전체 반환 (cap 까지). matchType: 'label', matchValue: candidate.label
254
+ * - 자유 substring → 우선순위 (id > name > tag > type > class > text > label > description)
255
+ * 로 첫 매치 필드 발견. matchType + matchValue 동봉
256
+ * - field:value → 해당 field 만 매칭
257
+ * * `id:` / `i:` — id
258
+ * * `name:` / `n:` — name
259
+ * * `type:` / `t:` — type
260
+ * * `tag:` — tag (신규)
261
+ * * `class:` / `c:` — class (신규)
262
+ * * `text:` — text (신규)
263
+ *
264
+ * cap default 50 (F-Finder 표준). label 길이 짧은 순 정렬.
265
+ */
266
+ export function filterMentionCandidates(
267
+ candidates: MentionCandidate[],
268
+ query: string,
269
+ cap = 50
270
+ ): FilterResult[] {
271
+ // Empty query / 'type:' 같은 빈 needle 도 일반 매칭 경로와 동일 로직.
272
+ // 각 candidate 의 가장 prominent 한 field 를 default match 로 — 모든 행이
273
+ // 항상 동일 layout (badge + label + value 가능 시) 으로 일관 표시.
274
+ if (!query) {
275
+ return candidates.slice(0, cap).map(c => defaultMatchFor(c))
276
+ }
277
+ const parsed = parseQuery(query)
278
+ if (parsed.field && !parsed.needle) {
279
+ // 'type:' 만 입력 — 전체 반환, 일반 default
280
+ return candidates.slice(0, cap).map(c => defaultMatchFor(c))
281
+ }
282
+
283
+ const out: FilterResult[] = []
284
+ if (parsed.field) {
285
+ const fieldKey = parsed.field
286
+ for (const c of candidates) {
287
+ const v = c[fieldKey]
288
+ if (typeof v === 'string' && v.toLowerCase().includes(parsed.needle)) {
289
+ out.push({ candidate: c, matchType: parsed.field, matchValue: v })
290
+ }
291
+ }
292
+ } else {
293
+ // 자유 검색 — 우선순위 필드 순회, 첫 매치 찾으면 break
294
+ const q = parsed.needle
295
+ for (const c of candidates) {
296
+ for (const f of SEARCH_FIELDS) {
297
+ const v = c[f.key]
298
+ if (typeof v === 'string' && v.toLowerCase().includes(q)) {
299
+ out.push({ candidate: c, matchType: f.type, matchValue: v })
300
+ break
301
+ }
302
+ }
303
+ }
304
+ }
305
+ out.sort((a, b) => a.candidate.label.length - b.candidate.label.length)
306
+ return out.slice(0, cap)
307
+ }
308
+
309
+ /**
310
+ * Candidate 의 default match — 우선순위 (id → name → tag → type → ...) 로 첫 non-empty
311
+ * field 를 matchType + matchValue 로 사용. Empty query 시 popup row 가 filter 시와
312
+ * 같은 layout 으로 보이도록 (badge + label + value).
313
+ */
314
+ function defaultMatchFor(c: MentionCandidate): FilterResult {
315
+ for (const f of SEARCH_FIELDS) {
316
+ const v = c[f.key]
317
+ if (typeof v === 'string' && v.length > 0) {
318
+ return { candidate: c, matchType: f.type, matchValue: v }
319
+ }
320
+ }
321
+ // 모든 field 비어있는 희귀 케이스 — matchType 없이 그냥 candidate
322
+ return { candidate: c }
323
+ }
324
+
325
+ /**
326
+ * query 를 'field:value' 형태로 파싱.
327
+ * field 가 인식되지 않으면 자유 검색 (field=undefined).
328
+ */
329
+ function parseQuery(query: string): {
330
+ field?: 'id' | 'type' | 'tag' | 'class' | 'text'
331
+ needle: string
332
+ } {
333
+ const m = /^([a-zA-Z]+):(.*)$/.exec(query)
334
+ if (m) {
335
+ const f = m[1].toLowerCase()
336
+ const map: Record<string, 'id' | 'type' | 'tag' | 'class' | 'text'> = {
337
+ i: 'id',
338
+ id: 'id',
339
+ t: 'type',
340
+ type: 'type',
341
+ tag: 'tag',
342
+ c: 'class',
343
+ class: 'class',
344
+ text: 'text'
345
+ }
346
+ if (map[f]) {
347
+ return { field: map[f], needle: m[2].toLowerCase() }
348
+ }
349
+ }
350
+ return { needle: query.toLowerCase() }
351
+ }
352
+
353
+ // ── Click resolve (메시지 버블의 #token 클릭 → refid) ──────────────
354
+
355
+ export interface ResolveMentionTokenOptions {
356
+ /** 메시지 버블의 `<span data-refid>` 값. 작성 시점에 박제된 refid (가장 정확). */
357
+ persistedRefid?: string | null
358
+ /** 호스트의 SceneSearchEngine 위임 — 작성 시점 refid 없거나 stale 일 때 fallback. */
359
+ searchProvider?: (query: string) => FilterResult[]
360
+ /** 보드 모델 — components.find(id) 직접 매칭 최후 fallback. */
361
+ board?: { components?: any[] } | null
362
+ }
363
+
364
+ /**
365
+ * `#token` 클릭 시 token → refid resolve. 우선순위:
366
+ * 1. `persistedRefid` (data-refid 속성, 작성 시점 박제)
367
+ * 2. `searchProvider` — 'id' 정확 일치 → 첫 결과
368
+ * 3. `board.components.find(c => c.id === token)` — id 직접 매칭
369
+ *
370
+ * 모두 실패하면 `undefined`. UI 측에서 not-found 처리 (toast 등).
371
+ *
372
+ * Pure 함수 — DOM/Lit 의존 없음, 단위 테스트 가능.
373
+ */
374
+ export function resolveMentionToken(
375
+ token: string,
376
+ opts: ResolveMentionTokenOptions
377
+ ): number | undefined {
378
+ if (!token) return undefined
379
+
380
+ // 1. 작성 시점 박제 refid
381
+ if (opts.persistedRefid) {
382
+ const n = Number(opts.persistedRefid)
383
+ if (Number.isFinite(n)) return n
384
+ }
385
+
386
+ // 2. searchProvider — 'id' 정확 일치 우선
387
+ if (opts.searchProvider) {
388
+ const results = opts.searchProvider(token)
389
+ const exact = results.find(
390
+ r => r.matchType === 'id' && r.matchValue === token
391
+ )
392
+ const hit = (exact ?? results[0])?.candidate.refid
393
+ if (typeof hit === 'number' && Number.isFinite(hit)) return hit
394
+ }
395
+
396
+ // 3. board.components — id 직접 매칭
397
+ const components = opts.board?.components
398
+ if (Array.isArray(components)) {
399
+ const hit = components.find((c: any) => c?.id === token)
400
+ if (hit && typeof hit.refid === 'number' && Number.isFinite(hit.refid)) {
401
+ return hit.refid
402
+ }
403
+ }
404
+
405
+ return undefined
406
+ }
407
+
408
+ /**
409
+ * 선택된 후보로 textarea text 갱신 — trigger + query 영역을 교체.
410
+ *
411
+ * 두 모드:
412
+ * - 일반 (component / catalog): trigger + label + 공백 으로 교체
413
+ * - slash template: candidate.replacementText 가 있으면 그걸로 통째 교체
414
+ *
415
+ * 새 cursor 위치도 같이 반환.
416
+ *
417
+ * trigger default '#' — 기존 호출자 호환.
418
+ */
419
+ export function applyMentionSelection(
420
+ text: string,
421
+ cursorPos: number,
422
+ hashPos: number,
423
+ candidate: MentionCandidate,
424
+ trigger: string = '#'
425
+ ): { text: string; cursorPos: number } {
426
+ const before = text.slice(0, hashPos)
427
+ const after = text.slice(cursorPos)
428
+ const insert = candidate.replacementText ?? `${trigger}${candidate.label} `
429
+ return {
430
+ text: before + insert + after,
431
+ cursorPos: before.length + insert.length
432
+ }
433
+ }