优化机器人私聊
This commit is contained in:
@@ -42,6 +42,7 @@ import type {
|
||||
PublicMapTileSourcesResponse,
|
||||
TelemetryRecord,
|
||||
TextMessage,
|
||||
BotDirectMessage,
|
||||
} from './types'
|
||||
|
||||
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -377,12 +378,12 @@ export function getBotMessages(botId = 0, limit = 100, offset = 0): Promise<List
|
||||
return getJSON<ListResponse<BotMessage>>(`/api/admin/bot/messages?${params.toString()}`)
|
||||
}
|
||||
|
||||
export function getBotDirectTextMessages(botId: number, targetNodeNum: number, limit = 100, offset = 0, channelId = ''): Promise<ListResponse<TextMessage>> {
|
||||
export function getBotDirectMessages(botId: number, targetNodeNum: number, limit = 100, offset = 0, direction = ''): Promise<ListResponse<BotDirectMessage>> {
|
||||
const params = new URLSearchParams({ bot_id: String(botId), target_node_num: String(targetNodeNum), limit: String(limit), offset: String(offset) })
|
||||
if (channelId) {
|
||||
params.set('channel_id', channelId)
|
||||
if (direction) {
|
||||
params.set('direction', direction)
|
||||
}
|
||||
return getJSON<ListResponse<TextMessage>>(`/api/admin/bot/direct-messages?${params.toString()}`)
|
||||
return getJSON<ListResponse<BotDirectMessage>>(`/api/admin/bot/direct-messages?${params.toString()}`)
|
||||
}
|
||||
|
||||
export function sendBotMessage(payload: BotSendMessagePayload): Promise<BotMessageMutationResponse> {
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onBeforeUpdate, onMounted, onUpdated, ref, watch } from 'vue'
|
||||
import { getBotDirectTextMessages, getBotNodes, getNodeInfo, sendBotMessage } from '../api'
|
||||
import type { BotNode, NodeInfo, TextMessage } from '../types'
|
||||
import { getBotDirectMessages, getBotNodes, getNodeInfo, sendBotMessage } from '../api'
|
||||
import type { BotDirectMessage, BotNode, NodeInfo } from '../types'
|
||||
|
||||
const chatPageSize = 30
|
||||
const maxTextBytes = 200
|
||||
const topThreshold = 8
|
||||
const bottomThreshold = 40
|
||||
// 私聊固定走 PKI,channel_id 与固件 ServiceEnvelope 保持一致
|
||||
const directChannelId = 'PKI'
|
||||
|
||||
const bots = ref<BotNode[]>([])
|
||||
const targets = ref<NodeInfo[]>([])
|
||||
const messages = ref<TextMessage[]>([])
|
||||
const messages = ref<BotDirectMessage[]>([])
|
||||
const selectedBotId = ref<number | null>(null)
|
||||
const selectedTargetId = ref('')
|
||||
const text = ref('')
|
||||
@@ -36,9 +34,10 @@ const selectedTarget = computed(() => targets.value.find((item) => item.node_id
|
||||
const directTextBytes = computed(() => new TextEncoder().encode(text.value).length)
|
||||
const canSend = computed(() => !!selectedBot.value && !!selectedTarget.value && !!text.value.trim() && directTextBytes.value <= maxTextBytes && !sending.value)
|
||||
const groupedMessages = computed(() => {
|
||||
const groups = new Map<string, TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }>()
|
||||
// 同一条 DM 在 inbound/outbound 两侧不会重复(来源是同一个表),但保留按 packet_id+text 合并的能力以容忍重复 publish。
|
||||
const groups = new Map<string, BotDirectMessage & { mergedCount: number; mergedMessages: BotDirectMessage[] }>()
|
||||
for (const item of messages.value) {
|
||||
const key = `${item.packet_id ?? ''}\n${item.text ?? ''}`
|
||||
const key = `${item.direction}\n${item.packet_id ?? ''}\n${item.text ?? ''}`
|
||||
const group = groups.get(key)
|
||||
if (group) {
|
||||
group.mergedCount += 1
|
||||
@@ -70,17 +69,17 @@ function resetChat() {
|
||||
restoreMessageCount = 0
|
||||
}
|
||||
|
||||
function toChronological(items: TextMessage[]) {
|
||||
function toChronological(items: BotDirectMessage[]) {
|
||||
return [...items].reverse()
|
||||
}
|
||||
|
||||
function compareMessages(a: TextMessage, b: TextMessage) {
|
||||
function compareMessages(a: BotDirectMessage, b: BotDirectMessage) {
|
||||
const timeDiff = Date.parse(a.created_at) - Date.parse(b.created_at)
|
||||
return timeDiff !== 0 ? timeDiff : a.id - b.id
|
||||
}
|
||||
|
||||
function mergeMessages(existing: TextMessage[], incoming: TextMessage[]) {
|
||||
const byId = new Map<number, TextMessage>()
|
||||
function mergeMessages(existing: BotDirectMessage[], incoming: BotDirectMessage[]) {
|
||||
const byId = new Map<number, BotDirectMessage>()
|
||||
for (const item of existing) byId.set(item.id, item)
|
||||
for (const item of incoming) byId.set(item.id, item)
|
||||
return Array.from(byId.values()).sort(compareMessages)
|
||||
@@ -117,7 +116,7 @@ async function loadInitialMessages() {
|
||||
if (!selectedBot.value || !selectedTarget.value) return
|
||||
loadingOlder.value = true
|
||||
try {
|
||||
const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0, directChannelId)
|
||||
const response = await getBotDirectMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0)
|
||||
messages.value = toChronological(response.items)
|
||||
hasMore.value = response.items.length === chatPageSize
|
||||
initialized.value = true
|
||||
@@ -135,7 +134,7 @@ async function loadOlderMessages() {
|
||||
if (!selectedBot.value || !selectedTarget.value || loadingOlder.value || !hasMore.value) return
|
||||
loadingOlder.value = true
|
||||
try {
|
||||
const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, messages.value.length, directChannelId)
|
||||
const response = await getBotDirectMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, messages.value.length)
|
||||
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
||||
hasMore.value = response.items.length === chatPageSize
|
||||
} catch (err) {
|
||||
@@ -147,7 +146,7 @@ async function loadOlderMessages() {
|
||||
|
||||
async function pollLatestMessages() {
|
||||
if (!selectedBot.value || !selectedTarget.value) return
|
||||
const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0, directChannelId)
|
||||
const response = await getBotDirectMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0)
|
||||
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
||||
}
|
||||
|
||||
@@ -157,7 +156,7 @@ async function sendDirectMessage() {
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
const response = await sendBotMessage({ bot_id: selectedBot.value.id, message_type: 'direct', channel_id: directChannelId, to_node_id: selectedTarget.value.node_id, text: text.value })
|
||||
const response = await sendBotMessage({ bot_id: selectedBot.value.id, message_type: 'direct', channel_id: 'PKI', to_node_id: selectedTarget.value.node_id, text: text.value })
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
} else {
|
||||
@@ -183,14 +182,20 @@ function handleScroll() {
|
||||
loadOlderMessages()
|
||||
}
|
||||
|
||||
function isOwn(item: TextMessage) {
|
||||
return item.from_id === selectedBot.value?.node_id
|
||||
function isOwn(item: BotDirectMessage) {
|
||||
return item.direction === 'outbound'
|
||||
}
|
||||
|
||||
function senderName(item: TextMessage) {
|
||||
if (item.from_id === selectedBot.value?.node_id) return selectedBot.value?.long_name || item.from_id
|
||||
if (item.from_id === selectedTarget.value?.node_id) return selectedTarget.value?.long_name || selectedTarget.value?.short_name || item.from_id
|
||||
return item.from_id
|
||||
function senderName(item: BotDirectMessage) {
|
||||
if (item.direction === 'outbound') return selectedBot.value?.long_name || item.bot_node_id
|
||||
return selectedTarget.value?.long_name || selectedTarget.value?.short_name || item.peer_node_id
|
||||
}
|
||||
|
||||
function statusLabel(item: BotDirectMessage) {
|
||||
if (item.direction !== 'outbound') return ''
|
||||
if (item.status === 'failed') return `发送失败${item.error ? ':' + item.error : ''}`
|
||||
if (item.status === 'pending') return '发送中…'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
@@ -270,6 +275,7 @@ onBeforeUnmount(() => {
|
||||
<div class="chat-bubble">
|
||||
<div class="bubble-meta"><strong>{{ senderName(item) }}</strong><small>{{ formatTime(item.created_at) }}</small></div>
|
||||
<div class="bubble-text">{{ item.text || '[binary]' }} <span v-if="item.mergedCount > 1" class="message-merge-count">x{{ item.mergedCount }}</span></div>
|
||||
<div v-if="statusLabel(item)" class="bubble-status">{{ statusLabel(item) }}</div>
|
||||
<div class="bubble-topic">{{ item.topic }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,6 +309,7 @@ input, select, textarea { box-sizing: border-box; width: 100%; border: 1px solid
|
||||
.bubble-meta small, .bubble-topic, .hint { color: #64748b; }
|
||||
.hint.warn { color: #b91c1c; font-weight: 800; }
|
||||
.bubble-text { margin-top: 6px; color: #0f172a; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
|
||||
.bubble-status { margin-top: 4px; color: #b91c1c; font-size: 11px; font-weight: 700; }
|
||||
.bubble-topic { margin-top: 6px; font-size: 11px; word-break: break-all; }
|
||||
.message-merge-count { display: inline-flex; margin-left: 6px; border-radius: 999px; padding: 1px 6px; color: #1d4ed8; background: #bfdbfe; font-size: 12px; font-weight: 800; }
|
||||
.direct-composer { display: grid; gap: 10px; }
|
||||
|
||||
@@ -149,6 +149,31 @@ export interface TextMessage {
|
||||
content_json: string
|
||||
}
|
||||
|
||||
// 机器人 PKI 私聊(bot_direct_messages 表)。direction 区分本地 bot 视角的进出方向。
|
||||
export interface BotDirectMessage {
|
||||
id: number
|
||||
bot_id: number
|
||||
bot_node_id: string
|
||||
bot_node_num: number
|
||||
peer_node_id: string
|
||||
peer_node_num: number
|
||||
direction: 'inbound' | 'outbound'
|
||||
topic: string
|
||||
packet_id: number
|
||||
text: string
|
||||
payload_len: number
|
||||
pki_encrypted: boolean
|
||||
want_ack: boolean
|
||||
gateway_id: string | null
|
||||
status: string
|
||||
error: string
|
||||
bot_message_id: number | null
|
||||
created_by: string | null
|
||||
published_at: string | null
|
||||
received_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PositionRecord {
|
||||
id: number
|
||||
from_id: string
|
||||
|
||||
Reference in New Issue
Block a user