mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-21 15:10:52 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d419dbe9e | ||
|
|
ca1ef91bff | ||
|
|
482259953c | ||
|
|
26eac85908 | ||
|
|
9cd5947401 | ||
|
|
e9e3844e3b | ||
|
|
8129c1227b | ||
|
|
aa4e3388fc | ||
|
|
33bffc10bc | ||
|
|
a98e4af9a8 | ||
|
|
eaa9dbea73 | ||
|
|
046482fccd | ||
|
|
7e6ce2e0c5 | ||
|
|
e26c0fce91 | ||
|
|
abbab85f24 | ||
|
|
af9acb4a36 | ||
|
|
f43005ae34 |
@@ -173,6 +173,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
|||||||
- `content`
|
- `content`
|
||||||
- `rawContent`
|
- `rawContent`
|
||||||
- `parsedContent`
|
- `parsedContent`
|
||||||
|
- `replyToMessageId`(引用回复目标消息的 `serverId`,仅引用消息返回)
|
||||||
|
- `quote`(引用消息快照,包含被引用消息的 ID、发送者、内容和类型)
|
||||||
- `mediaType`
|
- `mediaType`
|
||||||
- `mediaFileName`
|
- `mediaFileName`
|
||||||
- `mediaUrl`
|
- `mediaUrl`
|
||||||
@@ -184,7 +186,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"talker": "xxx@chatroom",
|
"talker": "xxx@chatroom",
|
||||||
"count": 2,
|
"count": 3,
|
||||||
"hasMore": true,
|
"hasMore": true,
|
||||||
"media": {
|
"media": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -203,6 +205,25 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
|||||||
"rawContent": "你好",
|
"rawContent": "你好",
|
||||||
"parsedContent": "你好"
|
"parsedContent": "你好"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"localId": 125,
|
||||||
|
"serverId": "6116895530414915133",
|
||||||
|
"localType": 244813135921,
|
||||||
|
"createTime": 1738713700,
|
||||||
|
"isSend": 0,
|
||||||
|
"senderUsername": "wxid_member",
|
||||||
|
"content": "收到",
|
||||||
|
"rawContent": "<msg>...</msg>",
|
||||||
|
"parsedContent": "收到",
|
||||||
|
"replyToMessageId": "6116895530414915131",
|
||||||
|
"quote": {
|
||||||
|
"platformMessageId": "6116895530414915131",
|
||||||
|
"sender": "wxid_other",
|
||||||
|
"accountName": "张三",
|
||||||
|
"content": "你好",
|
||||||
|
"type": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"localId": 124,
|
"localId": 124,
|
||||||
"localType": 3,
|
"localType": 3,
|
||||||
@@ -243,6 +264,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
|||||||
- `messages[].type`
|
- `messages[].type`
|
||||||
- `messages[].content`
|
- `messages[].content`
|
||||||
- `messages[].platformMessageId`
|
- `messages[].platformMessageId`
|
||||||
|
- `messages[].replyToMessageId`
|
||||||
- `messages[].mediaPath`
|
- `messages[].mediaPath`
|
||||||
|
|
||||||
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
||||||
|
|||||||
73
electron/services/accountDirResolver.ts
Normal file
73
electron/services/accountDirResolver.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { existsSync, readdirSync, statSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const accountDirCache = new Map<string, string>()
|
||||||
|
|
||||||
|
const cleanAccountDirName = (dirName: string): string => {
|
||||||
|
const trimmed = dirName.trim()
|
||||||
|
if (!trimmed) return trimmed
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
|
if (match) return match[1]
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
|
if (suffixMatch) return suffixMatch[1]
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectory = (path: string): boolean => {
|
||||||
|
try {
|
||||||
|
return statSync(path).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null => {
|
||||||
|
if (!dbPath || !wxid) return null
|
||||||
|
|
||||||
|
const cleanedWxid = cleanAccountDirName(wxid)
|
||||||
|
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||||
|
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
|
||||||
|
|
||||||
|
const cached = accountDirCache.get(cacheKey)
|
||||||
|
if (cached && existsSync(cached)) return cached
|
||||||
|
if (cached && !existsSync(cached)) {
|
||||||
|
accountDirCache.delete(cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerWxid = cleanedWxid.toLowerCase()
|
||||||
|
if (!lowerWxid.startsWith('wxid_')) {
|
||||||
|
const direct = join(normalized, cleanedWxid)
|
||||||
|
if (existsSync(direct) && isDirectory(direct)) {
|
||||||
|
accountDirCache.set(cacheKey, direct)
|
||||||
|
return direct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(normalized)
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(normalized, entry)
|
||||||
|
if (!isDirectory(entryPath)) continue
|
||||||
|
|
||||||
|
const lowerEntry = entry.toLowerCase()
|
||||||
|
const isExactMatch = lowerEntry === lowerWxid
|
||||||
|
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
|
||||||
|
const shouldMatch = lowerWxid.startsWith('wxid_')
|
||||||
|
? isSuffixMatch
|
||||||
|
: (isExactMatch || isSuffixMatch)
|
||||||
|
|
||||||
|
if (shouldMatch) {
|
||||||
|
accountDirCache.set(cacheKey, entryPath)
|
||||||
|
return entryPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { parentPort } from 'worker_threads'
|
import { parentPort } from 'worker_threads'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { ConfigService } from './config'
|
import { resolveAccountDir } from './accountDirResolver'
|
||||||
|
|
||||||
export interface TopContact {
|
export interface TopContact {
|
||||||
username: string
|
username: string
|
||||||
@@ -159,8 +159,7 @@ class AnnualReportService {
|
|||||||
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
||||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||||
|
|
||||||
const configService = ConfigService.getInstance()
|
const accountDir = resolveAccountDir(dbPath, wxid)
|
||||||
const accountDir = configService.getAccountDir(dbPath, wxid)
|
|
||||||
if (!accountDir) return { success: false, error: '未找到账号目录' }
|
if (!accountDir) return { success: false, error: '未找到账号目录' }
|
||||||
|
|
||||||
const ok = await wcdbService.open(accountDir, decryptKey)
|
const ok = await wcdbService.open(accountDir, decryptKey)
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export class ConfigService {
|
|||||||
aiModelApiBaseUrl: '',
|
aiModelApiBaseUrl: '',
|
||||||
aiModelApiKey: '',
|
aiModelApiKey: '',
|
||||||
aiModelApiModel: 'gpt-4o-mini',
|
aiModelApiModel: 'gpt-4o-mini',
|
||||||
aiModelApiMaxTokens: 200,
|
aiModelApiMaxTokens: 1024,
|
||||||
aiInsightEnabled: false,
|
aiInsightEnabled: false,
|
||||||
aiInsightApiBaseUrl: '',
|
aiInsightApiBaseUrl: '',
|
||||||
aiInsightApiKey: '',
|
aiInsightApiKey: '',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { parentPort } from 'worker_threads'
|
import { parentPort } from 'worker_threads'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { resolveAccountDir } from './accountDirResolver'
|
||||||
|
|
||||||
|
|
||||||
export interface DualReportMessage {
|
export interface DualReportMessage {
|
||||||
@@ -109,11 +110,11 @@ class DualReportService {
|
|||||||
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
||||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||||
|
|
||||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
const accountDir = resolveAccountDir(dbPath, wxid)
|
||||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
|
||||||
if (!accountDir) return { success: false, error: '无法找到账号目录' }
|
if (!accountDir) return { success: false, error: '无法找到账号目录' }
|
||||||
const ok = await wcdbService.open(accountDir, decryptKey)
|
const ok = await wcdbService.open(accountDir, decryptKey)
|
||||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||||
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
return { success: true, cleanedWxid, rawWxid: wxid }
|
return { success: true, cleanedWxid, rawWxid: wxid }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,20 @@ interface ChatLabMessage {
|
|||||||
mediaPath?: string
|
mediaPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiQuoteSnapshot {
|
||||||
|
platformMessageId?: string
|
||||||
|
sender?: string
|
||||||
|
accountName?: string
|
||||||
|
content?: string
|
||||||
|
type?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiQuoteInfo {
|
||||||
|
replyText?: string
|
||||||
|
replyToMessageId?: string
|
||||||
|
quote?: ApiQuoteSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatLabData {
|
interface ChatLabData {
|
||||||
chatlab: ChatLabHeader
|
chatlab: ChatLabHeader
|
||||||
meta: ChatLabMeta
|
meta: ChatLabMeta
|
||||||
@@ -1600,8 +1614,8 @@ class HttpService {
|
|||||||
|
|
||||||
private toApiMessage(msg: Message, media?: ApiExportedMedia): Record<string, any> {
|
private toApiMessage(msg: Message, media?: ApiExportedMedia): Record<string, any> {
|
||||||
const serverId = this.getMessageServerId(msg)
|
const serverId = this.getMessageServerId(msg)
|
||||||
|
const quoteInfo = this.extractApiQuoteInfo(msg)
|
||||||
return {
|
const apiMessage: Record<string, any> = {
|
||||||
localId: msg.localId,
|
localId: msg.localId,
|
||||||
serverId: serverId || '0',
|
serverId: serverId || '0',
|
||||||
localType: msg.localType,
|
localType: msg.localType,
|
||||||
@@ -1609,7 +1623,7 @@ class HttpService {
|
|||||||
sortSeq: msg.sortSeq,
|
sortSeq: msg.sortSeq,
|
||||||
isSend: msg.isSend,
|
isSend: msg.isSend,
|
||||||
senderUsername: msg.senderUsername,
|
senderUsername: msg.senderUsername,
|
||||||
content: this.getMessageContent(msg),
|
content: this.getMessageContent(msg, quoteInfo),
|
||||||
rawContent: msg.rawContent,
|
rawContent: msg.rawContent,
|
||||||
parsedContent: msg.parsedContent,
|
parsedContent: msg.parsedContent,
|
||||||
mediaType: media?.kind,
|
mediaType: media?.kind,
|
||||||
@@ -1617,6 +1631,15 @@ class HttpService {
|
|||||||
mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
||||||
mediaLocalPath: media?.fullPath
|
mediaLocalPath: media?.fullPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (quoteInfo?.replyToMessageId) {
|
||||||
|
apiMessage.replyToMessageId = quoteInfo.replyToMessageId
|
||||||
|
}
|
||||||
|
if (quoteInfo?.quote && Object.keys(quoteInfo.quote).length > 0) {
|
||||||
|
apiMessage.quote = quoteInfo.quote
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMessageServerId(msg: Message): string {
|
private getMessageServerId(msg: Message): string {
|
||||||
@@ -1896,17 +1919,22 @@ class HttpService {
|
|||||||
// 转换消息
|
// 转换消息
|
||||||
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
|
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
|
||||||
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
|
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
|
||||||
|
const quoteInfo = this.extractApiQuoteInfo(msg)
|
||||||
|
|
||||||
return {
|
const chatLabMessage: ChatLabMessage = {
|
||||||
sender: senderInfo.sender,
|
sender: senderInfo.sender,
|
||||||
accountName: senderInfo.accountName,
|
accountName: senderInfo.accountName,
|
||||||
groupNickname: senderInfo.groupNickname,
|
groupNickname: senderInfo.groupNickname,
|
||||||
timestamp: msg.createTime,
|
timestamp: msg.createTime,
|
||||||
type: this.mapMessageType(msg.localType, msg),
|
type: this.mapMessageType(msg.localType, msg),
|
||||||
content: this.getMessageContent(msg),
|
content: this.getMessageContent(msg, quoteInfo),
|
||||||
platformMessageId: this.getMessageServerId(msg) || undefined,
|
platformMessageId: this.getMessageServerId(msg) || undefined,
|
||||||
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||||
}
|
}
|
||||||
|
if (quoteInfo?.replyToMessageId) {
|
||||||
|
chatLabMessage.replyToMessageId = quoteInfo.replyToMessageId
|
||||||
|
}
|
||||||
|
return chatLabMessage
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1995,7 +2023,7 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractType49Subtype(rawContent: string): string {
|
private extractType49Subtype(rawContent: string): string {
|
||||||
const content = String(rawContent || '')
|
const content = this.normalizeAppMessageContent(String(rawContent || ''))
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
|
|
||||||
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
|
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
|
||||||
@@ -2049,9 +2077,9 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getType49Content(msg: Message): string {
|
private getType49Content(msg: Message, quoteInfo?: ApiQuoteInfo): string {
|
||||||
const subtype = this.resolveType49Subtype(msg)
|
const subtype = this.resolveType49Subtype(msg)
|
||||||
const title = msg.linkTitle || msg.fileName || ''
|
const title = msg.linkTitle || msg.fileName || this.extractAppMessageTitle(msg.rawContent) || ''
|
||||||
|
|
||||||
switch (subtype) {
|
switch (subtype) {
|
||||||
case '5':
|
case '5':
|
||||||
@@ -2065,7 +2093,7 @@ class HttpService {
|
|||||||
case '36':
|
case '36':
|
||||||
return title ? `[小程序] ${title}` : '[小程序]'
|
return title ? `[小程序] ${title}` : '[小程序]'
|
||||||
case '57':
|
case '57':
|
||||||
return msg.parsedContent || title || '[引用消息]'
|
return msg.parsedContent || quoteInfo?.replyText || title || '[引用消息]'
|
||||||
case '2000':
|
case '2000':
|
||||||
return title ? `[转账] ${title}` : '[转账]'
|
return title ? `[转账] ${title}` : '[转账]'
|
||||||
case '2001':
|
case '2001':
|
||||||
@@ -2080,7 +2108,7 @@ class HttpService {
|
|||||||
/**
|
/**
|
||||||
* 获取消息内容
|
* 获取消息内容
|
||||||
*/
|
*/
|
||||||
private getMessageContent(msg: Message): string | null {
|
private getMessageContent(msg: Message, quoteInfo?: ApiQuoteInfo): string | null {
|
||||||
const normalizeTextContent = (value: string | null | undefined): string | null => {
|
const normalizeTextContent = (value: string | null | undefined): string | null => {
|
||||||
const text = String(value || '')
|
const text = String(value || '')
|
||||||
if (!text) return null
|
if (!text) return null
|
||||||
@@ -2088,7 +2116,11 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.localType === 49) {
|
if (msg.localType === 49) {
|
||||||
return this.getType49Content(msg)
|
return this.getType49Content(msg, quoteInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isReplyMessage(msg, quoteInfo)) {
|
||||||
|
return msg.parsedContent || quoteInfo?.replyText || this.extractAppMessageTitle(msg.rawContent) || '[引用消息]'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先使用已解析的内容
|
// 优先使用已解析的内容
|
||||||
@@ -2113,12 +2145,258 @@ class HttpService {
|
|||||||
case 48:
|
case 48:
|
||||||
return '[位置]'
|
return '[位置]'
|
||||||
case 49:
|
case 49:
|
||||||
return this.getType49Content(msg)
|
return this.getType49Content(msg, quoteInfo)
|
||||||
default:
|
default:
|
||||||
return normalizeTextContent(msg.parsedContent || msg.rawContent) || null
|
return normalizeTextContent(msg.parsedContent || msg.rawContent) || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isReplyMessage(msg: Message, quoteInfo?: ApiQuoteInfo): boolean {
|
||||||
|
if (!quoteInfo?.replyToMessageId && !quoteInfo?.quote) return false
|
||||||
|
if (msg.localType === 244813135921) return true
|
||||||
|
if (msg.localType === 49 && this.resolveType49Subtype(msg) === '57') return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractApiQuoteInfo(msg: Message): ApiQuoteInfo | undefined {
|
||||||
|
const rawContent = String(msg.rawContent || msg.content || '')
|
||||||
|
if (!rawContent || !this.messageMayContainQuote(rawContent)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = this.normalizeAppMessageContent(rawContent)
|
||||||
|
const referMsgXml = this.extractXmlBlock(normalized, 'refermsg')
|
||||||
|
if (!referMsgXml) return undefined
|
||||||
|
|
||||||
|
const replyToMessageId = this.extractReplyToMessageId(referMsgXml)
|
||||||
|
const referTypeRaw = this.extractXmlValue(referMsgXml, 'type')
|
||||||
|
const referContentRaw = this.extractXmlValue(referMsgXml, 'content')
|
||||||
|
const quoteContent = this.resolveQuotedContent(referMsgXml, referTypeRaw, referContentRaw)
|
||||||
|
const sender = this.resolveQuotedSender(referMsgXml)
|
||||||
|
const accountName = this.resolveQuotedAccountName(referMsgXml)
|
||||||
|
const quoteType = this.mapQuotedMessageType(referTypeRaw, referContentRaw)
|
||||||
|
|
||||||
|
const quote: ApiQuoteSnapshot = {}
|
||||||
|
if (replyToMessageId) quote.platformMessageId = replyToMessageId
|
||||||
|
if (sender) quote.sender = sender
|
||||||
|
if (accountName) quote.accountName = accountName
|
||||||
|
if (quoteContent) quote.content = quoteContent
|
||||||
|
if (quoteType !== undefined) quote.type = quoteType
|
||||||
|
|
||||||
|
const replyText = this.extractAppMessageTitle(normalized)
|
||||||
|
|
||||||
|
if (!replyToMessageId && Object.keys(quote).length === 0 && !replyText) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
replyText: replyText || undefined,
|
||||||
|
replyToMessageId,
|
||||||
|
quote: Object.keys(quote).length > 0 ? quote : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private messageMayContainQuote(content: string): boolean {
|
||||||
|
return content.includes('<refermsg>') ||
|
||||||
|
content.includes('<refermsg>') ||
|
||||||
|
content.includes('<type>57</type>') ||
|
||||||
|
content.includes('<type>57</type>')
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAppMessageContent(content: string): string {
|
||||||
|
return this.decodeHtmlEntities(String(content || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeHtmlEntities(text: string): string {
|
||||||
|
if (!text) return ''
|
||||||
|
return text
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractXmlBlock(xml: string, tag: string): string {
|
||||||
|
if (!xml || !tag) return ''
|
||||||
|
const match = new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'i').exec(xml)
|
||||||
|
return match ? match[0] : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractXmlValue(xml: string, tag: string): string {
|
||||||
|
if (!xml || !tag) return ''
|
||||||
|
const match = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(xml)
|
||||||
|
if (!match) return ''
|
||||||
|
return this.decodeHtmlEntities(match[1])
|
||||||
|
.replace(/<!\[CDATA\[/g, '')
|
||||||
|
.replace(/\]\]>/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractAppMessageTitle(content: string): string {
|
||||||
|
const normalized = this.normalizeAppMessageContent(content || '')
|
||||||
|
if (!normalized) return ''
|
||||||
|
const appMsgXml = this.extractXmlBlock(normalized, 'appmsg')
|
||||||
|
return this.sanitizeQuotedContent(this.extractXmlValue(appMsgXml || normalized, 'title'))
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractReplyToMessageId(referMsgXml: string): string | undefined {
|
||||||
|
const candidates = [
|
||||||
|
this.extractXmlValue(referMsgXml, 'svrid'),
|
||||||
|
this.extractXmlValue(referMsgXml, 'msgsvrid'),
|
||||||
|
this.extractXmlValue(referMsgXml, 'newmsgid'),
|
||||||
|
this.extractXmlValue(referMsgXml, 'msgid')
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = this.normalizeUnsignedIntToken(candidate)
|
||||||
|
if (normalized && normalized !== '0') return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveQuotedSender(referMsgXml: string): string | undefined {
|
||||||
|
const chatusr = this.extractXmlValue(referMsgXml, 'chatusr')
|
||||||
|
if (chatusr) return chatusr
|
||||||
|
|
||||||
|
const fromusr = this.extractXmlValue(referMsgXml, 'fromusr')
|
||||||
|
if (fromusr && !fromusr.endsWith('@chatroom')) return fromusr
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveQuotedAccountName(referMsgXml: string): string | undefined {
|
||||||
|
const displayName = this.extractXmlValue(referMsgXml, 'displayname')
|
||||||
|
if (!displayName || this.looksLikeWxid(displayName)) return undefined
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikeWxid(value: string): boolean {
|
||||||
|
const text = String(value || '').trim().toLowerCase()
|
||||||
|
return Boolean(text) && (text.startsWith('wxid_') || /^wx[a-z0-9_-]{4,}$/.test(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveQuotedContent(referMsgXml: string, referTypeRaw: string, referContentRaw: string): string {
|
||||||
|
const referType = String(referTypeRaw || '').trim()
|
||||||
|
switch (referType) {
|
||||||
|
case '1':
|
||||||
|
return this.extractPreferredQuotedText(referMsgXml)
|
||||||
|
case '3':
|
||||||
|
return '[图片]'
|
||||||
|
case '34':
|
||||||
|
return '[语音]'
|
||||||
|
case '43':
|
||||||
|
return '[视频]'
|
||||||
|
case '47':
|
||||||
|
return '[动画表情]'
|
||||||
|
case '42':
|
||||||
|
return '[名片]'
|
||||||
|
case '48':
|
||||||
|
return '[位置]'
|
||||||
|
case '49': {
|
||||||
|
const innerType = this.extractType49Subtype(referContentRaw)
|
||||||
|
if (innerType === '57') {
|
||||||
|
return this.extractAppMessageTitle(referContentRaw) || '[引用消息]'
|
||||||
|
}
|
||||||
|
if (innerType === '6') return '[文件]'
|
||||||
|
if (innerType === '19') return '[聊天记录]'
|
||||||
|
if (innerType === '33' || innerType === '36') return '[小程序]'
|
||||||
|
return '[链接]'
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if (!referContentRaw || referContentRaw.includes('wxid_')) return '[消息]'
|
||||||
|
return this.sanitizeQuotedContent(referContentRaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractPreferredQuotedText(referMsgXml: string): string {
|
||||||
|
const candidateTags = [
|
||||||
|
'selectedcontent',
|
||||||
|
'selectedtext',
|
||||||
|
'selectcontent',
|
||||||
|
'selecttext',
|
||||||
|
'quotecontent',
|
||||||
|
'quotetext',
|
||||||
|
'partcontent',
|
||||||
|
'parttext',
|
||||||
|
'excerpt',
|
||||||
|
'summary',
|
||||||
|
'preview',
|
||||||
|
'content'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const tag of candidateTags) {
|
||||||
|
const value = this.sanitizeQuotedContent(this.extractXmlValue(referMsgXml, tag))
|
||||||
|
if (value) return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeQuotedContent(content: string): string {
|
||||||
|
if (!content) return ''
|
||||||
|
return String(content || '')
|
||||||
|
.replace(/wxid_[A-Za-z0-9_-]{3,}/g, '')
|
||||||
|
.replace(/^[\s::\-]+/, '')
|
||||||
|
.replace(/[::]{2,}/g, ':')
|
||||||
|
.replace(/^[\s::\-]+/, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapQuotedMessageType(referTypeRaw: string, referContentRaw: string): number | undefined {
|
||||||
|
const referType = String(referTypeRaw || '').trim()
|
||||||
|
switch (referType) {
|
||||||
|
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.mapQuotedType49MessageType(referContentRaw)
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapQuotedType49MessageType(content: string): number {
|
||||||
|
const subtype = this.extractType49Subtype(content)
|
||||||
|
switch (subtype) {
|
||||||
|
case '57':
|
||||||
|
return ChatLabType.REPLY
|
||||||
|
case '6':
|
||||||
|
return ChatLabType.FILE
|
||||||
|
case '19':
|
||||||
|
return ChatLabType.FORWARD
|
||||||
|
case '33':
|
||||||
|
case '36':
|
||||||
|
return ChatLabType.SHARE
|
||||||
|
case '2000':
|
||||||
|
return ChatLabType.TRANSFER
|
||||||
|
case '2001':
|
||||||
|
return ChatLabType.RED_PACKET
|
||||||
|
case '5':
|
||||||
|
case '49':
|
||||||
|
default:
|
||||||
|
return ChatLabType.LINK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 JSON 响应
|
* 发送 JSON 响应
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000
|
|||||||
|
|
||||||
/** 单次 API 请求超时(毫秒) */
|
/** 单次 API 请求超时(毫秒) */
|
||||||
const API_TIMEOUT_MS = 45_000
|
const API_TIMEOUT_MS = 45_000
|
||||||
const API_MAX_TOKENS_DEFAULT = 200
|
const API_MAX_TOKENS_DEFAULT = 1024
|
||||||
const API_MAX_TOKENS_MIN = 1
|
const API_MAX_TOKENS_MIN = 1
|
||||||
const API_MAX_TOKENS_MAX = 65_535
|
const API_MAX_TOKENS_MAX = 2_000_000
|
||||||
const API_TEMPERATURE = 0.7
|
const API_TEMPERATURE = 0.7
|
||||||
const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png'
|
const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png'
|
||||||
|
|
||||||
@@ -582,7 +582,7 @@ ${topMentionText}
|
|||||||
25_000,
|
25_000,
|
||||||
maxTokens
|
maxTokens
|
||||||
)
|
)
|
||||||
const insight = result.trim().slice(0, 400)
|
const insight = result.trim()
|
||||||
if (!insight) return { success: false, message: '模型返回为空' }
|
if (!insight) return { success: false, message: '模型返回为空' }
|
||||||
return { success: true, message: '生成成功', insight }
|
return { success: true, message: '生成成功', insight }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1214,7 +1214,7 @@ ${topMentionText}
|
|||||||
}
|
}
|
||||||
if (!this.isEnabled()) return
|
if (!this.isEnabled()) return
|
||||||
|
|
||||||
const insight = result.slice(0, 120)
|
const insight = result.trim()
|
||||||
const notifTitle = `见解 · ${resolvedDisplayName}`
|
const notifTitle = `见解 · ${resolvedDisplayName}`
|
||||||
const recordLog: InsightRecordLog = {
|
const recordLog: InsightRecordLog = {
|
||||||
endpoint,
|
endpoint,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface RouteGuardProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const PUBLIC_ROUTES = ['/', '/home', '/settings']
|
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/account-management']
|
||||||
|
|
||||||
function RouteGuard({ children }: RouteGuardProps) {
|
function RouteGuard({ children }: RouteGuardProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|||||||
@@ -46,7 +46,43 @@ type NoticeState =
|
|||||||
|
|
||||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||||
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
||||||
const hiddenDeletedAccountIds = new Set<string>()
|
|
||||||
|
const HIDDEN_DELETED_ACCOUNT_NORM_IDS_KEY = 'weflow_account_mgmt_hidden_deleted_norm_v1'
|
||||||
|
|
||||||
|
const readHiddenDeletedAccountNormIds = (): Set<string> => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(HIDDEN_DELETED_ACCOUNT_NORM_IDS_KEY)
|
||||||
|
if (!raw) return new Set()
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(parsed)) return new Set()
|
||||||
|
return new Set(parsed.filter((x): x is string => typeof x === 'string' && x.length > 0))
|
||||||
|
} catch {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeHiddenDeletedAccountNormIds = (ids: Set<string>): void => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(HIDDEN_DELETED_ACCOUNT_NORM_IDS_KEY, JSON.stringify(Array.from(ids)))
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addHiddenDeletedAccountNormId = (normalized: string): void => {
|
||||||
|
if (!normalized) return
|
||||||
|
const next = readHiddenDeletedAccountNormIds()
|
||||||
|
next.add(normalized)
|
||||||
|
writeHiddenDeletedAccountNormIds(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeHiddenDeletedAccountNormId = (normalized: string): void => {
|
||||||
|
if (!normalized) return
|
||||||
|
const next = readHiddenDeletedAccountNormIds()
|
||||||
|
if (!next.delete(normalized)) return
|
||||||
|
writeHiddenDeletedAccountNormIds(next)
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_ACCOUNT_DISPLAY_NAME = '微信用户'
|
const DEFAULT_ACCOUNT_DISPLAY_NAME = '微信用户'
|
||||||
|
|
||||||
const normalizeAccountId = (value?: string | null): string => {
|
const normalizeAccountId = (value?: string | null): string => {
|
||||||
@@ -194,12 +230,14 @@ function AccountManagementPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 被“删除配置”操作移除的账号,在当前会话中从列表隐藏;
|
// 被「删除配置」移除的账号:微信目录仍在扫描结果里会出现无配置条目,持久化隐藏避免误导;
|
||||||
// 若后续再次生成配置,则自动恢复展示。
|
// 若后续再次保存该账号配置,则自动恢复展示。
|
||||||
|
const hiddenDeletedNormIds = readHiddenDeletedAccountNormIds()
|
||||||
for (const [normalized, item] of Array.from(merged.entries())) {
|
for (const [normalized, item] of Array.from(merged.entries())) {
|
||||||
if (!hiddenDeletedAccountIds.has(normalized)) continue
|
if (!hiddenDeletedNormIds.has(normalized)) continue
|
||||||
if (item.hasConfig) {
|
if (item.hasConfig) {
|
||||||
hiddenDeletedAccountIds.delete(normalized)
|
hiddenDeletedNormIds.delete(normalized)
|
||||||
|
writeHiddenDeletedAccountNormIds(hiddenDeletedNormIds)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
merged.delete(normalized)
|
merged.delete(normalized)
|
||||||
@@ -362,7 +400,7 @@ function AccountManagementPage() {
|
|||||||
const [nextWxid, nextConfig] = remainingEntries[0]
|
const [nextWxid, nextConfig] = remainingEntries[0]
|
||||||
await applyWxidConfig(nextWxid, nextConfig || null)
|
await applyWxidConfig(nextWxid, nextConfig || null)
|
||||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: nextWxid } }))
|
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: nextWxid } }))
|
||||||
hiddenDeletedAccountIds.add(normalizedTarget)
|
addHiddenDeletedAccountNormId(normalizedTarget)
|
||||||
setDeleteUndoState(undoPayload)
|
setDeleteUndoState(undoPayload)
|
||||||
setNotice({ type: 'success', text: `已删除「${targetWxid}」配置,并切换到「${nextWxid}」` })
|
setNotice({ type: 'success', text: `已删除「${targetWxid}」配置,并切换到「${nextWxid}」` })
|
||||||
await loadAccounts()
|
await loadAccounts()
|
||||||
@@ -375,14 +413,14 @@ function AccountManagementPage() {
|
|||||||
await configService.setImageAesKey('')
|
await configService.setImageAesKey('')
|
||||||
setDbConnected(false)
|
setDbConnected(false)
|
||||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: '' } }))
|
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: '' } }))
|
||||||
hiddenDeletedAccountIds.add(normalizedTarget)
|
addHiddenDeletedAccountNormId(normalizedTarget)
|
||||||
setDeleteUndoState(undoPayload)
|
setDeleteUndoState(undoPayload)
|
||||||
setNotice({ type: 'info', text: `已删除「${targetWxid}」配置,当前无可用账号配置,可撤回或添加账号` })
|
setNotice({ type: 'info', text: `已删除「${targetWxid}」配置,当前无可用账号配置,可撤回或添加账号` })
|
||||||
await loadAccounts()
|
await loadAccounts()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hiddenDeletedAccountIds.add(normalizedTarget)
|
addHiddenDeletedAccountNormId(normalizedTarget)
|
||||||
setDeleteUndoState(undoPayload)
|
setDeleteUndoState(undoPayload)
|
||||||
setNotice({ type: 'success', text: `已删除账号「${targetWxid}」配置` })
|
setNotice({ type: 'success', text: `已删除账号「${targetWxid}」配置` })
|
||||||
await loadAccounts()
|
await loadAccounts()
|
||||||
@@ -406,7 +444,7 @@ function AccountManagementPage() {
|
|||||||
restoredConfigs[key] = configValue || {}
|
restoredConfigs[key] = configValue || {}
|
||||||
}
|
}
|
||||||
await configService.setWxidConfigs(restoredConfigs)
|
await configService.setWxidConfigs(restoredConfigs)
|
||||||
hiddenDeletedAccountIds.delete(normalizeAccountId(deleteUndoState.targetWxid) || deleteUndoState.targetWxid)
|
removeHiddenDeletedAccountNormId(normalizeAccountId(deleteUndoState.targetWxid) || deleteUndoState.targetWxid)
|
||||||
|
|
||||||
const accountProfileCache = readAccountProfilesCache()
|
const accountProfileCache = readAccountProfilesCache()
|
||||||
for (const [key, profile] of deleteUndoState.deletedProfileEntries) {
|
for (const [key, profile] of deleteUndoState.deletedProfileEntries) {
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [aiModelApiBaseUrl, setAiModelApiBaseUrl] = useState('')
|
const [aiModelApiBaseUrl, setAiModelApiBaseUrl] = useState('')
|
||||||
const [aiModelApiKey, setAiModelApiKey] = useState('')
|
const [aiModelApiKey, setAiModelApiKey] = useState('')
|
||||||
const [aiModelApiModel, setAiModelApiModel] = useState('gpt-4o-mini')
|
const [aiModelApiModel, setAiModelApiModel] = useState('gpt-4o-mini')
|
||||||
const [aiModelApiMaxTokens, setAiModelApiMaxTokens] = useState(200)
|
const [aiModelApiMaxTokens, setAiModelApiMaxTokens] = useState(1024)
|
||||||
const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3)
|
const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3)
|
||||||
const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
|
const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
|
||||||
const [aiInsightAllowMomentsContext, setAiInsightAllowMomentsContext] = useState(false)
|
const [aiInsightAllowMomentsContext, setAiInsightAllowMomentsContext] = useState(false)
|
||||||
@@ -3030,18 +3030,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>通用 Max Tokens</label>
|
<label>通用 Max Tokens</label>
|
||||||
<span className="form-hint">
|
<span className="form-hint">
|
||||||
设置单次请求的最大输出 token 数量,见解与足迹共享该值。默认 <code>200</code>。
|
设置单次请求的最大输出 token 数量,见解与足迹共享该值。默认 <code>1024</code>。
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
value={aiModelApiMaxTokens}
|
value={aiModelApiMaxTokens}
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={2000000}
|
||||||
step={1}
|
step={1}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const parsed = parseInt(e.target.value, 10)
|
const parsed = parseInt(e.target.value, 10)
|
||||||
const val = Math.min(65535, Math.max(1, Number.isFinite(parsed) ? parsed : 200))
|
const val = Math.min(2000000, Math.max(1, Number.isFinite(parsed) ? parsed : 1024))
|
||||||
setAiModelApiMaxTokens(val)
|
setAiModelApiMaxTokens(val)
|
||||||
scheduleConfigSave('aiModelApiMaxTokens', () => configService.setAiModelApiMaxTokens(val))
|
scheduleConfigSave('aiModelApiMaxTokens', () => configService.setAiModelApiMaxTokens(val))
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1903,13 +1903,13 @@ export async function getAiModelApiMaxTokens(): Promise<number> {
|
|||||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
return Math.floor(value)
|
return Math.floor(value)
|
||||||
}
|
}
|
||||||
return 200
|
return 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setAiModelApiMaxTokens(maxTokens: number): Promise<void> {
|
export async function setAiModelApiMaxTokens(maxTokens: number): Promise<void> {
|
||||||
const normalized = Number.isFinite(maxTokens)
|
const normalized = Number.isFinite(maxTokens)
|
||||||
? Math.min(65535, Math.max(1, Math.floor(maxTokens)))
|
? Math.min(2000000, Math.max(1, Math.floor(maxTokens)))
|
||||||
: 200
|
: 1024
|
||||||
await config.set(CONFIG_KEYS.AI_MODEL_API_MAX_TOKENS, normalized)
|
await config.set(CONFIG_KEYS.AI_MODEL_API_MAX_TOKENS, normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user