mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 新增实验性微信卡片分享与自动部署能力
补充 Cloudflare Worker、R2 存储、微信 JS-SDK 签名与上传鉴权 增加自动部署 Skill 和配置引导文档 优化报告工具栏、微信卡片弹窗及窄屏响应式布局 补充 Worker 鉴权、卡片生成、过期清理与安全转义测试
This commit is contained in:
@@ -4,6 +4,9 @@ import { ReportGroupMemberSelector } from '../../src/renderer/src/components/rep
|
||||
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
|
||||
import { ReportTemplateSelector } from '../../src/renderer/src/components/reports/ReportTemplateSelector'
|
||||
import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer'
|
||||
import { ReportInfoPanel } from '../../src/renderer/src/components/reports/ReportInfoPanel'
|
||||
import { ReportToolbar } from '../../src/renderer/src/components/reports/ReportToolbar'
|
||||
import { ModelSummary } from '../../src/renderer/src/components/reports/ModelSummary'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||
|
||||
@@ -211,6 +214,61 @@ describe('daily report controls', () => {
|
||||
expect(screen.getByText(/从第三步继续/)).toBeVisible()
|
||||
})
|
||||
|
||||
it('selects separate text-summary and image-understanding models with the 10-minute cache rule', () => {
|
||||
const onTextModelChange = vi.fn()
|
||||
const onVisionModelChange = vi.fn()
|
||||
const textModels = [
|
||||
{
|
||||
providerId: 'deepseek',
|
||||
providerName: 'DeepSeek',
|
||||
model: 'deepseek-chat',
|
||||
modelName: 'DeepSeek Chat',
|
||||
configured: true as const,
|
||||
status: 'connected' as const
|
||||
},
|
||||
{
|
||||
providerId: 'openai',
|
||||
providerName: 'OpenAI',
|
||||
model: 'gpt-5.6-sol',
|
||||
modelName: 'GPT-5.6 Sol',
|
||||
configured: true as const,
|
||||
status: 'connected' as const
|
||||
}
|
||||
]
|
||||
const visionModels = [
|
||||
{
|
||||
providerId: 'sol-provider',
|
||||
providerName: 'OpenAI',
|
||||
model: 'gpt-5.6-sol',
|
||||
modelName: 'GPT-5.6 Sol',
|
||||
configured: true as const,
|
||||
status: 'connected' as const
|
||||
}
|
||||
]
|
||||
render(
|
||||
<ModelSummary
|
||||
config={textModels[0]}
|
||||
visionConfig={visionModels[0]}
|
||||
textModels={textModels}
|
||||
visionModels={visionModels}
|
||||
onTextModelChange={onTextModelChange}
|
||||
onVisionModelChange={onVisionModelChange}
|
||||
onOpenSettings={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const textSelect = screen.getByRole('combobox', { name: '文字总结模型' })
|
||||
const visionSelect = screen.getByRole('combobox', { name: '图片理解模型' })
|
||||
expect(textSelect).toHaveValue('deepseek::deepseek-chat')
|
||||
expect(visionSelect).toHaveValue('sol-provider::gpt-5.6-sol')
|
||||
expect(screen.getAllByRole('option', { name: 'OpenAI · GPT-5.6 Sol' })).toHaveLength(2)
|
||||
fireEvent.change(textSelect, { target: { value: 'openai::gpt-5.6-sol' } })
|
||||
fireEvent.change(visionSelect, { target: { value: 'sol-provider::gpt-5.6-sol' } })
|
||||
expect(onTextModelChange).toHaveBeenCalledWith(textModels[1])
|
||||
expect(onVisionModelChange).toHaveBeenCalledWith(visionModels[0])
|
||||
expect(screen.getByText(/图片识别缓存 10 分钟/)).toBeVisible()
|
||||
})
|
||||
|
||||
it('zooms relative to a full-image fit constrained by viewport width and height', () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver
|
||||
globalThis.ResizeObserver = class {
|
||||
@@ -272,6 +330,116 @@ describe('daily report controls', () => {
|
||||
globalThis.ResizeObserver = originalResizeObserver
|
||||
})
|
||||
|
||||
it('keeps zoom working when a newly saved report replaces the initial result', () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver
|
||||
globalThis.ResizeObserver = class {
|
||||
observe(): void {
|
||||
return undefined
|
||||
}
|
||||
disconnect(): void {
|
||||
return undefined
|
||||
}
|
||||
unobserve(): void {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const baseReport: GeneratedReportRecord = {
|
||||
id: 'temporary-result',
|
||||
contactId: 'group-md5',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-13T10:00:00.000Z',
|
||||
reportDate: '2026-08-13',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
generatedImage: 'data:image/png;base64,fixture'
|
||||
}
|
||||
const props = {
|
||||
hasReports: true,
|
||||
onBackToConfigure: vi.fn(),
|
||||
onRegenerate: vi.fn(),
|
||||
onCopyImage: vi.fn(async () => ({ success: true })),
|
||||
onReveal: vi.fn(async () => ({ success: true })),
|
||||
onSwitchTemplate: vi.fn(async () => ({ success: true }))
|
||||
}
|
||||
const { rerender } = render(<ReportViewer report={baseReport} {...props} />)
|
||||
let image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
|
||||
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1000 })
|
||||
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 2000 })
|
||||
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 544
|
||||
})
|
||||
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 1044
|
||||
})
|
||||
fireEvent.load(image)
|
||||
expect(image.style.width).toBe('500px')
|
||||
|
||||
rerender(<ReportViewer report={{ ...baseReport, id: 'saved-result' }} {...props} />)
|
||||
image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
|
||||
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1000 })
|
||||
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 2000 })
|
||||
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 544
|
||||
})
|
||||
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 1044
|
||||
})
|
||||
fireEvent.load(image)
|
||||
fireEvent.click(screen.getByRole('button', { name: '放大' }))
|
||||
expect(image.style.width).toBe('625px')
|
||||
globalThis.ResizeObserver = originalResizeObserver
|
||||
})
|
||||
|
||||
it('keeps secondary report actions inside More and labels both AI model roles', () => {
|
||||
render(
|
||||
<>
|
||||
<ReportToolbar
|
||||
canCopyImage
|
||||
canReveal
|
||||
canShare
|
||||
canSwitchTemplate
|
||||
currentTemplateId="v1"
|
||||
isSwitchingTemplate={false}
|
||||
onSwitchTemplate={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn()}
|
||||
onReveal={vi.fn()}
|
||||
onShare={vi.fn()}
|
||||
/>
|
||||
<ReportInfoPanel
|
||||
report={{
|
||||
id: 'model-info',
|
||||
contactId: 'group-md5',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-13T10:00:00.000Z',
|
||||
reportDate: '2026-08-13',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
textModelName: 'deepseek-chat',
|
||||
imageModelName: 'gpt-5.6-sol'
|
||||
}}
|
||||
onReveal={vi.fn(async () => ({ success: true }))}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: '生成微信卡片' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '更多' }))
|
||||
expect(screen.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
|
||||
expect(screen.getByText('文字模型')).toBeVisible()
|
||||
expect(screen.getByText('DeepSeek Chat')).toBeVisible()
|
||||
expect(screen.getByText('图片模型')).toBeVisible()
|
||||
expect(screen.getByText('gpt-5.6-sol')).toBeVisible()
|
||||
})
|
||||
|
||||
it('switches templates from the top toolbar using the saved report snapshot', async () => {
|
||||
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
|
||||
const report: GeneratedReportRecord = {
|
||||
|
||||
+22
-1
@@ -273,6 +273,12 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
|
||||
await fixture.page.getByRole('button', { name: '日报' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
|
||||
const textModel = fixture.page.getByRole('combobox', { name: '文字总结模型' })
|
||||
const visionModel = fixture.page.getByRole('combobox', { name: '图片理解模型' })
|
||||
await expect(textModel).toHaveValue('fixture-provider::fixture-model')
|
||||
await expect(visionModel).toHaveValue('fixture-provider::fixture-vision-model')
|
||||
await expect(textModel.locator('option')).toHaveCount(2)
|
||||
await expect(visionModel.locator('option')).toHaveCount(1)
|
||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
||||
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
||||
const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
|
||||
@@ -281,6 +287,20 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
|
||||
await expect(fixture.page.getByAltText('产品测试群 群聊日报')).toBeVisible({
|
||||
timeout: 15_000
|
||||
})
|
||||
await expect(fixture.page.getByText('文字模型')).toBeVisible()
|
||||
await expect(fixture.page.getByText('固定响应模型')).toBeVisible()
|
||||
await expect(fixture.page.getByText('图片模型')).toBeVisible()
|
||||
await expect(fixture.page.getByText('固定图片识别模型')).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toHaveCount(0)
|
||||
await fixture.page.getByRole('button', { name: '更多' }).click()
|
||||
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
|
||||
|
||||
await fixture.page.setViewportSize({ width: 1024, height: 760 })
|
||||
const reportTitle = fixture.page.getByRole('heading', { name: '产品测试群 群聊日报' })
|
||||
await expect(reportTitle).toBeVisible()
|
||||
expect((await reportTitle.boundingBox())?.width || 0).toBeGreaterThan(170)
|
||||
await expect(fixture.page.getByRole('button', { name: '放大' })).toBeEnabled()
|
||||
await fixture.page.getByRole('button', { name: '放大' }).click()
|
||||
|
||||
const exported = await fixture.page.evaluate(async () =>
|
||||
window.api.exportGroupReport({
|
||||
@@ -308,7 +328,8 @@ test('REPORT-03 report failure is retryable and leaves other pages usable', asyn
|
||||
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled()
|
||||
await expect(fixture.page.getByRole('button', { name: '使用所选模型重新生成' })).toBeEnabled()
|
||||
await expect(fixture.page.getByText(/从第三步继续/)).toBeVisible()
|
||||
await fixture.page.getByRole('button', { name: '档案' }).click()
|
||||
await expect(fixture.page.locator('main.app-shell-main[aria-label="档案"]')).toBeVisible()
|
||||
await expect(
|
||||
|
||||
@@ -411,9 +411,44 @@ handle('ai:getRuntimeConfig', () => ({
|
||||
status: 'connected',
|
||||
timeoutMs: 5000
|
||||
}))
|
||||
handle('ai:getVisionRuntimeConfig', () => ({
|
||||
providerId: 'fixture-vision-provider',
|
||||
providerName: '本地图片假服务',
|
||||
model: 'fixture-vision-model',
|
||||
modelName: '固定图片识别模型',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
timeoutMs: 5000,
|
||||
source: 'vision-capability'
|
||||
}))
|
||||
handle('ai:listProviders', () => ({
|
||||
success: true,
|
||||
providers: [],
|
||||
providers: [
|
||||
{
|
||||
id: 'fixture-provider',
|
||||
name: '本地假服务',
|
||||
type: 'openai-compatible',
|
||||
baseUrl: 'http://127.0.0.1:1/v1',
|
||||
auth: { type: 'none' },
|
||||
models: [
|
||||
{
|
||||
id: 'fixture-model',
|
||||
name: '固定响应模型',
|
||||
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
|
||||
},
|
||||
{
|
||||
id: 'fixture-vision-model',
|
||||
name: '固定图片识别模型',
|
||||
capabilities: { chat: true, vision: true, ocr: true, longContext: true }
|
||||
}
|
||||
],
|
||||
defaultModel: 'fixture-model',
|
||||
advanced: { timeoutMs: 5000, extraHeaders: {} },
|
||||
hasApiKey: true,
|
||||
isDefault: true,
|
||||
status: 'connected'
|
||||
}
|
||||
],
|
||||
defaultProviderId: 'fixture-provider'
|
||||
}))
|
||||
handle('ai:migrateLegacy', () => ({ success: true, providers: [] }))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { AIProviderConfig } from '../../src/shared/ai-provider'
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'tracememo-ai-vision-routing-'))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => root },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString('utf8')
|
||||
}
|
||||
}))
|
||||
|
||||
import { AIProviderService } from '../../src/main/services/ai-provider-service'
|
||||
|
||||
const provider = (id: string, modelId: string, vision: boolean): AIProviderConfig => ({
|
||||
id,
|
||||
name: id === 'deepseek' ? 'DeepSeek' : 'OpenAI',
|
||||
type: 'openai-compatible',
|
||||
baseUrl: `https://${id}.example.test/v1`,
|
||||
auth: { type: 'none' },
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
capabilities: { chat: true, vision, ocr: vision, longContext: true }
|
||||
}
|
||||
],
|
||||
defaultModel: modelId,
|
||||
advanced: { timeoutMs: 120_000, extraHeaders: {} }
|
||||
})
|
||||
|
||||
describe('AI provider vision routing', () => {
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
it('keeps DeepSeek as the text model while routing images to a verified vision model', () => {
|
||||
const service = new AIProviderService()
|
||||
expect(service.save(provider('deepseek', 'deepseek-chat', false)).success).toBe(true)
|
||||
expect(service.save(provider('sol-provider', 'gpt-5.6-sol', true)).success).toBe(true)
|
||||
expect(service.setDefault('deepseek').success).toBe(true)
|
||||
|
||||
expect(service.getRuntimeConfig()).toMatchObject({
|
||||
providerId: 'deepseek',
|
||||
model: 'deepseek-chat'
|
||||
})
|
||||
expect(service.getVisionRuntimeConfig()).toMatchObject({
|
||||
providerId: 'sol-provider',
|
||||
model: 'gpt-5.6-sol',
|
||||
configured: true,
|
||||
source: 'vision-capability'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports unavailable when no configured model has vision capability', () => {
|
||||
const service = new AIProviderService()
|
||||
for (const item of service.list().providers) service.delete(item.id)
|
||||
expect(service.save(provider('deepseek', 'deepseek-chat', false)).success).toBe(true)
|
||||
|
||||
expect(service.getVisionRuntimeConfig()).toMatchObject({
|
||||
configured: false,
|
||||
model: '',
|
||||
source: 'unavailable'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -210,9 +210,24 @@ describe('group report parsing', () => {
|
||||
})
|
||||
|
||||
const input = await buildGroupReportInput(messages, null, true, 'full', {
|
||||
onProgress: progress
|
||||
onProgress: progress,
|
||||
visionModel: {
|
||||
providerId: 'selected-vision-provider',
|
||||
providerName: '视觉服务',
|
||||
model: 'selected-vision-model',
|
||||
modelName: '视觉模型',
|
||||
configured: true,
|
||||
status: 'connected'
|
||||
}
|
||||
})
|
||||
|
||||
expect(window.api.imageAnalyze).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerId: 'selected-vision-provider',
|
||||
modelId: 'selected-vision-model'
|
||||
})
|
||||
)
|
||||
|
||||
expect(input.prompt).toContain('AI 图片识别摘要:')
|
||||
expect(input.prompt).toContain('一张表格型网页截图,包含多列数据。')
|
||||
expect(input.prompt).toContain('OCR: 项目 状态 负责人')
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
IMAGE_INSIGHT_CACHE_TTL_MS,
|
||||
isFreshImageInsight,
|
||||
type ImageInsight
|
||||
} from '../../src/shared/image-insight'
|
||||
|
||||
const { getByHash } = vi.hoisted(() => ({ getByHash: vi.fn() }))
|
||||
const { getByHash, upsert } = vi.hoisted(() => ({
|
||||
getByHash: vi.fn(),
|
||||
upsert: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/db/image-insights-store', () => ({
|
||||
imageInsightsStore: {
|
||||
getByHash,
|
||||
upsert: vi.fn(),
|
||||
upsert,
|
||||
listBySession: vi.fn()
|
||||
}
|
||||
}))
|
||||
@@ -19,7 +27,11 @@ const query = {
|
||||
limit: 3
|
||||
}
|
||||
|
||||
const input = (id: string, responseCount: number, interactionCount: number): {
|
||||
const input = (
|
||||
id: string,
|
||||
responseCount: number,
|
||||
interactionCount: number
|
||||
): {
|
||||
messageId: string
|
||||
md5: string
|
||||
sessionId: string
|
||||
@@ -41,6 +53,7 @@ describe('ImageInsightService hot image selection', () => {
|
||||
beforeEach(() => {
|
||||
getByHash.mockReset()
|
||||
getByHash.mockReturnValue(null)
|
||||
upsert.mockReset()
|
||||
})
|
||||
|
||||
it('returns fewer than three images when only two pass the hot threshold', async () => {
|
||||
@@ -78,11 +91,207 @@ describe('ImageInsightService hot image selection', () => {
|
||||
})
|
||||
|
||||
it('keeps a cached insight attached to an eligible candidate', async () => {
|
||||
const cached = { imageHash: 'a'.repeat(32), description: '缓存识别结果' }
|
||||
const cached = {
|
||||
imageHash: 'a'.repeat(32),
|
||||
description: '缓存识别结果',
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
getByHash.mockImplementation((hash: string) => (hash === 'a'.repeat(32) ? cached : null))
|
||||
|
||||
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
|
||||
|
||||
expect(result[0]).toMatchObject({ messageId: 'a', insight: cached })
|
||||
})
|
||||
|
||||
it('does not attach a cached insight once its 10-minute TTL has elapsed', async () => {
|
||||
getByHash.mockReturnValue({
|
||||
imageHash: 'a'.repeat(32),
|
||||
description: '过期识别结果',
|
||||
updatedAt: Date.now() - IMAGE_INSIGHT_CACHE_TTL_MS
|
||||
})
|
||||
|
||||
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
|
||||
|
||||
expect(result[0]).not.toHaveProperty('insight')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImageInsightService cache TTL and vision routing', () => {
|
||||
const now = new Date('2026-08-12T10:00:00.000Z').getTime()
|
||||
const cachedInsight = (updatedAt: number): ImageInsight => ({
|
||||
id: 'cached-insight',
|
||||
messageId: 'image-1',
|
||||
imageHash: 'a'.repeat(32),
|
||||
description: '缓存图片描述',
|
||||
tags: ['缓存'],
|
||||
category: 'screenshot',
|
||||
importance: 'medium',
|
||||
provider: 'vision-provider',
|
||||
model: 'vision-model',
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
sender: '成员一',
|
||||
sentAt: now,
|
||||
sessionId: 'group@chatroom'
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
getByHash.mockReset()
|
||||
getByHash.mockReturnValue(null)
|
||||
upsert.mockReset()
|
||||
})
|
||||
|
||||
it('treats only results strictly newer than 10 minutes as fresh', () => {
|
||||
expect(isFreshImageInsight(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS + 1), now)).toBe(true)
|
||||
expect(isFreshImageInsight(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS), now)).toBe(false)
|
||||
expect(isFreshImageInsight(cachedInsight(0), now)).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the independent vision runtime after an expired cache entry', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(now)
|
||||
getByHash.mockReturnValue(cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS))
|
||||
const analyzeImage = vi.fn(async () => ({
|
||||
success: true,
|
||||
data: JSON.stringify({
|
||||
description: '重新识别后的图片描述',
|
||||
ocrText: '新的 OCR',
|
||||
tags: ['更新', '截图'],
|
||||
category: 'screenshot',
|
||||
importance: 'high'
|
||||
})
|
||||
}))
|
||||
const getVisionRuntimeConfig = vi.fn(() => ({
|
||||
providerId: 'sol-provider',
|
||||
providerName: 'OpenAI',
|
||||
model: 'gpt-5.6-sol',
|
||||
modelName: 'gpt-5.6-sol',
|
||||
configured: true
|
||||
}))
|
||||
imageInsightService.bind({
|
||||
providerService: {
|
||||
list: () => ({ providers: [], defaultProviderId: 'deepseek' }),
|
||||
getVisionRuntimeConfig,
|
||||
analyzeImage
|
||||
},
|
||||
decryptService: {
|
||||
findImageFile: () => null,
|
||||
decryptImageToBase64: () => null
|
||||
}
|
||||
})
|
||||
|
||||
const result = await imageInsightService.analyze({
|
||||
imageHash: 'a'.repeat(32),
|
||||
imageDataUrl: 'data:image/png;base64,fixture',
|
||||
messageId: 'image-1',
|
||||
sender: '成员一',
|
||||
sentAt: now,
|
||||
sessionId: 'group@chatroom'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ success: true, fromCache: false })
|
||||
expect(analyzeImage).toHaveBeenCalledWith(expect.any(Array), {
|
||||
providerId: 'sol-provider',
|
||||
modelId: 'gpt-5.6-sol'
|
||||
})
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: '重新识别后的图片描述',
|
||||
provider: 'sol-provider',
|
||||
model: 'gpt-5.6-sol',
|
||||
updatedAt: now
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the report-selected vision model on a cache miss', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(now)
|
||||
const analyzeImage = vi.fn(async () => ({
|
||||
success: true,
|
||||
data: JSON.stringify({
|
||||
description: '指定模型识别结果',
|
||||
tags: ['指定'],
|
||||
category: 'screenshot',
|
||||
importance: 'medium'
|
||||
})
|
||||
}))
|
||||
const getVisionRuntimeConfig = vi.fn(() => ({
|
||||
providerId: 'automatic-provider',
|
||||
providerName: '自动模型',
|
||||
model: 'automatic-vision',
|
||||
modelName: '自动视觉模型',
|
||||
configured: true
|
||||
}))
|
||||
imageInsightService.bind({
|
||||
providerService: {
|
||||
list: () => ({ providers: [], defaultProviderId: 'automatic-provider' }),
|
||||
getVisionRuntimeConfig,
|
||||
analyzeImage
|
||||
},
|
||||
decryptService: {
|
||||
findImageFile: () => null,
|
||||
decryptImageToBase64: () => null
|
||||
}
|
||||
})
|
||||
|
||||
const result = await imageInsightService.analyze({
|
||||
imageHash: 'b'.repeat(32),
|
||||
imageDataUrl: 'data:image/png;base64,fixture',
|
||||
messageId: 'image-selected',
|
||||
sender: '成员二',
|
||||
sentAt: now,
|
||||
sessionId: 'group@chatroom',
|
||||
providerId: 'selected-provider',
|
||||
modelId: 'selected-vision'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ success: true, fromCache: false })
|
||||
expect(analyzeImage).toHaveBeenCalledWith(expect.any(Array), {
|
||||
providerId: 'selected-provider',
|
||||
modelId: 'selected-vision'
|
||||
})
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: 'selected-provider', model: 'selected-vision' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a fresh cached result without calling the vision model', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(now)
|
||||
const cached = cachedInsight(now - IMAGE_INSIGHT_CACHE_TTL_MS + 1)
|
||||
getByHash.mockReturnValue(cached)
|
||||
const analyzeImage = vi.fn()
|
||||
imageInsightService.bind({
|
||||
providerService: {
|
||||
list: () => ({ providers: [], defaultProviderId: 'deepseek' }),
|
||||
getVisionRuntimeConfig: () => ({
|
||||
providerId: 'sol-provider',
|
||||
providerName: 'OpenAI',
|
||||
model: 'gpt-5.6-sol',
|
||||
modelName: 'gpt-5.6-sol',
|
||||
configured: true
|
||||
}),
|
||||
analyzeImage
|
||||
},
|
||||
decryptService: {
|
||||
findImageFile: () => null,
|
||||
decryptImageToBase64: () => null
|
||||
}
|
||||
})
|
||||
|
||||
const result = await imageInsightService.analyze({
|
||||
imageHash: cached.imageHash,
|
||||
imageDataUrl: 'data:image/png;base64,fixture',
|
||||
messageId: cached.messageId,
|
||||
sender: cached.sender,
|
||||
sentAt: cached.sentAt,
|
||||
sessionId: cached.sessionId
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true, insight: cached, fromCache: true })
|
||||
expect(analyzeImage).not.toHaveBeenCalled()
|
||||
expect(upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user