koishi-plugin-chat-patch 1.3.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/index.scss +35 -0
- package/client/index.ts +2 -0
- package/client/vue/chat-logic.ts +148 -3715
- package/client/vue/composables/useChatActions.ts +61 -0
- package/client/vue/composables/useChatData.ts +184 -0
- package/client/vue/composables/useImageCache.ts +140 -0
- package/client/vue/index.vue +399 -327
- package/client/vue/types.ts +71 -0
- package/dist/index.js +5 -27
- package/dist/style.css +1 -1
- package/lib/index.js +68 -36
- package/package.json +14 -5
- package/readme.md +1 -1
- package/src/api-handlers.ts +17 -10
- package/src/config.ts +2 -4
- package/src/index.ts +1 -1
- package/src/message-handler.ts +5 -4
- package/src/utils.ts +48 -4
- package/client/vue/style.css +0 -1997
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { send } from '@koishijs/client'
|
|
3
|
+
import type { MessageInfo } from '../types'
|
|
4
|
+
|
|
5
|
+
export function useChatActions() {
|
|
6
|
+
const isSending = ref(false)
|
|
7
|
+
|
|
8
|
+
async function sendMessage(botId: string, channelId: string, content: string, images: any[]) {
|
|
9
|
+
if (isSending.value) return
|
|
10
|
+
isSending.value = true
|
|
11
|
+
try {
|
|
12
|
+
const result = await (send as any)('send-message', {
|
|
13
|
+
selfId: botId,
|
|
14
|
+
channelId: channelId,
|
|
15
|
+
content: content,
|
|
16
|
+
images: images.map(img => ({
|
|
17
|
+
tempId: img.tempId,
|
|
18
|
+
filename: img.filename
|
|
19
|
+
}))
|
|
20
|
+
})
|
|
21
|
+
return result
|
|
22
|
+
} finally {
|
|
23
|
+
isSending.value = false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function deleteBotData(botId: string) {
|
|
28
|
+
return await (send as any)('delete-bot-data', { selfId: botId })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function deleteChannelData(botId: string, channelId: string) {
|
|
32
|
+
return await (send as any)('delete-channel-data', { selfId: botId, channelId: channelId })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function clearHistory(botId: string, channelId: string) {
|
|
36
|
+
return await (send as any)('clear-channel-history', { selfId: botId, channelId: channelId })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function togglePinBot(botId: string, pinned: boolean, pinnedBots: Set<string>) {
|
|
40
|
+
if (pinned) pinnedBots.delete(botId)
|
|
41
|
+
else pinnedBots.add(botId)
|
|
42
|
+
await (send as any)('set-pinned-bots', { pinnedBots: Array.from(pinnedBots) })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function togglePinChannel(botId: string, channelId: string, pinned: boolean, pinnedChannels: Set<string>) {
|
|
46
|
+
const key = `${botId}:${channelId}`
|
|
47
|
+
if (pinned) pinnedChannels.delete(key)
|
|
48
|
+
else pinnedChannels.add(key)
|
|
49
|
+
await (send as any)('set-pinned-channels', { pinnedChannels: Array.from(pinnedChannels) })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
isSending,
|
|
54
|
+
sendMessage,
|
|
55
|
+
deleteBotData,
|
|
56
|
+
deleteChannelData,
|
|
57
|
+
clearHistory,
|
|
58
|
+
togglePinBot,
|
|
59
|
+
togglePinChannel
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { ref, computed } from 'vue'
|
|
2
|
+
import { receive, send } from '@koishijs/client'
|
|
3
|
+
import type { ChatData, BotInfo, ChannelInfo, MessageInfo, PluginConfig } from '../types'
|
|
4
|
+
|
|
5
|
+
export function useChatData() {
|
|
6
|
+
const chatData = ref<ChatData>({
|
|
7
|
+
bots: {},
|
|
8
|
+
channels: {},
|
|
9
|
+
messages: {}
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const pluginConfig = ref<PluginConfig>({
|
|
13
|
+
maxMessagesPerChannel: 1000,
|
|
14
|
+
keepMessagesOnClear: 50,
|
|
15
|
+
loggerinfo: false,
|
|
16
|
+
blockedPlatforms: [],
|
|
17
|
+
chatContainerHeight: 80,
|
|
18
|
+
clearIndexedDBOnStart: true
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const pinnedBots = ref<Set<string>>(new Set())
|
|
22
|
+
const pinnedChannels = ref<Set<string>>(new Set())
|
|
23
|
+
|
|
24
|
+
// 记录每个频道的分页状态
|
|
25
|
+
const channelPagination = ref<Record<string, { offset: number, hasMore: boolean }>>({})
|
|
26
|
+
|
|
27
|
+
// 计算属性
|
|
28
|
+
const bots = computed(() => {
|
|
29
|
+
return Object.values(chatData.value.bots).sort((a, b) => {
|
|
30
|
+
const aPinned = pinnedBots.value.has(a.selfId)
|
|
31
|
+
const bPinned = pinnedBots.value.has(b.selfId)
|
|
32
|
+
return aPinned === bPinned ? 0 : aPinned ? -1 : 1
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const getChannels = (botId: string) => {
|
|
37
|
+
if (!botId || !chatData.value.channels[botId]) return []
|
|
38
|
+
return Object.values(chatData.value.channels[botId]).sort((a, b) => {
|
|
39
|
+
const aPinned = pinnedChannels.value.has(`${botId}:${a.id}`)
|
|
40
|
+
const bPinned = pinnedChannels.value.has(`${botId}:${b.id}`)
|
|
41
|
+
return aPinned === bPinned ? 0 : aPinned ? -1 : 1
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const getMessages = (botId: string, channelId: string) => {
|
|
46
|
+
const key = `${botId}:${channelId}`
|
|
47
|
+
return chatData.value.messages[key] || []
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const getPagination = (botId: string, channelId: string) => {
|
|
51
|
+
const key = `${botId}:${channelId}`
|
|
52
|
+
return channelPagination.value[key] || { offset: 0, hasMore: true }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 加载数据
|
|
56
|
+
async function loadInitialData() {
|
|
57
|
+
const result = await (send as any)('get-chat-data')
|
|
58
|
+
if (result.success && result.data) {
|
|
59
|
+
chatData.value = {
|
|
60
|
+
bots: result.data.bots || {},
|
|
61
|
+
channels: result.data.channels || {},
|
|
62
|
+
messages: result.data.messages || {}
|
|
63
|
+
}
|
|
64
|
+
pinnedBots.value = new Set(result.data.pinnedBots || [])
|
|
65
|
+
pinnedChannels.value = new Set(result.data.pinnedChannels || [])
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function loadConfig() {
|
|
70
|
+
const result = await (send as any)('get-plugin-config')
|
|
71
|
+
if (result.success && result.config) {
|
|
72
|
+
pluginConfig.value = result.config
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 消息处理
|
|
77
|
+
function addMessage(msg: any) {
|
|
78
|
+
const selfId = msg.selfId
|
|
79
|
+
const channelId = msg.channelId
|
|
80
|
+
const key = `${selfId}:${channelId}`
|
|
81
|
+
|
|
82
|
+
// 1. 确保机器人存在
|
|
83
|
+
if (!chatData.value.bots[selfId]) {
|
|
84
|
+
chatData.value.bots[selfId] = {
|
|
85
|
+
selfId,
|
|
86
|
+
platform: msg.platform || 'unknown',
|
|
87
|
+
username: msg.bot?.name || `Bot-${selfId}`,
|
|
88
|
+
avatar: msg.bot?.avatar,
|
|
89
|
+
status: 'online'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 2. 确保频道存在
|
|
94
|
+
if (!chatData.value.channels[selfId]) {
|
|
95
|
+
chatData.value.channels[selfId] = {}
|
|
96
|
+
}
|
|
97
|
+
if (!chatData.value.channels[selfId][channelId]) {
|
|
98
|
+
chatData.value.channels[selfId][channelId] = {
|
|
99
|
+
id: channelId,
|
|
100
|
+
name: msg.guildName || msg.username || channelId,
|
|
101
|
+
type: msg.channelType || 0
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 3. 消息去重与添加
|
|
106
|
+
if (!chatData.value.messages[key]) chatData.value.messages[key] = []
|
|
107
|
+
|
|
108
|
+
const messages = chatData.value.messages[key]
|
|
109
|
+
// 严格去重:检查 ID,如果是机器人发送的临时消息,则通过内容和时间戳近似匹配
|
|
110
|
+
const isDuplicate = messages.some(m => {
|
|
111
|
+
if (m.id === msg.messageId || m.id === msg.id) return true
|
|
112
|
+
// 针对机器人发送消息的特殊去重逻辑
|
|
113
|
+
if ((msg.type === 'bot-message' || msg.type === 'bot-message-sent' || msg.type === 'bot') && m.isBot && Math.abs(m.timestamp - msg.timestamp) < 2000 && m.content === msg.content) return true
|
|
114
|
+
return false
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
if (!isDuplicate) {
|
|
118
|
+
const newMsg: MessageInfo = {
|
|
119
|
+
id: msg.messageId || msg.id,
|
|
120
|
+
content: msg.content,
|
|
121
|
+
userId: msg.userId,
|
|
122
|
+
username: msg.username,
|
|
123
|
+
avatar: msg.avatar,
|
|
124
|
+
timestamp: msg.timestamp,
|
|
125
|
+
channelId: channelId,
|
|
126
|
+
selfId: selfId,
|
|
127
|
+
elements: msg.elements,
|
|
128
|
+
isBot: msg.isBot || msg.type === 'bot-message' || msg.type === 'bot',
|
|
129
|
+
quote: msg.quote
|
|
130
|
+
}
|
|
131
|
+
messages.push(newMsg)
|
|
132
|
+
messages.sort((a, b) => a.timestamp - b.timestamp)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 强制触发响应式更新,确保列表实时刷新
|
|
136
|
+
chatData.value = { ...chatData.value }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function loadHistory(botId: string, channelId: string, limit = 50) {
|
|
140
|
+
const key = `${botId}:${channelId}`
|
|
141
|
+
const pagination = getPagination(botId, channelId)
|
|
142
|
+
if (!pagination.hasMore) return
|
|
143
|
+
|
|
144
|
+
const result = await (send as any)('get-history-messages', {
|
|
145
|
+
selfId: botId,
|
|
146
|
+
channelId: channelId,
|
|
147
|
+
limit,
|
|
148
|
+
offset: pagination.offset
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
if (result.success && result.messages) {
|
|
152
|
+
if (!chatData.value.messages[key]) chatData.value.messages[key] = []
|
|
153
|
+
const existing = chatData.value.messages[key]
|
|
154
|
+
|
|
155
|
+
// 合并并去重
|
|
156
|
+
const newMsgs = result.messages.filter((m: any) => !existing.find(e => e.id === m.id))
|
|
157
|
+
chatData.value.messages[key] = [...newMsgs, ...existing].sort((a, b) => a.timestamp - b.timestamp)
|
|
158
|
+
|
|
159
|
+
channelPagination.value[key] = {
|
|
160
|
+
offset: pagination.offset + result.messages.length,
|
|
161
|
+
hasMore: result.messages.length >= limit && result.total > (pagination.offset + result.messages.length)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
chatData.value = { ...chatData.value }
|
|
165
|
+
return result.messages.length
|
|
166
|
+
}
|
|
167
|
+
return 0
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
chatData,
|
|
172
|
+
pluginConfig,
|
|
173
|
+
pinnedBots,
|
|
174
|
+
pinnedChannels,
|
|
175
|
+
bots,
|
|
176
|
+
getChannels,
|
|
177
|
+
getMessages,
|
|
178
|
+
getPagination,
|
|
179
|
+
loadInitialData,
|
|
180
|
+
loadConfig,
|
|
181
|
+
addMessage,
|
|
182
|
+
loadHistory
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ref, onMounted, onUnmounted } from 'vue'
|
|
2
|
+
import { send } from '@koishijs/client'
|
|
3
|
+
|
|
4
|
+
// 图片缓存 - IndexedDB
|
|
5
|
+
interface ImageCacheItem {
|
|
6
|
+
url: string
|
|
7
|
+
blob: Blob
|
|
8
|
+
timestamp: number
|
|
9
|
+
size: number
|
|
10
|
+
channelKey: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useImageCache() {
|
|
14
|
+
const imageBlobUrls = ref<Record<string, string>>({})
|
|
15
|
+
const loadingImages = new Map<string, Promise<string | null>>()
|
|
16
|
+
|
|
17
|
+
// 内存管理配置
|
|
18
|
+
const MAX_MEMORY_USAGE = 100 * 1024 * 1024 // 100MB
|
|
19
|
+
const MAX_BLOB_COUNT = 50
|
|
20
|
+
let currentMemoryUsage = 0
|
|
21
|
+
|
|
22
|
+
// IndexedDB 配置
|
|
23
|
+
let imageDB: IDBDatabase | null = null
|
|
24
|
+
const DB_NAME = 'ChatImageCache'
|
|
25
|
+
const DB_VERSION = 2
|
|
26
|
+
const STORE_NAME = 'images'
|
|
27
|
+
const MAX_DB_SIZE = 50 * 1024 * 1024 // 50MB
|
|
28
|
+
const MAX_TOTAL_IMAGES = 500
|
|
29
|
+
const MAX_IMAGE_SIZE = 12 * 1024 * 1024 // 12MB
|
|
30
|
+
|
|
31
|
+
// 初始化数据库
|
|
32
|
+
async function initDB(): Promise<boolean> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
|
35
|
+
request.onupgradeneeded = (event) => {
|
|
36
|
+
const db = (event.target as IDBOpenDBRequest).result
|
|
37
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
38
|
+
const store = db.createObjectStore(STORE_NAME, { keyPath: 'url' })
|
|
39
|
+
store.createIndex('channelKey', 'channelKey', { unique: false })
|
|
40
|
+
store.createIndex('timestamp', 'timestamp', { unique: false })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
request.onsuccess = () => {
|
|
44
|
+
imageDB = request.result
|
|
45
|
+
resolve(true)
|
|
46
|
+
}
|
|
47
|
+
request.onerror = () => resolve(false)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 获取缓存图片
|
|
52
|
+
async function getCachedImageUrl(channelKey: string, url: string): Promise<string | null> {
|
|
53
|
+
if (imageBlobUrls.value[url]) return imageBlobUrls.value[url]
|
|
54
|
+
if (loadingImages.has(url)) return loadingImages.get(url)!
|
|
55
|
+
|
|
56
|
+
const promise = (async () => {
|
|
57
|
+
try {
|
|
58
|
+
if (!imageDB) await initDB()
|
|
59
|
+
const item = await getImageFromDB(url)
|
|
60
|
+
if (item) {
|
|
61
|
+
const blobUrl = URL.createObjectURL(item.blob)
|
|
62
|
+
imageBlobUrls.value[url] = blobUrl
|
|
63
|
+
return blobUrl
|
|
64
|
+
}
|
|
65
|
+
return null
|
|
66
|
+
} finally {
|
|
67
|
+
loadingImages.delete(url)
|
|
68
|
+
}
|
|
69
|
+
})()
|
|
70
|
+
|
|
71
|
+
loadingImages.set(url, promise)
|
|
72
|
+
return promise
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function getImageFromDB(url: string): Promise<ImageCacheItem | null> {
|
|
76
|
+
if (!imageDB) return null
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const transaction = imageDB!.transaction([STORE_NAME], 'readonly')
|
|
79
|
+
const store = transaction.objectStore(STORE_NAME)
|
|
80
|
+
const request = store.get(url)
|
|
81
|
+
request.onsuccess = () => resolve(request.result || null)
|
|
82
|
+
request.onerror = () => resolve(null)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function cacheImage(channelKey: string, url: string): Promise<string | null> {
|
|
87
|
+
const existing = await getCachedImageUrl(channelKey, url)
|
|
88
|
+
if (existing) return existing
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const result = await (send as any)('fetch-image', { url })
|
|
92
|
+
if (!result.success) return null
|
|
93
|
+
|
|
94
|
+
const response = await fetch(result.dataUrl)
|
|
95
|
+
const blob = await response.blob()
|
|
96
|
+
|
|
97
|
+
if (blob.size > MAX_IMAGE_SIZE) return null
|
|
98
|
+
|
|
99
|
+
const item: ImageCacheItem = {
|
|
100
|
+
url,
|
|
101
|
+
blob,
|
|
102
|
+
timestamp: Date.now(),
|
|
103
|
+
size: blob.size,
|
|
104
|
+
channelKey
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await saveToDB(item)
|
|
108
|
+
const blobUrl = URL.createObjectURL(blob)
|
|
109
|
+
imageBlobUrls.value[url] = blobUrl
|
|
110
|
+
return blobUrl
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function saveToDB(item: ImageCacheItem) {
|
|
117
|
+
if (!imageDB) return
|
|
118
|
+
const transaction = imageDB.transaction([STORE_NAME], 'readwrite')
|
|
119
|
+
transaction.objectStore(STORE_NAME).put(item)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function clearChannelCache(channelKey: string) {
|
|
123
|
+
// 简化实现
|
|
124
|
+
Object.keys(imageBlobUrls.value).forEach(url => {
|
|
125
|
+
URL.revokeObjectURL(imageBlobUrls.value[url])
|
|
126
|
+
delete imageBlobUrls.value[url]
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
onUnmounted(() => {
|
|
131
|
+
Object.values(imageBlobUrls.value).forEach(URL.revokeObjectURL)
|
|
132
|
+
if (imageDB) imageDB.close()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
getCachedImageUrl,
|
|
137
|
+
cacheImage,
|
|
138
|
+
clearChannelCache
|
|
139
|
+
}
|
|
140
|
+
}
|