diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index 7666dfd..b0358df 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -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 = '
' + displayText(article.title || '公众号文章') + '
' + + (article.description ? '
' + displayText(article.description) + '
' : '') + + '
' + (cover ? '' : '') + return href + ? '' + body + '' + : '
' + body + '
' + }).join('') + return '
' + + '
' + displayText(data.appname || label) + '
' + + '
' + articleMarkup + '
' + } let title = decodeEntities(data.title || label) let description = decodeEntities(data.des || '') if (String(data.typeVal) === '51' && /当前微信版本不支持展示该内容/.test(title) && description) { diff --git a/src/main/index.ts b/src/main/index.ts index a49b6e1..7daec90 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) } ) diff --git a/src/main/knowledge/knowledge-search-service.ts b/src/main/knowledge/knowledge-search-service.ts index 3997c20..912355a 100644 --- a/src/main/knowledge/knowledge-search-service.ts +++ b/src/main/knowledge/knowledge-search-service.ts @@ -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 ? { diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index fde7bcf..fcb937a 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -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 (!/]*)?>([\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() + 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 { diff --git a/src/main/services/bootstrap-cache.ts b/src/main/services/bootstrap-cache.ts index 03a5c7f..46ad6e6 100644 --- a/src/main/services/bootstrap-cache.ts +++ b/src/main/services/bootstrap-cache.ts @@ -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(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 if (!isCurrentAccountFile(raw, normalizedRoot)) return null const result: StartupCacheFile = { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 503e022..a719219 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 66dcfd8..32179a8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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[], diff --git a/src/renderer/src/components/DatabaseConnectionPage.tsx b/src/renderer/src/components/DatabaseConnectionPage.tsx index 6dc667c..14dc631 100644 --- a/src/renderer/src/components/DatabaseConnectionPage.tsx +++ b/src/renderer/src/components/DatabaseConnectionPage.tsx @@ -333,12 +333,14 @@ export function DatabaseConnectionPage({ {account.avatar ? ( ) : ( - (account.nickname || '?').charAt(0) + (account.nickname || account.directoryName || '?').charAt(0) )} - {account.nickname || '昵称未识别'} - {account.wxid || 'wxid 未识别'} + + {account.nickname || `账号目录 ${account.directoryName || '待识别'}`} + + {account.wxid || '连接后读取微信号'} {account.accountRoot} diff --git a/src/renderer/src/components/RichMessageBubble.tsx b/src/renderer/src/components/RichMessageBubble.tsx index 70ed1ee..1372445 100644 --- a/src/renderer/src/components/RichMessageBubble.tsx +++ b/src/renderer/src/components/RichMessageBubble.tsx @@ -158,7 +158,31 @@ function CardBubble({ data }: { data: Extract } } function ShareBubble({ data }: { data: Extract }): JSX.Element { - const { title, des, url, appname } = data + const { title, des, url, appname, articles } = data + + if (articles?.length) { + return ( +
+ {appname &&
{appname}
} +
+ {articles.map((article, index) => ( + + ))} +
+
+ ) + } const handleClick = (): void => { if (url) { diff --git a/src/renderer/src/components/reports/ReportTemplateSelector.tsx b/src/renderer/src/components/reports/ReportTemplateSelector.tsx index 05977d4..aafc1b1 100644 --- a/src/renderer/src/components/reports/ReportTemplateSelector.tsx +++ b/src/renderer/src/components/reports/ReportTemplateSelector.tsx @@ -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 = ({

日报模板

- 选择日报呈现风格。模板2 支持图片板块,但需要 AI 模型服务商支持图片识别。 + 选择日报呈现风格。勾选图片后会尝试生成图片精选;识别失败不会影响文字日报。

{TEMPLATES.map((tpl) => { diff --git a/src/renderer/src/styles/rich-message.scss b/src/renderer/src/styles/rich-message.scss index c0f3930..d8f33ae 100644 --- a/src/renderer/src/styles/rich-message.scss +++ b/src/renderer/src/styles/rich-message.scss @@ -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; diff --git a/src/renderer/src/utils/group-report-facts.ts b/src/renderer/src/utils/group-report-facts.ts index 24b688c..b845633 100644 --- a/src/renderer/src/utils/group-report-facts.ts +++ b/src/renderer/src/utils/group-report-facts.ts @@ -34,7 +34,8 @@ declare const window: { getImage: ( imageMd5?: string, imageDatNameOrThumb?: string | boolean, - sessionId?: string + sessionId?: string, + options?: { includeData?: boolean } ) => Promise } } @@ -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 = [ diff --git a/src/renderer/src/utils/group-report.ts b/src/renderer/src/utils/group-report.ts index b80127f..73e82e2 100644 --- a/src/renderer/src/utils/group-report.ts +++ b/src/renderer/src/utils/group-report.ts @@ -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 } diff --git a/src/shared/types.ts b/src/shared/types.ts index dc0e42c..b24c4eb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/tests/component/image-bubble.test.tsx b/tests/component/image-bubble.test.tsx index 5f18fcc..c4cbeb6 100644 --- a/tests/component/image-bubble.test.tsx +++ b/tests/component/image-bubble.test.tsx @@ -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( + + ) + + 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() + }) +}) diff --git a/tests/unit/bootstrap-cache.test.ts b/tests/unit/bootstrap-cache.test.ts index b5dad83..abfc4b6 100644 --- a/tests/unit/bootstrap-cache.test.ts +++ b/tests/unit/bootstrap-cache.test.ts @@ -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() + }) }) diff --git a/tests/unit/group-report.test.ts b/tests/unit/group-report.test.ts new file mode 100644 index 0000000..bfeb441 --- /dev/null +++ b/tests/unit/group-report.test.ts @@ -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(['肌酸', '训练', '健身安排']) + }) +}) diff --git a/tests/unit/message-parser.test.ts b/tests/unit/message-parser.test.ts index 24f6f9f..40978d9 100644 --- a/tests/unit/message-parser.test.ts +++ b/tests/unit/message-parser.test.ts @@ -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( + `5长江日报<![CDATA[女子吃酒席时意外发现]]>霍尔木兹海峡开放临时协议国际油价短期走势https://mp.weixin.qq.com/b东野圭吾新作https://mp.weixin.qq.com/c`, + 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( '57回复内容1123456789@chatroomwxid_fixture_member被引用内容',