feat: 本地api增加图片理解

This commit is contained in:
Wxw-Gu
2026-08-26 15:21:05 +08:00
parent 1e25ae4065
commit fc42b18f4a
8 changed files with 423 additions and 10 deletions
+18
View File
@@ -36,6 +36,7 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
| GET | `/api/v1/media/{messageId}` | 获取图片消息的二进制资源 | 使用 `/chatlog` 返回的图片消息 `id` |
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
@@ -84,6 +85,7 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
- `200`:请求成功;
- `401`:缺少、错误或已失效的 Bearer Token;
- `400`:参数或 JSON 请求体无效;
- `422`:媒体 `messageId` 无效,或目标消息不是可读取的图片;
- `403`:浏览器 Origin 不在允许的 loopback 列表;
- `404`:端点、会话或群聊不存在;
- `503`:数据库或 Agent Hub 尚未就绪;
@@ -91,6 +93,22 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
成功响应会返回端点对应的 JSON 对象,例如 `chatlog` 包含 `contact`、`query`、`count` 和 `messages`,`contact` 返回 `count` 与 `contacts`。
图片消息在 `messages` 中保留原有字段,并额外提供 `media`:
```json
{
"type": "图片",
"content": "",
"media": {
"type": "image",
"available": true,
"url": "/api/v1/media/msg_xxx"
}
}
```
当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。
## 与 MCP 的关系
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
+26 -1
View File
@@ -1,6 +1,6 @@
---
name: tracememo-reader
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据。当用户要求查看微信消息、查找联系人或群聊、总结聊天、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
---
# TraceMemo Reader
@@ -35,6 +35,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
| GET | `/recent_chat` | 最近会话;可传 `limit` |
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 |
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
@@ -52,6 +53,29 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
- 根据多条消息整理出的总结;
- 没有来源支持的推断。
## 媒体消息
当 `/chatlog` 返回图片消息时:
1. 如果用户只是询问图片消息是否存在,不需要获取图片。
2. 如果用户要求查看、识别、理解或分析图片,使用该消息 `media.url`(`/media/{messageId}`)获取真实图片。
3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。
4. 获取成功后,将图片交给当前 Agent 的视觉能力。
5. 如果图片获取失败,明确说明无法读取图片。
6. 不要声称看到了没有成功获取的图片。
7. 不要向用户暴露 Token、本地文件路径或数据库路径。
### 图片分析
用户:“看看张三昨天发的那张截图。”
1. 调用 `/health`;必要时调用 `/current_time`。
2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。
3. 调用 `/media/{messageId}`,将返回的图片交给 Vision。
4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。
不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。
## 隐私和安全
只读取用户请求所需的会话和时间范围。不要把完整聊天数据库、密钥或 Token 暴露给用户。Reader API 本身不自动把聊天转发到外部服务器,但当前 Agent 可能会把工具结果交给其配置的模型;如有疑问,提醒用户检查 Agent 的数据策略。
@@ -61,5 +85,6 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
- `404`:先用 `/resolve` 确认会话标识。
- `422`:`messageId` 无效,或消息不是可读取的图片。
- `503`:用户还没有完成数据库连接或对应服务未就绪。
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
+84
View File
@@ -0,0 +1,84 @@
import { loadSettings } from './services/settings-store'
import { getChatDb, getImageMessageReference, isReady } from './services/chat-service'
import { ImageDecryptService } from './image-decrypt-service'
import { ImageKeyConfigService } from './services/image-key-config-service'
export type HttpImageResult = {
buffer: Buffer
mimeType: string
}
export class HttpMediaError extends Error {
constructor(
public readonly code: 'NOT_READY' | 'NOT_FOUND' | 'NOT_IMAGE' | 'READ_FAILED',
message: string
) {
super(message)
this.name = 'HttpMediaError'
}
}
let imageService: ImageDecryptService | null = null
let imageServiceKey = ''
let imageServiceClient: ReturnType<
NonNullable<ReturnType<typeof getChatDb>>['getWcdb4Client']
> | null = null
function getImageService(): ImageDecryptService {
const config = new ImageKeyConfigService()
const imageConfig = config.getConfig()
const aesKey = imageConfig.aesKey || ''
const xorKey = imageConfig.xorKey || '0x40'
const key = `${xorKey}:${aesKey}`
const client = getChatDb()?.getWcdb4Client() || null
if (!imageService || imageServiceKey !== key || imageServiceClient !== client) {
imageService = new ImageDecryptService(xorKey, aesKey, client, loadSettings().dbRoot)
imageServiceKey = key
imageServiceClient = client
}
return imageService
}
function decodeDataUrl(value: string): HttpImageResult | null {
const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i.exec(value)
if (!match) return null
return { mimeType: match[1].toLowerCase(), buffer: Buffer.from(match[2], 'base64') }
}
export async function readImageMedia(messageId: string): Promise<HttpImageResult> {
if (!isReady()) throw new HttpMediaError('NOT_READY', 'TraceMemo 数据库未初始化')
const reference = getImageMessageReference(messageId)
if (!reference) throw new HttpMediaError('NOT_FOUND', '未找到图片消息')
if (!reference.imageMd5 && !reference.imageDatName) {
throw new HttpMediaError('NOT_IMAGE', '消息不是可读取的图片消息')
}
const service = getImageService()
const filePath = await service.findImageFileAsync(reference.imageMd5, reference.imageDatName, {
allowThumbnail: false,
sessionId: reference.sessionId,
createTime: reference.createTime
})
const fallbackPath =
filePath ||
(await service.findImageFileAsync(reference.imageMd5, reference.imageDatName, {
allowThumbnail: true,
sessionId: reference.sessionId,
createTime: reference.createTime
}))
if (!fallbackPath) throw new HttpMediaError('NOT_FOUND', '图片文件不存在')
const decoded = await service.decryptImageToBase64WithFallbackAsync(fallbackPath, true)
if (!decoded) throw new HttpMediaError('READ_FAILED', '图片读取或解密失败')
const image = decodeDataUrl(decoded.data)
if (!image || image.buffer.length === 0) {
throw new HttpMediaError('READ_FAILED', '图片数据无效')
}
return image
}
export function resetHttpMediaService(): void {
imageService = null
imageServiceKey = ''
imageServiceClient = null
}
+81 -2
View File
@@ -14,6 +14,7 @@ import { generateAgentGroupReport } from './services/agent-group-report-service'
import { agentHubService } from './services/agent-hub-service'
import { safeError, safeLog, safeWarn } from './safe-log'
import { apiTokenStore } from './api-token-store'
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service'
export const DEFAULT_HTTP_HOST = '127.0.0.1'
export const DEFAULT_HTTP_PORT = 6131
@@ -33,6 +34,7 @@ interface RouteContext {
export interface HttpServerOptions {
tokenProvider?: () => string | null
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
}
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
@@ -90,6 +92,26 @@ function sendError(res: ServerResponse, status: number, message: string, extra?:
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
}
function sendBinary(res: ServerResponse, status: number, result: HttpImageResult): void {
res.writeHead(status, {
'Content-Type': result.mimeType,
'Content-Length': result.buffer.length,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'
})
res.end(result.buffer)
}
function sanitizeChatlogMessage(message: Record<string, unknown>): Record<string, unknown> {
const contentData = message.contentData
if (!contentData || typeof contentData !== 'object' || !('aeskey' in contentData)) {
return message
}
const safeContentData = { ...(contentData as Record<string, unknown>) }
delete safeContentData.aeskey
return { ...message, contentData: safeContentData }
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
@@ -252,7 +274,9 @@ const routes: Record<string, RouteHandler> = {
contact: resolved,
query: { talker, time: timeParam, startTime, endTime },
count: messages.length,
messages
messages: messages.map((message) =>
sanitizeChatlogMessage(message as unknown as Record<string, unknown>)
)
})
},
@@ -343,12 +367,63 @@ const routes: Record<string, RouteHandler> = {
}
}
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
function createMediaRoute(
mediaProvider: (messageId: string) => Promise<HttpImageResult>
): RouteHandler {
return async ({ req, res, url }) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return sendError(res, 405, '需要 GET 请求')
}
const encodedMessageId = url.pathname.slice(MEDIA_ROUTE_PREFIX.length)
let messageId: string
try {
messageId = decodeURIComponent(encodedMessageId)
} catch {
return sendError(res, 422, 'messageId 格式无效')
}
if (!messageId || messageId.includes('/') || messageId.includes('\\')) {
return sendError(res, 422, 'messageId 格式无效')
}
try {
const result = await mediaProvider(messageId)
if (req.method === 'HEAD') {
res.writeHead(200, {
'Content-Type': result.mimeType,
'Content-Length': result.buffer.length,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'
})
res.end()
return
}
sendBinary(res, 200, result)
} catch (error) {
if (error instanceof HttpMediaError) {
const status =
error.code === 'NOT_READY'
? 503
: error.code === 'NOT_IMAGE'
? 422
: error.code === 'NOT_FOUND'
? 404
: 500
return sendError(res, status, error.message)
}
safeError('[HttpServer] media request failed:', error)
return sendError(res, 500, '图片读取失败')
}
}
}
export function startHttpServer(
host: string = DEFAULT_HTTP_HOST,
port: number = DEFAULT_HTTP_PORT,
options: HttpServerOptions = {}
): Promise<HttpServerHandle> {
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
const mediaProvider = options.mediaProvider || readImageMedia
return new Promise((resolve, reject) => {
const server: Server = http.createServer(async (req, res) => {
try {
@@ -360,7 +435,11 @@ export function startHttpServer(
res.writeHead(204)
return res.end()
}
const handler = routes[url.pathname]
const handler =
routes[url.pathname] ||
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
? createMediaRoute(mediaProvider)
: undefined)
if (!handler) {
return sendError(res, 404, `端点不存在: ${url.pathname}`)
}
+62 -4
View File
@@ -59,6 +59,11 @@ export interface FormattedMessage {
name?: string
senderId?: string
contentData?: ReturnType<typeof parseMessageContent>
media?: {
type: 'image'
available: boolean
url: string
}
voiceDataUrl?: string
voiceDuration?: number
voiceTranscript?: string
@@ -122,6 +127,16 @@ function normalizeMsgType(value: string | number | undefined): number {
let dbRef: WechatDb | null = null
let shutdownRequested = false
export interface ImageMessageReference {
messageId: string
sessionId: string
imageMd5?: string
imageDatName?: string
createTime?: number
}
const imageMessageReferences = new Map<string, ImageMessageReference | null>()
export function setChatDb(db: WechatDb | null): boolean {
if (shutdownRequested) {
db?.close()
@@ -129,9 +144,17 @@ export function setChatDb(db: WechatDb | null): boolean {
}
dbRef?.close()
dbRef = db
imageMessageReferences.clear()
return true
}
export function getImageMessageReference(messageId: string): ImageMessageReference | null {
if (!dbRef) return null
const normalizedId = String(messageId || '').trim()
if (!normalizedId) return null
return imageMessageReferences.get(normalizedId) || null
}
export async function closeChatDbForQuit(): Promise<boolean> {
shutdownRequested = true
const current = dbRef
@@ -405,10 +428,44 @@ function listSourceMessages(
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
return {
id: recoveredFromRecallJournal
const messageId = String(
recoveredFromRecallJournal
? `recovered:${msg.mesLocalID || msg.serverId || createTime}`
: msg.mesLocalID || Math.random().toString(),
: msg.mesLocalID || Math.random().toString()
)
const imageContent = contentData?.type === 'image' ? contentData : undefined
const media = imageContent
? {
type: 'image' as const,
available: Boolean(imageContent.md5 || imageContent.datName),
url: `/api/v1/media/${encodeURIComponent(messageId)}`
}
: undefined
if (imageContent && media) {
const reference: ImageMessageReference = {
messageId,
sessionId: username || '',
imageMd5: imageContent.md5,
imageDatName: imageContent.datName,
createTime
}
const previous = imageMessageReferences.get(messageId)
if (
previous &&
(previous.sessionId !== reference.sessionId ||
previous.imageMd5 !== reference.imageMd5 ||
previous.imageDatName !== reference.imageDatName)
) {
// Local message ids can repeat between conversations. Never resolve an
// ambiguous id to the wrong account or image.
imageMessageReferences.set(messageId, null)
} else if (previous !== null) {
imageMessageReferences.set(messageId, reference)
}
}
return {
id: messageId,
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
isSender: isMine,
type: displayType,
@@ -422,7 +479,8 @@ function listSourceMessages(
serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined,
createTime,
recoveredFromRecallJournal,
contentData
contentData,
media
}
})
+5
View File
@@ -24,6 +24,11 @@ export interface Message {
name?: string
senderId?: string
contentData?: ParsedContent
media?: {
type: 'image'
available: boolean
url: string
}
voiceDataUrl?: string
voiceDuration?: number
voiceTranscript?: string
+71 -3
View File
@@ -12,6 +12,19 @@ const fixture = vi.hoisted(() => ({
type: 'user' as const
}
],
chatlogMessages: [
{
id: 'message:1',
type: '图片',
content: '',
contentData: { type: 'image', md5: 'fixture-md5', aeskey: 'do-not-return' },
media: {
type: 'image',
available: true,
url: '/api/v1/media/message%3A1'
}
}
],
testSend: vi.fn(async () => ({ success: true, status: 'sent' }))
}))
@@ -27,7 +40,7 @@ vi.mock('electron', () => ({
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => true,
listContacts: () => fixture.contacts,
listMessages: () => [],
listMessages: () => fixture.chatlogMessages,
getGroupSnapshot: () => ({ members: [] }),
listRecentChat: () => [],
resolveMd5: () => fixture.contacts[0]
@@ -55,6 +68,7 @@ vi.mock('../../src/main/services/agent-hub-service', () => ({
import { apiTokenStore } from '../../src/main/api-token-store'
import { apiServer, startHttpServer, type HttpServerHandle } from '../../src/main/http-server'
import { HttpMediaError } from '../../src/main/http-media-service'
import {
buildLocalApiCurlCommand,
testLocalApiRequest
@@ -68,9 +82,10 @@ function baseUrl(handle: HttpServerHandle): string {
}
async function startFixtureServer(
tokenProvider = (): string => VALID_TOKEN
tokenProvider = (): string => VALID_TOKEN,
mediaProvider?: (messageId: string) => Promise<{ buffer: Buffer; mimeType: string }>
): Promise<HttpServerHandle> {
const handle = await startHttpServer('127.0.0.1', 0, { tokenProvider })
const handle = await startHttpServer('127.0.0.1', 0, { tokenProvider, mediaProvider })
handles.push(handle)
return handle
}
@@ -118,6 +133,7 @@ describe('Local API authentication', () => {
['GET', '/api/v1/chatroom'],
['GET', '/api/v1/recent_chat'],
['GET', '/api/v1/chatlog'],
['GET', '/api/v1/media/message-1'],
['GET', '/api/v1/group_snapshot'],
['GET', '/api/v1/resolve'],
['POST', '/api/v1/report'],
@@ -133,6 +149,58 @@ describe('Local API authentication', () => {
expect(response.status).toBe(401)
})
it('returns image bytes from the authenticated media route without exposing a path', async () => {
const provider = vi.fn(async (messageId: string) => {
expect(messageId).toBe('message:1')
return { buffer: Buffer.from([0xff, 0xd8, 0xff, 0xd9]), mimeType: 'image/jpeg' }
})
const handle = await startFixtureServer(() => VALID_TOKEN, provider)
const response = await fetch(
`${baseUrl(handle)}/api/v1/media/${encodeURIComponent('message:1')}`,
{
headers: { Authorization: `Bearer ${VALID_TOKEN}` }
}
)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toContain('image/jpeg')
expect(Buffer.from(await response.arrayBuffer())).toEqual(Buffer.from([0xff, 0xd8, 0xff, 0xd9]))
expect(provider).toHaveBeenCalledOnce()
expect(JSON.stringify(response.headers)).not.toMatch(/path|token|database/i)
})
it('adds media metadata to chatlog while redacting image keys', async () => {
const handle = await startFixtureServer()
const response = await fetch(`${baseUrl(handle)}/api/v1/chatlog?talker=测试联系人`, {
headers: { Authorization: `Bearer ${VALID_TOKEN}` }
})
expect(response.status).toBe(200)
const body = await response.json()
expect(body.messages[0]).toMatchObject({
id: 'message:1',
media: {
type: 'image',
available: true,
url: '/api/v1/media/message%3A1'
}
})
expect(body.messages[0].contentData).not.toHaveProperty('aeskey')
})
it('maps media lookup failures to stable API statuses', async () => {
const handle = await startFixtureServer(
() => VALID_TOKEN,
async () => {
throw new HttpMediaError('NOT_IMAGE', '消息不是可读取的图片消息')
}
)
const response = await fetch(`${baseUrl(handle)}/api/v1/media/message-1`, {
headers: { Authorization: `Bearer ${VALID_TOKEN}` }
})
expect(response.status).toBe(422)
await expect(response.json()).resolves.toMatchObject({ status: 422 })
})
it.each(['Basic xxx', 'Bearer', 'bearer xxx', 'Bearer xxx', 'xxx'])(
'rejects the invalid Authorization format %s',
async (authorization) => {
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest'
const fixture = vi.hoisted(() => ({
ready: true,
reference: {
messageId: 'message-1',
sessionId: 'wxid_fixture',
imageMd5: '0123456789abcdef0123456789abcdef',
createTime: 1_756_000_000
},
findImageFileAsync: vi.fn(async () => '/private/fixture/image.dat'),
decryptImageToBase64WithFallbackAsync: vi.fn(async () => ({
data: 'data:image/png;base64,iVBORw0KGgo=',
filePath: '/private/fixture/image.dat'
}))
}))
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => fixture.ready,
getChatDb: () => ({ getWcdb4Client: () => ({}) }),
getImageMessageReference: (messageId: string) =>
messageId === fixture.reference.messageId ? fixture.reference : null
}))
vi.mock('../../src/main/services/settings-store', () => ({
loadSettings: () => ({ dbRoot: '/private/fixture' })
}))
vi.mock('../../src/main/services/image-key-config-service', () => ({
ImageKeyConfigService: class {
getConfig(): { xorKey: string; aesKey: string } {
return { xorKey: '0x40', aesKey: 'fixture-aes-key' }
}
}
}))
vi.mock('../../src/main/image-decrypt-service', () => ({
ImageDecryptService: class {
findImageFileAsync = fixture.findImageFileAsync
decryptImageToBase64WithFallbackAsync = fixture.decryptImageToBase64WithFallbackAsync
}
}))
import { HttpMediaError, readImageMedia } from '../../src/main/http-media-service'
describe('HTTP media service', () => {
it('resolves a registered image message through the existing decrypt service', async () => {
await expect(readImageMedia('message-1')).resolves.toEqual({
mimeType: 'image/png',
buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
})
expect(fixture.findImageFileAsync).toHaveBeenCalledWith(
fixture.reference.imageMd5,
undefined,
expect.objectContaining({
allowThumbnail: false,
sessionId: fixture.reference.sessionId,
createTime: fixture.reference.createTime
})
)
})
it('does not resolve an unregistered message id', async () => {
await expect(readImageMedia('unknown')).rejects.toMatchObject<HttpMediaError>({
code: 'NOT_FOUND'
})
})
it('reports database readiness separately', async () => {
fixture.ready = false
await expect(readImageMedia('message-1')).rejects.toMatchObject<HttpMediaError>({
code: 'NOT_READY'
})
fixture.ready = true
})
})