mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 完善日报图片与公众号消息展示
修复日报图片读取格式与模型不支持时的友好降级 恢复话题关键词回退,确保词云在缺少顶层关键词时仍可展示 支持公众号多文章消息在聊天、日报、检索和 HTML 导出中完整呈现 迁移账号身份缓存并优化首次连接的账号占位信息 补充图片、词云、账号缓存与多文章消息回归测试
This commit is contained in:
@@ -579,6 +579,19 @@ body {
|
||||
}
|
||||
.structured-link { color: inherit; text-decoration: none; }
|
||||
.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 {
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
@@ -990,6 +1003,22 @@ const renderExportScript = (name: string): string => `
|
||||
}
|
||||
const renderShareContent = (data) => {
|
||||
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 description = decodeEntities(data.des || '')
|
||||
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
|
||||
data: string
|
||||
isThumb: boolean
|
||||
@@ -330,7 +330,7 @@ function buildImageResponse(image: DecodedImage): {
|
||||
} {
|
||||
const mediaService = image.cacheFilePath ? getImageMediaService() : null
|
||||
const data =
|
||||
mediaService && image.cacheFilePath && existsSync(image.cacheFilePath)
|
||||
!includeData && mediaService && image.cacheFilePath && existsSync(image.cacheFilePath)
|
||||
? mediaService.createLocalMediaUrl(image.cacheFilePath)
|
||||
: image.data
|
||||
return {
|
||||
@@ -1126,7 +1126,12 @@ app.whenReady().then(async () => {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
_sessionId?: string,
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
options?: {
|
||||
force?: boolean
|
||||
preferThumbnail?: boolean
|
||||
priority?: number
|
||||
includeData?: boolean
|
||||
}
|
||||
) => {
|
||||
let service = imageDecryptService
|
||||
if (!service) {
|
||||
@@ -1146,6 +1151,7 @@ app.whenReady().then(async () => {
|
||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||
const force = options?.force === true
|
||||
const preferThumbnail = options?.preferThumbnail === true
|
||||
const includeData = options?.includeData === true
|
||||
const priority = Number.isFinite(options?.priority) ? Number(options?.priority) : 0
|
||||
const imageCacheKey = [
|
||||
imageMd5 || '',
|
||||
@@ -1154,10 +1160,10 @@ app.whenReady().then(async () => {
|
||||
].join('|')
|
||||
const mediaService = getImageMediaService()
|
||||
const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
|
||||
includeData: !mediaService
|
||||
includeData: includeData || !mediaService
|
||||
})
|
||||
if (cachedImage && (!force || !cachedImage.isThumbnail)) {
|
||||
return buildImageResponse(cachedImage)
|
||||
return buildImageResponse(cachedImage, includeData)
|
||||
}
|
||||
|
||||
return enqueueColdImageLoad(async () => {
|
||||
@@ -1183,10 +1189,10 @@ app.whenReady().then(async () => {
|
||||
// A previous queued request may have populated the cache while this one waited.
|
||||
const queuedMediaService = getImageMediaService()
|
||||
const queuedCacheHit = await coldService.getCachedDecodedImage(imageCacheKey, {
|
||||
includeData: !queuedMediaService
|
||||
includeData: includeData || !queuedMediaService
|
||||
})
|
||||
if (queuedCacheHit && (!force || !queuedCacheHit.isThumbnail)) {
|
||||
return buildImageResponse(queuedCacheHit)
|
||||
return buildImageResponse(queuedCacheHit, includeData)
|
||||
}
|
||||
|
||||
let filePath = force
|
||||
@@ -1220,7 +1226,7 @@ app.whenReady().then(async () => {
|
||||
isThumbnail: coldService.isThumbnailFile(decrypted.filePath)
|
||||
}
|
||||
await coldService.cacheDecodedImage(imageCacheKey, decodedImage)
|
||||
return buildImageResponse(decodedImage)
|
||||
return buildImageResponse(decodedImage, includeData)
|
||||
}, priority)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -89,8 +89,11 @@ function sourceTextAndAttachment(message: chat.FormattedMessage): {
|
||||
if (content.type === 'share') {
|
||||
const title = content.title?.trim() || ''
|
||||
const description = content.des?.trim() || ''
|
||||
const articles = (content.articles || []).flatMap((article) =>
|
||||
[article.title, article.description].map((value) => value?.trim()).filter(Boolean)
|
||||
)
|
||||
return {
|
||||
text: [text, title, description].filter(Boolean).join('\n') || undefined,
|
||||
text: [text, title, description, ...articles].filter(Boolean).join('\n') || undefined,
|
||||
attachment:
|
||||
title || content.url
|
||||
? {
|
||||
|
||||
@@ -8,6 +8,12 @@ type LocationContent = {
|
||||
lng: number
|
||||
}
|
||||
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||
type ShareArticle = {
|
||||
title: string
|
||||
description?: string
|
||||
url: string
|
||||
coverUrl?: string
|
||||
}
|
||||
type ShareContent = {
|
||||
type: 'share'
|
||||
title: string
|
||||
@@ -15,6 +21,7 @@ type ShareContent = {
|
||||
url: string
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
articles?: ShareArticle[]
|
||||
}
|
||||
type ForwardedMessageItem = {
|
||||
messageType: number
|
||||
@@ -482,17 +489,71 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
}
|
||||
}
|
||||
|
||||
const title = decodeXmlEntities(extractXmlValue(content, 'title')) || ''
|
||||
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
|
||||
const url = extractXmlValue(content, 'url') || ''
|
||||
const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || ''
|
||||
const articles = parseShareArticles(content)
|
||||
const title =
|
||||
articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
|
||||
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') || ''
|
||||
|
||||
if (!title && !url) {
|
||||
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 {
|
||||
|
||||
@@ -155,14 +155,46 @@ function isCurrentAccountFile(
|
||||
function readStartupCacheFile(accountRoot: string): StartupCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getAccountCachePaths(normalizedRoot).startup
|
||||
const paths = getAccountCachePaths(normalizedRoot)
|
||||
const file = paths.startup
|
||||
const scheduled = readScheduledValue<StartupCacheFile>(file)
|
||||
if (scheduled) return scheduled
|
||||
const memory = startupMemory.get(file)
|
||||
if (memory) return memory
|
||||
|
||||
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>
|
||||
if (!isCurrentAccountFile(raw, normalizedRoot)) return null
|
||||
const result: StartupCacheFile = {
|
||||
|
||||
Vendored
+6
-1
@@ -261,7 +261,12 @@ declare global {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
options?: {
|
||||
force?: boolean
|
||||
preferThumbnail?: boolean
|
||||
priority?: number
|
||||
includeData?: boolean
|
||||
}
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
|
||||
@@ -167,7 +167,12 @@ const api = {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
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),
|
||||
getVideo: (
|
||||
hashes: string[],
|
||||
|
||||
@@ -333,12 +333,14 @@ export function DatabaseConnectionPage({
|
||||
{account.avatar ? (
|
||||
<img src={account.avatar} alt="" />
|
||||
) : (
|
||||
(account.nickname || '?').charAt(0)
|
||||
(account.nickname || account.directoryName || '?').charAt(0)
|
||||
)}
|
||||
</span>
|
||||
<span className="database-account-identity">
|
||||
<strong>{account.nickname || '昵称未识别'}</strong>
|
||||
<small>{account.wxid || 'wxid 未识别'}</small>
|
||||
<strong>
|
||||
{account.nickname || `账号目录 ${account.directoryName || '待识别'}`}
|
||||
</strong>
|
||||
<small>{account.wxid || '连接后读取微信号'}</small>
|
||||
<code title={account.accountRoot}>{account.accountRoot}</code>
|
||||
</span>
|
||||
<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 {
|
||||
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 => {
|
||||
if (url) {
|
||||
|
||||
@@ -30,8 +30,8 @@ const TEMPLATES: TemplateMeta[] = [
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
label: '模板2 · 支持图片板块',
|
||||
tagline: '包含热点图片与上下文;需要模型服务商支持图片识别',
|
||||
label: '模板2 · 丰富日报',
|
||||
tagline: '包含更多群聊分析板块;勾选图片后会尝试生成图片精选',
|
||||
preview: {
|
||||
title: '支持图片板块',
|
||||
sections: [
|
||||
@@ -72,7 +72,7 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
|
||||
<section className="report-section">
|
||||
<h3>日报模板</h3>
|
||||
<p className="report-section-desc">
|
||||
选择日报呈现风格。模板2 支持图片板块,但需要 AI 模型服务商支持图片识别。
|
||||
选择日报呈现风格。勾选图片后会尝试生成图片精选;识别失败不会影响文字日报。
|
||||
</p>
|
||||
<div className="report-template-list">
|
||||
{TEMPLATES.map((tpl) => {
|
||||
|
||||
@@ -227,6 +227,67 @@
|
||||
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 {
|
||||
width: min(280px, 48vw);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -34,7 +34,8 @@ declare const window: {
|
||||
getImage: (
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string
|
||||
sessionId?: string,
|
||||
options?: { includeData?: boolean }
|
||||
) => Promise<ReportImageReadResult>
|
||||
}
|
||||
}
|
||||
@@ -58,6 +59,20 @@ export interface GroupReportFactsSnapshot {
|
||||
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 =>
|
||||
/@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':
|
||||
return `[语音${data.duration ? ` ${data.duration}秒` : ''}]`
|
||||
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': {
|
||||
const reply = data.title || data.content || message.content || '[回复]'
|
||||
const quotedSender =
|
||||
@@ -282,7 +301,8 @@ const buildMediaSection = async (
|
||||
const img = await rendererApi.getImage(
|
||||
candidate.md5,
|
||||
candidate.datName,
|
||||
candidate.sessionId
|
||||
candidate.sessionId,
|
||||
{ includeData: true }
|
||||
)
|
||||
if (!img.success || !img.data) {
|
||||
warnings.push(
|
||||
@@ -336,7 +356,9 @@ const buildMediaSection = async (
|
||||
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
|
||||
if (!orig) return item
|
||||
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/')) {
|
||||
return { ...item, imageUrl: img.data }
|
||||
}
|
||||
@@ -356,7 +378,9 @@ const buildMediaSection = async (
|
||||
const imageCandidates = rendererApi
|
||||
? await Promise.all(
|
||||
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
|
||||
return {
|
||||
sender: item.sender,
|
||||
@@ -637,7 +661,7 @@ export const buildGroupReportFacts = async (
|
||||
)
|
||||
if (warnings.length) metadata.warnings = [...(metadata.warnings || []), ...warnings]
|
||||
if (imageCount > 0 && !media.visionGallery?.length) {
|
||||
metadata.footerNote = `图片识别未成功:${warnings[0] || '当前模型未返回图片理解结果'}。其余内容基于已读取聊天记录生成。`
|
||||
metadata.footerNote = friendlyImageNotice(warnings)
|
||||
} else if (media.visionGallery?.length) {
|
||||
metadata.footerNote = `基于已读取聊天记录生成;其中 ${media.visionGallery.length} 张图片已由当前视觉模型识别。`
|
||||
}
|
||||
@@ -647,9 +671,7 @@ export const buildGroupReportFacts = async (
|
||||
transcriptRows.every((row) => row.content === '[图片]') &&
|
||||
!media.visionGallery?.length
|
||||
) {
|
||||
throw new Error(
|
||||
warnings[0] || '所选记录只有图片,但当前图片均未能识别,请检查图片解密密钥和模型视觉能力'
|
||||
)
|
||||
throw new Error('当前范围只有图片,但这些图片暂时无法分析。请改选文字消息,或在设置中验证图片理解能力。')
|
||||
}
|
||||
|
||||
const factsPrompt = [
|
||||
|
||||
@@ -850,6 +850,15 @@ export const parseGroupDailyReport = (
|
||||
const root = asObject(extractJson(raw))
|
||||
const topics = parseTopics(root)
|
||||
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 report: GroupDailyReport = {
|
||||
@@ -888,7 +897,7 @@ export const parseGroupDailyReport = (
|
||||
})),
|
||||
voiceLeaderboard
|
||||
},
|
||||
keywords: asStrings(root.keywords, 15),
|
||||
keywords,
|
||||
media
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,12 @@ type LocationContent = {
|
||||
lng: number
|
||||
}
|
||||
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||
export type ShareArticle = {
|
||||
title: string
|
||||
description?: string
|
||||
url: string
|
||||
coverUrl?: string
|
||||
}
|
||||
type ShareContent = {
|
||||
type: 'share'
|
||||
title: string
|
||||
@@ -60,6 +66,7 @@ type ShareContent = {
|
||||
url: string
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
articles?: ShareArticle[]
|
||||
}
|
||||
export type ForwardedMessageItem = {
|
||||
messageType: number
|
||||
|
||||
@@ -72,3 +72,34 @@ describe('ImageBubble', () => {
|
||||
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 { join } from 'path'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -105,4 +106,39 @@ describe('bootstrap cache', () => {
|
||||
}).nickname
|
||||
).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' })
|
||||
})
|
||||
|
||||
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', () => {
|
||||
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>',
|
||||
|
||||
Reference in New Issue
Block a user