mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
feat: 完善日报图片与公众号消息展示
修复日报图片读取格式与模型不支持时的友好降级 恢复话题关键词回退,确保词云在缺少顶层关键词时仍可展示 支持公众号多文章消息在聊天、日报、检索和 HTML 导出中完整呈现 迁移账号身份缓存并优化首次连接的账号占位信息 补充图片、词云、账号缓存与多文章消息回归测试
This commit is contained in:
@@ -579,6 +579,19 @@ body {
|
|||||||
}
|
}
|
||||||
.structured-link { color: inherit; text-decoration: none; }
|
.structured-link { color: inherit; text-decoration: none; }
|
||||||
.structured-link:hover .structured-title { color: var(--accent); }
|
.structured-link:hover .structured-title { color: var(--accent); }
|
||||||
|
.structured-share-articles { display: flex; flex-direction: column; }
|
||||||
|
.structured-share-article {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.structured-share-article:first-child { border-top: 0; }
|
||||||
|
.structured-share-article img { width: 54px; height: 54px; border-radius: 4px; object-fit: cover; }
|
||||||
.location-content {
|
.location-content {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
@@ -990,6 +1003,22 @@ const renderExportScript = (name: string): string => `
|
|||||||
}
|
}
|
||||||
const renderShareContent = (data) => {
|
const renderShareContent = (data) => {
|
||||||
const label = shareLabel(data.typeVal)
|
const label = shareLabel(data.typeVal)
|
||||||
|
const articles = Array.isArray(data.articles) ? data.articles : []
|
||||||
|
if (articles.length) {
|
||||||
|
const articleMarkup = articles.map((article) => {
|
||||||
|
const href = externalUrl(article.url)
|
||||||
|
const cover = imageUrl(article.coverUrl)
|
||||||
|
const body = '<span><div class="structured-title">' + displayText(article.title || '公众号文章') + '</div>' +
|
||||||
|
(article.description ? '<div class="structured-description">' + displayText(article.description) + '</div>' : '') +
|
||||||
|
'</span>' + (cover ? '<img src="' + cover + '" alt="">' : '')
|
||||||
|
return href
|
||||||
|
? '<a class="structured-share-article" href="' + href + '" target="_blank" rel="noreferrer noopener">' + body + '</a>'
|
||||||
|
: '<div class="structured-share-article">' + body + '</div>'
|
||||||
|
}).join('')
|
||||||
|
return '<div class="structured-content share-content" data-rich-kind="share">' +
|
||||||
|
'<div class="structured-kicker">' + displayText(data.appname || label) + '</div>' +
|
||||||
|
'<div class="structured-share-articles">' + articleMarkup + '</div></div>'
|
||||||
|
}
|
||||||
let title = decodeEntities(data.title || label)
|
let title = decodeEntities(data.title || label)
|
||||||
let description = decodeEntities(data.des || '')
|
let description = decodeEntities(data.des || '')
|
||||||
if (String(data.typeVal) === '51' && /当前微信版本不支持展示该内容/.test(title) && description) {
|
if (String(data.typeVal) === '51' && /当前微信版本不支持展示该内容/.test(title) && description) {
|
||||||
|
|||||||
+14
-8
@@ -321,7 +321,7 @@ function getLocalMediaMimeType(filePath: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImageResponse(image: DecodedImage): {
|
function buildImageResponse(image: DecodedImage, includeData = false): {
|
||||||
success: true
|
success: true
|
||||||
data: string
|
data: string
|
||||||
isThumb: boolean
|
isThumb: boolean
|
||||||
@@ -330,7 +330,7 @@ function buildImageResponse(image: DecodedImage): {
|
|||||||
} {
|
} {
|
||||||
const mediaService = image.cacheFilePath ? getImageMediaService() : null
|
const mediaService = image.cacheFilePath ? getImageMediaService() : null
|
||||||
const data =
|
const data =
|
||||||
mediaService && image.cacheFilePath && existsSync(image.cacheFilePath)
|
!includeData && mediaService && image.cacheFilePath && existsSync(image.cacheFilePath)
|
||||||
? mediaService.createLocalMediaUrl(image.cacheFilePath)
|
? mediaService.createLocalMediaUrl(image.cacheFilePath)
|
||||||
: image.data
|
: image.data
|
||||||
return {
|
return {
|
||||||
@@ -1126,7 +1126,12 @@ app.whenReady().then(async () => {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
_sessionId?: string,
|
_sessionId?: string,
|
||||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
options?: {
|
||||||
|
force?: boolean
|
||||||
|
preferThumbnail?: boolean
|
||||||
|
priority?: number
|
||||||
|
includeData?: boolean
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
let service = imageDecryptService
|
let service = imageDecryptService
|
||||||
if (!service) {
|
if (!service) {
|
||||||
@@ -1146,6 +1151,7 @@ app.whenReady().then(async () => {
|
|||||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||||
const force = options?.force === true
|
const force = options?.force === true
|
||||||
const preferThumbnail = options?.preferThumbnail === true
|
const preferThumbnail = options?.preferThumbnail === true
|
||||||
|
const includeData = options?.includeData === true
|
||||||
const priority = Number.isFinite(options?.priority) ? Number(options?.priority) : 0
|
const priority = Number.isFinite(options?.priority) ? Number(options?.priority) : 0
|
||||||
const imageCacheKey = [
|
const imageCacheKey = [
|
||||||
imageMd5 || '',
|
imageMd5 || '',
|
||||||
@@ -1154,10 +1160,10 @@ app.whenReady().then(async () => {
|
|||||||
].join('|')
|
].join('|')
|
||||||
const mediaService = getImageMediaService()
|
const mediaService = getImageMediaService()
|
||||||
const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
|
const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
|
||||||
includeData: !mediaService
|
includeData: includeData || !mediaService
|
||||||
})
|
})
|
||||||
if (cachedImage && (!force || !cachedImage.isThumbnail)) {
|
if (cachedImage && (!force || !cachedImage.isThumbnail)) {
|
||||||
return buildImageResponse(cachedImage)
|
return buildImageResponse(cachedImage, includeData)
|
||||||
}
|
}
|
||||||
|
|
||||||
return enqueueColdImageLoad(async () => {
|
return enqueueColdImageLoad(async () => {
|
||||||
@@ -1183,10 +1189,10 @@ app.whenReady().then(async () => {
|
|||||||
// A previous queued request may have populated the cache while this one waited.
|
// A previous queued request may have populated the cache while this one waited.
|
||||||
const queuedMediaService = getImageMediaService()
|
const queuedMediaService = getImageMediaService()
|
||||||
const queuedCacheHit = await coldService.getCachedDecodedImage(imageCacheKey, {
|
const queuedCacheHit = await coldService.getCachedDecodedImage(imageCacheKey, {
|
||||||
includeData: !queuedMediaService
|
includeData: includeData || !queuedMediaService
|
||||||
})
|
})
|
||||||
if (queuedCacheHit && (!force || !queuedCacheHit.isThumbnail)) {
|
if (queuedCacheHit && (!force || !queuedCacheHit.isThumbnail)) {
|
||||||
return buildImageResponse(queuedCacheHit)
|
return buildImageResponse(queuedCacheHit, includeData)
|
||||||
}
|
}
|
||||||
|
|
||||||
let filePath = force
|
let filePath = force
|
||||||
@@ -1220,7 +1226,7 @@ app.whenReady().then(async () => {
|
|||||||
isThumbnail: coldService.isThumbnailFile(decrypted.filePath)
|
isThumbnail: coldService.isThumbnailFile(decrypted.filePath)
|
||||||
}
|
}
|
||||||
await coldService.cacheDecodedImage(imageCacheKey, decodedImage)
|
await coldService.cacheDecodedImage(imageCacheKey, decodedImage)
|
||||||
return buildImageResponse(decodedImage)
|
return buildImageResponse(decodedImage, includeData)
|
||||||
}, priority)
|
}, priority)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -89,8 +89,11 @@ function sourceTextAndAttachment(message: chat.FormattedMessage): {
|
|||||||
if (content.type === 'share') {
|
if (content.type === 'share') {
|
||||||
const title = content.title?.trim() || ''
|
const title = content.title?.trim() || ''
|
||||||
const description = content.des?.trim() || ''
|
const description = content.des?.trim() || ''
|
||||||
|
const articles = (content.articles || []).flatMap((article) =>
|
||||||
|
[article.title, article.description].map((value) => value?.trim()).filter(Boolean)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
text: [text, title, description].filter(Boolean).join('\n') || undefined,
|
text: [text, title, description, ...articles].filter(Boolean).join('\n') || undefined,
|
||||||
attachment:
|
attachment:
|
||||||
title || content.url
|
title || content.url
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ type LocationContent = {
|
|||||||
lng: number
|
lng: number
|
||||||
}
|
}
|
||||||
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||||
|
type ShareArticle = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
url: string
|
||||||
|
coverUrl?: string
|
||||||
|
}
|
||||||
type ShareContent = {
|
type ShareContent = {
|
||||||
type: 'share'
|
type: 'share'
|
||||||
title: string
|
title: string
|
||||||
@@ -15,6 +21,7 @@ type ShareContent = {
|
|||||||
url: string
|
url: string
|
||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
|
articles?: ShareArticle[]
|
||||||
}
|
}
|
||||||
type ForwardedMessageItem = {
|
type ForwardedMessageItem = {
|
||||||
messageType: number
|
messageType: number
|
||||||
@@ -482,17 +489,71 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = decodeXmlEntities(extractXmlValue(content, 'title')) || ''
|
const articles = parseShareArticles(content)
|
||||||
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
|
const title =
|
||||||
const url = extractXmlValue(content, 'url') || ''
|
articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
|
||||||
const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || ''
|
const des =
|
||||||
|
articles[0]?.description ||
|
||||||
|
decodeXmlEntities(extractXmlValue(content, 'des') || extractXmlValue(content, 'desc')) ||
|
||||||
|
''
|
||||||
|
const url = articles[0]?.url || decodeXmlUrl(extractXmlValue(content, 'url')) || ''
|
||||||
|
const appname =
|
||||||
|
decodeXmlEntities(
|
||||||
|
extractXmlValue(content, 'appname') ||
|
||||||
|
extractXmlValue(content, 'publisher') ||
|
||||||
|
extractXmlValue(content, 'appInfo')
|
||||||
|
) || ''
|
||||||
const typeVal = extractXmlValue(content, 'type') || ''
|
const typeVal = extractXmlValue(content, 'type') || ''
|
||||||
|
|
||||||
if (!title && !url) {
|
if (!title && !url) {
|
||||||
return { type: 'unknown', raw: content }
|
return { type: 'unknown', raw: content }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: 'share', title, des, url, appname, typeVal }
|
return {
|
||||||
|
type: 'share',
|
||||||
|
title,
|
||||||
|
des,
|
||||||
|
url,
|
||||||
|
appname,
|
||||||
|
typeVal,
|
||||||
|
articles: articles.length > 1 ? articles : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseShareArticles(content: string): ShareArticle[] {
|
||||||
|
if (!/<mmreader\b/i.test(content)) return []
|
||||||
|
const articles = Array.from(
|
||||||
|
content.matchAll(/<item(?:\s[^>]*)?>([\s\S]*?)<\/item>/gi),
|
||||||
|
(match) => match[1] || ''
|
||||||
|
)
|
||||||
|
.map((item): ShareArticle | null => {
|
||||||
|
const title = decodeXmlEntities(extractXmlValue(item, 'title'))
|
||||||
|
const url = decodeXmlUrl(extractXmlValue(item, 'url'))
|
||||||
|
if (!title && !url) return null
|
||||||
|
const description = decodeXmlEntities(
|
||||||
|
extractXmlValue(item, 'digest') ||
|
||||||
|
extractXmlValue(item, 'summary') ||
|
||||||
|
extractXmlValue(item, 'des')
|
||||||
|
)
|
||||||
|
const coverUrl = decodeXmlUrl(
|
||||||
|
extractXmlValue(item, 'cover') || extractXmlValue(item, 'cover_1_1')
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
title: title || '公众号文章',
|
||||||
|
url,
|
||||||
|
description: description || undefined,
|
||||||
|
coverUrl: coverUrl || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((article): article is ShareArticle => Boolean(article))
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return articles.filter((article) => {
|
||||||
|
const key = `${article.url}|${article.title}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseForwardBundle(content: string): ForwardBundleContent {
|
function parseForwardBundle(content: string): ForwardBundleContent {
|
||||||
|
|||||||
@@ -155,14 +155,46 @@ function isCurrentAccountFile(
|
|||||||
function readStartupCacheFile(accountRoot: string): StartupCacheFile | null {
|
function readStartupCacheFile(accountRoot: string): StartupCacheFile | null {
|
||||||
const normalizedRoot = normalizeRoot(accountRoot)
|
const normalizedRoot = normalizeRoot(accountRoot)
|
||||||
if (!normalizedRoot) return null
|
if (!normalizedRoot) return null
|
||||||
const file = getAccountCachePaths(normalizedRoot).startup
|
const paths = getAccountCachePaths(normalizedRoot)
|
||||||
|
const file = paths.startup
|
||||||
const scheduled = readScheduledValue<StartupCacheFile>(file)
|
const scheduled = readScheduledValue<StartupCacheFile>(file)
|
||||||
if (scheduled) return scheduled
|
if (scheduled) return scheduled
|
||||||
const memory = startupMemory.get(file)
|
const memory = startupMemory.get(file)
|
||||||
if (memory) return memory
|
if (memory) return memory
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(file)) return null
|
if (!fs.existsSync(file)) {
|
||||||
|
// Version 1 stored startup data in one JSON file. Migrate it lazily so
|
||||||
|
// account discovery can still show a cached nickname/avatar before the
|
||||||
|
// database key is entered.
|
||||||
|
if (!fs.existsSync(paths.legacy)) return null
|
||||||
|
const legacy = fs.readJsonSync(paths.legacy) as {
|
||||||
|
version?: number
|
||||||
|
platform?: NodeJS.Platform
|
||||||
|
accountRoot?: string
|
||||||
|
updatedAt?: number
|
||||||
|
self?: CachedSelfInfo
|
||||||
|
contacts?: Contact[]
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
legacy.version !== 1 ||
|
||||||
|
legacy.platform !== process.platform ||
|
||||||
|
normalizeRoot(legacy.accountRoot) !== normalizedRoot
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const migrated: StartupCacheFile = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
platform: process.platform,
|
||||||
|
accountRoot: normalizedRoot,
|
||||||
|
updatedAt: Number(legacy.updatedAt) || 0,
|
||||||
|
self: legacy.self,
|
||||||
|
contacts: Array.isArray(legacy.contacts) ? legacy.contacts : []
|
||||||
|
}
|
||||||
|
startupMemory.set(file, migrated)
|
||||||
|
scheduleWrite(file, migrated, { cleanupFile: paths.legacy })
|
||||||
|
return migrated
|
||||||
|
}
|
||||||
const raw = fs.readJsonSync(file) as Partial<StartupCacheFile>
|
const raw = fs.readJsonSync(file) as Partial<StartupCacheFile>
|
||||||
if (!isCurrentAccountFile(raw, normalizedRoot)) return null
|
if (!isCurrentAccountFile(raw, normalizedRoot)) return null
|
||||||
const result: StartupCacheFile = {
|
const result: StartupCacheFile = {
|
||||||
|
|||||||
Vendored
+6
-1
@@ -261,7 +261,12 @@ declare global {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
options?: {
|
||||||
|
force?: boolean
|
||||||
|
preferThumbnail?: boolean
|
||||||
|
priority?: number
|
||||||
|
includeData?: boolean
|
||||||
|
}
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: string
|
data?: string
|
||||||
|
|||||||
@@ -167,7 +167,12 @@ const api = {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
options?: {
|
||||||
|
force?: boolean
|
||||||
|
preferThumbnail?: boolean
|
||||||
|
priority?: number
|
||||||
|
includeData?: boolean
|
||||||
|
}
|
||||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||||
getVideo: (
|
getVideo: (
|
||||||
hashes: string[],
|
hashes: string[],
|
||||||
|
|||||||
@@ -333,12 +333,14 @@ export function DatabaseConnectionPage({
|
|||||||
{account.avatar ? (
|
{account.avatar ? (
|
||||||
<img src={account.avatar} alt="" />
|
<img src={account.avatar} alt="" />
|
||||||
) : (
|
) : (
|
||||||
(account.nickname || '?').charAt(0)
|
(account.nickname || account.directoryName || '?').charAt(0)
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="database-account-identity">
|
<span className="database-account-identity">
|
||||||
<strong>{account.nickname || '昵称未识别'}</strong>
|
<strong>
|
||||||
<small>{account.wxid || 'wxid 未识别'}</small>
|
{account.nickname || `账号目录 ${account.directoryName || '待识别'}`}
|
||||||
|
</strong>
|
||||||
|
<small>{account.wxid || '连接后读取微信号'}</small>
|
||||||
<code title={account.accountRoot}>{account.accountRoot}</code>
|
<code title={account.accountRoot}>{account.accountRoot}</code>
|
||||||
</span>
|
</span>
|
||||||
<span className="database-account-status">
|
<span className="database-account-status">
|
||||||
|
|||||||
@@ -158,7 +158,31 @@ function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
|
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
|
||||||
const { title, des, url, appname } = data
|
const { title, des, url, appname, articles } = data
|
||||||
|
|
||||||
|
if (articles?.length) {
|
||||||
|
return (
|
||||||
|
<div className="share-message share-message-multi">
|
||||||
|
{appname && <div className="share-appname">{appname}</div>}
|
||||||
|
<div className="share-article-list">
|
||||||
|
{articles.map((article, index) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="share-article"
|
||||||
|
key={`${article.url}-${index}`}
|
||||||
|
onClick={() => article.url && window.open(article.url, '_blank')}
|
||||||
|
>
|
||||||
|
<span className="share-article-copy">
|
||||||
|
<strong>{article.title || '公众号文章'}</strong>
|
||||||
|
{article.description ? <small>{article.description}</small> : null}
|
||||||
|
</span>
|
||||||
|
{article.coverUrl ? <img src={article.coverUrl} alt="" referrerPolicy="no-referrer" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handleClick = (): void => {
|
const handleClick = (): void => {
|
||||||
if (url) {
|
if (url) {
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ const TEMPLATES: TemplateMeta[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'v2',
|
id: 'v2',
|
||||||
label: '模板2 · 支持图片板块',
|
label: '模板2 · 丰富日报',
|
||||||
tagline: '包含热点图片与上下文;需要模型服务商支持图片识别',
|
tagline: '包含更多群聊分析板块;勾选图片后会尝试生成图片精选',
|
||||||
preview: {
|
preview: {
|
||||||
title: '支持图片板块',
|
title: '支持图片板块',
|
||||||
sections: [
|
sections: [
|
||||||
@@ -72,7 +72,7 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
|
|||||||
<section className="report-section">
|
<section className="report-section">
|
||||||
<h3>日报模板</h3>
|
<h3>日报模板</h3>
|
||||||
<p className="report-section-desc">
|
<p className="report-section-desc">
|
||||||
选择日报呈现风格。模板2 支持图片板块,但需要 AI 模型服务商支持图片识别。
|
选择日报呈现风格。勾选图片后会尝试生成图片精选;识别失败不会影响文字日报。
|
||||||
</p>
|
</p>
|
||||||
<div className="report-template-list">
|
<div className="report-template-list">
|
||||||
{TEMPLATES.map((tpl) => {
|
{TEMPLATES.map((tpl) => {
|
||||||
|
|||||||
@@ -227,6 +227,67 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-message-multi {
|
||||||
|
width: min(310px, 66vw);
|
||||||
|
max-width: 310px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-article-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-article {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-article:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-article-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-article-copy strong,
|
||||||
|
.share-article-copy small {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-article-copy strong {
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-article-copy small {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-article img {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.mini-program-message {
|
.mini-program-message {
|
||||||
width: min(280px, 48vw);
|
width: min(280px, 48vw);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ declare const window: {
|
|||||||
getImage: (
|
getImage: (
|
||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
sessionId?: string
|
sessionId?: string,
|
||||||
|
options?: { includeData?: boolean }
|
||||||
) => Promise<ReportImageReadResult>
|
) => Promise<ReportImageReadResult>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,20 @@ export interface GroupReportFactsSnapshot {
|
|||||||
factsPrompt: string
|
factsPrompt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function friendlyImageNotice(warnings: string[]): string {
|
||||||
|
const detail = warnings.join(' ')
|
||||||
|
if (/模型.*不支持|vision|multimodal|image.*support/i.test(detail)) {
|
||||||
|
return '当前 AI 模型暂未通过图片理解验证,已跳过图片精选;文字日报不受影响。'
|
||||||
|
}
|
||||||
|
if (/解密|密钥|未找到|读取失败/.test(detail)) {
|
||||||
|
return '部分图片在本机暂不可用,已跳过图片精选;文字日报不受影响。'
|
||||||
|
}
|
||||||
|
if (/格式.*不支持|图片格式/.test(detail)) {
|
||||||
|
return '部分图片暂不适合 AI 分析,已跳过图片精选;文字日报不受影响。'
|
||||||
|
}
|
||||||
|
return '图片精选暂未生成,文字消息、统计和关键词仍已正常处理。'
|
||||||
|
}
|
||||||
|
|
||||||
export const isInternalIdentifier = (value: string): boolean =>
|
export const isInternalIdentifier = (value: string): boolean =>
|
||||||
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
|
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
|
||||||
|
|
||||||
@@ -90,7 +105,11 @@ export const summaryContent = (message: Message): string => {
|
|||||||
case 'voice':
|
case 'voice':
|
||||||
return `[语音${data.duration ? ` ${data.duration}秒` : ''}]`
|
return `[语音${data.duration ? ` ${data.duration}秒` : ''}]`
|
||||||
case 'share':
|
case 'share':
|
||||||
return `[分享] ${data.title}${data.des ? `:${data.des}` : ''}`
|
return data.articles?.length
|
||||||
|
? `[分享] ${data.articles
|
||||||
|
.map((article) => `${article.title}${article.description ? `:${article.description}` : ''}`)
|
||||||
|
.join(';')}`
|
||||||
|
: `[分享] ${data.title}${data.des ? `:${data.des}` : ''}`
|
||||||
case 'quote': {
|
case 'quote': {
|
||||||
const reply = data.title || data.content || message.content || '[回复]'
|
const reply = data.title || data.content || message.content || '[回复]'
|
||||||
const quotedSender =
|
const quotedSender =
|
||||||
@@ -282,7 +301,8 @@ const buildMediaSection = async (
|
|||||||
const img = await rendererApi.getImage(
|
const img = await rendererApi.getImage(
|
||||||
candidate.md5,
|
candidate.md5,
|
||||||
candidate.datName,
|
candidate.datName,
|
||||||
candidate.sessionId
|
candidate.sessionId,
|
||||||
|
{ includeData: true }
|
||||||
)
|
)
|
||||||
if (!img.success || !img.data) {
|
if (!img.success || !img.data) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
@@ -336,7 +356,9 @@ const buildMediaSection = async (
|
|||||||
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
|
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
|
||||||
if (!orig) return item
|
if (!orig) return item
|
||||||
try {
|
try {
|
||||||
const img = await rendererApi.getImage(orig.md5, orig.datName, orig.sessionId)
|
const img = await rendererApi.getImage(orig.md5, orig.datName, orig.sessionId, {
|
||||||
|
includeData: true
|
||||||
|
})
|
||||||
if (img.success && img.data?.startsWith('data:image/')) {
|
if (img.success && img.data?.startsWith('data:image/')) {
|
||||||
return { ...item, imageUrl: img.data }
|
return { ...item, imageUrl: img.data }
|
||||||
}
|
}
|
||||||
@@ -356,7 +378,9 @@ const buildMediaSection = async (
|
|||||||
const imageCandidates = rendererApi
|
const imageCandidates = rendererApi
|
||||||
? await Promise.all(
|
? await Promise.all(
|
||||||
rawImageCandidates.map(async (item) => {
|
rawImageCandidates.map(async (item) => {
|
||||||
const result = await rendererApi.getImage(item.md5, item.datName, item.sessionId)
|
const result = await rendererApi.getImage(item.md5, item.datName, item.sessionId, {
|
||||||
|
includeData: true
|
||||||
|
})
|
||||||
if (!result.success || !result.data?.startsWith('data:image/')) return null
|
if (!result.success || !result.data?.startsWith('data:image/')) return null
|
||||||
return {
|
return {
|
||||||
sender: item.sender,
|
sender: item.sender,
|
||||||
@@ -637,7 +661,7 @@ export const buildGroupReportFacts = async (
|
|||||||
)
|
)
|
||||||
if (warnings.length) metadata.warnings = [...(metadata.warnings || []), ...warnings]
|
if (warnings.length) metadata.warnings = [...(metadata.warnings || []), ...warnings]
|
||||||
if (imageCount > 0 && !media.visionGallery?.length) {
|
if (imageCount > 0 && !media.visionGallery?.length) {
|
||||||
metadata.footerNote = `图片识别未成功:${warnings[0] || '当前模型未返回图片理解结果'}。其余内容基于已读取聊天记录生成。`
|
metadata.footerNote = friendlyImageNotice(warnings)
|
||||||
} else if (media.visionGallery?.length) {
|
} else if (media.visionGallery?.length) {
|
||||||
metadata.footerNote = `基于已读取聊天记录生成;其中 ${media.visionGallery.length} 张图片已由当前视觉模型识别。`
|
metadata.footerNote = `基于已读取聊天记录生成;其中 ${media.visionGallery.length} 张图片已由当前视觉模型识别。`
|
||||||
}
|
}
|
||||||
@@ -647,9 +671,7 @@ export const buildGroupReportFacts = async (
|
|||||||
transcriptRows.every((row) => row.content === '[图片]') &&
|
transcriptRows.every((row) => row.content === '[图片]') &&
|
||||||
!media.visionGallery?.length
|
!media.visionGallery?.length
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error('当前范围只有图片,但这些图片暂时无法分析。请改选文字消息,或在设置中验证图片理解能力。')
|
||||||
warnings[0] || '所选记录只有图片,但当前图片均未能识别,请检查图片解密密钥和模型视觉能力'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const factsPrompt = [
|
const factsPrompt = [
|
||||||
|
|||||||
@@ -850,6 +850,15 @@ export const parseGroupDailyReport = (
|
|||||||
const root = asObject(extractJson(raw))
|
const root = asObject(extractJson(raw))
|
||||||
const topics = parseTopics(root)
|
const topics = parseTopics(root)
|
||||||
if (!topics.length) throw new Error('AI 日报中没有有效话题')
|
if (!topics.length) throw new Error('AI 日报中没有有效话题')
|
||||||
|
const keywords = Array.from(
|
||||||
|
new Set([
|
||||||
|
...asStrings(root.keywords, 15),
|
||||||
|
...topics.flatMap((topic) => topic.keywords),
|
||||||
|
...topics.map((topic) => topic.title)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 15)
|
||||||
|
|
||||||
const heroRoot = asObject(root.hero)
|
const heroRoot = asObject(root.hero)
|
||||||
const report: GroupDailyReport = {
|
const report: GroupDailyReport = {
|
||||||
@@ -888,7 +897,7 @@ export const parseGroupDailyReport = (
|
|||||||
})),
|
})),
|
||||||
voiceLeaderboard
|
voiceLeaderboard
|
||||||
},
|
},
|
||||||
keywords: asStrings(root.keywords, 15),
|
keywords,
|
||||||
media
|
media
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ type LocationContent = {
|
|||||||
lng: number
|
lng: number
|
||||||
}
|
}
|
||||||
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||||
|
export type ShareArticle = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
url: string
|
||||||
|
coverUrl?: string
|
||||||
|
}
|
||||||
type ShareContent = {
|
type ShareContent = {
|
||||||
type: 'share'
|
type: 'share'
|
||||||
title: string
|
title: string
|
||||||
@@ -60,6 +66,7 @@ type ShareContent = {
|
|||||||
url: string
|
url: string
|
||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
|
articles?: ShareArticle[]
|
||||||
}
|
}
|
||||||
export type ForwardedMessageItem = {
|
export type ForwardedMessageItem = {
|
||||||
messageType: number
|
messageType: number
|
||||||
|
|||||||
@@ -72,3 +72,34 @@ describe('ImageBubble', () => {
|
|||||||
expect(await screen.findByAltText('图片')).toBeVisible()
|
expect(await screen.findByAltText('图片')).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('RichMessageBubble', () => {
|
||||||
|
it('renders every article in a public-account bundle', async () => {
|
||||||
|
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RichMessageBubble
|
||||||
|
contentData={{
|
||||||
|
type: 'share',
|
||||||
|
title: 'Article one',
|
||||||
|
url: 'https://example.test/one',
|
||||||
|
appname: 'Fixture Publisher',
|
||||||
|
articles: [
|
||||||
|
{ title: 'Article one', url: 'https://example.test/one' },
|
||||||
|
{ title: 'Article two', url: 'https://example.test/two' },
|
||||||
|
{ title: 'Article three', url: 'https://example.test/three' }
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Fixture Publisher')).toBeVisible()
|
||||||
|
expect(screen.getByRole('button', { name: 'Article one' })).toBeVisible()
|
||||||
|
expect(screen.getByRole('button', { name: 'Article two' })).toBeVisible()
|
||||||
|
expect(screen.getByRole('button', { name: 'Article three' })).toBeVisible()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Article three' }))
|
||||||
|
expect(open).toHaveBeenCalledWith('https://example.test/three', '_blank')
|
||||||
|
open.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
|
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync } from 'fs'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -105,4 +106,39 @@ describe('bootstrap cache', () => {
|
|||||||
}).nickname
|
}).nickname
|
||||||
).toBe('濑岛田井卫')
|
).toBe('濑岛田井卫')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('migrates the previous startup cache so account discovery can show identity before unlock', () => {
|
||||||
|
const legacyRoot = '/fixture/legacy-account'
|
||||||
|
const digest = createHash('sha1')
|
||||||
|
.update(`${process.platform}:${legacyRoot}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16)
|
||||||
|
const legacyFile = join(userData, 'cache', 'bootstrap', `${process.platform}-${digest}.json`)
|
||||||
|
mkdirSync(join(userData, 'cache', 'bootstrap'), { recursive: true })
|
||||||
|
writeFileSync(
|
||||||
|
legacyFile,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
platform: process.platform,
|
||||||
|
accountRoot: legacyRoot,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
self: {
|
||||||
|
wxid: 'wxid_legacy',
|
||||||
|
nickname: '缓存昵称',
|
||||||
|
avatar: 'data:image/png;base64,fixture',
|
||||||
|
accountRoot: legacyRoot
|
||||||
|
},
|
||||||
|
contacts: []
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
clearBootstrapCache()
|
||||||
|
expect(getBootstrapCache(legacyRoot)?.self).toMatchObject({
|
||||||
|
wxid: 'wxid_legacy',
|
||||||
|
nickname: '缓存昵称',
|
||||||
|
avatar: 'data:image/png;base64,fixture'
|
||||||
|
})
|
||||||
|
flushBootstrapCacheWritesSync()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { parseGroupDailyReport } from '../../src/renderer/src/utils/group-report'
|
||||||
|
import type { GroupReportMetadata } from '../../src/shared/group-report'
|
||||||
|
|
||||||
|
const metadata: GroupReportMetadata = {
|
||||||
|
groupName: '测试群',
|
||||||
|
reportDate: '2026-08-06',
|
||||||
|
dateRange: '今日',
|
||||||
|
messageCount: 3,
|
||||||
|
activeUsers: 2,
|
||||||
|
timeSpan: '1 h',
|
||||||
|
generatedAt: '2026/8/6 12:00:00',
|
||||||
|
recordNote: 'fixture',
|
||||||
|
footerNote: '',
|
||||||
|
heroParticipants: [],
|
||||||
|
reportMode: 'full'
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = {
|
||||||
|
gallery: [],
|
||||||
|
voiceHighlights: [],
|
||||||
|
funBadges: []
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('group report parsing', () => {
|
||||||
|
it('falls back to topic keywords when the model omits top-level keywords', () => {
|
||||||
|
const report = parseGroupDailyReport(
|
||||||
|
JSON.stringify({
|
||||||
|
topics: [
|
||||||
|
{
|
||||||
|
title: '健身安排',
|
||||||
|
summary: '讨论训练时间和肌酸。',
|
||||||
|
keywords: ['肌酸', '训练']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
'',
|
||||||
|
[],
|
||||||
|
metadata,
|
||||||
|
media
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(report.keywords).toEqual(['肌酸', '训练', '健身安排'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -69,6 +69,23 @@ describe('message parser', () => {
|
|||||||
expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' })
|
expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves every article in a public-account multi-article message', () => {
|
||||||
|
const parsed = parseMessageContent(
|
||||||
|
`<appmsg><type>5</type><appname>长江日报</appname><mmreader><category count="3"><item><title><![CDATA[女子吃酒席时意外发现]]></title><url><![CDATA[https://mp.weixin.qq.com/a]]></url><cover><![CDATA[https://img.test/a.jpg]]></cover></item><item><title>霍尔木兹海峡开放临时协议</title><digest>国际油价短期走势</digest><url>https://mp.weixin.qq.com/b</url></item><item><title>东野圭吾新作</title><url>https://mp.weixin.qq.com/c</url></item></category></mmreader></appmsg>`,
|
||||||
|
49
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(parsed).toMatchObject({ type: 'share', appname: '长江日报' })
|
||||||
|
if (parsed.type === 'share') {
|
||||||
|
expect(parsed.articles).toHaveLength(3)
|
||||||
|
expect(parsed.articles?.map((article) => article.title)).toEqual([
|
||||||
|
'女子吃酒席时意外发现',
|
||||||
|
'霍尔木兹海峡开放临时协议',
|
||||||
|
'东野圭吾新作'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('uses the quoted group member id instead of the chatroom id', () => {
|
it('uses the quoted group member id instead of the chatroom id', () => {
|
||||||
const parsed = parseMessageContent(
|
const parsed = parseMessageContent(
|
||||||
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',
|
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',
|
||||||
|
|||||||
Reference in New Issue
Block a user