@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,508 @@
1
+ /**
2
+ * Board AI 채팅 메시지의 Markdown 렌더링.
3
+ *
4
+ * 보안 (defense-in-depth):
5
+ * 1. raw HTML 차단 — marked 의 renderer.html → '' 로 block-level HTML 무력화.
6
+ * 2. 위험 link 스킴 차단 — javascript: / data: / vbscript: 등 거부.
7
+ * 3. DOMPurify 후처리 — marked 출력 전체를 sanitize. 우회되는 inline HTML
8
+ * (이미지 onerror, span style="javascript:..." 등) 까지 차단.
9
+ *
10
+ * 1·2 만으로도 표면이 좁지만, 다음의 *알려진 우회 경로* 가 존재하므로 3 이 필수:
11
+ * - marked 가 토큰화 실패한 일부 입력 (특히 코드블록 안 백틱 escape 미스) 에서
12
+ * 생 HTML 이 escape 안 된 채 흘러나갈 수 있음
13
+ * - mention span (`<span class="mention" ...>`) 이 우리 손으로 만드는 element 라
14
+ * allowlist 기반 정화 필요
15
+ * - 멀티유저 환경 (다른 사용자 메시지 echo) 시 표면 ↑
16
+ *
17
+ * CJK 보강:
18
+ * - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와
19
+ * ** 강조 마커 사이에 구두점이 끼면 마커가 닫히지 않는 케이스가 발생한다.
20
+ * 예) "**'rect'**의" → 닫는 ** 가 [구두점] + [CJK] 사이에 위치하므로 right-flanking 실패
21
+ * - 이런 케이스는 marked 에 넘기기 전에 자체적으로 추출 → 자리표시자로 치환 → marked
22
+ * 수행 → 후처리에서 <strong>...</strong> 로 복원하는 방식으로 처리한다.
23
+ * - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은
24
+ * escapeHtml 로 항상 escape).
25
+ */
26
+ import DOMPurify from 'dompurify'
27
+ import { marked } from 'marked'
28
+
29
+ /**
30
+ * DOMPurify 정화 정책.
31
+ *
32
+ * 우리가 markdown 출력에서 의도적으로 사용하는 element / attribute 만 명시 허용:
33
+ * - 표준 markdown elements (p, h1~6, ul/ol/li, strong/em, a, img, code, pre, blockquote, hr, br, table 계열)
34
+ * - mention span (class + data-refid + data-userid)
35
+ *
36
+ * 그 외는 모두 strip — 이벤트 핸들러 (onerror/onclick), inline style, javascript:
37
+ * URL 등 자동 차단. 우리 구조에 없는 element (script/iframe/object/embed 등) 도 모두 제거.
38
+ */
39
+ const PURIFY_CONFIG: any = {
40
+ ALLOWED_TAGS: [
41
+ 'p', 'br', 'hr',
42
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
43
+ 'strong', 'em', 'del', 's', 'u',
44
+ 'ul', 'ol', 'li',
45
+ 'a', 'img',
46
+ 'code', 'pre',
47
+ 'blockquote',
48
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
49
+ 'span'
50
+ ],
51
+ ALLOWED_ATTR: [
52
+ 'href', 'title', 'alt', 'src', 'class',
53
+ 'data-refid', 'data-userid'
54
+ ],
55
+ // javascript: / data: / vbscript: 등 위험 스킴 자동 차단
56
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
57
+ // target="_blank" 같이 우리가 명시하는 link 외엔 추가 attr 안 붙이도록
58
+ ADD_ATTR: ['target', 'rel'],
59
+ // raw HTML 입력은 marked.renderer.html → '' 로 이미 막혀있지만,
60
+ // dompurify 가 전체 input 을 한 번 더 정화 — 우회 차단
61
+ KEEP_CONTENT: true
62
+ }
63
+
64
+ const markdownRenderer = new marked.Renderer()
65
+ markdownRenderer.html = () => ''
66
+
67
+ const DANGEROUS_SCHEME_RE = /^(javascript|data|vbscript|file):/i
68
+
69
+ const origLink = markdownRenderer.link.bind(markdownRenderer)
70
+ markdownRenderer.link = (token: any) => {
71
+ const href = typeof token?.href === 'string' ? token.href.trim() : ''
72
+ if (DANGEROUS_SCHEME_RE.test(href)) {
73
+ return token?.text ?? ''
74
+ }
75
+ return origLink(token)
76
+ }
77
+
78
+ // 이미지 renderer — src scheme 검사 + alt 의 inline HTML escape
79
+ const origImage = markdownRenderer.image.bind(markdownRenderer)
80
+ markdownRenderer.image = (token: any) => {
81
+ const src = typeof token?.href === 'string' ? token.href.trim() : ''
82
+ if (DANGEROUS_SCHEME_RE.test(src)) {
83
+ // 위험 src — 이미지 자체 제거. alt 텍스트는 escape 해서 평문 노출
84
+ return escapeHtml(token?.text ?? '')
85
+ }
86
+ return origImage(token)
87
+ }
88
+
89
+ marked.setOptions({
90
+ gfm: true,
91
+ breaks: true,
92
+ pedantic: false,
93
+ renderer: markdownRenderer,
94
+ async: false
95
+ } as any)
96
+
97
+ export function escapeHtml(text: string): string {
98
+ return text
99
+ .replace(/&/g, '&amp;')
100
+ .replace(/</g, '&lt;')
101
+ .replace(/>/g, '&gt;')
102
+ .replace(/"/g, '&quot;')
103
+ .replace(/'/g, '&#039;')
104
+ }
105
+
106
+ // CJK 문자 범위: Hiragana, Katakana, CJK Unified Ideographs (Ext A 포함), Hangul, 전각/반각
107
+ const CJK_RE = /[぀-ヿ㐀-䶿一-鿿가-힯＀-￯]/
108
+ const PUNCT_RE = /\p{P}/u
109
+
110
+ // marked 가 절대 변형하지 않는 토큰 (영숫자만 사용)
111
+ const PLACEHOLDER_PREFIX = 'BAICJKSTRONG'
112
+ const PLACEHOLDER_SUFFIX = 'ENDMARK'
113
+ const MENTION_PLACEHOLDER_PREFIX = 'BAIMENTION'
114
+ const MENTION_PLACEHOLDER_SUFFIX = 'ENDMENTION'
115
+
116
+ // mention 토큰에 허용되는 문자 — alphanumeric + 한글/일본/중국어. 공백/구두점에서 끊김.
117
+ const MENTION_WORD_RE = /[\w\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u
118
+
119
+ interface ExtractResult {
120
+ processed: string
121
+ stored: string[]
122
+ }
123
+
124
+ interface MentionExtractResult {
125
+ processed: string
126
+ stored: StoredMention[]
127
+ }
128
+
129
+ /**
130
+ * `**X**` 패턴 중 CJK + 구두점 인접 때문에 marked 의 flanking 규칙에서 누락되는
131
+ * 케이스를 추출. 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않는다.
132
+ *
133
+ * 검출 기준 (CommonMark right-flanking 실패 ↔ left-flanking 실패):
134
+ * - 닫는 ** 직전 문자가 구두점 AND 직후 문자가 CJK letter
135
+ * - 여는 ** 직전 문자가 CJK letter AND 직후 문자가 구두점
136
+ */
137
+ export function extractCjkStrongs(text: string): ExtractResult {
138
+ const stored: string[] = []
139
+ const lines = text.split('\n')
140
+ const outLines: string[] = []
141
+ let inFence = false
142
+
143
+ for (const line of lines) {
144
+ if (/^\s{0,3}```/.test(line)) {
145
+ inFence = !inFence
146
+ outLines.push(line)
147
+ continue
148
+ }
149
+ if (inFence) {
150
+ outLines.push(line)
151
+ continue
152
+ }
153
+ outLines.push(processLine(line, stored, () => outLines[outLines.length - 1] ?? ''))
154
+ }
155
+
156
+ return { processed: outLines.join('\n'), stored }
157
+ }
158
+
159
+ function processLine(line: string, stored: string[], prevLine: () => string): string {
160
+ let i = 0
161
+ let result = ''
162
+ while (i < line.length) {
163
+ const ch = line[i]
164
+
165
+ if (ch === '`') {
166
+ const end = line.indexOf('`', i + 1)
167
+ if (end === -1) {
168
+ result += line.slice(i)
169
+ break
170
+ }
171
+ result += line.slice(i, end + 1)
172
+ i = end + 1
173
+ continue
174
+ }
175
+
176
+ if (ch === '*' && line[i + 1] === '*') {
177
+ const close = findCloseStrong(line, i + 2)
178
+ if (close === -1) {
179
+ result += '**'
180
+ i += 2
181
+ continue
182
+ }
183
+ const inner = line.slice(i + 2, close)
184
+ if (inner.length === 0) {
185
+ result += '**'
186
+ i += 2
187
+ continue
188
+ }
189
+ const before = result.length > 0 ? result.slice(-1) : prevLine().slice(-1)
190
+ const after = close + 2 < line.length ? line[close + 2] : ''
191
+ if (needsCjkFix(before, after, inner)) {
192
+ stored.push(inner)
193
+ result += `${PLACEHOLDER_PREFIX}${stored.length - 1}${PLACEHOLDER_SUFFIX}`
194
+ } else {
195
+ result += line.slice(i, close + 2)
196
+ }
197
+ i = close + 2
198
+ continue
199
+ }
200
+
201
+ result += ch
202
+ i++
203
+ }
204
+ return result
205
+ }
206
+
207
+ function findCloseStrong(line: string, start: number): number {
208
+ let i = start
209
+ while (i < line.length - 1) {
210
+ if (line[i] === '`') {
211
+ const end = line.indexOf('`', i + 1)
212
+ if (end === -1) return -1
213
+ i = end + 1
214
+ continue
215
+ }
216
+ if (line[i] === '*' && line[i + 1] === '*') return i
217
+ i++
218
+ }
219
+ return -1
220
+ }
221
+
222
+ function needsCjkFix(before: string, after: string, inner: string): boolean {
223
+ const innerStart = inner[0]
224
+ const innerEnd = inner[inner.length - 1]
225
+ return (
226
+ (before !== '' && CJK_RE.test(before) && PUNCT_RE.test(innerStart)) ||
227
+ (after !== '' && CJK_RE.test(after) && PUNCT_RE.test(innerEnd))
228
+ )
229
+ }
230
+
231
+ function restoreCjkStrongs(html: string, stored: string[]): string {
232
+ let out = html
233
+ for (let n = 0; n < stored.length; n++) {
234
+ const token = `${PLACEHOLDER_PREFIX}${n}${PLACEHOLDER_SUFFIX}`
235
+ const replacement = `<strong>${escapeHtml(stored[n])}</strong>`
236
+ out = out.split(token).join(replacement)
237
+ }
238
+ return out
239
+ }
240
+
241
+ /**
242
+ * `#token` mention 패턴을 추출 → placeholder 로 치환.
243
+ *
244
+ * 활성 조건 (chat textarea 의 extractMentionContext 와 일관):
245
+ * - `#` 직전이 문자열 시작 또는 word char 가 아님 (공백/구두점/줄 시작)
246
+ * - `#` 직후가 word char (영숫자/한글/일본/중국어)
247
+ * - 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않음
248
+ *
249
+ * Markdown ATX 헤딩 (`# Title`) 과 충돌하지 않음 — 헤딩은 `#` 다음에 공백이 오므로
250
+ * 본 정규식 (직후가 word char) 매칭 안 됨.
251
+ */
252
+ export function extractMentions(text: string): MentionExtractResult {
253
+ const stored: StoredMention[] = []
254
+ const lines = text.split('\n')
255
+ const outLines: string[] = []
256
+ let inFence = false
257
+
258
+ for (const line of lines) {
259
+ if (/^\s{0,3}```/.test(line)) {
260
+ inFence = !inFence
261
+ outLines.push(line)
262
+ continue
263
+ }
264
+ if (inFence) {
265
+ outLines.push(line)
266
+ continue
267
+ }
268
+ outLines.push(processMentionLine(line, stored))
269
+ }
270
+
271
+ return { processed: outLines.join('\n'), stored }
272
+ }
273
+
274
+ /** 추출된 mention — trigger 와 함께 보존해 restore 시 trigger 별 다른 span 생성. */
275
+ interface StoredMention {
276
+ trigger: '#' | '@'
277
+ token: string
278
+ }
279
+
280
+ function processMentionLine(line: string, stored: StoredMention[]): string {
281
+ let result = ''
282
+ let i = 0
283
+ while (i < line.length) {
284
+ const ch = line[i]
285
+ if (ch === '`') {
286
+ // 인라인 코드 — 닫는 backtick 까지 그대로 통과
287
+ const end = line.indexOf('`', i + 1)
288
+ if (end === -1) {
289
+ result += line.slice(i)
290
+ break
291
+ }
292
+ result += line.slice(i, end + 1)
293
+ i = end + 1
294
+ continue
295
+ }
296
+ if (ch === '#' || ch === '@') {
297
+ const prev = i > 0 ? line[i - 1] : ''
298
+ const next = line[i + 1] ?? ''
299
+ if (
300
+ (prev === '' || !MENTION_WORD_RE.test(prev)) &&
301
+ next !== '' &&
302
+ MENTION_WORD_RE.test(next)
303
+ ) {
304
+ // 토큰 끝 위치 찾기
305
+ let j = i + 1
306
+ while (j < line.length && MENTION_WORD_RE.test(line[j])) j++
307
+ const token = line.slice(i + 1, j)
308
+ stored.push({ trigger: ch as '#' | '@', token })
309
+ result += `${MENTION_PLACEHOLDER_PREFIX}${stored.length - 1}${MENTION_PLACEHOLDER_SUFFIX}`
310
+ i = j
311
+ continue
312
+ }
313
+ }
314
+ result += ch
315
+ i++
316
+ }
317
+ return result
318
+ }
319
+
320
+ /**
321
+ * placeholder 를 mention span 으로 복원. trigger 별로 다른 span:
322
+ * - `#component` → `<span class="mention" data-refid="N">#token</span>`
323
+ * - `@user` → `<span class="user-mention" data-userid="UUID">@token</span>`
324
+ * 매핑 없으면 data-* 속성 없이 단순 span.
325
+ */
326
+ function restoreMentions(
327
+ html: string,
328
+ stored: StoredMention[],
329
+ maps: InlineMentionMaps
330
+ ): string {
331
+ let out = html
332
+ for (let n = 0; n < stored.length; n++) {
333
+ const placeholder = `${MENTION_PLACEHOLDER_PREFIX}${n}${MENTION_PLACEHOLDER_SUFFIX}`
334
+ const { trigger, token } = stored[n]
335
+ let replacement: string
336
+ if (trigger === '#') {
337
+ const refid = maps.refidByToken.get(token)
338
+ const attr =
339
+ typeof refid === 'number' && Number.isFinite(refid)
340
+ ? ` data-refid="${refid}"`
341
+ : ''
342
+ replacement = `<span class="mention"${attr}>#${escapeHtml(token)}</span>`
343
+ } else {
344
+ const userId = maps.userIdByToken.get(token)
345
+ const attr = userId ? ` data-userid="${escapeHtml(userId)}"` : ''
346
+ replacement = `<span class="user-mention"${attr}>@${escapeHtml(token)}</span>`
347
+ }
348
+ out = out.split(placeholder).join(replacement)
349
+ }
350
+ return out
351
+ }
352
+
353
+ /**
354
+ * 인라인 마커 정규식 — `{key:value}` 형태. 현재 사용:
355
+ * - `{refid:N}` — `#component` 멘션
356
+ * - `{userId:UUID}` — `@user` 멘션
357
+ * 향후 추가 종류 (agentId / threadId / 외부 시스템 id 등) 도 동일 형식.
358
+ */
359
+ const MENTION_MARKER_RE = /\{[a-zA-Z][a-zA-Z0-9]*:[^}\s]+\}/g
360
+
361
+ const WORD_CLASS =
362
+ '\\w\\p{Script=Hangul}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Han}'
363
+
364
+ /**
365
+ * 본문에서 모든 `{key:value}` 마커 제거 — textarea 편집 / LLM 입력 / 평문 표출 등.
366
+ * 토큰 (#component / @user) 자체는 보존.
367
+ */
368
+ export function stripMentionRefids(text: string | null | undefined): string {
369
+ if (!text) return ''
370
+ return text.replace(MENTION_MARKER_RE, '')
371
+ }
372
+
373
+ interface InlineMentionMaps {
374
+ /** trigger='#' 의 token → refid (component) */
375
+ refidByToken: Map<string, number>
376
+ /** trigger='@' 의 token → userId (user) */
377
+ userIdByToken: Map<string, string>
378
+ }
379
+
380
+ /**
381
+ * `#token{refid:N}` / `@user{userId:UUID}` 마커를 추출해 매핑 + 마커 제거된 본문 반환.
382
+ * 렌더링 직전에 호출 — 마킹 정보 분리 후 markdown 처리에 깨끗한 텍스트만.
383
+ */
384
+ function extractInlineMentions(text: string): {
385
+ cleaned: string
386
+ maps: InlineMentionMaps
387
+ } {
388
+ const refidByToken = new Map<string, number>()
389
+ const userIdByToken = new Map<string, string>()
390
+
391
+ const componentRe = new RegExp(
392
+ `(^|[^${WORD_CLASS}])#([${WORD_CLASS}-]+)\\{refid:(\\d+)\\}`,
393
+ 'gu'
394
+ )
395
+ const userRe = new RegExp(
396
+ `(^|[^${WORD_CLASS}])@([${WORD_CLASS}-]+)\\{userId:([^}\\s]+)\\}`,
397
+ 'gu'
398
+ )
399
+
400
+ let cleaned = text.replace(componentRe, (_m, leading, token, refidStr) => {
401
+ const refid = Number(refidStr)
402
+ if (Number.isFinite(refid) && !refidByToken.has(token)) refidByToken.set(token, refid)
403
+ return `${leading}#${token}`
404
+ })
405
+ cleaned = cleaned.replace(userRe, (_m, leading, token, userId) => {
406
+ if (!userIdByToken.has(token)) userIdByToken.set(token, userId)
407
+ return `${leading}@${token}`
408
+ })
409
+ return { cleaned, maps: { refidByToken, userIdByToken } }
410
+ }
411
+
412
+ /**
413
+ * 일반화된 inline marker 주입.
414
+ * injectInlineMarkers(text, '#', 'refid', [{token, value: 35}])
415
+ * injectInlineMarkers(text, '@', 'userId', [{token, value: 'uuid'}])
416
+ */
417
+ function injectInlineMarkers(
418
+ text: string,
419
+ trigger: string,
420
+ markerKey: string,
421
+ picks: ReadonlyArray<{ token: string; value: string | number }> | null | undefined
422
+ ): string {
423
+ if (!text || !picks || picks.length === 0) return text ?? ''
424
+ const map = new Map<string, string | number>()
425
+ for (const p of picks) map.set(p.token, p.value)
426
+ const escapedTrig = trigger.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
427
+ const re = new RegExp(
428
+ `(^|[^${WORD_CLASS}])(${escapedTrig})([${WORD_CLASS}-]+)(\\{${markerKey}:[^}\\s]+\\})?`,
429
+ 'gu'
430
+ )
431
+ return text.replace(re, (match, leading, trig, token, existingMarker) => {
432
+ if (existingMarker) return match
433
+ const value = map.get(token)
434
+ if (value === undefined) return match
435
+ return `${leading}${trig}${token}{${markerKey}:${value}}`
436
+ })
437
+ }
438
+
439
+ /** `#component` 멘션 picks → `{refid:N}` 마커 주입 (호환 유지). */
440
+ export function injectMentionRefids(
441
+ text: string,
442
+ picks: ReadonlyArray<{ token: string; refid: number }> | null | undefined
443
+ ): string {
444
+ return injectInlineMarkers(
445
+ text,
446
+ '#',
447
+ 'refid',
448
+ picks?.map(p => ({ token: p.token, value: p.refid }))
449
+ )
450
+ }
451
+
452
+ /** `@user` 멘션 picks → `{userId:UUID}` 마커 주입. */
453
+ export function injectMentionUserIds(
454
+ text: string,
455
+ picks: ReadonlyArray<{ token: string; userId: string }> | null | undefined
456
+ ): string {
457
+ return injectInlineMarkers(
458
+ text,
459
+ '@',
460
+ 'userId',
461
+ picks?.map(p => ({ token: p.token, value: p.userId }))
462
+ )
463
+ }
464
+
465
+ export function renderMarkdown(text: string): string {
466
+ if (!text) return ''
467
+ try {
468
+ // 0) inline 마커 추출 (`{refid:N}` / `{userId:UUID}`) → 매핑 분리 + 본문 정화
469
+ const { cleaned, maps } = extractInlineMentions(text)
470
+ // 1) mention placeholder — `#abc` / `@abc` 가 marked 의 ATX 헤딩 / 이메일 등에
471
+ // 휩쓸리지 않도록 영숫자 placeholder 로 치환.
472
+ const m = extractMentions(cleaned)
473
+ // 2) CJK strong placeholder
474
+ const c = extractCjkStrongs(m.processed)
475
+ const out = marked.parse(c.processed)
476
+ if (typeof out !== 'string') {
477
+ // marked 가 Promise 를 반환했다면 (async lexer 등) 안전한 fallback
478
+ return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>')
479
+ }
480
+ // 3) 역순 복원 — CJK strong → mention span. maps 로 data-refid / data-userid embed.
481
+ const restored = restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps)
482
+ // 4) DOMPurify 후처리 — defense-in-depth. marked / 우리 mention span 둘 다 한 번 더 정화.
483
+ return sanitizeHtml(restored)
484
+ } catch {
485
+ return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>')
486
+ }
487
+ }
488
+
489
+ /**
490
+ * DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.
491
+ *
492
+ * 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.
493
+ * Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback
494
+ * (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).
495
+ */
496
+ export function sanitizeHtml(html: string): string {
497
+ try {
498
+ if (typeof window === 'undefined' || !DOMPurify.sanitize) {
499
+ return html
500
+ }
501
+ const result = DOMPurify.sanitize(html, PURIFY_CONFIG)
502
+ return typeof result === 'string' ? result : String(result)
503
+ } catch {
504
+ // DOM 환경 부재 또는 dompurify 초기화 실패 — 입력 그대로 통과
505
+ // (브라우저에선 정상 동작 — Node 테스트에선 sanitize 의 효과를 직접 검증)
506
+ return html
507
+ }
508
+ }