mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
尝试新增api 优化导出
This commit is contained in:
@@ -23,6 +23,7 @@ import { contactExportService } from './services/contactExportService'
|
||||
import { windowsHelloService } from './services/windowsHelloService'
|
||||
import { llamaService } from './services/llamaService'
|
||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { httpService } from './services/httpService'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
@@ -1282,6 +1283,23 @@ function registerIpcHandlers() {
|
||||
})
|
||||
})
|
||||
|
||||
// HTTP API 服务
|
||||
ipcMain.handle('http:start', async (_, port?: number) => {
|
||||
return httpService.start(port || 5031)
|
||||
})
|
||||
|
||||
ipcMain.handle('http:stop', async () => {
|
||||
await httpService.stop()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('http:status', async () => {
|
||||
return {
|
||||
running: httpService.isRunning(),
|
||||
port: httpService.getPort()
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// 主窗口引用
|
||||
|
||||
@@ -286,5 +286,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('llama:downloadProgress', listener)
|
||||
return () => ipcRenderer.removeListener('llama:downloadProgress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
// HTTP API 服务
|
||||
http: {
|
||||
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
||||
stop: () => ipcRenderer.invoke('http:stop'),
|
||||
status: () => ipcRenderer.invoke('http:status')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -197,7 +197,9 @@ class AnnualReportService {
|
||||
const bytes = Buffer.from(raw, 'hex')
|
||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||
}
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||
try {
|
||||
const bytes = Buffer.from(raw, 'base64')
|
||||
return this.decodeBinaryContent(bytes)
|
||||
|
||||
@@ -2237,7 +2237,9 @@ class ChatService {
|
||||
}
|
||||
|
||||
// 检查是否是 base64 编码
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||
try {
|
||||
const bytes = Buffer.from(raw, 'base64')
|
||||
return this.decodeBinaryContent(bytes, raw)
|
||||
|
||||
@@ -110,7 +110,9 @@ class DualReportService {
|
||||
const bytes = Buffer.from(raw, 'hex')
|
||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||
}
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||
try {
|
||||
const bytes = Buffer.from(raw, 'base64')
|
||||
return this.decodeBinaryContent(bytes)
|
||||
|
||||
@@ -274,7 +274,9 @@ class ExportService {
|
||||
const bytes = Buffer.from(raw, 'hex')
|
||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||
}
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||
try {
|
||||
const bytes = Buffer.from(raw, 'base64')
|
||||
return this.decodeBinaryContent(bytes)
|
||||
@@ -1849,6 +1851,24 @@ class ExportService {
|
||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||
}
|
||||
|
||||
// ========== 获取群昵称并更新到 memberSet ==========
|
||||
const groupNicknamesMap = isGroup
|
||||
? await this.getGroupNicknamesForRoom(sessionId)
|
||||
: new Map<string, string>()
|
||||
|
||||
// 将群昵称更新到 memberSet 中
|
||||
if (isGroup && groupNicknamesMap.size > 0) {
|
||||
for (const [username, info] of collected.memberSet) {
|
||||
// 尝试多种方式查找群昵称(支持大小写)
|
||||
const groupNickname = groupNicknamesMap.get(username)
|
||||
|| groupNicknamesMap.get(username.toLowerCase())
|
||||
|| ''
|
||||
if (groupNickname) {
|
||||
info.member.groupNickname = groupNickname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allMessages.sort((a, b) => a.createTime - b.createTime)
|
||||
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
@@ -1925,6 +1945,11 @@ class ExportService {
|
||||
groupNickname: undefined
|
||||
}
|
||||
|
||||
// 如果 memberInfo 中没有群昵称,尝试从 groupNicknamesMap 获取
|
||||
const groupNickname = memberInfo.groupNickname
|
||||
|| (isGroup ? (groupNicknamesMap.get(msg.senderUsername) || groupNicknamesMap.get(msg.senderUsername?.toLowerCase()) || '') : '')
|
||||
|| ''
|
||||
|
||||
// 确定消息内容
|
||||
let content: string | null
|
||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
@@ -1937,7 +1962,7 @@ class ExportService {
|
||||
const message: ChatLabMessage = {
|
||||
sender: msg.senderUsername,
|
||||
accountName: memberInfo.accountName,
|
||||
groupNickname: memberInfo.groupNickname,
|
||||
groupNickname: groupNickname || undefined,
|
||||
timestamp: msg.createTime,
|
||||
type: this.convertMessageType(msg.localType, msg.content),
|
||||
content: content
|
||||
|
||||
584
electron/services/httpService.ts
Normal file
584
electron/services/httpService.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* HTTP API 服务
|
||||
* 提供 ChatLab 标准化格式的消息查询 API
|
||||
*/
|
||||
import * as http from 'http'
|
||||
import { URL } from 'url'
|
||||
import { chatService, Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
// ChatLab 格式定义
|
||||
interface ChatLabHeader {
|
||||
version: string
|
||||
exportedAt: number
|
||||
generator: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ChatLabMeta {
|
||||
name: string
|
||||
platform: string
|
||||
type: 'group' | 'private'
|
||||
groupId?: string
|
||||
groupAvatar?: string
|
||||
ownerId?: string
|
||||
}
|
||||
|
||||
interface ChatLabMember {
|
||||
platformId: string
|
||||
accountName: string
|
||||
groupNickname?: string
|
||||
aliases?: string[]
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
interface ChatLabMessage {
|
||||
sender: string
|
||||
accountName: string
|
||||
groupNickname?: string
|
||||
timestamp: number
|
||||
type: number
|
||||
content: string | null
|
||||
platformMessageId?: string
|
||||
replyToMessageId?: string
|
||||
}
|
||||
|
||||
interface ChatLabData {
|
||||
chatlab: ChatLabHeader
|
||||
meta: ChatLabMeta
|
||||
members: ChatLabMember[]
|
||||
messages: ChatLabMessage[]
|
||||
}
|
||||
|
||||
// ChatLab 消息类型映射
|
||||
const ChatLabType = {
|
||||
TEXT: 0,
|
||||
IMAGE: 1,
|
||||
VOICE: 2,
|
||||
VIDEO: 3,
|
||||
FILE: 4,
|
||||
EMOJI: 5,
|
||||
LINK: 7,
|
||||
LOCATION: 8,
|
||||
RED_PACKET: 20,
|
||||
TRANSFER: 21,
|
||||
POKE: 22,
|
||||
CALL: 23,
|
||||
SHARE: 24,
|
||||
REPLY: 25,
|
||||
FORWARD: 26,
|
||||
CONTACT: 27,
|
||||
SYSTEM: 80,
|
||||
RECALL: 81,
|
||||
OTHER: 99
|
||||
} as const
|
||||
|
||||
class HttpService {
|
||||
private server: http.Server | null = null
|
||||
private configService: ConfigService
|
||||
private port: number = 5031
|
||||
private running: boolean = false
|
||||
private connections: Set<import('net').Socket> = new Set()
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 HTTP 服务
|
||||
*/
|
||||
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
|
||||
if (this.running && this.server) {
|
||||
return { success: true, port: this.port }
|
||||
}
|
||||
|
||||
this.port = port
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res))
|
||||
|
||||
// 跟踪所有连接,以便关闭时能强制断开
|
||||
this.server.on('connection', (socket) => {
|
||||
this.connections.add(socket)
|
||||
socket.on('close', () => {
|
||||
this.connections.delete(socket)
|
||||
})
|
||||
})
|
||||
|
||||
this.server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[HttpService] Port ${this.port} is already in use`)
|
||||
resolve({ success: false, error: `Port ${this.port} is already in use` })
|
||||
} else {
|
||||
console.error('[HttpService] Server error:', err)
|
||||
resolve({ success: false, error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => {
|
||||
this.running = true
|
||||
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
||||
resolve({ success: true, port: this.port })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 HTTP 服务
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
// 强制关闭所有活动连接
|
||||
for (const socket of this.connections) {
|
||||
socket.destroy()
|
||||
}
|
||||
this.connections.clear()
|
||||
|
||||
this.server.close(() => {
|
||||
this.running = false
|
||||
this.server = null
|
||||
console.log('[HttpService] HTTP API server stopped')
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
this.running = false
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查服务是否运行
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.running
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前端口
|
||||
*/
|
||||
getPort(): number {
|
||||
return this.port
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// 设置 CORS 头
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
|
||||
const pathname = url.pathname
|
||||
|
||||
try {
|
||||
// 路由处理
|
||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||
this.sendJson(res, { status: 'ok' })
|
||||
} else if (pathname === '/api/v1/messages') {
|
||||
await this.handleMessages(url, res)
|
||||
} else if (pathname === '/api/v1/sessions') {
|
||||
await this.handleSessions(url, res)
|
||||
} else if (pathname === '/api/v1/contacts') {
|
||||
await this.handleContacts(url, res)
|
||||
} else {
|
||||
this.sendError(res, 404, 'Not Found')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HttpService] Request error:', error)
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息查询
|
||||
* GET /api/v1/messages?talker=xxx&limit=100&start=20260101&chatlab=1
|
||||
*/
|
||||
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const talker = url.searchParams.get('talker')
|
||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
|
||||
const startParam = url.searchParams.get('start')
|
||||
const endParam = url.searchParams.get('end')
|
||||
const chatlab = url.searchParams.get('chatlab') === '1'
|
||||
const format = url.searchParams.get('format') || (chatlab ? 'chatlab' : 'json')
|
||||
|
||||
if (!talker) {
|
||||
this.sendError(res, 400, 'Missing required parameter: talker')
|
||||
return
|
||||
}
|
||||
|
||||
// 解析时间参数 (支持 YYYYMMDD 格式)
|
||||
const startTime = this.parseTimeParam(startParam)
|
||||
const endTime = this.parseTimeParam(endParam, true)
|
||||
|
||||
// 获取消息
|
||||
const result = await chatService.getMessages(talker, offset, limit, startTime, endTime, true)
|
||||
if (!result.success || !result.messages) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||
return
|
||||
}
|
||||
|
||||
if (format === 'chatlab') {
|
||||
// 获取会话显示名
|
||||
const displayNames = await this.getDisplayNames([talker])
|
||||
const talkerName = displayNames[talker] || talker
|
||||
|
||||
const chatLabData = await this.convertToChatLab(result.messages, talker, talkerName)
|
||||
this.sendJson(res, chatLabData)
|
||||
} else {
|
||||
// 返回原始消息格式
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
talker,
|
||||
count: result.messages.length,
|
||||
hasMore: result.hasMore,
|
||||
messages: result.messages
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理会话列表查询
|
||||
* GET /api/v1/sessions?keyword=xxx&limit=100
|
||||
*/
|
||||
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
|
||||
|
||||
try {
|
||||
const sessions = await chatService.getSessions()
|
||||
if (!sessions.success || !sessions.sessions) {
|
||||
this.sendError(res, 500, sessions.error || 'Failed to get sessions')
|
||||
return
|
||||
}
|
||||
|
||||
let filteredSessions = sessions.sessions
|
||||
if (keyword) {
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
filteredSessions = sessions.sessions.filter(s =>
|
||||
s.username.toLowerCase().includes(lowerKeyword) ||
|
||||
(s.displayName && s.displayName.toLowerCase().includes(lowerKeyword))
|
||||
)
|
||||
}
|
||||
|
||||
// 应用 limit
|
||||
const limitedSessions = filteredSessions.slice(0, limit)
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
count: limitedSessions.length,
|
||||
sessions: limitedSessions.map(s => ({
|
||||
username: s.username,
|
||||
displayName: s.displayName,
|
||||
type: s.type,
|
||||
lastTimestamp: s.lastTimestamp,
|
||||
unreadCount: s.unreadCount
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理联系人查询
|
||||
* GET /api/v1/contacts?keyword=xxx&limit=100
|
||||
*/
|
||||
private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
|
||||
|
||||
try {
|
||||
const contacts = await chatService.getContacts()
|
||||
if (!contacts.success || !contacts.contacts) {
|
||||
this.sendError(res, 500, contacts.error || 'Failed to get contacts')
|
||||
return
|
||||
}
|
||||
|
||||
let filteredContacts = contacts.contacts
|
||||
if (keyword) {
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
filteredContacts = contacts.contacts.filter(c =>
|
||||
c.username.toLowerCase().includes(lowerKeyword) ||
|
||||
(c.nickname && c.nickname.toLowerCase().includes(lowerKeyword)) ||
|
||||
(c.remark && c.remark.toLowerCase().includes(lowerKeyword)) ||
|
||||
(c.displayName && c.displayName.toLowerCase().includes(lowerKeyword))
|
||||
)
|
||||
}
|
||||
|
||||
const limited = filteredContacts.slice(0, limit)
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
count: limited.length,
|
||||
contacts: limited
|
||||
})
|
||||
} catch (error) {
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间参数
|
||||
* 支持 YYYYMMDD 格式,返回秒级时间戳
|
||||
*/
|
||||
private parseTimeParam(param: string | null, isEnd: boolean = false): number {
|
||||
if (!param) return 0
|
||||
|
||||
// 纯数字且长度为8,视为 YYYYMMDD
|
||||
if (/^\d{8}$/.test(param)) {
|
||||
const year = parseInt(param.slice(0, 4), 10)
|
||||
const month = parseInt(param.slice(4, 6), 10) - 1
|
||||
const day = parseInt(param.slice(6, 8), 10)
|
||||
const date = new Date(year, month, day)
|
||||
if (isEnd) {
|
||||
// 结束时间设为当天 23:59:59
|
||||
date.setHours(23, 59, 59, 999)
|
||||
}
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
// 纯数字,视为时间戳
|
||||
if (/^\d+$/.test(param)) {
|
||||
const ts = parseInt(param, 10)
|
||||
// 如果是毫秒级时间戳,转为秒级
|
||||
return ts > 10000000000 ? Math.floor(ts / 1000) : ts
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取显示名称
|
||||
*/
|
||||
private async getDisplayNames(usernames: string[]): Promise<Record<string, string>> {
|
||||
try {
|
||||
const result = await wcdbService.getDisplayNames(usernames)
|
||||
if (result.success && result.map) {
|
||||
return result.map
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HttpService] Failed to get display names:', e)
|
||||
}
|
||||
// 返回空对象,调用方会使用 username 作为备用
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 ChatLab 格式
|
||||
*/
|
||||
private async convertToChatLab(messages: Message[], talkerId: string, talkerName: string): Promise<ChatLabData> {
|
||||
const isGroup = talkerId.endsWith('@chatroom')
|
||||
const myWxid = this.configService.get('myWxid') || ''
|
||||
|
||||
// 收集所有发送者
|
||||
const senderSet = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
if (msg.senderUsername) {
|
||||
senderSet.add(msg.senderUsername)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取发送者显示名
|
||||
const senderNames = await this.getDisplayNames(Array.from(senderSet))
|
||||
|
||||
// 获取群昵称(如果是群聊)
|
||||
let groupNicknamesMap = new Map<string, string>()
|
||||
if (isGroup) {
|
||||
try {
|
||||
const result = await wcdbService.getGroupNicknames(talkerId)
|
||||
if (result.success && result.nicknames) {
|
||||
groupNicknamesMap = new Map(Object.entries(result.nicknames))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HttpService] Failed to get group nicknames:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建成员列表
|
||||
const memberMap = new Map<string, ChatLabMember>()
|
||||
for (const msg of messages) {
|
||||
const sender = msg.senderUsername || ''
|
||||
if (sender && !memberMap.has(sender)) {
|
||||
const displayName = senderNames[sender] || sender
|
||||
const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase()
|
||||
// 获取群昵称(尝试多种方式)
|
||||
const groupNickname = isGroup
|
||||
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
||||
: ''
|
||||
memberMap.set(sender, {
|
||||
platformId: sender,
|
||||
accountName: isSelf ? '我' : displayName,
|
||||
groupNickname: groupNickname || undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 转换消息
|
||||
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
|
||||
const sender = msg.senderUsername || ''
|
||||
const isSelf = msg.isSend === 1 || sender === myWxid
|
||||
const accountName = isSelf ? '我' : (senderNames[sender] || sender)
|
||||
// 获取该发送者的群昵称
|
||||
const groupNickname = isGroup
|
||||
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
||||
: ''
|
||||
|
||||
return {
|
||||
sender,
|
||||
accountName,
|
||||
groupNickname: groupNickname || undefined,
|
||||
timestamp: msg.createTime,
|
||||
type: this.mapMessageType(msg.localType, msg),
|
||||
content: this.getMessageContent(msg),
|
||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
chatlab: {
|
||||
version: '0.0.2',
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
generator: 'WeFlow'
|
||||
},
|
||||
meta: {
|
||||
name: talkerName,
|
||||
platform: 'wechat',
|
||||
type: isGroup ? 'group' : 'private',
|
||||
groupId: isGroup ? talkerId : undefined,
|
||||
ownerId: myWxid || undefined
|
||||
},
|
||||
members: Array.from(memberMap.values()),
|
||||
messages: chatLabMessages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射 WeChat 消息类型到 ChatLab 类型
|
||||
*/
|
||||
private mapMessageType(localType: number, msg: Message): number {
|
||||
switch (localType) {
|
||||
case 1: // 文本
|
||||
return ChatLabType.TEXT
|
||||
case 3: // 图片
|
||||
return ChatLabType.IMAGE
|
||||
case 34: // 语音
|
||||
return ChatLabType.VOICE
|
||||
case 43: // 视频
|
||||
return ChatLabType.VIDEO
|
||||
case 47: // 动画表情
|
||||
return ChatLabType.EMOJI
|
||||
case 48: // 位置
|
||||
return ChatLabType.LOCATION
|
||||
case 42: // 名片
|
||||
return ChatLabType.CONTACT
|
||||
case 50: // 语音/视频通话
|
||||
return ChatLabType.CALL
|
||||
case 10000: // 系统消息
|
||||
return ChatLabType.SYSTEM
|
||||
case 49: // 复合消息
|
||||
return this.mapType49(msg)
|
||||
case 244813135921: // 引用消息
|
||||
return ChatLabType.REPLY
|
||||
case 266287972401: // 拍一拍
|
||||
return ChatLabType.POKE
|
||||
case 8594229559345: // 红包
|
||||
return ChatLabType.RED_PACKET
|
||||
case 8589934592049: // 转账
|
||||
return ChatLabType.TRANSFER
|
||||
default:
|
||||
return ChatLabType.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射 Type 49 子类型
|
||||
*/
|
||||
private mapType49(msg: Message): number {
|
||||
const xmlType = msg.xmlType
|
||||
|
||||
switch (xmlType) {
|
||||
case '5': // 链接
|
||||
case '49':
|
||||
return ChatLabType.LINK
|
||||
case '6': // 文件
|
||||
return ChatLabType.FILE
|
||||
case '19': // 聊天记录
|
||||
return ChatLabType.FORWARD
|
||||
case '33': // 小程序
|
||||
case '36':
|
||||
return ChatLabType.SHARE
|
||||
case '57': // 引用消息
|
||||
return ChatLabType.REPLY
|
||||
case '2000': // 转账
|
||||
return ChatLabType.TRANSFER
|
||||
case '2001': // 红包
|
||||
return ChatLabType.RED_PACKET
|
||||
default:
|
||||
return ChatLabType.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息内容
|
||||
*/
|
||||
private getMessageContent(msg: Message): string | null {
|
||||
// 优先使用已解析的内容
|
||||
if (msg.parsedContent) {
|
||||
return msg.parsedContent
|
||||
}
|
||||
|
||||
// 根据类型返回占位符
|
||||
switch (msg.localType) {
|
||||
case 1:
|
||||
return msg.rawContent || null
|
||||
case 3:
|
||||
return msg.imageMd5 || '[图片]'
|
||||
case 34:
|
||||
return '[语音]'
|
||||
case 43:
|
||||
return msg.videoMd5 || '[视频]'
|
||||
case 47:
|
||||
return msg.emojiCdnUrl || msg.emojiMd5 || '[表情]'
|
||||
case 42:
|
||||
return msg.cardNickname || '[名片]'
|
||||
case 48:
|
||||
return '[位置]'
|
||||
case 49:
|
||||
return msg.linkTitle || msg.fileName || '[消息]'
|
||||
default:
|
||||
return msg.rawContent || null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 JSON 响应
|
||||
*/
|
||||
private sendJson(res: http.ServerResponse, data: any): void {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.writeHead(200)
|
||||
res.end(JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误响应
|
||||
*/
|
||||
private sendError(res: http.ServerResponse, code: number, message: string): void {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.writeHead(code)
|
||||
res.end(JSON.stringify({ error: message }))
|
||||
}
|
||||
}
|
||||
|
||||
export const httpService = new HttpService()
|
||||
@@ -1856,3 +1856,130 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// API 服务设置样式
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.running {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
&.stopped {
|
||||
background: rgba(156, 163, 175, 0.15);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.api-url {
|
||||
display: inline-block;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.api-docs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-item {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
.api-endpoint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.method {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.get {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
&.post {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.api-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.api-params {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.param {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
code {
|
||||
color: var(--primary);
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-block {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px !important;
|
||||
font-size: 12px !important;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon,
|
||||
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell
|
||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe
|
||||
} from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'security' | 'about'
|
||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about'
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
@@ -23,6 +23,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'models', label: '模型管理', icon: Mic },
|
||||
{ id: 'export', label: '导出', icon: Download },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'api', label: 'API 服务', icon: Globe },
|
||||
{ id: 'security', label: '安全', icon: ShieldCheck },
|
||||
{ id: 'about', label: '关于', icon: Info }
|
||||
]
|
||||
@@ -137,6 +138,12 @@ function SettingsPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isSettingHello, setIsSettingHello] = useState(false)
|
||||
|
||||
// HTTP API 设置 state
|
||||
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
||||
const [httpApiPort, setHttpApiPort] = useState(5031)
|
||||
const [httpApiRunning, setHttpApiRunning] = useState(false)
|
||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||
|
||||
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
||||
|
||||
// 检查 Hello 可用性
|
||||
@@ -146,6 +153,22 @@ function SettingsPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 检查 HTTP API 服务状态
|
||||
useEffect(() => {
|
||||
const checkApiStatus = async () => {
|
||||
try {
|
||||
const status = await window.electronAPI.http.status()
|
||||
setHttpApiRunning(status.running)
|
||||
if (status.port) {
|
||||
setHttpApiPort(status.port)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查 API 状态失败:', e)
|
||||
}
|
||||
}
|
||||
checkApiStatus()
|
||||
}, [])
|
||||
|
||||
async function sha256(message: string) {
|
||||
const msgBuffer = new TextEncoder().encode(message)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
|
||||
@@ -1835,6 +1858,151 @@ function SettingsPage() {
|
||||
</div>
|
||||
)
|
||||
|
||||
// HTTP API 服务控制
|
||||
const handleToggleApi = async () => {
|
||||
if (isTogglingApi) return
|
||||
setIsTogglingApi(true)
|
||||
try {
|
||||
if (httpApiRunning) {
|
||||
await window.electronAPI.http.stop()
|
||||
setHttpApiRunning(false)
|
||||
showMessage('API 服务已停止', true)
|
||||
} else {
|
||||
const result = await window.electronAPI.http.start(httpApiPort)
|
||||
if (result.success) {
|
||||
setHttpApiRunning(true)
|
||||
if (result.port) setHttpApiPort(result.port)
|
||||
showMessage(`API 服务已启动,端口 ${result.port}`, true)
|
||||
} else {
|
||||
showMessage(`启动失败: ${result.error}`, false)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
showMessage(`操作失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsTogglingApi(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyApiUrl = () => {
|
||||
const url = `http://127.0.0.1:${httpApiPort}`
|
||||
navigator.clipboard.writeText(url)
|
||||
showMessage('已复制 API 地址', true)
|
||||
}
|
||||
|
||||
const renderApiTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<label>HTTP API 服务</label>
|
||||
<span className="form-hint">启用后可通过 HTTP 接口查询消息数据</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span className={`status-badge ${httpApiRunning ? 'running' : 'stopped'}`}>
|
||||
{httpApiRunning ? '运行中' : '已停止'}
|
||||
</span>
|
||||
<button
|
||||
className={`btn ${httpApiRunning ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={handleToggleApi}
|
||||
disabled={isTogglingApi}
|
||||
>
|
||||
{isTogglingApi ? '处理中...' : (httpApiRunning ? '停止服务' : '启动服务')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>服务端口</label>
|
||||
<span className="form-hint">API 服务监听的端口号</span>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 10 }}>
|
||||
<input
|
||||
type="number"
|
||||
className="field-input"
|
||||
value={httpApiPort}
|
||||
onChange={(e) => setHttpApiPort(parseInt(e.target.value, 10) || 5031)}
|
||||
disabled={httpApiRunning}
|
||||
style={{ width: 120 }}
|
||||
min={1024}
|
||||
max={65535}
|
||||
/>
|
||||
<span className="form-hint" style={{ alignSelf: 'center' }}>
|
||||
{httpApiRunning ? '停止服务后可修改端口' : '建议使用 1024-65535 之间的端口'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>API 地址</label>
|
||||
<span className="form-hint">使用以下地址访问 API</span>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 10, alignItems: 'center' }}>
|
||||
<code className="api-url">http://127.0.0.1:{httpApiPort}</code>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleCopyApiUrl}>
|
||||
<Copy size={14} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>接口文档</label>
|
||||
<span className="form-hint">支持的 API 接口列表</span>
|
||||
<div className="api-docs" style={{ marginTop: 12 }}>
|
||||
<div className="api-item">
|
||||
<div className="api-endpoint">
|
||||
<span className="method get">GET</span>
|
||||
<code>/api/v1/messages</code>
|
||||
</div>
|
||||
<p className="api-desc">获取消息列表,支持 ChatLab 格式输出</p>
|
||||
<div className="api-params">
|
||||
<span className="param"><code>talker</code> - 会话ID(必填)</span>
|
||||
<span className="param"><code>limit</code> - 数量限制</span>
|
||||
<span className="param"><code>start</code> - 开始时间 (YYYYMMDD)</span>
|
||||
<span className="param"><code>end</code> - 结束时间 (YYYYMMDD)</span>
|
||||
<span className="param"><code>chatlab=1</code> - 输出 ChatLab 格式</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="api-item">
|
||||
<div className="api-endpoint">
|
||||
<span className="method get">GET</span>
|
||||
<code>/api/v1/sessions</code>
|
||||
</div>
|
||||
<p className="api-desc">获取会话列表</p>
|
||||
</div>
|
||||
<div className="api-item">
|
||||
<div className="api-endpoint">
|
||||
<span className="method get">GET</span>
|
||||
<code>/api/v1/contacts</code>
|
||||
</div>
|
||||
<p className="api-desc">获取联系人列表</p>
|
||||
</div>
|
||||
<div className="api-item">
|
||||
<div className="api-endpoint">
|
||||
<span className="method get">GET</span>
|
||||
<code>/health</code>
|
||||
</div>
|
||||
<p className="api-desc">健康检查</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>示例请求</label>
|
||||
<div className="code-block" style={{ marginTop: 10 }}>
|
||||
<code>GET http://127.0.0.1:{httpApiPort}/api/v1/messages?talker=wxid_xxx&limit=100&chatlab=1</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleSetupHello = async () => {
|
||||
setIsSettingHello(true)
|
||||
try {
|
||||
@@ -2075,6 +2243,7 @@ function SettingsPage() {
|
||||
{activeTab === 'models' && renderModelsTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
{activeTab === 'api' && renderApiTab()}
|
||||
{activeTab === 'security' && renderSecurityTab()}
|
||||
{activeTab === 'about' && renderAboutTab()}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user