feat: 新增实验性微信卡片分享与自动部署能力

补充 Cloudflare Worker、R2 存储、微信 JS-SDK 签名与上传鉴权
增加自动部署 Skill 和配置引导文档
优化报告工具栏、微信卡片弹窗及窄屏响应式布局
补充 Worker 鉴权、卡片生成、过期清理与安全转义测试
This commit is contained in:
Wxw-Gu
2026-08-13 10:48:07 +08:00
parent d439b4b749
commit 4436d7c8ce
45 changed files with 3832 additions and 69 deletions
@@ -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'
})
})
})
+16 -1
View File
@@ -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: 项目 状态 负责人')
+213 -4
View File
@@ -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()
})
})