fix(chat): keep balanced brackets in a pasted URL
The inline link pattern excluded `)` outright, so https://en.wikipedia.org/wiki/Rust_(programming_language) was truncated mid-path and the embed never matched. The regex now admits brackets and trimUrlTail drops a trailing `)`/`]`/`}` only when it is unbalanced, counting full-width forms too; ordinary sentence punctuation (both Latin and 、。,!?《》【】) is still trimmed. Mirrors the server-side trim_trailing_punctuation so the highlighted text and the resolved URL agree.
ZhenYi committed
Modified web/src/features/chat/ui/parts.tsx web/src/features/chat/ui/parts.tsx +40 −3 View web/src/features/chat/ui/parts.tsx
@@ -264,8 +264,41 @@
264264 return tokens
265265}
266266
267const INLINE = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)|(~~[^~\n]+~~)|(\bhttps?:\/\/[^\s<>'")\]]+)/g
267// 链接里允许成对括号(`.../Rust_(programming_language)`),所以右括号不直接排除,
268// 由 trimUrlTail 按配对情况决定是否截断。
269const INLINE = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)|(~~[^~\n]+~~)|(\bhttps?:\/\/[^\s<>'"]+)/g
268270
271/**
272 * 裁掉链接末尾不属于 URL 的标点。右括号只有在比左括号多的时候才算句尾
273 * (`(见 https://a/b)` 的 `)` 要去掉,`.../Rust_(x)` 的要留着)。
274 */
275function trimUrlTail(value: string): string {
276 let end = value.length
277 const counts = (text: string, open: string, close: string) => {
278 let depth = 0
279 for (const ch of text) {
280 if (ch === open) depth += 1
281 else if (ch === close) depth -= 1
282 }
283 return depth
284 }
285 while (end > 0) {
286 const ch = value[end - 1]
287 const head = value.slice(0, end)
288 const drop =
289 ch === ')' || ch === '\uff09'
290 ? counts(head, '(', ')') + counts(head, '\uff08', '\uff09') < 0
291 : ch === ']' || ch === '\uff3d'
292 ? counts(head, '[', ']') + counts(head, '\uff3b', '\uff3d') < 0
293 : ch === '}' || ch === '\uff5d'
294 ? counts(head, '{', '}') + counts(head, '\uff5b', '\uff5d') < 0
295 : '.,;!?>\u3002\u3001\uff0c\uff01\uff1f\u300b\u300d\u300f\u3011\u3017'.includes(ch)
296 if (!drop) break
297 end -= 1
298 }
299 return value.slice(0, end)
300}
301
269302function linkTarget(value: string): string {
270303 return value.startsWith('http') ? value : `https://${value}`
271304}
@@ -300,9 +333,13 @@
300333 } else if (value.startsWith('*')) {
301334 nodes.push(<em key={`i${(key += 1)}`}>{value.slice(1, -1)}</em>)
302335 } else if (/^https?:\/\//.test(value)) {
303 const embed = embeds.get(value) ?? embeds.get(embedKey(value))
336 // 句尾标点不属于链接:留着会让 embed 匹配不上,也会让点开的地址 404。
337 const url = trimUrlTail(value)
338 const tail = value.slice(url.length)
339 const embed = embeds.get(url) ?? embeds.get(embedKey(url))
304340 if (embed) nodes.push(<EmbedView key={`i${(key += 1)}`} embed={embed} />)
305 else nodes.push(<InlineLink key={`i${(key += 1)}`} url={value} />)
341 else nodes.push(<InlineLink key={`i${(key += 1)}`} url={url} />)
342 if (tail) nodes.push(tail)
306343 } else {
307344 nodes.push(value)
308345 }