@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,165 @@
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
+ export interface MentionCandidate {
8
+ /** popup 에 보일 1차 식별 라벨 */
9
+ label: string;
10
+ /** 카테고리 — 컴포넌트 type / 카탈로그 kind ('prefab'/'palette'/'layout') / 'template' / 'user' */
11
+ type: string;
12
+ /** 컴포넌트 후보 — things-scene refid (popup 표시 안 함, AI 내부 reference) */
13
+ refid?: number;
14
+ /** 사용자 멘션 후보 — 도메인 사용자 id (UUID). popup 표시 안 함. */
15
+ userId?: string;
16
+ /** 사용자 멘션 후보 — 이메일 (라벨 부가 정보 / 검색 대상) */
17
+ email?: string;
18
+ /** 선택 시 trigger+label 대신 삽입할 전체 텍스트 (slash template 용) */
19
+ replacementText?: string;
20
+ /** F-Finder 검색 필드 — things-scene 컴포넌트의 실제 키. 'name' 없음. */
21
+ id?: string;
22
+ tag?: string;
23
+ class?: string;
24
+ text?: string;
25
+ /** 카탈로그/슬래시 후보용 — description 도 검색 대상 */
26
+ description?: string;
27
+ }
28
+ /** filter 결과 — 매칭됐을 때만 matchType + matchValue 동봉.
29
+ * matchType union 은 things-scene SearchResult 와 동일 (id/name/tag/type/class/
30
+ * text/data/property) + 자체 'description' (catalog/slash). */
31
+ export interface FilterResult {
32
+ candidate: MentionCandidate;
33
+ matchType?: 'id' | 'name' | 'tag' | 'type' | 'class' | 'text' | 'data' | 'property' | 'description';
34
+ matchValue?: string;
35
+ }
36
+ /**
37
+ * 보드의 컴포넌트를 popup 후보로 변환. things-scene 컴포넌트 state 키만 사용:
38
+ * id / type / tag / class / text. 'name' 같은 별도 개념 도입 안 함.
39
+ *
40
+ * Label: id 있으면 id, 없으면 type. refid 는 popup 에 표시 안 함 (시스템 핸들).
41
+ */
42
+ export declare function buildMentionCandidates(components: any): MentionCandidate[];
43
+ /**
44
+ * 도메인 사용자 → MentionCandidate. server `domainUsersForMention` 결과를 popup 표시용
45
+ * 으로 변환.
46
+ * - label : 화면 표시 + textarea 삽입 토큰 (공백 / 비-단어 문자 불가 → 이메일 username 으로
47
+ * fallback 또는 underscore 치환)
48
+ * - userId : 영속용 UUID (inline marker `{userId:UUID}` 로 박힘)
49
+ *
50
+ * mention 토큰 정규식은 word char 만 허용하므로 공백 / 점 / 하이픈 같은 문자 들어간 name 은
51
+ * 그대로 쓰면 토큰이 첫 단어에서 끊김. 안전한 토큰 우선순위:
52
+ * 1. name (공백 / 비-단어 문자 없음)
53
+ * 2. email 의 username (공백 / 비-단어 문자 없음)
54
+ * 3. name 의 공백을 underscore 로 치환
55
+ * 4. id (UUID — 마지막 폴백)
56
+ */
57
+ export declare function buildUserCandidates(users: ReadonlyArray<{
58
+ id: string;
59
+ name?: string | null;
60
+ email?: string | null;
61
+ }>): MentionCandidate[];
62
+ export interface CatalogEntry {
63
+ /** 카탈로그 식별자 — '@token' 으로 사용 + addPrefab/modifyBoard 인자에 전달 */
64
+ name: string;
65
+ /** 종류 — UI 표시 + server-side disambiguation 과 일치 */
66
+ kind: 'prefab' | 'palette' | 'layout';
67
+ /** 한 줄 설명 (사용자 popup 에 보임) */
68
+ description?: string;
69
+ }
70
+ /**
71
+ * 카탈로그 항목 묶음 → MentionCandidate[]. 클라이언트가 chat panel mount 시 1회만
72
+ * 빌드 후 캐시. 이름 + kind 가 unique 여야 함 — 동일 name 다른 kind 는 카탈로그
73
+ * resolver 가 disambiguation 처리.
74
+ *
75
+ * 정렬: kind 우선 (prefab → palette → layout), 그 안에서 name asc.
76
+ */
77
+ export declare function buildCatalogCandidates(entries: CatalogEntry[]): MentionCandidate[];
78
+ export interface SlashTemplate {
79
+ /** 식별자 — '/grid' 의 'grid'. trigger 빠진 형태 */
80
+ name: string;
81
+ /** 사용자에게 보일 한 줄 설명 */
82
+ description?: string;
83
+ /** 선택 시 textarea 에 삽입될 전체 자연어 텍스트 (trigger+name 영역 교체) */
84
+ template: string;
85
+ }
86
+ /**
87
+ * Slash template 묶음 → MentionCandidate[] (replacementText 사용).
88
+ *
89
+ * 정렬: name asc.
90
+ *
91
+ * Slash 는 component/catalog 와 달리 LLM 우회 명령이 아니라 사용자에게 미리 작성된
92
+ * 자연어 prompt 를 빠르게 끼워넣는 용도. AI 는 변환 결과를 그대로 자연어로 처리.
93
+ */
94
+ export declare function buildSlashCandidates(templates: SlashTemplate[]): MentionCandidate[];
95
+ /**
96
+ * cursor 직전의 trigger char (`#` or `@`) + token 추출. popup 활성 여부 판단.
97
+ *
98
+ * 활성 조건:
99
+ * - cursor 이전에 trigger 가 존재
100
+ * - trigger 와 cursor 사이에 공백 / 줄바꿈 / 동일 trigger 없음
101
+ * - trigger 직전이 문자열 시작 또는 공백/줄바꿈/구두점 (단어 중간 trigger 무시)
102
+ *
103
+ * 반환 — 활성 시 { hashPos, query } (hashPos 는 trigger 위치 — # 또는 @) /
104
+ * 비활성 시 null.
105
+ *
106
+ * trigger default '#' — 기존 호출자 호환.
107
+ */
108
+ export declare function extractMentionContext(text: string, cursorPos: number, trigger?: string): {
109
+ hashPos: number;
110
+ query: string;
111
+ } | null;
112
+ /**
113
+ * query 로 후보 필터 — F-Finder 스타일 multi-field.
114
+ *
115
+ * 검색 문법:
116
+ * - 빈 query → 전체 반환 (cap 까지). matchType: 'label', matchValue: candidate.label
117
+ * - 자유 substring → 우선순위 (id > name > tag > type > class > text > label > description)
118
+ * 로 첫 매치 필드 발견. matchType + matchValue 동봉
119
+ * - field:value → 해당 field 만 매칭
120
+ * * `id:` / `i:` — id
121
+ * * `name:` / `n:` — name
122
+ * * `type:` / `t:` — type
123
+ * * `tag:` — tag (신규)
124
+ * * `class:` / `c:` — class (신규)
125
+ * * `text:` — text (신규)
126
+ *
127
+ * cap default 50 (F-Finder 표준). label 길이 짧은 순 정렬.
128
+ */
129
+ export declare function filterMentionCandidates(candidates: MentionCandidate[], query: string, cap?: number): FilterResult[];
130
+ export interface ResolveMentionTokenOptions {
131
+ /** 메시지 버블의 `<span data-refid>` 값. 작성 시점에 박제된 refid (가장 정확). */
132
+ persistedRefid?: string | null;
133
+ /** 호스트의 SceneSearchEngine 위임 — 작성 시점 refid 없거나 stale 일 때 fallback. */
134
+ searchProvider?: (query: string) => FilterResult[];
135
+ /** 보드 모델 — components.find(id) 직접 매칭 최후 fallback. */
136
+ board?: {
137
+ components?: any[];
138
+ } | null;
139
+ }
140
+ /**
141
+ * `#token` 클릭 시 token → refid resolve. 우선순위:
142
+ * 1. `persistedRefid` (data-refid 속성, 작성 시점 박제)
143
+ * 2. `searchProvider` — 'id' 정확 일치 → 첫 결과
144
+ * 3. `board.components.find(c => c.id === token)` — id 직접 매칭
145
+ *
146
+ * 모두 실패하면 `undefined`. UI 측에서 not-found 처리 (toast 등).
147
+ *
148
+ * Pure 함수 — DOM/Lit 의존 없음, 단위 테스트 가능.
149
+ */
150
+ export declare function resolveMentionToken(token: string, opts: ResolveMentionTokenOptions): number | undefined;
151
+ /**
152
+ * 선택된 후보로 textarea text 갱신 — trigger + query 영역을 교체.
153
+ *
154
+ * 두 모드:
155
+ * - 일반 (component / catalog): trigger + label + 공백 으로 교체
156
+ * - slash template: candidate.replacementText 가 있으면 그걸로 통째 교체
157
+ *
158
+ * 새 cursor 위치도 같이 반환.
159
+ *
160
+ * trigger default '#' — 기존 호출자 호환.
161
+ */
162
+ export declare function applyMentionSelection(text: string, cursorPos: number, hashPos: number, candidate: MentionCandidate, trigger?: string): {
163
+ text: string;
164
+ cursorPos: number;
165
+ };
@@ -0,0 +1,330 @@
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
+ /** 검색 시 시도할 field 순서.
8
+ * - 컴포넌트: id 우선 (F-Finder 와 동일 — id/tag/type/class/text)
9
+ * - user 후보: id 없으니 label/email 로 매칭
10
+ * - catalog/slash: label/description 로 매칭
11
+ *
12
+ * 'label' 이 fallback 으로 들어있어 모든 candidate 가 최소 1개 매칭 가능. */
13
+ const SEARCH_FIELDS = [
14
+ { key: 'id', type: 'id' },
15
+ { key: 'label', type: 'name' },
16
+ { key: 'email', type: 'data' },
17
+ { key: 'tag', type: 'tag' },
18
+ { key: 'type', type: 'type' },
19
+ { key: 'class', type: 'class' },
20
+ { key: 'text', type: 'text' },
21
+ { key: 'description', type: 'description' }
22
+ ];
23
+ /**
24
+ * 보드의 컴포넌트를 popup 후보로 변환. things-scene 컴포넌트 state 키만 사용:
25
+ * id / type / tag / class / text. 'name' 같은 별도 개념 도입 안 함.
26
+ *
27
+ * Label: id 있으면 id, 없으면 type. refid 는 popup 에 표시 안 함 (시스템 핸들).
28
+ */
29
+ export function buildMentionCandidates(components) {
30
+ const list = Array.isArray(components) ? components : [];
31
+ const out = [];
32
+ for (const c of list) {
33
+ if (!c || typeof c.refid !== 'number')
34
+ continue;
35
+ const refid = c.refid;
36
+ const id = strField(c, 'id');
37
+ const type = typeof c.type === 'string' ? c.type : 'unknown';
38
+ const tag = strField(c, 'tag');
39
+ const cls = strField(c, 'class');
40
+ const text = strField(c, 'text');
41
+ const label = id || type;
42
+ out.push({ refid, label, type, id, tag, class: cls, text });
43
+ }
44
+ return out;
45
+ }
46
+ function strField(c, key) {
47
+ const v = c[key];
48
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
49
+ }
50
+ // ── User (`@`) 후보 ─────────────────────────────────────────────────
51
+ /**
52
+ * 도메인 사용자 → MentionCandidate. server `domainUsersForMention` 결과를 popup 표시용
53
+ * 으로 변환.
54
+ * - label : 화면 표시 + textarea 삽입 토큰 (공백 / 비-단어 문자 불가 → 이메일 username 으로
55
+ * fallback 또는 underscore 치환)
56
+ * - userId : 영속용 UUID (inline marker `{userId:UUID}` 로 박힘)
57
+ *
58
+ * mention 토큰 정규식은 word char 만 허용하므로 공백 / 점 / 하이픈 같은 문자 들어간 name 은
59
+ * 그대로 쓰면 토큰이 첫 단어에서 끊김. 안전한 토큰 우선순위:
60
+ * 1. name (공백 / 비-단어 문자 없음)
61
+ * 2. email 의 username (공백 / 비-단어 문자 없음)
62
+ * 3. name 의 공백을 underscore 로 치환
63
+ * 4. id (UUID — 마지막 폴백)
64
+ */
65
+ export function buildUserCandidates(users) {
66
+ const out = [];
67
+ for (const u of users) {
68
+ if (!u || typeof u.id !== 'string')
69
+ continue;
70
+ const name = typeof u.name === 'string' && u.name.trim() ? u.name.trim() : undefined;
71
+ const email = typeof u.email === 'string' && u.email.trim() ? u.email.trim() : undefined;
72
+ out.push({
73
+ label: deriveUserToken(u.id, name, email),
74
+ type: 'user',
75
+ userId: u.id,
76
+ email
77
+ });
78
+ }
79
+ return out;
80
+ }
81
+ /** 토큰 안전 검사 — mention 정규식 (word char + 한/일/중) 으로만 구성됐는지. */
82
+ function isSafeMentionToken(s) {
83
+ return /^[\w\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}-]+$/u.test(s);
84
+ }
85
+ function deriveUserToken(id, name, email) {
86
+ if (name && isSafeMentionToken(name))
87
+ return name;
88
+ if (email) {
89
+ const local = email.split('@')[0];
90
+ if (local && isSafeMentionToken(local))
91
+ return local;
92
+ }
93
+ if (name) {
94
+ // 공백 / 비-단어 문자 underscore 치환 — 시각적 우선순위 유지
95
+ const sanitized = name.replace(/[^\w\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}-]+/gu, '_');
96
+ if (sanitized.length > 0)
97
+ return sanitized;
98
+ }
99
+ return id;
100
+ }
101
+ /**
102
+ * 카탈로그 항목 묶음 → MentionCandidate[]. 클라이언트가 chat panel mount 시 1회만
103
+ * 빌드 후 캐시. 이름 + kind 가 unique 여야 함 — 동일 name 다른 kind 는 카탈로그
104
+ * resolver 가 disambiguation 처리.
105
+ *
106
+ * 정렬: kind 우선 (prefab → palette → layout), 그 안에서 name asc.
107
+ */
108
+ export function buildCatalogCandidates(entries) {
109
+ const KIND_ORDER = { prefab: 0, palette: 1, layout: 2 };
110
+ const sorted = entries.slice().sort((a, b) => {
111
+ const ka = KIND_ORDER[a.kind] ?? 99;
112
+ const kb = KIND_ORDER[b.kind] ?? 99;
113
+ if (ka !== kb)
114
+ return ka - kb;
115
+ return a.name.localeCompare(b.name);
116
+ });
117
+ return sorted.map(e => ({
118
+ label: e.name,
119
+ type: e.kind,
120
+ description: e.description
121
+ }));
122
+ }
123
+ /**
124
+ * Slash template 묶음 → MentionCandidate[] (replacementText 사용).
125
+ *
126
+ * 정렬: name asc.
127
+ *
128
+ * Slash 는 component/catalog 와 달리 LLM 우회 명령이 아니라 사용자에게 미리 작성된
129
+ * 자연어 prompt 를 빠르게 끼워넣는 용도. AI 는 변환 결과를 그대로 자연어로 처리.
130
+ */
131
+ export function buildSlashCandidates(templates) {
132
+ const sorted = templates.slice().sort((a, b) => a.name.localeCompare(b.name));
133
+ return sorted.map(t => ({
134
+ label: t.name,
135
+ type: 'template',
136
+ description: t.description,
137
+ replacementText: t.template
138
+ }));
139
+ }
140
+ /**
141
+ * cursor 직전의 trigger char (`#` or `@`) + token 추출. popup 활성 여부 판단.
142
+ *
143
+ * 활성 조건:
144
+ * - cursor 이전에 trigger 가 존재
145
+ * - trigger 와 cursor 사이에 공백 / 줄바꿈 / 동일 trigger 없음
146
+ * - trigger 직전이 문자열 시작 또는 공백/줄바꿈/구두점 (단어 중간 trigger 무시)
147
+ *
148
+ * 반환 — 활성 시 { hashPos, query } (hashPos 는 trigger 위치 — # 또는 @) /
149
+ * 비활성 시 null.
150
+ *
151
+ * trigger default '#' — 기존 호출자 호환.
152
+ */
153
+ export function extractMentionContext(text, cursorPos, trigger = '#') {
154
+ if (!text || cursorPos < 0 || cursorPos > text.length)
155
+ return null;
156
+ if (!trigger || trigger.length !== 1)
157
+ return null;
158
+ // cursor 에서 뒤로 이동하며 trigger 찾기 — 공백/줄바꿈 만나면 중단
159
+ let i = cursorPos - 1;
160
+ while (i >= 0) {
161
+ const ch = text.charAt(i);
162
+ if (ch === trigger)
163
+ break;
164
+ if (ch === ' ' || ch === '\n' || ch === '\t')
165
+ return null;
166
+ i--;
167
+ }
168
+ if (i < 0)
169
+ return null;
170
+ // i 가 trigger 위치. 직전 글자 검사 (단어 중간 trigger 거부)
171
+ if (i > 0) {
172
+ const prev = text.charAt(i - 1);
173
+ // 알파벳/숫자/한글 같은 word char 직후는 mention 으로 안 봄 (예: 'foo#bar', 'user@host')
174
+ if (!/[\s\n\t.,!?;:()[\]{}'"`]/.test(prev))
175
+ return null;
176
+ }
177
+ return { hashPos: i, query: text.slice(i + 1, cursorPos) };
178
+ }
179
+ /**
180
+ * query 로 후보 필터 — F-Finder 스타일 multi-field.
181
+ *
182
+ * 검색 문법:
183
+ * - 빈 query → 전체 반환 (cap 까지). matchType: 'label', matchValue: candidate.label
184
+ * - 자유 substring → 우선순위 (id > name > tag > type > class > text > label > description)
185
+ * 로 첫 매치 필드 발견. matchType + matchValue 동봉
186
+ * - field:value → 해당 field 만 매칭
187
+ * * `id:` / `i:` — id
188
+ * * `name:` / `n:` — name
189
+ * * `type:` / `t:` — type
190
+ * * `tag:` — tag (신규)
191
+ * * `class:` / `c:` — class (신규)
192
+ * * `text:` — text (신규)
193
+ *
194
+ * cap default 50 (F-Finder 표준). label 길이 짧은 순 정렬.
195
+ */
196
+ export function filterMentionCandidates(candidates, query, cap = 50) {
197
+ // Empty query / 'type:' 같은 빈 needle 도 일반 매칭 경로와 동일 로직.
198
+ // 각 candidate 의 가장 prominent 한 field 를 default match 로 — 모든 행이
199
+ // 항상 동일 layout (badge + label + value 가능 시) 으로 일관 표시.
200
+ if (!query) {
201
+ return candidates.slice(0, cap).map(c => defaultMatchFor(c));
202
+ }
203
+ const parsed = parseQuery(query);
204
+ if (parsed.field && !parsed.needle) {
205
+ // 'type:' 만 입력 — 전체 반환, 일반 default
206
+ return candidates.slice(0, cap).map(c => defaultMatchFor(c));
207
+ }
208
+ const out = [];
209
+ if (parsed.field) {
210
+ const fieldKey = parsed.field;
211
+ for (const c of candidates) {
212
+ const v = c[fieldKey];
213
+ if (typeof v === 'string' && v.toLowerCase().includes(parsed.needle)) {
214
+ out.push({ candidate: c, matchType: parsed.field, matchValue: v });
215
+ }
216
+ }
217
+ }
218
+ else {
219
+ // 자유 검색 — 우선순위 필드 순회, 첫 매치 찾으면 break
220
+ const q = parsed.needle;
221
+ for (const c of candidates) {
222
+ for (const f of SEARCH_FIELDS) {
223
+ const v = c[f.key];
224
+ if (typeof v === 'string' && v.toLowerCase().includes(q)) {
225
+ out.push({ candidate: c, matchType: f.type, matchValue: v });
226
+ break;
227
+ }
228
+ }
229
+ }
230
+ }
231
+ out.sort((a, b) => a.candidate.label.length - b.candidate.label.length);
232
+ return out.slice(0, cap);
233
+ }
234
+ /**
235
+ * Candidate 의 default match — 우선순위 (id → name → tag → type → ...) 로 첫 non-empty
236
+ * field 를 matchType + matchValue 로 사용. Empty query 시 popup row 가 filter 시와
237
+ * 같은 layout 으로 보이도록 (badge + label + value).
238
+ */
239
+ function defaultMatchFor(c) {
240
+ for (const f of SEARCH_FIELDS) {
241
+ const v = c[f.key];
242
+ if (typeof v === 'string' && v.length > 0) {
243
+ return { candidate: c, matchType: f.type, matchValue: v };
244
+ }
245
+ }
246
+ // 모든 field 비어있는 희귀 케이스 — matchType 없이 그냥 candidate
247
+ return { candidate: c };
248
+ }
249
+ /**
250
+ * query 를 'field:value' 형태로 파싱.
251
+ * field 가 인식되지 않으면 자유 검색 (field=undefined).
252
+ */
253
+ function parseQuery(query) {
254
+ const m = /^([a-zA-Z]+):(.*)$/.exec(query);
255
+ if (m) {
256
+ const f = m[1].toLowerCase();
257
+ const map = {
258
+ i: 'id',
259
+ id: 'id',
260
+ t: 'type',
261
+ type: 'type',
262
+ tag: 'tag',
263
+ c: 'class',
264
+ class: 'class',
265
+ text: 'text'
266
+ };
267
+ if (map[f]) {
268
+ return { field: map[f], needle: m[2].toLowerCase() };
269
+ }
270
+ }
271
+ return { needle: query.toLowerCase() };
272
+ }
273
+ /**
274
+ * `#token` 클릭 시 token → refid resolve. 우선순위:
275
+ * 1. `persistedRefid` (data-refid 속성, 작성 시점 박제)
276
+ * 2. `searchProvider` — 'id' 정확 일치 → 첫 결과
277
+ * 3. `board.components.find(c => c.id === token)` — id 직접 매칭
278
+ *
279
+ * 모두 실패하면 `undefined`. UI 측에서 not-found 처리 (toast 등).
280
+ *
281
+ * Pure 함수 — DOM/Lit 의존 없음, 단위 테스트 가능.
282
+ */
283
+ export function resolveMentionToken(token, opts) {
284
+ if (!token)
285
+ return undefined;
286
+ // 1. 작성 시점 박제 refid
287
+ if (opts.persistedRefid) {
288
+ const n = Number(opts.persistedRefid);
289
+ if (Number.isFinite(n))
290
+ return n;
291
+ }
292
+ // 2. searchProvider — 'id' 정확 일치 우선
293
+ if (opts.searchProvider) {
294
+ const results = opts.searchProvider(token);
295
+ const exact = results.find(r => r.matchType === 'id' && r.matchValue === token);
296
+ const hit = (exact ?? results[0])?.candidate.refid;
297
+ if (typeof hit === 'number' && Number.isFinite(hit))
298
+ return hit;
299
+ }
300
+ // 3. board.components — id 직접 매칭
301
+ const components = opts.board?.components;
302
+ if (Array.isArray(components)) {
303
+ const hit = components.find((c) => c?.id === token);
304
+ if (hit && typeof hit.refid === 'number' && Number.isFinite(hit.refid)) {
305
+ return hit.refid;
306
+ }
307
+ }
308
+ return undefined;
309
+ }
310
+ /**
311
+ * 선택된 후보로 textarea text 갱신 — trigger + query 영역을 교체.
312
+ *
313
+ * 두 모드:
314
+ * - 일반 (component / catalog): trigger + label + 공백 으로 교체
315
+ * - slash template: candidate.replacementText 가 있으면 그걸로 통째 교체
316
+ *
317
+ * 새 cursor 위치도 같이 반환.
318
+ *
319
+ * trigger default '#' — 기존 호출자 호환.
320
+ */
321
+ export function applyMentionSelection(text, cursorPos, hashPos, candidate, trigger = '#') {
322
+ const before = text.slice(0, hashPos);
323
+ const after = text.slice(cursorPos);
324
+ const insert = candidate.replacementText ?? `${trigger}${candidate.label} `;
325
+ return {
326
+ text: before + insert + after,
327
+ cursorPos: before.length + insert.length
328
+ };
329
+ }
330
+ //# sourceMappingURL=mention-popup-helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mention-popup-helpers.js","sourceRoot":"","sources":["../../client/components/mention-popup-helpers.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Mention popup pure helpers — Lit / DOM 의존 없음. 단위 테스트 가능.\n *\n * Lit 엘리먼트 (ox-mention-popup) 와 분리 — jest 의 node 환경에서 'lit' import 가\n * ESM 변환 없이 실패하므로 chat-input-builder 패턴과 동일하게 helper 분리.\n */\n\nexport interface MentionCandidate {\n /** popup 에 보일 1차 식별 라벨 */\n label: string\n /** 카테고리 — 컴포넌트 type / 카탈로그 kind ('prefab'/'palette'/'layout') / 'template' / 'user' */\n type: string\n /** 컴포넌트 후보 — things-scene refid (popup 표시 안 함, AI 내부 reference) */\n refid?: number\n /** 사용자 멘션 후보 — 도메인 사용자 id (UUID). popup 표시 안 함. */\n userId?: string\n /** 사용자 멘션 후보 — 이메일 (라벨 부가 정보 / 검색 대상) */\n email?: string\n /** 선택 시 trigger+label 대신 삽입할 전체 텍스트 (slash template 용) */\n replacementText?: string\n /** F-Finder 검색 필드 — things-scene 컴포넌트의 실제 키. 'name' 없음. */\n id?: string\n tag?: string\n class?: string\n text?: string\n /** 카탈로그/슬래시 후보용 — description 도 검색 대상 */\n description?: string\n}\n\n/** filter 결과 — 매칭됐을 때만 matchType + matchValue 동봉.\n * matchType union 은 things-scene SearchResult 와 동일 (id/name/tag/type/class/\n * text/data/property) + 자체 'description' (catalog/slash). */\nexport interface FilterResult {\n candidate: MentionCandidate\n matchType?:\n | 'id'\n | 'name'\n | 'tag'\n | 'type'\n | 'class'\n | 'text'\n | 'data'\n | 'property'\n | 'description'\n matchValue?: string\n}\n\n/** 검색 시 시도할 field 순서.\n * - 컴포넌트: id 우선 (F-Finder 와 동일 — id/tag/type/class/text)\n * - user 후보: id 없으니 label/email 로 매칭\n * - catalog/slash: label/description 로 매칭\n *\n * 'label' 이 fallback 으로 들어있어 모든 candidate 가 최소 1개 매칭 가능. */\nconst SEARCH_FIELDS: Array<{\n key: keyof MentionCandidate\n type: NonNullable<FilterResult['matchType']>\n}> = [\n { key: 'id', type: 'id' },\n { key: 'label', type: 'name' },\n { key: 'email', type: 'data' },\n { key: 'tag', type: 'tag' },\n { key: 'type', type: 'type' },\n { key: 'class', type: 'class' },\n { key: 'text', type: 'text' },\n { key: 'description', type: 'description' }\n]\n\n/**\n * 보드의 컴포넌트를 popup 후보로 변환. things-scene 컴포넌트 state 키만 사용:\n * id / type / tag / class / text. 'name' 같은 별도 개념 도입 안 함.\n *\n * Label: id 있으면 id, 없으면 type. refid 는 popup 에 표시 안 함 (시스템 핸들).\n */\nexport function buildMentionCandidates(components: any): MentionCandidate[] {\n const list = Array.isArray(components) ? components : []\n const out: MentionCandidate[] = []\n for (const c of list) {\n if (!c || typeof (c as any).refid !== 'number') continue\n const refid = (c as any).refid\n const id = strField(c, 'id')\n const type = typeof (c as any).type === 'string' ? (c as any).type : 'unknown'\n const tag = strField(c, 'tag')\n const cls = strField(c, 'class')\n const text = strField(c, 'text')\n const label = id || type\n out.push({ refid, label, type, id, tag, class: cls, text })\n }\n return out\n}\n\nfunction strField(c: any, key: string): string | undefined {\n const v = c[key]\n return typeof v === 'string' && v.length > 0 ? v : undefined\n}\n\n// ── User (`@`) 후보 ─────────────────────────────────────────────────\n\n/**\n * 도메인 사용자 → MentionCandidate. server `domainUsersForMention` 결과를 popup 표시용\n * 으로 변환.\n * - label : 화면 표시 + textarea 삽입 토큰 (공백 / 비-단어 문자 불가 → 이메일 username 으로\n * fallback 또는 underscore 치환)\n * - userId : 영속용 UUID (inline marker `{userId:UUID}` 로 박힘)\n *\n * mention 토큰 정규식은 word char 만 허용하므로 공백 / 점 / 하이픈 같은 문자 들어간 name 은\n * 그대로 쓰면 토큰이 첫 단어에서 끊김. 안전한 토큰 우선순위:\n * 1. name (공백 / 비-단어 문자 없음)\n * 2. email 의 username (공백 / 비-단어 문자 없음)\n * 3. name 의 공백을 underscore 로 치환\n * 4. id (UUID — 마지막 폴백)\n */\nexport function buildUserCandidates(\n users: ReadonlyArray<{ id: string; name?: string | null; email?: string | null }>\n): MentionCandidate[] {\n const out: MentionCandidate[] = []\n for (const u of users) {\n if (!u || typeof u.id !== 'string') continue\n const name = typeof u.name === 'string' && u.name.trim() ? u.name.trim() : undefined\n const email = typeof u.email === 'string' && u.email.trim() ? u.email.trim() : undefined\n out.push({\n label: deriveUserToken(u.id, name, email),\n type: 'user',\n userId: u.id,\n email\n })\n }\n return out\n}\n\n/** 토큰 안전 검사 — mention 정규식 (word char + 한/일/중) 으로만 구성됐는지. */\nfunction isSafeMentionToken(s: string): boolean {\n return /^[\\w\\p{Script=Hangul}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Han}-]+$/u.test(s)\n}\n\nfunction deriveUserToken(id: string, name?: string, email?: string): string {\n if (name && isSafeMentionToken(name)) return name\n if (email) {\n const local = email.split('@')[0]\n if (local && isSafeMentionToken(local)) return local\n }\n if (name) {\n // 공백 / 비-단어 문자 underscore 치환 — 시각적 우선순위 유지\n const sanitized = name.replace(/[^\\w\\p{Script=Hangul}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Han}-]+/gu, '_')\n if (sanitized.length > 0) return sanitized\n }\n return id\n}\n\n// ── Catalog (`/` 와 함께) 후보 ──────────────────────────────────────\n\nexport interface CatalogEntry {\n /** 카탈로그 식별자 — '@token' 으로 사용 + addPrefab/modifyBoard 인자에 전달 */\n name: string\n /** 종류 — UI 표시 + server-side disambiguation 과 일치 */\n kind: 'prefab' | 'palette' | 'layout'\n /** 한 줄 설명 (사용자 popup 에 보임) */\n description?: string\n}\n\n/**\n * 카탈로그 항목 묶음 → MentionCandidate[]. 클라이언트가 chat panel mount 시 1회만\n * 빌드 후 캐시. 이름 + kind 가 unique 여야 함 — 동일 name 다른 kind 는 카탈로그\n * resolver 가 disambiguation 처리.\n *\n * 정렬: kind 우선 (prefab → palette → layout), 그 안에서 name asc.\n */\nexport function buildCatalogCandidates(entries: CatalogEntry[]): MentionCandidate[] {\n const KIND_ORDER: Record<string, number> = { prefab: 0, palette: 1, layout: 2 }\n const sorted = entries.slice().sort((a, b) => {\n const ka = KIND_ORDER[a.kind] ?? 99\n const kb = KIND_ORDER[b.kind] ?? 99\n if (ka !== kb) return ka - kb\n return a.name.localeCompare(b.name)\n })\n return sorted.map(e => ({\n label: e.name,\n type: e.kind,\n description: e.description\n }))\n}\n\n// ── Slash (`/`) 템플릿 ──────────────────────────────────────────────\n\nexport interface SlashTemplate {\n /** 식별자 — '/grid' 의 'grid'. trigger 빠진 형태 */\n name: string\n /** 사용자에게 보일 한 줄 설명 */\n description?: string\n /** 선택 시 textarea 에 삽입될 전체 자연어 텍스트 (trigger+name 영역 교체) */\n template: string\n}\n\n/**\n * Slash template 묶음 → MentionCandidate[] (replacementText 사용).\n *\n * 정렬: name asc.\n *\n * Slash 는 component/catalog 와 달리 LLM 우회 명령이 아니라 사용자에게 미리 작성된\n * 자연어 prompt 를 빠르게 끼워넣는 용도. AI 는 변환 결과를 그대로 자연어로 처리.\n */\nexport function buildSlashCandidates(templates: SlashTemplate[]): MentionCandidate[] {\n const sorted = templates.slice().sort((a, b) => a.name.localeCompare(b.name))\n return sorted.map(t => ({\n label: t.name,\n type: 'template',\n description: t.description,\n replacementText: t.template\n }))\n}\n\n/**\n * cursor 직전의 trigger char (`#` or `@`) + token 추출. popup 활성 여부 판단.\n *\n * 활성 조건:\n * - cursor 이전에 trigger 가 존재\n * - trigger 와 cursor 사이에 공백 / 줄바꿈 / 동일 trigger 없음\n * - trigger 직전이 문자열 시작 또는 공백/줄바꿈/구두점 (단어 중간 trigger 무시)\n *\n * 반환 — 활성 시 { hashPos, query } (hashPos 는 trigger 위치 — # 또는 @) /\n * 비활성 시 null.\n *\n * trigger default '#' — 기존 호출자 호환.\n */\nexport function extractMentionContext(\n text: string,\n cursorPos: number,\n trigger: string = '#'\n): { hashPos: number; query: string } | null {\n if (!text || cursorPos < 0 || cursorPos > text.length) return null\n if (!trigger || trigger.length !== 1) return null\n // cursor 에서 뒤로 이동하며 trigger 찾기 — 공백/줄바꿈 만나면 중단\n let i = cursorPos - 1\n while (i >= 0) {\n const ch = text.charAt(i)\n if (ch === trigger) break\n if (ch === ' ' || ch === '\\n' || ch === '\\t') return null\n i--\n }\n if (i < 0) return null\n // i 가 trigger 위치. 직전 글자 검사 (단어 중간 trigger 거부)\n if (i > 0) {\n const prev = text.charAt(i - 1)\n // 알파벳/숫자/한글 같은 word char 직후는 mention 으로 안 봄 (예: 'foo#bar', 'user@host')\n if (!/[\\s\\n\\t.,!?;:()[\\]{}'\"`]/.test(prev)) return null\n }\n return { hashPos: i, query: text.slice(i + 1, cursorPos) }\n}\n\n/**\n * query 로 후보 필터 — F-Finder 스타일 multi-field.\n *\n * 검색 문법:\n * - 빈 query → 전체 반환 (cap 까지). matchType: 'label', matchValue: candidate.label\n * - 자유 substring → 우선순위 (id > name > tag > type > class > text > label > description)\n * 로 첫 매치 필드 발견. matchType + matchValue 동봉\n * - field:value → 해당 field 만 매칭\n * * `id:` / `i:` — id\n * * `name:` / `n:` — name\n * * `type:` / `t:` — type\n * * `tag:` — tag (신규)\n * * `class:` / `c:` — class (신규)\n * * `text:` — text (신규)\n *\n * cap default 50 (F-Finder 표준). label 길이 짧은 순 정렬.\n */\nexport function filterMentionCandidates(\n candidates: MentionCandidate[],\n query: string,\n cap = 50\n): FilterResult[] {\n // Empty query / 'type:' 같은 빈 needle 도 일반 매칭 경로와 동일 로직.\n // 각 candidate 의 가장 prominent 한 field 를 default match 로 — 모든 행이\n // 항상 동일 layout (badge + label + value 가능 시) 으로 일관 표시.\n if (!query) {\n return candidates.slice(0, cap).map(c => defaultMatchFor(c))\n }\n const parsed = parseQuery(query)\n if (parsed.field && !parsed.needle) {\n // 'type:' 만 입력 — 전체 반환, 일반 default\n return candidates.slice(0, cap).map(c => defaultMatchFor(c))\n }\n\n const out: FilterResult[] = []\n if (parsed.field) {\n const fieldKey = parsed.field\n for (const c of candidates) {\n const v = c[fieldKey]\n if (typeof v === 'string' && v.toLowerCase().includes(parsed.needle)) {\n out.push({ candidate: c, matchType: parsed.field, matchValue: v })\n }\n }\n } else {\n // 자유 검색 — 우선순위 필드 순회, 첫 매치 찾으면 break\n const q = parsed.needle\n for (const c of candidates) {\n for (const f of SEARCH_FIELDS) {\n const v = c[f.key]\n if (typeof v === 'string' && v.toLowerCase().includes(q)) {\n out.push({ candidate: c, matchType: f.type, matchValue: v })\n break\n }\n }\n }\n }\n out.sort((a, b) => a.candidate.label.length - b.candidate.label.length)\n return out.slice(0, cap)\n}\n\n/**\n * Candidate 의 default match — 우선순위 (id → name → tag → type → ...) 로 첫 non-empty\n * field 를 matchType + matchValue 로 사용. Empty query 시 popup row 가 filter 시와\n * 같은 layout 으로 보이도록 (badge + label + value).\n */\nfunction defaultMatchFor(c: MentionCandidate): FilterResult {\n for (const f of SEARCH_FIELDS) {\n const v = c[f.key]\n if (typeof v === 'string' && v.length > 0) {\n return { candidate: c, matchType: f.type, matchValue: v }\n }\n }\n // 모든 field 비어있는 희귀 케이스 — matchType 없이 그냥 candidate\n return { candidate: c }\n}\n\n/**\n * query 를 'field:value' 형태로 파싱.\n * field 가 인식되지 않으면 자유 검색 (field=undefined).\n */\nfunction parseQuery(query: string): {\n field?: 'id' | 'type' | 'tag' | 'class' | 'text'\n needle: string\n} {\n const m = /^([a-zA-Z]+):(.*)$/.exec(query)\n if (m) {\n const f = m[1].toLowerCase()\n const map: Record<string, 'id' | 'type' | 'tag' | 'class' | 'text'> = {\n i: 'id',\n id: 'id',\n t: 'type',\n type: 'type',\n tag: 'tag',\n c: 'class',\n class: 'class',\n text: 'text'\n }\n if (map[f]) {\n return { field: map[f], needle: m[2].toLowerCase() }\n }\n }\n return { needle: query.toLowerCase() }\n}\n\n// ── Click resolve (메시지 버블의 #token 클릭 → refid) ──────────────\n\nexport interface ResolveMentionTokenOptions {\n /** 메시지 버블의 `<span data-refid>` 값. 작성 시점에 박제된 refid (가장 정확). */\n persistedRefid?: string | null\n /** 호스트의 SceneSearchEngine 위임 — 작성 시점 refid 없거나 stale 일 때 fallback. */\n searchProvider?: (query: string) => FilterResult[]\n /** 보드 모델 — components.find(id) 직접 매칭 최후 fallback. */\n board?: { components?: any[] } | null\n}\n\n/**\n * `#token` 클릭 시 token → refid resolve. 우선순위:\n * 1. `persistedRefid` (data-refid 속성, 작성 시점 박제)\n * 2. `searchProvider` — 'id' 정확 일치 → 첫 결과\n * 3. `board.components.find(c => c.id === token)` — id 직접 매칭\n *\n * 모두 실패하면 `undefined`. UI 측에서 not-found 처리 (toast 등).\n *\n * Pure 함수 — DOM/Lit 의존 없음, 단위 테스트 가능.\n */\nexport function resolveMentionToken(\n token: string,\n opts: ResolveMentionTokenOptions\n): number | undefined {\n if (!token) return undefined\n\n // 1. 작성 시점 박제 refid\n if (opts.persistedRefid) {\n const n = Number(opts.persistedRefid)\n if (Number.isFinite(n)) return n\n }\n\n // 2. searchProvider — 'id' 정확 일치 우선\n if (opts.searchProvider) {\n const results = opts.searchProvider(token)\n const exact = results.find(\n r => r.matchType === 'id' && r.matchValue === token\n )\n const hit = (exact ?? results[0])?.candidate.refid\n if (typeof hit === 'number' && Number.isFinite(hit)) return hit\n }\n\n // 3. board.components — id 직접 매칭\n const components = opts.board?.components\n if (Array.isArray(components)) {\n const hit = components.find((c: any) => c?.id === token)\n if (hit && typeof hit.refid === 'number' && Number.isFinite(hit.refid)) {\n return hit.refid\n }\n }\n\n return undefined\n}\n\n/**\n * 선택된 후보로 textarea text 갱신 — trigger + query 영역을 교체.\n *\n * 두 모드:\n * - 일반 (component / catalog): trigger + label + 공백 으로 교체\n * - slash template: candidate.replacementText 가 있으면 그걸로 통째 교체\n *\n * 새 cursor 위치도 같이 반환.\n *\n * trigger default '#' — 기존 호출자 호환.\n */\nexport function applyMentionSelection(\n text: string,\n cursorPos: number,\n hashPos: number,\n candidate: MentionCandidate,\n trigger: string = '#'\n): { text: string; cursorPos: number } {\n const before = text.slice(0, hashPos)\n const after = text.slice(cursorPos)\n const insert = candidate.replacementText ?? `${trigger}${candidate.label} `\n return {\n text: before + insert + after,\n cursorPos: before.length + insert.length\n }\n}\n"]}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * <ox-mention-popup> — Mention 후보 리스트 popup overlay.
3
+ *
4
+ * board-ai-chat 의 textarea 위에 absolute 로 띄움. plain `<textarea>` + popup 분리 패턴
5
+ * (한국어 IME 함정 회피 — contenteditable 사용 안 함).
6
+ *
7
+ * 키보드 네비게이션은 호스트 (board-ai-chat) 가 textarea keydown 으로 처리하고
8
+ * activeIndex 만 동기화. 마우스 클릭은 popup 자체에서 처리.
9
+ *
10
+ * Pure helper (extractMentionContext / filterMentionCandidates 등) 는
11
+ * `mention-popup-helpers.ts` 에 분리 — 단위 테스트 가능.
12
+ */
13
+ import { LitElement } from 'lit';
14
+ import type { FilterResult } from './mention-popup-helpers.js';
15
+ export type { MentionCandidate, FilterResult } from './mention-popup-helpers.js';
16
+ export { buildMentionCandidates, buildCatalogCandidates, buildSlashCandidates, buildUserCandidates, extractMentionContext, filterMentionCandidates, applyMentionSelection, resolveMentionToken, type CatalogEntry, type SlashTemplate, type ResolveMentionTokenOptions } from './mention-popup-helpers.js';
17
+ export declare class OxMentionPopup extends LitElement {
18
+ results: FilterResult[];
19
+ activeIndex: number;
20
+ /** filter cap 후 표시 안 된 추가 매칭 수 ("...and N more" 표기용) */
21
+ overflow: number;
22
+ static styles: import("lit").CSSResult;
23
+ render(): import("lit-html").TemplateResult<1>;
24
+ private _select;
25
+ /** 마우스 hover 로 active 이동 — host 가 받아 keyboard nav 와 동일하게 처리. */
26
+ private _setActive;
27
+ /** activeIndex 가 바뀌면 해당 항목을 view 로 스크롤 — popup 의 overflow-y:auto
28
+ * 영역에서 키보드 네비 시 active 가 가려지지 않도록. */
29
+ protected updated(changed: Map<string, unknown>): void;
30
+ }
31
+ declare global {
32
+ interface HTMLElementTagNameMap {
33
+ 'ox-mention-popup': OxMentionPopup;
34
+ }
35
+ }