diff --git a/electron/main.ts b/electron/main.ts index b6637a4..92564f8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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() + } + }) + } // 主窗口引用 diff --git a/electron/preload.ts b/electron/preload.ts index ce5e94f..715d548 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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') } }) diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index a4a31d5..bc4d1ed 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -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) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 2a38eb4..22670d0 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -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) diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts index a4305c3..a80c9e6 100644 --- a/electron/services/dualReportService.ts +++ b/electron/services/dualReportService.ts @@ -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) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 86b7d77..b9823a5 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -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() + + // 将群昵称更新到 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 diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts new file mode 100644 index 0000000..e620003 --- /dev/null +++ b/electron/services/httpService.ts @@ -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 = 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 { + 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 { + // 设置 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 { + 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 { + 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 { + 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> { + 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 { + const isGroup = talkerId.endsWith('@chatroom') + const myWxid = this.configService.get('myWxid') || '' + + // 收集所有发送者 + const senderSet = new Set() + for (const msg of messages) { + if (msg.senderUsername) { + senderSet.add(msg.senderUsername) + } + } + + // 获取发送者显示名 + const senderNames = await this.getDisplayNames(Array.from(senderSet)) + + // 获取群昵称(如果是群聊) + let groupNicknamesMap = new Map() + 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() + 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() diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 2e9e66f..eaecd91 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -1855,4 +1855,131 @@ to { 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; + } } \ No newline at end of file diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index d3d4adf..469cdd0 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -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() { ) + // 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 = () => ( +
+
+
+
+ + 启用后可通过 HTTP 接口查询消息数据 +
+
+ + {httpApiRunning ? '运行中' : '已停止'} + + +
+
+
+ +
+ +
+ + API 服务监听的端口号 +
+ setHttpApiPort(parseInt(e.target.value, 10) || 5031)} + disabled={httpApiRunning} + style={{ width: 120 }} + min={1024} + max={65535} + /> + + {httpApiRunning ? '停止服务后可修改端口' : '建议使用 1024-65535 之间的端口'} + +
+
+ +
+ +
+ + 使用以下地址访问 API +
+ http://127.0.0.1:{httpApiPort} + +
+
+ +
+ +
+ + 支持的 API 接口列表 +
+
+
+ GET + /api/v1/messages +
+

获取消息列表,支持 ChatLab 格式输出

+
+ talker - 会话ID(必填) + limit - 数量限制 + start - 开始时间 (YYYYMMDD) + end - 结束时间 (YYYYMMDD) + chatlab=1 - 输出 ChatLab 格式 +
+
+
+
+ GET + /api/v1/sessions +
+

获取会话列表

+
+
+
+ GET + /api/v1/contacts +
+

获取联系人列表

+
+
+
+ GET + /health +
+

健康检查

+
+
+
+ +
+ +
+ +
+ GET http://127.0.0.1:{httpApiPort}/api/v1/messages?talker=wxid_xxx&limit=100&chatlab=1 +
+
+
+ ) + 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()}