feat: Add Chat Analysis
This commit is contained in:
1 parent
ca6c479496
commit
1df4f0e523
14 files changed
+1493
-24
No files matched your search
@@ -1792,6 +1792,7 @@ function registerIpcHandlers() {
|
|||||||
sessionId?: string
|
sessionId?: string
|
||||||
startTime?: number
|
startTime?: number
|
||||||
endTime?: number
|
endTime?: number
|
||||||
|
sourceType?: 'insight' | 'message_analysis' | 'all'
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
}) => {
|
}) => {
|
||||||
@@ -1834,6 +1835,21 @@ function registerIpcHandlers() {
|
|||||||
return insightService.generateFootprintInsight(payload)
|
return insightService.generateFootprintInsight(payload)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('insight:generateMessageInsight', async (_, payload: {
|
||||||
|
sessionId: string
|
||||||
|
displayName?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
targetLocalId?: number
|
||||||
|
targetCreateTime?: number
|
||||||
|
targetMessageKey?: string
|
||||||
|
targetText: string
|
||||||
|
targetSenderName?: string
|
||||||
|
contextCount?: number
|
||||||
|
forceRefresh?: boolean
|
||||||
|
}) => {
|
||||||
|
return insightService.generateMessageInsight(payload)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => {
|
ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => {
|
||||||
try {
|
try {
|
||||||
if (!configService) {
|
if (!configService) {
|
||||||
|
|||||||
+13
-1
@@ -595,7 +595,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
}
|
}
|
||||||
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
||||||
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||||
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
|
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload),
|
||||||
|
generateMessageInsight: (payload: {
|
||||||
|
sessionId: string
|
||||||
|
displayName?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
targetLocalId?: number
|
||||||
|
targetCreateTime?: number
|
||||||
|
targetMessageKey?: string
|
||||||
|
targetText: string
|
||||||
|
targetSenderName?: string
|
||||||
|
contextCount?: number
|
||||||
|
forceRefresh?: boolean
|
||||||
|
}) => ipcRenderer.invoke('insight:generateMessageInsight', payload)
|
||||||
},
|
},
|
||||||
|
|
||||||
social: {
|
social: {
|
||||||
|
|||||||
@@ -2585,6 +2585,93 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessagesAround(
|
||||||
|
sessionId: string,
|
||||||
|
target: { localId?: number; createTime: number; messageKey?: string },
|
||||||
|
totalContextCount: number = 50
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
before: Message[]
|
||||||
|
after: Message[]
|
||||||
|
requested: number
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
const requested = Math.max(1, Math.min(200, Math.floor(Number(totalContextCount) || 50)))
|
||||||
|
const targetCreateTime = Math.floor(Number(target?.createTime || 0))
|
||||||
|
if (!sessionId || targetCreateTime <= 0) {
|
||||||
|
return { success: false, before: [], after: [], requested, error: '无效的目标消息' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const collect = async (ascending: boolean): Promise<Message[]> => {
|
||||||
|
let cursor: number | undefined
|
||||||
|
try {
|
||||||
|
const cursorResult = await wcdbService.openMessageCursorLite(
|
||||||
|
sessionId,
|
||||||
|
Math.min(240, Math.max(60, requested + 20)),
|
||||||
|
ascending,
|
||||||
|
ascending ? targetCreateTime : 0,
|
||||||
|
ascending ? 0 : targetCreateTime + 1
|
||||||
|
)
|
||||||
|
if (!cursorResult.success || !cursorResult.cursor) {
|
||||||
|
throw new Error(cursorResult.error || '打开消息游标失败')
|
||||||
|
}
|
||||||
|
cursor = cursorResult.cursor
|
||||||
|
const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursor, requested + 1)
|
||||||
|
if (!collected.success) {
|
||||||
|
throw new Error(collected.error || '读取上下文消息失败')
|
||||||
|
}
|
||||||
|
const targetLocalId = Math.floor(Number(target?.localId || 0))
|
||||||
|
const targetMessageKey = String(target?.messageKey || '').trim()
|
||||||
|
return (collected.messages || []).filter((message) => {
|
||||||
|
const sameLocalId = targetLocalId > 0 && Number(message.localId || 0) === targetLocalId
|
||||||
|
const sameCreateTime = Number(message.createTime || 0) === targetCreateTime
|
||||||
|
const sameKey = Boolean(targetMessageKey && message.messageKey === targetMessageKey)
|
||||||
|
return !(sameKey || (sameLocalId && sameCreateTime))
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (cursor) {
|
||||||
|
await wcdbService.closeMessageCursor(cursor).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [beforeCandidatesRaw, afterCandidatesRaw] = await Promise.all([
|
||||||
|
collect(false),
|
||||||
|
collect(true)
|
||||||
|
])
|
||||||
|
const beforeCandidates = beforeCandidatesRaw
|
||||||
|
.filter((message) => Number(message.createTime || 0) <= targetCreateTime)
|
||||||
|
.sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq))
|
||||||
|
const afterCandidates = afterCandidatesRaw
|
||||||
|
.filter((message) => Number(message.createTime || 0) >= targetCreateTime)
|
||||||
|
.sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq))
|
||||||
|
|
||||||
|
const baseBefore = Math.floor(requested / 2)
|
||||||
|
const baseAfter = requested - baseBefore
|
||||||
|
const takeAfter = Math.min(baseAfter, afterCandidates.length)
|
||||||
|
const takeBefore = Math.min(requested - takeAfter, beforeCandidates.length)
|
||||||
|
const remainingAfter = Math.max(0, requested - takeBefore - takeAfter)
|
||||||
|
const finalAfter = Math.min(afterCandidates.length, takeAfter + remainingAfter)
|
||||||
|
const finalBefore = Math.min(beforeCandidates.length, requested - finalAfter)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
before: beforeCandidates.slice(Math.max(0, beforeCandidates.length - finalBefore)),
|
||||||
|
after: afterCandidates.slice(0, finalAfter),
|
||||||
|
requested
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
before: [],
|
||||||
|
after: [],
|
||||||
|
requested,
|
||||||
|
error: (error as Error).message || String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getNewMessages(sessionId: string, minTime: number, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
|
async getNewMessages(sessionId: string, minTime: number, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ interface ConfigSchema {
|
|||||||
// AI 足迹
|
// AI 足迹
|
||||||
aiFootprintEnabled: boolean
|
aiFootprintEnabled: boolean
|
||||||
aiFootprintSystemPrompt: string
|
aiFootprintSystemPrompt: string
|
||||||
|
aiMessageInsightEnabled: boolean
|
||||||
|
aiMessageInsightContextCount: number
|
||||||
|
aiMessageInsightSystemPrompt: string
|
||||||
/** 是否将 AI 见解调试日志输出到桌面 */
|
/** 是否将 AI 见解调试日志输出到桌面 */
|
||||||
aiInsightDebugLogEnabled: boolean
|
aiInsightDebugLogEnabled: boolean
|
||||||
autoDownloadHighRes: boolean
|
autoDownloadHighRes: boolean
|
||||||
@@ -252,6 +255,9 @@ export class ConfigService {
|
|||||||
aiInsightWeiboBindings: {},
|
aiInsightWeiboBindings: {},
|
||||||
aiFootprintEnabled: false,
|
aiFootprintEnabled: false,
|
||||||
aiFootprintSystemPrompt: '',
|
aiFootprintSystemPrompt: '',
|
||||||
|
aiMessageInsightEnabled: false,
|
||||||
|
aiMessageInsightContextCount: 50,
|
||||||
|
aiMessageInsightSystemPrompt: '',
|
||||||
aiInsightDebugLogEnabled: false,
|
aiInsightDebugLogEnabled: false,
|
||||||
autoDownloadHighRes: false,
|
autoDownloadHighRes: false,
|
||||||
autoDownloadWhitelist: []
|
autoDownloadWhitelist: []
|
||||||
|
|||||||
@@ -4,7 +4,24 @@ import path from 'path'
|
|||||||
import { createHash, randomUUID } from 'crypto'
|
import { createHash, randomUUID } from 'crypto'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
|
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'message_analysis'
|
||||||
|
export type InsightRecordSourceType = 'insight' | 'message_analysis'
|
||||||
|
|
||||||
|
export interface MessageInsightAnalysis {
|
||||||
|
explicitText: string
|
||||||
|
emotion: string
|
||||||
|
intent: string
|
||||||
|
topic: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageInsightTarget {
|
||||||
|
targetLocalId: number
|
||||||
|
targetCreateTime: number
|
||||||
|
targetMessageKey: string
|
||||||
|
targetSenderName: string
|
||||||
|
targetTextPreview: string
|
||||||
|
analysis: MessageInsightAnalysis
|
||||||
|
}
|
||||||
|
|
||||||
export interface InsightRecordLog {
|
export interface InsightRecordLog {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
@@ -20,11 +37,29 @@ export interface InsightRecordLog {
|
|||||||
finalInsight: string
|
finalInsight: string
|
||||||
durationMs: number
|
durationMs: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
responseFormatJson?: boolean
|
||||||
|
responseFormatFallback?: boolean
|
||||||
|
responseFormatFallbackReason?: string
|
||||||
|
targetMessage?: {
|
||||||
|
localId: number
|
||||||
|
createTime: number
|
||||||
|
messageKey: string
|
||||||
|
senderName: string
|
||||||
|
textPreview: string
|
||||||
|
}
|
||||||
|
contextStats?: {
|
||||||
|
requested: number
|
||||||
|
beforeTarget: number
|
||||||
|
afterTarget: number
|
||||||
|
readError?: string
|
||||||
|
}
|
||||||
|
parsedAnalysis?: MessageInsightAnalysis
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InsightRecord {
|
export interface InsightRecord {
|
||||||
id: string
|
id: string
|
||||||
accountScope: string
|
accountScope: string
|
||||||
|
sourceType: InsightRecordSourceType
|
||||||
createdAt: number
|
createdAt: number
|
||||||
sessionId: string
|
sessionId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -32,11 +67,13 @@ export interface InsightRecord {
|
|||||||
triggerReason: InsightRecordTriggerReason
|
triggerReason: InsightRecordTriggerReason
|
||||||
insight: string
|
insight: string
|
||||||
read: boolean
|
read: boolean
|
||||||
|
messageInsight?: MessageInsightTarget
|
||||||
log: InsightRecordLog
|
log: InsightRecordLog
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InsightRecordSummary {
|
export interface InsightRecordSummary {
|
||||||
id: string
|
id: string
|
||||||
|
sourceType: InsightRecordSourceType
|
||||||
createdAt: number
|
createdAt: number
|
||||||
sessionId: string
|
sessionId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -44,6 +81,7 @@ export interface InsightRecordSummary {
|
|||||||
triggerReason: InsightRecordTriggerReason
|
triggerReason: InsightRecordTriggerReason
|
||||||
insight: string
|
insight: string
|
||||||
read: boolean
|
read: boolean
|
||||||
|
messageInsight?: MessageInsightTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InsightRecordContactFacet {
|
export interface InsightRecordContactFacet {
|
||||||
@@ -58,6 +96,7 @@ export interface InsightRecordFilters {
|
|||||||
sessionId?: string
|
sessionId?: string
|
||||||
startTime?: number
|
startTime?: number
|
||||||
endTime?: number
|
endTime?: number
|
||||||
|
sourceType?: InsightRecordSourceType | 'all'
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
}
|
}
|
||||||
@@ -136,13 +175,15 @@ class InsightRecordService {
|
|||||||
private toSummary(record: InsightRecord): InsightRecordSummary {
|
private toSummary(record: InsightRecord): InsightRecordSummary {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
|
sourceType: record.sourceType || 'insight',
|
||||||
createdAt: record.createdAt,
|
createdAt: record.createdAt,
|
||||||
sessionId: record.sessionId,
|
sessionId: record.sessionId,
|
||||||
displayName: record.displayName,
|
displayName: record.displayName,
|
||||||
avatarUrl: record.avatarUrl,
|
avatarUrl: record.avatarUrl,
|
||||||
triggerReason: record.triggerReason,
|
triggerReason: record.triggerReason,
|
||||||
insight: record.insight,
|
insight: record.insight,
|
||||||
read: record.read
|
read: record.read,
|
||||||
|
messageInsight: record.messageInsight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,8 +197,10 @@ class InsightRecordService {
|
|||||||
sessionId: string
|
sessionId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
sourceType?: InsightRecordSourceType
|
||||||
triggerReason: InsightRecordTriggerReason
|
triggerReason: InsightRecordTriggerReason
|
||||||
insight: string
|
insight: string
|
||||||
|
messageInsight?: MessageInsightTarget
|
||||||
log: InsightRecordLog
|
log: InsightRecordLog
|
||||||
}): InsightRecord {
|
}): InsightRecord {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
@@ -166,6 +209,7 @@ class InsightRecordService {
|
|||||||
const record: InsightRecord = {
|
const record: InsightRecord = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
accountScope: scope,
|
accountScope: scope,
|
||||||
|
sourceType: input.sourceType || 'insight',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
@@ -173,6 +217,7 @@ class InsightRecordService {
|
|||||||
triggerReason: input.triggerReason,
|
triggerReason: input.triggerReason,
|
||||||
insight: input.insight,
|
insight: input.insight,
|
||||||
read: false,
|
read: false,
|
||||||
|
messageInsight: input.messageInsight,
|
||||||
log: input.log
|
log: input.log
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +252,7 @@ class InsightRecordService {
|
|||||||
|
|
||||||
const keyword = String(filters.keyword || '').trim().toLowerCase()
|
const keyword = String(filters.keyword || '').trim().toLowerCase()
|
||||||
const sessionId = String(filters.sessionId || '').trim()
|
const sessionId = String(filters.sessionId || '').trim()
|
||||||
|
const sourceType = String(filters.sourceType || 'all').trim()
|
||||||
const startTime = Number(filters.startTime || 0)
|
const startTime = Number(filters.startTime || 0)
|
||||||
const endTime = Number(filters.endTime || 0)
|
const endTime = Number(filters.endTime || 0)
|
||||||
const offset = Math.max(0, Math.floor(Number(filters.offset || 0)))
|
const offset = Math.max(0, Math.floor(Number(filters.offset || 0)))
|
||||||
@@ -215,10 +261,22 @@ class InsightRecordService {
|
|||||||
const filtered = allScoped
|
const filtered = allScoped
|
||||||
.filter((record) => {
|
.filter((record) => {
|
||||||
if (sessionId && record.sessionId !== sessionId) return false
|
if (sessionId && record.sessionId !== sessionId) return false
|
||||||
|
const recordSourceType = record.sourceType || 'insight'
|
||||||
|
if (sourceType !== 'all' && sourceType && recordSourceType !== sourceType) return false
|
||||||
if (startTime > 0 && record.createdAt < startTime) return false
|
if (startTime > 0 && record.createdAt < startTime) return false
|
||||||
if (endTime > 0 && record.createdAt > endTime) return false
|
if (endTime > 0 && record.createdAt > endTime) return false
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
const haystack = `${record.displayName}\n${record.sessionId}\n${record.insight}`.toLowerCase()
|
const haystack = [
|
||||||
|
record.displayName,
|
||||||
|
record.sessionId,
|
||||||
|
record.insight,
|
||||||
|
record.messageInsight?.targetSenderName,
|
||||||
|
record.messageInsight?.targetTextPreview,
|
||||||
|
record.messageInsight?.analysis?.explicitText,
|
||||||
|
record.messageInsight?.analysis?.emotion,
|
||||||
|
record.messageInsight?.analysis?.intent,
|
||||||
|
record.messageInsight?.analysis?.topic
|
||||||
|
].join('\n').toLowerCase()
|
||||||
if (!haystack.includes(keyword)) return false
|
if (!haystack.includes(keyword)) return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -256,6 +314,36 @@ class InsightRecordService {
|
|||||||
return { success: true, record }
|
return { success: true, record }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findLatestMessageAnalysis(input: {
|
||||||
|
sessionId: string
|
||||||
|
targetLocalId?: number
|
||||||
|
targetCreateTime?: number
|
||||||
|
targetMessageKey?: string
|
||||||
|
}): InsightRecord | null {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const scope = this.getCurrentAccountScope()
|
||||||
|
const sessionId = String(input.sessionId || '').trim()
|
||||||
|
if (!sessionId) return null
|
||||||
|
const targetLocalId = Math.floor(Number(input.targetLocalId || 0))
|
||||||
|
const targetCreateTime = Math.floor(Number(input.targetCreateTime || 0))
|
||||||
|
const targetMessageKey = String(input.targetMessageKey || '').trim()
|
||||||
|
const matches = this.records
|
||||||
|
.filter((record) => {
|
||||||
|
if (record.accountScope !== scope) return false
|
||||||
|
if ((record.sourceType || 'insight') !== 'message_analysis') return false
|
||||||
|
if (record.sessionId !== sessionId) return false
|
||||||
|
const target = record.messageInsight
|
||||||
|
if (!target) return false
|
||||||
|
if (targetLocalId > 0 && Number(target.targetLocalId || 0) === targetLocalId) {
|
||||||
|
if (targetCreateTime <= 0 || Number(target.targetCreateTime || 0) === targetCreateTime) return true
|
||||||
|
}
|
||||||
|
if (targetMessageKey && target.targetMessageKey === targetMessageKey) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
return matches[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
markRecordRead(id: string): { success: boolean; error?: string } {
|
markRecordRead(id: string): { success: boolean; error?: string } {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
const normalizedId = String(id || '').trim()
|
const normalizedId = String(id || '').trim()
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ import { chatService, ChatSession, Message } from './chatService'
|
|||||||
import { snsService } from './snsService'
|
import { snsService } from './snsService'
|
||||||
import { weiboService } from './social/weiboService'
|
import { weiboService } from './social/weiboService'
|
||||||
import { showNotification } from '../windows/notificationWindow'
|
import { showNotification } from '../windows/notificationWindow'
|
||||||
import { insightRecordService, type InsightRecordLog, type InsightRecordTriggerReason } from './insightRecordService'
|
import {
|
||||||
|
insightRecordService,
|
||||||
|
type InsightRecordLog,
|
||||||
|
type InsightRecordTriggerReason,
|
||||||
|
type MessageInsightAnalysis
|
||||||
|
} from './insightRecordService'
|
||||||
|
|
||||||
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -81,6 +86,18 @@ interface SharedAiModelConfig {
|
|||||||
|
|
||||||
type InsightFilterMode = 'whitelist' | 'blacklist'
|
type InsightFilterMode = 'whitelist' | 'blacklist'
|
||||||
|
|
||||||
|
class ApiRequestError extends Error {
|
||||||
|
statusCode?: number
|
||||||
|
responseBody?: string
|
||||||
|
|
||||||
|
constructor(message: string, statusCode?: number, responseBody?: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiRequestError'
|
||||||
|
this.statusCode = statusCode
|
||||||
|
this.responseBody = responseBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
||||||
@@ -161,6 +178,52 @@ function normalizeSessionIdList(value: unknown): string[] {
|
|||||||
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampText(value: unknown, maxLength: number): string {
|
||||||
|
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
||||||
|
if (text.length <= maxLength) return text
|
||||||
|
return `${text.slice(0, Math.max(0, maxLength - 1))}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripJsonFence(value: string): string {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)
|
||||||
|
if (fenced) return fenced[1].trim()
|
||||||
|
const firstBrace = text.indexOf('{')
|
||||||
|
const lastBrace = text.lastIndexOf('}')
|
||||||
|
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||||
|
return text.slice(firstBrace, lastBrace + 1).trim()
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMessageInsightAnalysis(rawOutput: string): MessageInsightAnalysis {
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(stripJsonFence(rawOutput))
|
||||||
|
} catch {
|
||||||
|
throw new Error('模型输出格式异常:不是合法 JSON')
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
throw new Error('模型输出格式异常:JSON 根节点不是对象')
|
||||||
|
}
|
||||||
|
const source = parsed as Record<string, unknown>
|
||||||
|
const explicitText = clampText(source.explicit_text ?? source.explicitText, 120)
|
||||||
|
const emotion = clampText(source.emotion, 16)
|
||||||
|
const intent = clampText(source.intent, 20)
|
||||||
|
const topic = clampText(source.topic, 20)
|
||||||
|
if (!explicitText || !emotion || !intent || !topic) {
|
||||||
|
throw new Error('模型输出格式异常:缺少必要字段')
|
||||||
|
}
|
||||||
|
return { explicitText, emotion, intent, topic }
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFallbackJsonMode(error: unknown): boolean {
|
||||||
|
const statusCode = Number((error as ApiRequestError)?.statusCode || 0)
|
||||||
|
if (statusCode === 400 || statusCode === 404 || statusCode === 422) return true
|
||||||
|
const text = `${(error as Error)?.message || ''}\n${(error as ApiRequestError)?.responseBody || ''}`.toLowerCase()
|
||||||
|
return text.includes('response_format') || text.includes('json_object') || text.includes('json mode')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
||||||
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
||||||
@@ -171,7 +234,8 @@ function callApi(
|
|||||||
model: string,
|
model: string,
|
||||||
messages: Array<{ role: string; content: string }>,
|
messages: Array<{ role: string; content: string }>,
|
||||||
timeoutMs: number = API_TIMEOUT_MS,
|
timeoutMs: number = API_TIMEOUT_MS,
|
||||||
maxTokens: number = API_MAX_TOKENS_DEFAULT
|
maxTokens: number = API_MAX_TOKENS_DEFAULT,
|
||||||
|
options?: { responseFormatJson?: boolean }
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||||
@@ -183,13 +247,17 @@ function callApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = JSON.stringify({
|
const payload: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
max_tokens: normalizeApiMaxTokens(maxTokens),
|
max_tokens: normalizeApiMaxTokens(maxTokens),
|
||||||
temperature: API_TEMPERATURE,
|
temperature: API_TEMPERATURE,
|
||||||
stream: false
|
stream: false
|
||||||
})
|
}
|
||||||
|
if (options?.responseFormatJson) {
|
||||||
|
payload.response_format = { type: 'json_object' }
|
||||||
|
}
|
||||||
|
const body = JSON.stringify(payload)
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
hostname: urlObj.hostname,
|
hostname: urlObj.hostname,
|
||||||
@@ -210,6 +278,10 @@ function callApi(
|
|||||||
res.on('data', (chunk) => { data += chunk })
|
res.on('data', (chunk) => { data += chunk })
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
try {
|
try {
|
||||||
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
reject(new ApiRequestError(`API 请求失败 (${res.statusCode}): ${data.slice(0, 200)}`, res.statusCode, data))
|
||||||
|
return
|
||||||
|
}
|
||||||
const parsed = JSON.parse(data)
|
const parsed = JSON.parse(data)
|
||||||
const content = parsed?.choices?.[0]?.message?.content
|
const content = parsed?.choices?.[0]?.message?.content
|
||||||
if (typeof content === 'string' && content.trim()) {
|
if (typeof content === 'string' && content.trim()) {
|
||||||
@@ -590,6 +662,207 @@ ${topMentionText}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateMessageInsight(params: {
|
||||||
|
sessionId: string
|
||||||
|
displayName?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
targetLocalId?: number
|
||||||
|
targetCreateTime?: number
|
||||||
|
targetMessageKey?: string
|
||||||
|
targetText: string
|
||||||
|
targetSenderName?: string
|
||||||
|
contextCount?: number
|
||||||
|
forceRefresh?: boolean
|
||||||
|
}): Promise<{ success: boolean; message: string; cached?: boolean; recordId?: string; data?: MessageInsightAnalysis }> {
|
||||||
|
const enabled = this.config.get('aiMessageInsightEnabled') === true
|
||||||
|
if (!enabled) {
|
||||||
|
return { success: false, message: '请先在设置中开启「消息解析」' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = String(params?.sessionId || '').trim()
|
||||||
|
const targetText = clampText(params?.targetText || '', 500)
|
||||||
|
const targetCreateTime = Math.floor(Number(params?.targetCreateTime || 0))
|
||||||
|
const targetLocalId = Math.floor(Number(params?.targetLocalId || 0))
|
||||||
|
const targetMessageKey = String(params?.targetMessageKey || '').trim()
|
||||||
|
if (!sessionId || !targetText || targetCreateTime <= 0) {
|
||||||
|
return { success: false, message: '目标消息无效,无法解析' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.forceRefresh !== true) {
|
||||||
|
const cached = insightRecordService.findLatestMessageAnalysis({
|
||||||
|
sessionId,
|
||||||
|
targetLocalId,
|
||||||
|
targetCreateTime,
|
||||||
|
targetMessageKey
|
||||||
|
})
|
||||||
|
if (cached?.messageInsight?.analysis) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '已读取缓存解析',
|
||||||
|
cached: true,
|
||||||
|
recordId: cached.id,
|
||||||
|
data: cached.messageInsight.analysis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
|
||||||
|
if (!apiBaseUrl || !apiKey) {
|
||||||
|
return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredContextCount = Number(this.config.get('aiMessageInsightContextCount') || 50)
|
||||||
|
const contextCount = Math.max(1, Math.min(200, Math.floor(Number(params?.contextCount || configuredContextCount) || 50)))
|
||||||
|
const displayName = await this.resolveInsightSessionDisplayName(sessionId, String(params?.displayName || sessionId))
|
||||||
|
const targetSenderName = clampText(params?.targetSenderName || displayName, 40) || displayName
|
||||||
|
const targetTextPreview = clampText(targetText, 120)
|
||||||
|
let avatarUrl = String(params?.avatarUrl || '').trim() || undefined
|
||||||
|
if (!avatarUrl) {
|
||||||
|
try {
|
||||||
|
const contact = await chatService.getContactAvatar(sessionId)
|
||||||
|
avatarUrl = String(contact?.avatarUrl || '').trim() || undefined
|
||||||
|
} catch {
|
||||||
|
avatarUrl = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let beforeMessages: Message[] = []
|
||||||
|
let afterMessages: Message[] = []
|
||||||
|
let contextReadError = ''
|
||||||
|
try {
|
||||||
|
const aroundResult = await chatService.getMessagesAround(
|
||||||
|
sessionId,
|
||||||
|
{ localId: targetLocalId, createTime: targetCreateTime, messageKey: targetMessageKey },
|
||||||
|
contextCount
|
||||||
|
)
|
||||||
|
if (aroundResult.success) {
|
||||||
|
beforeMessages = aroundResult.before || []
|
||||||
|
afterMessages = aroundResult.after || []
|
||||||
|
} else {
|
||||||
|
contextReadError = aroundResult.error || '读取上下文失败'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
contextReadError = (error as Error).message || String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLine = (message: Message) => {
|
||||||
|
const senderName = message.isSend === 1 ? '我' : (message.senderDisplayName || targetSenderName || displayName)
|
||||||
|
return `${this.formatInsightMessageTimestamp(message.createTime)} ${senderName}:${this.formatInsightMessageContent(message)}`
|
||||||
|
}
|
||||||
|
const beforeText = beforeMessages.length > 0 ? beforeMessages.map(formatLine).join('\n') : '无'
|
||||||
|
const afterText = afterMessages.length > 0 ? afterMessages.map(formatLine).join('\n') : '无'
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGE_INSIGHT_PROMPT = `你是一个克制、准确的聊天语义分析助手。你的任务是把用户选中的一句聊天消息做深度解析,帮助用户理解对方未明说的含义。
|
||||||
|
|
||||||
|
严格要求:
|
||||||
|
1. 必须且只能输出合法的纯 JSON。
|
||||||
|
2. 禁止输出解释说明、前言后语,禁止使用 Markdown 或代码块。
|
||||||
|
3. 不要编造上下文没有支持的信息;不确定时用谨慎表述。
|
||||||
|
4. explicit_text 用自然中文说明这句话可能想表达的真实含义,80字以内。
|
||||||
|
5. emotion、intent、topic 必须是短标签。
|
||||||
|
|
||||||
|
JSON 输出格式:
|
||||||
|
{
|
||||||
|
"explicit_text": "暗示转明示,80字以内",
|
||||||
|
"emotion": "2-6字情绪标签",
|
||||||
|
"intent": "2-8字意图标签",
|
||||||
|
"topic": "2-8字话题标签"
|
||||||
|
}`
|
||||||
|
const customPrompt = String(this.config.get('aiMessageInsightSystemPrompt') || '').trim()
|
||||||
|
const systemPrompt = customPrompt || DEFAULT_MESSAGE_INSIGHT_PROMPT
|
||||||
|
const userPromptBase = `会话:${displayName}
|
||||||
|
目标发送者:${targetSenderName}
|
||||||
|
目标消息时间:${this.formatInsightMessageTimestamp(targetCreateTime)}
|
||||||
|
|
||||||
|
目标消息:
|
||||||
|
${targetText}
|
||||||
|
|
||||||
|
目标消息之前的上下文(${beforeMessages.length} 条):
|
||||||
|
${beforeText}
|
||||||
|
|
||||||
|
目标消息之后的上下文(${afterMessages.length} 条):
|
||||||
|
${afterText}
|
||||||
|
|
||||||
|
请分析目标消息,只输出指定 JSON。`
|
||||||
|
const userPrompt = appendPromptCurrentTime(userPromptBase)
|
||||||
|
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||||
|
const requestMessages = [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt }
|
||||||
|
]
|
||||||
|
|
||||||
|
let rawOutput = ''
|
||||||
|
let responseFormatJson = true
|
||||||
|
let responseFormatFallback = false
|
||||||
|
let responseFormatFallbackReason = ''
|
||||||
|
const startedAt = Date.now()
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
rawOutput = await callApi(apiBaseUrl, apiKey, model, requestMessages, API_TIMEOUT_MS, maxTokens, { responseFormatJson: true })
|
||||||
|
} catch (error) {
|
||||||
|
if (!shouldFallbackJsonMode(error)) throw error
|
||||||
|
responseFormatJson = false
|
||||||
|
responseFormatFallback = true
|
||||||
|
responseFormatFallbackReason = (error as Error).message || 'response_format 不受支持'
|
||||||
|
rawOutput = await callApi(apiBaseUrl, apiKey, model, requestMessages, API_TIMEOUT_MS, maxTokens)
|
||||||
|
}
|
||||||
|
const analysis = parseMessageInsightAnalysis(rawOutput)
|
||||||
|
const finalInsight = analysis.explicitText
|
||||||
|
const log: InsightRecordLog = {
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
maxTokens,
|
||||||
|
temperature: API_TEMPERATURE,
|
||||||
|
triggerReason: 'message_analysis',
|
||||||
|
allowContext: true,
|
||||||
|
contextCount,
|
||||||
|
systemPrompt,
|
||||||
|
userPrompt,
|
||||||
|
rawOutput,
|
||||||
|
finalInsight,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
responseFormatJson,
|
||||||
|
responseFormatFallback,
|
||||||
|
responseFormatFallbackReason,
|
||||||
|
targetMessage: {
|
||||||
|
localId: targetLocalId,
|
||||||
|
createTime: targetCreateTime,
|
||||||
|
messageKey: targetMessageKey,
|
||||||
|
senderName: targetSenderName,
|
||||||
|
textPreview: targetTextPreview
|
||||||
|
},
|
||||||
|
contextStats: {
|
||||||
|
requested: contextCount,
|
||||||
|
beforeTarget: beforeMessages.length,
|
||||||
|
afterTarget: afterMessages.length,
|
||||||
|
readError: contextReadError || undefined
|
||||||
|
},
|
||||||
|
parsedAnalysis: analysis
|
||||||
|
}
|
||||||
|
const record = insightRecordService.addRecord({
|
||||||
|
sessionId,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
sourceType: 'message_analysis',
|
||||||
|
triggerReason: 'message_analysis',
|
||||||
|
insight: finalInsight,
|
||||||
|
messageInsight: {
|
||||||
|
targetLocalId,
|
||||||
|
targetCreateTime,
|
||||||
|
targetMessageKey,
|
||||||
|
targetSenderName,
|
||||||
|
targetTextPreview,
|
||||||
|
analysis
|
||||||
|
},
|
||||||
|
log
|
||||||
|
})
|
||||||
|
return { success: true, message: '解析完成', cached: false, recordId: record.id, data: analysis }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: `解析失败:${(error as Error).message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 私有方法 ────────────────────────────────────────────────────────────────
|
// ── 私有方法 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private isEnabled(): boolean {
|
private isEnabled(): boolean {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface ChatMessageBubbleProps {
|
|||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
onContextMenu?: (event: React.MouseEvent, message: Message) => void
|
onContextMenu?: (event: React.MouseEvent, message: Message) => void
|
||||||
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void
|
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void
|
||||||
|
actionNode?: React.ReactNode
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
portal?: React.ReactNode
|
portal?: React.ReactNode
|
||||||
}
|
}
|
||||||
@@ -57,6 +58,7 @@ function ChatMessageBubble({
|
|||||||
isSelected,
|
isSelected,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onToggleSelection,
|
onToggleSelection,
|
||||||
|
actionNode,
|
||||||
children,
|
children,
|
||||||
portal
|
portal
|
||||||
}: ChatMessageBubbleProps) {
|
}: ChatMessageBubbleProps) {
|
||||||
@@ -92,10 +94,18 @@ function ChatMessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
<div className="bubble-body">
|
<div className="bubble-body">
|
||||||
{isGroupChat && !isSent && (
|
{isGroupChat && !isSent && (
|
||||||
<div className="sender-name">
|
<div className="sender-line">
|
||||||
{resolvedSenderName || '群成员'}
|
<div className="sender-name">
|
||||||
|
{resolvedSenderName || '群成员'}
|
||||||
|
</div>
|
||||||
|
{actionNode}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isGroupChat && !isSent && actionNode ? (
|
||||||
|
<div className="message-action-floating">
|
||||||
|
{actionNode}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,6 +141,7 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps) {
|
|||||||
prev.isSelected === next.isSelected &&
|
prev.isSelected === next.isSelected &&
|
||||||
prev.onContextMenu === next.onContextMenu &&
|
prev.onContextMenu === next.onContextMenu &&
|
||||||
prev.onToggleSelection === next.onToggleSelection &&
|
prev.onToggleSelection === next.onToggleSelection &&
|
||||||
|
prev.actionNode === next.actionNode &&
|
||||||
prev.children === next.children &&
|
prev.children === next.children &&
|
||||||
prev.portal === next.portal
|
prev.portal === next.portal
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1922,6 +1922,10 @@
|
|||||||
|
|
||||||
.message-wrapper.new-message {
|
.message-wrapper.new-message {
|
||||||
animation: messagePop 0.35s ease-out;
|
animation: messagePop 0.35s ease-out;
|
||||||
|
|
||||||
|
.message-bubble:not(.system) .bubble-content {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes messagePop {
|
@keyframes messagePop {
|
||||||
@@ -5828,6 +5832,217 @@
|
|||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sender-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 18px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
|
||||||
|
.sender-name {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-floating {
|
||||||
|
height: 18px;
|
||||||
|
margin: 0 0 3px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-trigger {
|
||||||
|
height: 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(3px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.16s ease, color 0.16s ease, background 0.16s ease;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-wrapper-with-selection:hover .message-insight-trigger,
|
||||||
|
.message-insight-trigger:focus-visible {
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-trigger:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4100;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-card {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 4101;
|
||||||
|
width: min(336px, calc(100vw - 16px));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: messageInsightPop 0.14s ease-out;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-card-header {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 10px 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-refresh {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--primary);
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-card-body {
|
||||||
|
min-height: 132px;
|
||||||
|
padding: 13px 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-loading,
|
||||||
|
.message-insight-error {
|
||||||
|
min-height: 104px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-error {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--primary);
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.62;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 12px 0;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-insight-tag {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
&.mood {
|
||||||
|
color: #8a5a00;
|
||||||
|
background: rgba(245, 158, 11, 0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.intent {
|
||||||
|
color: #225f5c;
|
||||||
|
background: rgba(91, 147, 144, 0.14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes messageInsightPop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ambient Reply dark mode / alternate adjustments handled via CSS variables
|
// Ambient Reply dark mode / alternate adjustments handled via CSS variables
|
||||||
|
|
||||||
.link-message,
|
.link-message,
|
||||||
|
|||||||
+388
-4
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
|
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper, Star } from 'lucide-react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||||
@@ -55,6 +55,12 @@ interface PendingFootprintJumpPayload {
|
|||||||
createTime: number
|
createTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingMessageAnalysisJumpPayload {
|
||||||
|
sessionId: string
|
||||||
|
localId: number
|
||||||
|
createTime: number
|
||||||
|
}
|
||||||
|
|
||||||
interface QuotedMessageJumpTarget {
|
interface QuotedMessageJumpTarget {
|
||||||
sourceMessageKey: string
|
sourceMessageKey: string
|
||||||
sourceCreateTime: number
|
sourceCreateTime: number
|
||||||
@@ -1613,6 +1619,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const [globalMsgSearchError, setGlobalMsgSearchError] = useState<string | null>(null)
|
const [globalMsgSearchError, setGlobalMsgSearchError] = useState<string | null>(null)
|
||||||
const pendingInSessionSearchRef = useRef<PendingInSessionSearchPayload | null>(null)
|
const pendingInSessionSearchRef = useRef<PendingInSessionSearchPayload | null>(null)
|
||||||
const pendingFootprintJumpRef = useRef<PendingFootprintJumpPayload | null>(null)
|
const pendingFootprintJumpRef = useRef<PendingFootprintJumpPayload | null>(null)
|
||||||
|
const pendingMessageAnalysisJumpRef = useRef<PendingMessageAnalysisJumpPayload | null>(null)
|
||||||
|
const messageAnalysisJumpLoadKeyRef = useRef<string | null>(null)
|
||||||
const pendingQuotedMessageJumpRef = useRef<QuotedMessageJumpTarget | null>(null)
|
const pendingQuotedMessageJumpRef = useRef<QuotedMessageJumpTarget | null>(null)
|
||||||
const loadMessagesRef = useRef<LoadMessagesFn | null>(null)
|
const loadMessagesRef = useRef<LoadMessagesFn | null>(null)
|
||||||
const pendingGlobalMsgSearchReplayRef = useRef<string | null>(null)
|
const pendingGlobalMsgSearchReplayRef = useRef<string | null>(null)
|
||||||
@@ -1637,6 +1645,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
|
|
||||||
const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys])
|
const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys])
|
||||||
|
const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false)
|
||||||
|
const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50)
|
||||||
const messageKeySetRef = useRef<Set<string>>(new Set())
|
const messageKeySetRef = useRef<Set<string>>(new Set())
|
||||||
const lastMessageTimeRef = useRef(0)
|
const lastMessageTimeRef = useRef(0)
|
||||||
const isMessageListAtBottomRef = useRef(true)
|
const isMessageListAtBottomRef = useRef(true)
|
||||||
@@ -3095,6 +3105,36 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let canceled = false
|
||||||
|
|
||||||
|
const loadMessageInsightConfig = () => {
|
||||||
|
void Promise.all([
|
||||||
|
configService.getAiMessageInsightEnabled(),
|
||||||
|
configService.getAiMessageInsightContextCount()
|
||||||
|
])
|
||||||
|
.then(([enabled, contextCount]) => {
|
||||||
|
if (canceled) return
|
||||||
|
setAiMessageInsightEnabled(enabled)
|
||||||
|
setAiMessageInsightContextCount(contextCount)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('加载消息解析配置失败:', error)
|
||||||
|
if (canceled) return
|
||||||
|
setAiMessageInsightEnabled(false)
|
||||||
|
setAiMessageInsightContextCount(50)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMessageInsightConfig()
|
||||||
|
const handleFocus = () => loadMessageInsightConfig()
|
||||||
|
window.addEventListener('focus', handleFocus)
|
||||||
|
return () => {
|
||||||
|
canceled = true
|
||||||
|
window.removeEventListener('focus', handleFocus)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -3111,6 +3151,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
// 同步 currentSessionId 到 ref
|
// 同步 currentSessionId 到 ref
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentSessionRef.current = currentSessionId
|
currentSessionRef.current = currentSessionId
|
||||||
|
messageInsightMemoryCache.clear()
|
||||||
isMessageListAtBottomRef.current = true
|
isMessageListAtBottomRef.current = true
|
||||||
topRangeLoadLockRef.current = false
|
topRangeLoadLockRef.current = false
|
||||||
bottomRangeLoadLockRef.current = false
|
bottomRangeLoadLockRef.current = false
|
||||||
@@ -5115,6 +5156,66 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, 220)
|
}, 220)
|
||||||
}, [currentSessionId, flashNewMessages, getMessageKey, loadMessages])
|
}, [currentSessionId, flashNewMessages, getMessageKey, loadMessages])
|
||||||
|
|
||||||
|
const findMessageAnalysisTargetInMessages = useCallback((target: PendingMessageAnalysisJumpPayload): { index: number; message: Message } | null => {
|
||||||
|
if (messages.length === 0) return null
|
||||||
|
const targetLocalId = Math.floor(Number(target.localId || 0))
|
||||||
|
const targetCreateTime = Math.floor(Number(target.createTime || 0))
|
||||||
|
if (targetLocalId <= 0 && targetCreateTime <= 0) return null
|
||||||
|
|
||||||
|
let bestIndex = -1
|
||||||
|
let bestMessage: Message | null = null
|
||||||
|
let bestScore = -1
|
||||||
|
for (let index = 0; index < messages.length; index++) {
|
||||||
|
const item = messages[index]
|
||||||
|
const localId = Math.floor(Number(item.localId || 0))
|
||||||
|
const createTime = Math.floor(Number(item.createTime || 0))
|
||||||
|
const localIdMatch = targetLocalId > 0 && localId === targetLocalId
|
||||||
|
const exactTimeMatch = targetCreateTime > 0 && createTime === targetCreateTime
|
||||||
|
const nearTimeMatch = targetCreateTime > 0 && Math.abs(createTime - targetCreateTime) <= 1
|
||||||
|
if (!localIdMatch && !exactTimeMatch && !nearTimeMatch) continue
|
||||||
|
|
||||||
|
const score = (localIdMatch ? 100 : 0) + (exactTimeMatch ? 40 : (nearTimeMatch ? 20 : 0))
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestIndex = index
|
||||||
|
bestMessage = item
|
||||||
|
bestScore = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestMessage ? { index: bestIndex, message: bestMessage } : null
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const jumpToMessageAnalysisTarget = useCallback((target: PendingMessageAnalysisJumpPayload, behavior: 'auto' | 'smooth' = 'auto') => {
|
||||||
|
const resolved = findMessageAnalysisTargetInMessages(target)
|
||||||
|
if (resolved) {
|
||||||
|
pendingMessageAnalysisJumpRef.current = null
|
||||||
|
messageAnalysisJumpLoadKeyRef.current = null
|
||||||
|
scrollToResolvedMessage(resolved, behavior)
|
||||||
|
navigate('/chat', { replace: true })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [findMessageAnalysisTargetInMessages, navigate, scrollToResolvedMessage])
|
||||||
|
|
||||||
|
const requestMessageAnalysisWindowLoad = useCallback((target: PendingMessageAnalysisJumpPayload) => {
|
||||||
|
const targetSessionId = String(target.sessionId || '').trim()
|
||||||
|
const targetTime = Math.floor(Number(target.createTime || 0))
|
||||||
|
if (!targetSessionId || targetTime <= 0) return
|
||||||
|
const loadKey = `${targetSessionId}:${Math.floor(Number(target.localId || 0))}:${targetTime}`
|
||||||
|
if (messageAnalysisJumpLoadKeyRef.current === loadKey) return
|
||||||
|
messageAnalysisJumpLoadKeyRef.current = loadKey
|
||||||
|
|
||||||
|
const requestSeq = inSessionResultJumpRequestSeqRef.current + 1
|
||||||
|
inSessionResultJumpRequestSeqRef.current = requestSeq
|
||||||
|
setCurrentOffset(0)
|
||||||
|
setJumpStartTime(0)
|
||||||
|
setJumpEndTime(targetTime + 1)
|
||||||
|
suppressAutoLoadLaterRef.current = true
|
||||||
|
void loadMessagesRef.current?.(targetSessionId, 0, 0, targetTime + 1, false, {
|
||||||
|
forceInitialLimit: 120,
|
||||||
|
inSessionJumpRequestSeq: requestSeq
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
suppressScrollToBottomButton(220)
|
suppressScrollToBottomButton(220)
|
||||||
@@ -5868,7 +5969,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
selectSessionById
|
selectSessionById
|
||||||
])
|
])
|
||||||
|
|
||||||
// 监听 URL 参数中的会话/锚点(通知跳转 + 足迹锚点定位)
|
// 监听 URL 参数中的会话/锚点(通知跳转 + 足迹/深度解析锚点定位)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理
|
if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理
|
||||||
const params = new URLSearchParams(location.search)
|
const params = new URLSearchParams(location.search)
|
||||||
@@ -5883,6 +5984,11 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
&& jumpLocalId > 0
|
&& jumpLocalId > 0
|
||||||
&& Number.isFinite(jumpCreateTime)
|
&& Number.isFinite(jumpCreateTime)
|
||||||
&& jumpCreateTime > 0
|
&& jumpCreateTime > 0
|
||||||
|
const hasMessageAnalysisAnchor = jumpSource === 'messageAnalysis'
|
||||||
|
&& Number.isFinite(jumpLocalId)
|
||||||
|
&& jumpLocalId > 0
|
||||||
|
&& Number.isFinite(jumpCreateTime)
|
||||||
|
&& jumpCreateTime > 0
|
||||||
|
|
||||||
if (hasFootprintAnchor) {
|
if (hasFootprintAnchor) {
|
||||||
pendingFootprintJumpRef.current = {
|
pendingFootprintJumpRef.current = {
|
||||||
@@ -5912,7 +6018,27 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasMessageAnalysisAnchor) {
|
||||||
|
const pendingTarget = {
|
||||||
|
sessionId: urlSessionId,
|
||||||
|
localId: jumpLocalId,
|
||||||
|
createTime: jumpCreateTime
|
||||||
|
}
|
||||||
|
messageAnalysisJumpLoadKeyRef.current = null
|
||||||
|
pendingMessageAnalysisJumpRef.current = pendingTarget
|
||||||
|
if (currentSessionId !== urlSessionId) {
|
||||||
|
selectSessionById(urlSessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!jumpToMessageAnalysisTarget(pendingTarget, 'auto')) {
|
||||||
|
requestMessageAnalysisWindowLoad(pendingTarget)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
pendingFootprintJumpRef.current = null
|
pendingFootprintJumpRef.current = null
|
||||||
|
pendingMessageAnalysisJumpRef.current = null
|
||||||
|
messageAnalysisJumpLoadKeyRef.current = null
|
||||||
if (currentSessionId !== urlSessionId) {
|
if (currentSessionId !== urlSessionId) {
|
||||||
selectSessionById(urlSessionId)
|
selectSessionById(urlSessionId)
|
||||||
}
|
}
|
||||||
@@ -5926,6 +6052,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
selectSessionById,
|
selectSessionById,
|
||||||
handleInSessionResultJump,
|
handleInSessionResultJump,
|
||||||
|
jumpToMessageAnalysisTarget,
|
||||||
|
requestMessageAnalysisWindowLoad,
|
||||||
navigate
|
navigate
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -5952,6 +6080,24 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
navigate('/chat', { replace: true })
|
navigate('/chat', { replace: true })
|
||||||
}, [isConnected, isConnecting, currentSessionId, handleInSessionResultJump, navigate])
|
}, [isConnected, isConnecting, currentSessionId, handleInSessionResultJump, navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pending = pendingMessageAnalysisJumpRef.current
|
||||||
|
if (!pending) return
|
||||||
|
if (!isConnected || isConnecting) return
|
||||||
|
if (currentSessionId !== pending.sessionId) return
|
||||||
|
if (jumpToMessageAnalysisTarget(pending, 'auto')) return
|
||||||
|
if (isLoadingMessages || isSessionSwitching) return
|
||||||
|
requestMessageAnalysisWindowLoad(pending)
|
||||||
|
}, [
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
currentSessionId,
|
||||||
|
isLoadingMessages,
|
||||||
|
isSessionSwitching,
|
||||||
|
jumpToMessageAnalysisTarget,
|
||||||
|
requestMessageAnalysisWindowLoad
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!standaloneSessionWindow || !normalizedInitialSessionId) return
|
if (!standaloneSessionWindow || !normalizedInitialSessionId) return
|
||||||
if (!isConnected || isConnecting) {
|
if (!isConnected || isConnecting) {
|
||||||
@@ -6887,6 +7033,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
messageKey={messageKey}
|
messageKey={messageKey}
|
||||||
isSelected={selectedMessages.has(messageKey)}
|
isSelected={selectedMessages.has(messageKey)}
|
||||||
onToggleSelection={handleToggleSelection}
|
onToggleSelection={handleToggleSelection}
|
||||||
|
aiMessageInsightEnabled={aiMessageInsightEnabled}
|
||||||
|
aiMessageInsightContextCount={aiMessageInsightContextCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -6906,7 +7054,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
handleJumpToQuotedMessage,
|
handleJumpToQuotedMessage,
|
||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
selectedMessages,
|
selectedMessages,
|
||||||
handleToggleSelection
|
handleToggleSelection,
|
||||||
|
aiMessageInsightEnabled,
|
||||||
|
aiMessageInsightContextCount
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -8401,6 +8551,32 @@ const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?:
|
|||||||
})
|
})
|
||||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||||
|
|
||||||
|
type MessageInsightAnalysis = {
|
||||||
|
explicitText: string
|
||||||
|
emotion: string
|
||||||
|
intent: string
|
||||||
|
topic: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageInsightState = {
|
||||||
|
status: 'idle' | 'loading' | 'success' | 'error'
|
||||||
|
data?: MessageInsightAnalysis
|
||||||
|
error?: string
|
||||||
|
cached?: boolean
|
||||||
|
recordId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageInsightMemoryCache = new Map<string, MessageInsightState>()
|
||||||
|
|
||||||
|
function buildMessageInsightCacheKey(sessionId: string, message: Message, messageKey: string): string {
|
||||||
|
return [
|
||||||
|
String(sessionId || '').trim(),
|
||||||
|
Math.floor(Number(message.localId || 0)),
|
||||||
|
Math.floor(Number(message.createTime || 0)),
|
||||||
|
messageKey
|
||||||
|
].join(':')
|
||||||
|
}
|
||||||
|
|
||||||
function getSharedImageDecryptTask(
|
function getSharedImageDecryptTask(
|
||||||
key: string,
|
key: string,
|
||||||
createTask: () => Promise<SharedImageDecryptResult>
|
createTask: () => Promise<SharedImageDecryptResult>
|
||||||
@@ -8456,6 +8632,181 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 消息气泡组件
|
// 消息气泡组件
|
||||||
|
function MessageInsightControl({
|
||||||
|
message,
|
||||||
|
messageKey,
|
||||||
|
session,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
senderName,
|
||||||
|
targetText,
|
||||||
|
contextCount
|
||||||
|
}: {
|
||||||
|
message: Message
|
||||||
|
messageKey: string
|
||||||
|
session: ChatSession
|
||||||
|
displayName?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
senderName?: string
|
||||||
|
targetText: string
|
||||||
|
contextCount: number
|
||||||
|
}) {
|
||||||
|
const anchorRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
const cardRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const cacheKey = useMemo(() => buildMessageInsightCacheKey(session.username, message, messageKey), [message, messageKey, session.username])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [state, setState] = useState<MessageInsightState>(() => messageInsightMemoryCache.get(cacheKey) || { status: 'idle' })
|
||||||
|
const [position, setPosition] = useState<{ top: number; left: number; placement: 'top' | 'bottom' }>({ top: 0, left: 0, placement: 'top' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState(messageInsightMemoryCache.get(cacheKey) || { status: 'idle' })
|
||||||
|
setOpen(false)
|
||||||
|
}, [cacheKey])
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
const anchor = anchorRef.current
|
||||||
|
if (!anchor) return
|
||||||
|
const rect = anchor.getBoundingClientRect()
|
||||||
|
const cardWidth = cardRef.current?.offsetWidth || 320
|
||||||
|
const cardHeight = cardRef.current?.offsetHeight || 190
|
||||||
|
const gap = 10
|
||||||
|
const preferredTop = rect.top - cardHeight - gap
|
||||||
|
const placement: 'top' | 'bottom' = preferredTop < 8 ? 'bottom' : 'top'
|
||||||
|
const top = placement === 'top' ? preferredTop : rect.bottom + gap
|
||||||
|
const left = Math.min(Math.max(8, rect.left + 20), Math.max(8, window.innerWidth - cardWidth - 8))
|
||||||
|
setPosition({
|
||||||
|
top: Math.min(Math.max(8, top), Math.max(8, window.innerHeight - cardHeight - 8)),
|
||||||
|
left,
|
||||||
|
placement
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
updatePosition()
|
||||||
|
const handle = () => updatePosition()
|
||||||
|
window.addEventListener('resize', handle)
|
||||||
|
window.addEventListener('scroll', handle, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handle)
|
||||||
|
window.removeEventListener('scroll', handle, true)
|
||||||
|
}
|
||||||
|
}, [open, updatePosition])
|
||||||
|
|
||||||
|
const requestInsight = useCallback(async (forceRefresh = false) => {
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = messageInsightMemoryCache.get(cacheKey)
|
||||||
|
if (cached?.status === 'success') {
|
||||||
|
setState(cached)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setState({ status: 'loading' })
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.insight.generateMessageInsight({
|
||||||
|
sessionId: session.username,
|
||||||
|
displayName: displayName || session.displayName || session.username,
|
||||||
|
avatarUrl: avatarUrl || session.avatarUrl,
|
||||||
|
targetLocalId: message.localId,
|
||||||
|
targetCreateTime: message.createTime,
|
||||||
|
targetMessageKey: messageKey,
|
||||||
|
targetText,
|
||||||
|
targetSenderName: senderName || displayName || session.displayName || session.username,
|
||||||
|
contextCount,
|
||||||
|
forceRefresh
|
||||||
|
})
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const nextState: MessageInsightState = {
|
||||||
|
status: 'success',
|
||||||
|
data: result.data,
|
||||||
|
cached: result.cached === true,
|
||||||
|
recordId: result.recordId
|
||||||
|
}
|
||||||
|
messageInsightMemoryCache.set(cacheKey, nextState)
|
||||||
|
setState(nextState)
|
||||||
|
} else {
|
||||||
|
setState({ status: 'error', error: result.message || '解析失败' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setState({ status: 'error', error: (error as Error).message || '解析失败' })
|
||||||
|
}
|
||||||
|
}, [avatarUrl, cacheKey, contextCount, displayName, message.createTime, message.localId, messageKey, senderName, session.avatarUrl, session.displayName, session.username, targetText])
|
||||||
|
|
||||||
|
const handleOpen = useCallback((event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setOpen(true)
|
||||||
|
window.setTimeout(updatePosition, 0)
|
||||||
|
const cached = messageInsightMemoryCache.get(cacheKey)
|
||||||
|
if (cached?.status === 'success') {
|
||||||
|
setState(cached)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void requestInsight(false)
|
||||||
|
}, [cacheKey, requestInsight, updatePosition])
|
||||||
|
|
||||||
|
const card = open ? createPortal(
|
||||||
|
<>
|
||||||
|
<button className="message-insight-backdrop" type="button" aria-label="关闭深度解析" onClick={() => setOpen(false)} />
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
className={`message-insight-card open placement-${position.placement}`}
|
||||||
|
style={{ top: position.top, left: position.left }}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="message-insight-card-header">
|
||||||
|
<Star size={14} />
|
||||||
|
<span>深度解析</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="message-insight-refresh"
|
||||||
|
title="重新解析"
|
||||||
|
onClick={() => void requestInsight(true)}
|
||||||
|
disabled={state.status === 'loading'}
|
||||||
|
>
|
||||||
|
{state.status === 'loading' ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="message-insight-card-body">
|
||||||
|
{state.status === 'loading' && (
|
||||||
|
<div className="message-insight-loading">
|
||||||
|
<Loader2 size={15} className="spin" />
|
||||||
|
<span>解析中...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state.status === 'error' && (
|
||||||
|
<div className="message-insight-error">
|
||||||
|
<span>{state.error || '解析失败'}</span>
|
||||||
|
<button type="button" onClick={() => void requestInsight(true)}>重试</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state.status === 'success' && state.data && (
|
||||||
|
<>
|
||||||
|
<p className="message-insight-text">{state.data.explicitText}</p>
|
||||||
|
<div className="message-insight-divider" />
|
||||||
|
<div className="message-insight-tags">
|
||||||
|
<span className="message-insight-tag mood">情绪:{state.data.emotion}</span>
|
||||||
|
<span className="message-insight-tag intent">意图:{state.data.intent}</span>
|
||||||
|
<span className="message-insight-tag">话题:{state.data.topic}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button ref={anchorRef} type="button" className="message-insight-trigger" onClick={handleOpen} title="深度解析">
|
||||||
|
<Star size={12} />
|
||||||
|
<span>深度解析</span>
|
||||||
|
</button>
|
||||||
|
{card}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MessageBubble({
|
function MessageBubble({
|
||||||
message,
|
message,
|
||||||
messageKey,
|
messageKey,
|
||||||
@@ -8471,7 +8822,9 @@ function MessageBubble({
|
|||||||
onJumpToQuotedMessage,
|
onJumpToQuotedMessage,
|
||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
isSelected,
|
isSelected,
|
||||||
onToggleSelection
|
onToggleSelection,
|
||||||
|
aiMessageInsightEnabled,
|
||||||
|
aiMessageInsightContextCount
|
||||||
}: {
|
}: {
|
||||||
message: Message;
|
message: Message;
|
||||||
messageKey: string;
|
messageKey: string;
|
||||||
@@ -8488,6 +8841,8 @@ function MessageBubble({
|
|||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void;
|
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void;
|
||||||
|
aiMessageInsightEnabled?: boolean;
|
||||||
|
aiMessageInsightContextCount?: number;
|
||||||
}) {
|
}) {
|
||||||
const isSystem = isSystemMessage(message.localType)
|
const isSystem = isSystemMessage(message.localType)
|
||||||
const isEmoji = message.localType === 47
|
const isEmoji = message.localType === 47
|
||||||
@@ -9706,6 +10061,32 @@ function MessageBubble({
|
|||||||
const avatarUrl = isSent
|
const avatarUrl = isSent
|
||||||
? (myAvatarUrl || resolvedSenderAvatarUrl)
|
? (myAvatarUrl || resolvedSenderAvatarUrl)
|
||||||
: (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl)
|
: (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl)
|
||||||
|
const canShowMessageInsight = Boolean(
|
||||||
|
aiMessageInsightEnabled &&
|
||||||
|
!isSent &&
|
||||||
|
!isSystem &&
|
||||||
|
!isImage &&
|
||||||
|
!isVideo &&
|
||||||
|
!isVoice &&
|
||||||
|
!isEmoji &&
|
||||||
|
!isCard &&
|
||||||
|
!isCall &&
|
||||||
|
!isType49 &&
|
||||||
|
message.localType === 1 &&
|
||||||
|
cleanedParsedContent.trim()
|
||||||
|
)
|
||||||
|
const messageInsightControl = canShowMessageInsight ? (
|
||||||
|
<MessageInsightControl
|
||||||
|
message={message}
|
||||||
|
messageKey={messageKey}
|
||||||
|
session={session}
|
||||||
|
displayName={session.displayName || session.username}
|
||||||
|
avatarUrl={avatarUrl}
|
||||||
|
senderName={resolvedSenderName || session.displayName || session.username}
|
||||||
|
targetText={cleanedParsedContent}
|
||||||
|
contextCount={aiMessageInsightContextCount || 50}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
|
||||||
// 是否有引用消息
|
// 是否有引用消息
|
||||||
const hasQuote = quotedContent.length > 0
|
const hasQuote = quotedContent.length > 0
|
||||||
@@ -11051,6 +11432,7 @@ function MessageBubble({
|
|||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
onToggleSelection={onToggleSelection}
|
onToggleSelection={onToggleSelection}
|
||||||
|
actionNode={messageInsightControl}
|
||||||
portal={systemAlertPortal}
|
portal={systemAlertPortal}
|
||||||
>
|
>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
@@ -11073,6 +11455,8 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
|
|||||||
if (prevProps.onContextMenu !== nextProps.onContextMenu) return false
|
if (prevProps.onContextMenu !== nextProps.onContextMenu) return false
|
||||||
if (prevProps.onJumpToQuotedMessage !== nextProps.onJumpToQuotedMessage) return false
|
if (prevProps.onJumpToQuotedMessage !== nextProps.onJumpToQuotedMessage) return false
|
||||||
if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false
|
if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false
|
||||||
|
if (prevProps.aiMessageInsightEnabled !== nextProps.aiMessageInsightEnabled) return false
|
||||||
|
if (prevProps.aiMessageInsightContextCount !== nextProps.aiMessageInsightContextCount) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
prevProps.session.username === nextProps.session.username &&
|
prevProps.session.username === nextProps.session.username &&
|
||||||
|
|||||||
@@ -267,6 +267,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insight-source-pill {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(91, 147, 144, 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.message_analysis {
|
||||||
|
background: rgba(245, 158, 11, 0.13);
|
||||||
|
color: #8a5a00;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.insight-time {
|
.insight-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
@@ -282,6 +296,43 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-analysis-target {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-analysis-target-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-analysis-target-text {
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-analysis-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.insight-filter-panel {
|
.insight-filter-panel {
|
||||||
width: var(--insight-panel-width);
|
width: var(--insight-panel-width);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -376,6 +427,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insight-source-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 34px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(91, 147, 144, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.insight-custom-dates {
|
.insight-custom-dates {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
InsightRecordContactFacet,
|
InsightRecordContactFacet,
|
||||||
InsightRecordFilters,
|
InsightRecordFilters,
|
||||||
InsightRecordListResult,
|
InsightRecordListResult,
|
||||||
|
InsightRecordSourceType,
|
||||||
InsightRecordSummary,
|
InsightRecordSummary,
|
||||||
InsightRecordTriggerReason
|
InsightRecordTriggerReason
|
||||||
} from '../types/electron'
|
} from '../types/electron'
|
||||||
@@ -15,6 +16,7 @@ import './InsightInboxPage.scss'
|
|||||||
const INSIGHT_AVATAR_URL = './assets/insight/AI_Insight.png'
|
const INSIGHT_AVATAR_URL = './assets/insight/AI_Insight.png'
|
||||||
|
|
||||||
type DateFilterMode = 'all' | 'today' | 'week' | 'custom'
|
type DateFilterMode = 'all' | 'today' | 'week' | 'custom'
|
||||||
|
type SourceFilterMode = InsightRecordSourceType | 'all'
|
||||||
|
|
||||||
function getStartOfDay(date: Date): number {
|
function getStartOfDay(date: Date): number {
|
||||||
const next = new Date(date)
|
const next = new Date(date)
|
||||||
@@ -62,16 +64,22 @@ function formatGroupDate(timestamp: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTriggerLabel(reason: InsightRecordTriggerReason): string {
|
function getTriggerLabel(reason: InsightRecordTriggerReason): string {
|
||||||
|
if (reason === 'message_analysis') return '深度解析'
|
||||||
if (reason === 'silence') return '沉默提醒'
|
if (reason === 'silence') return '沉默提醒'
|
||||||
if (reason === 'test') return '测试见解'
|
if (reason === 'test') return '测试见解'
|
||||||
return '活跃分析'
|
return '活跃分析'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSourceLabel(sourceType?: InsightRecordSourceType): string {
|
||||||
|
return sourceType === 'message_analysis' ? '深度解析' : 'AI 见解'
|
||||||
|
}
|
||||||
|
|
||||||
function buildLogText(record: InsightRecord): string {
|
function buildLogText(record: InsightRecord): string {
|
||||||
const log = record.log
|
const log = record.log
|
||||||
return [
|
const lines = [
|
||||||
`时间:${new Date(record.createdAt).toLocaleString('zh-CN')}`,
|
`时间:${new Date(record.createdAt).toLocaleString('zh-CN')}`,
|
||||||
`联系人:${record.displayName} (${record.sessionId})`,
|
`联系人:${record.displayName} (${record.sessionId})`,
|
||||||
|
`来源:${getSourceLabel(record.sourceType)}`,
|
||||||
`触发类型:${getTriggerLabel(record.triggerReason)}`,
|
`触发类型:${getTriggerLabel(record.triggerReason)}`,
|
||||||
`接口地址:${log.endpoint}`,
|
`接口地址:${log.endpoint}`,
|
||||||
`模型:${log.model}`,
|
`模型:${log.model}`,
|
||||||
@@ -90,7 +98,23 @@ function buildLogText(record: InsightRecord): string {
|
|||||||
'',
|
'',
|
||||||
'最终见解:',
|
'最终见解:',
|
||||||
log.finalInsight
|
log.finalInsight
|
||||||
].join('\n')
|
]
|
||||||
|
|
||||||
|
if (record.sourceType === 'message_analysis') {
|
||||||
|
lines.splice(8, 0,
|
||||||
|
`JSON Mode:${log.responseFormatJson ? '启用' : '未启用'}`,
|
||||||
|
`JSON Mode 降级:${log.responseFormatFallback ? '是' : '否'}`,
|
||||||
|
`降级原因:${log.responseFormatFallbackReason || '无'}`,
|
||||||
|
`上下文:请求 ${log.contextStats?.requested ?? log.contextCount} 条,前 ${log.contextStats?.beforeTarget ?? 0} 条,后 ${log.contextStats?.afterTarget ?? 0} 条`,
|
||||||
|
`上下文读取异常:${log.contextStats?.readError || '无'}`
|
||||||
|
)
|
||||||
|
lines.splice(4, 0,
|
||||||
|
`目标消息:${record.messageInsight?.targetSenderName || log.targetMessage?.senderName || ''}:${record.messageInsight?.targetTextPreview || log.targetMessage?.textPreview || ''}`,
|
||||||
|
`目标定位:localId=${record.messageInsight?.targetLocalId || log.targetMessage?.localId || 0}, createTime=${record.messageInsight?.targetCreateTime || log.targetMessage?.createTime || 0}, key=${record.messageInsight?.targetMessageKey || log.targetMessage?.messageKey || ''}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InsightInboxPage() {
|
export default function InsightInboxPage() {
|
||||||
@@ -101,6 +125,7 @@ export default function InsightInboxPage() {
|
|||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
const [contactSearch, setContactSearch] = useState('')
|
const [contactSearch, setContactSearch] = useState('')
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState('')
|
const [selectedSessionId, setSelectedSessionId] = useState('')
|
||||||
|
const [sourceType, setSourceType] = useState<SourceFilterMode>('all')
|
||||||
const [dateMode, setDateMode] = useState<DateFilterMode>('all')
|
const [dateMode, setDateMode] = useState<DateFilterMode>('all')
|
||||||
const [customStart, setCustomStart] = useState(formatDateInput(new Date()))
|
const [customStart, setCustomStart] = useState(formatDateInput(new Date()))
|
||||||
const [customEnd, setCustomEnd] = useState(formatDateInput(new Date()))
|
const [customEnd, setCustomEnd] = useState(formatDateInput(new Date()))
|
||||||
@@ -133,11 +158,12 @@ export default function InsightInboxPage() {
|
|||||||
const filters = useMemo<InsightRecordFilters>(() => ({
|
const filters = useMemo<InsightRecordFilters>(() => ({
|
||||||
keyword: keyword.trim() || undefined,
|
keyword: keyword.trim() || undefined,
|
||||||
sessionId: selectedSessionId || undefined,
|
sessionId: selectedSessionId || undefined,
|
||||||
|
sourceType,
|
||||||
startTime: dateRange.startTime,
|
startTime: dateRange.startTime,
|
||||||
endTime: dateRange.endTime,
|
endTime: dateRange.endTime,
|
||||||
limit: 200,
|
limit: 200,
|
||||||
offset: 0
|
offset: 0
|
||||||
}), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId])
|
}), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId, sourceType])
|
||||||
|
|
||||||
const loadRecords = useCallback(async () => {
|
const loadRecords = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -200,6 +226,16 @@ export default function InsightInboxPage() {
|
|||||||
}, [contactSearch, contacts])
|
}, [contactSearch, contacts])
|
||||||
|
|
||||||
const openChat = (record: InsightRecordSummary) => {
|
const openChat = (record: InsightRecordSummary) => {
|
||||||
|
if (record.sourceType === 'message_analysis' && record.messageInsight) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
sessionId: record.sessionId,
|
||||||
|
jumpSource: 'messageAnalysis',
|
||||||
|
jumpLocalId: String(record.messageInsight.targetLocalId || 0),
|
||||||
|
jumpCreateTime: String(record.messageInsight.targetCreateTime || 0)
|
||||||
|
})
|
||||||
|
navigate(`/chat?${query.toString()}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
navigate(`/chat?sessionId=${encodeURIComponent(record.sessionId)}`)
|
navigate(`/chat?sessionId=${encodeURIComponent(record.sessionId)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +341,7 @@ export default function InsightInboxPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="insight-card-actions">
|
<div className="insight-card-actions">
|
||||||
|
<span className={`insight-source-pill ${record.sourceType || 'insight'}`}>{getSourceLabel(record.sourceType)}</span>
|
||||||
<span className={`insight-trigger-pill ${record.triggerReason}`}>{getTriggerLabel(record.triggerReason)}</span>
|
<span className={`insight-trigger-pill ${record.triggerReason}`}>{getTriggerLabel(record.triggerReason)}</span>
|
||||||
<span className="insight-time">{formatRecordTime(record.createdAt)}</span>
|
<span className="insight-time">{formatRecordTime(record.createdAt)}</span>
|
||||||
<button className="insight-action-btn" onClick={() => openChat(record)} title="打开聊天">
|
<button className="insight-action-btn" onClick={() => openChat(record)} title="打开聊天">
|
||||||
@@ -318,7 +355,22 @@ export default function InsightInboxPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{record.sourceType === 'message_analysis' && record.messageInsight && (
|
||||||
|
<div className="message-analysis-target">
|
||||||
|
<span className="message-analysis-target-label">目标消息</span>
|
||||||
|
<span className="message-analysis-target-text">
|
||||||
|
{record.messageInsight.targetSenderName}:{record.messageInsight.targetTextPreview}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="insight-body">{record.insight}</p>
|
<p className="insight-body">{record.insight}</p>
|
||||||
|
{record.sourceType === 'message_analysis' && record.messageInsight && (
|
||||||
|
<div className="message-analysis-tags">
|
||||||
|
<span>情绪:{record.messageInsight.analysis.emotion}</span>
|
||||||
|
<span>意图:{record.messageInsight.analysis.intent}</span>
|
||||||
|
<span>话题:{record.messageInsight.analysis.topic}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -347,6 +399,28 @@ export default function InsightInboxPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="insight-filter-widget">
|
||||||
|
<div className="insight-widget-title">
|
||||||
|
<Sparkles size={14} />
|
||||||
|
<span>来源类型</span>
|
||||||
|
</div>
|
||||||
|
<div className="insight-source-tabs">
|
||||||
|
{[
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'insight', label: 'AI 见解' },
|
||||||
|
{ value: 'message_analysis', label: '深度解析' }
|
||||||
|
].map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
className={sourceType === option.value ? 'active' : ''}
|
||||||
|
onClick={() => setSourceType(option.value as SourceFilterMode)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="insight-filter-widget">
|
<div className="insight-filter-widget">
|
||||||
<div className="insight-widget-title">
|
<div className="insight-widget-title">
|
||||||
<CalendarDays size={14} />
|
<CalendarDays size={14} />
|
||||||
@@ -440,9 +514,44 @@ export default function InsightInboxPage() {
|
|||||||
`Max Tokens: ${logRecord.log.maxTokens}`,
|
`Max Tokens: ${logRecord.log.maxTokens}`,
|
||||||
`Temperature: ${logRecord.log.temperature}`,
|
`Temperature: ${logRecord.log.temperature}`,
|
||||||
`Duration: ${logRecord.log.durationMs}ms`,
|
`Duration: ${logRecord.log.durationMs}ms`,
|
||||||
`Trigger: ${getTriggerLabel(logRecord.triggerReason)}`
|
`Source: ${getSourceLabel(logRecord.sourceType)}`,
|
||||||
|
`Trigger: ${getTriggerLabel(logRecord.triggerReason)}`,
|
||||||
|
...(logRecord.sourceType === 'message_analysis'
|
||||||
|
? [
|
||||||
|
`JSON Mode: ${logRecord.log.responseFormatJson ? 'enabled' : 'disabled'}`,
|
||||||
|
`JSON Fallback: ${logRecord.log.responseFormatFallback ? 'yes' : 'no'}`,
|
||||||
|
`Fallback Reason: ${logRecord.log.responseFormatFallbackReason || 'none'}`
|
||||||
|
]
|
||||||
|
: [])
|
||||||
].join('\n')}</pre>
|
].join('\n')}</pre>
|
||||||
</section>
|
</section>
|
||||||
|
{logRecord.sourceType === 'message_analysis' && (
|
||||||
|
<section>
|
||||||
|
<h4>深度解析目标</h4>
|
||||||
|
<pre>{[
|
||||||
|
`Sender: ${logRecord.messageInsight?.targetSenderName || logRecord.log.targetMessage?.senderName || ''}`,
|
||||||
|
`Preview: ${logRecord.messageInsight?.targetTextPreview || logRecord.log.targetMessage?.textPreview || ''}`,
|
||||||
|
`LocalId: ${logRecord.messageInsight?.targetLocalId || logRecord.log.targetMessage?.localId || 0}`,
|
||||||
|
`CreateTime: ${logRecord.messageInsight?.targetCreateTime || logRecord.log.targetMessage?.createTime || 0}`,
|
||||||
|
`MessageKey: ${logRecord.messageInsight?.targetMessageKey || logRecord.log.targetMessage?.messageKey || ''}`,
|
||||||
|
`Context Requested: ${logRecord.log.contextStats?.requested ?? logRecord.log.contextCount}`,
|
||||||
|
`Context Before: ${logRecord.log.contextStats?.beforeTarget ?? 0}`,
|
||||||
|
`Context After: ${logRecord.log.contextStats?.afterTarget ?? 0}`,
|
||||||
|
`Context Error: ${logRecord.log.contextStats?.readError || 'none'}`
|
||||||
|
].join('\n')}</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{logRecord.sourceType === 'message_analysis' && logRecord.log.parsedAnalysis && (
|
||||||
|
<section>
|
||||||
|
<h4>解析字段</h4>
|
||||||
|
<pre>{[
|
||||||
|
`explicitText: ${logRecord.log.parsedAnalysis.explicitText}`,
|
||||||
|
`emotion: ${logRecord.log.parsedAnalysis.emotion}`,
|
||||||
|
`intent: ${logRecord.log.parsedAnalysis.intent}`,
|
||||||
|
`topic: ${logRecord.log.parsedAnalysis.topic}`
|
||||||
|
].join('\n')}</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
<section>
|
<section>
|
||||||
<h4>System Prompt</h4>
|
<h4>System Prompt</h4>
|
||||||
<pre>{logRecord.log.systemPrompt}</pre>
|
<pre>{logRecord.log.systemPrompt}</pre>
|
||||||
|
|||||||
+118
-5
@@ -32,9 +32,10 @@ type SettingsTab =
|
|||||||
| 'aiCommon'
|
| 'aiCommon'
|
||||||
| 'insight'
|
| 'insight'
|
||||||
| 'aiFootprint'
|
| 'aiFootprint'
|
||||||
|
| 'aiMessageInsight'
|
||||||
| 'autoDownload'
|
| 'autoDownload'
|
||||||
|
|
||||||
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string; icon: React.ElementType }[] = [
|
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint' | 'aiMessageInsight'>; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'appearance', label: '外观', icon: Palette },
|
{ id: 'appearance', label: '外观', icon: Palette },
|
||||||
{ id: 'notification', label: '通知', icon: Bell },
|
{ id: 'notification', label: '通知', icon: Bell },
|
||||||
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
|
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
|
||||||
@@ -56,10 +57,11 @@ const filteredTabs = tabs.filter(tab => {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint'>; label: string }> = [
|
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint' | 'aiMessageInsight'>; label: string }> = [
|
||||||
{ id: 'aiCommon', label: '基础配置' },
|
{ id: 'aiCommon', label: '基础配置' },
|
||||||
{ id: 'insight', label: 'AI 见解' },
|
{ id: 'insight', label: 'AI 见解' },
|
||||||
{ id: 'aiFootprint', label: 'AI 足迹' }
|
{ id: 'aiFootprint', label: 'AI 足迹' },
|
||||||
|
{ id: 'aiMessageInsight', label: '消息解析' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||||
@@ -327,6 +329,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState<string | null>(null)
|
const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState<string | null>(null)
|
||||||
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
|
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
|
||||||
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
|
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
|
||||||
|
const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false)
|
||||||
|
const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50)
|
||||||
|
const [aiMessageInsightSystemPrompt, setAiMessageInsightSystemPrompt] = useState('')
|
||||||
|
|
||||||
// 自动下载图片
|
// 自动下载图片
|
||||||
const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null)
|
const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null)
|
||||||
@@ -372,7 +377,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}, [location.state])
|
}, [location.state])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') {
|
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiMessageInsight') {
|
||||||
setAiGroupExpanded(true)
|
setAiGroupExpanded(true)
|
||||||
}
|
}
|
||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
@@ -590,6 +595,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings()
|
const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings()
|
||||||
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
|
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
|
||||||
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
|
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
|
||||||
|
const savedAiMessageInsightEnabled = await configService.getAiMessageInsightEnabled()
|
||||||
|
const savedAiMessageInsightContextCount = await configService.getAiMessageInsightContextCount()
|
||||||
|
const savedAiMessageInsightSystemPrompt = await configService.getAiMessageInsightSystemPrompt()
|
||||||
|
|
||||||
setAiInsightEnabled(savedAiInsightEnabled)
|
setAiInsightEnabled(savedAiInsightEnabled)
|
||||||
setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
|
setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
|
||||||
@@ -616,6 +624,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setAiInsightWeiboBindings(savedAiInsightWeiboBindings)
|
setAiInsightWeiboBindings(savedAiInsightWeiboBindings)
|
||||||
setAiFootprintEnabled(savedAiFootprintEnabled)
|
setAiFootprintEnabled(savedAiFootprintEnabled)
|
||||||
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
|
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
|
||||||
|
setAiMessageInsightEnabled(savedAiMessageInsightEnabled)
|
||||||
|
setAiMessageInsightContextCount(savedAiMessageInsightContextCount)
|
||||||
|
setAiMessageInsightSystemPrompt(savedAiMessageInsightSystemPrompt)
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('加载配置失败:', e)
|
console.error('加载配置失败:', e)
|
||||||
@@ -4021,6 +4032,107 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const renderAiMessageInsightTab = () => (
|
||||||
|
<div className="tab-content">
|
||||||
|
{(() => {
|
||||||
|
const DEFAULT_MESSAGE_INSIGHT_PROMPT = `你是一个克制、准确的聊天语义分析助手。你的任务是把用户选中的一句聊天消息做深度解析,帮助用户理解对方未明说的含义。
|
||||||
|
|
||||||
|
严格要求:
|
||||||
|
1. 必须且只能输出合法的纯 JSON。
|
||||||
|
2. 禁止输出解释说明、前言后语,禁止使用 Markdown 或代码块。
|
||||||
|
3. 不要编造上下文没有支持的信息;不确定时用谨慎表述。
|
||||||
|
4. explicit_text 用自然中文说明这句话可能想表达的真实含义,80字以内。
|
||||||
|
5. emotion、intent、topic 必须是短标签。
|
||||||
|
|
||||||
|
JSON 输出格式:
|
||||||
|
{
|
||||||
|
"explicit_text": "暗示转明示,80字以内",
|
||||||
|
"emotion": "2-6字情绪标签",
|
||||||
|
"intent": "2-8字意图标签",
|
||||||
|
"topic": "2-8字话题标签"
|
||||||
|
}`
|
||||||
|
const displayValue = aiMessageInsightSystemPrompt || DEFAULT_MESSAGE_INSIGHT_PROMPT
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>消息深度解析</label>
|
||||||
|
<span className="form-hint">
|
||||||
|
开启后,在聊天页悬停对方文本消息时显示深度解析入口。点击后按需调用 AI,解析结果会保存到灵感信箱。
|
||||||
|
</span>
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">{aiMessageInsightEnabled ? '已开启' : '已关闭'}</span>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={aiMessageInsightEnabled}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const val = e.target.checked
|
||||||
|
setAiMessageInsightEnabled(val)
|
||||||
|
await configService.setAiMessageInsightEnabled(val)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>发送上下文对话条数</label>
|
||||||
|
<span className="form-hint">
|
||||||
|
围绕选中消息向前、向后各取一半;一侧不足时自动由另一侧补齐。条数越多分析越准确,token 消耗也越多。
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="field-input"
|
||||||
|
value={aiMessageInsightContextCount}
|
||||||
|
min={1}
|
||||||
|
max={200}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 50))
|
||||||
|
setAiMessageInsightContextCount(val)
|
||||||
|
scheduleConfigSave('aiMessageInsightContextCount', () => configService.setAiMessageInsightContextCount(val))
|
||||||
|
}}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<label style={{ marginBottom: 0 }}>消息解析提示词</label>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={async () => {
|
||||||
|
setAiMessageInsightSystemPrompt('')
|
||||||
|
await configService.setAiMessageInsightSystemPrompt('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
恢复默认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="form-hint">
|
||||||
|
消息解析专用提示词。留空时使用内置默认提示词。
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
className="field-input ai-prompt-textarea"
|
||||||
|
rows={10}
|
||||||
|
style={{ width: '100%', resize: 'vertical' }}
|
||||||
|
value={displayValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value
|
||||||
|
setAiMessageInsightSystemPrompt(val)
|
||||||
|
scheduleConfigSave('aiMessageInsightSystemPrompt', () => configService.setAiMessageInsightSystemPrompt(val))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="form-hint" style={{ color: 'var(--danger, #ef4444)', marginTop: 8, display: 'block' }}>
|
||||||
|
该提示词控制 JSON 输出结构和解析口径,不建议随意修改,否则可能导致解析失败或内容错位。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const renderApiTab = () => (
|
const renderApiTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -5049,7 +5161,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
row.push(
|
row.push(
|
||||||
<div key="ai-settings-group" className={`tab-group ${aiGroupExpanded ? 'expanded' : ''}`}>
|
<div key="ai-settings-group" className={`tab-group ${aiGroupExpanded ? 'expanded' : ''}`}>
|
||||||
<button
|
<button
|
||||||
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') ? 'active' : ''}`}
|
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiMessageInsight') ? 'active' : ''}`}
|
||||||
onClick={() => setAiGroupExpanded((prev) => !prev)}
|
onClick={() => setAiGroupExpanded((prev) => !prev)}
|
||||||
aria-expanded={aiGroupExpanded}
|
aria-expanded={aiGroupExpanded}
|
||||||
>
|
>
|
||||||
@@ -5091,6 +5203,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
{activeTab === 'aiCommon' && renderAiCommonTab()}
|
{activeTab === 'aiCommon' && renderAiCommonTab()}
|
||||||
{activeTab === 'insight' && renderInsightTab()}
|
{activeTab === 'insight' && renderInsightTab()}
|
||||||
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
|
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
|
||||||
|
{activeTab === 'aiMessageInsight' && renderAiMessageInsightTab()}
|
||||||
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
|
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
|
||||||
{activeTab === 'updates' && renderUpdatesTab()}
|
{activeTab === 'updates' && renderUpdatesTab()}
|
||||||
{activeTab === 'analytics' && renderAnalyticsTab()}
|
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ export const CONFIG_KEYS = {
|
|||||||
// AI 足迹
|
// AI 足迹
|
||||||
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
|
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
|
||||||
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
|
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
|
||||||
|
AI_MESSAGE_INSIGHT_ENABLED: 'aiMessageInsightEnabled',
|
||||||
|
AI_MESSAGE_INSIGHT_CONTEXT_COUNT: 'aiMessageInsightContextCount',
|
||||||
|
AI_MESSAGE_INSIGHT_SYSTEM_PROMPT: 'aiMessageInsightSystemPrompt',
|
||||||
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled',
|
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled',
|
||||||
AUTO_DOWNLOAD_HIGH_RES: 'autoDownloadHighRes',
|
AUTO_DOWNLOAD_HIGH_RES: 'autoDownloadHighRes',
|
||||||
AUTO_DOWNLOAD_WHITELIST: 'autoDownloadWhitelist'
|
AUTO_DOWNLOAD_WHITELIST: 'autoDownloadWhitelist'
|
||||||
@@ -2175,6 +2178,36 @@ export async function setAiFootprintSystemPrompt(prompt: string): Promise<void>
|
|||||||
await config.set(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT, prompt)
|
await config.set(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAiMessageInsightEnabled(): Promise<boolean> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.AI_MESSAGE_INSIGHT_ENABLED)
|
||||||
|
return value === true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAiMessageInsightEnabled(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.AI_MESSAGE_INSIGHT_ENABLED, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAiMessageInsightContextCount(): Promise<number> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.AI_MESSAGE_INSIGHT_CONTEXT_COUNT)
|
||||||
|
const numeric = Number(value)
|
||||||
|
if (!Number.isFinite(numeric)) return 50
|
||||||
|
return Math.max(1, Math.min(200, Math.floor(numeric)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAiMessageInsightContextCount(count: number): Promise<void> {
|
||||||
|
const normalized = Number.isFinite(count) ? Math.max(1, Math.min(200, Math.floor(count))) : 50
|
||||||
|
await config.set(CONFIG_KEYS.AI_MESSAGE_INSIGHT_CONTEXT_COUNT, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAiMessageInsightSystemPrompt(): Promise<string> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.AI_MESSAGE_INSIGHT_SYSTEM_PROMPT)
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAiMessageInsightSystemPrompt(prompt: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.AI_MESSAGE_INSIGHT_SYSTEM_PROMPT, prompt)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAiInsightDebugLogEnabled(): Promise<boolean> {
|
export async function getAiInsightDebugLogEnabled(): Promise<boolean> {
|
||||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_DEBUG_LOG_ENABLED)
|
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_DEBUG_LOG_ENABLED)
|
||||||
return value === true
|
return value === true
|
||||||
|
|||||||
Vendored
+50
-1
@@ -21,7 +21,24 @@ export interface SocialSaveWeiboCookieResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
|
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'message_analysis'
|
||||||
|
export type InsightRecordSourceType = 'insight' | 'message_analysis'
|
||||||
|
|
||||||
|
export interface MessageInsightAnalysis {
|
||||||
|
explicitText: string
|
||||||
|
emotion: string
|
||||||
|
intent: string
|
||||||
|
topic: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageInsightTarget {
|
||||||
|
targetLocalId: number
|
||||||
|
targetCreateTime: number
|
||||||
|
targetMessageKey: string
|
||||||
|
targetSenderName: string
|
||||||
|
targetTextPreview: string
|
||||||
|
analysis: MessageInsightAnalysis
|
||||||
|
}
|
||||||
|
|
||||||
export interface InsightRecordLog {
|
export interface InsightRecordLog {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
@@ -37,10 +54,28 @@ export interface InsightRecordLog {
|
|||||||
finalInsight: string
|
finalInsight: string
|
||||||
durationMs: number
|
durationMs: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
responseFormatJson?: boolean
|
||||||
|
responseFormatFallback?: boolean
|
||||||
|
responseFormatFallbackReason?: string
|
||||||
|
targetMessage?: {
|
||||||
|
localId: number
|
||||||
|
createTime: number
|
||||||
|
messageKey: string
|
||||||
|
senderName: string
|
||||||
|
textPreview: string
|
||||||
|
}
|
||||||
|
contextStats?: {
|
||||||
|
requested: number
|
||||||
|
beforeTarget: number
|
||||||
|
afterTarget: number
|
||||||
|
readError?: string
|
||||||
|
}
|
||||||
|
parsedAnalysis?: MessageInsightAnalysis
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InsightRecordSummary {
|
export interface InsightRecordSummary {
|
||||||
id: string
|
id: string
|
||||||
|
sourceType: InsightRecordSourceType
|
||||||
createdAt: number
|
createdAt: number
|
||||||
sessionId: string
|
sessionId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -48,6 +83,7 @@ export interface InsightRecordSummary {
|
|||||||
triggerReason: InsightRecordTriggerReason
|
triggerReason: InsightRecordTriggerReason
|
||||||
insight: string
|
insight: string
|
||||||
read: boolean
|
read: boolean
|
||||||
|
messageInsight?: MessageInsightTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InsightRecord extends InsightRecordSummary {
|
export interface InsightRecord extends InsightRecordSummary {
|
||||||
@@ -67,6 +103,7 @@ export interface InsightRecordFilters {
|
|||||||
sessionId?: string
|
sessionId?: string
|
||||||
startTime?: number
|
startTime?: number
|
||||||
endTime?: number
|
endTime?: number
|
||||||
|
sourceType?: InsightRecordSourceType | 'all'
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
}
|
}
|
||||||
@@ -1320,6 +1357,18 @@ export interface ElectronAPI {
|
|||||||
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
||||||
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||||
}) => Promise<{ success: boolean; message: string; insight?: string }>
|
}) => Promise<{ success: boolean; message: string; insight?: string }>
|
||||||
|
generateMessageInsight: (payload: {
|
||||||
|
sessionId: string
|
||||||
|
displayName?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
targetLocalId?: number
|
||||||
|
targetCreateTime?: number
|
||||||
|
targetMessageKey?: string
|
||||||
|
targetText: string
|
||||||
|
targetSenderName?: string
|
||||||
|
contextCount?: number
|
||||||
|
forceRefresh?: boolean
|
||||||
|
}) => Promise<{ success: boolean; message: string; cached?: boolean; recordId?: string; data?: MessageInsightAnalysis }>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user