mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-28 00:14:21 +00:00
feat(monitoring): 关联反馈记录与消息ID,新增反馈导出 (#2120)
* feat(monitoring): link feedback to LangBot message ID and add feedback export - Add pipeline→adapter notification hook so monitoring message ID is passed back to WecomBotAdapter after creation - Store stream_id→monitoring_message_id mapping with 10-min TTL cleanup - Replace feedback record stream_id with LangBot monitoring message ID so feedback can be linked to actual message records - Rename streamId label to "Related Query ID" in all 7 i18n locales - Remove non-functional message ID jump button from FeedbackList - Add feedback export option to ExportDropdown (backend already implemented) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(monitoring): add combined refresh handler for monitoring and feedback data * fix(wecombot): improve stream ID mapping and error logging in WecomBotAdapter * feat(lark): add monitoring message ID mapping for feedback correlation * feat(lark): rename monitoring message ID mappings for clarity and consistency feat(feedback): add button to view conversation for feedback items * feat(bot-session-monitor): add feedback handling for bot messages with visual indicators * feat(bot-session-monitor): enhance feedback display with hover content for like/dislike indicators * fix(dingtalk): use voice recognition text instead of raw audio binary When DingTalk sends a voice message to the bot, the callback JSON contains a 'recognition' field with the speech-to-text result (powered by Qwen). Previously, LangBot only extracted the 'downloadCode' to download the raw audio binary and passed it as 'file_base64' to LLM APIs, which caused 400 errors since most models don't support this content type. This patch: - Extracts the 'recognition' field from DingTalk audio message content - Uses it as plain text input to the LLM instead of raw audio - Falls back to audio binary only when no recognition text is available - Fixes duplicate text issue for audio messages with recognition Fixes voice messages returning 'Request failed' on all LLM models. * fix: add filereader for dingtalk,lark (#2122) * fix: add filereader for dingtalk * feat: add lark * feat: update uv.lock * chore: update version to 4.9.6 in pyproject.toml, __init__.py, and uv.lock * fix: update langbot-plugin version to 0.3.8 * fix: update langbot-plugin version to 0.3.8 * fix(wecombot): extend StreamSession TTL for feedback sessions to prevent context data loss StreamSessionManager.cleanup() removes sessions after 60s TTL, but feedback events (like → cancel → dislike) can arrive later. When the session expires before the dislike event, all context fields (session_id, user_id, message_id, stream_id) are lost because get_session_by_feedback_id() returns None. Fix: Sessions with registered feedback_ids now use a 10-minute TTL, aligned with the adapter's _stream_to_monitoring_msg TTL in wecombot.py. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: 6mvp6 <13727783693@163.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: fdc310 <2213070223@qq.com> Co-authored-by: haiyangbg <zhouhaiyangaa@gmail.com> Co-authored-by: Guanchao Wang <wangcham233@gmail.com> Co-authored-by: Rock Chin <1010553892@qq.com>
This commit is contained in:
@@ -71,6 +71,11 @@ class StreamSession:
|
|||||||
class StreamSessionManager:
|
class StreamSessionManager:
|
||||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||||
|
|
||||||
|
# Sessions with registered feedback_ids use a longer TTL to survive the
|
||||||
|
# full like → cancel → dislike feedback flow. Must align with the adapter's
|
||||||
|
# _stream_to_monitoring_msg TTL (wecombot.py).
|
||||||
|
_FEEDBACK_SESSION_TTL = 600 # 10 minutes
|
||||||
|
|
||||||
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
@@ -214,11 +219,17 @@ class StreamSessionManager:
|
|||||||
session.last_access = time.time()
|
session.last_access = time.time()
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
"""定期清理过期会话,防止队列与映射无上限累积。"""
|
"""定期清理过期会话,防止队列与映射无上限累积。
|
||||||
|
|
||||||
|
已注册 feedback_id 的会话使用更长的 TTL,确保用户在点赞/取消/点踩流程中
|
||||||
|
不会因为 session 被提前清除而丢失上下文信息。
|
||||||
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
expired: list[str] = []
|
expired: list[str] = []
|
||||||
for stream_id, session in self._sessions.items():
|
for stream_id, session in self._sessions.items():
|
||||||
if now - session.last_access > self.ttl:
|
# Sessions with registered feedback_ids use a longer TTL
|
||||||
|
effective_ttl = self._FEEDBACK_SESSION_TTL if session.feedback_id else self.ttl
|
||||||
|
if now - session.last_access > effective_ttl:
|
||||||
expired.append(stream_id)
|
expired.append(stream_id)
|
||||||
|
|
||||||
for stream_id in expired:
|
for stream_id in expired:
|
||||||
|
|||||||
@@ -297,6 +297,9 @@ class RuntimePipeline:
|
|||||||
)
|
)
|
||||||
# Store message_id in query variables for LLM call monitoring
|
# Store message_id in query variables for LLM call monitoring
|
||||||
query.variables['_monitoring_message_id'] = message_id
|
query.variables['_monitoring_message_id'] = message_id
|
||||||
|
# Notify adapter so it can map platform-specific IDs to monitoring message ID
|
||||||
|
if hasattr(query.adapter, 'on_monitoring_message_created'):
|
||||||
|
await query.adapter.on_monitoring_message_created(query, message_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'Failed to record query start: {e}')
|
self.ap.logger.error(f'Failed to record query start: {e}')
|
||||||
|
|
||||||
|
|||||||
@@ -787,6 +787,13 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
||||||
|
|
||||||
|
# Monitoring message ID mapping for feedback correlation
|
||||||
|
# Temp: user Lark message ID → monitoring_message_id (populated by on_monitoring_message_created, consumed by create_message_card)
|
||||||
|
pending_monitoring_msg: dict[str, str]
|
||||||
|
# Final: reply Lark message ID → (monitoring_message_id, timestamp) (used by feedback callbacks)
|
||||||
|
reply_to_monitoring_msg: dict[str, tuple[str, float]]
|
||||||
|
_MONITORING_MAPPING_TTL = 600 # 10 minutes
|
||||||
|
|
||||||
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
||||||
bot_uuid: str = None # 机器人UUID
|
bot_uuid: str = None # 机器人UUID
|
||||||
app_ticket: str = None # 商店应用用到
|
app_ticket: str = None # 商店应用用到
|
||||||
@@ -833,6 +840,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
else:
|
else:
|
||||||
session_id = None
|
session_id = None
|
||||||
|
|
||||||
|
# Resolve monitoring message ID from reply message mapping
|
||||||
|
monitoring_msg_id = None
|
||||||
|
if open_message_id and open_message_id in self.reply_to_monitoring_msg:
|
||||||
|
monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0]
|
||||||
|
|
||||||
feedback_event = platform_events.FeedbackEvent(
|
feedback_event = platform_events.FeedbackEvent(
|
||||||
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
|
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
|
||||||
feedback_type=feedback_type,
|
feedback_type=feedback_type,
|
||||||
@@ -840,6 +852,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
message_id=open_message_id,
|
message_id=open_message_id,
|
||||||
|
stream_id=monitoring_msg_id,
|
||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -878,6 +891,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
logger=logger,
|
logger=logger,
|
||||||
lark_tenant_key=config.get('lark_tenant_key', ''),
|
lark_tenant_key=config.get('lark_tenant_key', ''),
|
||||||
card_id_dict={},
|
card_id_dict={},
|
||||||
|
pending_monitoring_msg={},
|
||||||
|
reply_to_monitoring_msg={},
|
||||||
seq=1,
|
seq=1,
|
||||||
listeners={},
|
listeners={},
|
||||||
quart_app=quart_app,
|
quart_app=quart_app,
|
||||||
@@ -1018,6 +1033,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
is_stream = True
|
is_stream = True
|
||||||
return is_stream
|
return is_stream
|
||||||
|
|
||||||
|
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
|
||||||
|
"""Called by pipeline after monitoring message is created, to map user message ID to monitoring message ID."""
|
||||||
|
try:
|
||||||
|
user_msg_id = query.message_event.message_chain.message_id
|
||||||
|
if user_msg_id:
|
||||||
|
self.pending_monitoring_msg[user_msg_id] = monitoring_message_id
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.debug(f'Failed to map message to monitoring message: {e}')
|
||||||
|
|
||||||
|
def _cleanup_monitoring_mapping(self):
|
||||||
|
"""Remove entries older than TTL from the reply-to-monitoring mapping."""
|
||||||
|
now = time.time()
|
||||||
|
expired = [k for k, (_, ts) in self.reply_to_monitoring_msg.items() if now - ts > self._MONITORING_MAPPING_TTL]
|
||||||
|
for k in expired:
|
||||||
|
del self.reply_to_monitoring_msg[k]
|
||||||
|
|
||||||
async def create_card_id(self, message_id):
|
async def create_card_id(self, message_id):
|
||||||
try:
|
try:
|
||||||
# self.logger.debug('飞书支持stream输出,创建卡片......')
|
# self.logger.debug('飞书支持stream输出,创建卡片......')
|
||||||
@@ -1257,6 +1288,18 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
raise Exception(
|
raise Exception(
|
||||||
f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Transfer monitoring message mapping: user msg ID → reply msg ID
|
||||||
|
try:
|
||||||
|
user_msg_id = event.message_chain.message_id
|
||||||
|
reply_msg_id = getattr(response.data, 'message_id', None)
|
||||||
|
monitoring_msg_id = self.pending_monitoring_msg.pop(user_msg_id, None)
|
||||||
|
if reply_msg_id and monitoring_msg_id:
|
||||||
|
self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time())
|
||||||
|
self._cleanup_monitoring_mapping()
|
||||||
|
except Exception as e:
|
||||||
|
asyncio.create_task(self.logger.debug(f'Failed to transfer monitoring mapping in create_message_card: {e}'))
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
@@ -1567,6 +1610,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
else:
|
else:
|
||||||
session_id = None
|
session_id = None
|
||||||
|
|
||||||
|
# Resolve monitoring message ID from reply message mapping
|
||||||
|
monitoring_msg_id = None
|
||||||
|
if open_message_id and open_message_id in self.reply_to_monitoring_msg:
|
||||||
|
monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0]
|
||||||
|
|
||||||
feedback_event = platform_events.FeedbackEvent(
|
feedback_event = platform_events.FeedbackEvent(
|
||||||
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
|
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
|
||||||
feedback_type=feedback_type,
|
feedback_type=feedback_type,
|
||||||
@@ -1574,6 +1622,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
message_id=open_message_id,
|
message_id=open_message_id,
|
||||||
|
stream_id=monitoring_msg_id,
|
||||||
source_platform_object=data,
|
source_platform_object=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import typing
|
import typing
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -293,6 +294,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
_ws_mode: bool = False
|
_ws_mode: bool = False
|
||||||
bot_name: str = ''
|
bot_name: str = ''
|
||||||
listeners: dict = {}
|
listeners: dict = {}
|
||||||
|
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
|
||||||
|
_STREAM_MAPPING_TTL = 600 # 10 minutes
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
enable_webhook = config.get('enable-webhook', False)
|
enable_webhook = config.get('enable-webhook', False)
|
||||||
@@ -329,8 +332,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
bot_account_id=bot_account_id,
|
bot_account_id=bot_account_id,
|
||||||
bot_name=bot_name,
|
bot_name=bot_name,
|
||||||
event_converter=event_converter,
|
event_converter=event_converter,
|
||||||
|
listeners={},
|
||||||
|
_stream_to_monitoring_msg={},
|
||||||
)
|
)
|
||||||
self.listeners = {}
|
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
@@ -422,6 +426,23 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||||
self.bot_uuid = bot_uuid
|
self.bot_uuid = bot_uuid
|
||||||
|
|
||||||
|
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
|
||||||
|
"""Called by pipeline after monitoring message is created, to map stream_id to monitoring message ID."""
|
||||||
|
try:
|
||||||
|
stream_id = query.message_event.source_platform_object.stream_id
|
||||||
|
if stream_id:
|
||||||
|
self._stream_to_monitoring_msg[stream_id] = (monitoring_message_id, time.time())
|
||||||
|
self._cleanup_stream_mapping()
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.debug(f'Failed to map stream_id to monitoring message: {e}')
|
||||||
|
|
||||||
|
def _cleanup_stream_mapping(self):
|
||||||
|
"""Remove entries older than TTL from the stream_id to monitoring message mapping."""
|
||||||
|
now = time.time()
|
||||||
|
expired = [k for k, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL]
|
||||||
|
for k in expired:
|
||||||
|
del self._stream_to_monitoring_msg[k]
|
||||||
|
|
||||||
async def _on_feedback(self, **kwargs):
|
async def _on_feedback(self, **kwargs):
|
||||||
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
|
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
|
||||||
try:
|
try:
|
||||||
@@ -447,6 +468,11 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
message_id = session.msg_id
|
message_id = session.msg_id
|
||||||
stream_id = session.stream_id
|
stream_id = session.stream_id
|
||||||
|
|
||||||
|
# Resolve stream_id to LangBot monitoring message ID if available
|
||||||
|
monitoring_msg_id = None
|
||||||
|
if stream_id and stream_id in self._stream_to_monitoring_msg:
|
||||||
|
monitoring_msg_id = self._stream_to_monitoring_msg[stream_id][0]
|
||||||
|
|
||||||
await self.logger.info(
|
await self.logger.info(
|
||||||
f'Feedback event: feedback_id={feedback_id}, type={feedback_type}, '
|
f'Feedback event: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
f'session_id={session_id}, user_id={user_id}, message_id={message_id}'
|
f'session_id={session_id}, user_id={user_id}, message_id={message_id}'
|
||||||
@@ -460,7 +486,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
stream_id=stream_id,
|
stream_id=monitoring_msg_id or stream_id,
|
||||||
source_platform_object=session,
|
source_platform_object=session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Ban, Bot, Copy, Check, Workflow } from 'lucide-react';
|
import {
|
||||||
|
Ban,
|
||||||
|
Bot,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Workflow,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
MessageChainComponent,
|
MessageChainComponent,
|
||||||
Plain,
|
Plain,
|
||||||
@@ -54,6 +62,12 @@ interface SessionMessage {
|
|||||||
role?: string | null;
|
role?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionFeedback {
|
||||||
|
feedback_type: number; // 1=like, 2=dislike
|
||||||
|
feedback_content?: string | null;
|
||||||
|
stream_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BotSessionMonitorHandle {
|
export interface BotSessionMonitorHandle {
|
||||||
refreshSessions: () => Promise<void>;
|
refreshSessions: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -75,6 +89,9 @@ const BotSessionMonitor = forwardRef<
|
|||||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||||
|
const [feedbackMap, setFeedbackMap] = useState<
|
||||||
|
Record<string, SessionFeedback>
|
||||||
|
>({});
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const parseSessionType = (sessionId: string): string | null => {
|
const parseSessionType = (sessionId: string): string | null => {
|
||||||
@@ -117,21 +134,50 @@ const BotSessionMonitor = forwardRef<
|
|||||||
[loadSessions],
|
[loadSessions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadMessages = useCallback(async (sessionId: string) => {
|
const loadMessages = useCallback(
|
||||||
setLoadingMessages(true);
|
async (sessionId: string) => {
|
||||||
try {
|
setLoadingMessages(true);
|
||||||
const response = await httpClient.getSessionMessages(sessionId);
|
try {
|
||||||
const sorted = (response.messages ?? []).sort(
|
const messagesRes = await httpClient.getSessionMessages(sessionId);
|
||||||
(a, b) =>
|
const sorted = (messagesRes.messages ?? []).sort(
|
||||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
(a, b) =>
|
||||||
);
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||||
setMessages(sorted);
|
);
|
||||||
} catch (error) {
|
setMessages(sorted);
|
||||||
console.error('Failed to load session messages:', error);
|
|
||||||
} finally {
|
// Collect user message IDs for feedback matching
|
||||||
setLoadingMessages(false);
|
const userMsgIds = new Set(
|
||||||
}
|
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
|
||||||
}, []);
|
);
|
||||||
|
|
||||||
|
if (userMsgIds.size > 0) {
|
||||||
|
// Fetch feedback for this bot, then match by stream_id locally
|
||||||
|
const feedbackRes = await httpClient.get<{
|
||||||
|
feedback: SessionFeedback[];
|
||||||
|
}>(
|
||||||
|
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const map: Record<string, SessionFeedback> = {};
|
||||||
|
if (feedbackRes?.feedback) {
|
||||||
|
for (const fb of feedbackRes.feedback) {
|
||||||
|
if (fb.stream_id && userMsgIds.has(fb.stream_id)) {
|
||||||
|
map[fb.stream_id] = fb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFeedbackMap(map);
|
||||||
|
} else {
|
||||||
|
setFeedbackMap({});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load session messages:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMessages(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[botId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSessions();
|
loadSessions();
|
||||||
@@ -479,11 +525,21 @@ const BotSessionMonitor = forwardRef<
|
|||||||
{t('bots.sessionMonitor.noMessages')}
|
{t('bots.sessionMonitor.noMessages')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
messages.map((msg) => {
|
messages.map((msg, msgIndex) => {
|
||||||
const isUser = isUserMessage(msg);
|
const isUser = isUserMessage(msg);
|
||||||
const isDiscarded =
|
const isDiscarded =
|
||||||
msg.status === 'discarded' ||
|
msg.status === 'discarded' ||
|
||||||
msg.pipeline_id === PIPELINE_DISCARD;
|
msg.pipeline_id === PIPELINE_DISCARD;
|
||||||
|
// For bot replies, find feedback linked to the preceding user message
|
||||||
|
let msgFeedback: SessionFeedback | undefined;
|
||||||
|
if (!isUser) {
|
||||||
|
for (let i = msgIndex - 1; i >= 0; i--) {
|
||||||
|
if (isUserMessage(messages[i])) {
|
||||||
|
msgFeedback = feedbackMap[messages[i].id];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
@@ -543,6 +599,30 @@ const BotSessionMonitor = forwardRef<
|
|||||||
{msg.runner_name}
|
{msg.runner_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Feedback indicator — same line, pushed right */}
|
||||||
|
{!isUser &&
|
||||||
|
msgFeedback &&
|
||||||
|
(msgFeedback.feedback_type === 1 ? (
|
||||||
|
<span className="inline-flex items-center gap-1 ml-auto text-green-600 dark:text-green-400 cursor-default relative group">
|
||||||
|
<ThumbsUp className="w-3 h-3 flex-shrink-0" />
|
||||||
|
{t('monitoring.feedback.like')}
|
||||||
|
{msgFeedback.feedback_content && (
|
||||||
|
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
|
||||||
|
{msgFeedback.feedback_content}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 ml-auto text-red-500 dark:text-red-400 cursor-default relative group">
|
||||||
|
<ThumbsDown className="w-3 h-3 flex-shrink-0" />
|
||||||
|
{t('monitoring.feedback.dislike')}
|
||||||
|
{msgFeedback.feedback_content && (
|
||||||
|
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
|
||||||
|
{msgFeedback.feedback_content}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Users,
|
Users,
|
||||||
Layers,
|
Layers,
|
||||||
|
ThumbsUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +26,8 @@ export type ExportType =
|
|||||||
| 'llm-calls'
|
| 'llm-calls'
|
||||||
| 'embedding-calls'
|
| 'embedding-calls'
|
||||||
| 'errors'
|
| 'errors'
|
||||||
| 'sessions';
|
| 'sessions'
|
||||||
|
| 'feedback';
|
||||||
|
|
||||||
interface ExportDropdownProps {
|
interface ExportDropdownProps {
|
||||||
filterState: FilterState;
|
filterState: FilterState;
|
||||||
@@ -162,6 +164,11 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
|
|||||||
label: t('monitoring.export.sessions'),
|
label: t('monitoring.export.sessions'),
|
||||||
icon: <Users className="w-4 h-4 mr-2" />,
|
icon: <Users className="w-4 h-4 mr-2" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'feedback',
|
||||||
|
label: t('monitoring.export.feedback'),
|
||||||
|
icon: <ThumbsUp className="w-4 h-4 mr-2" />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -127,6 +127,20 @@ export function FeedbackList({
|
|||||||
{item.platform}
|
{item.platform}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{item.streamId && onViewMessage && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-1.5 text-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewMessage(item.streamId!);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3 mr-1" />
|
||||||
|
{t('monitoring.messageList.viewConversation')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.feedbackContent && (
|
{item.feedbackContent && (
|
||||||
@@ -221,21 +235,8 @@ export function FeedbackList({
|
|||||||
<div className="text-gray-500 dark:text-gray-400">
|
<div className="text-gray-500 dark:text-gray-400">
|
||||||
{t('monitoring.feedback.messageId')}
|
{t('monitoring.feedback.messageId')}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-gray-900 dark:text-white truncate flex items-center gap-1">
|
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||||
<span className="truncate">{item.messageId}</span>
|
{item.messageId}
|
||||||
{onViewMessage && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-5 px-1.5 text-xs shrink-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onViewMessage(item.messageId!);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Suspense, useState, useMemo } from 'react';
|
import React, { Suspense, useState, useMemo, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -69,6 +69,9 @@ function MonitoringPageContent() {
|
|||||||
useMonitoringFilters();
|
useMonitoringFilters();
|
||||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||||
|
|
||||||
|
// Counter to force feedbackTimeRange recomputation on manual refresh
|
||||||
|
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
||||||
|
|
||||||
// Get time range for feedback data
|
// Get time range for feedback data
|
||||||
const feedbackTimeRange = useMemo(() => {
|
const feedbackTimeRange = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -106,7 +109,8 @@ function MonitoringPageContent() {
|
|||||||
startTime: startTime?.toISOString(),
|
startTime: startTime?.toISOString(),
|
||||||
endTime: endTime.toISOString(),
|
endTime: endTime.toISOString(),
|
||||||
};
|
};
|
||||||
}, [filterState.timeRange, filterState.customDateRange]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filterState.timeRange, filterState.customDateRange, feedbackRefreshKey]);
|
||||||
|
|
||||||
// Feedback data hook
|
// Feedback data hook
|
||||||
const {
|
const {
|
||||||
@@ -127,6 +131,12 @@ function MonitoringPageContent() {
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Combined refresh handler for both monitoring and feedback data
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
refetch();
|
||||||
|
setFeedbackRefreshKey((k) => k + 1);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -265,7 +275,7 @@ function MonitoringPageContent() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={refetch}
|
onClick={handleRefresh}
|
||||||
className="shadow-sm flex-shrink-0"
|
className="shadow-sm flex-shrink-0"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1163,7 +1163,7 @@ const enUS = {
|
|||||||
contextInfo: 'Context Info',
|
contextInfo: 'Context Info',
|
||||||
userId: 'User ID',
|
userId: 'User ID',
|
||||||
messageId: 'Message ID',
|
messageId: 'Message ID',
|
||||||
streamId: 'Stream ID',
|
streamId: 'Related Query ID',
|
||||||
inaccurateReasons: 'Inaccurate Reasons',
|
inaccurateReasons: 'Inaccurate Reasons',
|
||||||
platform: 'Platform',
|
platform: 'Platform',
|
||||||
exportFeedback: 'Export Feedback',
|
exportFeedback: 'Export Feedback',
|
||||||
@@ -1194,6 +1194,7 @@ const enUS = {
|
|||||||
embeddingCalls: 'Embedding Calls',
|
embeddingCalls: 'Embedding Calls',
|
||||||
errors: 'Error Logs',
|
errors: 'Error Logs',
|
||||||
sessions: 'Sessions',
|
sessions: 'Sessions',
|
||||||
|
feedback: 'User Feedback',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
limitation: {
|
limitation: {
|
||||||
|
|||||||
@@ -1194,7 +1194,7 @@ const esES = {
|
|||||||
contextInfo: 'Información de contexto',
|
contextInfo: 'Información de contexto',
|
||||||
userId: 'ID de usuario',
|
userId: 'ID de usuario',
|
||||||
messageId: 'ID de mensaje',
|
messageId: 'ID de mensaje',
|
||||||
streamId: 'ID de flujo',
|
streamId: 'ID de consulta relacionada',
|
||||||
inaccurateReasons: 'Razones de inexactitud',
|
inaccurateReasons: 'Razones de inexactitud',
|
||||||
platform: 'Plataforma',
|
platform: 'Plataforma',
|
||||||
exportFeedback: 'Exportar comentarios',
|
exportFeedback: 'Exportar comentarios',
|
||||||
@@ -1225,6 +1225,7 @@ const esES = {
|
|||||||
embeddingCalls: 'Llamadas Embedding',
|
embeddingCalls: 'Llamadas Embedding',
|
||||||
errors: 'Registros de errores',
|
errors: 'Registros de errores',
|
||||||
sessions: 'Sesiones',
|
sessions: 'Sesiones',
|
||||||
|
feedback: 'Comentarios de usuarios',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
limitation: {
|
limitation: {
|
||||||
|
|||||||
@@ -1166,7 +1166,7 @@
|
|||||||
contextInfo: 'コンテキスト情報',
|
contextInfo: 'コンテキスト情報',
|
||||||
userId: 'ユーザーID',
|
userId: 'ユーザーID',
|
||||||
messageId: 'メッセージID',
|
messageId: 'メッセージID',
|
||||||
streamId: 'ストリームID',
|
streamId: '関連質問ID',
|
||||||
inaccurateReasons: '不正確な理由',
|
inaccurateReasons: '不正確な理由',
|
||||||
platform: 'プラットフォーム',
|
platform: 'プラットフォーム',
|
||||||
exportFeedback: 'フィードバックをエクスポート',
|
exportFeedback: 'フィードバックをエクスポート',
|
||||||
@@ -1197,6 +1197,7 @@
|
|||||||
embeddingCalls: 'Embedding コール',
|
embeddingCalls: 'Embedding コール',
|
||||||
errors: 'エラーログ',
|
errors: 'エラーログ',
|
||||||
sessions: 'セッション',
|
sessions: 'セッション',
|
||||||
|
feedback: 'ユーザーフィードバック',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
limitation: {
|
limitation: {
|
||||||
|
|||||||
@@ -1142,7 +1142,7 @@ const thTH = {
|
|||||||
contextInfo: 'ข้อมูลบริบท',
|
contextInfo: 'ข้อมูลบริบท',
|
||||||
userId: 'ID ผู้ใช้',
|
userId: 'ID ผู้ใช้',
|
||||||
messageId: 'ID ข้อความ',
|
messageId: 'ID ข้อความ',
|
||||||
streamId: 'ID สตรีม',
|
streamId: 'ID คำถามที่เกี่ยวข้อง',
|
||||||
inaccurateReasons: 'เหตุผลที่ไม่ถูกต้อง',
|
inaccurateReasons: 'เหตุผลที่ไม่ถูกต้อง',
|
||||||
platform: 'แพลตฟอร์ม',
|
platform: 'แพลตฟอร์ม',
|
||||||
exportFeedback: 'ส่งออกความคิดเห็น',
|
exportFeedback: 'ส่งออกความคิดเห็น',
|
||||||
@@ -1173,6 +1173,7 @@ const thTH = {
|
|||||||
embeddingCalls: 'การเรียก Embedding',
|
embeddingCalls: 'การเรียก Embedding',
|
||||||
errors: 'บันทึกข้อผิดพลาด',
|
errors: 'บันทึกข้อผิดพลาด',
|
||||||
sessions: 'เซสชัน',
|
sessions: 'เซสชัน',
|
||||||
|
feedback: 'ความคิดเห็นผู้ใช้',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
limitation: {
|
limitation: {
|
||||||
|
|||||||
@@ -1163,7 +1163,7 @@ const viVN = {
|
|||||||
contextInfo: 'Thông tin ngữ cảnh',
|
contextInfo: 'Thông tin ngữ cảnh',
|
||||||
userId: 'ID người dùng',
|
userId: 'ID người dùng',
|
||||||
messageId: 'ID tin nhắn',
|
messageId: 'ID tin nhắn',
|
||||||
streamId: 'ID luồng',
|
streamId: 'ID câu hỏi liên quan',
|
||||||
inaccurateReasons: 'Lý do không chính xác',
|
inaccurateReasons: 'Lý do không chính xác',
|
||||||
platform: 'Nền tảng',
|
platform: 'Nền tảng',
|
||||||
exportFeedback: 'Xuất phản hồi',
|
exportFeedback: 'Xuất phản hồi',
|
||||||
@@ -1194,6 +1194,7 @@ const viVN = {
|
|||||||
embeddingCalls: 'Cuộc gọi Embedding',
|
embeddingCalls: 'Cuộc gọi Embedding',
|
||||||
errors: 'Nhật ký lỗi',
|
errors: 'Nhật ký lỗi',
|
||||||
sessions: 'Phiên',
|
sessions: 'Phiên',
|
||||||
|
feedback: 'Phản hồi người dùng',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
limitation: {
|
limitation: {
|
||||||
|
|||||||
@@ -1110,7 +1110,7 @@ const zhHans = {
|
|||||||
contextInfo: '上下文信息',
|
contextInfo: '上下文信息',
|
||||||
userId: '用户ID',
|
userId: '用户ID',
|
||||||
messageId: '消息ID',
|
messageId: '消息ID',
|
||||||
streamId: '流ID',
|
streamId: '关联提问ID',
|
||||||
inaccurateReasons: '不准确原因',
|
inaccurateReasons: '不准确原因',
|
||||||
platform: '平台',
|
platform: '平台',
|
||||||
exportFeedback: '导出反馈',
|
exportFeedback: '导出反馈',
|
||||||
@@ -1141,6 +1141,7 @@ const zhHans = {
|
|||||||
embeddingCalls: 'Embedding 调用',
|
embeddingCalls: 'Embedding 调用',
|
||||||
errors: '错误日志',
|
errors: '错误日志',
|
||||||
sessions: '会话记录',
|
sessions: '会话记录',
|
||||||
|
feedback: '用户反馈',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
limitation: {
|
limitation: {
|
||||||
|
|||||||
@@ -1109,7 +1109,7 @@ const zhHant = {
|
|||||||
contextInfo: '上下文資訊',
|
contextInfo: '上下文資訊',
|
||||||
userId: '使用者ID',
|
userId: '使用者ID',
|
||||||
messageId: '訊息ID',
|
messageId: '訊息ID',
|
||||||
streamId: '串流ID',
|
streamId: '關聯提問ID',
|
||||||
inaccurateReasons: '不準確原因',
|
inaccurateReasons: '不準確原因',
|
||||||
platform: '平台',
|
platform: '平台',
|
||||||
exportFeedback: '匯出反饋',
|
exportFeedback: '匯出反饋',
|
||||||
@@ -1140,6 +1140,7 @@ const zhHant = {
|
|||||||
embeddingCalls: 'Embedding 呼叫',
|
embeddingCalls: 'Embedding 呼叫',
|
||||||
errors: '錯誤日誌',
|
errors: '錯誤日誌',
|
||||||
sessions: '會話記錄',
|
sessions: '會話記錄',
|
||||||
|
feedback: '使用者回饋',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
limitation: {
|
limitation: {
|
||||||
|
|||||||
Reference in New Issue
Block a user