Files
meshtastic_mqtt_server/meshmap_frontend/src/components/AdminLLM.vue
T
kevinandClaude 8e7d11a162 feat(llm): 处理完成的消息自动软删除并隐藏删除按钮
- MarkAsProcessed 在写入 reply/processed_at 的同时设置 deleted_at,
  避免 /admin/llm 队列页堆积已处理消息(仍可勾选"包含已删除"查看)
- 前端列表中已删除的消息行隐藏删除按钮,改为显示灰色"已删除"标签

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-19 17:50:56 +08:00

824 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { deleteLLMMessage, deleteLLMMessagesByBot, getBotNodes, getLLMMessages, updateBotNode } from '../api'
import type { BotNode, LLMMessage, ListResponse } from '../types'
const loading = ref(false)
const savingBotId = ref<number | null>(null)
const error = ref('')
const messages = ref<LLMMessage[]>([])
const botNodes = ref<BotNode[]>([])
const total = ref(0)
const limit = 50
const offset = ref(0)
const selectedBotId = ref<number | ''>('')
const includeDeleted = ref(false)
const statusColors: Record<string, string> = {
pending: 'background: linear-gradient(135deg, #fef3c7 0%, #fde68a33 100%);',
processing: 'background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe33 100%);',
processed: 'background: linear-gradient(135deg, #dcfce7 0%, #bbf7d033 100%);',
error: 'background: linear-gradient(135deg, #fee2e2 0%, #fecaca33 100%);',
}
const statusLabels: Record<string, string> = {
pending: '待处理',
processing: '处理中',
processed: '已处理',
error: '错误',
}
function getMessageType(msg: LLMMessage): string {
return msg.channel_id ? '频道' : '私聊'
}
function getBotName(botId: number): string {
const bot = botNodes.value.find(b => b.id === botId)
return bot ? bot.long_name : '-'
}
async function loadBotNodes() {
try {
const response = await getBotNodes(100, 0)
botNodes.value = response.items
} catch (err) {
console.error('加载机器人列表失败:', err)
}
}
async function toggleBotLLMQueue(bot: BotNode) {
savingBotId.value = bot.id
try {
await updateBotNode(bot.id, {
long_name: bot.long_name,
short_name: bot.short_name,
enabled: bot.enabled,
default_channel_id: bot.default_channel_id,
topic_prefix: bot.topic_prefix,
psk: bot.psk,
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
nodeinfo_broadcast_interval_seconds: bot.nodeinfo_broadcast_interval_seconds,
llm_queue_enabled: !bot.llm_queue_enabled,
llm_include_channel_messages: bot.llm_include_channel_messages,
})
bot.llm_queue_enabled = !bot.llm_queue_enabled
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
savingBotId.value = null
}
}
async function toggleBotIncludeChannel(bot: BotNode) {
savingBotId.value = bot.id
try {
await updateBotNode(bot.id, {
long_name: bot.long_name,
short_name: bot.short_name,
enabled: bot.enabled,
default_channel_id: bot.default_channel_id,
topic_prefix: bot.topic_prefix,
psk: bot.psk,
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
nodeinfo_broadcast_interval_seconds: bot.nodeinfo_broadcast_interval_seconds,
llm_queue_enabled: bot.llm_queue_enabled,
llm_include_channel_messages: !bot.llm_include_channel_messages,
})
bot.llm_include_channel_messages = !bot.llm_include_channel_messages
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
savingBotId.value = null
}
}
async function loadMessages(resetOffset = false) {
if (resetOffset) {
offset.value = 0
}
loading.value = true
error.value = ''
try {
const botId = selectedBotId.value === '' ? undefined : selectedBotId.value
const response: ListResponse<LLMMessage> = await getLLMMessages(limit, offset.value, botId, includeDeleted.value)
messages.value = response.items
total.value = response.total ?? response.items.length
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function handleDeleteMessage(id: number) {
if (!confirm('确定要删除这条消息吗?')) {
return
}
try {
await deleteLLMMessage(id)
await loadMessages()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
async function handleDeleteAllByBot() {
const botId = selectedBotId.value === '' ? undefined : selectedBotId.value
if (!botId) {
alert('请先选择机器人')
return
}
if (!confirm(`确定要删除该机器人的所有队列消息吗?`)) {
return
}
try {
await deleteLLMMessagesByBot(botId)
await loadMessages()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
function formatTime(timeStr: string | null): string {
if (!timeStr) return '-'
const date = new Date(timeStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
const displayFrom = computed(() => offset.value + 1)
const displayTo = computed(() => Math.min(offset.value + limit, total.value))
const hasMore = computed(() => offset.value + limit < total.value)
const hasPrev = computed(() => offset.value > 0)
function goPrev() {
if (hasPrev.value) {
offset.value = Math.max(0, offset.value - limit)
loadMessages()
}
}
function goNext() {
if (hasMore.value) {
offset.value += limit
loadMessages()
}
}
onMounted(() => {
loadBotNodes()
loadMessages()
})
</script>
<template>
<div class="admin-llm">
<h2>LLM 消息队列</h2>
<div class="admin-llm-section">
<h3>机器人 LLM 设置</h3>
<p class="section-desc">每个机器人可以独立启用或禁用 LLM 消息队列启用后该机器人收到的私聊消息将被加入队列</p>
<div class="bot-settings-grid">
<div v-for="bot in botNodes" :key="bot.id" class="bot-settings-card">
<div class="bot-header">
<strong>{{ bot.long_name }}</strong>
<span class="bot-node-id">{{ bot.node_id }}</span>
</div>
<div class="bot-settings">
<label class="setting-item">
<input
type="checkbox"
:checked="bot.llm_queue_enabled"
@change="toggleBotLLMQueue(bot)"
:disabled="savingBotId === bot.id"
/>
<span>启用 LLM 队列</span>
</label>
<label class="setting-item">
<input
type="checkbox"
:checked="bot.llm_include_channel_messages"
@change="toggleBotIncludeChannel(bot)"
:disabled="savingBotId === bot.id || !bot.llm_queue_enabled"
/>
<span>包含频道消息</span>
</label>
</div>
<div v-if="savingBotId === bot.id" class="saving-indicator">保存中...</div>
</div>
</div>
</div>
<div class="admin-llm-toolbar">
<div class="admin-llm-filter">
<label>选择机器人</label>
<select v-model="selectedBotId" @change="loadMessages(true)">
<option :value="''">全部</option>
<option v-for="bot in botNodes" :key="bot.id" :value="bot.id">
{{ bot.long_name }} ({{ bot.node_id }})
</option>
</select>
</div>
<div class="admin-llm-filter">
<label>
<input type="checkbox" v-model="includeDeleted" @change="loadMessages(true)" />
包含已删除
</label>
</div>
<button class="admin-button admin-button-danger" @click="handleDeleteAllByBot" :disabled="!selectedBotId">
删除该机器人所有消息
</button>
<button class="admin-button" @click="() => loadMessages()">刷新</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="admin-llm-stats">
{{ total }} 条消息当前显示 {{ displayFrom }} - {{ displayTo }}
</div>
<div v-if="loading" class="admin-loading">
<div style="margin-bottom: 1rem;">🔄</div>
加载中...
</div>
<div v-else class="admin-llm-table-wrapper">
<table class="admin-llm-table">
<thead>
<tr>
<th>ID</th>
<th>机器人</th>
<th>类型</th>
<th>状态</th>
<th>来自节点</th>
<th>频道</th>
<th>消息内容</th>
<th>接收时间</th>
<th>处理时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="msg in messages" :key="msg.id" :style="statusColors[msg.status]">
<td>{{ msg.id }}</td>
<td class="bot-name-cell">
<div class="bot-info">
<div class="bot-long-name">{{ getBotName(msg.bot_id) }}</div>
<div class="bot-node-id" v-if="msg.bot_id !== 0">{{ msg.bot_node_id }}</div>
</div>
</td>
<td>
<span class="type-badge" :class="getMessageType(msg) === '频道' ? 'channel' : 'direct'">
{{ getMessageType(msg) }}
</span>
</td>
<td>
<span class="status-badge" :style="statusColors[msg.status]">
{{ statusLabels[msg.status] || msg.status }}
</span>
</td>
<td>
<div class="node-info">
<div class="node-name">{{ msg.long_name || msg.short_name || '-' }}</div>
<div class="node-id">{{ msg.from_node_id }}</div>
</div>
</td>
<td class="channel-cell">{{ msg.channel_id || '-' }}</td>
<td class="message-text">{{ msg.text }}</td>
<td>{{ formatTime(msg.received_at) }}</td>
<td>{{ formatTime(msg.processed_at) }}</td>
<td>
<button
v-if="!msg.deleted_at"
class="admin-button admin-button-small admin-button-danger"
@click="handleDeleteMessage(msg.id)"
>
删除
</button>
<span v-else class="muted-tag">已删除</span>
</td>
</tr>
<tr v-if="messages.length === 0">
<td colspan="10" class="empty-state">暂无消息</td>
</tr>
</tbody>
</table>
</div>
<div class="admin-llm-pagination">
<button class="admin-button admin-button-small" @click="goPrev" :disabled="!hasPrev">上一页</button>
<span class="pagination-info"> {{ Math.floor(offset / limit) + 1 }} </span>
<button class="admin-button admin-button-small" @click="goNext" :disabled="!hasMore">下一页</button>
</div>
</div>
</template>
<style scoped>
.admin-llm {
padding: 1.5rem;
max-width: 100%;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
min-height: 100vh;
}
.admin-llm h2 {
margin: 0 0 2rem;
font-size: 1.75rem;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.02em;
}
.admin-llm-section {
background: white;
padding: 1.75rem;
border-radius: 16px;
margin-bottom: 2rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 4px 6px rgba(0, 0, 0, 0.03);
border: 1px solid #e2e8f0;
}
.admin-llm-section h3 {
margin: 0 0 0.75rem;
font-size: 1.25rem;
font-weight: 600;
color: #334155;
}
.section-desc {
margin: 0 0 1.5rem;
color: #64748b;
font-size: 0.95rem;
line-height: 1.5;
}
.bot-settings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.25rem;
}
.bot-settings-card {
background: linear-gradient(135deg, #fafbfc 0%, #f8fafc 100%);
padding: 1.25rem;
border-radius: 12px;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.bot-settings-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
opacity: 0;
transition: opacity 0.2s ease;
}
.bot-settings-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
border-color: #cbd5e1;
}
.bot-settings-card:hover::before {
opacity: 1;
}
.bot-header {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e2e8f0;
}
.bot-header strong {
font-size: 1.05rem;
color: #1e293b;
font-weight: 600;
}
.bot-node-id {
font-size: 0.8rem;
color: #64748b;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
background: #f1f5f9;
padding: 0.2rem 0.5rem;
border-radius: 4px;
display: inline-block;
width: fit-content;
}
.bot-settings {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.setting-item {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
font-size: 0.9rem;
color: #475569;
padding: 0.5rem;
border-radius: 8px;
transition: background-color 0.15s ease;
}
.setting-item:hover {
background: #f1f5f9;
}
.setting-item input[type='checkbox'] {
cursor: pointer;
width: 18px;
height: 18px;
accent-color: #3b82f6;
}
.saving-indicator {
margin-top: 0.75rem;
font-size: 0.8rem;
color: #3b82f6;
display: flex;
align-items: center;
gap: 0.5rem;
}
.saving-indicator::before {
content: '';
width: 12px;
height: 12px;
border: 2px solid #3b82f6;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.admin-llm-toolbar {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
padding: 1.25rem;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
}
.admin-llm-filter {
display: flex;
align-items: center;
gap: 0.75rem;
}
.admin-llm-filter label {
font-size: 0.9rem;
color: #64748b;
font-weight: 500;
}
.admin-llm-filter select {
padding: 0.6rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 8px;
min-width: 220px;
font-size: 0.9rem;
color: #334155;
background: white;
cursor: pointer;
transition: all 0.15s ease;
}
.admin-llm-filter select:hover {
border-color: #94a3b8;
}
.admin-llm-filter select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.admin-llm-filter input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: #3b82f6;
cursor: pointer;
}
.admin-llm-stats {
padding: 1rem 1.25rem;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
border-radius: 10px;
margin-bottom: 1.25rem;
font-size: 0.9rem;
color: #1e40af;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid #bfdbfe;
}
.admin-loading {
padding: 3rem;
text-align: center;
color: #64748b;
background: white;
border-radius: 12px;
font-size: 1rem;
}
.type-badge {
display: inline-block;
padding: 0.35rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.type-badge.channel {
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
color: #4338ca;
border: 1px solid #a5b4fc;
}
.type-badge.direct {
background: linear-gradient(135deg, #fce7f3 0%, #fbcfe8 100%);
color: #be185d;
border: 1px solid #f9a8d4;
}
.channel-cell {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
font-size: 0.8rem;
color: #64748b;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: #f8fafc;
padding: 0.25rem 0.5rem;
border-radius: 4px;
border: 1px solid #e2e8f0;
}
.bot-name-cell {
min-width: 160px;
}
.bot-info {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.bot-long-name {
font-weight: 600;
color: #1e293b;
}
.node-info {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.node-name {
font-weight: 600;
color: #1e293b;
}
.node-id {
font-size: 0.8rem;
color: #64748b;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
background: #f1f5f9;
padding: 0.2rem 0.5rem;
border-radius: 4px;
display: inline-block;
width: fit-content;
}
.admin-llm-table-wrapper {
overflow-x: auto;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
}
.admin-llm-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
min-width: 1000px;
}
.admin-llm-table th,
.admin-llm-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
.admin-llm-table th {
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
font-weight: 600;
color: #475569;
position: sticky;
top: 0;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.03em;
border-bottom: 2px solid #e2e8f0;
}
.admin-llm-table tbody tr {
transition: background-color 0.15s ease;
}
.admin-llm-table tbody tr:hover {
background: #f8fafc;
}
.status-badge {
display: inline-block;
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
border: 1px solid transparent;
}
.muted-tag {
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 6px;
font-size: 0.75rem;
color: #94a3b8;
background: #f1f5f9;
}
.status-badge[style*='#fff3cd'] {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%) !important;
color: #92400e;
border-color: #fcd34d;
}
.status-badge[style*='#cfe2ff'] {
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%) !important;
color: #1e40af;
border-color: #93c5fd;
}
.status-badge[style*='#d1e7dd'] {
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%) !important;
color: #166534;
border-color: #86efac;
}
.status-badge[style*='#f8d7da'] {
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%) !important;
color: #991b1b;
border-color: #fca5a5;
}
.message-text {
max-width: 450px;
word-break: break-word;
line-height: 1.5;
color: #334155;
font-size: 0.875rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem !important;
color: #94a3b8;
font-size: 1rem;
}
.empty-state::before {
content: '📭';
display: block;
font-size: 3rem;
margin-bottom: 1rem;
}
.admin-llm-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
padding: 1.25rem;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
}
.pagination-info {
color: #64748b;
font-size: 0.9rem;
font-weight: 500;
padding: 0.5rem 1rem;
background: #f8fafc;
border-radius: 8px;
min-width: 80px;
text-align: center;
}
.error {
color: #991b1b;
padding: 1rem 1.25rem;
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
border-radius: 10px;
margin-bottom: 1.25rem;
border: 1px solid #fca5a5;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.error::before {
content: '⚠️';
}
.admin-button {
padding: 0.6rem 1.25rem;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
}
.admin-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.admin-button:active:not(:disabled) {
transform: translateY(0);
}
.admin-button-small {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
}
.admin-button-danger {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);
}
.admin-button-danger:hover:not(:disabled) {
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
</style>