feat: 支持微信内置表情渲染

This commit is contained in:
电摇小子
2026-07-14 10:20:45 +08:00
committed by 电摇小子
parent 71e970c55c
commit ad50939005
8 changed files with 139 additions and 6 deletions
+1
View File
@@ -10,3 +10,4 @@ out
docs/design/
docs/ui-redesign-plan.md
docs/ui-redesign-spec.md
AGENTS.md
+2 -1
View File
@@ -39,7 +39,8 @@
"fzstd": "^0.1.1",
"koffi": "^3.1.0",
"openai": "^6.10.0",
"silk-wasm": "^3.7.1"
"silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
+7
View File
@@ -33,6 +33,7 @@ specifiers:
silk-wasm: ^3.7.1
typescript: ^5.9.3
vite: ^7.2.6
wechat-emojis: ^1.0.2
dependencies:
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
@@ -42,6 +43,7 @@ dependencies:
koffi: 3.1.0
openai: 6.10.0
silk-wasm: 3.7.1
wechat-emojis: 1.0.2
devDependencies:
'@electron-toolkit/eslint-config-prettier': 3.0.0_fiitszekoa4sqtbwyewxi6kyy4
@@ -4817,6 +4819,11 @@ packages:
defaults: 1.0.4
dev: true
/wechat-emojis/1.0.2:
resolution: {integrity: sha512-T1drHGy92rKm/Vo7LRkU4D4wdREpVTjAMEa4gR1NB9IAyck3qmmewFSrnEEIyZfsv3SXTA7X1kt6Smt0UKVCyw==}
engines: {node: '>=14.0.0'}
dev: false
/which-boxed-primitive/1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
+7
View File
@@ -364,6 +364,13 @@ body {
min-width: 0;
}
.inline-wechat-emoji {
display: inline-block;
margin: 0 1px;
vertical-align: -5px;
object-fit: contain;
}
.voice-bubble {
min-width: 138px;
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { Message, Contact } from '../../../shared/types'
import { VoicePlayer } from './VoicePlayer'
import { RichMessageBubble } from './RichMessageBubble'
import { ImageBubble } from './ImageBubble'
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
import {
buildGroupReportInput,
GROUP_REPORT_SYSTEM_PROMPT,
@@ -416,7 +417,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
) : isRichMedia && msg.contentData ? (
<RichMessageBubble contentData={msg.contentData} />
) : (
<div className="message-text">{msg.content}</div>
<div className="message-text">{renderWechatEmojiText(msg.content)}</div>
)}
</div>
<div className="message-meta">
@@ -1,6 +1,7 @@
import { ParsedContent } from '../../../shared/types'
import { useEffect, useState } from 'react'
import type { JSX, MouseEvent } from 'react'
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
const stickerDataUrlCache = new Map<string, string>()
@@ -26,7 +27,9 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
return <SystemBubble data={contentData} />
case 'unknown':
return (
<div className="message-text">{(contentData as { raw?: string }).raw || '[未知消息]'}</div>
<div className="message-text">
{renderWechatEmojiText((contentData as { raw?: string }).raw || '[未知消息]')}
</div>
)
default:
return <div className="message-text">[]</div>
@@ -207,15 +210,15 @@ function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }>
<div className="quote-message">
<div className="quoted-message">
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
<span className="quoted-text">{quotedText}</span>
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
</div>
{replyText && <div className="quote-reply">{replyText}</div>}
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
</div>
)
}
function SystemBubble({ data }: { data: Extract<ParsedContent, { type: 'system' }> }): JSX.Element {
return <div className="message-text">{data.content || '[系统消息]'}</div>
return <div className="message-text">{renderWechatEmojiText(data.content || '[系统消息]')}</div>
}
function getUrlHost(url?: string): string {
+3
View File
@@ -0,0 +1,3 @@
declare module 'wechat-emojis' {
export function getEmojiPath(name: string): string | undefined
}
+110
View File
@@ -0,0 +1,110 @@
import type { ReactNode } from 'react'
import { getEmojiPath } from 'wechat-emojis'
const emojiAssetModules = import.meta.glob(
'../../../../node_modules/wechat-emojis/assets/**/*.png',
{
eager: true,
query: '?url',
import: 'default'
}
) as Record<string, string>
const emojiUrlByPath = new Map<string, string>()
for (const [modulePath, url] of Object.entries(emojiAssetModules)) {
const normalized = modulePath.replace(/\\/g, '/')
const match = normalized.match(/wechat-emojis\/(.+)$/)
if (match) emojiUrlByPath.set(match[1], url)
}
const unicodeFallback: Record<string, string> = {
: '🙂',
: '😒',
: '😍',
: '😳',
: '😎',
: '😭',
: '😊',
: '🤐',
: '😴',
: '😭',
: '😅',
: '😡',
: '😜',
: '😁',
: '😮',
: '😞',
: '😫',
: '🤮',
: '🤭',
: '😄',
: '🙄',
: '😤',
: '😪',
: '😱',
: '😄',
: '😌',
: '😤',
: '❓',
: '🤫',
: '😵',
: '😞',
: '💀',
: '🔨',
: '👋',
: '😓',
: '🤧',
: '👏',
: '😏',
: '😒',
: '🥺',
: '😢',
: '😈',
: '😘',
: '🥺',
: '😄',
: '🤒',
: '😊',
: '😂',
: '😨',
: '😞',
: '😑',
: '😄',
: '😭',
: '😏',
: '😏',
: '😟',
: '✌️',
666: '👍',
Emm: '😐'
}
function resolveEmojiUrl(name: string): string {
const emojiPath = getEmojiPath(name)
return emojiPath ? emojiUrlByPath.get(emojiPath) || '' : ''
}
export function renderWechatEmojiText(text: string, size = 22): ReactNode {
if (!text || !text.includes('[')) return text
return text.split(/\[([^\[\]\r\n]{1,16})\]/g).map((part, index) => {
if (index % 2 === 0) return part
const url = resolveEmojiUrl(part)
if (url) {
return (
<img
key={`${part}-${index}`}
className="inline-wechat-emoji"
src={url}
alt={`[${part}]`}
title={`[${part}]`}
style={{ width: size, height: size }}
/>
)
}
return unicodeFallback[part] || `[${part}]`
})
}