@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.
- package/client/components/board-ai-chat.test.ts +149 -0
- package/client/components/board-ai-chat.ts +3228 -0
- package/client/components/chat-defaults.ts +27 -0
- package/client/components/chat-echo-dedup.test.ts +230 -0
- package/client/components/chat-echo-dedup.ts +155 -0
- package/client/components/chat-input-builder.ts +69 -0
- package/client/components/markdown.test.ts +518 -0
- package/client/components/markdown.ts +508 -0
- package/client/components/mention-popup-helpers.ts +433 -0
- package/client/components/mention-popup.test.ts +588 -0
- package/client/components/mention-popup.ts +238 -0
- package/client/index.ts +14 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/board-edit-patch.ts +374 -0
- package/dist-client/components/board-ai-chat.d.ts +423 -0
- package/dist-client/components/board-ai-chat.js +3034 -0
- package/dist-client/components/board-ai-chat.js.map +1 -0
- package/dist-client/components/board-ai-chat.test.d.ts +1 -0
- package/dist-client/components/board-ai-chat.test.js +138 -0
- package/dist-client/components/board-ai-chat.test.js.map +1 -0
- package/dist-client/components/chat-defaults.d.ts +8 -0
- package/dist-client/components/chat-defaults.js +22 -0
- package/dist-client/components/chat-defaults.js.map +1 -0
- package/dist-client/components/chat-echo-dedup.d.ts +54 -0
- package/dist-client/components/chat-echo-dedup.js +115 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -0
- package/dist-client/components/chat-echo-dedup.test.d.ts +1 -0
- package/dist-client/components/chat-echo-dedup.test.js +203 -0
- package/dist-client/components/chat-echo-dedup.test.js.map +1 -0
- package/dist-client/components/chat-input-builder.d.ts +55 -0
- package/dist-client/components/chat-input-builder.js +32 -0
- package/dist-client/components/chat-input-builder.js.map +1 -0
- package/dist-client/components/markdown.d.ts +60 -0
- package/dist-client/components/markdown.js +422 -0
- package/dist-client/components/markdown.js.map +1 -0
- package/dist-client/components/markdown.test.d.ts +1 -0
- package/dist-client/components/markdown.test.js +418 -0
- package/dist-client/components/markdown.test.js.map +1 -0
- package/dist-client/components/mention-popup-helpers.d.ts +165 -0
- package/dist-client/components/mention-popup-helpers.js +330 -0
- package/dist-client/components/mention-popup-helpers.js.map +1 -0
- package/dist-client/components/mention-popup.d.ts +35 -0
- package/dist-client/components/mention-popup.js +223 -0
- package/dist-client/components/mention-popup.js.map +1 -0
- package/dist-client/components/mention-popup.test.d.ts +1 -0
- package/dist-client/components/mention-popup.test.js +474 -0
- package/dist-client/components/mention-popup.test.js.map +1 -0
- package/dist-client/index.d.ts +14 -0
- package/dist-client/index.js +15 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/utils/board-edit-patch.d.ts +157 -0
- package/dist-client/utils/board-edit-patch.js +284 -0
- package/dist-client/utils/board-edit-patch.js.map +1 -0
- package/dist-server/index.d.ts +28 -0
- package/dist-server/index.js +32 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
- package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
- package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
- package/dist-server/service/chat-message/chat-message-publish.js +27 -0
- package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
- package/dist-server/service/chat-message/chat-message-subscription.js +57 -0
- package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
- package/dist-server/service/chat-message/chat-message.d.ts +68 -0
- package/dist-server/service/chat-message/chat-message.js +172 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -0
- package/dist-server/service/chat-message/fold-history.d.ts +43 -0
- package/dist-server/service/chat-message/fold-history.js +33 -0
- package/dist-server/service/chat-message/fold-history.js.map +1 -0
- package/dist-server/service/chat-message/history-summary.d.ts +43 -0
- package/dist-server/service/chat-message/history-summary.js +77 -0
- package/dist-server/service/chat-message/history-summary.js.map +1 -0
- package/dist-server/service/chat-message/index.d.ts +5 -0
- package/dist-server/service/chat-message/index.js +12 -0
- package/dist-server/service/chat-message/index.js.map +1 -0
- package/dist-server/service/chat-message/llm-history.d.ts +46 -0
- package/dist-server/service/chat-message/llm-history.js +87 -0
- package/dist-server/service/chat-message/llm-history.js.map +1 -0
- package/dist-server/service/chat-session/chat-session.d.ts +60 -0
- package/dist-server/service/chat-session/chat-session.js +159 -0
- package/dist-server/service/chat-session/chat-session.js.map +1 -0
- package/dist-server/service/chat-session/index.d.ts +3 -0
- package/dist-server/service/chat-session/index.js +7 -0
- package/dist-server/service/chat-session/index.js.map +1 -0
- package/dist-server/service/chat-session/session-activity-broadcast.d.ts +30 -0
- package/dist-server/service/chat-session/session-activity-broadcast.js +34 -0
- package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -0
- package/dist-server/service/chat-session/session-activity-publish.d.ts +12 -0
- package/dist-server/service/chat-session/session-activity-publish.js +37 -0
- package/dist-server/service/chat-session/session-activity-publish.js.map +1 -0
- package/dist-server/service/chat-session/session-activity-subscription.d.ts +14 -0
- package/dist-server/service/chat-session/session-activity-subscription.js +87 -0
- package/dist-server/service/chat-session/session-activity-subscription.js.map +1 -0
- package/dist-server/service/chat-session/session-anchor.d.ts +26 -0
- package/dist-server/service/chat-session/session-anchor.js +100 -0
- package/dist-server/service/chat-session/session-anchor.js.map +1 -0
- package/dist-server/service/chat-session/session-inbox.d.ts +80 -0
- package/dist-server/service/chat-session/session-inbox.js +133 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +49 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.js +141 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
- package/dist-server/service/chat-session-participant/index.d.ts +4 -0
- package/dist-server/service/chat-session-participant/index.js +10 -0
- package/dist-server/service/chat-session-participant/index.js.map +1 -0
- package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
- package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
- package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
- package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
- package/dist-server/service/chat-session-participant/presence.js +22 -0
- package/dist-server/service/chat-session-participant/presence.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +93 -0
- package/dist-server/service/chat-session-resolver.js +706 -0
- package/dist-server/service/chat-session-resolver.js.map +1 -0
- package/dist-server/service/index.d.ts +24 -0
- package/dist-server/service/index.js +39 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/subscribe-auth.d.ts +14 -0
- package/dist-server/service/subscribe-auth.js +33 -0
- package/dist-server/service/subscribe-auth.js.map +1 -0
- package/dist-server/service/types.d.ts +5 -0
- package/dist-server/service/types.js +12 -0
- package/dist-server/service/types.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -0
- package/server/index.ts +28 -0
- package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
- package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
- package/server/service/chat-message/chat-message-broadcast.ts +27 -0
- package/server/service/chat-message/chat-message-publish.ts +22 -0
- package/server/service/chat-message/chat-message-subscription.ts +45 -0
- package/server/service/chat-message/chat-message.ts +195 -0
- package/server/service/chat-message/fold-history.test.ts +98 -0
- package/server/service/chat-message/fold-history.ts +77 -0
- package/server/service/chat-message/history-summary.test.ts +127 -0
- package/server/service/chat-message/history-summary.ts +100 -0
- package/server/service/chat-message/index.ts +12 -0
- package/server/service/chat-message/llm-history.test.ts +194 -0
- package/server/service/chat-message/llm-history.ts +163 -0
- package/server/service/chat-session/chat-session.ts +193 -0
- package/server/service/chat-session/index.ts +5 -0
- package/server/service/chat-session/session-activity-broadcast.ts +57 -0
- package/server/service/chat-session/session-activity-publish.ts +44 -0
- package/server/service/chat-session/session-activity-subscription.ts +67 -0
- package/server/service/chat-session/session-anchor.ts +106 -0
- package/server/service/chat-session/session-inbox.test.ts +185 -0
- package/server/service/chat-session/session-inbox.ts +161 -0
- package/server/service/chat-session-participant/chat-session-participant.ts +135 -0
- package/server/service/chat-session-participant/index.ts +6 -0
- package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
- package/server/service/chat-session-participant/invite-notification.ts +48 -0
- package/server/service/chat-session-participant/presence.test.ts +39 -0
- package/server/service/chat-session-participant/presence.ts +21 -0
- package/server/service/chat-session-resolver.ts +644 -0
- package/server/service/index.ts +37 -0
- package/server/service/session-anchor.test.ts +133 -0
- package/server/service/subscribe-auth.test.ts +57 -0
- package/server/service/subscribe-auth.ts +34 -0
- package/server/service/types.ts +15 -0
- package/things-factory.config.js +1 -0
- 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, '&')
|
|
100
|
+
.replace(/</g, '<')
|
|
101
|
+
.replace(/>/g, '>')
|
|
102
|
+
.replace(/"/g, '"')
|
|
103
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|